feat(ft546): persist shadow comparison evidence
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
|
||||
import com.ruoyi.ft546.protocol.Ft546EncodedCommand;
|
||||
import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
|
||||
@@ -9,6 +10,13 @@ import com.ruoyi.shadow.facts.ShadowFactBatchValidator;
|
||||
import com.ruoyi.shadow.facts.ShadowMapFact;
|
||||
import com.ruoyi.shadow.facts.ShadowQueueFact;
|
||||
import com.ruoyi.shadow.facts.ShadowVehicleFact;
|
||||
import com.ruoyi.shadow.evidence.ShadowCycleEvidence;
|
||||
import com.ruoyi.shadow.evidence.ShadowCycleEvidenceCollector;
|
||||
import com.ruoyi.shadow.evidence.ShadowEvidenceDigest;
|
||||
import com.ruoyi.shadow.evidence.ShadowEvidenceException;
|
||||
import com.ruoyi.shadow.evidence.ShadowEvidenceRepository;
|
||||
import com.ruoyi.shadow.evidence.ShadowEvidenceStorageHealth;
|
||||
import com.ruoyi.shadow.startup.ShadowStartupReport;
|
||||
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
|
||||
import com.ruoyi.wcs.application.ShadowDispatchResult;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
|
||||
@@ -36,10 +44,13 @@ import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class Ft546ShadowCycleTest {
|
||||
|
||||
@@ -220,6 +231,168 @@ class Ft546ShadowCycleTest {
|
||||
assertEquals(1, gateway.getRecorded().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeCyclePersistsEvidenceInsideOuterTransactionBeforeRecording() {
|
||||
Instant now = Instant.parse("2026-08-14T06:00:00Z");
|
||||
RecordingEvidenceRepository evidence =
|
||||
new RecordingEvidenceRepository();
|
||||
ShadowStartupReport report = ShadowStartupReport.starting(now,
|
||||
Collections.emptyMap());
|
||||
RecordingFt546CommandGateway gateway = gateway();
|
||||
Ft546ShadowCycle cycle = evidenceCycle(now, evidence, report, gateway);
|
||||
ShadowFactBatch batch = ShadowFactBatch.create(now.minusSeconds(1), now,
|
||||
Collections.singletonList(queueWithoutLegacyAssignment(now)),
|
||||
Collections.singletonList(vehicle(now)), maps());
|
||||
|
||||
ShadowCycleResult result = cycle.run(batch, now);
|
||||
|
||||
assertEquals("COMPARABLE", result.getStatusCode());
|
||||
assertEquals(1, evidence.saved.size());
|
||||
assertEquals(1, gateway.getRecorded().size());
|
||||
assertTrue(evidence.dispatchObservedInsideTransaction);
|
||||
assertEquals(ShadowStartupReport.Status.READY, report.getStatus());
|
||||
assertEquals(now, report.getLastCompletedCycle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void evidenceFailureRecordsNoCommandAndStopsLaterCalculation() {
|
||||
Instant now = Instant.parse("2026-08-14T06:00:00Z");
|
||||
RecordingEvidenceRepository evidence =
|
||||
new RecordingEvidenceRepository();
|
||||
evidence.saveFailure = new ShadowEvidenceException(
|
||||
"EVIDENCE_WRITE_FAILED");
|
||||
ShadowStartupReport report = ShadowStartupReport.starting(now,
|
||||
Collections.emptyMap());
|
||||
RecordingFt546CommandGateway gateway = gateway();
|
||||
Ft546ShadowCycle cycle = evidenceCycle(now, evidence, report, gateway);
|
||||
ShadowFactBatch batch = ShadowFactBatch.create(now.minusSeconds(1), now,
|
||||
Collections.singletonList(queueWithoutLegacyAssignment(now)),
|
||||
Collections.singletonList(vehicle(now)), maps());
|
||||
|
||||
ShadowCycleResult first = cycle.run(batch, now);
|
||||
ShadowCycleResult second = cycle.run(batch, now.plusSeconds(1));
|
||||
|
||||
assertEquals("DEGRADED", first.getStatusCode());
|
||||
assertEquals("EVIDENCE_STORAGE_UNAVAILABLE", first.getReasonCode());
|
||||
assertEquals("DEGRADED", second.getStatusCode());
|
||||
assertEquals(1, evidence.transactionCalls.get());
|
||||
assertEquals(Collections.emptyList(), gateway.getRecorded());
|
||||
assertEquals(ShadowStartupReport.Status.DEGRADED,
|
||||
report.getStatus());
|
||||
assertEquals("EVIDENCE_STORAGE_UNAVAILABLE",
|
||||
report.getReasonCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void evidenceCollisionKeepsItsDistinctFailClosedReason() {
|
||||
Instant now = Instant.parse("2026-08-14T06:00:00Z");
|
||||
RecordingEvidenceRepository evidence =
|
||||
new RecordingEvidenceRepository();
|
||||
evidence.saveFailure = new ShadowEvidenceException(
|
||||
"EVIDENCE_KEY_COLLISION");
|
||||
ShadowStartupReport report = ShadowStartupReport.starting(now,
|
||||
Collections.emptyMap());
|
||||
RecordingFt546CommandGateway gateway = gateway();
|
||||
Ft546ShadowCycle cycle = evidenceCycle(now, evidence, report, gateway);
|
||||
ShadowFactBatch batch = ShadowFactBatch.create(now.minusSeconds(1), now,
|
||||
Collections.singletonList(queueWithoutLegacyAssignment(now)),
|
||||
Collections.singletonList(vehicle(now)), maps());
|
||||
|
||||
ShadowCycleResult first = cycle.run(batch, now);
|
||||
ShadowCycleResult second = cycle.run(batch, now.plusSeconds(1));
|
||||
|
||||
assertEquals("DEGRADED", first.getStatusCode());
|
||||
assertEquals("EVIDENCE_KEY_COLLISION", first.getReasonCode());
|
||||
assertEquals("EVIDENCE_KEY_COLLISION", second.getReasonCode());
|
||||
assertEquals("EVIDENCE_KEY_COLLISION", report.getReasonCode());
|
||||
assertEquals(1, evidence.transactionCalls.get());
|
||||
assertEquals(Collections.emptyList(), gateway.getRecorded());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unhealthyStorageDegradesBeforeAssemblyAndIsNotRechecked() {
|
||||
Instant now = Instant.parse("2026-08-14T06:00:00Z");
|
||||
AtomicInteger healthChecks = new AtomicInteger();
|
||||
AtomicInteger assemblyCalls = new AtomicInteger();
|
||||
AtomicInteger dispatchCalls = new AtomicInteger();
|
||||
ShadowEvidenceStorageHealth health = () -> {
|
||||
healthChecks.incrementAndGet();
|
||||
return ShadowEvidenceStorageHealth.Check.unavailable(
|
||||
"FREE_SPACE_BELOW_MINIMUM");
|
||||
};
|
||||
RecordingEvidenceRepository evidence =
|
||||
new RecordingEvidenceRepository();
|
||||
ShadowStartupReport report = ShadowStartupReport.starting(now,
|
||||
Collections.emptyMap());
|
||||
Ft546ShadowCycle cycle = new Ft546ShadowCycle(
|
||||
new ShadowFactBatchValidator(Duration.ofSeconds(4),
|
||||
Duration.ofSeconds(30)),
|
||||
batch -> {
|
||||
assemblyCalls.incrementAndGet();
|
||||
throw new AssertionError("must not assemble");
|
||||
},
|
||||
new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(snapshot -> {
|
||||
dispatchCalls.incrementAndGet();
|
||||
return DispatchCycleResult.retrySnapshotChanged();
|
||||
}, new ShadowCommandIntentFactory()::create),
|
||||
gateway(), health, evidence,
|
||||
collector(evidence.runId, 3L), report);
|
||||
ShadowFactBatch batch = ShadowFactBatch.create(now.minusSeconds(1), now,
|
||||
Collections.singletonList(queueWithoutLegacyAssignment(now)),
|
||||
Collections.singletonList(vehicle(now)), maps());
|
||||
|
||||
ShadowCycleResult first = cycle.run(batch, now);
|
||||
ShadowCycleResult second = cycle.run(batch, now.plusSeconds(1));
|
||||
|
||||
assertEquals("DEGRADED", first.getStatusCode());
|
||||
assertEquals("EVIDENCE_STORAGE_UNAVAILABLE", first.getReasonCode());
|
||||
assertEquals("DEGRADED", second.getStatusCode());
|
||||
assertEquals(1, healthChecks.get());
|
||||
assertEquals(0, assemblyCalls.get());
|
||||
assertEquals(0, dispatchCalls.get());
|
||||
assertEquals(0, evidence.transactionCalls.get());
|
||||
}
|
||||
|
||||
private static Ft546ShadowCycle evidenceCycle(
|
||||
Instant now, RecordingEvidenceRepository evidence,
|
||||
ShadowStartupReport report,
|
||||
RecordingFt546CommandGateway gateway) {
|
||||
RoutePlanner routePlanner = new AStarRoutePlanner();
|
||||
DispatchCoordinator coordinator = new DispatchCoordinator(
|
||||
new DefaultVehicleAssignmentPolicy(routePlanner,
|
||||
DispatchPolicy.NONE),
|
||||
new DeadlockGuard(new WaitForGraph(), routePlanner),
|
||||
commit -> {
|
||||
evidence.dispatchObservedInsideTransaction =
|
||||
evidence.insideTransaction;
|
||||
return true;
|
||||
}, () -> "SHADOW-1");
|
||||
ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> readOnly =
|
||||
new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(
|
||||
coordinator::dispatch,
|
||||
new ShadowCommandIntentFactory()::create);
|
||||
ShadowDispatchSnapshotAssembler assembler =
|
||||
new ShadowDispatchSnapshotAssembler("FT546", 3L,
|
||||
topology -> new ShadowTrafficState(
|
||||
new ResourceLedger(topology),
|
||||
new WaitForGraph(), 0L,
|
||||
Collections.emptyMap()),
|
||||
Clock.fixed(now, ZoneOffset.UTC));
|
||||
return new Ft546ShadowCycle(
|
||||
new ShadowFactBatchValidator(Duration.ofSeconds(4),
|
||||
Duration.ofSeconds(30)),
|
||||
assembler::assemble, readOnly, gateway,
|
||||
() -> ShadowEvidenceStorageHealth.Check.available(
|
||||
Long.MAX_VALUE),
|
||||
evidence, collector(evidence.runId, 3L), report);
|
||||
}
|
||||
|
||||
private static ShadowCycleEvidenceCollector collector(
|
||||
UUID runId, long routeVersion) {
|
||||
return new ShadowCycleEvidenceCollector(runId, routeVersion,
|
||||
new ObjectMapper().findAndRegisterModules());
|
||||
}
|
||||
|
||||
private static Ft546EncodedCommand encode(GrantedDispatch grant) {
|
||||
RecordingFt546CommandGateway gateway = gateway();
|
||||
Ft546SrgvCommandIntent intent =
|
||||
@@ -359,4 +532,66 @@ class Ft546ShadowCycleTest {
|
||||
new ShadowMapFact("MAP-1", "70501", 0, 0, 1, 1),
|
||||
new ShadowMapFact("MAP-2", "70502", 1, 0, 1, 1));
|
||||
}
|
||||
|
||||
private static final class RecordingEvidenceRepository
|
||||
implements ShadowEvidenceRepository {
|
||||
private final UUID runId = UUID.randomUUID();
|
||||
private final List<ShadowCycleEvidence> saved =
|
||||
new java.util.ArrayList<ShadowCycleEvidence>();
|
||||
private final AtomicInteger transactionCalls = new AtomicInteger();
|
||||
private boolean insideTransaction;
|
||||
private boolean dispatchObservedInsideTransaction;
|
||||
private RuntimeException saveFailure;
|
||||
|
||||
@Override
|
||||
public SavedCycle save(ShadowCycleEvidence evidence) {
|
||||
return saveAll(Collections.singletonList(evidence)).get(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SavedCycle> saveAll(List<ShadowCycleEvidence> values) {
|
||||
assertTrue(insideTransaction,
|
||||
"evidence must be saved inside the outer transaction");
|
||||
if (saveFailure != null) {
|
||||
throw saveFailure;
|
||||
}
|
||||
List<SavedCycle> result = new java.util.ArrayList<SavedCycle>();
|
||||
for (ShadowCycleEvidence value : values) {
|
||||
saved.add(value);
|
||||
result.add(new SavedCycle(value.getCycleId(),
|
||||
value.getEvidenceHash(), value.getStatus(),
|
||||
value.getReasonCode()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T inTransaction(Supplier<T> work) {
|
||||
transactionCalls.incrementAndGet();
|
||||
insideTransaction = true;
|
||||
try {
|
||||
return work.get();
|
||||
} finally {
|
||||
insideTransaction = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public SavedCycle findCycle(UUID runId, String inputBatchHash,
|
||||
String parentTaskId, String phase,
|
||||
long routeVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ShadowEvidenceDigest dailyDigest(
|
||||
java.time.LocalDate date, java.time.ZoneId zoneId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isHealthy() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user