feat(ft546): persist shadow comparison evidence
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
package com.ruoyi.shadow;
|
||||
|
||||
import com.ruoyi.project.shadowtest.ForbiddenFactoryBean;
|
||||
import com.ruoyi.shadow.evidence.ShadowCycleEvidenceCollector;
|
||||
import com.ruoyi.shadow.evidence.ShadowEvidenceRepository;
|
||||
import com.ruoyi.shadow.evidence.ShadowEvidenceStorageHealth;
|
||||
import com.ruoyi.shadow.evidence.ShadowRunEvidence;
|
||||
import com.ruoyi.shadow.startup.ShadowDatabaseGate;
|
||||
import com.ruoyi.shadow.startup.ShadowStartupReport;
|
||||
import com.ruoyi.shadow.web.ShadowStatusController;
|
||||
@@ -23,6 +27,9 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
@@ -34,6 +41,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
class Ft546ShadowApplicationTest {
|
||||
private static final String ARTIFACT_SHA256 =
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
private static final String CONFIGURATION_SHA256 =
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
private final WebApplicationContextRunner contextRunner =
|
||||
new WebApplicationContextRunner()
|
||||
.withInitializer(
|
||||
@@ -74,6 +86,29 @@ class Ft546ShadowApplicationTest {
|
||||
assertNotNull(context.getBean(RecordingFt546CommandGateway.class));
|
||||
assertNotNull(context.getBean(
|
||||
JdbcShadowTrafficStateRepository.class));
|
||||
assertNotNull(context.getBean(ShadowRunEvidence.class));
|
||||
assertNotNull(context.getBean(ShadowEvidenceRepository.class));
|
||||
assertNotNull(context.getBean(ShadowEvidenceStorageHealth.class));
|
||||
assertNotNull(context.getBean(ShadowCycleEvidenceCollector.class));
|
||||
Set<String> cycleDependencies = Stream.of(context.getBeanFactory()
|
||||
.getDependenciesForBean("ft546ShadowCycle"))
|
||||
.collect(Collectors.toSet());
|
||||
assertTrue(cycleDependencies.contains("shadowEvidenceRepository"));
|
||||
assertTrue(cycleDependencies.contains(
|
||||
"shadowEvidenceStorageHealth"));
|
||||
assertTrue(cycleDependencies.contains(
|
||||
"shadowCycleEvidenceCollector"));
|
||||
assertTrue(cycleDependencies.contains("shadowStartupReport"));
|
||||
Set<String> trafficDependencies = Stream.of(
|
||||
context.getBeanFactory().getDependenciesForBean(
|
||||
"shadowTrafficStateRepository"))
|
||||
.collect(Collectors.toSet());
|
||||
Set<String> evidenceDependencies = Stream.of(
|
||||
context.getBeanFactory().getDependenciesForBean(
|
||||
"shadowEvidenceRepository"))
|
||||
.collect(Collectors.toSet());
|
||||
assertTrue(trafficDependencies.contains("shadowEvidenceTx"));
|
||||
assertTrue(evidenceDependencies.contains("shadowEvidenceTx"));
|
||||
assertEquals(context.getBean(
|
||||
JdbcShadowTrafficStateRepository.class),
|
||||
context.getBean(com.ruoyi.wcs.port.out
|
||||
@@ -141,13 +176,25 @@ class Ft546ShadowApplicationTest {
|
||||
assertStartupFailure(configured(contextRunner).withPropertyValues(
|
||||
"shadow.evidence.username="),
|
||||
"SHADOW_EVIDENCE_USERNAME_REQUIRED");
|
||||
assertStartupFailure(contextRunner.withPropertyValues(
|
||||
"spring.profiles.active=shadow",
|
||||
"shadow.enabled=true",
|
||||
"shadow.source.jdbc-url=jdbc:h2:mem:source",
|
||||
"shadow.evidence.jdbc-url=jdbc:h2:mem:evidence",
|
||||
"shadow.source.username=source",
|
||||
"shadow.evidence.username=evidence",
|
||||
assertStartupFailure(configured(contextRunner).withPropertyValues(
|
||||
"shadow.evidence.output-directory="),
|
||||
"SHADOW_EVIDENCE_OUTPUT_DIRECTORY_REQUIRED");
|
||||
assertStartupFailure(configured(contextRunner).withPropertyValues(
|
||||
"shadow.evidence.minimum-free-bytes=-1"),
|
||||
"SHADOW_EVIDENCE_MINIMUM_FREE_BYTES_INVALID");
|
||||
assertStartupFailure(configured(contextRunner).withPropertyValues(
|
||||
"shadow.evidence.run.artifact-sha256=invalid"),
|
||||
"SHADOW_ARTIFACT_SHA256_INVALID");
|
||||
assertStartupFailure(configured(contextRunner).withPropertyValues(
|
||||
"shadow.evidence.run.configuration-sha256="),
|
||||
"SHADOW_CONFIGURATION_SHA256_REQUIRED");
|
||||
assertStartupFailure(configured(contextRunner).withPropertyValues(
|
||||
"shadow.evidence.run.source-server="),
|
||||
"SHADOW_SOURCE_SERVER_REQUIRED");
|
||||
assertStartupFailure(configured(contextRunner).withPropertyValues(
|
||||
"shadow.evidence.run.source-database="),
|
||||
"SHADOW_SOURCE_DATABASE_REQUIRED");
|
||||
assertStartupFailure(configured(contextRunner).withPropertyValues(
|
||||
"server.address=0.0.0.0"),
|
||||
"SHADOW_STATUS_ADDRESS_MUST_BE_LOOPBACK");
|
||||
}
|
||||
@@ -185,7 +232,14 @@ class Ft546ShadowApplicationTest {
|
||||
"shadow.source.jdbc-url=jdbc:h2:mem:source",
|
||||
"shadow.evidence.jdbc-url=jdbc:h2:mem:evidence",
|
||||
"shadow.source.username=source",
|
||||
"shadow.evidence.username=evidence");
|
||||
"shadow.evidence.username=evidence",
|
||||
"shadow.evidence.output-directory=.",
|
||||
"shadow.evidence.minimum-free-bytes=0",
|
||||
"shadow.evidence.run.artifact-sha256=" + ARTIFACT_SHA256,
|
||||
"shadow.evidence.run.configuration-sha256="
|
||||
+ CONFIGURATION_SHA256,
|
||||
"shadow.evidence.run.source-server=ft546-test-server",
|
||||
"shadow.evidence.run.source-database=ft546-test-source");
|
||||
}
|
||||
|
||||
private static void assertStartupFailure(
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
package com.ruoyi.shadow.evidence;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
|
||||
import com.ruoyi.ft546.protocol.Ft546EncodedCommand;
|
||||
import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
|
||||
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
|
||||
import com.ruoyi.shadow.facts.ShadowFactBatch;
|
||||
import com.ruoyi.shadow.facts.ShadowLegacyQueueResult;
|
||||
import com.ruoyi.shadow.facts.ShadowLegacyQueueResult.DataType;
|
||||
import com.ruoyi.shadow.facts.ShadowLegacyQueueResult.LegacyRegisterWrite;
|
||||
import com.ruoyi.shadow.facts.ShadowQueueFact;
|
||||
import com.ruoyi.shadow.kernel.JdbcShadowTrafficStateRepository;
|
||||
import com.ruoyi.shadow.kernel.RecordingFt546CommandGateway;
|
||||
import com.ruoyi.shadow.kernel.ShadowCommandIntentFactory;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchCommit;
|
||||
import com.ruoyi.wcs.application.dispatch.HeldDispatch;
|
||||
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyCompiler;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyDefinition;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyEdge;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyNode;
|
||||
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
|
||||
import com.ruoyi.wcs.domain.traffic.TrafficDecisionType;
|
||||
import com.ruoyi.wcs.domain.traffic.WaitForGraph;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
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.AtomicBoolean;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class JdbcShadowEvidenceRepositoryTest {
|
||||
private static final Instant NOW =
|
||||
Instant.parse("2026-08-14T06:00:00Z");
|
||||
|
||||
private JdbcTemplate jdbc;
|
||||
private TransactionTemplate transaction;
|
||||
private ObjectMapper objectMapper;
|
||||
private ShadowRunEvidence run;
|
||||
private JdbcShadowEvidenceRepository repository;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
JdbcDataSource dataSource = new JdbcDataSource();
|
||||
dataSource.setURL("jdbc:h2:mem:" + UUID.randomUUID()
|
||||
+ ";MODE=MSSQLServer;DB_CLOSE_DELAY=-1");
|
||||
jdbc = new JdbcTemplate(dataSource);
|
||||
transaction = new TransactionTemplate(
|
||||
new DataSourceTransactionManager(dataSource));
|
||||
objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||
createSchema();
|
||||
run = new ShadowRunEvidence(UUID.randomUUID(), NOW.minusSeconds(60),
|
||||
repeat('a'), repeat('b'), "source-host", "FT546-wcs",
|
||||
"ft546_shadow_ro", "RUNNING");
|
||||
repository = new JdbcShadowEvidenceRepository(jdbc, transaction,
|
||||
objectMapper, run);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sqlServerDuplicateKeyCodesAreRecognizedWithoutBroadeningIntegrityErrors() {
|
||||
assertTrue(JdbcShadowEvidenceRepository.isDuplicateKey(
|
||||
new DataIntegrityViolationException("duplicate",
|
||||
new SQLException("duplicate", "23000", 2627))));
|
||||
assertTrue(JdbcShadowEvidenceRepository.isDuplicateKey(
|
||||
new DataIntegrityViolationException("duplicate",
|
||||
new SQLException("duplicate", "23000", 2601))));
|
||||
assertFalse(JdbcShadowEvidenceRepository.isDuplicateKey(
|
||||
new DataIntegrityViolationException("foreign key",
|
||||
new SQLException("foreign key", "23000", 547))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeCycleCommitsEveryEvidenceRowAndDailyDigest() {
|
||||
ShadowCycleEvidence cycle = matchingCycle();
|
||||
|
||||
ShadowEvidenceRepository.SavedCycle saved = repository.save(cycle);
|
||||
|
||||
assertEquals(cycle.getCycleId(), saved.getCycleId());
|
||||
assertEquals(1, count("shadow_run"));
|
||||
assertEquals(1, count("shadow_cycle"));
|
||||
assertEquals(1, count("shadow_input_batch"));
|
||||
assertEquals(1, count("shadow_legacy_queue"));
|
||||
assertEquals(1, count("shadow_decision"));
|
||||
assertEquals(4, count("shadow_encoded_write"));
|
||||
assertEquals(1, count("shadow_difference"));
|
||||
ShadowEvidenceDigest digest = repository.dailyDigest(
|
||||
LocalDate.of(2026, 8, 14), ZoneOffset.UTC);
|
||||
assertEquals(1L, digest.getCycleCount());
|
||||
assertEquals(Long.valueOf(1L), digest.getCategoryCounts().get(
|
||||
ShadowDifferenceCategory.MATCH));
|
||||
assertEquals(64, digest.getEvidenceHash().length());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIdentityAndHashReplayReturnsTheExistingCycle() {
|
||||
ShadowCycleEvidence first = matchingCycle();
|
||||
ShadowCycleEvidence replay = matchingCycle();
|
||||
assertNotEquals(first.getCycleId(), replay.getCycleId());
|
||||
assertEquals(first.getEvidenceHash(), replay.getEvidenceHash());
|
||||
|
||||
ShadowEvidenceRepository.SavedCycle firstSaved = repository.save(first);
|
||||
ShadowEvidenceRepository.SavedCycle replaySaved =
|
||||
repository.save(replay);
|
||||
|
||||
assertEquals(firstSaved.getCycleId(), replaySaved.getCycleId());
|
||||
assertEquals(first.getEvidenceHash(), replaySaved.getEvidenceHash());
|
||||
assertEquals(1, count("shadow_cycle"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIdentityWithDifferentHashFailsClosed() {
|
||||
ShadowCycleEvidence first = matchingCycle();
|
||||
ShadowCycleEvidence collision = cycleWithDifferences(
|
||||
Collections.singletonList(new ShadowDifference(
|
||||
ShadowDifferenceCategory.ALGORITHM_VEHICLE,
|
||||
"SELECTED_VEHICLE_MISMATCH", "\"VEH-1\"",
|
||||
"\"VEH-2\"")));
|
||||
assertEquals(first.getInputBatchHash(), collision.getInputBatchHash());
|
||||
assertNotEquals(first.getEvidenceHash(), collision.getEvidenceHash());
|
||||
repository.save(first);
|
||||
|
||||
ShadowEvidenceException failure = assertThrows(
|
||||
ShadowEvidenceException.class,
|
||||
() -> repository.save(collision));
|
||||
|
||||
assertEquals("EVIDENCE_KEY_COLLISION", failure.getReasonCode());
|
||||
assertEquals(1, count("shadow_cycle"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void childInsertFailureRollsBackTheWholeCycleAndRun() {
|
||||
jdbc.execute("ALTER TABLE shadow_difference ADD CONSTRAINT "
|
||||
+ "ck_test_category CHECK (category <> 'PROTOCOL_VALUE')");
|
||||
ShadowCycleEvidence rejected = cycleWithDifferences(
|
||||
Collections.singletonList(new ShadowDifference(
|
||||
ShadowDifferenceCategory.PROTOCOL_VALUE,
|
||||
"REGISTER_VALUE_MISMATCH:4002", "1", "9")));
|
||||
|
||||
assertThrows(RuntimeException.class, () -> repository.save(rejected));
|
||||
|
||||
assertEquals(0, count("shadow_run"));
|
||||
assertEquals(0, count("shadow_cycle"));
|
||||
assertEquals(0, count("shadow_input_batch"));
|
||||
assertEquals(0, count("shadow_decision"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiDecisionBatchUsesOneOuterEvidenceTransaction() {
|
||||
jdbc.execute("ALTER TABLE shadow_difference ADD CONSTRAINT "
|
||||
+ "ck_test_source_gap CHECK (category <> 'SOURCE_GAP')");
|
||||
ShadowCycleEvidence first = matchingCycle();
|
||||
ShadowCycleEvidence rejected = ShadowCycleEvidence.sourceGap(
|
||||
run.getRunId(), "SOURCE_TIMEOUT", NOW.plusMillis(20),
|
||||
objectMapper);
|
||||
|
||||
assertThrows(RuntimeException.class, () -> repository.inTransaction(
|
||||
() -> repository.saveAll(Arrays.asList(first, rejected))));
|
||||
|
||||
assertEquals(0, count("shadow_run"));
|
||||
assertEquals(0, count("shadow_cycle"));
|
||||
assertEquals(0, count("shadow_difference"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void evidenceFailureRollsBackTrafficStateAndEveryEvidenceTable() {
|
||||
createTrafficSchema();
|
||||
jdbc.execute("ALTER TABLE shadow_difference ADD CONSTRAINT "
|
||||
+ "ck_test_outer_tx CHECK (category <> 'SOURCE_GAP')");
|
||||
JdbcShadowTrafficStateRepository traffic =
|
||||
new JdbcShadowTrafficStateRepository(jdbc, transaction,
|
||||
objectMapper, java.time.Clock.fixed(NOW,
|
||||
ZoneOffset.UTC), new ShadowCommandIntentFactory(),
|
||||
recordingGateway());
|
||||
DispatchCommit commit = trafficCommit();
|
||||
ShadowCycleEvidence first = matchingCycle();
|
||||
ShadowCycleEvidence rejected = ShadowCycleEvidence.sourceGap(
|
||||
run.getRunId(), "SOURCE_TIMEOUT", NOW.plusMillis(20),
|
||||
objectMapper);
|
||||
AtomicBoolean trafficAccepted = new AtomicBoolean();
|
||||
|
||||
assertThrows(RuntimeException.class, () -> repository.inTransaction(
|
||||
() -> {
|
||||
trafficAccepted.set(traffic.commit(commit));
|
||||
repository.saveAll(Arrays.asList(first, rejected));
|
||||
return null;
|
||||
}));
|
||||
|
||||
assertTrue(trafficAccepted.get());
|
||||
assertEquals(0, count("shadow_traffic_state"));
|
||||
assertEquals(0, count("shadow_hold_history"));
|
||||
assertEquals(0, count("shadow_run"));
|
||||
assertEquals(0, count("shadow_cycle"));
|
||||
assertEquals(0, count("shadow_input_batch"));
|
||||
assertEquals(0, count("shadow_legacy_queue"));
|
||||
assertEquals(0, count("shadow_decision"));
|
||||
assertEquals(0, count("shadow_encoded_write"));
|
||||
assertEquals(0, count("shadow_difference"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistedEvidenceIsQueryableAfterRepositoryRestart() {
|
||||
ShadowCycleEvidence cycle = matchingCycle();
|
||||
repository.save(cycle);
|
||||
JdbcShadowEvidenceRepository restarted =
|
||||
new JdbcShadowEvidenceRepository(jdbc, transaction,
|
||||
objectMapper, run);
|
||||
|
||||
ShadowEvidenceRepository.SavedCycle found = restarted.findCycle(
|
||||
run.getRunId(), cycle.getInputBatchHash(),
|
||||
cycle.getParentTaskId(), cycle.getPhase(),
|
||||
cycle.getRouteVersion());
|
||||
ShadowEvidenceDigest first = repository.dailyDigest(
|
||||
LocalDate.of(2026, 8, 14), ZoneOffset.UTC);
|
||||
ShadowEvidenceDigest second = restarted.dailyDigest(
|
||||
LocalDate.of(2026, 8, 14), ZoneOffset.UTC);
|
||||
|
||||
assertNotNull(found);
|
||||
assertEquals(cycle.getEvidenceHash(), found.getEvidenceHash());
|
||||
assertEquals(first.getEvidenceHash(), second.getEvidenceHash());
|
||||
assertEquals(first.getCategoryCounts(), second.getCategoryCounts());
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalEvidenceHashIgnoresMapInsertionOrder() {
|
||||
Map<String, String> forward = new LinkedHashMap<String, String>();
|
||||
forward.put("taskNo", "30001");
|
||||
forward.put("taskType", "1");
|
||||
Map<String, String> reverse = new LinkedHashMap<String, String>();
|
||||
reverse.put("taskType", "1");
|
||||
reverse.put("taskNo", "30001");
|
||||
|
||||
ShadowCycleEvidence first = cycle(legacy(forward), match());
|
||||
ShadowCycleEvidence second = cycle(legacy(reverse), match());
|
||||
|
||||
assertEquals(first.getEvidenceHash(), second.getEvidenceHash());
|
||||
assertEquals(first.getInputBatchHash(), second.getInputBatchHash());
|
||||
}
|
||||
|
||||
@Test
|
||||
void encodedWritesAlwaysParticipateInTheEvidenceHash() {
|
||||
ShadowCycleEvidence first = cycle(legacyFields(), match(),
|
||||
Collections.singletonList(match()), encoded("4002"));
|
||||
ShadowCycleEvidence changed = cycle(legacyFields(), match(),
|
||||
Collections.singletonList(match()), encoded("4999"));
|
||||
|
||||
assertNotEquals(first.getEvidenceHash(), changed.getEvidenceHash());
|
||||
}
|
||||
|
||||
private ShadowCycleEvidence matchingCycle() {
|
||||
return cycleWithDifferences(Collections.singletonList(match()));
|
||||
}
|
||||
|
||||
private ShadowCycleEvidence cycleWithDifferences(
|
||||
List<ShadowDifference> differences) {
|
||||
Map<String, String> fields = new LinkedHashMap<String, String>();
|
||||
fields.put("taskNo", "30001");
|
||||
fields.put("taskType", "1");
|
||||
return cycle(legacy(fields), differences.get(0), differences);
|
||||
}
|
||||
|
||||
private ShadowCycleEvidence cycle(ShadowLegacyQueueResult legacy,
|
||||
ShadowDifference difference) {
|
||||
return cycle(legacy, difference,
|
||||
Collections.singletonList(difference));
|
||||
}
|
||||
|
||||
private ShadowCycleEvidence cycle(ShadowLegacyQueueResult legacy,
|
||||
ShadowDifference difference,
|
||||
List<ShadowDifference> differences) {
|
||||
return cycle(legacy, difference, differences, encoded());
|
||||
}
|
||||
|
||||
private ShadowCycleEvidence cycle(ShadowLegacyQueueResult legacy,
|
||||
ShadowDifference difference,
|
||||
List<ShadowDifference> differences,
|
||||
Ft546EncodedCommand encoded) {
|
||||
ShadowFactBatch batch = ShadowFactBatch.create(NOW.minusMillis(100), NOW,
|
||||
Collections.singletonList(ShadowQueueFact.builder(
|
||||
"shadow_ro.v_wcs_srgv_queue", "QUEUE-1")
|
||||
.taskNo("30001")
|
||||
.taskType("2")
|
||||
.agvId("VEH-1")
|
||||
.pathInfo("70501,70502")
|
||||
.default02("1")
|
||||
.createdAt(NOW.minusSeconds(10))
|
||||
.build()),
|
||||
Collections.emptyList(), Collections.emptyList());
|
||||
ShadowDecision decision = ShadowDecision.granted("PARENT-1",
|
||||
"LOADED_TO_PLACE", 3L, "VEH-1", points(70501, 70502));
|
||||
return ShadowCycleEvidence.complete(run.getRunId(), batch, legacy,
|
||||
decision, encoded, differences, NOW.plusMillis(10),
|
||||
objectMapper);
|
||||
}
|
||||
|
||||
private ShadowLegacyQueueResult legacy(Map<String, String> fields) {
|
||||
return ShadowLegacyQueueResult.available("VEH-1",
|
||||
points(70501, 70502), fields, mainWrites());
|
||||
}
|
||||
|
||||
private static ShadowDifference match() {
|
||||
return new ShadowDifference(ShadowDifferenceCategory.MATCH,
|
||||
"LEGACY_AND_SHADOW_MATCH", null, null);
|
||||
}
|
||||
|
||||
private static Ft546EncodedCommand encoded() {
|
||||
return encoded("4002");
|
||||
}
|
||||
|
||||
private static Ft546EncodedCommand encoded(String taskTypeAddress) {
|
||||
Set<String> capabilities = new LinkedHashSet<String>(
|
||||
Collections.singletonList("POINT_TO_POINT_COMMAND"));
|
||||
Ft546DeviceProfile profile = new Ft546DeviceProfile();
|
||||
profile.setCapabilities(capabilities);
|
||||
Map<String, String> registers = new LinkedHashMap<String, String>();
|
||||
registers.put("taskNo", "4000");
|
||||
registers.put("taskType", taskTypeAddress);
|
||||
registers.put("path", "4004");
|
||||
registers.put("trigger", "4021");
|
||||
profile.setRegisters(registers);
|
||||
return new Ft546SrgvCommandEncoder().encode(
|
||||
new Ft546SrgvCommandIntent("VEH-1", "PARENT-1",
|
||||
"LOADED_TO_PLACE", "PARENT-1:LOADED_TO_PLACE:3",
|
||||
3L, 30001, (short) 1, points(70501, 70502),
|
||||
null, null), profile, capabilities);
|
||||
}
|
||||
|
||||
private ShadowLegacyQueueResult legacyFields() {
|
||||
Map<String, String> fields = new LinkedHashMap<String, String>();
|
||||
fields.put("taskNo", "30001");
|
||||
fields.put("taskType", "1");
|
||||
return legacy(fields);
|
||||
}
|
||||
|
||||
private static RecordingFt546CommandGateway recordingGateway() {
|
||||
return new RecordingFt546CommandGateway(
|
||||
new Ft546SrgvCommandEncoder(), new Ft546DeviceProfile(),
|
||||
Collections.<String>emptySet());
|
||||
}
|
||||
|
||||
private static DispatchCommit trafficCommit() {
|
||||
EffectiveTopology topology = new TopologyCompiler().compile(
|
||||
new TopologyDefinition("FT546", 3L, true,
|
||||
Arrays.asList(
|
||||
new TopologyNode("A", "F1", 0, 0, 1,
|
||||
false, false),
|
||||
new TopologyNode("B", "F1", 1, 0, 1,
|
||||
false, false)),
|
||||
Collections.singletonList(
|
||||
new TopologyEdge("A", "B", 1, 1)),
|
||||
Collections.emptyList(), Collections.emptyList(),
|
||||
Collections.emptyList()));
|
||||
HeldDispatch held = new HeldDispatch("PARENT-1", "STEP-1",
|
||||
"VEH-1", TrafficDecisionType.HOLD, "CAPACITY_CONFLICT",
|
||||
new LinkedHashSet<String>(
|
||||
Collections.singletonList("VEH-2")),
|
||||
null, null);
|
||||
return new DispatchCommit("FT546", topology.version(), 0L,
|
||||
Collections.emptyList(), Collections.singletonList(held),
|
||||
Collections.emptyList(), Collections.emptyList(),
|
||||
new ResourceLedger(topology), new WaitForGraph());
|
||||
}
|
||||
|
||||
private static List<LegacyRegisterWrite> mainWrites() {
|
||||
List<LegacyRegisterWrite> writes =
|
||||
new ArrayList<LegacyRegisterWrite>();
|
||||
writes.add(LegacyRegisterWrite.of("4004", DataType.INT32,
|
||||
Arrays.asList(70501, 70502, 0, 0, 0, 0, 0, 0),
|
||||
1, false));
|
||||
writes.add(LegacyRegisterWrite.of("4000", DataType.INT32,
|
||||
Collections.singletonList(30001), 2, false));
|
||||
writes.add(LegacyRegisterWrite.of("4002", DataType.INT16,
|
||||
Collections.singletonList(1), 3, false));
|
||||
writes.add(LegacyRegisterWrite.of("4021", DataType.INT16,
|
||||
Collections.singletonList(1), 4, true));
|
||||
return writes;
|
||||
}
|
||||
|
||||
private int count(String table) {
|
||||
return jdbc.queryForObject("SELECT COUNT(*) FROM " + table,
|
||||
Integer.class);
|
||||
}
|
||||
|
||||
private static List<Integer> points(Integer... values) {
|
||||
return Arrays.asList(values);
|
||||
}
|
||||
|
||||
private static String repeat(char value) {
|
||||
char[] values = new char[64];
|
||||
Arrays.fill(values, value);
|
||||
return new String(values);
|
||||
}
|
||||
|
||||
private void createSchema() {
|
||||
jdbc.execute("CREATE TABLE shadow_run (run_id UUID PRIMARY KEY, "
|
||||
+ "started_at TIMESTAMP NOT NULL, artifact_sha256 CHAR(64) NOT NULL, "
|
||||
+ "configuration_sha256 CHAR(64) NOT NULL, source_server VARCHAR(256) NOT NULL, "
|
||||
+ "source_database VARCHAR(128) NOT NULL, source_login VARCHAR(128) NOT NULL, "
|
||||
+ "status VARCHAR(32) NOT NULL)");
|
||||
jdbc.execute("CREATE TABLE shadow_cycle (cycle_id UUID PRIMARY KEY, "
|
||||
+ "run_id UUID NOT NULL, input_batch_hash CHAR(64) NOT NULL, "
|
||||
+ "parent_task_id VARCHAR(128) NOT NULL, phase VARCHAR(64) NOT NULL, "
|
||||
+ "route_version BIGINT NOT NULL, status VARCHAR(64) NOT NULL, "
|
||||
+ "reason_code VARCHAR(512), database_captured_at TIMESTAMP, "
|
||||
+ "completed_at TIMESTAMP NOT NULL, evidence_hash CHAR(64) NOT NULL, "
|
||||
+ "FOREIGN KEY (run_id) REFERENCES shadow_run(run_id), "
|
||||
+ "UNIQUE (run_id,input_batch_hash,parent_task_id,phase,route_version))");
|
||||
jdbc.execute("CREATE TABLE shadow_input_batch (cycle_id UUID PRIMARY KEY, "
|
||||
+ "content_hash CHAR(64) NOT NULL, captured_json CLOB NOT NULL, "
|
||||
+ "FOREIGN KEY (cycle_id) REFERENCES shadow_cycle(cycle_id))");
|
||||
jdbc.execute("CREATE TABLE shadow_legacy_queue (cycle_id UUID PRIMARY KEY, "
|
||||
+ "vehicle_id VARCHAR(64), path_info CLOB, result_status VARCHAR(64) NOT NULL, "
|
||||
+ "result_json CLOB NOT NULL, content_hash CHAR(64) NOT NULL, "
|
||||
+ "FOREIGN KEY (cycle_id) REFERENCES shadow_cycle(cycle_id))");
|
||||
jdbc.execute("CREATE TABLE shadow_decision (cycle_id UUID PRIMARY KEY, "
|
||||
+ "decision VARCHAR(64) NOT NULL, vehicle_id VARCHAR(64), "
|
||||
+ "reason_code VARCHAR(512), decision_json CLOB NOT NULL, "
|
||||
+ "content_hash CHAR(64) NOT NULL, "
|
||||
+ "FOREIGN KEY (cycle_id) REFERENCES shadow_cycle(cycle_id))");
|
||||
jdbc.execute("CREATE TABLE shadow_encoded_write (cycle_id UUID NOT NULL, "
|
||||
+ "write_sequence INT NOT NULL, register_address VARCHAR(16) NOT NULL, "
|
||||
+ "data_type VARCHAR(16) NOT NULL, values_json CLOB NOT NULL, "
|
||||
+ "trigger_value BIGINT, PRIMARY KEY (cycle_id,write_sequence), "
|
||||
+ "FOREIGN KEY (cycle_id) REFERENCES shadow_cycle(cycle_id))");
|
||||
jdbc.execute("CREATE TABLE shadow_difference (difference_id BIGINT IDENTITY PRIMARY KEY, "
|
||||
+ "cycle_id UUID NOT NULL, category VARCHAR(64) NOT NULL, "
|
||||
+ "reason_code VARCHAR(512) NOT NULL, expected_json CLOB, actual_json CLOB, "
|
||||
+ "FOREIGN KEY (cycle_id) REFERENCES shadow_cycle(cycle_id))");
|
||||
}
|
||||
|
||||
private void createTrafficSchema() {
|
||||
jdbc.execute("CREATE TABLE shadow_traffic_state ("
|
||||
+ "state_key VARCHAR(256) PRIMARY KEY,"
|
||||
+ "snapshot_version BIGINT NOT NULL,"
|
||||
+ "state_json CLOB NOT NULL,"
|
||||
+ "updated_at TIMESTAMP NOT NULL)");
|
||||
jdbc.execute("CREATE TABLE shadow_hold_history ("
|
||||
+ "parent_task_id VARCHAR(128) NOT NULL,"
|
||||
+ "vehicle_id VARCHAR(64) NOT NULL,"
|
||||
+ "resource_key VARCHAR(256) NOT NULL,"
|
||||
+ "reason_code VARCHAR(512) NOT NULL,"
|
||||
+ "consecutive_count INT NOT NULL,"
|
||||
+ "updated_at TIMESTAMP NOT NULL,"
|
||||
+ "PRIMARY KEY(parent_task_id,vehicle_id,resource_key,"
|
||||
+ "reason_code))");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.ruoyi.shadow.evidence;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class JdbcShadowEvidenceStorageHealthTest {
|
||||
@TempDir
|
||||
Path outputDirectory;
|
||||
|
||||
@Test
|
||||
void unavailableDatabaseFailsBeforeDiskCheck() {
|
||||
Path missing = outputDirectory.resolve("missing");
|
||||
JdbcShadowEvidenceStorageHealth health =
|
||||
new JdbcShadowEvidenceStorageHealth(repository(false),
|
||||
missing, 0L);
|
||||
|
||||
ShadowEvidenceStorageHealth.Check result = health.verify();
|
||||
|
||||
assertFalse(result.isAvailable());
|
||||
assertEquals("EVIDENCE_DATABASE_UNAVAILABLE",
|
||||
result.getReasonCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void freeSpaceBelowConfiguredMinimumFailsClosed() {
|
||||
JdbcShadowEvidenceStorageHealth health =
|
||||
new JdbcShadowEvidenceStorageHealth(repository(true),
|
||||
outputDirectory, Long.MAX_VALUE);
|
||||
|
||||
ShadowEvidenceStorageHealth.Check result = health.verify();
|
||||
|
||||
assertFalse(result.isAvailable());
|
||||
assertEquals("FREE_SPACE_BELOW_MINIMUM", result.getReasonCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthyDatabaseAndExistingDirectoryAreAvailable() {
|
||||
JdbcShadowEvidenceStorageHealth health =
|
||||
new JdbcShadowEvidenceStorageHealth(repository(true),
|
||||
outputDirectory, 0L);
|
||||
|
||||
ShadowEvidenceStorageHealth.Check result = health.verify();
|
||||
|
||||
assertTrue(result.isAvailable());
|
||||
assertTrue(result.getUsableBytes() >= 0L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOutputDirectoryFailsClosed() {
|
||||
JdbcShadowEvidenceStorageHealth health =
|
||||
new JdbcShadowEvidenceStorageHealth(repository(true),
|
||||
outputDirectory.resolve("missing"), 0L);
|
||||
|
||||
ShadowEvidenceStorageHealth.Check result = health.verify();
|
||||
|
||||
assertFalse(result.isAvailable());
|
||||
assertEquals("EVIDENCE_FREE_SPACE_CHECK_FAILED",
|
||||
result.getReasonCode());
|
||||
}
|
||||
|
||||
private static ShadowEvidenceRepository repository(boolean healthy) {
|
||||
return new ShadowEvidenceRepository() {
|
||||
@Override
|
||||
public SavedCycle save(ShadowCycleEvidence evidence) {
|
||||
throw unsupported();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SavedCycle> saveAll(
|
||||
List<ShadowCycleEvidence> evidence) {
|
||||
throw unsupported();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T inTransaction(Supplier<T> work) {
|
||||
throw unsupported();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SavedCycle findCycle(
|
||||
UUID runId, String inputBatchHash,
|
||||
String parentTaskId, String phase, long routeVersion) {
|
||||
throw unsupported();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ShadowEvidenceDigest dailyDigest(
|
||||
LocalDate date, ZoneId zoneId) {
|
||||
throw unsupported();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isHealthy() {
|
||||
return healthy;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static UnsupportedOperationException unsupported() {
|
||||
return new UnsupportedOperationException("not used by health check");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package com.ruoyi.shadow.evidence;
|
||||
|
||||
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
|
||||
import com.ruoyi.ft546.protocol.Ft546EncodedCommand;
|
||||
import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
|
||||
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
|
||||
import com.ruoyi.shadow.facts.LegacyQueueProtocolProjector;
|
||||
import com.ruoyi.shadow.facts.ShadowLegacyQueueResult;
|
||||
import com.ruoyi.shadow.facts.ShadowQueueFact;
|
||||
import com.ruoyi.shadow.facts.ShadowLegacyQueueResult.DataType;
|
||||
import com.ruoyi.shadow.facts.ShadowLegacyQueueResult.LegacyRegisterWrite;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.ALGORITHM_HOLD;
|
||||
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.ALGORITHM_ROUTE;
|
||||
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.ALGORITHM_VEHICLE;
|
||||
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.INSUFFICIENT_FACTS;
|
||||
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.MATCH;
|
||||
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.PROTOCOL_ADDRESS;
|
||||
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.PROTOCOL_DATA_TYPE;
|
||||
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.PROTOCOL_ORDER;
|
||||
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.PROTOCOL_VALUE;
|
||||
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.QUEUE_FIELD;
|
||||
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.SOURCE_GAP;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class ShadowComparisonServiceTest {
|
||||
private final ShadowComparisonService comparison =
|
||||
new ShadowComparisonService();
|
||||
|
||||
@Test
|
||||
void exactLegacyAndShadowResultIsAMatch() {
|
||||
assertCategories(Collections.singletonList(MATCH),
|
||||
compare(legacy("VEH-1", points(70501, 70502), "30001", "1",
|
||||
mainWrites()),
|
||||
ShadowDecision.granted("PARENT-1", "LOADED_TO_PLACE",
|
||||
3L, "VEH-1", points(70501, 70502)),
|
||||
encoded()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyProjectionCarriesVehicleRouteAndQueueFieldsIntoComparison() {
|
||||
ShadowQueueFact queue = ShadowQueueFact.builder(
|
||||
"shadow_ro.v_wcs_srgv_queue", "QUEUE-1")
|
||||
.taskNo("30001")
|
||||
.taskType("2")
|
||||
.agvId("VEH-1")
|
||||
.pathInfo("70501,70502")
|
||||
.default02("1")
|
||||
.build();
|
||||
|
||||
assertCategories(Collections.singletonList(MATCH),
|
||||
compare(new LegacyQueueProtocolProjector().project(queue),
|
||||
matchingDecision(), encoded()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentVehicleWithMatchingWritesIsAlgorithmOnly() {
|
||||
assertCategories(Collections.singletonList(ALGORITHM_VEHICLE),
|
||||
compare(legacy("VEH-1", points(70501, 70502), "30001", "1",
|
||||
mainWrites()),
|
||||
ShadowDecision.granted("PARENT-1", "LOADED_TO_PLACE",
|
||||
3L, "VEH-2", points(70501, 70502)),
|
||||
encoded()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentSafeRouteIsAnAlgorithmRouteDifference() {
|
||||
assertCategories(Collections.singletonList(ALGORITHM_ROUTE),
|
||||
compare(legacy("VEH-1", points(70501, 70503), "30001", "1",
|
||||
mainWrites()),
|
||||
ShadowDecision.granted("PARENT-1", "LOADED_TO_PLACE",
|
||||
3L, "VEH-1", points(70501, 70502)),
|
||||
encoded()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void holdInsteadOfLegacyCommandIsAnAlgorithmHoldDifference() {
|
||||
assertCategories(Collections.singletonList(ALGORITHM_HOLD),
|
||||
compare(legacy("VEH-1", points(70501, 70502), "30001", "1",
|
||||
mainWrites()),
|
||||
ShadowDecision.held("PARENT-1", "LOADED_TO_PLACE", 3L,
|
||||
"VEH-1", "CAPACITY_CONFLICT"), null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unavailableLegacyProjectionIsASourceGap() {
|
||||
assertCategories(Collections.singletonList(SOURCE_GAP),
|
||||
compare(ShadowLegacyQueueResult.unavailable("path_info"),
|
||||
ShadowDecision.granted("PARENT-1", "LOADED_TO_PLACE",
|
||||
3L, "VEH-1", points(70501, 70502)),
|
||||
encoded()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidSnapshotIsClassifiedWithoutProtocolClaims() {
|
||||
assertCategories(Collections.singletonList(INSUFFICIENT_FACTS),
|
||||
compare(legacy("VEH-1", points(70501, 70502), "30001", "1",
|
||||
mainWrites()),
|
||||
ShadowDecision.insufficient("PARENT-1",
|
||||
"LOADED_TO_PLACE", 3L,
|
||||
"MISSING_VEHICLE_FACTS"), null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void changedAddressIsAProtocolAddressDifference() {
|
||||
List<LegacyRegisterWrite> writes = mainWrites();
|
||||
writes.set(2, write("4099", DataType.INT16, 3, false, 1));
|
||||
|
||||
assertCategories(Collections.singletonList(PROTOCOL_ADDRESS),
|
||||
compare(legacy("VEH-1", points(70501, 70502), "30001", "1",
|
||||
writes), matchingDecision(), encoded()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void changedDataTypeIsAProtocolDataTypeDifference() {
|
||||
List<LegacyRegisterWrite> writes = mainWrites();
|
||||
writes.set(2, write("4002", DataType.INT32, 3, false, 1));
|
||||
|
||||
assertCategories(Collections.singletonList(PROTOCOL_DATA_TYPE),
|
||||
compare(legacy("VEH-1", points(70501, 70502), "30001", "1",
|
||||
writes), matchingDecision(), encoded()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void changedRegisterValueIsAProtocolValueDifference() {
|
||||
List<LegacyRegisterWrite> writes = mainWrites();
|
||||
writes.set(2, write("4002", DataType.INT16, 3, false, 9));
|
||||
|
||||
assertCategories(Collections.singletonList(PROTOCOL_VALUE),
|
||||
compare(legacy("VEH-1", points(70501, 70502), "30001", "1",
|
||||
writes), matchingDecision(), encoded()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void changedWriteOrderIsProtocolOrderEvenWhenValuesMatch() {
|
||||
List<LegacyRegisterWrite> writes = new ArrayList<LegacyRegisterWrite>();
|
||||
writes.add(pathWrite());
|
||||
writes.add(write("4002", DataType.INT16, 2, false, 1));
|
||||
writes.add(write("4000", DataType.INT32, 3, false, 30001));
|
||||
writes.add(write("4021", DataType.INT16, 4, true, 1));
|
||||
|
||||
assertCategories(Collections.singletonList(PROTOCOL_ORDER),
|
||||
compare(legacy("VEH-1", points(70501, 70502), "30001", "1",
|
||||
writes), matchingDecision(), encoded()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateRegisterAddressesAreComparedByOccurrence() {
|
||||
List<LegacyRegisterWrite> writes = mainWrites();
|
||||
writes.set(2, write("4000", DataType.INT16, 3, false, 1));
|
||||
|
||||
assertCategories(Collections.singletonList(MATCH),
|
||||
compare(legacy("VEH-1", points(70501, 70502), "30001", "1",
|
||||
writes), matchingDecision(),
|
||||
encodedWithTaskTypeAddress("4000")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void changedCapturedQueueFieldDoesNotMasqueradeAsProtocolFailure() {
|
||||
assertCategories(Collections.singletonList(QUEUE_FIELD),
|
||||
compare(legacy("VEH-1", points(70501, 70502), "30001", "9",
|
||||
mainWrites()), matchingDecision(), encoded()));
|
||||
}
|
||||
|
||||
private List<ShadowDifference> compare(ShadowLegacyQueueResult legacy,
|
||||
ShadowDecision decision,
|
||||
Ft546EncodedCommand encoded) {
|
||||
return comparison.compare(legacy, decision, encoded);
|
||||
}
|
||||
|
||||
private static ShadowDecision matchingDecision() {
|
||||
return ShadowDecision.granted("PARENT-1", "LOADED_TO_PLACE", 3L,
|
||||
"VEH-1", points(70501, 70502));
|
||||
}
|
||||
|
||||
private static ShadowLegacyQueueResult legacy(
|
||||
String vehicleId, List<Integer> path, String taskNo,
|
||||
String taskType, List<LegacyRegisterWrite> writes) {
|
||||
Map<String, String> fields = new LinkedHashMap<String, String>();
|
||||
fields.put("taskNo", taskNo);
|
||||
fields.put("taskType", taskType);
|
||||
return ShadowLegacyQueueResult.available(vehicleId, path, fields,
|
||||
writes);
|
||||
}
|
||||
|
||||
private static Ft546EncodedCommand encoded() {
|
||||
return encodedWithTaskTypeAddress("4002");
|
||||
}
|
||||
|
||||
private static Ft546EncodedCommand encodedWithTaskTypeAddress(
|
||||
String taskTypeAddress) {
|
||||
Set<String> capabilities = new LinkedHashSet<String>(
|
||||
Collections.singletonList("POINT_TO_POINT_COMMAND"));
|
||||
Ft546DeviceProfile profile = new Ft546DeviceProfile();
|
||||
profile.setCapabilities(capabilities);
|
||||
Map<String, String> registers = new LinkedHashMap<String, String>();
|
||||
registers.put("taskNo", "4000");
|
||||
registers.put("taskType", taskTypeAddress);
|
||||
registers.put("path", "4004");
|
||||
registers.put("trigger", "4021");
|
||||
profile.setRegisters(registers);
|
||||
return new Ft546SrgvCommandEncoder().encode(
|
||||
new Ft546SrgvCommandIntent("VEH-1", "PARENT-1",
|
||||
"LOADED_TO_PLACE", "PARENT-1:LOADED_TO_PLACE:3",
|
||||
3L, 30001, (short) 1, points(70501, 70502),
|
||||
null, null), profile, capabilities);
|
||||
}
|
||||
|
||||
private static List<LegacyRegisterWrite> mainWrites() {
|
||||
return new ArrayList<LegacyRegisterWrite>(Arrays.asList(
|
||||
pathWrite(),
|
||||
write("4000", DataType.INT32, 2, false, 30001),
|
||||
write("4002", DataType.INT16, 3, false, 1),
|
||||
write("4021", DataType.INT16, 4, true, 1)));
|
||||
}
|
||||
|
||||
private static LegacyRegisterWrite pathWrite() {
|
||||
return write("4004", DataType.INT32, 1, false,
|
||||
70501, 70502, 0, 0, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
private static LegacyRegisterWrite write(String address, DataType type,
|
||||
int sequence, boolean trigger,
|
||||
Integer... values) {
|
||||
return LegacyRegisterWrite.of(address, type, Arrays.asList(values),
|
||||
sequence, trigger);
|
||||
}
|
||||
|
||||
private static List<Integer> points(Integer... values) {
|
||||
return Arrays.asList(values);
|
||||
}
|
||||
|
||||
private static void assertCategories(
|
||||
List<ShadowDifferenceCategory> expected,
|
||||
List<ShadowDifference> actual) {
|
||||
List<ShadowDifferenceCategory> categories =
|
||||
new ArrayList<ShadowDifferenceCategory>();
|
||||
for (ShadowDifference difference : actual) {
|
||||
categories.add(difference.getCategory());
|
||||
}
|
||||
assertEquals(expected, categories);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,54 @@ class SqlServerShadowDatabaseGateTest {
|
||||
verify(evidenceJdbc, never()).update(anyString(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runMetadataSourceServerMustMatchTheActualConnection()
|
||||
throws Exception {
|
||||
Connection source = connection(
|
||||
"jdbc:sqlserver://prod:1433;databaseName=FT546-wcs",
|
||||
"shadow_ro");
|
||||
Connection evidence = connection(
|
||||
"jdbc:sqlserver://evidence:1433;databaseName=FT546-shadow",
|
||||
"shadow_evidence");
|
||||
SqlServerPermissionProbe probe = mock(SqlServerPermissionProbe.class);
|
||||
SqlServerShadowDatabaseGate gate = new SqlServerShadowDatabaseGate(
|
||||
dataSource(source), dataSource(evidence),
|
||||
mock(JdbcTemplate.class), probe);
|
||||
ShadowProperties properties = properties();
|
||||
properties.getEvidence().getRun().setSourceServer("wrong-server");
|
||||
|
||||
IllegalStateException failure = assertThrows(
|
||||
IllegalStateException.class, () -> gate.verify(properties));
|
||||
|
||||
assertEquals("SHADOW_RUN_SOURCE_SERVER_MISMATCH",
|
||||
failure.getMessage());
|
||||
verify(probe, never()).verifySource(any(Connection.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void runMetadataSourceDatabaseMustMatchTheActualConnection()
|
||||
throws Exception {
|
||||
Connection source = connection(
|
||||
"jdbc:sqlserver://prod:1433;databaseName=FT546-wcs",
|
||||
"shadow_ro");
|
||||
Connection evidence = connection(
|
||||
"jdbc:sqlserver://evidence:1433;databaseName=FT546-shadow",
|
||||
"shadow_evidence");
|
||||
SqlServerPermissionProbe probe = mock(SqlServerPermissionProbe.class);
|
||||
SqlServerShadowDatabaseGate gate = new SqlServerShadowDatabaseGate(
|
||||
dataSource(source), dataSource(evidence),
|
||||
mock(JdbcTemplate.class), probe);
|
||||
ShadowProperties properties = properties();
|
||||
properties.getEvidence().getRun().setSourceDatabase("wrong-db");
|
||||
|
||||
IllegalStateException failure = assertThrows(
|
||||
IllegalStateException.class, () -> gate.verify(properties));
|
||||
|
||||
assertEquals("SHADOW_RUN_SOURCE_DATABASE_MISMATCH",
|
||||
failure.getMessage());
|
||||
verify(probe, never()).verifySource(any(Connection.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptedDatabasesPersistAllSuppliedStartupChecksInEvidenceDatabase()
|
||||
throws Exception {
|
||||
@@ -219,6 +267,8 @@ class SqlServerShadowDatabaseGateTest {
|
||||
properties.getEvidence().setJdbcUrl(
|
||||
"jdbc:sqlserver://evidence:1433;databaseName=FT546-shadow");
|
||||
properties.getEvidence().setUsername("shadow_evidence");
|
||||
properties.getEvidence().getRun().setSourceServer("prod");
|
||||
properties.getEvidence().getRun().setSourceDatabase("FT546-wcs");
|
||||
return properties;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,3 +9,10 @@ shadow:
|
||||
evidence:
|
||||
jdbc-url: jdbc:h2:mem:evidence
|
||||
username: evidence
|
||||
output-directory: .
|
||||
minimum-free-bytes: 0
|
||||
run:
|
||||
artifact-sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
configuration-sha256: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
|
||||
source-server: ft546-test-server
|
||||
source-database: ft546-test-source
|
||||
|
||||
Reference in New Issue
Block a user