fix(shadow): recover completed read timeouts and pair issued observations
This commit is contained in:
@@ -139,7 +139,7 @@ class Ft546ShadowApplicationTest {
|
||||
.andExpect(jsonPath("$.status").value("STARTING"))
|
||||
.andExpect(jsonPath("$.checkedAt").exists())
|
||||
.andExpect(jsonPath("$.evidenceBacklog").value(0))
|
||||
.andExpect(jsonPath("$.runtimeIdentity.buildId").value("ft546-shadow-20260908-phase-storage-v2"))
|
||||
.andExpect(jsonPath("$.runtimeIdentity.buildId").value("ft546-shadow-20260909-recovery-pairing-v3"))
|
||||
.andExpect(jsonPath("$.runtimeIdentity.effectiveSettingsSha256").exists())
|
||||
.andExpect(jsonPath("$.runtimeIdentity.safePointConfiguration").value("UNCONFIGURED"))
|
||||
.andExpect(jsonPath("$.startupChecks.SHADOW_ENABLED")
|
||||
|
||||
@@ -116,7 +116,7 @@ class Ft546ShadowFaultIsolationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryAndPermanentFailurePublishDifferentCollectionStates() {
|
||||
void completedSourceFailuresPublishRetryableCollectionState() {
|
||||
for (boolean timeout : new boolean[] {false, true}) {
|
||||
MutableClock clock = new MutableClock(NOW);
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
@@ -137,7 +137,7 @@ class Ft546ShadowFaultIsolationTest {
|
||||
assertFalse(scheduler.runOnce());
|
||||
Map<String, Object> failed = heartbeats.get(heartbeats.size() - 1);
|
||||
assertEquals("DEGRADED", failed.get("status"));
|
||||
assertEquals(Boolean.valueOf(timeout), failed.get("collectionStopped"));
|
||||
assertEquals(Boolean.FALSE, failed.get("collectionStopped"));
|
||||
assertEquals(null, failed.get("lastCompletedCycle"));
|
||||
clock.advance(Duration.ofMillis(100));
|
||||
scheduler.runScheduledCycle();
|
||||
@@ -172,7 +172,7 @@ class Ft546ShadowFaultIsolationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceQueryTimeoutPermanentlyStopsCollectionBeforeCycle() {
|
||||
void completedSourceQueryTimeoutBacksOffWithoutRunningCycle() {
|
||||
MutableClock clock = new MutableClock(NOW);
|
||||
AtomicInteger sourceCalls = new AtomicInteger();
|
||||
SqlServerShadowFactReader reader = reader(timeoutSource(sourceCalls), clock);
|
||||
@@ -197,17 +197,18 @@ class Ft546ShadowFaultIsolationTest {
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
|
||||
assertFalse(scheduler.runOnce());
|
||||
assertEquals(1, sourceCalls.get());
|
||||
assertEquals(2, sourceCalls.get());
|
||||
assertEquals(0, evidence.transactionCalls.get());
|
||||
assertEquals(Collections.emptyList(), gateway.getRecorded());
|
||||
assertEquals(ShadowStartupReport.Status.DEGRADED,
|
||||
report.getStatus());
|
||||
assertEquals("SOURCE_QUERY_TIMEOUT", report.getReasonCode());
|
||||
assertTrue(scheduler.isPermanentlyStopped());
|
||||
assertFalse(scheduler.isPermanentlyStopped());
|
||||
assertEquals(Duration.ofSeconds(2), scheduler.getRemainingBackoff());
|
||||
assertTrue(logAppender.list.stream().anyMatch(event ->
|
||||
event.getFormattedMessage()
|
||||
.contains("stage=SOURCE_READ")),
|
||||
"source timeout log must identify the blocked stage");
|
||||
.contains("retry after")),
|
||||
"completed source timeout must report the retry delay");
|
||||
} finally {
|
||||
schedulerLogger.detachAppender(logAppender);
|
||||
logAppender.stop();
|
||||
@@ -216,7 +217,7 @@ class Ft546ShadowFaultIsolationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceBatchDeadlinePermanentlyStopsCollection() {
|
||||
void completedSourceBatchDeadlineBacksOff() {
|
||||
MutableClock clock = new MutableClock(NOW);
|
||||
AtomicInteger sourceCalls = new AtomicInteger();
|
||||
SqlServerShadowFactReader.FactSource source = new FactSourceAdapter() {
|
||||
@@ -241,17 +242,18 @@ class Ft546ShadowFaultIsolationTest {
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
|
||||
assertFalse(scheduler.runOnce());
|
||||
assertEquals(1, sourceCalls.get());
|
||||
assertEquals(2, sourceCalls.get());
|
||||
assertEquals("SOURCE_BATCH_DEADLINE_EXCEEDED",
|
||||
report.getReasonCode());
|
||||
assertTrue(scheduler.isPermanentlyStopped());
|
||||
assertFalse(scheduler.isPermanentlyStopped());
|
||||
assertEquals(Duration.ofSeconds(2), scheduler.getRemainingBackoff());
|
||||
} finally {
|
||||
scheduler.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrappedSourceQueryTimeoutPermanentlyStopsCollection() {
|
||||
void completedWrappedSourceQueryTimeoutBacksOff() {
|
||||
MutableClock clock = new MutableClock(NOW);
|
||||
AtomicInteger sourceCalls = new AtomicInteger();
|
||||
SqlServerShadowFactReader.FactSource source = new FactSourceAdapter() {
|
||||
@@ -277,9 +279,10 @@ class Ft546ShadowFaultIsolationTest {
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
|
||||
assertFalse(scheduler.runOnce());
|
||||
assertEquals(1, sourceCalls.get());
|
||||
assertEquals(2, sourceCalls.get());
|
||||
assertEquals("SOURCE_QUERY_TIMEOUT", report.getReasonCode());
|
||||
assertTrue(scheduler.isPermanentlyStopped());
|
||||
assertFalse(scheduler.isPermanentlyStopped());
|
||||
assertEquals(Duration.ofSeconds(2), scheduler.getRemainingBackoff());
|
||||
} finally {
|
||||
scheduler.close();
|
||||
}
|
||||
@@ -511,6 +514,39 @@ class Ft546ShadowFaultIsolationTest {
|
||||
Ft546ShadowFaultIsolationTest::isOutboundEndpointKey));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceWorkerThatIgnoresCancellationIsNeverRetried() {
|
||||
MutableClock clock = new MutableClock(NOW);
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
SqlServerShadowFactReader.FactSource source = new FactSourceAdapter() {
|
||||
@Override public Instant readDatabaseClock(Instant deadline) {
|
||||
calls.incrementAndGet();
|
||||
boolean interrupted = false;
|
||||
while (release.getCount() != 0) {
|
||||
try { release.await(); }
|
||||
catch (InterruptedException ignored) { interrupted = true; }
|
||||
}
|
||||
if (interrupted) Thread.currentThread().interrupt();
|
||||
return clock.instant();
|
||||
}
|
||||
};
|
||||
ShadowStartupReport report = report();
|
||||
try (Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(reader(source, clock),
|
||||
evidenceCycle(report, new RecordingEvidenceRepository(), gateway(),
|
||||
() -> ShadowEvidenceStorageHealth.Check.available(Long.MAX_VALUE)),
|
||||
report, clock, Duration.ofSeconds(1))) {
|
||||
assertFalse(scheduler.runOnce());
|
||||
assertEquals("SHADOW_CYCLE_TIMEOUT", report.getReasonCode());
|
||||
assertTrue(scheduler.isPermanentlyStopped());
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
assertFalse(scheduler.runOnce());
|
||||
assertEquals(1, calls.get());
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void longCycleDeadlineKeepsSourceReadDeadlineCappedAtFourSeconds() {
|
||||
MutableClock clock = new MutableClock(NOW);
|
||||
@@ -578,6 +614,93 @@ class Ft546ShadowFaultIsolationTest {
|
||||
assertEquals("", observed.get(1).getLastGuid());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedTimeoutRecoversOnlyAfterBackoffAndResetsFailureCount() {
|
||||
MutableClock clock = new MutableClock(NOW);
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
ShadowStartupReport report = report();
|
||||
SqlServerShadowFactReader.FactSource source = new FactSourceAdapter() {
|
||||
@Override public Instant readDatabaseClock(Instant deadline) {
|
||||
if (calls.incrementAndGet() == 1) throw new QueryTimeoutException("completed timeout");
|
||||
return clock.instant();
|
||||
}
|
||||
};
|
||||
try (Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(reader(source, clock),
|
||||
evidenceCycle(report, new RecordingEvidenceRepository(), gateway(),
|
||||
() -> ShadowEvidenceStorageHealth.Check.available(Long.MAX_VALUE)), report, clock)) {
|
||||
assertFalse(scheduler.runOnce());
|
||||
assertFalse(scheduler.runOnce());
|
||||
assertEquals(1, calls.get());
|
||||
clock.advance(Duration.ofSeconds(1));
|
||||
assertTrue(scheduler.runOnce());
|
||||
assertEquals(3, calls.get());
|
||||
assertEquals(ShadowStartupReport.Status.READY, report.getStatus());
|
||||
assertEquals(null, report.getReasonCode());
|
||||
assertFalse(scheduler.isPermanentlyStopped());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedSourceRollbackPermanentlyStopsEvenWhenOriginalCauseWasTimeout() {
|
||||
MutableClock clock = new MutableClock(NOW);
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
DriverManagerDataSource dataSource = new DriverManagerDataSource(
|
||||
"jdbc:h2:mem:" + UUID.randomUUID(), "sa", "");
|
||||
DataSourceTransactionManager transactions = new DataSourceTransactionManager(dataSource) {
|
||||
@Override protected void doRollback(
|
||||
org.springframework.transaction.support.DefaultTransactionStatus status) {
|
||||
super.doRollback(status);
|
||||
throw new org.springframework.transaction.TransactionSystemException("rollback failed");
|
||||
}
|
||||
};
|
||||
SqlServerShadowFactReader source = new SqlServerShadowFactReader(timeoutSource(calls),
|
||||
new TransactionTemplate(transactions), clock);
|
||||
ShadowStartupReport report = report();
|
||||
try (Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(source,
|
||||
evidenceCycle(report, new RecordingEvidenceRepository(), gateway(),
|
||||
() -> ShadowEvidenceStorageHealth.Check.available(Long.MAX_VALUE)), report, clock)) {
|
||||
assertFalse(scheduler.runOnce());
|
||||
assertEquals("SOURCE_TRANSACTION_FAILED", report.getReasonCode());
|
||||
assertTrue(scheduler.isPermanentlyStopped());
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
assertFalse(scheduler.runOnce());
|
||||
assertEquals(1, calls.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceBudgetStartsAfterRuntimePublicationAndIsCappedByWholeCycle() {
|
||||
for (long publicationMillis : new long[] {3230L, 7000L, 11000L}) {
|
||||
MutableClock clock = new MutableClock(NOW);
|
||||
AtomicInteger publications = new AtomicInteger();
|
||||
AtomicReference<Instant> deadlineSeen = new AtomicReference<Instant>();
|
||||
SqlServerShadowFactReader.FactSource source = new FactSourceAdapter() {
|
||||
@Override public Instant readDatabaseClock(Instant deadline) {
|
||||
deadlineSeen.set(deadline);
|
||||
return clock.instant();
|
||||
}
|
||||
};
|
||||
ShadowStartupReport report = report();
|
||||
try (Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(reader(source, clock),
|
||||
evidenceCycle(report, new RecordingEvidenceRepository(), gateway(),
|
||||
() -> ShadowEvidenceStorageHealth.Check.available(Long.MAX_VALUE)),
|
||||
report, clock, Duration.ofSeconds(10), state -> {
|
||||
if (publications.incrementAndGet() == 1) clock.advance(Duration.ofMillis(publicationMillis));
|
||||
})) {
|
||||
if (publicationMillis > 10000L) {
|
||||
assertFalse(scheduler.runOnce());
|
||||
assertEquals(null, deadlineSeen.get());
|
||||
assertEquals("SHADOW_CYCLE_TIMEOUT", report.getReasonCode());
|
||||
} else {
|
||||
assertTrue(scheduler.runOnce());
|
||||
assertEquals(NOW.plusMillis(Math.min(publicationMillis + 4000L, 10000L)), deadlineSeen.get());
|
||||
Map<?, ?> phases = (Map<?, ?>) report.getCycleDiagnostics().get("phaseMillis");
|
||||
assertTrue(phases.containsKey("RUNTIME_PUBLICATION"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static SqlServerShadowFactReader reader(
|
||||
SqlServerShadowFactReader.FactSource source, Clock clock) {
|
||||
DriverManagerDataSource dataSource = new DriverManagerDataSource(
|
||||
|
||||
@@ -285,6 +285,33 @@ class JdbcShadowEvidenceRepositoryTest {
|
||||
assertEquals(1, count("shadow_cycle"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deferredComparisonPersistsAlongsideObservationUsingExistingSchema() {
|
||||
ShadowCycleEvidence prediction = matchingCycle();
|
||||
ShadowEvidenceRepository.SavedCycle pending = repository.save(prediction);
|
||||
Map<String, Object> trace = new LinkedHashMap<String, Object>();
|
||||
trace.put("predictionCycleId", pending.getCycleId());
|
||||
trace.put("predictionEvidenceHash", pending.getEvidenceHash());
|
||||
trace.put("predictionInputHash", prediction.getInputBatchHash());
|
||||
String actual = ShadowEvidenceDigest.canonicalJson(objectMapper, trace);
|
||||
ShadowCycleEvidence observation = ShadowCycleEvidence.complete(run.getRunId(),
|
||||
ShadowFactBatch.create(NOW.plusSeconds(5), NOW.plusSeconds(5),
|
||||
Collections.emptyList(), Collections.emptyList(), Collections.emptyList()), legacyFields(),
|
||||
ShadowDecision.observation("PARENT-1", "LOADED_TO_PLACE", 3L, "VEH-1", "QUEUE_PHASE_NOT_PENDING"),
|
||||
null, Arrays.asList(new ShadowDifference(ShadowDifferenceCategory.OBSERVATION_ONLY,
|
||||
"QUEUE_PHASE_NOT_PENDING", null, null), new ShadowDifference(ShadowDifferenceCategory.DEFERRED_COMPARISON,
|
||||
"DEFERRED_MATCH", "{}", actual)), NOW.plusSeconds(5), objectMapper);
|
||||
repository.save(observation);
|
||||
assertEquals(actual, jdbc.queryForObject("SELECT actual_json FROM shadow_difference WHERE cycle_id=? AND category=?",
|
||||
String.class, observation.getCycleId(), "DEFERRED_COMPARISON"));
|
||||
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM shadow_encoded_write WHERE cycle_id=?",
|
||||
Integer.class, observation.getCycleId()));
|
||||
assertEquals(pending.getEvidenceHash(), repository.findCycle(run.getRunId(), prediction.getInputBatchHash(),
|
||||
prediction.getParentTaskId(), prediction.getPhase(), prediction.getRouteVersion()).getEvidenceHash());
|
||||
assertEquals(Long.valueOf(1), repository.dailyDigest(LocalDate.of(2026, 8, 14), ZoneOffset.UTC)
|
||||
.getCategoryCounts().get(ShadowDifferenceCategory.DEFERRED_COMPARISON));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIdentityWithDifferentHashFailsClosed() {
|
||||
ShadowCycleEvidence first = matchingCycle();
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
package com.ruoyi.shadow.evidence;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.ft546.protocol.*;
|
||||
import com.ruoyi.shadow.facts.*;
|
||||
import com.ruoyi.shadow.kernel.*;
|
||||
import com.ruoyi.shadow.startup.ShadowStartupReport;
|
||||
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
|
||||
import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy;
|
||||
import com.ruoyi.wcs.domain.traffic.*;
|
||||
import com.ruoyi.wcs.port.out.DispatchPolicy;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ShadowDeferredComparisonTest {
|
||||
private static final Instant NOW = Instant.parse("2026-09-09T01:00:00Z");
|
||||
private static final ObjectMapper JSON = new ObjectMapper().findAndRegisterModules();
|
||||
|
||||
@Test
|
||||
void committedPendingPredictionPairsWithLaterIssuedPhaseWithoutChangingOldEvidence() throws Exception {
|
||||
Harness h = new Harness();
|
||||
ShadowCycleEvidence pending = h.run(0, queue(0, null));
|
||||
String originalHash = pending.getEvidenceHash();
|
||||
assertEquals("GRANTED", pending.getDecision());
|
||||
assertEquals("SOURCE_GAP", pending.getDifferences().get(0).getCategory().name());
|
||||
ShadowCycleEvidence observed = h.run(5, queue(1, "70501,70502"));
|
||||
ShadowDifference paired = deferred(observed);
|
||||
assertEquals("DEFERRED_MATCH", paired.getReasonCode());
|
||||
JsonNode trace = JSON.readTree(paired.getActualJson());
|
||||
assertEquals(pending.getCycleId().toString(), trace.get("predictionCycleId").asText());
|
||||
assertEquals(pending.getInputBatchHash(), trace.get("predictionInputHash").asText());
|
||||
assertEquals(originalHash, trace.get("predictionEvidenceHash").asText());
|
||||
assertEquals("OBSERVATION_ONLY", observed.getDecision());
|
||||
assertTrue(observed.getEncodedWrites().isEmpty());
|
||||
assertEquals(originalHash, pending.getEvidenceHash());
|
||||
assertEquals(1, h.gateway.getRecorded().size());
|
||||
assertNull(deferredOrNull(h.run(10, queue(1, "70501,70502"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesLastCommittedPredictionAndActualPersistedId() throws Exception {
|
||||
Harness h = new Harness();
|
||||
h.run(0, queue(0, null));
|
||||
h.repository.returnedId = UUID.randomUUID();
|
||||
ShadowCycleEvidence last = h.run(5, queue(0, null));
|
||||
UUID persisted = h.repository.returnedId;
|
||||
h.repository.returnedId = null;
|
||||
JsonNode trace = JSON.readTree(deferred(h.run(10, queue(1, "70501,70502"))).getActualJson());
|
||||
assertEquals(persisted.toString(), trace.get("predictionCycleId").asText());
|
||||
assertEquals(last.getEvidenceHash(), trace.get("predictionEvidenceHash").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void changedRouteIsADifferenceInsteadOfFalseMatch() {
|
||||
Harness h = new Harness();
|
||||
h.run(0, queue(0, null));
|
||||
ShadowQueueFact changed = builder(1, "70501,70502").turnPath("70503").build();
|
||||
ShadowDifference difference = deferred(h.run(5, changed));
|
||||
assertEquals("DEFERRED_DIFFERENCE", difference.getReasonCode());
|
||||
assertTrue(difference.getActualJson().contains("ALGORITHM_ROUTE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void neverMatchesChangedPhaseTaskIdentityVehicleOrSpecialHandoff() {
|
||||
List<ShadowQueueFact> changed = Arrays.asList(
|
||||
builder(1, "70501,70502").taskType("1").build(),
|
||||
builder(1, "70501,70502").taskNo("30002").build(),
|
||||
builder(1, "70501,70502").createdAt(NOW.minusSeconds(1)).build(),
|
||||
builder(1, "70501,70502").agvId("VEH-8").build(),
|
||||
builder(1, "70501,70502").startSite("70503").build(),
|
||||
builder(1, "70501,70502").default02("1").build(),
|
||||
builder(1, "70501,70502").default08("2").build());
|
||||
for (ShadowQueueFact row : changed) {
|
||||
Harness h = new Harness();
|
||||
h.run(0, queue(0, null));
|
||||
ShadowDifference result = deferredOrNull(h.run(5, row));
|
||||
assertTrue(result == null || "DEFERRED_UNAVAILABLE".equals(result.getReasonCode()), row.getContentHash());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOrStaleLegacyPathRemainsUnavailable() {
|
||||
for (String path : new String[] {null, "70502,70501"}) {
|
||||
Harness h = new Harness();
|
||||
h.run(0, queue(0, null));
|
||||
ShadowDifference result = deferred(h.run(5, queue(1, path)));
|
||||
assertEquals("DEFERRED_UNAVAILABLE", result.getReasonCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void noPredictionAcrossRestartMissingPollExpiredIntervalOrInvalidBatch() {
|
||||
Harness fresh = new Harness();
|
||||
assertNull(deferredOrNull(fresh.run(5, queue(1, "70501,70502"))));
|
||||
Harness expired = new Harness();
|
||||
expired.run(0, queue(0, null));
|
||||
assertNull(deferredOrNull(expired.run(120, queue(1, "70501,70502"))));
|
||||
Harness gap = new Harness();
|
||||
gap.run(0, queue(0, null));
|
||||
gap.runBatch(gap.batch(5, Collections.emptyList(), true));
|
||||
assertNull(deferredOrNull(gap.run(10, queue(1, "70501,70502"))));
|
||||
Harness invalid = new Harness();
|
||||
invalid.run(0, queue(0, null));
|
||||
invalid.runBatch(invalid.batch(5, Collections.singletonList(queue(0, null)), false));
|
||||
assertNull(deferredOrNull(invalid.run(10, queue(1, "70501,70502"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedOuterCommitCannotCreateAPairablePrediction() {
|
||||
Harness h = new Harness();
|
||||
h.repository.failCommit = true;
|
||||
assertEquals("DEGRADED", h.runBatch(h.batch(0, Collections.singletonList(queue(0, null)), true)).getStatusCode());
|
||||
h.repository.failCommit = false;
|
||||
// Reuse the collector deliberately: even an in-process reconstruction must not retain a rolled-back prediction.
|
||||
h.cycle = h.newCycle();
|
||||
assertNull(deferredOrNull(h.run(5, queue(1, "70501,70502"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void replayingSameObservedBatchKeepsIdenticalEvidenceHash() {
|
||||
Harness h = new Harness();
|
||||
h.run(0, queue(0, null));
|
||||
ShadowCycleEvidence first = h.run(5, queue(1, "70501,70502"));
|
||||
assertNotNull(deferred(first));
|
||||
ShadowCycleEvidence replay = h.run(5, queue(1, "70501,70502"));
|
||||
assertEquals(first.getEvidenceHash(), replay.getEvidenceHash());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceTimeoutBreaksPairingBeforeSuccessfulRecovery() {
|
||||
Harness h = new Harness();
|
||||
AtomicReference<ShadowFactBatch> facts = new AtomicReference<ShadowFactBatch>(
|
||||
h.batch(0, Collections.singletonList(queue(0, null)), true));
|
||||
java.util.concurrent.atomic.AtomicBoolean fail = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
SqlServerShadowFactReader.FactSource source = new SqlServerShadowFactReader.FactSource() {
|
||||
public Instant readDatabaseClock(Instant deadline) {
|
||||
if (fail.get()) throw new org.springframework.dao.QueryTimeoutException("completed timeout");
|
||||
return facts.get().getDatabaseCapturedAt();
|
||||
}
|
||||
public List<ShadowQueueFact> readQueues(ShadowReadCursor cursor, Instant deadline) { return facts.get().getSourceQueues(); }
|
||||
public List<ShadowVehicleFact> readVehicles(Instant deadline) { return facts.get().getVehicles(); }
|
||||
public List<ShadowMapFact> readMaps(Instant deadline) { return facts.get().getMaps(); }
|
||||
};
|
||||
org.springframework.jdbc.datasource.DriverManagerDataSource dataSource =
|
||||
new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID(), "sa", "");
|
||||
SqlServerShadowFactReader reader = new SqlServerShadowFactReader(source,
|
||||
new org.springframework.transaction.support.TransactionTemplate(
|
||||
new org.springframework.jdbc.datasource.DataSourceTransactionManager(dataSource)), h.clock);
|
||||
ShadowStartupReport status = ShadowStartupReport.starting(NOW, Collections.emptyMap());
|
||||
try (Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(reader, h.cycle, status, h.clock)) {
|
||||
assertTrue(scheduler.runOnce());
|
||||
fail.set(true);
|
||||
h.now.set(NOW.plusSeconds(5));
|
||||
assertFalse(scheduler.runOnce());
|
||||
fail.set(false);
|
||||
h.now.set(NOW.plusSeconds(10));
|
||||
facts.set(h.batch(10, Collections.singletonList(queue(1, "70501,70502")), true));
|
||||
assertTrue(scheduler.runOnce());
|
||||
assertNull(deferredOrNull(h.repository.saved.get(h.repository.saved.size() - 1)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void changedMapAndDuplicateQueueObservationsCannotMatch() {
|
||||
Harness h = new Harness();
|
||||
h.run(0, queue(0, null));
|
||||
ShadowFactBatch observed = h.batch(5, Collections.singletonList(queue(1, "70501,70502")), true);
|
||||
ShadowFactBatch changedMap = ShadowFactBatch.create(observed.getDatabaseCaptureStartedAt(),
|
||||
observed.getDatabaseCapturedAt(), observed.getSourceQueues(), observed.getVehicles(),
|
||||
Arrays.asList(new ShadowMapFact("MAP-1", "70501", 0, 0, 1, 1),
|
||||
new ShadowMapFact("MAP-2", "70502", 2, 0, 1, 1)));
|
||||
List<ShadowCycleEvidence> evidence = h.collector.collect(changedMap,
|
||||
com.ruoyi.wcs.application.dispatch.DispatchCycleResult.committed(Collections.emptyList(),
|
||||
Collections.emptyList(), Collections.emptyList(), Collections.emptyList()),
|
||||
Collections.emptyList(), NOW.plusSeconds(5));
|
||||
assertNull(deferredOrNull(evidence.get(0)));
|
||||
ShadowFactBatch duplicate = h.batch(5, Arrays.asList(queue(1, "70501,70502"), queue(1, "70501,70502")), true);
|
||||
evidence = h.collector.collect(duplicate,
|
||||
com.ruoyi.wcs.application.dispatch.DispatchCycleResult.committed(Collections.emptyList(),
|
||||
Collections.emptyList(), Collections.emptyList(), Collections.emptyList()),
|
||||
Collections.emptyList(), NOW.plusSeconds(5));
|
||||
for (ShadowCycleEvidence value : evidence) assertNull(deferredOrNull(value));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preparingSameObservationBeforeCommitIsDeterministicAndDoesNotConsumePrediction() {
|
||||
Harness h = new Harness();
|
||||
h.run(0, queue(0, null));
|
||||
ShadowFactBatch observed = h.batch(5, Collections.singletonList(queue(1, "70501,70502")), true);
|
||||
com.ruoyi.wcs.application.dispatch.DispatchCycleResult noDispatch =
|
||||
com.ruoyi.wcs.application.dispatch.DispatchCycleResult.committed(Collections.emptyList(),
|
||||
Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
|
||||
ShadowCycleEvidence first = h.collector.collect(observed, noDispatch, Collections.emptyList(), NOW.plusSeconds(5)).get(0);
|
||||
ShadowCycleEvidence retry = h.collector.collect(observed, noDispatch, Collections.emptyList(), NOW.plusSeconds(6)).get(0);
|
||||
assertNotNull(deferred(first));
|
||||
assertNotNull(deferred(retry));
|
||||
assertEquals(first.getEvidenceHash(), retry.getEvidenceHash());
|
||||
assertEquals(first.getEvidenceHash(), h.run(5, queue(1, "70501,70502")).getEvidenceHash());
|
||||
}
|
||||
|
||||
private static ShadowDifference deferred(ShadowCycleEvidence value) {
|
||||
ShadowDifference result = deferredOrNull(value);
|
||||
assertNotNull(result, "same-stage pending->issued observation needs traced comparison evidence");
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ShadowDifference deferredOrNull(ShadowCycleEvidence value) {
|
||||
return value.getDifferences().stream().filter(d -> "DEFERRED_COMPARISON".equals(d.getCategory().name()))
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
private static ShadowQueueFact queue(int issued, String path) { return builder(issued, path).build(); }
|
||||
private static ShadowQueueFact.Builder builder(int issued, String path) {
|
||||
return ShadowQueueFact.builder("shadow_ro.v_wcs_srgv_queue", "ACTIVE")
|
||||
.issued(issued).executionStatus(issued == 0 ? 1 : 3)
|
||||
.taskNo("30001").taskType("2").priority(2L).theFloor("1")
|
||||
.cargoPickSite("70501").cargoPlaceSite("70502").startSite("70501").endSite("70502")
|
||||
.createdAt(NOW.minusSeconds(10)).agvId("VEH-9").pathInfo(path).default02("2");
|
||||
}
|
||||
|
||||
private static final class Harness {
|
||||
final Repository repository = new Repository();
|
||||
final ShadowCycleEvidenceCollector collector = new ShadowCycleEvidenceCollector(UUID.randomUUID(), 3L, JSON);
|
||||
final AtomicReference<Instant> now = new AtomicReference<Instant>(NOW);
|
||||
final Clock clock = new Clock() {
|
||||
public ZoneId getZone() { return ZoneOffset.UTC; }
|
||||
public Clock withZone(ZoneId zone) { return this; }
|
||||
public Instant instant() { return now.get(); }
|
||||
};
|
||||
final RecordingFt546CommandGateway gateway;
|
||||
Ft546ShadowCycle cycle;
|
||||
|
||||
Harness() {
|
||||
Ft546DeviceProfile profile = new Ft546DeviceProfile();
|
||||
Map<String, String> registers = new LinkedHashMap<String, String>();
|
||||
registers.put("taskNo", "4000"); registers.put("taskType", "4002");
|
||||
registers.put("path", "4004"); registers.put("trigger", "4021");
|
||||
profile.setRegisters(registers);
|
||||
Set<String> capabilities = Collections.singleton("POINT_TO_POINT_COMMAND");
|
||||
profile.setCapabilities(capabilities);
|
||||
gateway = new RecordingFt546CommandGateway(new Ft546SrgvCommandEncoder(), profile, capabilities);
|
||||
cycle = newCycle();
|
||||
}
|
||||
|
||||
Ft546ShadowCycle newCycle() {
|
||||
RoutePlanner routes = new AStarRoutePlanner();
|
||||
DispatchCoordinator coordinator = new DispatchCoordinator(
|
||||
new ShadowVehicleAssignmentPolicy(new DefaultVehicleAssignmentPolicy(routes, DispatchPolicy.NONE)),
|
||||
new DeadlockGuard(new WaitForGraph(), routes), commit -> true, () -> UUID.randomUUID().toString());
|
||||
ShadowDispatchSnapshotAssembler assembler = new ShadowDispatchSnapshotAssembler("FT546", 3L,
|
||||
topology -> new ShadowTrafficState(new ResourceLedger(topology), new WaitForGraph(), 0L,
|
||||
Collections.emptyMap()), clock);
|
||||
return new Ft546ShadowCycle(new ShadowFactBatchValidator(Duration.ofSeconds(4), Duration.ofSeconds(30)),
|
||||
assembler::assemble, new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(coordinator::dispatch,
|
||||
new ShadowCommandIntentFactory()::create), gateway,
|
||||
() -> ShadowEvidenceStorageHealth.Check.available(Long.MAX_VALUE), repository, collector,
|
||||
ShadowStartupReport.starting(now.get(), Collections.emptyMap()), clock);
|
||||
}
|
||||
|
||||
ShadowFactBatch batch(long seconds, List<ShadowQueueFact> queues, boolean vehicles) {
|
||||
Instant at = NOW.plusSeconds(seconds);
|
||||
return ShadowFactBatch.create(at.minusMillis(10), at, queues,
|
||||
vehicles ? Collections.singletonList(ShadowVehicleFact.builder("VEH-GUID", "VEH-9")
|
||||
.agvStatus("1").currentSite("70501").battery(80).workMode(5).alarm(0).status(1)
|
||||
.errorCode(0).allowSend(1).currentFloor(1).capturedAt(at).build()) : Collections.emptyList(),
|
||||
Arrays.asList(new ShadowMapFact("MAP-1", "70501", 0, 0, 1, 1),
|
||||
new ShadowMapFact("MAP-2", "70502", 1, 0, 1, 1)));
|
||||
}
|
||||
|
||||
ShadowCycleResult runBatch(ShadowFactBatch batch) {
|
||||
now.set(batch.getDatabaseCapturedAt());
|
||||
return cycle.run(batch, now.get());
|
||||
}
|
||||
|
||||
ShadowCycleEvidence run(long seconds, ShadowQueueFact queue) {
|
||||
assertEquals("COMPARABLE", runBatch(batch(seconds, Collections.singletonList(queue), true)).getStatusCode());
|
||||
return repository.saved.get(repository.saved.size() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Repository implements ShadowEvidenceRepository {
|
||||
final List<ShadowCycleEvidence> saved = new ArrayList<ShadowCycleEvidence>();
|
||||
boolean failCommit;
|
||||
UUID returnedId;
|
||||
public SavedCycle save(ShadowCycleEvidence value) { return saveAll(Collections.singletonList(value)).get(0); }
|
||||
public List<SavedCycle> saveAll(List<ShadowCycleEvidence> values) {
|
||||
saved.addAll(values);
|
||||
List<SavedCycle> result = new ArrayList<SavedCycle>();
|
||||
for (ShadowCycleEvidence value : values) result.add(new SavedCycle(
|
||||
returnedId == null ? value.getCycleId() : returnedId,
|
||||
value.getEvidenceHash(), value.getStatus(), value.getReasonCode()));
|
||||
return result;
|
||||
}
|
||||
public <T> T inTransaction(Supplier<T> work) {
|
||||
T result = work.get();
|
||||
if (failCommit) throw new ShadowEvidenceException("EVIDENCE_WRITE_FAILED");
|
||||
return result;
|
||||
}
|
||||
public SavedCycle findCycle(UUID run, String hash, String parent, String phase, long version) { return null; }
|
||||
public ShadowEvidenceDigest dailyDigest(LocalDate date, ZoneId zone) { throw new UnsupportedOperationException(); }
|
||||
public boolean isHealthy() { return true; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.ft546.protocol.*;
|
||||
import com.ruoyi.shadow.evidence.*;
|
||||
import com.ruoyi.shadow.facts.*;
|
||||
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
|
||||
import com.ruoyi.wcs.application.ShadowDispatchResult;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
|
||||
import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy;
|
||||
import com.ruoyi.wcs.domain.traffic.*;
|
||||
import com.ruoyi.wcs.port.out.DispatchPolicy;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
||||
|
||||
import java.nio.file.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** Private representative samples are independent inputs, never a reconstructed continuous history. */
|
||||
@EnabledIfSystemProperty(named = "shadow.replay.current-directory", matches = ".+")
|
||||
class ShadowCurrentFieldReplayTest {
|
||||
@Test
|
||||
void currentFieldInputsKeepHashesAndPredictionBoundaries() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
|
||||
int count = 0, grants = 0, holds = 0, observations = 0;
|
||||
try (DirectoryStream<Path> paths = Files.newDirectoryStream(
|
||||
Paths.get(System.getProperty("shadow.replay.current-directory")), "input-*.json")) {
|
||||
for (Path path : paths) {
|
||||
JsonNode export = mapper.readTree(path.toFile());
|
||||
String raw = export.get("capturedJson").asText();
|
||||
ShadowFactBatch batch = ShadowCapturedInputReplayTest.batch(mapper.readTree(raw));
|
||||
ShadowCycleEvidenceCollector collector = new ShadowCycleEvidenceCollector(UUID.randomUUID(), 1L, mapper);
|
||||
ShadowCycleEvidence original = collector.insufficient(batch, "INDEPENDENT_INPUT_REPLAY",
|
||||
batch.getDatabaseCapturedAt()).get(0);
|
||||
assertEquals(raw, original.getInputJson());
|
||||
assertEquals(export.get("contentHash").asText(), original.getInputBatchHash());
|
||||
Clock clock = Clock.fixed(batch.getDatabaseCapturedAt(), ZoneOffset.UTC);
|
||||
ShadowDispatchSnapshotAssembler assembler = new ShadowDispatchSnapshotAssembler("FT546", 1L,
|
||||
topology -> new ShadowTrafficState(new ResourceLedger(topology), new WaitForGraph(),
|
||||
0L, Collections.emptyMap()), clock);
|
||||
DispatchSnapshot snapshot = assembler.assemble(batch);
|
||||
RoutePlanner routes = new AStarRoutePlanner();
|
||||
DispatchCoordinator coordinator = new DispatchCoordinator(new ShadowVehicleAssignmentPolicy(
|
||||
new DefaultVehicleAssignmentPolicy(routes, DispatchPolicy.NONE)),
|
||||
new DeadlockGuard(new WaitForGraph(), routes), commit -> true, () -> UUID.randomUUID().toString());
|
||||
ShadowDispatchResult<Ft546SrgvCommandIntent> result = new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(
|
||||
coordinator::dispatch, new ShadowCommandIntentFactory()::create).run(snapshot);
|
||||
Ft546DeviceProfile profile = new Ft546DeviceProfile();
|
||||
Map<String, String> registers = new LinkedHashMap<String, String>();
|
||||
registers.put("taskNo", "4000"); registers.put("taskType", "4002");
|
||||
registers.put("path", "4004"); registers.put("trigger", "4021");
|
||||
profile.setRegisters(registers);
|
||||
Set<String> capabilities = Collections.singleton("POINT_TO_POINT_COMMAND");
|
||||
profile.setCapabilities(capabilities);
|
||||
List<Ft546EncodedCommand> encoded = new RecordingFt546CommandGateway(
|
||||
new Ft546SrgvCommandEncoder(), profile, capabilities).previewAll(result.getIntents());
|
||||
for (ShadowCycleEvidence value : collector.collect(batch, result.getDecisions(), encoded, clock.instant())) {
|
||||
assertEquals(original.getInputBatchHash(), value.getInputBatchHash());
|
||||
assertTrue(value.getDifferences().stream().noneMatch(d -> d.getCategory() == ShadowDifferenceCategory.DEFERRED_COMPARISON));
|
||||
if ("OBSERVATION_ONLY".equals(value.getDecision())) {
|
||||
observations++;
|
||||
assertTrue(value.getEncodedWrites().isEmpty());
|
||||
}
|
||||
}
|
||||
result.getDecisions().getGranted().forEach(grant -> {
|
||||
ShadowQueueFact queue = batch.queue(grant.getStepId());
|
||||
assertNull(ShadowQueueDispatchEligibility.observationReason(queue, batch));
|
||||
if (queue.getAgvId() != null && !queue.getAgvId().trim().isEmpty())
|
||||
assertEquals(queue.getAgvId(), grant.getVehicleId());
|
||||
});
|
||||
if (export.get("cycleId").asText().equals("33d2f0b4-fa10-4908-a31c-c681bc6ded06")) {
|
||||
ShadowQueueFact pending = batch.getQueues().stream().filter(q -> q.isActive()
|
||||
&& "60906".equals(q.getTaskNo())).findFirst().get();
|
||||
assertEquals("LEGACY_RESULT_UNAVAILABLE:INVALID_MAIN_PATH",
|
||||
new LegacyQueueProtocolProjector().project(pending, batch).getReasonCode());
|
||||
}
|
||||
grants += result.getDecisions().getGranted().size();
|
||||
holds += result.getDecisions().getHeld().size();
|
||||
count++;
|
||||
}
|
||||
}
|
||||
assertEquals(11, count, "2026-09-09 representative export must be complete");
|
||||
System.out.println("CURRENT_FIELD_REPLAY independentInputs=" + count + " grants=" + grants
|
||||
+ " holds=" + holds + " observations=" + observations + " historicalPairingNotInferred=true");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user