feat(ft546): persist shadow comparison evidence

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

View File

@@ -1,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(

View File

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

View File

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

View File

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

View File

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

View File

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