From 240f5b080809e12740d1a6c20bd17a7b6446cc4e Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Fri, 14 Aug 2026 17:25:52 +0800 Subject: [PATCH] feat(ft546): persist shadow comparison evidence --- .../config/ShadowEvidenceConfiguration.java | 42 ++ .../config/ShadowKernelConfiguration.java | 27 +- .../ruoyi/shadow/config/ShadowProperties.java | 48 +- .../JdbcShadowEvidenceRepository.java | 351 +++++++++++++ .../JdbcShadowEvidenceStorageHealth.java | 43 ++ .../evidence/ShadowComparisonService.java | 264 ++++++++++ .../shadow/evidence/ShadowCycleEvidence.java | 381 ++++++++++++++ .../ShadowCycleEvidenceCollector.java | 177 +++++++ .../ruoyi/shadow/evidence/ShadowDecision.java | 103 ++++ .../shadow/evidence/ShadowDifference.java | 32 ++ .../evidence/ShadowDifferenceCategory.java | 15 + .../shadow/evidence/ShadowEvidenceDigest.java | 87 ++++ .../evidence/ShadowEvidenceException.java | 24 + .../evidence/ShadowEvidenceRepository.java | 43 ++ .../evidence/ShadowEvidenceStorageHealth.java | 39 ++ .../shadow/evidence/ShadowRunEvidence.java | 63 +++ .../facts/LegacyQueueProtocolProjector.java | 30 +- .../shadow/facts/ShadowLegacyQueueResult.java | 45 +- .../ruoyi/shadow/kernel/Ft546ShadowCycle.java | 140 ++++++ .../kernel/RecordingFt546CommandGateway.java | 14 +- .../shadow/kernel/ShadowCycleResult.java | 6 + .../startup/ShadowDatabaseIdentity.java | 10 + .../shadow/startup/ShadowStartupGate.java | 29 ++ .../shadow/startup/ShadowStartupReport.java | 33 +- .../startup/SqlServerShadowDatabaseGate.java | 3 + .../src/main/resources/application-shadow.yml | 7 + .../shadow/Ft546ShadowApplicationTest.java | 70 ++- .../JdbcShadowEvidenceRepositoryTest.java | 470 ++++++++++++++++++ .../JdbcShadowEvidenceStorageHealthTest.java | 113 +++++ .../evidence/ShadowComparisonServiceTest.java | 253 ++++++++++ .../shadow/kernel/Ft546ShadowCycleTest.java | 235 +++++++++ .../SqlServerShadowDatabaseGateTest.java | 50 ++ .../resources/application-shadow-test.yml | 7 + 33 files changed, 3228 insertions(+), 26 deletions(-) create mode 100644 ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepository.java create mode 100644 ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceStorageHealth.java create mode 100644 ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowComparisonService.java create mode 100644 ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowCycleEvidence.java create mode 100644 ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowCycleEvidenceCollector.java create mode 100644 ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDecision.java create mode 100644 ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDifference.java create mode 100644 ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDifferenceCategory.java create mode 100644 ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceDigest.java create mode 100644 ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceException.java create mode 100644 ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceRepository.java create mode 100644 ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceStorageHealth.java create mode 100644 ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowRunEvidence.java create mode 100644 ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepositoryTest.java create mode 100644 ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceStorageHealthTest.java create mode 100644 ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/ShadowComparisonServiceTest.java diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowEvidenceConfiguration.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowEvidenceConfiguration.java index 7653e63..8661bec 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowEvidenceConfiguration.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowEvidenceConfiguration.java @@ -1,5 +1,11 @@ package com.ruoyi.shadow.config; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.shadow.evidence.JdbcShadowEvidenceRepository; +import com.ruoyi.shadow.evidence.JdbcShadowEvidenceStorageHealth; +import com.ruoyi.shadow.evidence.ShadowEvidenceRepository; +import com.ruoyi.shadow.evidence.ShadowEvidenceStorageHealth; +import com.ruoyi.shadow.evidence.ShadowRunEvidence; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import org.springframework.beans.factory.annotation.Qualifier; @@ -7,8 +13,12 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; import javax.sql.DataSource; +import java.nio.file.Paths; +import java.time.Clock; +import java.util.UUID; @Configuration(proxyBeanMethods = false) public class ShadowEvidenceConfiguration { @@ -37,4 +47,36 @@ public class ShadowEvidenceConfiguration { @Qualifier("shadowEvidenceDataSource") DataSource evidence) { return new DataSourceTransactionManager(evidence); } + + @Bean + public ShadowRunEvidence shadowRunEvidence( + ShadowProperties properties, + @Qualifier("shadowClock") Clock clock) { + ShadowProperties.RunMetadata metadata = + properties.getEvidence().getRun(); + return new ShadowRunEvidence(UUID.randomUUID(), clock.instant(), + metadata.getArtifactSha256(), + metadata.getConfigurationSha256(), + metadata.getSourceServer(), metadata.getSourceDatabase(), + properties.getSource().getUsername(), "RUNNING"); + } + + @Bean + public ShadowEvidenceRepository shadowEvidenceRepository( + @Qualifier("shadowEvidenceJdbc") JdbcTemplate evidenceJdbc, + @Qualifier("shadowEvidenceTx") + DataSourceTransactionManager evidenceTx, + ObjectMapper objectMapper, ShadowRunEvidence run) { + return new JdbcShadowEvidenceRepository(evidenceJdbc, + new TransactionTemplate(evidenceTx), objectMapper, run); + } + + @Bean + public ShadowEvidenceStorageHealth shadowEvidenceStorageHealth( + ShadowEvidenceRepository repository, + ShadowProperties properties) { + return new JdbcShadowEvidenceStorageHealth(repository, + Paths.get(properties.getEvidence().getOutputDirectory()), + properties.getEvidence().getMinimumFreeBytes().longValue()); + } } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowKernelConfiguration.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowKernelConfiguration.java index 4b2a93a..6833b6c 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowKernelConfiguration.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowKernelConfiguration.java @@ -4,12 +4,17 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.ruoyi.ft546.protocol.Ft546DeviceProfile; import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder; import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent; +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.facts.ShadowFactBatchValidator; import com.ruoyi.shadow.kernel.Ft546ShadowCycle; import com.ruoyi.shadow.kernel.JdbcShadowTrafficStateRepository; import com.ruoyi.shadow.kernel.RecordingFt546CommandGateway; import com.ruoyi.shadow.kernel.ShadowCommandIntentFactory; import com.ruoyi.shadow.kernel.ShadowDispatchSnapshotAssembler; +import com.ruoyi.shadow.startup.ShadowStartupReport; import com.ruoyi.wcs.application.ReadOnlyDispatchCycle; import com.ruoyi.wcs.application.dispatch.DispatchCoordinator; import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy; @@ -39,6 +44,8 @@ import java.util.concurrent.atomic.AtomicLong; @Configuration(proxyBeanMethods = false) public class ShadowKernelConfiguration { + private static final long SHADOW_ROUTE_VERSION = 1L; + @Bean public RoutePlanner shadowRoutePlanner() { return new AStarRoutePlanner(); @@ -100,10 +107,18 @@ public class ShadowKernelConfiguration { public ShadowDispatchSnapshotAssembler shadowDispatchSnapshotAssembler( JdbcShadowTrafficStateRepository trafficStateRepository, @Qualifier("shadowClock") Clock clock) { - return new ShadowDispatchSnapshotAssembler("FT546", 1L, + return new ShadowDispatchSnapshotAssembler("FT546", + SHADOW_ROUTE_VERSION, trafficStateRepository::load, clock); } + @Bean + public ShadowCycleEvidenceCollector shadowCycleEvidenceCollector( + ShadowRunEvidence run, ObjectMapper objectMapper) { + return new ShadowCycleEvidenceCollector(run.getRunId(), + SHADOW_ROUTE_VERSION, objectMapper); + } + @Bean public ReadOnlyDispatchCycle shadowReadOnlyDispatchCycle( @@ -151,8 +166,14 @@ public class ShadowKernelConfiguration { ShadowFactBatchValidator validator, ShadowDispatchSnapshotAssembler assembler, ReadOnlyDispatchCycle dispatchCycle, - RecordingFt546CommandGateway commandGateway) { + RecordingFt546CommandGateway commandGateway, + ShadowEvidenceStorageHealth shadowEvidenceStorageHealth, + ShadowEvidenceRepository shadowEvidenceRepository, + ShadowCycleEvidenceCollector shadowCycleEvidenceCollector, + ShadowStartupReport shadowStartupReport) { return new Ft546ShadowCycle(validator, assembler::assemble, - dispatchCycle, commandGateway); + dispatchCycle, commandGateway, shadowEvidenceStorageHealth, + shadowEvidenceRepository, shadowCycleEvidenceCollector, + shadowStartupReport); } } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java index 66b79b0..e7c06c8 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java @@ -6,14 +6,14 @@ import org.springframework.boot.context.properties.ConfigurationProperties; public class ShadowProperties { private boolean enabled; private final JdbcEndpoint source = new JdbcEndpoint(); - private final JdbcEndpoint evidence = new JdbcEndpoint(); + private final EvidenceEndpoint evidence = new EvidenceEndpoint(); public boolean isEnabled() { return enabled; } public void setEnabled(boolean enabled) { this.enabled = enabled; } public JdbcEndpoint getSource() { return source; } - public JdbcEndpoint getEvidence() { return evidence; } + public EvidenceEndpoint getEvidence() { return evidence; } - public static final class JdbcEndpoint { + public static class JdbcEndpoint { private String jdbcUrl; private String username; private String password; @@ -25,4 +25,46 @@ public class ShadowProperties { public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } } + + public static final class EvidenceEndpoint extends JdbcEndpoint { + private String outputDirectory; + private Long minimumFreeBytes; + private final RunMetadata run = new RunMetadata(); + + public String getOutputDirectory() { return outputDirectory; } + public void setOutputDirectory(String outputDirectory) { + this.outputDirectory = outputDirectory; + } + public Long getMinimumFreeBytes() { return minimumFreeBytes; } + public void setMinimumFreeBytes(Long minimumFreeBytes) { + this.minimumFreeBytes = minimumFreeBytes; + } + public RunMetadata getRun() { return run; } + } + + public static final class RunMetadata { + private String artifactSha256; + private String configurationSha256; + private String sourceServer; + private String sourceDatabase; + + public String getArtifactSha256() { return artifactSha256; } + public void setArtifactSha256(String artifactSha256) { + this.artifactSha256 = artifactSha256; + } + public String getConfigurationSha256() { + return configurationSha256; + } + public void setConfigurationSha256(String configurationSha256) { + this.configurationSha256 = configurationSha256; + } + public String getSourceServer() { return sourceServer; } + public void setSourceServer(String sourceServer) { + this.sourceServer = sourceServer; + } + public String getSourceDatabase() { return sourceDatabase; } + public void setSourceDatabase(String sourceDatabase) { + this.sourceDatabase = sourceDatabase; + } + } } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepository.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepository.java new file mode 100644 index 0000000..c0b4e05 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepository.java @@ -0,0 +1,351 @@ +package com.ruoyi.shadow.evidence; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.ft546.protocol.Ft546RegisterWrite; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.support.TransactionTemplate; + +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Supplier; + +public final class JdbcShadowEvidenceRepository + implements ShadowEvidenceRepository { + private final JdbcTemplate jdbc; + private final TransactionTemplate transaction; + private final ObjectMapper objectMapper; + private final ShadowRunEvidence run; + + public JdbcShadowEvidenceRepository(JdbcTemplate jdbc, + TransactionTemplate transaction, + ObjectMapper objectMapper, + ShadowRunEvidence run) { + this.jdbc = Objects.requireNonNull(jdbc, "jdbc"); + this.transaction = Objects.requireNonNull(transaction, "transaction"); + this.objectMapper = Objects.requireNonNull(objectMapper, + "objectMapper"); + this.run = Objects.requireNonNull(run, "run"); + } + + @Override + public SavedCycle save(ShadowCycleEvidence evidence) { + return saveAll(Collections.singletonList(evidence)).get(0); + } + + @Override + public List saveAll(List evidence) { + Objects.requireNonNull(evidence, "evidence"); + if (evidence.isEmpty()) { + return Collections.emptyList(); + } + for (ShadowCycleEvidence item : evidence) { + if (item == null) { + throw new IllegalArgumentException( + "evidence must not contain null"); + } + if (!run.getRunId().equals(item.getRunId())) { + throw new ShadowEvidenceException( + "EVIDENCE_RUN_ID_MISMATCH"); + } + } + try { + return transaction.execute(status -> + saveAllInTransaction(evidence)); + } catch (DataAccessException race) { + if (!isDuplicateKey(race)) { + throw race; + } + List existing = resolveReplays(evidence); + if (existing != null) { + return existing; + } + if (runMatches()) { + return transaction.execute( + status -> saveAllInTransaction(evidence)); + } + throw race; + } + } + + static boolean isDuplicateKey(DataAccessException failure) { + if (failure instanceof DuplicateKeyException) { + return true; + } + Throwable cause = failure; + while (cause != null) { + if (cause instanceof SQLException) { + for (SQLException sql = (SQLException) cause; + sql != null; sql = sql.getNextException()) { + if (sql.getErrorCode() == 2601 + || sql.getErrorCode() == 2627) { + return true; + } + } + } + Throwable next = cause.getCause(); + cause = next == cause ? null : next; + } + return false; + } + + @Override + public T inTransaction(Supplier work) { + Objects.requireNonNull(work, "work"); + return transaction.execute(status -> work.get()); + } + + @Override + public SavedCycle findCycle(UUID runId, String inputBatchHash, + String parentTaskId, String phase, + long routeVersion) { + List values = jdbc.query( + "SELECT cycle_id,evidence_hash,status,reason_code " + + "FROM shadow_cycle WHERE run_id=? " + + "AND input_batch_hash=? AND parent_task_id=? " + + "AND phase=? AND route_version=?", + (rs, row) -> new SavedCycle( + uuid(rs.getObject("cycle_id")), + rs.getString("evidence_hash"), + rs.getString("status"), + rs.getString("reason_code")), + runId, inputBatchHash, parentTaskId, phase, routeVersion); + return values.isEmpty() ? null : values.get(0); + } + + @Override + public ShadowEvidenceDigest dailyDigest(LocalDate date, ZoneId zoneId) { + Objects.requireNonNull(date, "date"); + Objects.requireNonNull(zoneId, "zoneId"); + Instant start = date.atStartOfDay(zoneId).toInstant(); + Instant end = date.plusDays(1).atStartOfDay(zoneId).toInstant(); + List> rows = jdbc.queryForList( + "SELECT c.cycle_id,c.evidence_hash,d.category " + + "FROM shadow_cycle c LEFT JOIN shadow_difference d " + + "ON d.cycle_id=c.cycle_id WHERE c.run_id=? " + + "AND c.completed_at>=? AND c.completed_at cycles = new LinkedHashMap(); + Map categories = + new EnumMap( + ShadowDifferenceCategory.class); + for (Map row : rows) { + cycles.put(uuid(row.get("cycle_id")), + string(row.get("evidence_hash"))); + String category = string(row.get("category")); + if (category != null) { + ShadowDifferenceCategory key; + try { + key = ShadowDifferenceCategory.valueOf(category); + } catch (IllegalArgumentException corrupt) { + throw new ShadowEvidenceException( + "UNKNOWN_EVIDENCE_DIFFERENCE_CATEGORY", corrupt); + } + Long count = categories.get(key); + categories.put(key, count == null ? 1L : count + 1L); + } + } + return ShadowEvidenceDigest.create( + new ArrayList(cycles.values()), categories, + objectMapper); + } + + @Override + public boolean isHealthy() { + try { + Integer value = jdbc.queryForObject("SELECT 1", Integer.class); + return Integer.valueOf(1).equals(value); + } catch (DataAccessException failure) { + return false; + } + } + + private List saveAllInTransaction( + List evidence) { + ensureRun(); + List result = new ArrayList(evidence.size()); + List inserts = + new ArrayList(); + for (ShadowCycleEvidence item : evidence) { + SavedCycle existing = find(item); + if (existing == null) { + inserts.add(item); + result.add(null); + } else { + result.add(verifyReplay(existing, item)); + } + } + for (ShadowCycleEvidence item : inserts) { + insert(item); + } + for (int index = 0; index < evidence.size(); index++) { + if (result.get(index) == null) { + result.set(index, saved(evidence.get(index))); + } + } + return Collections.unmodifiableList(result); + } + + private void insert(ShadowCycleEvidence evidence) { + jdbc.update("INSERT INTO shadow_cycle " + + "(cycle_id,run_id,input_batch_hash,parent_task_id," + + "phase,route_version,status,reason_code," + + "database_captured_at,completed_at,evidence_hash) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?)", + evidence.getCycleId(), evidence.getRunId(), + evidence.getInputBatchHash(), evidence.getParentTaskId(), + evidence.getPhase(), evidence.getRouteVersion(), + evidence.getStatus(), evidence.getReasonCode(), + timestamp(evidence.getDatabaseCapturedAt()), + Timestamp.from(evidence.getCompletedAt()), + evidence.getEvidenceHash()); + jdbc.update("INSERT INTO shadow_input_batch " + + "(cycle_id,content_hash,captured_json) VALUES (?,?,?)", + evidence.getCycleId(), evidence.getInputBatchHash(), + evidence.getInputJson()); + jdbc.update("INSERT INTO shadow_legacy_queue " + + "(cycle_id,vehicle_id,path_info,result_status," + + "result_json,content_hash) VALUES (?,?,?,?,?,?)", + evidence.getCycleId(), evidence.getLegacyVehicleId(), + evidence.getLegacyPathInfo(), evidence.getLegacyStatus(), + evidence.getLegacyJson(), evidence.getLegacyHash()); + jdbc.update("INSERT INTO shadow_decision " + + "(cycle_id,decision,vehicle_id,reason_code," + + "decision_json,content_hash) VALUES (?,?,?,?,?,?)", + evidence.getCycleId(), evidence.getDecision(), + evidence.getDecisionVehicleId(), + evidence.getDecisionReasonCode(), evidence.getDecisionJson(), + evidence.getDecisionHash()); + for (Ft546RegisterWrite write : evidence.getEncodedWrites()) { + jdbc.update("INSERT INTO shadow_encoded_write " + + "(cycle_id,write_sequence,register_address," + + "data_type,values_json,trigger_value) " + + "VALUES (?,?,?,?,?,?)", + evidence.getCycleId(), write.getWriteSequence(), + write.getAddress(), write.getDataType().name(), + ShadowEvidenceDigest.canonicalJson(objectMapper, + write.getValues()), + triggerValue(write)); + } + for (ShadowDifference difference : evidence.getDifferences()) { + jdbc.update("INSERT INTO shadow_difference " + + "(cycle_id,category,reason_code,expected_json," + + "actual_json) VALUES (?,?,?,?,?)", + evidence.getCycleId(), difference.getCategory().name(), + difference.getReasonCode(), difference.getExpectedJson(), + difference.getActualJson()); + } + } + + private void ensureRun() { + List> rows = jdbc.queryForList( + "SELECT artifact_sha256,configuration_sha256,source_server," + + "source_database,source_login FROM shadow_run " + + "WHERE run_id=?", run.getRunId()); + if (rows.isEmpty()) { + jdbc.update("INSERT INTO shadow_run " + + "(run_id,started_at,artifact_sha256," + + "configuration_sha256,source_server," + + "source_database,source_login,status) " + + "VALUES (?,?,?,?,?,?,?,?)", + run.getRunId(), Timestamp.from(run.getStartedAt()), + run.getArtifactSha256(), run.getConfigurationSha256(), + run.getSourceServer(), run.getSourceDatabase(), + run.getSourceLogin(), run.getStatus()); + return; + } + if (!matchesRun(rows.get(0))) { + throw new ShadowEvidenceException("EVIDENCE_RUN_COLLISION"); + } + } + + private boolean runMatches() { + List> rows = jdbc.queryForList( + "SELECT artifact_sha256,configuration_sha256,source_server," + + "source_database,source_login FROM shadow_run " + + "WHERE run_id=?", run.getRunId()); + return rows.size() == 1 && matchesRun(rows.get(0)); + } + + private boolean matchesRun(Map row) { + return run.getArtifactSha256().equals( + string(row.get("artifact_sha256"))) + && run.getConfigurationSha256().equals( + string(row.get("configuration_sha256"))) + && run.getSourceServer().equals( + string(row.get("source_server"))) + && run.getSourceDatabase().equals( + string(row.get("source_database"))) + && run.getSourceLogin().equals( + string(row.get("source_login"))); + } + + private SavedCycle find(ShadowCycleEvidence evidence) { + return findCycle(evidence.getRunId(), evidence.getInputBatchHash(), + evidence.getParentTaskId(), evidence.getPhase(), + evidence.getRouteVersion()); + } + + private List resolveReplays( + List evidence) { + List result = new ArrayList(evidence.size()); + for (ShadowCycleEvidence item : evidence) { + SavedCycle existing = find(item); + if (existing == null) { + return null; + } + result.add(verifyReplay(existing, item)); + } + return Collections.unmodifiableList(result); + } + + private static SavedCycle verifyReplay( + SavedCycle existing, ShadowCycleEvidence evidence) { + if (!existing.getEvidenceHash().equals(evidence.getEvidenceHash())) { + throw new ShadowEvidenceException("EVIDENCE_KEY_COLLISION"); + } + return existing; + } + + private static SavedCycle saved(ShadowCycleEvidence evidence) { + return new SavedCycle(evidence.getCycleId(), + evidence.getEvidenceHash(), evidence.getStatus(), + evidence.getReasonCode()); + } + + private static Long triggerValue(Ft546RegisterWrite write) { + if (!write.isTrigger() || write.getValues().isEmpty()) { + return null; + } + return Long.valueOf(write.getValues().get(0).longValue()); + } + + private static Timestamp timestamp(Instant value) { + return value == null ? null : Timestamp.from(value); + } + + private static UUID uuid(Object value) { + if (value instanceof UUID) { + return (UUID) value; + } + return UUID.fromString(value.toString()); + } + + private static String string(Object value) { + return value == null ? null : value.toString().trim(); + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceStorageHealth.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceStorageHealth.java new file mode 100644 index 0000000..30fa3e7 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceStorageHealth.java @@ -0,0 +1,43 @@ +package com.ruoyi.shadow.evidence; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; + +public final class JdbcShadowEvidenceStorageHealth + implements ShadowEvidenceStorageHealth { + private final ShadowEvidenceRepository repository; + private final Path outputDirectory; + private final long minimumFreeBytes; + + public JdbcShadowEvidenceStorageHealth( + ShadowEvidenceRepository repository, Path outputDirectory, + long minimumFreeBytes) { + this.repository = Objects.requireNonNull(repository, "repository"); + this.outputDirectory = Objects.requireNonNull(outputDirectory, + "outputDirectory").toAbsolutePath().normalize(); + if (minimumFreeBytes < 0L) { + throw new IllegalArgumentException( + "minimumFreeBytes must not be negative"); + } + this.minimumFreeBytes = minimumFreeBytes; + } + + @Override + public Check verify() { + if (!repository.isHealthy()) { + return Check.unavailable("EVIDENCE_DATABASE_UNAVAILABLE"); + } + try { + long usableBytes = Files.getFileStore(outputDirectory) + .getUsableSpace(); + if (usableBytes < minimumFreeBytes) { + return Check.unavailable("FREE_SPACE_BELOW_MINIMUM"); + } + return Check.available(usableBytes); + } catch (IOException | RuntimeException failure) { + return Check.unavailable("EVIDENCE_FREE_SPACE_CHECK_FAILED"); + } + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowComparisonService.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowComparisonService.java new file mode 100644 index 0000000..85b1b32 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowComparisonService.java @@ -0,0 +1,264 @@ +package com.ruoyi.shadow.evidence; + +import com.ruoyi.ft546.protocol.Ft546EncodedCommand; +import com.ruoyi.ft546.protocol.Ft546RegisterWrite; +import com.ruoyi.shadow.facts.ShadowLegacyQueueResult; +import com.ruoyi.shadow.facts.ShadowLegacyQueueResult.LegacyRegisterWrite; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public final class ShadowComparisonService { + public List compare( + ShadowLegacyQueueResult legacy, + ShadowDecision decision, + Ft546EncodedCommand encoded) { + Objects.requireNonNull(legacy, "legacy"); + Objects.requireNonNull(decision, "decision"); + List differences = + new ArrayList(); + + if (decision.getType() == ShadowDecision.Type.INSUFFICIENT_FACTS) { + differences.add(difference( + ShadowDifferenceCategory.INSUFFICIENT_FACTS, + decision.getReasonCode(), null, null)); + return immutableSorted(differences); + } + if (decision.getType() == ShadowDecision.Type.SOURCE_GAP + || legacy.getStatus() + == ShadowLegacyQueueResult.Status.LEGACY_RESULT_UNAVAILABLE) { + differences.add(difference(ShadowDifferenceCategory.SOURCE_GAP, + legacy.getStatus() + == ShadowLegacyQueueResult.Status.LEGACY_RESULT_UNAVAILABLE + ? legacy.getReasonCode() : decision.getReasonCode(), + null, null)); + return immutableSorted(differences); + } + if (decision.getType() == ShadowDecision.Type.HELD + || decision.getType() == ShadowDecision.Type.YIELDED) { + differences.add(difference(ShadowDifferenceCategory.ALGORITHM_HOLD, + "SHADOW_" + decision.getType().name() + ':' + + decision.getReasonCode(), + json("LEGACY_COMMAND"), json(decision.getType().name()))); + return immutableSorted(differences); + } + if (encoded == null) { + differences.add(difference( + ShadowDifferenceCategory.INSUFFICIENT_FACTS, + "MISSING_ENCODED_COMMAND", null, null)); + return immutableSorted(differences); + } + + compareAlgorithm(legacy, decision, differences); + compareQueueFields(legacy, encoded, differences); + compareProtocol(legacy.getWrites(), encoded.getWrites(), differences); + if (differences.isEmpty()) { + differences.add(difference(ShadowDifferenceCategory.MATCH, + "LEGACY_AND_SHADOW_MATCH", null, null)); + } + return immutableSorted(differences); + } + + private static void compareAlgorithm( + ShadowLegacyQueueResult legacy, ShadowDecision decision, + List differences) { + if (!Objects.equals(legacy.getVehicleId(), decision.getVehicleId())) { + differences.add(difference( + ShadowDifferenceCategory.ALGORITHM_VEHICLE, + "SELECTED_VEHICLE_MISMATCH", json(legacy.getVehicleId()), + json(decision.getVehicleId()))); + } + if (!legacy.getPathPoints().equals(decision.getRoutePoints())) { + differences.add(difference( + ShadowDifferenceCategory.ALGORITHM_ROUTE, + "SELECTED_ROUTE_MISMATCH", json(legacy.getPathPoints()), + json(decision.getRoutePoints()))); + } + } + + private static void compareQueueFields( + ShadowLegacyQueueResult legacy, Ft546EncodedCommand encoded, + List differences) { + compareQueueField("taskNo", legacy.getQueueFields().get("taskNo"), + Integer.toString(encoded.getTaskNo()), differences); + compareQueueField("taskType", legacy.getQueueFields().get("taskType"), + Short.toString(encoded.getTaskType()), differences); + } + + private static void compareQueueField( + String field, String expected, String actual, + List differences) { + if (expected != null && !expected.equals(actual)) { + differences.add(difference(ShadowDifferenceCategory.QUEUE_FIELD, + "QUEUE_FIELD_MISMATCH:" + field, json(expected), + json(actual))); + } + } + + private static void compareProtocol( + List legacy, + List encoded, + List differences) { + if (legacy.size() != encoded.size()) { + differences.add(difference(ShadowDifferenceCategory.PROTOCOL_ORDER, + "REGISTER_WRITE_COUNT_MISMATCH", json(legacy.size()), + json(encoded.size()))); + return; + } + + List legacyAddresses = legacyAddresses(legacy); + List encodedAddresses = encodedAddresses(encoded); + boolean sameAddressOrder = legacyAddresses.equals(encodedAddresses); + boolean sameAddressSet = counts(legacyAddresses).equals( + counts(encodedAddresses)); + if (!sameAddressOrder && sameAddressSet) { + differences.add(difference(ShadowDifferenceCategory.PROTOCOL_ORDER, + "REGISTER_ADDRESS_ORDER_MISMATCH", json(legacyAddresses), + json(encodedAddresses))); + } + + Map addressOccurrences = + new LinkedHashMap(); + for (int index = 0; index < legacy.size(); index++) { + LegacyRegisterWrite expected = legacy.get(index); + int occurrence = nextOccurrence(addressOccurrences, + expected.getAddress()); + Ft546RegisterWrite actual = sameAddressSet + ? occurrence(encoded, expected.getAddress(), occurrence) + : encoded.get(index); + if (actual == null + || !expected.getAddress().equals(actual.getAddress())) { + differences.add(difference( + ShadowDifferenceCategory.PROTOCOL_ADDRESS, + "REGISTER_ADDRESS_MISMATCH:" + (index + 1), + json(expected.getAddress()), + json(actual == null ? null : actual.getAddress()))); + continue; + } + if (sameAddressOrder + && expected.getWriteSequence() + != actual.getWriteSequence()) { + differences.add(difference( + ShadowDifferenceCategory.PROTOCOL_ORDER, + "REGISTER_SEQUENCE_MISMATCH:" + expected.getAddress(), + json(expected.getWriteSequence()), + json(actual.getWriteSequence()))); + } + if (!expected.getDataType().name().equals( + actual.getDataType().name())) { + differences.add(difference( + ShadowDifferenceCategory.PROTOCOL_DATA_TYPE, + "REGISTER_DATA_TYPE_MISMATCH:" + expected.getAddress(), + json(expected.getDataType().name()), + json(actual.getDataType().name()))); + } + if (!expected.getValues().equals(actual.getValues()) + || expected.isTrigger() != actual.isTrigger()) { + differences.add(difference( + ShadowDifferenceCategory.PROTOCOL_VALUE, + "REGISTER_VALUE_MISMATCH:" + expected.getAddress(), + valueJson(expected.getValues(), expected.isTrigger()), + valueJson(actual.getValues(), actual.isTrigger()))); + } + } + } + + private static List legacyAddresses( + List writes) { + List values = new ArrayList(); + for (LegacyRegisterWrite write : writes) { + values.add(write.getAddress()); + } + return values; + } + + private static List encodedAddresses( + List writes) { + List values = new ArrayList(); + for (Ft546RegisterWrite write : writes) { + values.add(write.getAddress()); + } + return values; + } + + private static Map counts(List values) { + Map counts = new LinkedHashMap(); + for (String value : values) { + Integer count = counts.get(value); + counts.put(value, count == null ? 1 : count + 1); + } + return counts; + } + + private static int nextOccurrence(Map occurrences, + String address) { + Integer previous = occurrences.get(address); + int current = previous == null ? 1 : previous.intValue() + 1; + occurrences.put(address, Integer.valueOf(current)); + return current; + } + + private static Ft546RegisterWrite occurrence( + List values, String address, int occurrence) { + int current = 0; + for (Ft546RegisterWrite value : values) { + if (address.equals(value.getAddress()) && ++current == occurrence) { + return value; + } + } + return null; + } + + private static ShadowDifference difference( + ShadowDifferenceCategory category, String reason, + String expected, String actual) { + return new ShadowDifference(category, reason, expected, actual); + } + + private static List immutableSorted( + List source) { + Collections.sort(source, Comparator + .comparing((ShadowDifference value) -> + value.getCategory().ordinal()) + .thenComparing(ShadowDifference::getReasonCode) + .thenComparing(value -> nullToEmpty(value.getExpectedJson())) + .thenComparing(value -> nullToEmpty(value.getActualJson()))); + return Collections.unmodifiableList( + new ArrayList(source)); + } + + private static String valueJson(List values, boolean trigger) { + return "{\"trigger\":" + trigger + ",\"values\":" + + json(values) + '}'; + } + + private static String json(Object value) { + if (value == null) { + return "null"; + } + if (value instanceof Number || value instanceof Boolean) { + return value.toString(); + } + if (value instanceof List) { + StringBuilder result = new StringBuilder("["); + for (Object item : (List) value) { + if (result.length() > 1) { + result.append(','); + } + result.append(json(item)); + } + return result.append(']').toString(); + } + return '"' + value.toString().replace("\\", "\\\\") + .replace("\"", "\\\"") + '"'; + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowCycleEvidence.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowCycleEvidence.java new file mode 100644 index 0000000..4c68dd2 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowCycleEvidence.java @@ -0,0 +1,381 @@ +package com.ruoyi.shadow.evidence; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.ft546.protocol.Ft546EncodedCommand; +import com.ruoyi.ft546.protocol.Ft546RegisterWrite; +import com.ruoyi.shadow.facts.ShadowFactBatch; +import com.ruoyi.shadow.facts.ShadowLegacyQueueResult; +import com.ruoyi.shadow.facts.ShadowMapFact; +import com.ruoyi.shadow.facts.ShadowQueueFact; +import com.ruoyi.shadow.facts.ShadowVehicleFact; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +public final class ShadowCycleEvidence { + private final UUID cycleId; + private final UUID runId; + private final String inputBatchHash; + private final String parentTaskId; + private final String phase; + private final long routeVersion; + private final String status; + private final String reasonCode; + private final Instant databaseCapturedAt; + private final Instant completedAt; + private final String evidenceHash; + private final String inputJson; + private final String legacyVehicleId; + private final String legacyPathInfo; + private final String legacyStatus; + private final String legacyJson; + private final String legacyHash; + private final String decision; + private final String decisionVehicleId; + private final String decisionReasonCode; + private final String decisionJson; + private final String decisionHash; + private final List encodedWrites; + private final String encodedHash; + private final List differences; + + private ShadowCycleEvidence(UUID runId, ShadowFactBatch batch, + ShadowLegacyQueueResult legacy, + ShadowDecision decision, + Ft546EncodedCommand encoded, + List differences, + Instant completedAt, + ObjectMapper objectMapper) { + this.cycleId = UUID.randomUUID(); + this.runId = Objects.requireNonNull(runId, "runId"); + this.parentTaskId = decision.getParentTaskId(); + this.phase = decision.getPhase(); + this.routeVersion = decision.getRouteVersion(); + this.status = status(decision); + this.reasonCode = decision.getReasonCode(); + this.databaseCapturedAt = batch == null + ? null : batch.getDatabaseCapturedAt(); + this.completedAt = Objects.requireNonNull(completedAt, "completedAt"); + this.inputJson = ShadowEvidenceDigest.canonicalJson(objectMapper, + inputPayload(batch, decision)); + this.inputBatchHash = ShadowEvidenceDigest.sha256(inputJson); + this.legacyVehicleId = legacy.getVehicleId(); + this.legacyPathInfo = join(legacy.getPathPoints()); + this.legacyStatus = legacy.getStatus().name(); + this.legacyJson = ShadowEvidenceDigest.canonicalJson(objectMapper, + legacyPayload(legacy)); + this.legacyHash = ShadowEvidenceDigest.sha256(legacyJson); + this.decision = decision.getType().name(); + this.decisionVehicleId = decision.getVehicleId(); + this.decisionReasonCode = decision.getReasonCode(); + this.decisionJson = ShadowEvidenceDigest.canonicalJson(objectMapper, + decisionPayload(decision, encoded)); + this.decisionHash = ShadowEvidenceDigest.sha256(decisionJson); + this.encodedWrites = encoded == null + ? Collections.emptyList() + : immutable(encoded.getWrites()); + this.encodedHash = ShadowEvidenceDigest.sha256( + ShadowEvidenceDigest.canonicalJson(objectMapper, + encodedPayload(this.encodedWrites))); + this.differences = immutableDifferences(differences); + Map digest = new LinkedHashMap(); + digest.put("decisionHash", decisionHash); + digest.put("differenceHash", ShadowEvidenceDigest.sha256( + ShadowEvidenceDigest.canonicalJson(objectMapper, + differencePayload(this.differences)))); + digest.put("encodedHash", encodedHash); + digest.put("inputBatchHash", inputBatchHash); + digest.put("legacyHash", legacyHash); + digest.put("parentTaskId", parentTaskId); + digest.put("phase", phase); + digest.put("routeVersion", routeVersion); + digest.put("status", status); + this.evidenceHash = ShadowEvidenceDigest.sha256( + ShadowEvidenceDigest.canonicalJson(objectMapper, digest)); + } + + public static ShadowCycleEvidence complete( + UUID runId, ShadowFactBatch batch, + ShadowLegacyQueueResult legacy, ShadowDecision decision, + Ft546EncodedCommand encoded, List differences, + Instant completedAt, ObjectMapper objectMapper) { + return new ShadowCycleEvidence(runId, + Objects.requireNonNull(batch, "batch"), + Objects.requireNonNull(legacy, "legacy"), + Objects.requireNonNull(decision, "decision"), encoded, + differences, completedAt, + Objects.requireNonNull(objectMapper, "objectMapper")); + } + + public static ShadowCycleEvidence sourceGap( + UUID runId, String reasonCode, Instant completedAt, + ObjectMapper objectMapper) { + ShadowDecision decision = ShadowDecision.sourceGap(reasonCode); + ShadowLegacyQueueResult legacy = + ShadowLegacyQueueResult.unavailable(reasonCode); + return new ShadowCycleEvidence(runId, null, legacy, decision, null, + Collections.singletonList(new ShadowDifference( + ShadowDifferenceCategory.SOURCE_GAP, reasonCode, + null, null)), completedAt, objectMapper); + } + + public UUID getCycleId() { return cycleId; } + public UUID getRunId() { return runId; } + public String getInputBatchHash() { return inputBatchHash; } + public String getParentTaskId() { return parentTaskId; } + public String getPhase() { return phase; } + public long getRouteVersion() { return routeVersion; } + public String getStatus() { return status; } + public String getReasonCode() { return reasonCode; } + public Instant getDatabaseCapturedAt() { return databaseCapturedAt; } + public Instant getCompletedAt() { return completedAt; } + public String getEvidenceHash() { return evidenceHash; } + public String getInputJson() { return inputJson; } + public String getLegacyVehicleId() { return legacyVehicleId; } + public String getLegacyPathInfo() { return legacyPathInfo; } + public String getLegacyStatus() { return legacyStatus; } + public String getLegacyJson() { return legacyJson; } + public String getLegacyHash() { return legacyHash; } + public String getDecision() { return decision; } + public String getDecisionVehicleId() { return decisionVehicleId; } + public String getDecisionReasonCode() { return decisionReasonCode; } + public String getDecisionJson() { return decisionJson; } + public String getDecisionHash() { return decisionHash; } + public List getEncodedWrites() { + return encodedWrites; + } + public String getEncodedHash() { return encodedHash; } + public List getDifferences() { return differences; } + + private static Map inputPayload( + ShadowFactBatch batch, ShadowDecision decision) { + Map payload = new LinkedHashMap(); + if (batch == null) { + payload.put("sourceGap", decision.getReasonCode()); + return payload; + } + payload.put("databaseCaptureStartedAt", + batch.getDatabaseCaptureStartedAt()); + payload.put("databaseCapturedAt", batch.getDatabaseCapturedAt()); + payload.put("maps", maps(batch.getMaps())); + payload.put("queues", queues(batch.getSourceQueues())); + payload.put("vehicles", vehicles(batch.getVehicles())); + return payload; + } + + private static List> queues( + List values) { + List> result = + new ArrayList>(); + for (ShadowQueueFact value : values) { + Map row = new LinkedHashMap(); + row.put("agvId", value.getAgvId()); + row.put("cargoPickSite", value.getCargoPickSite()); + row.put("cargoPlaceSite", value.getCargoPlaceSite()); + row.put("contentHash", value.getContentHash()); + row.put("createdAt", value.getCreatedAt()); + row.put("default02", value.getDefault02()); + row.put("default03", value.getDefault03()); + row.put("default04", value.getDefault04()); + row.put("default05", value.getDefault05()); + row.put("default06", value.getDefault06()); + row.put("default07", value.getDefault07()); + row.put("default08", value.getDefault08()); + row.put("endSite", value.getEndSite()); + row.put("guid", value.getGuid()); + row.put("pathInfo", value.getPathInfo()); + row.put("priority", value.getPriority()); + row.put("startSite", value.getStartSite()); + row.put("tableName", value.getTableName()); + row.put("taskNo", value.getTaskNo()); + row.put("taskType", value.getTaskType()); + row.put("turnPath", value.getTurnPath()); + result.add(row); + } + return result; + } + + private static List> vehicles( + List values) { + List> result = + new ArrayList>(); + for (ShadowVehicleFact value : values) { + Map row = new LinkedHashMap(); + row.put("agvStatus", value.getAgvStatus()); + row.put("alarm", value.getAlarm()); + row.put("allowSend", value.getAllowSend()); + row.put("battery", value.getBattery()); + row.put("capturedAt", value.getCapturedAt()); + row.put("cargoFlag", value.getCargoFlag()); + row.put("changeLayer", value.getChangeLayer()); + row.put("chargeFlag", value.getChargeFlag()); + row.put("contentHash", value.getContentHash()); + row.put("currentFloor", value.getCurrentFloor()); + row.put("currentSite", value.getCurrentSite()); + row.put("deviceId", value.getDeviceId()); + row.put("errorCode", value.getErrorCode()); + row.put("finish", value.getFinish()); + row.put("guid", value.getGuid()); + row.put("locked", value.getLocked()); + row.put("path", value.getPath()); + row.put("sourceSite", value.getSourceSite()); + row.put("spare2", value.getSpare2()); + row.put("spare5", value.getSpare5()); + row.put("status", value.getStatus()); + row.put("targetSite", value.getTargetSite()); + row.put("taskNo", value.getTaskNo()); + row.put("taskType", value.getTaskType()); + row.put("workMode", value.getWorkMode()); + row.put("workPhase", value.getWorkPhase()); + result.add(row); + } + return result; + } + + private static List> maps(List values) { + List> result = + new ArrayList>(); + for (ShadowMapFact value : values) { + Map row = new LinkedHashMap(); + row.put("guid", value.getGuid()); + row.put("siteId", value.getSiteId()); + row.put("value", value.getValue()); + row.put("x", value.getX()); + row.put("y", value.getY()); + row.put("z", value.getZ()); + result.add(row); + } + return result; + } + + private static Map legacyPayload( + ShadowLegacyQueueResult legacy) { + Map payload = new LinkedHashMap(); + payload.put("pathPoints", legacy.getPathPoints()); + payload.put("queueFields", legacy.getQueueFields()); + payload.put("reasonCode", legacy.getReasonCode()); + payload.put("status", legacy.getStatus().name()); + payload.put("vehicleId", legacy.getVehicleId()); + List> writes = + new ArrayList>(); + for (ShadowLegacyQueueResult.LegacyRegisterWrite write + : legacy.getWrites()) { + writes.add(writePayload(write.getAddress(), + write.getDataType().name(), write.getValues(), + write.getWriteSequence(), write.isTrigger())); + } + payload.put("writes", writes); + return payload; + } + + private static Map decisionPayload( + ShadowDecision decision, Ft546EncodedCommand encoded) { + Map payload = new LinkedHashMap(); + payload.put("decision", decision.getType().name()); + payload.put("parentTaskId", decision.getParentTaskId()); + payload.put("phase", decision.getPhase()); + payload.put("reasonCode", decision.getReasonCode()); + payload.put("routePoints", decision.getRoutePoints()); + payload.put("routeVersion", decision.getRouteVersion()); + payload.put("vehicleId", decision.getVehicleId()); + if (encoded != null) { + Map command = new LinkedHashMap(); + command.put("commandKey", encoded.getCommandKey()); + command.put("deviceId", encoded.getDeviceId()); + command.put("parentTaskId", encoded.getParentTaskId()); + command.put("pathPoints", encoded.getPathPoints()); + command.put("phase", encoded.getPhase()); + command.put("routeVersion", encoded.getRouteVersion()); + command.put("taskNo", encoded.getTaskNo()); + command.put("taskType", encoded.getTaskType()); + command.put("triggerValue", encoded.getTriggerValue()); + payload.put("encodedCommand", command); + } + return payload; + } + + private static List> differencePayload( + List values) { + List> result = + new ArrayList>(); + for (ShadowDifference value : values) { + Map row = new LinkedHashMap(); + row.put("actualJson", value.getActualJson()); + row.put("category", value.getCategory().name()); + row.put("expectedJson", value.getExpectedJson()); + row.put("reasonCode", value.getReasonCode()); + result.add(row); + } + return result; + } + + private static List> encodedPayload( + List values) { + List> result = + new ArrayList>(); + for (Ft546RegisterWrite value : values) { + result.add(writePayload(value.getAddress(), + value.getDataType().name(), value.getValues(), + value.getWriteSequence(), value.isTrigger())); + } + return result; + } + + private static Map writePayload( + String address, String dataType, List values, + int sequence, boolean trigger) { + Map row = new LinkedHashMap(); + row.put("address", address); + row.put("dataType", dataType); + row.put("trigger", trigger); + row.put("values", values); + row.put("writeSequence", sequence); + return row; + } + + private static String status(ShadowDecision decision) { + if (decision.getType() == ShadowDecision.Type.INSUFFICIENT_FACTS) { + return "INSUFFICIENT_FACTS"; + } + if (decision.getType() == ShadowDecision.Type.SOURCE_GAP) { + return "SOURCE_GAP"; + } + return "COMPARABLE"; + } + + private static String join(List values) { + if (values.isEmpty()) { + return null; + } + StringBuilder result = new StringBuilder(); + for (Integer value : values) { + if (result.length() > 0) { + result.append(','); + } + result.append(value); + } + return result.toString(); + } + + private static List immutable( + List values) { + return Collections.unmodifiableList( + new ArrayList(values)); + } + + private static List immutableDifferences( + List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException( + "differences must not be empty"); + } + return Collections.unmodifiableList( + new ArrayList(values)); + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowCycleEvidenceCollector.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowCycleEvidenceCollector.java new file mode 100644 index 0000000..b521ec2 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowCycleEvidenceCollector.java @@ -0,0 +1,177 @@ +package com.ruoyi.shadow.evidence; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.ft546.protocol.Ft546EncodedCommand; +import com.ruoyi.shadow.facts.LegacyQueueProtocolProjector; +import com.ruoyi.shadow.facts.ShadowFactBatch; +import com.ruoyi.shadow.facts.ShadowLegacyQueueResult; +import com.ruoyi.shadow.facts.ShadowQueueFact; +import com.ruoyi.wcs.application.dispatch.DispatchCycleResult; +import com.ruoyi.wcs.application.dispatch.GrantedDispatch; +import com.ruoyi.wcs.application.dispatch.HeldDispatch; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +public final class ShadowCycleEvidenceCollector { + private static final String ACTIVE_QUEUE = + "shadow_ro.v_wcs_srgv_queue"; + + private final UUID runId; + private final long routeVersion; + private final ObjectMapper objectMapper; + private final LegacyQueueProtocolProjector legacyProjector; + private final ShadowComparisonService comparison; + + public ShadowCycleEvidenceCollector(UUID runId, long routeVersion, + ObjectMapper objectMapper) { + this(runId, routeVersion, objectMapper, + new LegacyQueueProtocolProjector(), + new ShadowComparisonService()); + } + + ShadowCycleEvidenceCollector( + UUID runId, long routeVersion, ObjectMapper objectMapper, + LegacyQueueProtocolProjector legacyProjector, + ShadowComparisonService comparison) { + this.runId = Objects.requireNonNull(runId, "runId"); + if (routeVersion <= 0L) { + throw new IllegalArgumentException( + "routeVersion must be positive"); + } + this.routeVersion = routeVersion; + this.objectMapper = Objects.requireNonNull(objectMapper, + "objectMapper"); + this.legacyProjector = Objects.requireNonNull(legacyProjector, + "legacyProjector"); + this.comparison = Objects.requireNonNull(comparison, "comparison"); + } + + public List collect( + ShadowFactBatch batch, DispatchCycleResult decisions, + List encodedCommands, Instant completedAt) { + Objects.requireNonNull(batch, "batch"); + Objects.requireNonNull(decisions, "decisions"); + Objects.requireNonNull(encodedCommands, "encodedCommands"); + if (decisions.getGranted().size() != encodedCommands.size()) { + throw new ShadowEvidenceException( + "ENCODED_COMMAND_COUNT_MISMATCH"); + } + List evidence = + new ArrayList(); + for (int index = 0; index < decisions.getGranted().size(); index++) { + GrantedDispatch grant = decisions.getGranted().get(index); + Ft546EncodedCommand encoded = encodedCommands.get(index); + ShadowQueueFact queue = batch.queue(grant.getStepId()); + ShadowLegacyQueueResult legacy = legacy(queue); + ShadowDecision decision = ShadowDecision.granted( + encoded.getParentTaskId(), encoded.getPhase(), + encoded.getRouteVersion(), grant.getVehicleId(), + encoded.getPathPoints()); + evidence.add(complete(batch, legacy, decision, encoded, + completedAt)); + } + addHeld(evidence, batch, decisions.getHeld(), false, completedAt); + addHeld(evidence, batch, decisions.getYielded(), true, completedAt); + return Collections.unmodifiableList(evidence); + } + + public List insufficient( + ShadowFactBatch batch, String reasonCode, Instant completedAt) { + Objects.requireNonNull(batch, "batch"); + List result = + new ArrayList(); + for (ShadowQueueFact queue : batch.getQueues()) { + if (!ACTIVE_QUEUE.equals(queue.getTableName())) { + continue; + } + ShadowDecision decision = ShadowDecision.insufficient( + parent(queue), phase(queue), routeVersion, reasonCode); + result.add(complete(batch, legacy(queue), decision, null, + completedAt)); + } + if (result.isEmpty()) { + ShadowDecision decision = ShadowDecision.insufficient( + "BATCH", "VALIDATION", 0L, reasonCode); + result.add(complete(batch, + ShadowLegacyQueueResult.unavailable("MISSING_ACTIVE_QUEUE"), + decision, null, completedAt)); + } + return Collections.unmodifiableList(result); + } + + private void addHeld(List evidence, + ShadowFactBatch batch, List values, + boolean yielded, Instant completedAt) { + for (HeldDispatch held : values) { + ShadowQueueFact queue = batch.queue(held.getStepId()); + String parent = queue == null ? held.getOrderId() : parent(queue); + String phase = queue == null ? "UNKNOWN_PHASE" : phase(queue); + ShadowDecision decision; + if (yielded) { + decision = ShadowDecision.yielded(parent, phase, + routeVersion, held.getVehicleId(), + route(held), held.getReasonCode()); + } else { + decision = ShadowDecision.held(parent, phase, routeVersion, + held.getVehicleId(), held.getReasonCode()); + } + evidence.add(complete(batch, legacy(queue), decision, null, + completedAt)); + } + } + + private ShadowCycleEvidence complete( + ShadowFactBatch batch, ShadowLegacyQueueResult legacy, + ShadowDecision decision, Ft546EncodedCommand encoded, + Instant completedAt) { + List differences = comparison.compare(legacy, + decision, encoded); + return ShadowCycleEvidence.complete(runId, batch, legacy, decision, + encoded, differences, completedAt, objectMapper); + } + + private ShadowLegacyQueueResult legacy(ShadowQueueFact queue) { + return queue == null + ? ShadowLegacyQueueResult.unavailable("MISSING_QUEUE_FOR_DECISION") + : legacyProjector.project(queue); + } + + private static List route(HeldDispatch held) { + if (held.getYieldRoute() == null) { + return Collections.emptyList(); + } + List result = new ArrayList(); + for (String node : held.getYieldRoute().getNodeIds()) { + try { + result.add(Integer.valueOf(node)); + } catch (NumberFormatException invalid) { + throw new ShadowEvidenceException( + "NON_NUMERIC_YIELD_ROUTE_NODE", invalid); + } + } + return result; + } + + private static String parent(ShadowQueueFact queue) { + String taskNo = queue.getTaskNo(); + return taskNo == null || taskNo.trim().isEmpty() + ? queue.getGuid() : taskNo.trim(); + } + + private static String phase(ShadowQueueFact queue) { + String taskType = queue.getTaskType(); + if ("1".equals(taskType)) { + return "EMPTY_TO_PICKUP"; + } + if ("2".equals(taskType)) { + return "LOADED_TO_PLACE"; + } + return taskType == null || taskType.trim().isEmpty() + ? "UNKNOWN_PHASE" : "LEGACY_PHASE_" + taskType.trim(); + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDecision.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDecision.java new file mode 100644 index 0000000..0520811 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDecision.java @@ -0,0 +1,103 @@ +package com.ruoyi.shadow.evidence; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class ShadowDecision { + public enum Type { GRANTED, HELD, YIELDED, INSUFFICIENT_FACTS, SOURCE_GAP } + + private final Type type; + private final String parentTaskId; + private final String phase; + private final long routeVersion; + private final String vehicleId; + private final List routePoints; + private final String reasonCode; + + private ShadowDecision(Type type, String parentTaskId, String phase, + long routeVersion, String vehicleId, + List routePoints, String reasonCode) { + if (type == null) { + throw new IllegalArgumentException("type must not be null"); + } + if (routeVersion < 0L) { + throw new IllegalArgumentException( + "routeVersion must not be negative"); + } + this.type = type; + this.parentTaskId = required(parentTaskId, "parentTaskId"); + this.phase = required(phase, "phase"); + this.routeVersion = routeVersion; + this.vehicleId = optional(vehicleId); + this.routePoints = immutable(routePoints); + this.reasonCode = optional(reasonCode); + } + + public static ShadowDecision granted(String parentTaskId, String phase, + long routeVersion, String vehicleId, + List routePoints) { + return new ShadowDecision(Type.GRANTED, parentTaskId, phase, + routeVersion, required(vehicleId, "vehicleId"), routePoints, + null); + } + + public static ShadowDecision held(String parentTaskId, String phase, + long routeVersion, String vehicleId, + String reasonCode) { + return new ShadowDecision(Type.HELD, parentTaskId, phase, routeVersion, + vehicleId, Collections.emptyList(), + required(reasonCode, "reasonCode")); + } + + public static ShadowDecision yielded(String parentTaskId, String phase, + long routeVersion, String vehicleId, + List routePoints, + String reasonCode) { + return new ShadowDecision(Type.YIELDED, parentTaskId, phase, + routeVersion, vehicleId, routePoints, + required(reasonCode, "reasonCode")); + } + + public static ShadowDecision insufficient(String parentTaskId, + String phase, + long routeVersion, + String reasonCode) { + return new ShadowDecision(Type.INSUFFICIENT_FACTS, parentTaskId, + phase, routeVersion, null, Collections.emptyList(), + required(reasonCode, "reasonCode")); + } + + public static ShadowDecision sourceGap(String reasonCode) { + return new ShadowDecision(Type.SOURCE_GAP, "SOURCE", "SOURCE_READ", + 0L, null, Collections.emptyList(), + required(reasonCode, "reasonCode")); + } + + public Type getType() { return type; } + public String getParentTaskId() { return parentTaskId; } + public String getPhase() { return phase; } + public long getRouteVersion() { return routeVersion; } + public String getVehicleId() { return vehicleId; } + public List getRoutePoints() { return routePoints; } + public String getReasonCode() { return reasonCode; } + + private static List immutable(List values) { + if (values == null) { + throw new IllegalArgumentException("routePoints must not be null"); + } + return Collections.unmodifiableList(new ArrayList(values)); + } + + private static String required(String value, String name) { + String normalized = optional(value); + if (normalized == null) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return normalized; + } + + private static String optional(String value) { + return value == null || value.trim().isEmpty() ? null : value.trim(); + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDifference.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDifference.java new file mode 100644 index 0000000..2c89c5f --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDifference.java @@ -0,0 +1,32 @@ +package com.ruoyi.shadow.evidence; + +import java.util.Objects; + +public final class ShadowDifference { + private final ShadowDifferenceCategory category; + private final String reasonCode; + private final String expectedJson; + private final String actualJson; + + public ShadowDifference(ShadowDifferenceCategory category, + String reasonCode, + String expectedJson, + String actualJson) { + this.category = Objects.requireNonNull(category, "category"); + this.reasonCode = required(reasonCode, "reasonCode"); + this.expectedJson = expectedJson; + this.actualJson = actualJson; + } + + public ShadowDifferenceCategory getCategory() { return category; } + public String getReasonCode() { return reasonCode; } + public String getExpectedJson() { return expectedJson; } + public String getActualJson() { return actualJson; } + + private static String required(String value, String name) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value.trim(); + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDifferenceCategory.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDifferenceCategory.java new file mode 100644 index 0000000..62f5869 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowDifferenceCategory.java @@ -0,0 +1,15 @@ +package com.ruoyi.shadow.evidence; + +public enum ShadowDifferenceCategory { + MATCH, + PROTOCOL_ADDRESS, + PROTOCOL_DATA_TYPE, + PROTOCOL_VALUE, + PROTOCOL_ORDER, + QUEUE_FIELD, + ALGORITHM_VEHICLE, + ALGORITHM_ROUTE, + ALGORITHM_HOLD, + INSUFFICIENT_FACTS, + SOURCE_GAP +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceDigest.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceDigest.java new file mode 100644 index 0000000..9945121 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceDigest.java @@ -0,0 +1,87 @@ +package com.ruoyi.shadow.evidence; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Collections; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class ShadowEvidenceDigest { + private final long cycleCount; + private final Map categoryCounts; + private final String evidenceHash; + + ShadowEvidenceDigest(long cycleCount, + Map categoryCounts, + String evidenceHash) { + this.cycleCount = cycleCount; + this.categoryCounts = Collections.unmodifiableMap( + new EnumMap(categoryCounts)); + this.evidenceHash = evidenceHash; + } + + public long getCycleCount() { return cycleCount; } + public Map getCategoryCounts() { + return categoryCounts; + } + public String getEvidenceHash() { return evidenceHash; } + + static ShadowEvidenceDigest create( + List cycleHashes, + Map categoryCounts, + ObjectMapper objectMapper) { + Map payload = new LinkedHashMap(); + payload.put("categoryCounts", namedCounts(categoryCounts)); + payload.put("cycleHashes", cycleHashes); + return new ShadowEvidenceDigest(cycleHashes.size(), categoryCounts, + sha256(canonicalJson(objectMapper, payload))); + } + + static String canonicalJson(ObjectMapper source, Object value) { + ObjectMapper mapper = source.copy(); + mapper.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true); + mapper.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + try { + return mapper.writeValueAsString(value); + } catch (JsonProcessingException failure) { + throw new ShadowEvidenceException( + "EVIDENCE_SERIALIZATION_FAILED", failure); + } + } + + static String sha256(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest( + value.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(64); + for (byte item : digest) { + hex.append(String.format("%02x", item & 0xff)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is unavailable", impossible); + } + } + + private static Map namedCounts( + Map values) { + Map result = new LinkedHashMap(); + for (ShadowDifferenceCategory category + : ShadowDifferenceCategory.values()) { + Long count = values.get(category); + if (count != null) { + result.put(category.name(), count); + } + } + return result; + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceException.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceException.java new file mode 100644 index 0000000..70e32f6 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceException.java @@ -0,0 +1,24 @@ +package com.ruoyi.shadow.evidence; + +public final class ShadowEvidenceException extends IllegalStateException { + private final String reasonCode; + + public ShadowEvidenceException(String reasonCode) { + super(required(reasonCode)); + this.reasonCode = reasonCode.trim(); + } + + public ShadowEvidenceException(String reasonCode, Throwable cause) { + super(required(reasonCode), cause); + this.reasonCode = reasonCode.trim(); + } + + public String getReasonCode() { return reasonCode; } + + private static String required(String value) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException("reasonCode must not be blank"); + } + return value.trim(); + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceRepository.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceRepository.java new file mode 100644 index 0000000..a8178b2 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceRepository.java @@ -0,0 +1,43 @@ +package com.ruoyi.shadow.evidence; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.UUID; +import java.util.function.Supplier; + +public interface ShadowEvidenceRepository { + SavedCycle save(ShadowCycleEvidence evidence); + + List saveAll(List evidence); + + T inTransaction(Supplier work); + + SavedCycle findCycle(UUID runId, String inputBatchHash, + String parentTaskId, String phase, + long routeVersion); + + ShadowEvidenceDigest dailyDigest(LocalDate date, ZoneId zoneId); + + boolean isHealthy(); + + final class SavedCycle { + private final UUID cycleId; + private final String evidenceHash; + private final String status; + private final String reasonCode; + + public SavedCycle(UUID cycleId, String evidenceHash, + String status, String reasonCode) { + this.cycleId = cycleId; + this.evidenceHash = evidenceHash; + this.status = status; + this.reasonCode = reasonCode; + } + + public UUID getCycleId() { return cycleId; } + public String getEvidenceHash() { return evidenceHash; } + public String getStatus() { return status; } + public String getReasonCode() { return reasonCode; } + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceStorageHealth.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceStorageHealth.java new file mode 100644 index 0000000..418a5d2 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowEvidenceStorageHealth.java @@ -0,0 +1,39 @@ +package com.ruoyi.shadow.evidence; + +public interface ShadowEvidenceStorageHealth { + Check verify(); + + final class Check { + private final boolean available; + private final String reasonCode; + private final long usableBytes; + + private Check(boolean available, String reasonCode, + long usableBytes) { + this.available = available; + this.reasonCode = reasonCode; + this.usableBytes = usableBytes; + } + + public static Check available(long usableBytes) { + if (usableBytes < 0L) { + throw new IllegalArgumentException( + "usableBytes must not be negative"); + } + return new Check(true, "EVIDENCE_STORAGE_AVAILABLE", + usableBytes); + } + + public static Check unavailable(String reasonCode) { + if (reasonCode == null || reasonCode.trim().isEmpty()) { + throw new IllegalArgumentException( + "reasonCode must not be blank"); + } + return new Check(false, reasonCode.trim(), -1L); + } + + public boolean isAvailable() { return available; } + public String getReasonCode() { return reasonCode; } + public long getUsableBytes() { return usableBytes; } + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowRunEvidence.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowRunEvidence.java new file mode 100644 index 0000000..7390c6a --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowRunEvidence.java @@ -0,0 +1,63 @@ +package com.ruoyi.shadow.evidence; + +import java.time.Instant; +import java.util.UUID; + +public final class ShadowRunEvidence { + private final UUID runId; + private final Instant startedAt; + private final String artifactSha256; + private final String configurationSha256; + private final String sourceServer; + private final String sourceDatabase; + private final String sourceLogin; + private final String status; + + public ShadowRunEvidence(UUID runId, Instant startedAt, + String artifactSha256, + String configurationSha256, + String sourceServer, String sourceDatabase, + String sourceLogin, String status) { + this.runId = required(runId, "runId"); + this.startedAt = required(startedAt, "startedAt"); + this.artifactSha256 = hash(artifactSha256, "artifactSha256"); + this.configurationSha256 = hash(configurationSha256, + "configurationSha256"); + this.sourceServer = text(sourceServer, "sourceServer"); + this.sourceDatabase = text(sourceDatabase, "sourceDatabase"); + this.sourceLogin = text(sourceLogin, "sourceLogin"); + this.status = text(status, "status"); + } + + public UUID getRunId() { return runId; } + public Instant getStartedAt() { return startedAt; } + public String getArtifactSha256() { return artifactSha256; } + public String getConfigurationSha256() { return configurationSha256; } + public String getSourceServer() { return sourceServer; } + public String getSourceDatabase() { return sourceDatabase; } + public String getSourceLogin() { return sourceLogin; } + public String getStatus() { return status; } + + private static String hash(String value, String name) { + String normalized = text(value, name).toLowerCase(); + if (!normalized.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException( + name + " must be a 64-character SHA-256 value"); + } + return normalized; + } + + private static String text(String value, String name) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value.trim(); + } + + private static T required(T value, String name) { + if (value == null) { + throw new IllegalArgumentException(name + " must not be null"); + } + return value; + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/LegacyQueueProtocolProjector.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/LegacyQueueProtocolProjector.java index 3a5a401..6923ce4 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/LegacyQueueProtocolProjector.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/LegacyQueueProtocolProjector.java @@ -3,7 +3,9 @@ package com.ruoyi.shadow.facts; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; public final class LegacyQueueProtocolProjector { public ShadowLegacyQueueResult project(ShadowQueueFact queue) { @@ -38,7 +40,7 @@ public final class LegacyQueueProtocolProjector { return stageOne(queue, taskNo, taskType); } if ("2".equals(stage)) { - return stageTwo(queue, taskNo); + return stageTwo(queue, taskNo, taskType); } return unavailable("INVALID_DEFAULT08"); } @@ -68,7 +70,7 @@ public final class LegacyQueueProtocolProjector { add(writes, "4022", ShadowLegacyQueueResult.DataType.INT16, false, Collections.singletonList(target)); } - return ShadowLegacyQueueResult.available(writes); + return available(queue, taskNo, taskType, path, writes); } private static ShadowLegacyQueueResult stageOne(ShadowQueueFact queue, @@ -86,11 +88,12 @@ public final class LegacyQueueProtocolProjector { Collections.singletonList(taskType)); add(writes, "4400", ShadowLegacyQueueResult.DataType.INT32, false, Collections.singletonList(taskNo)); - return ShadowLegacyQueueResult.available(writes); + return available(queue, taskNo, taskType, path, writes); } private static ShadowLegacyQueueResult stageTwo(ShadowQueueFact queue, - int taskNo) { + int taskNo, + int taskType) { Integer start = site(queue.getEndSite()); Integer end = site(queue.getDefault06()); if (start == null) { @@ -114,7 +117,7 @@ public final class LegacyQueueProtocolProjector { Collections.singletonList(9)); add(writes, "4400", ShadowLegacyQueueResult.DataType.INT32, false, Collections.singletonList(taskNo)); - return ShadowLegacyQueueResult.available(writes); + return available(queue, taskNo, taskType, path, writes); } private static List ordinaryPath(ShadowQueueFact queue) { @@ -176,6 +179,23 @@ public final class LegacyQueueProtocolProjector { return ShadowLegacyQueueResult.unavailable(reason); } + private static ShadowLegacyQueueResult available( + ShadowQueueFact queue, int taskNo, int taskType, + List paddedPath, + List writes) { + Map fields = new LinkedHashMap(); + fields.put("taskNo", Integer.toString(taskNo)); + fields.put("taskType", Integer.toString(taskType)); + List path = new ArrayList(); + for (Integer point : paddedPath) { + if (point != null && point.intValue() != 0) { + path.add(point); + } + } + return ShadowLegacyQueueResult.available(queue.getAgvId(), path, + fields, writes); + } + private static Integer site(String value) { Integer parsed = integer(value); return parsed == null || parsed <= 0 || parsed > 999_999 ? null : parsed; diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowLegacyQueueResult.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowLegacyQueueResult.java index a9d3898..7ad175e 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowLegacyQueueResult.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowLegacyQueueResult.java @@ -2,7 +2,9 @@ package com.ruoyi.shadow.facts; import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; public final class ShadowLegacyQueueResult { public enum Status { AVAILABLE, LEGACY_RESULT_UNAVAILABLE } @@ -10,30 +12,57 @@ public final class ShadowLegacyQueueResult { private final Status status; private final String reasonCode; + private final String vehicleId; + private final List pathPoints; + private final Map queueFields; private final List writes; private ShadowLegacyQueueResult(Status status, String reasonCode, + String vehicleId, + List pathPoints, + Map queueFields, List writes) { this.status = status; this.reasonCode = reasonCode; + this.vehicleId = vehicleId; + this.pathPoints = Collections.unmodifiableList( + new ArrayList(pathPoints)); + this.queueFields = Collections.unmodifiableMap( + new LinkedHashMap(queueFields)); this.writes = Collections.unmodifiableList( new ArrayList(writes)); } public static ShadowLegacyQueueResult available( List writes) { + return available(null, Collections.emptyList(), + Collections.emptyMap(), writes); + } + + public static ShadowLegacyQueueResult available( + String vehicleId, List pathPoints, + Map queueFields, + List writes) { return new ShadowLegacyQueueResult(Status.AVAILABLE, - "LEGACY_RESULT_AVAILABLE", writes); + "LEGACY_RESULT_AVAILABLE", vehicleId, + required(pathPoints, "pathPoints"), + required(queueFields, "queueFields"), + required(writes, "writes")); } public static ShadowLegacyQueueResult unavailable(String missing) { return new ShadowLegacyQueueResult(Status.LEGACY_RESULT_UNAVAILABLE, "LEGACY_RESULT_UNAVAILABLE:" + missing, + null, Collections.emptyList(), + Collections.emptyMap(), Collections.emptyList()); } public Status getStatus() { return status; } public String getReasonCode() { return reasonCode; } + public String getVehicleId() { return vehicleId; } + public List getPathPoints() { return pathPoints; } + public Map getQueueFields() { return queueFields; } public List getWrites() { return writes; } public String getWriteAddresses() { @@ -74,10 +103,24 @@ public final class ShadowLegacyQueueResult { this.trigger = trigger; } + public static LegacyRegisterWrite of( + String address, DataType dataType, List values, + int writeSequence, boolean trigger) { + return new LegacyRegisterWrite(address, dataType, values, + writeSequence, trigger); + } + public String getAddress() { return address; } public DataType getDataType() { return dataType; } public List getValues() { return values; } public int getWriteSequence() { return writeSequence; } public boolean isTrigger() { return trigger; } } + + private static T required(T value, String name) { + if (value == null) { + throw new IllegalArgumentException(name + " must not be null"); + } + return value; + } } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java index 8fda292..b933d6a 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java @@ -5,6 +5,12 @@ import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent; import com.ruoyi.shadow.facts.ShadowFactBatch; import com.ruoyi.shadow.facts.ShadowFactBatchValidator; import com.ruoyi.shadow.facts.ShadowFactValidation; +import com.ruoyi.shadow.evidence.ShadowCycleEvidence; +import com.ruoyi.shadow.evidence.ShadowCycleEvidenceCollector; +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.DispatchSnapshot; @@ -12,6 +18,7 @@ import com.ruoyi.wcs.application.dispatch.DispatchSnapshot; import java.time.Instant; import java.util.List; import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; public final class Ft546ShadowCycle { @@ -19,6 +26,12 @@ public final class Ft546ShadowCycle { private final Function assembler; private final ReadOnlyDispatchCycle dispatchCycle; private final RecordingFt546CommandGateway commandGateway; + private final ShadowEvidenceStorageHealth storageHealth; + private final ShadowEvidenceRepository evidenceRepository; + private final ShadowCycleEvidenceCollector evidenceCollector; + private final ShadowStartupReport statusReport; + private final AtomicReference degradedReason = + new AtomicReference(); public Ft546ShadowCycle( ShadowFactBatchValidator validator, @@ -31,9 +44,136 @@ public final class Ft546ShadowCycle { "dispatchCycle"); this.commandGateway = Objects.requireNonNull(commandGateway, "commandGateway"); + this.storageHealth = null; + this.evidenceRepository = null; + this.evidenceCollector = null; + this.statusReport = null; + } + + public Ft546ShadowCycle( + ShadowFactBatchValidator validator, + Function assembler, + ReadOnlyDispatchCycle dispatchCycle, + RecordingFt546CommandGateway commandGateway, + ShadowEvidenceStorageHealth storageHealth, + ShadowEvidenceRepository evidenceRepository, + ShadowCycleEvidenceCollector evidenceCollector, + ShadowStartupReport statusReport) { + this.validator = Objects.requireNonNull(validator, "validator"); + this.assembler = Objects.requireNonNull(assembler, "assembler"); + this.dispatchCycle = Objects.requireNonNull(dispatchCycle, + "dispatchCycle"); + this.commandGateway = Objects.requireNonNull(commandGateway, + "commandGateway"); + this.storageHealth = Objects.requireNonNull(storageHealth, + "storageHealth"); + this.evidenceRepository = Objects.requireNonNull(evidenceRepository, + "evidenceRepository"); + this.evidenceCollector = Objects.requireNonNull(evidenceCollector, + "evidenceCollector"); + this.statusReport = Objects.requireNonNull(statusReport, + "statusReport"); } public ShadowCycleResult run(ShadowFactBatch batch, Instant now) { + if (evidenceRepository == null) { + return runWithoutEvidence(batch, now); + } + String stoppedReason = degradedReason.get(); + if (stoppedReason != null) { + return ShadowCycleResult.degraded(stoppedReason); + } + ShadowEvidenceStorageHealth.Check health = storageHealth.verify(); + if (!health.isAvailable()) { + return degrade(now); + } + try { + ShadowCycleResult result = evidenceRepository.inTransaction( + () -> runAndPersist(batch, now)); + commandGateway.rememberAll(result.getEncodedCommands()); + Long lag = batch.getDatabaseCapturedAt() == null ? null + : Long.valueOf(Math.max(0L, + java.time.Duration.between( + batch.getDatabaseCapturedAt(), now).toMillis())); + statusReport.markReady(now, lag); + return result; + } catch (RuntimeException evidenceFailure) { + return degrade(failureReason(evidenceFailure), now); + } + } + + private ShadowCycleResult runAndPersist(ShadowFactBatch batch, + Instant now) { + ShadowFactValidation validation = validator.validate(batch, now); + if (validation.getStatus() + != ShadowFactValidation.Status.COMPARABLE) { + return persistInsufficient(batch, validation.getReasonCode(), now); + } + final DispatchSnapshot snapshot; + try { + snapshot = assembler.apply(batch); + } catch (RuntimeException failure) { + return persistInsufficient(batch, + "INVALID_KERNEL_FACTS:" + message(failure), now); + } + ShadowDispatchResult result; + try { + result = dispatchCycle.run(snapshot); + } catch (IllegalArgumentException failure) { + return persistInsufficient(batch, + "INVALID_COMMAND_FACTS:" + message(failure), now); + } + if (result.getDecisions().isRetrySnapshotChanged()) { + List evidence = evidenceCollector.insufficient( + batch, "SHADOW_TRAFFIC_STATE_CHANGED", now); + evidenceRepository.saveAll(evidence); + return ShadowCycleResult.retry(result.getDecisions()); + } + final List encoded; + try { + encoded = commandGateway.previewAll(result.getIntents()); + } catch (RuntimeException failure) { + return persistInsufficient(batch, + "INVALID_COMMAND_FACTS:" + message(failure), now); + } + evidenceRepository.saveAll(evidenceCollector.collect(batch, + result.getDecisions(), encoded, now)); + return ShadowCycleResult.comparable(result.getDecisions(), + result.getIntents(), encoded); + } + + private ShadowCycleResult persistInsufficient( + ShadowFactBatch batch, String reasonCode, Instant now) { + evidenceRepository.saveAll(evidenceCollector.insufficient(batch, + reasonCode, now)); + return ShadowCycleResult.insufficient(reasonCode); + } + + private ShadowCycleResult degrade(Instant now) { + return degrade("EVIDENCE_STORAGE_UNAVAILABLE", now); + } + + private ShadowCycleResult degrade(String reasonCode, Instant now) { + degradedReason.compareAndSet(null, reasonCode); + String stoppedReason = degradedReason.get(); + statusReport.markDegraded(stoppedReason, now); + return ShadowCycleResult.degraded(stoppedReason); + } + + private static String failureReason(RuntimeException failure) { + if (failure instanceof ShadowEvidenceException) { + String reasonCode = ((ShadowEvidenceException) failure) + .getReasonCode(); + if ("EVIDENCE_KEY_COLLISION".equals(reasonCode) + || "EVIDENCE_RUN_COLLISION".equals(reasonCode)) { + return reasonCode; + } + } + return "EVIDENCE_STORAGE_UNAVAILABLE"; + } + + private ShadowCycleResult runWithoutEvidence(ShadowFactBatch batch, + Instant now) { ShadowFactValidation validation = validator.validate(batch, now); if (validation.getStatus() != ShadowFactValidation.Status.COMPARABLE) { diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/RecordingFt546CommandGateway.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/RecordingFt546CommandGateway.java index 83ae982..a35759b 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/RecordingFt546CommandGateway.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/RecordingFt546CommandGateway.java @@ -44,16 +44,28 @@ public final class RecordingFt546CommandGateway { public synchronized List recordAll( List intents) { + List encoded = previewAll(intents); + rememberAll(encoded); + return encoded; + } + + public synchronized List previewAll( + List intents) { Objects.requireNonNull(intents, "intents"); List encoded = new ArrayList(intents.size()); for (Ft546SrgvCommandIntent intent : intents) { encoded.add(preview(intent)); } - recorded.addAll(encoded); return Collections.unmodifiableList(encoded); } + public synchronized void rememberAll( + List encodedCommands) { + recorded.addAll(new ArrayList( + Objects.requireNonNull(encodedCommands, "encodedCommands"))); + } + public synchronized List getRecorded() { return Collections.unmodifiableList( new ArrayList(recorded)); diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCycleResult.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCycleResult.java index 0396936..07f09ad 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCycleResult.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCycleResult.java @@ -47,6 +47,12 @@ public final class ShadowCycleResult { encodedCommands); } + public static ShadowCycleResult degraded(String reasonCode) { + return new ShadowCycleResult("DEGRADED", reasonCode, null, + Collections.emptyList(), + Collections.emptyList()); + } + public String getStatusCode() { return statusCode; } public String getReasonCode() { return reasonCode; } public DispatchCycleResult getDecisions() { return decisions; } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowDatabaseIdentity.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowDatabaseIdentity.java index b65e431..033a6c5 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowDatabaseIdentity.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowDatabaseIdentity.java @@ -100,6 +100,16 @@ public final class ShadowDatabaseIdentity { } } + public void requireRunMetadataMatches(String configuredServer, + String configuredDatabase) { + if (!serverName.equals(normalize(configuredServer))) { + throw failure("SHADOW_RUN_SOURCE_SERVER_MISMATCH"); + } + if (!databaseName.equals(normalize(configuredDatabase))) { + throw failure("SHADOW_RUN_SOURCE_DATABASE_MISMATCH"); + } + } + private String physicalKey() { return serverName + ':' + port + '/' + databaseName; } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupGate.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupGate.java index 9916246..6d20b83 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupGate.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupGate.java @@ -32,6 +32,21 @@ public final class ShadowStartupGate { "SHADOW_SOURCE_USERNAME_REQUIRED"); requireText(properties.getEvidence().getUsername(), "SHADOW_EVIDENCE_USERNAME_REQUIRED"); + requireText(properties.getEvidence().getOutputDirectory(), + "SHADOW_EVIDENCE_OUTPUT_DIRECTORY_REQUIRED"); + requireNonNegative(properties.getEvidence().getMinimumFreeBytes(), + "SHADOW_EVIDENCE_MINIMUM_FREE_BYTES_INVALID"); + ShadowProperties.RunMetadata run = properties.getEvidence().getRun(); + requireHash(run.getArtifactSha256(), + "SHADOW_ARTIFACT_SHA256_REQUIRED", + "SHADOW_ARTIFACT_SHA256_INVALID"); + requireHash(run.getConfigurationSha256(), + "SHADOW_CONFIGURATION_SHA256_REQUIRED", + "SHADOW_CONFIGURATION_SHA256_INVALID"); + requireText(run.getSourceServer(), + "SHADOW_SOURCE_SERVER_REQUIRED"); + requireText(run.getSourceDatabase(), + "SHADOW_SOURCE_DATABASE_REQUIRED"); requireLoopback(environment.getProperty("server.address", "127.0.0.1")); requireNoBeans(context, DeviceCommandGateway.class, @@ -104,6 +119,20 @@ public final class ShadowStartupGate { } } + private static void requireHash(String value, String requiredReasonCode, + String invalidReasonCode) { + requireText(value, requiredReasonCode); + if (!value.trim().matches("(?i)[0-9a-f]{64}")) { + throw failure(invalidReasonCode); + } + } + + private static void requireNonNegative(Long value, String reasonCode) { + if (value == null || value.longValue() < 0L) { + throw failure(reasonCode); + } + } + private static IllegalStateException failure(String reasonCode) { return new IllegalStateException(reasonCode); } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupReport.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupReport.java index f39ce44..07be770 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupReport.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupReport.java @@ -8,11 +8,12 @@ import java.util.Map; public final class ShadowStartupReport { public enum Status { STARTING, READY, DEGRADED, STOPPED } - private final Status status; - private final Instant checkedAt; - private final Instant lastCompletedCycle; - private final Long sourceLagMillis; - private final long evidenceBacklog; + private volatile Status status; + private volatile Instant checkedAt; + private volatile Instant lastCompletedCycle; + private volatile Long sourceLagMillis; + private volatile long evidenceBacklog; + private volatile String reasonCode; private final Map startupChecks; private ShadowStartupReport(Status status, Instant checkedAt, @@ -24,6 +25,7 @@ public final class ShadowStartupReport { this.lastCompletedCycle = lastCompletedCycle; this.sourceLagMillis = sourceLagMillis; this.evidenceBacklog = evidenceBacklog; + this.reasonCode = null; this.startupChecks = Collections.unmodifiableMap( new LinkedHashMap(startupChecks)); } @@ -39,5 +41,26 @@ public final class ShadowStartupReport { public Instant getLastCompletedCycle() { return lastCompletedCycle; } public Long getSourceLagMillis() { return sourceLagMillis; } public long getEvidenceBacklog() { return evidenceBacklog; } + public String getReasonCode() { return reasonCode; } public Map getStartupChecks() { return startupChecks; } + + public synchronized void markReady(Instant completedAt, + Long sourceLagMillis) { + this.status = Status.READY; + this.checkedAt = completedAt; + this.lastCompletedCycle = completedAt; + this.sourceLagMillis = sourceLagMillis; + this.reasonCode = null; + } + + public synchronized void markDegraded(String reasonCode, + Instant checkedAt) { + if (reasonCode == null || reasonCode.trim().isEmpty()) { + throw new IllegalArgumentException( + "reasonCode must not be blank"); + } + this.status = Status.DEGRADED; + this.checkedAt = checkedAt; + this.reasonCode = reasonCode.trim(); + } } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/SqlServerShadowDatabaseGate.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/SqlServerShadowDatabaseGate.java index ad960f2..f6716fa 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/SqlServerShadowDatabaseGate.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/SqlServerShadowDatabaseGate.java @@ -69,6 +69,9 @@ public final class SqlServerShadowDatabaseGate implements ShadowDatabaseGate { sourceIdentity.getDatabaseName(), "EVIDENCE_LOGIN_CAN_ACCESS_SOURCE_DATABASE"); } + sourceIdentity.requireRunMetadataMatches( + properties.getEvidence().getRun().getSourceServer(), + properties.getEvidence().getRun().getSourceDatabase()); PermissionCheckResult sourceResult = permissionProbe.verifySource(source); diff --git a/ruoyi-wcs-shadow/src/main/resources/application-shadow.yml b/ruoyi-wcs-shadow/src/main/resources/application-shadow.yml index 264147d..83847c4 100644 --- a/ruoyi-wcs-shadow/src/main/resources/application-shadow.yml +++ b/ruoyi-wcs-shadow/src/main/resources/application-shadow.yml @@ -14,3 +14,10 @@ shadow: jdbc-url: username: password: + output-directory: + minimum-free-bytes: + run: + artifact-sha256: + configuration-sha256: + source-server: + source-database: diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java index 126df98..cfb4133 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java @@ -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 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 trafficDependencies = Stream.of( + context.getBeanFactory().getDependenciesForBean( + "shadowTrafficStateRepository")) + .collect(Collectors.toSet()); + Set 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( diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepositoryTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepositoryTest.java new file mode 100644 index 0000000..984fb74 --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepositoryTest.java @@ -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 forward = new LinkedHashMap(); + forward.put("taskNo", "30001"); + forward.put("taskType", "1"); + Map reverse = new LinkedHashMap(); + 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 differences) { + Map fields = new LinkedHashMap(); + 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 differences) { + return cycle(legacy, difference, differences, encoded()); + } + + private ShadowCycleEvidence cycle(ShadowLegacyQueueResult legacy, + ShadowDifference difference, + List 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 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 capabilities = new LinkedHashSet( + Collections.singletonList("POINT_TO_POINT_COMMAND")); + Ft546DeviceProfile profile = new Ft546DeviceProfile(); + profile.setCapabilities(capabilities); + Map registers = new LinkedHashMap(); + 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 fields = new LinkedHashMap(); + fields.put("taskNo", "30001"); + fields.put("taskType", "1"); + return legacy(fields); + } + + private static RecordingFt546CommandGateway recordingGateway() { + return new RecordingFt546CommandGateway( + new Ft546SrgvCommandEncoder(), new Ft546DeviceProfile(), + Collections.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( + 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 mainWrites() { + List writes = + new ArrayList(); + 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 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))"); + } +} diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceStorageHealthTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceStorageHealthTest.java new file mode 100644 index 0000000..6101e91 --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceStorageHealthTest.java @@ -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 saveAll( + List evidence) { + throw unsupported(); + } + + @Override + public T inTransaction(Supplier 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"); + } +} diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/ShadowComparisonServiceTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/ShadowComparisonServiceTest.java new file mode 100644 index 0000000..220305d --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/ShadowComparisonServiceTest.java @@ -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 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 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 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 writes = new ArrayList(); + 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 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 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 path, String taskNo, + String taskType, List writes) { + Map fields = new LinkedHashMap(); + 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 capabilities = new LinkedHashSet( + Collections.singletonList("POINT_TO_POINT_COMMAND")); + Ft546DeviceProfile profile = new Ft546DeviceProfile(); + profile.setCapabilities(capabilities); + Map registers = new LinkedHashMap(); + 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 mainWrites() { + return new ArrayList(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 points(Integer... values) { + return Arrays.asList(values); + } + + private static void assertCategories( + List expected, + List actual) { + List categories = + new ArrayList(); + for (ShadowDifference difference : actual) { + categories.add(difference.getCategory()); + } + assertEquals(expected, categories); + } +} diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/Ft546ShadowCycleTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/Ft546ShadowCycleTest.java index d7134fe..019c61c 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/Ft546ShadowCycleTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/Ft546ShadowCycleTest.java @@ -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(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 readOnly = + new ReadOnlyDispatchCycle( + 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 saved = + new java.util.ArrayList(); + 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 saveAll(List values) { + assertTrue(insideTransaction, + "evidence must be saved inside the outer transaction"); + if (saveFailure != null) { + throw saveFailure; + } + List result = new java.util.ArrayList(); + for (ShadowCycleEvidence value : values) { + saved.add(value); + result.add(new SavedCycle(value.getCycleId(), + value.getEvidenceHash(), value.getStatus(), + value.getReasonCode())); + } + return result; + } + + @Override + public T inTransaction(Supplier 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; + } + } } diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/startup/SqlServerShadowDatabaseGateTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/startup/SqlServerShadowDatabaseGateTest.java index 390bf08..1da7387 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/startup/SqlServerShadowDatabaseGateTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/startup/SqlServerShadowDatabaseGateTest.java @@ -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; } } diff --git a/ruoyi-wcs-shadow/src/test/resources/application-shadow-test.yml b/ruoyi-wcs-shadow/src/test/resources/application-shadow-test.yml index ba7594e..d6936f1 100644 --- a/ruoyi-wcs-shadow/src/test/resources/application-shadow-test.yml +++ b/ruoyi-wcs-shadow/src/test/resources/application-shadow-test.yml @@ -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