fix(selfop): isolate inventory paging and bound unstable scan retries
This commit is contained in:
+44
-1
@@ -39,12 +39,36 @@ class SelfopInventoryCollectorTest {
|
||||
Consumer<TransactionStatus> callback = invocation.getArgument(0);
|
||||
callback.accept(mock(TransactionStatus.class)); return null;
|
||||
}).when(tx).executeWithoutResult(any());
|
||||
config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setPageSize(2); config.setRequestBudget(10); config.setTimeBudgetSeconds(60);
|
||||
config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setInventoryPageSize(2); config.setRequestBudget(10); config.setTimeBudgetSeconds(60);
|
||||
collector = new SelfopInventoryCollector(client, stages, tasks, runs, leases, taskService, config, tx);
|
||||
task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000001"); task.setId(20L); task.setSourceAccount("demo"); task.setWarehouseCode("A");
|
||||
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 defaultInventoryPageSizeCollects1001RowsInTwoRequestsWithoutChangingOutboundSize() {
|
||||
var defaults=new SelfopAnalyticsProperties();
|
||||
assertEquals(100,defaults.getPageSize());assertEquals(1000,defaults.getInventoryPageSize());
|
||||
config.setInventoryPageSize(defaults.getInventoryPageSize());
|
||||
var first=new ArrayList<SelfopPayloads.Stock>();
|
||||
for(int i=0;i<1000;i++)first.add(stock("sku-"+i,BigDecimal.ONE));
|
||||
when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(1000)))
|
||||
.thenReturn(new LingxingAnalyticsClient.Page<>(first,1,1000,1001L,2));
|
||||
when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(2),eq(1000)))
|
||||
.thenReturn(new LingxingAnalyticsClient.Page<>(List.of(stock("last",BigDecimal.ZERO)),2,1000,1001L,2));
|
||||
assertTrue(collector.collect(task,run,lease));
|
||||
assertEquals(100,config.getPageSize());assertEquals(1001L,run.getUniqueObjectCount());
|
||||
assertEquals("COMPLETE",run.getCoverageStatus());assertEquals(2,run.getPageCount());
|
||||
verify(client,times(2)).inventory(any(SelfopRequestContext.class),eq("A"),anyInt(),eq(1000));
|
||||
verify(stages,times(1001)).insert(any(SelfopSyncStage.class));
|
||||
}
|
||||
@Test void invalidInventoryPageSizeRejectsBeforeAnyRequestOrStageWrite() {
|
||||
for(int size:new int[]{0,-1,1001}){
|
||||
config.setInventoryPageSize(size);
|
||||
assertEquals("SELFOP_INVENTORY_PAGE_SIZE_INVALID",assertThrows(IllegalStateException.class,
|
||||
()->collector.collect(task,run,lease)).getMessage());
|
||||
}
|
||||
verifyNoInteractions(client,stages,leases);
|
||||
}
|
||||
@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"));
|
||||
@@ -103,6 +127,25 @@ class SelfopInventoryCollectorTest {
|
||||
assertFalse(read(task.getRoutingManifestJson(), SelfopRoutingManifest.class).isDirectoryComplete());
|
||||
assertIncompleteAttemptWithPersistedProgress();
|
||||
}
|
||||
@Test void freshAttemptAfterDuplicateReReadsAllPagesAndKeepsFailedRowsOutOfItsBatch() {
|
||||
when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(2)))
|
||||
.thenReturn(page(1,3,2,stock("a",BigDecimal.ONE),stock("b",BigDecimal.ZERO)));
|
||||
when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(2),eq(2)))
|
||||
.thenReturn(page(2,3,2,stock("a",BigDecimal.ONE)),page(2,3,2,stock("c",BigDecimal.TEN)));
|
||||
assertThrows(IllegalStateException.class,()->collector.collect(task,run,lease));
|
||||
assertNull(run.getFetchFinishedAtUtc());
|
||||
// SelfopTaskService.fail/begin clear the failed cursor and allocate a fresh run.
|
||||
task.setRoutingManifestJson(null);
|
||||
var next=new SelfopSyncRun();next.setId(11L);next.setTaskId(task.getId());next.setAttemptNo(2);
|
||||
next.setExecutionPurpose("BUSINESS");next.setSourceAccount("demo");next.setStatus("FETCHING");
|
||||
next.setFetchStartedAtUtc(utcNow());next.setScopeSnapshotJson(run.getScopeSnapshotJson());
|
||||
assertTrue(collector.collect(task,next,lease));
|
||||
verify(client,times(2)).inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(2));
|
||||
var captured=ArgumentCaptor.forClass(SelfopSyncStage.class);verify(stages,times(5)).insert(captured.capture());
|
||||
assertEquals(3,captured.getAllValues().stream().filter(s->Objects.equals(s.getStageBatchId(),11L)).count());
|
||||
assertEquals(3L,next.getSourceTotalCount());assertEquals(3L,next.getUniqueObjectCount());
|
||||
assertEquals("COMPLETE",next.getCoverageStatus());assertNull(run.getCoverageStatus());
|
||||
}
|
||||
@Test void leakedWarehouseIsRejectedWithoutCompletingRun() {
|
||||
var leaked = new SelfopPayloads.Stock("demo", "customer", "B", "sku", 0, null,
|
||||
BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ZERO, BigDecimal.ZERO, null);
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ class SelfopInventoryRecoveryTest {
|
||||
var sales = new SelfopSyncScope(); sales.setId(30L); sales.setDataFamily("OUTBOUND"); sales.setPublishedRunId(80L);
|
||||
when(locks.lockTask(1L)).thenReturn(task); when(locks.lockRun(20L)).thenReturn(original);
|
||||
when(locks.lockScope(30L)).thenReturn(sales); when(scopes.selectById(10L)).thenReturn(inventory); when(scopes.selectOne(any())).thenReturn(sales);
|
||||
config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setRequestBudget(10); config.setTimeBudgetSeconds(60); config.setPageSize(2);
|
||||
config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setRequestBudget(10); config.setTimeBudgetSeconds(60); config.setInventoryPageSize(2);
|
||||
}
|
||||
|
||||
@Test void budgetYieldResumesSameAttemptWithFixedReferencesAndOriginalObservationStart() {
|
||||
|
||||
+49
@@ -468,6 +468,55 @@ class SelfopTaskServiceTest {
|
||||
assertThat(root.getStatus()).isEqualTo("DEAD");assertThat(root.getNextRetryAtUtc()).isNull();
|
||||
assertThat(run.getStatus()).isEqualTo("FAILED");assertThat(run.getErrorCode()).isEqualTo("LINGXING_WMS_RATE_LIMITED");
|
||||
}
|
||||
@Test void continuousInventoryScanFailureRetriesFromPageOneWithinFailureBudget(){
|
||||
for(String code:List.of("SELFOP_STOCK_DUPLICATE","SELFOP_STOCK_COUNT_MISMATCH","SELFOP_PAGE_TOTAL_CHANGED")) {
|
||||
var root=continuousInventoryRoot();root.setRoutingManifestJson("{\"nextPage\":6}");root.setAttemptCount(99);
|
||||
var failed=run("FETCHING");when(locks.lockRun(20L)).thenReturn(failed);
|
||||
when(runs.selectCount(any())).thenReturn(1L);config.setMaxAttempts(3);
|
||||
var before=utcNow();service.fail(1L,20L,code,true,lease);
|
||||
assertThat(root.getStatus()).isEqualTo("RETRY_WAIT");
|
||||
assertThat(root.getNextRetryAtUtc()).isBetween(before.plusSeconds(10),utcNow().plusSeconds(10));
|
||||
assertThat(root.getRoutingManifestJson()).isNull();
|
||||
assertThat(failed.getStatus()).isEqualTo("FAILED");assertThat(failed.getErrorCode()).isEqualTo(code);
|
||||
assertThat(root.getTaskType()).isEqualTo("SCHEDULED");
|
||||
assertThat(root.getPlannedAtUtc()).isEqualTo(LocalDateTime.of(2026,10,14,0,0));
|
||||
var fresh=service.begin(1L,lease);
|
||||
assertThat(fresh.getId()).isNotEqualTo(failed.getId());
|
||||
assertThat(fresh.getCoverageStatus()).isEqualTo("UNKNOWN");
|
||||
assertThat(fresh.getInventoryDailySampleDate()).isNull();
|
||||
assertThat(root.getRoutingManifestJson()).isNull();
|
||||
}
|
||||
}
|
||||
@Test void continuousInventoryScanFailureStopsAtConfiguredLimitAndClearsPersistedRetryTime(){
|
||||
var root=continuousInventoryRoot();root.setNextRetryAtUtc(utcNow().minusMinutes(1));
|
||||
when(locks.lockRun(20L)).thenReturn(run("FETCHING"));when(runs.selectCount(any())).thenReturn(3L);config.setMaxAttempts(3);
|
||||
service.fail(1L,20L,"SELFOP_STOCK_DUPLICATE",true,lease);
|
||||
assertThat(root.getStatus()).isEqualTo("DEAD");assertThat(root.getNextRetryAtUtc()).isNull();
|
||||
verify(tasks,atLeastOnce()).update(isNull(),argThat((com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<SelfopSyncTask> update)->
|
||||
update.getSqlSet().contains("next_retry_at_utc")));
|
||||
}
|
||||
@Test void continuousInventoryProtectionAndIdentityErrorsNeverAutomaticallyRetry(){
|
||||
for(String code:List.of("LINGXING_WMS_RATE_LIMITED","LINGXING_WMS_AUTHORIZATION_REJECTED",
|
||||
"INTEGRATION_TRANSPORT_FAILURE","SELFOP_STOCK_SCOPE_LEAK","SELFOP_STOCK_IDENTITY_MISSING","SELFOP_EXECUTION_FAILED")) {
|
||||
var root=continuousInventoryRoot();when(locks.lockRun(20L)).thenReturn(run("FETCHING"));when(runs.selectCount(any())).thenReturn(1L);
|
||||
service.fail(1L,20L,code,true,lease);
|
||||
assertThat(root.getStatus()).as(code).isEqualTo("DEAD");assertThat(root.getNextRetryAtUtc()).isNull();
|
||||
}
|
||||
var root=continuousInventoryRoot();when(locks.lockRun(20L)).thenReturn(run("FETCHING"));
|
||||
service.fail(1L,20L,"SELFOP_STOCK_DUPLICATE",false,lease);
|
||||
assertThat(root.getStatus()).isEqualTo("DEAD");
|
||||
}
|
||||
private SelfopSyncTask continuousInventoryRoot(){
|
||||
var root=boundedRoot(0,utcNow().plusMinutes(3));config.setContinuousBusinessCollectionEnabled(true);
|
||||
root.setTaskType("SCHEDULED");root.setStatus("RUNNING");root.setApplyState("NOT_APPLICABLE");
|
||||
root.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);root.setScopeHash("b".repeat(64));
|
||||
root.setPlannedAtUtc(LocalDateTime.of(2026,10,14,0,0));
|
||||
root.setRequestKey("continuous:inventory:WH-A:2026-10-14T00:00:"+"b".repeat(12));
|
||||
root.setQueryScopeJson(json(Map.of("dataFamily","INVENTORY","businessTimezone","Asia/Shanghai","sourceAccount","synthetic-account","warehouseCode","WH-A")));
|
||||
root.setBusinessBudgetJson(com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.freezeContinuous(utcNow()));
|
||||
when(scopes.selectById(10L)).thenReturn(scope("INVENTORY"));
|
||||
return root;
|
||||
}
|
||||
@Test void egressStopReasonSurvivesExecutorFailureHandling() {
|
||||
var root=boundedRoot(7,utcNow().plusMinutes(3));
|
||||
var budget=read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class);
|
||||
|
||||
Reference in New Issue
Block a user