perf(selfop): batch list checkpoints and publish each completed day

This commit is contained in:
2026-10-14 10:09:16 +08:00
parent 674914c651
commit 6193043880
8 changed files with 143 additions and 17 deletions
@@ -173,13 +173,6 @@ class OutboundCandidateBuilderTest {
assertNull(OutboundCandidateBuilder.class.getMethod("build",Long.class,Long.class,String.class,long.class).getAnnotation(org.springframework.transaction.annotation.Transactional.class));
for(String name:List.of("begin","batch","finish"))assertNotNull(OutboundCandidateTransaction.class.getMethod(name,Long.class,Long.class,String.class,long.class).getAnnotation(org.springframework.transaction.annotation.Transactional.class));
}
@Test void onlyRoutineFirstCollectionIsCoalesced() {
task.setTaskType("FIRST_COLLECTION"); task.setAttemptCount(1); LocalDateTime now=SelfopSupport.utcNow();
assertFalse(OutboundCandidateBuilder.regularBuildDue(task,now.minusMinutes(29),now));
assertTrue(OutboundCandidateBuilder.regularBuildDue(task,now.minusMinutes(30),now));
task.setTaskType("REBUILD"); assertTrue(OutboundCandidateBuilder.regularBuildDue(task,now,now));
task.setTaskType("FIRST_COLLECTION"); task.setAttemptCount(2); assertTrue(OutboundCandidateBuilder.regularBuildDue(task,now,now));
}
@Test void missingWarehouseSummaryPreventsReadyAndOldReadyCannotBypassBuild() {
transaction.begin(1L,10L,"worker",2);
var row=new SelfopSkuDailySales();row.setCustomerCode("c");row.setSkuCode("sku");row.setBusinessDate(LocalDate.of(2026,10,1));
@@ -378,6 +378,54 @@ class SelfopDiscoveryServiceTest {
assertEquals("ROUTED",run.getStatus());
assertEquals(1,read(task.getRoutingManifestJson(),SelfopRoutingManifest.class).getListPayloadVersion());
}
private void prepareCompleteBatch(int count) {
var manifest=new SelfopRoutingManifest();manifest.setListPayloadVersion(1);manifest.setDirectoryComplete(true);manifest.setPages(1);manifest.setTotal((long)count);
for(int i=0;i<count;i++) {
var payload=detail("batch-"+i,"2026-10-11 12:00:00");String key=hash(List.of("demo","customer",payload.deliveryNo()));
var stage=new SelfopSyncStage();stage.setId(1000L+i);stage.setRunId(run.getId());stage.setStageBatchId(task.getId());stage.setObjectKeyHash(key);
stage.setValidationStatus("PENDING");stage.setFirstPageNo(1);stage.setPayloadJson(json(payload));stage.setCreatedAt(from);stored.put(stage.getId(),stage);
var ref=new SelfopRoutingManifest.Entry();ref.setCustomerCode("customer");ref.setWarehouseCode("A");ref.setDeliveryNo(payload.deliveryNo());ref.setDirectoryStageId(stage.getId());manifest.getObjects().put(key,ref);
}
task.setRoutingManifestJson(json(manifest));
when(stages.selectList(any())).thenAnswer(inv->{
QueryWrapper<SelfopSyncStage> q=inv.getArgument(0);String sql=q.getSqlSegment();var values=q.getParamNameValuePairs().values();
if(sql.contains("observation_sequence"))return List.of();
return stored.values().stream().filter(s->values.contains(s.getId())).toList();
});
long[] next={101};when(leases.nextObservations(eq(lease),anyInt())).thenAnswer(inv->{long first=next[0];next[0]+=inv.<Integer>getArgument(1);return first;});
}
@Test void completeListsCommitBoundedBatchesInsteadOfPerOrderManifests() {
prepareCompleteBatch(205);service.collect(task,run,lease);
verifyNoInteractions(client);verify(leases,times(2)).nextObservations(lease,100);verify(leases).nextObservations(lease,5);
verify(tasks,times(4)).updateById(argThat((SelfopSyncTask t)->task.getId().equals(t.getId())));
assertEquals(205,stored.values().stream().filter(s->"VALID".equals(s.getValidationStatus())).count());
assertEquals(205,stored.values().stream().map(SelfopSyncStage::getObservationSequence).distinct().count());
assertEquals(101L,stored.values().stream().mapToLong(SelfopSyncStage::getObservationSequence).min().orElseThrow());
assertEquals(305L,stored.values().stream().mapToLong(SelfopSyncStage::getObservationSequence).max().orElseThrow());
assertEquals("ROUTED",run.getStatus());assertTrue(children.values().stream().allMatch(c->"READY".equals(c.getStatus())));
}
@Test void batchYieldResumesOnlyUncommittedRowsWithoutSupplierRequests() {
prepareCompleteBatch(205);
try(var budgets=mockConstruction(SelfopBudget.class,(b,c)->when(b.hasTime()).thenReturn(true,false))){service.collect(task,run,lease);}
assertEquals(100,stored.values().stream().filter(s->"VALID".equals(s.getValidationStatus())).count());
assertTrue(children.values().stream().allMatch(c->"WAITING_SOURCE".equals(c.getStatus())));
service.collect(task,run,lease);verifyNoInteractions(client);
verify(stages,times(205)).updateById(any(SelfopSyncStage.class));assertEquals("ROUTED",run.getStatus());
}
@Test void stoppedBatchCannotPromoteRowsOrAllocateObservations() {
prepareCompleteBatch(100);int[] checkpoints={0};
when(taskService.checkpoint(task,run,lease)).thenAnswer(inv->{if(++checkpoints[0]>1)throw new SelfopTaskService.BusinessSessionStoppedException("STOPPED");return new SelfopTaskService.CollectionCheckpoint(task,run);});
assertThrows(SelfopTaskService.BusinessSessionStoppedException.class,()->service.collect(task,run,lease));
verify(leases,never()).nextObservations(any(),anyInt());verify(stages,never()).updateById(any(SelfopSyncStage.class));
assertTrue(stored.values().stream().allMatch(s->"PENDING".equals(s.getValidationStatus())));verifyNoInteractions(client);
}
@Test void incompleteRowInBatchStillUsesDetailAndPreservesCompleteRows() {
prepareCompleteBatch(3);var missing=stored.get(1001L);var p=read(missing.getPayloadJson(),SelfopPayloads.Order.class);
missing.setPayloadJson(json(new SelfopPayloads.Order(p.sourceAccount(),p.customerCode(),p.warehouseCode(),p.deliveryNo(),p.sourceNo(),p.sourceStatus(),p.sourceCreatedTime(),p.sourceOutboundTime(),null)));
service.collect(task,run,lease);verify(client).detail(any(),eq("A"),eq("customer"),eq("batch-1"));
verify(client,never()).detail(any(),eq("A"),eq("customer"),eq("batch-0"));verify(client,never()).detail(any(),eq("A"),eq("customer"),eq("batch-2"));
assertTrue(stored.values().stream().allMatch(s->"VALID".equals(s.getValidationStatus())));
}
@Test void validationPurposeCannotReachAccountRoutingOrFormalCoverage() {
task.setExecutionPurpose("VALIDATION");run.setExecutionPurpose("VALIDATION");
assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED",assertThrows(IllegalStateException.class,()->service.collect(task,run,lease)).getMessage());
@@ -10,6 +10,21 @@ import java.time.LocalDateTime;
import static org.mockito.Mockito.*;
class SelfopExecutionServiceTest {
@Test void completedDayBuildsAndPublishesImmediatelyWithoutBuildIntervalWait() {
var config=new SelfopAnalyticsProperties();config.setCollectionMode("BUSINESS");config.setEnvironment("production");config.setSalesPublicationEnabled(true);
var taskService=mock(SelfopTaskService.class);var leases=mock(SelfopLeaseService.class);var tasks=mock(SelfopSyncTaskMapper.class);var scopes=mock(SelfopSyncScopeMapper.class);
var task=new SelfopSyncTask();task.setId(1L);task.setExecutionPurpose("BUSINESS");task.setStatus("READY");task.setTaskRole("WAREHOUSE_SYNC");task.setTaskType("BACKFILL");task.setScopeId(3L);task.setWarehouseCode("A");task.setApplyState("APPLIED");task.setAttemptCount(1);task.setRoutingManifestJson("{\"objects\":{}}");
var scope=new SelfopSyncScope();scope.setId(3L);scope.setWarehouseCode("A");scope.setDataFamily("OUTBOUND");var run=new SelfopSyncRun();run.setId(2L);
var lease=new SelfopLeaseService.Lease(3L,"worker",8L,false);
when(tasks.selectById(1L)).thenReturn(task);when(scopes.selectById(3L)).thenReturn(scope);when(scopes.selectOne(any())).thenReturn(scope);
when(leases.acquireScopes(any(),anyString())).thenReturn(java.util.Map.of(3L,lease));when(taskService.begin(1L,lease)).thenReturn(run);
var candidates=mock(OutboundCandidateBuilder.class);var publisher=mock(SelfopSalesPublisher.class);var runs=mock(SelfopSyncRunMapper.class);
when(candidates.reusable(anyLong(),anyLong(),anyString(),anyLong())).thenReturn(null);
var service=new SelfopExecutionService(config,taskService,leases,mock(SelfopDiscoveryService.class),mock(SelfopInventoryCollector.class),mock(InventorySnapshotService.class),mock(OutboundFactService.class),candidates,publisher,scopes,tasks,mock(SelfopSyncStageMapper.class),mock(SelfopOutboundOrderMapper.class),mock(SelfopReferenceRecovery.class),mock(SelfopValidationRunner.class),runs);
service.execute(1L);
var order=inOrder(candidates,publisher);order.verify(candidates).build(eq(3L),eq(2L),anyString(),eq(8L));order.verify(publisher).publish(eq(3L),eq(2L),anyString(),eq(8L));
verify(taskService,never()).waitForBudget(anyLong(),anyLong(),any(),any());verifyNoInteractions(runs);
}
@Test void unexpectedValidationFailureTerminatesSessionInsteadOfSchedulingRetry() {
executeValidationFailure(new IllegalStateException("VALIDATION_STRUCTURE_REJECTED"),"VALIDATION_STRUCTURE_REJECTED");
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import org.junit.jupiter.api.Test;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.utcNow;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class SelfopObservationReservationTest {
@Test void consecutiveBatchReservationsRetainFenceAndSingleObservationSemantics() {
var locks=mock(SelfopCoordinationMapper.class);var accounts=mock(SelfopOutboundDiscoveryScopeMapper.class);
var row=new SelfopOutboundDiscoveryScope();row.setId(1L);row.setLeaseOwner("worker");row.setFenceToken(2L);
row.setLeaseUntilUtc(utcNow().plusMinutes(1));row.setNextObservationSequence(101L);when(locks.lockDiscovery(1L)).thenReturn(row);
var service=new SelfopLeaseService(locks,mock(SelfopSyncScopeMapper.class),accounts,new SelfopAnalyticsProperties());
var lease=new SelfopLeaseService.Lease(1L,"worker",2L,true);
assertEquals(101L,service.nextObservations(lease,100));assertEquals(201L,service.nextObservation(lease));assertEquals(202L,row.getNextObservationSequence());
assertThrows(IllegalStateException.class,()->service.nextObservations(lease,0));assertThrows(IllegalStateException.class,()->service.nextObservations(lease,101));
assertThrows(IllegalStateException.class,()->service.nextObservations(new SelfopLeaseService.Lease(1L,"old-worker",1L,true),10));
assertEquals(202L,row.getNextObservationSequence());verify(accounts,times(2)).updateById(row);
}
}