fix(lastleg): persist bounded test collection session budgets

This commit is contained in:
2026-10-13 03:08:36 +08:00
parent 1fdd978084
commit 7ae91eb276
26 changed files with 739 additions and 71 deletions
@@ -64,11 +64,13 @@ class SelfoperatedAnalyticsSchemaContractTest {
var task = ddl.get("ll_selfop_sync_task");
for (var column : new String[]{"apply_state", "affected_warehouse_codes_json", "observation_baseline",
"routing_manifest_json", "request_hash", "apply_completed_at_utc", "task_no", "execution_purpose",
"validation_root_task_id", "validation_budget_json", "result_published_run_id", "progress_json",
"validation_root_task_id", "validation_budget_json", "business_root_task_id", "business_budget_json", "result_published_run_id", "progress_json",
"progress_updated_at_utc"}) {
assertTrue(task.contains(" " + column + " "), column);
}
assertTrue(task.contains("UNIQUE KEY uk_selfop_task_no (task_no)"));
assertTrue(task.contains("KEY idx_selfop_task_business_root (business_root_task_id,status,id)"));
assertTrue(task.contains("CONSTRAINT chk_selfop_business_budget CHECK"));
var account = ddl.get("ll_selfop_outbound_discovery_scope");
for (var column : new String[]{"api_budget_json", "api_cooldown_until_utc", "api_pause_reason"}) {
assertTrue(account.contains(" " + column + " "), column);
@@ -43,7 +43,84 @@ class CollectionQueryServiceTest {
}
Task task(String purpose,String progress,int attempts) {
return new Task(7L,"LXCOL-TASK","primary","A","OUTBOUND","WAREHOUSE_SYNC","SCHEDULED",purpose,
"hash","DEAD","BLOCKED",attempts,now.minusDays(1),now,null,null,null,null,progress,now,null,now,now);
"hash","DEAD","BLOCKED",attempts,now.minusDays(1),now,null,null,null,null,progress,now,null,null,false,now,now);
}
private void enableBusiness(String environment) {
config.setCollectionMode("BUSINESS");config.setEnvironment(environment);config.setQuantityUnit("PCS");
config.setExclusiveHandoffVerified(true);config.setPriorEgressClosed(true);config.setInflightDrained(true);config.setTransferredBudgetVerified(true);
config.setExclusiveGrantExpiresAtUtc(now.plusHours(1));config.setDailyRequestBudget(100);
}
private Task manualBusinessTask(String budget) { return manualBusinessTask(budget,false); }
private Task manualBusinessTask(String budget,boolean ready) {
return new Task(7L,"LXCOL-TASK","primary","A","OUTBOUND","WAREHOUSE_SYNC","BACKFILL","BUSINESS",
"hash","DEAD","BLOCKED",1,now.minusDays(1),now,null,null,null,null,null,now,null,budget,ready,now,now);
}
private String businessBudget(String deadline,String stopReason,int http) {
return "{\"startedAtUtc\":\""+now.minusHours(1)+"\",\"maxHttp\":5,\"http\":"+http+",\"confirmedHttp\":"+http+",\"deadlineAtUtc\":\""+deadline+"\",\"stopReason\":"
+(stopReason==null?"null":"\""+stopReason+"\"")+"}";
}
@Test void stoppedOrMissingTestBusinessRootBudgetCannotAdvertiseTaskOrAttemptRetry() {
enableBusiness("test");
for(String budget:Arrays.asList(null,"{}",businessBudget(now.plusHours(1).toString(),"BUSINESS_SESSION_HTTP_LIMIT",5))) {
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(budget));
when(mapper.latestAttempts(eq("primary"),anyList())).thenReturn(List.of(run(1,"FAILED",null)));
var detail=service.taskDetail("LXCOL-TASK",q(null,null,null)).task();
assertTrue(detail.allowedActions().isEmpty());assertFalse(detail.latestAttempt().retryable());
assertEquals(budget==null||budget.equals("{}")?"BUSINESS_SESSION_BUDGET_MISSING":"BUSINESS_SESSION_STOPPED",detail.actionBlockReason());
}
}
@Test void malformedBudgetBoundsAndAnyNonNullStopReasonCannotAdvertiseRetry() {
enableBusiness("test");
String valid=businessBudget(now.plusHours(1).toString(),null,5);
for(String budget:List.of(
valid.replace("\"startedAtUtc\"","\"missingStarted\""),
valid.replace(now.minusHours(1).toString(),"invalid"),
valid.replace(now.minusHours(1).toString(),now.plusHours(1).toString()),
valid.replace(now.minusHours(1).toString(),now.plusHours(2).toString()),
valid.replace("\"maxHttp\":5","\"maxHttp\":2147483648"),
valid.replace("\"http\":5","\"http\":6"),
valid.replace("\"confirmedHttp\":5","\"confirmedHttp\":6"))) {
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(budget,true));
var task=service.taskDetail("LXCOL-TASK",q(null,null,null)).task();
assertTrue(task.allowedActions().isEmpty());assertEquals("BUSINESS_SESSION_BUDGET_MISSING",task.actionBlockReason());
}
for(String stop:List.of("\"\"","\" \"","false","0","{}")) {
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(valid.replace("\"stopReason\":null","\"stopReason\":"+stop),true));
var task=service.taskDetail("LXCOL-TASK",q(null,null,null)).task();
assertTrue(task.allowedActions().isEmpty());assertEquals("BUSINESS_SESSION_STOPPED",task.actionBlockReason());
}
}
@Test void validTestBusinessBudgetAtExactHttpCapStillAllowsLocalRetryAndProductionNeedsNoTestBudget() {
enableBusiness("test");
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(businessBudget(now.plusHours(1).toString(),null,5)));
when(mapper.latestAttempts(eq("primary"),anyList())).thenReturn(List.of(run(1,"FAILED",null)));
var detail=service.taskDetail("LXCOL-TASK",q(null,null,null)).task();
assertEquals(List.of("RETRY"),detail.allowedActions());assertTrue(detail.latestAttempt().retryable());
enableBusiness("production");when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(null));
assertEquals(List.of("RETRY"),service.taskDetail("LXCOL-TASK",q(null,null,null)).task().allowedActions());
}
@Test void expiredBudgetOnlyAllowsProvenLocalWorkButStoppedBudgetNeverDoes() {
enableBusiness("test");
when(mapper.latestAttempts(eq("primary"),anyList())).thenReturn(List.of(run(1,"FAILED",null)));
for(boolean ready:List.of(false,true)) {
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(businessBudget(now.toString(),null,5),ready));
var task=service.taskDetail("LXCOL-TASK",q(null,null,null)).task();
assertEquals(ready,task.latestAttempt().retryable());
assertEquals(ready?null:"BUSINESS_SESSION_DEADLINE",task.actionBlockReason());
}
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(businessBudget(now.toString(),"BUSINESS_SESSION_DEADLINE",5),true));
assertEquals("BUSINESS_SESSION_STOPPED",service.taskDetail("LXCOL-TASK",q(null,null,null)).task().actionBlockReason());
}
@Test void businessBudgetTerminationCodesHaveSafeChineseAttemptMessagesEvenWithoutRawErrorText() {
var upper=new Bound(now,100L);when(mapper.runBound(any())).thenReturn(upper);
for(String code:List.of("BUSINESS_SESSION_HTTP_LIMIT","BUSINESS_SESSION_DEADLINE")) {
var attempt=new Attempt(90L,7L,"LXCOL-TASK",1,"FAILED","DETAILS","BUSINESS",now.minusMinutes(2),null,null,
now,null,now,null,"PARTIAL",null,null,code,null,now.minusMinutes(2),"A",1L,"hash");
when(mapper.runs(any(),eq(upper))).thenReturn(List.of(attempt));
String message=service.runs(q(null,null,null)).records().get(0).failureSummary();
assertTrue(message.contains(code.endsWith("HTTP_LIMIT")?"请求预算已耗尽":"已到截止时间"));
assertTrue(message.contains("不能恢复原任务"));
}
}
Attempt run(int attempt,String status,String snapshot) {
return new Attempt(90L,7L,"LXCOL-TASK",attempt,status,"DETAILS","BUSINESS",now.minusHours(1),null,null,
@@ -182,7 +259,7 @@ class CollectionQueryServiceTest {
@Test void validationBudgetReadsActualFlatPersistedFieldsAndMasksDetails() {
Task t=new Task(7L,"LXCOL-TASK","primary","A","OUTBOUND","WAREHOUSE_SYNC","VALIDATION","VALIDATION",
"hash","SAMPLE_DONE","NOT_APPLICABLE",1,null,null,null,null,null,null,null,null,
"{\"maxPages\":10,\"pages\":3,\"http\":5,\"confirmedHttp\":4,\"detailKeys\":[\"secret-order\"],\"deadlineAtUtc\":\"2026-10-12T04:05:00\"}",now,now);
"{\"maxPages\":10,\"pages\":3,\"http\":5,\"confirmedHttp\":4,\"detailKeys\":[\"secret-order\"],\"deadlineAtUtc\":\"2026-10-12T04:05:00\"}",null,false,now,now);
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(t);
var result=service.taskDetail("LXCOL-TASK",q(null,null,null)).task().validation();
assertEquals(10L,result.budgetLimits().get("maxPages"));assertEquals(3L,result.budgetConsumed().get("pages"));
@@ -202,10 +279,21 @@ class CollectionQueryServiceTest {
"publishedRuns","currentTasks","latestValidationTasks","lastSuccessfulTasks","sharedActiveAccount","barrierWarehouses",
"inventoryDailySamples","inventoryAccumulationStarts")) {
String sql=configuration.getMappedStatement(CollectionReadMapper.class.getName()+"."+method).getBoundSql(parameters).getSql();
assertFalse(sql.contains("routing_manifest_json"));assertFalse(sql.contains("ll_selfop_sku_daily_sales"));assertFalse(sql.contains("ll_selfop_inventory_snapshot"));
assertFalse(sql.contains("AS routing_manifest_json"));assertFalse(sql.contains("ll_selfop_sku_daily_sales"));assertFalse(sql.contains("ll_selfop_inventory_snapshot"));
assertFalse(sql.contains("ll_selfop_sync_stage"));
}
String runSql=configuration.getMappedStatement(CollectionReadMapper.class.getName()+".runs").getBoundSql(parameters).getSql();
assertFalse(runSql.contains("routing_manifest_json"));
for(String method:List.of("tasks","tasksByIds","currentTasks","latestValidationTasks","lastSuccessfulTasks","sharedActiveAccount")) {
String sql=configuration.getMappedStatement(CollectionReadMapper.class.getName()+"."+method).getBoundSql(parameters).getSql();
assertTrue(sql.contains("b.source_account=t.source_account"));
assertTrue(sql.contains("b.execution_purpose='BUSINESS' AND b.business_root_task_id=b.id"));
assertTrue(sql.contains("AS business_local_work_ready"));
assertTrue(sql.contains("'$.objects'))='OBJECT'"));
assertTrue(sql.contains("'$.objects.*'"));
assertTrue(sql.contains("COALESCE(JSON_TYPE(JSON_EXTRACT(manifest_item.entry,'$.effectiveStageId')),'NULL')"));
assertTrue(sql.contains("BETWEEN 1 AND 9223372036854775807) IS NOT TRUE"));
}
assertTrue(runSql.contains("r.created_at <"));assertTrue(runSql.contains("r.id <="));assertTrue(runSql.contains("LIMIT ? OFFSET ?"));
String samplesSql=configuration.getMappedStatement(CollectionReadMapper.class.getName()+".inventoryDailySamples").getBoundSql(parameters).getSql();
assertTrue(samplesSql.contains("r.inventory_daily_sample_date >= ?"));
@@ -250,7 +338,7 @@ class CollectionQueryServiceTest {
private Task validationRoot(String warehouse,Long rootId) {
return new Task(7L,"LXCOL-SAMPLE","primary",warehouse,"OUTBOUND","ACCOUNT_DISCOVERY","VALIDATION","VALIDATION",
"sample-hash","SAMPLE_DONE","NOT_APPLICABLE",1,null,null,null,null,rootId,null,sampleProgress(),now,
"{\"http\":23,\"detailHttp\":20,\"deadlineAtUtc\":\"2026-10-12T04:05:00\"}",now,now);
"{\"http\":23,\"detailHttp\":20,\"deadlineAtUtc\":\"2026-10-12T04:05:00\"}",null,false,now,now);
}
private Attempt sampleAttempt() {
return new Attempt(90L,7L,"LXCOL-SAMPLE",1,"SAMPLE_DONE","SAMPLE_DONE","VALIDATION",now.minusMinutes(2),now,null,
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.selfoperated.analytics.request;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopSyncTaskMapper;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncTask;
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/** Actual MyBatis Plus insert/update execution, on process-local H2 only. */
class SelfopBusinessBudgetPersistenceTest {
@Test void staleCollectorUpdateCannotResetFrozenRootIdentityConsumptionOrStopReason() throws Exception {
var source=new UnpooledDataSource("org.h2.Driver","jdbc:h2:mem:business_budget_"+UUID.randomUUID()+";MODE=MySQL","sa","");
var configuration=new MybatisConfiguration();configuration.setMapUnderscoreToCamelCase(true);
configuration.addMapper(SelfopSyncTaskMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(source);factory.setConfiguration(configuration);
try(var session=Objects.requireNonNull(factory.getObject()).openSession(true)){
var connection=session.getConnection();
try(var statement=connection.createStatement()){
statement.execute("CREATE TABLE ll_selfop_sync_task(id BIGINT PRIMARY KEY,status VARCHAR(32),progress_json VARCHAR(2000),business_root_task_id BIGINT,business_budget_json VARCHAR(4000))");
}
var tasks=session.getMapper(SelfopSyncTaskMapper.class);
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));
assertEquals(1,tasks.insert(stale)); // NEVER updates must still allow initial freezing.
var charged=SelfopSupport.read(stale.getBusinessBudgetJson(),SelfopBusinessBudget.class);
charged.http=2;charged.confirmedHttp=1;
String persisted=SelfopSupport.json(charged);
assertEquals(1,tasks.update(null,new UpdateWrapper<SelfopSyncTask>().eq("id",1L).set("business_budget_json",persisted)));
stale.setBusinessRootTaskId(999L);stale.setProgressJson("{\"phase\":\"DETAIL\"}");
var sql=configuration.getMappedStatement(SelfopSyncTaskMapper.class.getName()+".updateById")
.getBoundSql(Map.of("et",stale)).getSql();
assertFalse(sql.contains("business_root_task_id"));assertFalse(sql.contains("business_budget_json"));
assertEquals(1,tasks.updateById(stale));
assertStored(connection,persisted,stale.getProgressJson());
charged.stopReason="BUSINESS_SESSION_HTTP_LIMIT";persisted=SelfopSupport.json(charged);
assertEquals(1,tasks.update(null,new UpdateWrapper<SelfopSyncTask>().eq("id",1L).set("business_budget_json",persisted)));
stale.setProgressJson("{\"phase\":\"LOCAL_RESULT\"}");assertEquals(1,tasks.updateById(stale));
assertStored(connection,persisted,stale.getProgressJson());
}
}
private static void assertStored(java.sql.Connection connection,String budget,String progress) throws Exception {
try(var statement=connection.createStatement();var result=statement.executeQuery("SELECT business_root_task_id,business_budget_json,progress_json FROM ll_selfop_sync_task WHERE id=1")){
assertTrue(result.next());assertEquals(1L,result.getLong(1));assertEquals(budget,result.getString(2));assertEquals(progress,result.getString(3));assertFalse(result.next());
}
}
}
@@ -0,0 +1,33 @@
package com.baiyue.lastleg.selfoperated.analytics.request;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport;
import java.time.LocalDateTime;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class SelfopBusinessBudgetTest {
private final LocalDateTime start=LocalDateTime.of(2026,10,13,0,0);
@Test void serializedConsumptionAndDeadlineSurviveReload() {
var budget=SelfopSupport.read(SelfopBusinessBudget.freeze(start,2,60),SelfopBusinessBudget.class);
budget.http=2;budget.confirmedHttp=1;
var restored=SelfopSupport.read(SelfopSupport.json(budget),SelfopBusinessBudget.class);
assertEquals(start.plusSeconds(60),restored.deadlineAtUtc);
assertEquals(2,restored.http);assertEquals(1,restored.confirmedHttp);
assertEquals("BUSINESS_SESSION_HTTP_LIMIT",restored.limit(start.plusSeconds(1)));
assertEquals("BUSINESS_SESSION_DEADLINE",restored.limit(start.plusSeconds(60)));
restored.stopReason="BUSINESS_SESSION_HTTP_LIMIT";
assertEquals(restored.stopReason,restored.limit(start.plusDays(1)));
}
@Test void noImplicitUnlimitedOrInvalidAccounting() {
assertThrows(IllegalArgumentException.class,()->SelfopBusinessBudget.freeze(start,0,60));
assertThrows(IllegalArgumentException.class,()->SelfopBusinessBudget.freeze(start,1,0));
assertThrows(EgressRejectedException.class,()->new SelfopBusinessBudget().validate());
var budget=SelfopSupport.read(SelfopBusinessBudget.freeze(start,2,60),SelfopBusinessBudget.class);
budget.confirmedHttp=1;
assertThrows(EgressRejectedException.class,budget::validate);
budget.confirmedHttp=0;budget.http=3;
assertThrows(EgressRejectedException.class,budget::validate);
}
}
@@ -35,9 +35,10 @@ class SelfopEgressServiceTest {
cfg.setCollectionMode("VALIDATION");cfg.setPriorEgressClosed(false);assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED");verifyNoInteractions(mapper);
}
@Test void testBusinessStillChargesPersistentAccountAndRequiresLiveExclusiveGrant(){
business();cfg.setEnvironment("test");
testBusiness(5,300);
var c=reserve(Operation.WAREHOUSES,1,null,null);success(c,Operation.WAREHOUSES);
assertThat(budget().reservedHttp).isEqualTo(1);assertThat(budget().confirmedHttp).isEqualTo(1);
assertThat(businessBudget().http).isEqualTo(1);assertThat(businessBudget().confirmedHttp).isEqualTo(1);
cfg.setExclusiveGrantExpiresAtUtc(now());
assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED");
assertThat(budget().reservedHttp).isEqualTo(1);
@@ -147,6 +148,84 @@ class SelfopEgressServiceTest {
}
}
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 SelfopBusinessBudget businessBudget(){return SelfopSupport.read(task.getBusinessBudgetJson(),SelfopBusinessBudget.class);}
@Test void testBusinessLastReservedAttemptIsAllowedAndRestartCannotRenewSession(){
testBusiness(1,300);var start=businessBudget().startedAtUtc;var deadline=businessBudget().deadlineAtUtc;
var c=reserve(Operation.WAREHOUSES,1,null,null);assertThat(businessBudget().http).isEqualTo(1);
service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));
var completion=new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null);
service.complete(completion);new SelfopEgressService(mapper,cfg,clock).complete(completion);
assertThat(businessBudget().confirmedHttp).isEqualTo(1);clock.advance(10);
cfg.setRequestBudget(1000);cfg.setTimeBudgetSeconds(3600);cfg.setExclusiveGrantId("renewed");
var renewed=new SelfopRequestContext("synthetic","BUSINESS",1L,"selfop-1","renewed",1L,2L);
assertThatThrownBy(()->new SelfopEgressService(mapper,cfg,clock).reserve(renewed,Operation.OUTBOUND_QUERY,1,null,null,INTERNAL))
.hasMessage("BUSINESS_SESSION_HTTP_LIMIT").satisfies(e->{assertThat(((EgressRejectedException)e).sampleDone()).isFalse();assertThat(((EgressRejectedException)e).waitUntilUtc()).isNull();});
assertThat(task.getStatus()).isEqualTo("DEAD");assertThat(task.getNextRetryAtUtc()).isNull();
assertThat(businessBudget().startedAtUtc).isEqualTo(start);assertThat(businessBudget().deadlineAtUtc).isEqualTo(deadline);
assertThat(businessBudget().maxHttp).isEqualTo(1);assertThat(budget().reservedHttp).isEqualTo(1);
verify(mapper).terminateBusinessTasks(1L,"synthetic");verify(mapper).terminateBusinessRuns(eq(1L),eq("synthetic"),eq("BUSINESS_SESSION_HTTP_LIMIT"),any());
verify(mapper,never()).terminateValidationRuns(any(),any(),any(),any());
}
@Test void testBusinessDeadlineClipsPermitAndAuthorizeRechecksBeforeHttp(){
testBusiness(5,10);var c=reserve(Operation.WAREHOUSES,1,null,null);assertThat(budget().pending.expiresAtUtc).isEqualTo(businessBudget().deadlineAtUtc);
clock.advance(9);var permit=service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));
assertThat(permit.expiresAt()).isEqualTo(businessBudget().deadlineAtUtc.toInstant(ZoneOffset.UTC));
clock.advance(2);service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null));
assertThat(businessBudget().confirmedHttp).isEqualTo(1);
assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_DEADLINE");
assertThat(businessBudget().stopReason).isEqualTo("BUSINESS_SESSION_DEADLINE");
}
@Test void testBusinessExpiredOrStoppedPreparedAttemptCannotAuthorize(){
testBusiness(5,10);var c=reserve(Operation.WAREHOUSES,1,null,null);clock.advance(10);
assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)))
.hasMessage("BUSINESS_SESSION_DEADLINE").satisfies(e->assertThat(((EgressRejectedException)e).sampleDone()).isFalse());
assertThat(budget().pending.consumed).isFalse();assertThat(businessBudget().http).isEqualTo(1);assertThat(businessBudget().confirmedHttp).isZero();
assertThat(task.getStatus()).isEqualTo("DEAD");
}
@Test void testBusinessMissingBudgetOrWrongRootFailsClosedWithoutBackfill(){
testBusiness(5,300);task.setBusinessBudgetJson(null);
assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_INVALID");
assertThat(task.getBusinessBudgetJson()).isNull();assertThat(budget().reservedHttp).isZero();
task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now(),5,300));task.setBusinessRootTaskId(999L);
assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_INVALID");
assertThat(businessBudget().http).isZero();
}
@Test void testBusinessChildSharesFrozenRootAcrossYieldAndRejectsDifferentRoot(){
testBusiness(2,300);
var child=new SelfopSyncTask();child.setId(3L);child.setBusinessRootTaskId(1L);child.setTaskRole("WAREHOUSE_SYNC");child.setExecutionPurpose("BUSINESS");child.setSourceAccount("synthetic");child.setStatus("RUNNING");child.setScopeId(100L);child.setWarehouseCode("A");
var attempt=new SelfopSyncRun();attempt.setId(4L);attempt.setTaskId(3L);attempt.setSourceAccount("synthetic");attempt.setExecutionPurpose("BUSINESS");attempt.setStatus("FETCHING");attempt.setFenceToken(7L);attempt.setScopeId(100L);attempt.setWarehouseCode("A");
when(mapper.lockTask(3L)).thenReturn(child);when(mapper.lockRun(4L)).thenReturn(attempt);when(mapper.warehouseFence(eq(100L),eq(7L),eq(3L),any())).thenReturn(1);
success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES);
var context=new SelfopRequestContext("synthetic","BUSINESS",1L,"selfop-1","grant",3L,4L);
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();
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");
verify(mapper).terminateBusinessTasks(1L,"synthetic");
}
@Test void productionBusinessDoesNotRequireTestBudget(){
business();success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES);
assertThat(task.getBusinessBudgetJson()).isNull();assertThat(budget().confirmedHttp).isEqualTo(1);
verify(mapper,never()).terminateBusinessTasks(any(),any());
}
@Test void testBusinessPreparedAttemptHonorsPersistedStopAndDoesNotReportValidationComplete(){
testBusiness(5,300);var c=reserve(Operation.WAREHOUSES,1,null,null);
var b=businessBudget();b.stopReason="BUSINESS_SESSION_HTTP_LIMIT";task.setBusinessBudgetJson(SelfopSupport.json(b));task.setStatus("DEAD");
assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)))
.hasMessage("BUSINESS_SESSION_HTTP_LIMIT").satisfies(e->assertThat(((EgressRejectedException)e).sampleDone()).isFalse());
assertThat(budget().pending.consumed).isFalse();assertThat(businessBudget().confirmedHttp).isZero();
}
@Test void businessTerminationSqlIsRootScopedAndPreservesTerminalPublicationStates(){
var configuration=new org.apache.ibatis.session.Configuration();configuration.addMapper(SelfopEgressMapper.class);
var args=new HashMap<String,Object>();args.put("root",1L);args.put("account","synthetic");args.put("reason","BUSINESS_SESSION_HTTP_LIMIT");args.put("now",now());
String prefix=SelfopEgressMapper.class.getName()+".";
String tasks=configuration.getMappedStatement(prefix+"terminateBusinessTasks").getBoundSql(args).getSql();
String runs=configuration.getMappedStatement(prefix+"terminateBusinessRuns").getBoundSql(args).getSql();
assertThat(tasks).contains("business_root_task_id=? AND source_account=? AND execution_purpose='BUSINESS'", "next_retry_at_utc=NULL", "status IN ('WAITING_SOURCE','READY','RUNNING','RETRY_WAIT')");
assertThat(runs).contains("business_root_task_id=? AND source_account=? AND execution_purpose='BUSINESS'", "error_code=?", "status IN ('FETCHING','VALIDATED','YIELDED','READY','BUILDING')");
assertThat(tasks).doesNotContain("SUCCEEDED");assertThat(runs).doesNotContain("PUBLISHED","REUSED");
}
@Test void committedCompletionWithLostReplyStaysPausedAcrossIntegrationRestart(){
business();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));
@@ -24,6 +24,16 @@ class SelfopCommandServiceTest {
config.getWarehouses().put("WH-A", new SelfopAnalyticsProperties.Warehouse());
}
@Test void stoppedBusinessSessionKeepsItsCommittedTerminationAcrossCommandBoundary() throws Exception {
configure();var original=original("synthetic-account","WH-A");when(tasks.selectOne(any())).thenReturn(original);
var stopped=new SelfopTaskService.BusinessSessionStoppedException("BUSINESS_SESSION_DEADLINE");
when(taskService.retry(anyLong(),anyString(),anyString(),anyString())).thenThrow(stopped);
assertThatThrownBy(()->service.request(retry(),"42")).isSameAs(stopped);
var transaction=SelfopCommandService.class.getMethod("request",SelfopCommandService.Command.class,String.class)
.getAnnotation(org.springframework.transaction.annotation.Transactional.class);
assertThat(transaction.noRollbackFor()).contains(SelfopTaskService.BusinessSessionStoppedException.class);
}
@Test void retryCannotResumeTaskBelongingToAnotherWarehouse() {
configure();
var original = original("synthetic-account", "WH-B");
@@ -42,10 +42,13 @@ class SelfopDiscoveryServiceTest {
private SelfopOutboundDiscoveryScope account;
@BeforeEach void prepare() {
when(taskService.checkpoint(any(SelfopSyncTask.class),any(SelfopSyncRun.class),any())).thenAnswer(inv ->
new SelfopTaskService.CollectionCheckpoint(inv.getArgument(0),inv.getArgument(1)));
doAnswer(inv -> { Consumer<TransactionStatus> cb = inv.getArgument(0); cb.accept(mock(TransactionStatus.class)); return null; })
.when(tx).executeWithoutResult(any());
account = new SelfopOutboundDiscoveryScope(); account.setId(30L); account.setRequestBudget(10); account.setTimeBudgetSeconds(60);
when(accounts.selectById(30L)).thenReturn(account);
when(accounts.selectById(30L)).thenReturn(account);when(accounts.selectOne(any())).thenReturn(account);
task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000001"); task.setId(1L); task.setSourceAccount("demo"); task.setDiscoveryScopeId(30L);
task.setDiscoverySequence(17L); task.setObservationBaseline(90L); task.setTaskType("SCHEDULED"); task.setScopeHash("account-hash");
task.setQueryFromUtc(from); task.setQueryUntilUtc(until); task.setStatus("RUNNING");
@@ -76,6 +79,28 @@ class SelfopDiscoveryServiceTest {
when(client.detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), anyString())).thenAnswer(inv -> detail(inv.getArgument(3), "2026-10-11 12:00:00"));
service = new SelfopDiscoveryService(client, tasks, runs, stages, orders, scopes, accounts, taskService, leases, config, tx);
}
@Test void lateDirectoryResponseCannotPersistManifestOrStagesAfterSessionStops() {
when(client.discover(any(SelfopRequestContext.class),eq(from),eq(until),eq(1),eq(2)))
.thenReturn(page(1,2,1,1,summary("late","2026-10-11 12:00:00")));
when(taskService.checkpoint(task,run,lease)).thenThrow(new SelfopTaskService.BusinessSessionStoppedException("BUSINESS_SESSION_DEADLINE"));
assertThrows(SelfopTaskService.BusinessSessionStoppedException.class,()->service.collect(task,run,lease));
assertTrue(stored.isEmpty());verify(tasks,never()).updateById(any(SelfopSyncTask.class));verify(runs,never()).updateById(any(SelfopSyncRun.class));
}
@Test void lateDetailCannotPromotePendingStageOrRouteWarehouseReady() {
when(client.discover(any(SelfopRequestContext.class),eq(from),eq(until),eq(1),eq(2)))
.thenReturn(page(1,2,1,1,summary("late","2026-10-11 12:00:00")));
boolean[] stopped={false};
when(client.detail(any(SelfopRequestContext.class),eq("A"),eq("customer"),eq("late"))).thenAnswer(inv->{stopped[0]=true;return detail("late","2026-10-11 12:00:00");});
when(taskService.checkpoint(task,run,lease)).thenAnswer(inv->{
if(stopped[0])throw new SelfopTaskService.BusinessSessionStoppedException("BUSINESS_SESSION_DEADLINE");
return new SelfopTaskService.CollectionCheckpoint(task,run);
});
assertThrows(SelfopTaskService.BusinessSessionStoppedException.class,()->service.collect(task,run,lease));
assertTrue(stored.values().stream().allMatch(row->"PENDING".equals(row.getValidationStatus())));
assertTrue(children.values().stream().allMatch(row->"WAITING_SOURCE".equals(row.getStatus())));
assertEquals("FETCHING",run.getStatus());assertEquals("RUNNING",task.getStatus());
}
private LingxingAnalyticsClient.Summary summary(String no, String time) {
return new LingxingAnalyticsClient.Summary("demo", "customer", "A", no, null, 100, null, time);
}
@@ -32,6 +32,9 @@ class SelfopInventoryCollectorTest {
private SelfopSyncRun run;
@BeforeEach void prepare() {
when(taskService.checkpoint(any(SelfopSyncTask.class),any(SelfopSyncRun.class),any())).thenAnswer(inv ->
new SelfopTaskService.CollectionCheckpoint(inv.getArgument(0),inv.getArgument(1)));
doAnswer(invocation -> {
Consumer<TransactionStatus> callback = invocation.getArgument(0);
callback.accept(mock(TransactionStatus.class)); return null;
@@ -42,6 +45,22 @@ class SelfopInventoryCollectorTest {
run = new SelfopSyncRun(); run.setExecutionPurpose("BUSINESS"); run.setAttemptNo(1); run.setTaskId(task.getId()); run.setSourceAccount("demo"); run.setFenceToken(lease.fence()); run.setId(10L); run.setTaskId(20L); run.setStatus("FETCHING");
run.setScopeSnapshotJson("{\"quantityUnit\":\"PCS\"}"); run.setFetchStartedAtUtc(utcNow());
}
@Test void lateInventoryResponseCannotWriteStagesOrReviveStoppedAttempt() {
when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(2))).thenReturn(page(1,1,1,stock("late",BigDecimal.ONE)));
when(taskService.checkpoint(task,run,lease)).thenThrow(new SelfopTaskService.BusinessSessionStoppedException("BUSINESS_SESSION_DEADLINE"));
assertThrows(SelfopTaskService.BusinessSessionStoppedException.class,()->collector.collect(task,run,lease));
verify(stages,never()).insert(any(SelfopSyncStage.class));verify(tasks,never()).updateById(any(SelfopSyncTask.class));
verify(runs,never()).updateById(any(SelfopSyncRun.class));
}
@Test void terminationBetweenLastPageAndFinalValidationCannotSetValidated() {
when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(2))).thenReturn(page(1,1,1,stock("last",BigDecimal.ONE)));
when(taskService.checkpoint(task,run,lease)).thenReturn(new SelfopTaskService.CollectionCheckpoint(task,run))
.thenThrow(new SelfopTaskService.BusinessSessionStoppedException("BUSINESS_SESSION_DEADLINE"));
assertThrows(SelfopTaskService.BusinessSessionStoppedException.class,()->collector.collect(task,run,lease));
assertEquals("FETCHING",run.getStatus());assertNull(run.getFetchFinishedAtUtc());
verify(runs,times(1)).updateById(any(SelfopSyncRun.class));
}
private SelfopPayloads.Stock stock(String sku, BigDecimal available) {
return new SelfopPayloads.Stock("demo", "customer", "A", sku, 0, "Name",
BigDecimal.TEN, available, BigDecimal.ZERO, BigDecimal.ZERO, null);
@@ -90,6 +90,7 @@ class SelfopInventoryRecoveryTest {
doAnswer(invocation -> {
Consumer<TransactionStatus> callback = invocation.getArgument(0); callback.accept(mock(TransactionStatus.class)); return null;
}).when(tx).executeWithoutResult(any());
when(locks.lockRun(fresh.getId())).thenReturn(fresh);
var collector = new SelfopInventoryCollector(client, stages, tasks, runs, leases, service, config, tx);
assertTrue(collector.collect(task, fresh, lease));
var saved = ArgumentCaptor.forClass(SelfopSyncStage.class); verify(stages).insert(saved.capture());
@@ -366,6 +366,130 @@ class SelfopTaskServiceTest {
verify(runs,never()).updateById(any(SelfopSyncRun.class));verify(runs,never()).selectCount(any());
}
@Test void testManualRootFreezesBudgetAndContextWithoutAffectingProductionIdentity() {
config.setEnvironment("test");config.setRequestBudget(7);config.setTimeBudgetSeconds(91);
var root=task("READY");root.setRequestKey("manual:frozen");root.setRequestedBy("operator");
service.create(root);
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(budget.deadlineAtUtc).isEqualTo(budget.startedAtUtc.plusSeconds(91));
var child=task("READY");child.setId(42L);child.setBusinessRootTaskId(root.getId());
assertThat(SelfopRequestContext.forTask(child,run("FETCHING"),config).rootTaskId()).isEqualTo(root.getId());
config.setEnvironment("production");
assertThat(SelfopRequestContext.forTask(child,run("FETCHING"),config).rootTaskId()).isEqualTo(42L);
}
@Test void routedChildInheritsFrozenRootAndDoesNotGetAnIndependentBudget() {
var root=boundedRoot(0,utcNow().plusMinutes(3));
var child=task("READY");child.setUpstreamTaskId(1L);child.setRequestKey("route:1:WH-A");
child.setId(null);service.create(child);
assertThat(child.getBusinessRootTaskId()).isEqualTo(1L);assertThat(child.getBusinessBudgetJson()).isNull();
assertThat(read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class).http).isZero();
}
@Test void legacyTestTaskCannotAcquireAnImplicitUnlimitedBudget() {
config.setEnvironment("test");var root=task("READY");root.setRequestKey("manual:legacy");root.setRequestedBy("operator");
when(tasks.selectById(1L)).thenReturn(root);
assertThatThrownBy(()->service.begin(1L,lease)).hasMessage("BUSINESS_SESSION_ROOT_REQUIRED");
assertThatThrownBy(()->SelfopRequestContext.forTask(root,run("FETCHING"),config)).hasMessage("BUSINESS_SESSION_ROOT_REQUIRED");
verify(runs,never()).insert(any(SelfopSyncRun.class));assertThat(root.getBusinessBudgetJson()).isNull();
}
@Test void exactLastHttpResponseMayContinuePureLocalMergeWithoutAnotherReservation() {
var root=boundedRoot(7,utcNow().plusMinutes(3));when(scopes.selectById(10L)).thenReturn(scope("OUTBOUND"));
var manifest=new SelfopRoutingManifest();manifest.setDirectoryComplete(true);root.setRoutingManifestJson(json(manifest));
var next=service.begin(1L,lease);
assertThat(next.getStatus()).isEqualTo("FETCHING");
assertThat(read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class).http).isEqualTo(7);
verify(runs).insert(next);
}
@Test void expiredFetchCannotBeginAndManualRetryCannotRestartItsSession() {
var root=boundedRoot(2,utcNow().minusSeconds(1));
assertThatThrownBy(()->service.begin(1L,lease)).isInstanceOf(SelfopTaskService.BusinessSessionStoppedException.class);
assertThatThrownBy(()->service.retry(1L,"new-key","operator","retry"))
.isInstanceOf(SelfopTaskService.BusinessSessionStoppedException.class);
verify(runs,never()).insert(any(SelfopSyncRun.class));verify(tasks,never()).insert(any(SelfopSyncTask.class));
assertThat(read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class).http).isEqualTo(2);
}
@Test void expiredYieldAndBudgetWaitEndInsteadOfSchedulingAnotherDispatch() {
boundedRoot(2,utcNow().minusSeconds(1));
assertThatThrownBy(()->service.yield(1L,20L,lease)).isInstanceOf(SelfopTaskService.BusinessSessionStoppedException.class);
assertThatThrownBy(()->service.waitForBudget(1L,20L,utcNow().plusDays(1),lease)).isInstanceOf(SelfopTaskService.BusinessSessionStoppedException.class);
verify(tasks,never()).updateById(any(SelfopSyncTask.class));verify(runs,never()).updateById(any(SelfopSyncRun.class));
}
@Test void egressStopReasonSurvivesExecutorFailureHandling() {
var root=boundedRoot(7,utcNow().plusMinutes(3));
var budget=read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class);
budget.stopReason="BUSINESS_SESSION_HTTP_LIMIT";root.setBusinessBudgetJson(json(budget));root.setStatus("DEAD");
service.fail(1L,20L,"SELFOP_EXECUTION_FAILED",true,lease);
verify(runs,never()).updateById(any(SelfopSyncRun.class));verify(tasks,never()).updateById(any(SelfopSyncTask.class));
assertThat(read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class).stopReason).isEqualTo("BUSINESS_SESSION_HTTP_LIMIT");
}
@Test void manualRetryAuditRetainsOriginalRootAndCannotFreezeAnotherAllowance() {
var root=boundedRoot(3,utcNow().plusMinutes(3));root.setStatus("RETRY_WAIT");String frozen=root.getBusinessBudgetJson();
config.setRequestBudget(100);config.setTimeBudgetSeconds(900);
assertThat(service.retry(1L,"retry-bounded","operator","retry failed attempt")).isSameAs(root);
var saved=org.mockito.ArgumentCaptor.forClass(SelfopSyncTask.class);verify(tasks).insert(saved.capture());
assertThat(saved.getValue().getTaskType()).isEqualTo("MANUAL_RETRY");assertThat(saved.getValue().getStatus()).isEqualTo("SUCCEEDED");
assertThat(saved.getValue().getBusinessRootTaskId()).isEqualTo(1L);assertThat(saved.getValue().getBusinessBudgetJson()).isNull();
assertThat(root.getBusinessBudgetJson()).isEqualTo(frozen);assertThat(root.getStatus()).isEqualTo("READY");
}
@Test void completeLocalRebuildCanFinishAfterCollectionDeadline() {
var root=boundedRoot(7,utcNow().minusSeconds(1));root.setTaskType("REBUILD");
when(scopes.selectById(10L)).thenReturn(scope("OUTBOUND"));
assertThat(service.begin(1L,lease).getStatus()).isEqualTo("FETCHING");
assertThat(read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class).http).isEqualTo(7);
}
@Test void guardLocksAccountBeforeWarehouseAndRoot() {
boundedRoot(0,utcNow().plusMinutes(3));when(scopes.selectById(10L)).thenReturn(scope("OUTBOUND"));
service.begin(1L,lease);var order=inOrder(locks,leases);
order.verify(locks).lockDiscovery(99L);order.verify(leases).verify(lease);order.verify(locks).lockDiscovery(99L);
// This task is its own session root: guard locks the root, then begin locks the executable task.
order.verify(locks,times(2)).lockTask(1L);
}
@Test void incompleteManifestCannotUseLocalWorkExceptionAfterDeadline() {
var root=boundedRoot(1,utcNow().minusSeconds(1));when(scopes.selectById(10L)).thenReturn(scope("OUTBOUND"));
for(String manifest:List.of("{\"directoryComplete\":true}","{\"directoryComplete\":true,\"objects\":[]}",
"{\"directoryComplete\":true,\"objects\":{\"one\":{\"effectiveStageId\":\" \"}}}")) {
root.setRoutingManifestJson(manifest);
assertThatThrownBy(()->service.begin(1L,lease)).isInstanceOf(SelfopTaskService.BusinessSessionStoppedException.class);
}
verify(runs,never()).insert(any(SelfopSyncRun.class));
}
@Test void checkpointRejectsLateResponseUsingLockedPersistedStateWithoutOverwritingReason() {
var persisted=boundedRoot(7,utcNow().plusMinutes(1));persisted.setStatus("DEAD");
var budget=read(persisted.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class);
budget.stopReason="BUSINESS_SESSION_DEADLINE";persisted.setBusinessBudgetJson(json(budget));
var failed=run("FAILED");failed.setErrorCode(budget.stopReason);when(locks.lockRun(20L)).thenReturn(failed);
var responseTask=task("RUNNING");var responseRun=run("FETCHING");
assertThatThrownBy(()->service.checkpoint(responseTask,responseRun,lease)).isInstanceOf(SelfopTaskService.BusinessSessionStoppedException.class);
verify(tasks,never()).update(any(),any());verify(runs,never()).update(any(),any());
assertThat(persisted.getStatus()).isEqualTo("DEAD");assertThat(failed.getStatus()).isEqualTo("FAILED");assertThat(failed.getErrorCode()).isEqualTo("BUSINESS_SESSION_DEADLINE");
}
@Test void checkpointReturnsLockedFreshEntitiesAndAllowsLastPermittedResponsePastDeadline() {
var persisted=boundedRoot(7,utcNow().minusSeconds(1));persisted.setStatus("RUNNING");
var active=run("FETCHING");when(locks.lockRun(20L)).thenReturn(active);
var result=service.checkpoint(task("RUNNING"),run("FETCHING"),lease);
assertThat(result.task()).isSameAs(persisted);assertThat(result.run()).isSameAs(active);
assertThat(read(persisted.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class).http).isEqualTo(7);
verify(tasks,never()).update(any(),any());verify(runs,never()).update(any(),any());
}
@Test void completeLocalBuildMayWaitPastCollectionDeadlineWithoutReopeningHttp() {
var root=boundedRoot(7,utcNow().minusSeconds(1));root.setTaskType("REBUILD");root.setStatus("RUNNING");
when(scopes.selectById(10L)).thenReturn(scope("OUTBOUND"));when(locks.lockRun(20L)).thenReturn(run("BUILDING"));
service.waitForBudget(1L,20L,utcNow().plusMinutes(30),lease);
assertThat(root.getStatus()).isEqualTo("RETRY_WAIT");
assertThat(read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class).http).isEqualTo(7);
}
private SelfopSyncTask boundedRoot(int used,LocalDateTime deadline) {
config.setEnvironment("test");var root=task("READY");root.setRequestKey("manual:bounded");root.setRequestedBy("operator");root.setBusinessRootTaskId(1L);
var budget=new com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget();
budget.startedAtUtc=deadline.minusMinutes(5);budget.deadlineAtUtc=deadline;budget.maxHttp=7;budget.http=used;budget.confirmedHttp=used;
root.setBusinessBudgetJson(json(budget));when(tasks.selectById(1L)).thenReturn(root);when(locks.lockTask(1L)).thenReturn(root);
var account=new SelfopOutboundDiscoveryScope();account.setId(99L);when(accounts.selectOne(any())).thenReturn(account);
when(tasks.selectList(any())).thenReturn(List.of(root));when(locks.lockScope(10L)).thenReturn(scope("OUTBOUND"));return root;
}
private SelfopSyncTask task(String status) {
var task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000001");
task.setId(1L); task.setScopeId(10L); task.setSourceAccount("synthetic-account");