feat(ft546): persist shadow comparison evidence

This commit is contained in:
zgw
2026-08-14 17:25:52 +08:00
parent 6864010832
commit 240f5b0808
33 changed files with 3228 additions and 26 deletions

View File

@@ -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;
}
}
}