fix(selfop): remove business request count quotas

This commit is contained in:
2026-10-13 20:02:07 +08:00
parent f151e74f5c
commit c2bb769676
11 changed files with 80 additions and 46 deletions
@@ -24,6 +24,7 @@ public class SelfopAnalyticsProperties {
private LocalDate transferredBudgetDate; private LocalDate transferredBudgetDate;
private LocalDateTime transferredCooldownUntilUtc; private LocalDateTime transferredCooldownUntilUtc;
private String transferredPauseReason; private String transferredPauseReason;
/** VALIDATION-only daily ceiling; BUSINESS retains counts without a daily quota. */
private int dailyRequestBudget = 0; private int dailyRequestBudget = 0;
private int businessMinRequestIntervalMs = 5000; private int businessMinRequestIntervalMs = 5000;
private int maxRateLimitFailures = 5; private int maxRateLimitFailures = 5;
@@ -43,6 +44,7 @@ public class SelfopAnalyticsProperties {
private String sourceAccount = ""; private String sourceAccount = "";
private String sourceQueryTimezone; private String sourceQueryTimezone;
private String quantityUnit; private String quantityUnit;
/** Per-dispatch slice size, never a BUSINESS root/session HTTP-count ceiling. */
private int requestBudget = 100; private int requestBudget = 100;
private int timeBudgetSeconds = 120; private int timeBudgetSeconds = 120;
private int minRequestIntervalMs = 1100; private int minRequestIntervalMs = 1100;
@@ -295,7 +295,7 @@ public class CollectionQueryService {
JsonNode budget=boundedObject(task.businessRootBudgetJson()); JsonNode budget=boundedObject(task.businessRootBudgetJson());
Long maximum=number(budget,"maxHttp"),reserved=number(budget,"http"),confirmed=number(budget,"confirmedHttp"); Long maximum=number(budget,"maxHttp"),reserved=number(budget,"http"),confirmed=number(budget,"confirmedHttp");
Instant started=budgetInstant(budget,"startedAtUtc"),deadline=budgetInstant(budget,"deadlineAtUtc"); Instant started=budgetInstant(budget,"startedAtUtc"),deadline=budgetInstant(budget,"deadlineAtUtc");
if(budget==null||maximum==null||maximum<=0||maximum>Integer.MAX_VALUE||reserved==null||reserved>maximum if(budget==null||maximum==null||maximum<0||maximum>Integer.MAX_VALUE||reserved==null||reserved>Integer.MAX_VALUE
||confirmed==null||confirmed>reserved||started==null||deadline==null||!deadline.isAfter(started)) ||confirmed==null||confirmed>reserved||started==null||deadline==null||!deadline.isAfter(started))
return "BUSINESS_SESSION_BUDGET_MISSING"; return "BUSINESS_SESSION_BUDGET_MISSING";
JsonNode stopped=budget.get("stopReason"); JsonNode stopped=budget.get("stopReason");
@@ -332,7 +332,7 @@ public class CollectionQueryService {
var p=mapper.protection(account); var p=mapper.protection(account);
JsonNode budget=p==null?null:boundedObject(p.apiBudgetJson()); JsonNode budget=p==null?null:boundedObject(p.apiBudgetJson());
return new CollectionResponses.Protection(p==null?null:utc(p.apiCooldownUntilUtc()),p==null?null:p.apiPauseReason(), return new CollectionResponses.Protection(p==null?null:utc(p.apiCooldownUntilUtc()),p==null?null:p.apiPauseReason(),
config.getDailyRequestBudget(),number(budget,"reservedHttp"),number(budget,"confirmedHttp"), "BUSINESS".equals(config.getCollectionMode())?0:config.getDailyRequestBudget(),number(budget,"reservedHttp"),number(budget,"confirmedHttp"),
budget==null||!budget.path("budgetDate").isTextual()?null:budget.path("budgetDate").textValue()); budget==null||!budget.path("budgetDate").isTextual()?null:budget.path("budgetDate").textValue());
} }
private String executionBlock(String warehouse,CollectionResponses.Protection protection) { private String executionBlock(String warehouse,CollectionResponses.Protection protection) {
@@ -347,6 +347,7 @@ public class CollectionQueryService {
if(protection.accountCooldownUntil()!=null&&clock.instant().isBefore(protection.accountCooldownUntil()))return "ACCOUNT_COOLDOWN"; if(protection.accountCooldownUntil()!=null&&clock.instant().isBefore(protection.accountCooldownUntil()))return "ACCOUNT_COOLDOWN";
if(!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified() if(!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified()
||config.getExclusiveGrantExpiresAtUtc()==null||!clock.instant().isBefore(utc(config.getExclusiveGrantExpiresAtUtc())))return "EXCLUSIVE_GRANT_NOT_VALID"; ||config.getExclusiveGrantExpiresAtUtc()==null||!clock.instant().isBefore(utc(config.getExclusiveGrantExpiresAtUtc())))return "EXCLUSIVE_GRANT_NOT_VALID";
if("BUSINESS".equals(config.getCollectionMode()))return null;
if(config.getDailyRequestBudget()<=0)return "DAILY_BUDGET_NOT_CONFIGURED"; if(config.getDailyRequestBudget()<=0)return "DAILY_BUDGET_NOT_CONFIGURED";
if(protection.reservedHttpRequests()!=null&&clock.instant().atZone(BUSINESS_ZONE).toLocalDate().toString().equals(protection.budgetDate()) if(protection.reservedHttpRequests()!=null&&clock.instant().atZone(BUSINESS_ZONE).toLocalDate().toString().equals(protection.budgetDate())
&&protection.reservedHttpRequests()>=config.getDailyRequestBudget())return "DAILY_BUDGET_EXHAUSTED"; &&protection.reservedHttpRequests()>=config.getDailyRequestBudget())return "DAILY_BUDGET_EXHAUSTED";
@@ -3,24 +3,25 @@ package com.baiyue.lastleg.selfoperated.analytics.request;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport; import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** TEST manual BUSINESS session ceiling, frozen on its root and shared by descendants. */ /** BUSINESS session accounting and time ceiling, shared by the root and descendants. */
public final class SelfopBusinessBudget { public final class SelfopBusinessBudget {
public int maxHttp, http, confirmedHttp; /** Recorded field only; BUSINESS has no HTTP-count ceiling. New sessions write zero. */
public int maxHttp;
public int http, confirmedHttp;
public LocalDateTime startedAtUtc, deadlineAtUtc; public LocalDateTime startedAtUtc, deadlineAtUtc;
public String stopReason; public String stopReason;
public static String freeze(LocalDateTime now, int maxHttp, int seconds) { public static String freeze(LocalDateTime now, int seconds) {
if (now == null || maxHttp < 1 || seconds < 1) if (now == null || seconds < 1)
throw new IllegalArgumentException("BUSINESS_SESSION_BUDGET_INVALID"); throw new IllegalArgumentException("BUSINESS_SESSION_BUDGET_INVALID");
var budget = new SelfopBusinessBudget(); var budget = new SelfopBusinessBudget();
budget.maxHttp = maxHttp;
budget.startedAtUtc = now; budget.startedAtUtc = now;
budget.deadlineAtUtc = now.plusSeconds(seconds); budget.deadlineAtUtc = now.plusSeconds(seconds);
return SelfopSupport.json(budget); return SelfopSupport.json(budget);
} }
public void validate() { public void validate() {
if (maxHttp < 1 || http < 0 || http > maxHttp || confirmedHttp < 0 || confirmedHttp > http if (http < 0 || confirmedHttp < 0 || confirmedHttp > http
|| startedAtUtc == null || deadlineAtUtc == null || !deadlineAtUtc.isAfter(startedAtUtc) || startedAtUtc == null || deadlineAtUtc == null || !deadlineAtUtc.isAfter(startedAtUtc)
|| stopReason != null && !stopReason.matches("[A-Z0-9_]{1,64}")) || stopReason != null && !stopReason.matches("[A-Z0-9_]{1,64}"))
throw new EgressRejectedException("BUSINESS_SESSION_BUDGET_INVALID", false, null); throw new EgressRejectedException("BUSINESS_SESSION_BUDGET_INVALID", false, null);
@@ -30,7 +31,6 @@ public final class SelfopBusinessBudget {
validate(); validate();
if (stopReason != null) return stopReason; if (stopReason != null) return stopReason;
if (!now.isBefore(deadlineAtUtc)) return "BUSINESS_SESSION_DEADLINE"; if (!now.isBefore(deadlineAtUtc)) return "BUSINESS_SESSION_DEADLINE";
if (http >= maxHttp) return "BUSINESS_SESSION_HTTP_LIMIT";
return null; return null;
} }
} }
@@ -39,7 +39,7 @@ public class SelfopEgressService {
} }
int interval="VALIDATION".equals(context.executionPurpose())?Math.max(5000,budget.minIntervalMs):budget.minIntervalMs; int interval="VALIDATION".equals(context.executionPurpose())?Math.max(5000,budget.minIntervalMs):budget.minIntervalMs;
if(budget.lastRequestAtUtc!=null&&now.isBefore(budget.lastRequestAtUtc.plusNanos((long)interval*1000000)))throw new EgressRejectedException("ACCOUNT_INTERVAL_WAIT",false,budget.lastRequestAtUtc.plusNanos((long)interval*1000000)); if(budget.lastRequestAtUtc!=null&&now.isBefore(budget.lastRequestAtUtc.plusNanos((long)interval*1000000)))throw new EgressRejectedException("ACCOUNT_INTERVAL_WAIT",false,budget.lastRequestAtUtc.plusNanos((long)interval*1000000));
if(budget.reservedHttp>=budget.dailyLimit)throw new EgressRejectedException("ACCOUNT_DAILY_BUDGET",false,now.plusHours(8).toLocalDate().plusDays(1).atStartOfDay().minusHours(8)); if("VALIDATION".equals(context.executionPurpose())&&budget.reservedHttp>=budget.dailyLimit)throw new EgressRejectedException("ACCOUNT_DAILY_BUDGET",false,now.plusHours(8).toLocalDate().plusDays(1).atStartOfDay().minusHours(8));
var outbound=new EgressContext(context.sourceAccount(),context.executionPurpose(),String.valueOf(context.rootTaskId()),context.sessionId(),UUID.randomUUID().toString(),context.exclusiveGrantId()); var outbound=new EgressContext(context.sourceAccount(),context.executionPurpose(),String.valueOf(context.rootTaskId()),context.sessionId(),UUID.randomUUID().toString(),context.exclusiveGrantId());
var pending=new SelfopApiBudget.Pending();pending.context=outbound;pending.operation=operation;pending.internalRequestHash=requestHash;pending.taskId=context.taskId();pending.runId=context.runId();pending.expiresAtUtc=now.plusSeconds(Math.max(1,Math.min(120,config.getEgressLeaseSeconds()))); var pending=new SelfopApiBudget.Pending();pending.context=outbound;pending.operation=operation;pending.internalRequestHash=requestHash;pending.taskId=context.taskId();pending.runId=context.runId();pending.expiresAtUtc=now.plusSeconds(Math.max(1,Math.min(120,config.getEgressLeaseSeconds())));
if(pending.expiresAtUtc.isAfter(config.getExclusiveGrantExpiresAtUtc()))pending.expiresAtUtc=config.getExclusiveGrantExpiresAtUtc(); if(pending.expiresAtUtc.isAfter(config.getExclusiveGrantExpiresAtUtc()))pending.expiresAtUtc=config.getExclusiveGrantExpiresAtUtc();
@@ -58,8 +58,7 @@ public class SelfopEgressService {
var task=mapper.lockTask(pending.taskId);var run=mapper.lockRun(pending.runId); var task=mapper.lockTask(pending.taskId);var run=mapper.lockRun(pending.runId);
if(testBusiness(c.executionPurpose())){ if(testBusiness(c.executionPurpose())){
var b=business(root);verifyBusinessTask(task,root); var b=business(root);verifyBusinessTask(task,root);
// The last reservation already spent its HTTP allowance. Recheck time/stop, // Authorization preserves the root time/stop fence independently of request accounting.
// not http>=maxHttp, or that paid reservation could never be authorized.
String limit=b.stopReason!=null?b.stopReason:!now.isBefore(b.deadlineAtUtc)?"BUSINESS_SESSION_DEADLINE":null; String limit=b.stopReason!=null?b.stopReason:!now.isBefore(b.deadlineAtUtc)?"BUSINESS_SESSION_DEADLINE":null;
if(limit!=null){stopBusiness(root,b,limit,now);throw reject(limit);} if(limit!=null){stopBusiness(root,b,limit,now);throw reject(limit);}
if(pending.expiresAtUtc.isAfter(b.deadlineAtUtc))pending.expiresAtUtc=b.deadlineAtUtc; if(pending.expiresAtUtc.isAfter(b.deadlineAtUtc))pending.expiresAtUtc=b.deadlineAtUtc;
@@ -128,7 +127,7 @@ public class SelfopEgressService {
if("BUSINESS".equals(purpose)&&!("production".equals(config.getEnvironment())||"test".equals(config.getEnvironment())))throw reject("BUSINESS_ENVIRONMENT_REJECTED"); if("BUSINESS".equals(purpose)&&!("production".equals(config.getEnvironment())||"test".equals(config.getEnvironment())))throw reject("BUSINESS_ENVIRONMENT_REJECTED");
if(!Objects.equals(config.getSourceAccount(),account)||account==null||account.isBlank())throw reject("SOURCE_ACCOUNT_MISMATCH"); if(!Objects.equals(config.getSourceAccount(),account)||account==null||account.isBlank())throw reject("SOURCE_ACCOUNT_MISMATCH");
if(grant==null||!Objects.equals(grant,config.getExclusiveGrantId())||config.getExclusiveGrantExpiresAtUtc()==null||!now.isBefore(config.getExclusiveGrantExpiresAtUtc())||!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified())throw reject("EXCLUSIVE_HANDOFF_UNCONFIRMED"); if(grant==null||!Objects.equals(grant,config.getExclusiveGrantId())||config.getExclusiveGrantExpiresAtUtc()==null||!now.isBefore(config.getExclusiveGrantExpiresAtUtc())||!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified())throw reject("EXCLUSIVE_HANDOFF_UNCONFIRMED");
if(config.getDailyRequestBudget()<1||config.getBusinessMinRequestIntervalMs()<1||config.getRequestBudget()<1||config.getTimeBudgetSeconds()<1||config.getMaxRateLimitFailures()<1||config.getCooldownSeconds()<1)throw reject("ACCOUNT_BUDGET_UNCONFIGURED"); if(("VALIDATION".equals(purpose)&&config.getDailyRequestBudget()<1)||config.getBusinessMinRequestIntervalMs()<1||config.getRequestBudget()<1||config.getTimeBudgetSeconds()<1||config.getMaxRateLimitFailures()<1||config.getCooldownSeconds()<1)throw reject("ACCOUNT_BUDGET_UNCONFIGURED");
} }
private SelfopOutboundDiscoveryScope account(String id){var a=mapper.lockAccount(id);if(a==null)throw reject("EGRESS_ACCOUNT_MISSING");return a;} private SelfopOutboundDiscoveryScope account(String id){var a=mapper.lockAccount(id);if(a==null)throw reject("EGRESS_ACCOUNT_MISSING");return a;}
private SelfopApiBudget budget(SelfopOutboundDiscoveryScope account,LocalDateTime now){ private SelfopApiBudget budget(SelfopOutboundDiscoveryScope account,LocalDateTime now){
@@ -136,8 +135,9 @@ public class SelfopEgressService {
if(b.reservedHttp<0||b.confirmedHttp<0||b.confirmedHttp>b.reservedHttp)throw reject("ACCOUNT_BUDGET_INVALID"); if(b.reservedHttp<0||b.confirmedHttp<0||b.confirmedHttp>b.reservedHttp)throw reject("ACCOUNT_BUDGET_INVALID");
LocalDate day=now.plusHours(8).toLocalDate(); LocalDate day=now.plusHours(8).toLocalDate();
if(!Objects.equals(day,b.budgetDate)){/* unknown in-flight is charged again on new day; cooling survives */b.budgetDate=day;b.dailyLimit=config.getDailyRequestBudget();b.reservedHttp=b.pending!=null&&!b.pending.completed?1:0;b.confirmedHttp=0;} if(!Objects.equals(day,b.budgetDate)){/* unknown in-flight is charged again on new day; cooling survives */b.budgetDate=day;b.dailyLimit=config.getDailyRequestBudget();b.reservedHttp=b.pending!=null&&!b.pending.completed?1:0;b.confirmedHttp=0;}
if(b.dailyLimit<1)b.dailyLimit=config.getDailyRequestBudget(); if("BUSINESS".equals(config.getCollectionMode()))b.dailyLimit=0;
b.dailyLimit=Math.min(b.dailyLimit,config.getDailyRequestBudget());b.minIntervalMs=Math.max(b.minIntervalMs,config.getBusinessMinRequestIntervalMs()); else {if(b.dailyLimit<1)b.dailyLimit=config.getDailyRequestBudget();b.dailyLimit=Math.min(b.dailyLimit,config.getDailyRequestBudget());}
b.minIntervalMs=Math.max(b.minIntervalMs,config.getBusinessMinRequestIntervalMs());
b.configHash=SelfopSupport.hash(Arrays.asList(b.dailyLimit,b.minIntervalMs,config.getRequestBudget(),config.getTimeBudgetSeconds())); b.configHash=SelfopSupport.hash(Arrays.asList(b.dailyLimit,b.minIntervalMs,config.getRequestBudget(),config.getTimeBudgetSeconds()));
if(!Objects.equals(config.getExclusiveGrantId(),b.importedGrantId)){ if(!Objects.equals(config.getExclusiveGrantId(),b.importedGrantId)){
if(config.getTransferredBudgetDate()==null||!config.getTransferredBudgetDate().equals(day)||config.getTransferredDailyHttpCount()<0)throw reject("HANDOFF_BUDGET_DATE_UNCONFIRMED"); if(config.getTransferredBudgetDate()==null||!config.getTransferredBudgetDate().equals(day)||config.getTransferredDailyHttpCount()<0)throw reject("HANDOFF_BUDGET_DATE_UNCONFIRMED");
@@ -113,7 +113,7 @@ public class SelfopTaskService {
if(task.getRequestKey().startsWith("manual:")&&task.getUpstreamTaskId()==null) { if(task.getRequestKey().startsWith("manual:")&&task.getUpstreamTaskId()==null) {
require(controlledBusinessTask(task,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); require(controlledBusinessTask(task,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED");
task.setBusinessRootTaskId(task.getId()); task.setBusinessRootTaskId(task.getId());
task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now,config.getRequestBudget(),config.getTimeBudgetSeconds())); task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now,config.getTimeBudgetSeconds()));
}else { }else {
var parent=businessParent(task); var parent=businessParent(task);
require(parent!=null&&Objects.equals(parent.getSourceAccount(),task.getSourceAccount())&&controlledBusinessTask(parent,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); require(parent!=null&&Objects.equals(parent.getSourceAccount(),task.getSourceAccount())&&controlledBusinessTask(parent,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED");
@@ -693,7 +693,7 @@ components:
properties: properties:
accountCooldownUntil: {type: string, format: date-time, nullable: true} accountCooldownUntil: {type: string, format: date-time, nullable: true}
accountPauseReason: {type: string, nullable: true} accountPauseReason: {type: string, nullable: true}
dailyRequestBudget: {type: integer, nullable: true} dailyRequestBudget: {type: integer, minimum: 0, nullable: true, description: "BUSINESS 为 0,表示不限制累计请求次数;VALIDATION 为每日请求上限。实际请求计数继续累计。"}
reservedHttpRequests: {type: string, nullable: true} reservedHttpRequests: {type: string, nullable: true}
confirmedHttpRequests: {type: string, nullable: true} confirmedHttpRequests: {type: string, nullable: true}
budgetDate: {type: string, format: date, nullable: true} budgetDate: {type: string, format: date, nullable: true}
@@ -97,6 +97,29 @@ class CollectionQueryServiceTest {
result=service.taskDetail(t.taskNo(),q(null,null,null)).task(); result=service.taskDetail(t.taskNo(),q(null,null,null)).task();
assertEquals("UNAVAILABLE",result.stageEstimate().status());assertNull(result.stageEstimate().remainingSeconds()); assertEquals("UNAVAILABLE",result.stageEstimate().status());assertNull(result.stageEstimate().remainingSeconds());
} }
@Test void businessOverviewAndRetryIgnoreCumulativeRequestCapsButKeepCounters() {
enableBusiness("test");
when(mapper.protection("primary")).thenReturn(new Protection(null,null,
"{\"budgetDate\":\"2026-10-12\",\"dailyLimit\":100,\"reservedHttp\":12001,\"confirmedHttp\":12000}"));
for(int configured:List.of(100,0)) {
config.setDailyRequestBudget(configured);
for(var row:service.overview(q(null,null,null)).warehouses()) {
assertTrue(row.executionEnabled());assertNull(row.disabledReason());
assertEquals(0,row.protection().dailyRequestBudget());
assertEquals(12001L,row.protection().reservedHttpRequests());
assertEquals(12000L,row.protection().confirmedHttpRequests());
}
}
for(int maximum:List.of(5,0)) {
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(
businessBudget(now.plusHours(1).toString(),null,6000).replace("\"maxHttp\":5","\"maxHttp\":"+maximum)));
assertEquals(List.of("RETRY"),service.taskDetail("LXCOL-TASK",q(null,null,null)).task().allowedActions());
}
config.setCollectionMode("VALIDATION");config.setDailyRequestBudget(100);
for(var row:service.overview(q(null,null,null)).warehouses()) {
assertFalse(row.executionEnabled());assertEquals("DAILY_BUDGET_EXHAUSTED",row.disabledReason());
}
}
@Test void controlledTestBusinessProjectsEnabledInventoryPublicationWithoutAutomaticSchedule() { @Test void controlledTestBusinessProjectsEnabledInventoryPublicationWithoutAutomaticSchedule() {
config.setCollectionMode("BUSINESS");config.setEnvironment("test");config.setQuantityUnit("PCS"); config.setCollectionMode("BUSINESS");config.setEnvironment("test");config.setQuantityUnit("PCS");
config.setExclusiveHandoffVerified(true);config.setPriorEgressClosed(true);config.setInflightDrained(true);config.setTransferredBudgetVerified(true); config.setExclusiveHandoffVerified(true);config.setPriorEgressClosed(true);config.setInflightDrained(true);config.setTransferredBudgetVerified(true);
@@ -150,7 +173,7 @@ class CollectionQueryServiceTest {
valid.replace(now.minusHours(1).toString(),now.plusHours(1).toString()), valid.replace(now.minusHours(1).toString(),now.plusHours(1).toString()),
valid.replace(now.minusHours(1).toString(),now.plusHours(2).toString()), valid.replace(now.minusHours(1).toString(),now.plusHours(2).toString()),
valid.replace("\"maxHttp\":5","\"maxHttp\":2147483648"), valid.replace("\"maxHttp\":5","\"maxHttp\":2147483648"),
valid.replace("\"http\":5","\"http\":6"), valid.replace("\"http\":5","\"http\":2147483648"),
valid.replace("\"confirmedHttp\":5","\"confirmedHttp\":6"))) { valid.replace("\"confirmedHttp\":5","\"confirmedHttp\":6"))) {
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(budget,true)); when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(budget,true));
var task=service.taskDetail("LXCOL-TASK",q(null,null,null)).task(); var task=service.taskDetail("LXCOL-TASK",q(null,null,null)).task();
@@ -28,7 +28,7 @@ class SelfopBusinessBudgetPersistenceTest {
} }
var tasks=session.getMapper(SelfopSyncTaskMapper.class); var tasks=session.getMapper(SelfopSyncTaskMapper.class);
var stale=new SelfopSyncTask();stale.setId(1L);stale.setStatus("RUNNING");stale.setBusinessRootTaskId(1L); var stale=new SelfopSyncTask();stale.setId(1L);stale.setStatus("RUNNING");stale.setBusinessRootTaskId(1L);
stale.setBusinessBudgetJson(SelfopBusinessBudget.freeze(LocalDateTime.of(2026,10,13,0,0),2,120)); stale.setBusinessBudgetJson(SelfopBusinessBudget.freeze(LocalDateTime.of(2026,10,13,0,0),120));
assertEquals(1,tasks.insert(stale)); // NEVER updates must still allow initial freezing. assertEquals(1,tasks.insert(stale)); // NEVER updates must still allow initial freezing.
var charged=SelfopSupport.read(stale.getBusinessBudgetJson(),SelfopBusinessBudget.class); var charged=SelfopSupport.read(stale.getBusinessBudgetJson(),SelfopBusinessBudget.class);
charged.http=2;charged.confirmedHttp=1; charged.http=2;charged.confirmedHttp=1;
@@ -9,25 +9,26 @@ class SelfopBusinessBudgetTest {
private final LocalDateTime start=LocalDateTime.of(2026,10,13,0,0); private final LocalDateTime start=LocalDateTime.of(2026,10,13,0,0);
@Test void serializedConsumptionAndDeadlineSurviveReload() { @Test void serializedConsumptionAndDeadlineSurviveReload() {
var budget=SelfopSupport.read(SelfopBusinessBudget.freeze(start,2,60),SelfopBusinessBudget.class); var budget=SelfopSupport.read(SelfopBusinessBudget.freeze(start,60),SelfopBusinessBudget.class);
budget.http=2;budget.confirmedHttp=1; budget.http=2;budget.confirmedHttp=1;
var restored=SelfopSupport.read(SelfopSupport.json(budget),SelfopBusinessBudget.class); var restored=SelfopSupport.read(SelfopSupport.json(budget),SelfopBusinessBudget.class);
assertEquals(start.plusSeconds(60),restored.deadlineAtUtc); assertEquals(start.plusSeconds(60),restored.deadlineAtUtc);
assertEquals(2,restored.http);assertEquals(1,restored.confirmedHttp); assertEquals(2,restored.http);assertEquals(1,restored.confirmedHttp);
assertEquals("BUSINESS_SESSION_HTTP_LIMIT",restored.limit(start.plusSeconds(1))); assertEquals(0,restored.maxHttp);assertNull(restored.limit(start.plusSeconds(1)));
assertEquals("BUSINESS_SESSION_DEADLINE",restored.limit(start.plusSeconds(60))); assertEquals("BUSINESS_SESSION_DEADLINE",restored.limit(start.plusSeconds(60)));
restored.stopReason="BUSINESS_SESSION_HTTP_LIMIT"; restored.stopReason="BUSINESS_SESSION_HTTP_LIMIT";
assertEquals(restored.stopReason,restored.limit(start.plusDays(1))); assertEquals(restored.stopReason,restored.limit(start.plusDays(1)));
} }
@Test void noImplicitUnlimitedOrInvalidAccounting() { @Test void unlimitedHttpRetainsStrictDeadlineAndAccounting() {
assertThrows(IllegalArgumentException.class,()->SelfopBusinessBudget.freeze(start,0,60)); assertThrows(IllegalArgumentException.class,()->SelfopBusinessBudget.freeze(null,60));
assertThrows(IllegalArgumentException.class,()->SelfopBusinessBudget.freeze(start,1,0)); assertThrows(IllegalArgumentException.class,()->SelfopBusinessBudget.freeze(start,0));
assertThrows(EgressRejectedException.class,()->new SelfopBusinessBudget().validate()); assertThrows(EgressRejectedException.class,()->new SelfopBusinessBudget().validate());
var budget=SelfopSupport.read(SelfopBusinessBudget.freeze(start,2,60),SelfopBusinessBudget.class); var budget=SelfopSupport.read(SelfopBusinessBudget.freeze(start,60),SelfopBusinessBudget.class);
budget.confirmedHttp=1; budget.confirmedHttp=1;
assertThrows(EgressRejectedException.class,budget::validate); assertThrows(EgressRejectedException.class,budget::validate);
budget.confirmedHttp=0;budget.http=3; budget.confirmedHttp=12001;budget.http=12001;budget.maxHttp=5000;
assertThrows(EgressRejectedException.class,budget::validate); assertDoesNotThrow(budget::validate);assertNull(budget.limit(start.plusSeconds(1)));
budget.http=-1;assertThrows(EgressRejectedException.class,budget::validate);
} }
} }
@@ -148,24 +148,31 @@ class SelfopEgressServiceTest {
} }
} }
private void business(){cfg.setCollectionMode("BUSINESS");cfg.setEnvironment("production");task.setExecutionPurpose("BUSINESS");run.setExecutionPurpose("BUSINESS");} private void business(){cfg.setCollectionMode("BUSINESS");cfg.setEnvironment("production");task.setExecutionPurpose("BUSINESS");run.setExecutionPurpose("BUSINESS");}
private void testBusiness(int http,int seconds){business();cfg.setEnvironment("test");task.setBusinessRootTaskId(1L);task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now(),http,seconds));} private void testBusiness(int http,int seconds){business();cfg.setEnvironment("test");task.setBusinessRootTaskId(1L);task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now(),seconds));}
private SelfopBusinessBudget businessBudget(){return SelfopSupport.read(task.getBusinessBudgetJson(),SelfopBusinessBudget.class);} private SelfopBusinessBudget businessBudget(){return SelfopSupport.read(task.getBusinessBudgetJson(),SelfopBusinessBudget.class);}
@Test void testBusinessLastReservedAttemptIsAllowedAndRestartCannotRenewSession(){ @Test void businessSurpassesPriorDailyAndRootHttpCapsAcrossRestartWithoutRenewingDeadline(){
testBusiness(1,300);var start=businessBudget().startedAtUtc;var deadline=businessBudget().deadlineAtUtc; testBusiness(5000,300);cfg.setDailyRequestBudget(12000);cfg.setTransferredDailyHttpCount(12000);
var c=reserve(Operation.WAREHOUSES,1,null,null);assertThat(businessBudget().http).isEqualTo(1); var rootBudget=businessBudget();rootBudget.maxHttp=5000;rootBudget.http=5000;rootBudget.confirmedHttp=5000;task.setBusinessBudgetJson(SelfopSupport.json(rootBudget));
service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); var start=rootBudget.startedAtUtc;var deadline=rootBudget.deadlineAtUtc;
var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));
var completion=new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null); var completion=new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null);
service.complete(completion);new SelfopEgressService(mapper,cfg,clock).complete(completion); service.complete(completion);new SelfopEgressService(mapper,cfg,clock).complete(completion);
assertThat(businessBudget().confirmedHttp).isEqualTo(1);clock.advance(10); assertThat(businessBudget().http).isEqualTo(5001);assertThat(businessBudget().confirmedHttp).isEqualTo(5001);
cfg.setRequestBudget(1000);cfg.setTimeBudgetSeconds(3600);cfg.setExclusiveGrantId("renewed"); assertThat(budget().dailyLimit).isZero();assertThat(budget().reservedHttp).isEqualTo(12001);assertThat(budget().confirmedHttp).isEqualTo(1);
var renewed=new SelfopRequestContext("synthetic","BUSINESS",1L,"selfop-1","renewed",1L,2L); clock.advance(10);cfg.setRequestBudget(1000);cfg.setTimeBudgetSeconds(3600);
assertThatThrownBy(()->new SelfopEgressService(mapper,cfg,clock).reserve(renewed,Operation.OUTBOUND_QUERY,1,null,null,INTERNAL)) var next=new SelfopEgressService(mapper,cfg,clock).reserve(context(),Operation.OUTBOUND_QUERY,1,null,null,INTERNAL);success(next,Operation.OUTBOUND_QUERY);
.hasMessage("BUSINESS_SESSION_HTTP_LIMIT").satisfies(e->{assertThat(((EgressRejectedException)e).sampleDone()).isFalse();assertThat(((EgressRejectedException)e).waitUntilUtc()).isNull();}); assertThat(businessBudget().http).isEqualTo(5002);assertThat(businessBudget().startedAtUtc).isEqualTo(start);assertThat(businessBudget().deadlineAtUtc).isEqualTo(deadline);
assertThat(task.getStatus()).isEqualTo("DEAD");assertThat(task.getNextRetryAtUtc()).isNull(); verify(mapper,never()).terminateBusinessTasks(any(),any());verify(mapper,never()).terminateValidationRuns(any(),any(),any(),any());
assertThat(businessBudget().startedAtUtc).isEqualTo(start);assertThat(businessBudget().deadlineAtUtc).isEqualTo(deadline); }
assertThat(businessBudget().maxHttp).isEqualTo(1);assertThat(budget().reservedHttp).isEqualTo(1); @Test void businessWithoutDailyQuotaStillUsesIntervalAndActual429Review(){
verify(mapper).terminateBusinessTasks(1L,"synthetic");verify(mapper).terminateBusinessRuns(eq(1L),eq("synthetic"),eq("BUSINESS_SESSION_HTTP_LIMIT"),any()); testBusiness(1,300);cfg.setDailyRequestBudget(0);cfg.setBusinessMinRequestIntervalMs(1200);
verify(mapper,never()).terminateValidationRuns(any(),any(),any(),any()); var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));
service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null));
assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,1,null,null)).hasMessage("ACCOUNT_INTERVAL_WAIT");
clock.advance(2);var next=reserve(Operation.OUTBOUND_QUERY,1,null,null);service.authorize(new AuthorizationRequest(next,Operation.OUTBOUND_QUERY,PROVIDER));
service.complete(new CompletionRequest(next,Operation.OUTBOUND_QUERY,PROVIDER,1,"LINGXING_WMS_HTTP_RATE_LIMITED",429,null,true,null));
assertThat(budget().reservedHttp).isEqualTo(2);assertThat(budget().confirmedHttp).isEqualTo(2);assertThat(budget().rateLimitFailures).isEqualTo(1);
assertThat(account.getApiPauseReason()).isEqualTo("RATE_LIMIT_COOLDOWN");
} }
@Test void testBusinessDeadlineClipsPermitAndAuthorizeRechecksBeforeHttp(){ @Test void testBusinessDeadlineClipsPermitAndAuthorizeRechecksBeforeHttp(){
testBusiness(5,10);var c=reserve(Operation.WAREHOUSES,1,null,null);assertThat(budget().pending.expiresAtUtc).isEqualTo(businessBudget().deadlineAtUtc); testBusiness(5,10);var c=reserve(Operation.WAREHOUSES,1,null,null);assertThat(budget().pending.expiresAtUtc).isEqualTo(businessBudget().deadlineAtUtc);
@@ -187,7 +194,7 @@ class SelfopEgressServiceTest {
testBusiness(5,300);task.setBusinessBudgetJson(null); testBusiness(5,300);task.setBusinessBudgetJson(null);
assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_INVALID"); assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_INVALID");
assertThat(task.getBusinessBudgetJson()).isNull();assertThat(budget().reservedHttp).isZero(); assertThat(task.getBusinessBudgetJson()).isNull();assertThat(budget().reservedHttp).isZero();
task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now(),5,300));task.setBusinessRootTaskId(999L); task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now(),300));task.setBusinessRootTaskId(999L);
assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_INVALID"); assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_INVALID");
assertThat(businessBudget().http).isZero(); assertThat(businessBudget().http).isZero();
} }
@@ -201,8 +208,8 @@ class SelfopEgressServiceTest {
var c=new SelfopEgressService(mapper,cfg,clock).reserve(context,Operation.PRODUCT_INVENTORY,1,"A",null,INTERNAL);success(c,Operation.PRODUCT_INVENTORY); var c=new SelfopEgressService(mapper,cfg,clock).reserve(context,Operation.PRODUCT_INVENTORY,1,"A",null,INTERNAL);success(c,Operation.PRODUCT_INVENTORY);
assertThat(businessBudget().http).isEqualTo(2);assertThat(businessBudget().confirmedHttp).isEqualTo(2);assertThat(child.getBusinessBudgetJson()).isNull(); assertThat(businessBudget().http).isEqualTo(2);assertThat(businessBudget().confirmedHttp).isEqualTo(2);assertThat(child.getBusinessBudgetJson()).isNull();
child.setBusinessRootTaskId(999L);assertThatThrownBy(()->service.reserve(context,Operation.PRODUCT_INVENTORY,1,"A",null,INTERNAL)).hasMessage("BUSINESS_SESSION_ROOT_INVALID"); child.setBusinessRootTaskId(999L);assertThatThrownBy(()->service.reserve(context,Operation.PRODUCT_INVENTORY,1,"A",null,INTERNAL)).hasMessage("BUSINESS_SESSION_ROOT_INVALID");
child.setBusinessRootTaskId(1L);assertThatThrownBy(()->service.reserve(context,Operation.PRODUCT_INVENTORY,1,"A",null,INTERNAL)).hasMessage("BUSINESS_SESSION_HTTP_LIMIT"); child.setBusinessRootTaskId(1L);success(service.reserve(context,Operation.PRODUCT_INVENTORY,1,"A",null,INTERNAL),Operation.PRODUCT_INVENTORY);
verify(mapper).terminateBusinessTasks(1L,"synthetic"); assertThat(businessBudget().http).isEqualTo(3);verify(mapper,never()).terminateBusinessTasks(any(),any());
} }
@Test void productionBusinessDoesNotRequireTestBudget(){ @Test void productionBusinessDoesNotRequireTestBudget(){
business();success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES); business();success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES);
@@ -371,7 +371,7 @@ class SelfopTaskServiceTest {
var root=task("READY");root.setRequestKey("manual:frozen");root.setRequestedBy("operator"); var root=task("READY");root.setRequestKey("manual:frozen");root.setRequestedBy("operator");
service.create(root); service.create(root);
var budget=read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class); var budget=read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class);
assertThat(root.getBusinessRootTaskId()).isEqualTo(root.getId());assertThat(budget.maxHttp).isEqualTo(7); assertThat(root.getBusinessRootTaskId()).isEqualTo(root.getId());assertThat(budget.maxHttp).isZero();
assertThat(budget.deadlineAtUtc).isEqualTo(budget.startedAtUtc.plusSeconds(91)); assertThat(budget.deadlineAtUtc).isEqualTo(budget.startedAtUtc.plusSeconds(91));
var child=task("READY");child.setId(42L);child.setBusinessRootTaskId(root.getId()); var child=task("READY");child.setId(42L);child.setBusinessRootTaskId(root.getId());
assertThat(SelfopRequestContext.forTask(child,run("FETCHING"),config).rootTaskId()).isEqualTo(root.getId()); assertThat(SelfopRequestContext.forTask(child,run("FETCHING"),config).rootTaskId()).isEqualTo(root.getId());