feat(ft546): persist shadow comparison evidence

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

View File

@@ -1,5 +1,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());
}
}

View File

@@ -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<Ft546SrgvCommandIntent>
shadowReadOnlyDispatchCycle(
@@ -151,8 +166,14 @@ public class ShadowKernelConfiguration {
ShadowFactBatchValidator validator,
ShadowDispatchSnapshotAssembler assembler,
ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> 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);
}
}

View File

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

View File

@@ -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<SavedCycle> saveAll(List<ShadowCycleEvidence> 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<SavedCycle> 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> T inTransaction(Supplier<T> 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<SavedCycle> 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<Map<String, Object>> 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<? "
+ "ORDER BY c.input_batch_hash,c.parent_task_id,"
+ "c.phase,c.route_version,d.difference_id",
run.getRunId(), Timestamp.from(start), Timestamp.from(end));
Map<UUID, String> cycles = new LinkedHashMap<UUID, String>();
Map<ShadowDifferenceCategory, Long> categories =
new EnumMap<ShadowDifferenceCategory, Long>(
ShadowDifferenceCategory.class);
for (Map<String, Object> 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<String>(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<SavedCycle> saveAllInTransaction(
List<ShadowCycleEvidence> evidence) {
ensureRun();
List<SavedCycle> result = new ArrayList<SavedCycle>(evidence.size());
List<ShadowCycleEvidence> inserts =
new ArrayList<ShadowCycleEvidence>();
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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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<SavedCycle> resolveReplays(
List<ShadowCycleEvidence> evidence) {
List<SavedCycle> result = new ArrayList<SavedCycle>(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();
}
}

View File

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

View File

@@ -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<ShadowDifference> compare(
ShadowLegacyQueueResult legacy,
ShadowDecision decision,
Ft546EncodedCommand encoded) {
Objects.requireNonNull(legacy, "legacy");
Objects.requireNonNull(decision, "decision");
List<ShadowDifference> differences =
new ArrayList<ShadowDifference>();
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<ShadowDifference> 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<ShadowDifference> 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<ShadowDifference> 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<LegacyRegisterWrite> legacy,
List<Ft546RegisterWrite> encoded,
List<ShadowDifference> differences) {
if (legacy.size() != encoded.size()) {
differences.add(difference(ShadowDifferenceCategory.PROTOCOL_ORDER,
"REGISTER_WRITE_COUNT_MISMATCH", json(legacy.size()),
json(encoded.size())));
return;
}
List<String> legacyAddresses = legacyAddresses(legacy);
List<String> 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<String, Integer> addressOccurrences =
new LinkedHashMap<String, Integer>();
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<String> legacyAddresses(
List<LegacyRegisterWrite> writes) {
List<String> values = new ArrayList<String>();
for (LegacyRegisterWrite write : writes) {
values.add(write.getAddress());
}
return values;
}
private static List<String> encodedAddresses(
List<Ft546RegisterWrite> writes) {
List<String> values = new ArrayList<String>();
for (Ft546RegisterWrite write : writes) {
values.add(write.getAddress());
}
return values;
}
private static Map<String, Integer> counts(List<String> values) {
Map<String, Integer> counts = new LinkedHashMap<String, Integer>();
for (String value : values) {
Integer count = counts.get(value);
counts.put(value, count == null ? 1 : count + 1);
}
return counts;
}
private static int nextOccurrence(Map<String, Integer> 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<Ft546RegisterWrite> 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<ShadowDifference> immutableSorted(
List<ShadowDifference> 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<ShadowDifference>(source));
}
private static String valueJson(List<Integer> 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;
}
}

View File

@@ -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<Ft546RegisterWrite> encodedWrites;
private final String encodedHash;
private final List<ShadowDifference> differences;
private ShadowCycleEvidence(UUID runId, ShadowFactBatch batch,
ShadowLegacyQueueResult legacy,
ShadowDecision decision,
Ft546EncodedCommand encoded,
List<ShadowDifference> 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.<Ft546RegisterWrite>emptyList()
: immutable(encoded.getWrites());
this.encodedHash = ShadowEvidenceDigest.sha256(
ShadowEvidenceDigest.canonicalJson(objectMapper,
encodedPayload(this.encodedWrites)));
this.differences = immutableDifferences(differences);
Map<String, Object> digest = new LinkedHashMap<String, Object>();
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<ShadowDifference> 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<Ft546RegisterWrite> getEncodedWrites() {
return encodedWrites;
}
public String getEncodedHash() { return encodedHash; }
public List<ShadowDifference> getDifferences() { return differences; }
private static Map<String, Object> inputPayload(
ShadowFactBatch batch, ShadowDecision decision) {
Map<String, Object> payload = new LinkedHashMap<String, Object>();
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<Map<String, Object>> queues(
List<ShadowQueueFact> values) {
List<Map<String, Object>> result =
new ArrayList<Map<String, Object>>();
for (ShadowQueueFact value : values) {
Map<String, Object> row = new LinkedHashMap<String, Object>();
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<Map<String, Object>> vehicles(
List<ShadowVehicleFact> values) {
List<Map<String, Object>> result =
new ArrayList<Map<String, Object>>();
for (ShadowVehicleFact value : values) {
Map<String, Object> row = new LinkedHashMap<String, Object>();
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<Map<String, Object>> maps(List<ShadowMapFact> values) {
List<Map<String, Object>> result =
new ArrayList<Map<String, Object>>();
for (ShadowMapFact value : values) {
Map<String, Object> row = new LinkedHashMap<String, Object>();
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<String, Object> legacyPayload(
ShadowLegacyQueueResult legacy) {
Map<String, Object> payload = new LinkedHashMap<String, Object>();
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<Map<String, Object>> writes =
new ArrayList<Map<String, Object>>();
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<String, Object> decisionPayload(
ShadowDecision decision, Ft546EncodedCommand encoded) {
Map<String, Object> payload = new LinkedHashMap<String, Object>();
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<String, Object> command = new LinkedHashMap<String, Object>();
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<Map<String, Object>> differencePayload(
List<ShadowDifference> values) {
List<Map<String, Object>> result =
new ArrayList<Map<String, Object>>();
for (ShadowDifference value : values) {
Map<String, Object> row = new LinkedHashMap<String, Object>();
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<Map<String, Object>> encodedPayload(
List<Ft546RegisterWrite> values) {
List<Map<String, Object>> result =
new ArrayList<Map<String, Object>>();
for (Ft546RegisterWrite value : values) {
result.add(writePayload(value.getAddress(),
value.getDataType().name(), value.getValues(),
value.getWriteSequence(), value.isTrigger()));
}
return result;
}
private static Map<String, Object> writePayload(
String address, String dataType, List<Integer> values,
int sequence, boolean trigger) {
Map<String, Object> row = new LinkedHashMap<String, Object>();
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<Integer> 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<Ft546RegisterWrite> immutable(
List<Ft546RegisterWrite> values) {
return Collections.unmodifiableList(
new ArrayList<Ft546RegisterWrite>(values));
}
private static List<ShadowDifference> immutableDifferences(
List<ShadowDifference> values) {
if (values == null || values.isEmpty()) {
throw new IllegalArgumentException(
"differences must not be empty");
}
return Collections.unmodifiableList(
new ArrayList<ShadowDifference>(values));
}
}

View File

@@ -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<ShadowCycleEvidence> collect(
ShadowFactBatch batch, DispatchCycleResult decisions,
List<Ft546EncodedCommand> 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<ShadowCycleEvidence> evidence =
new ArrayList<ShadowCycleEvidence>();
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<ShadowCycleEvidence> insufficient(
ShadowFactBatch batch, String reasonCode, Instant completedAt) {
Objects.requireNonNull(batch, "batch");
List<ShadowCycleEvidence> result =
new ArrayList<ShadowCycleEvidence>();
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<ShadowCycleEvidence> evidence,
ShadowFactBatch batch, List<HeldDispatch> 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<ShadowDifference> 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<Integer> route(HeldDispatch held) {
if (held.getYieldRoute() == null) {
return Collections.emptyList();
}
List<Integer> result = new ArrayList<Integer>();
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();
}
}

View File

@@ -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<Integer> routePoints;
private final String reasonCode;
private ShadowDecision(Type type, String parentTaskId, String phase,
long routeVersion, String vehicleId,
List<Integer> 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<Integer> 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.<Integer>emptyList(),
required(reasonCode, "reasonCode"));
}
public static ShadowDecision yielded(String parentTaskId, String phase,
long routeVersion, String vehicleId,
List<Integer> 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.<Integer>emptyList(),
required(reasonCode, "reasonCode"));
}
public static ShadowDecision sourceGap(String reasonCode) {
return new ShadowDecision(Type.SOURCE_GAP, "SOURCE", "SOURCE_READ",
0L, null, Collections.<Integer>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<Integer> getRoutePoints() { return routePoints; }
public String getReasonCode() { return reasonCode; }
private static List<Integer> immutable(List<Integer> values) {
if (values == null) {
throw new IllegalArgumentException("routePoints must not be null");
}
return Collections.unmodifiableList(new ArrayList<Integer>(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();
}
}

View File

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

View File

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

View File

@@ -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<ShadowDifferenceCategory, Long> categoryCounts;
private final String evidenceHash;
ShadowEvidenceDigest(long cycleCount,
Map<ShadowDifferenceCategory, Long> categoryCounts,
String evidenceHash) {
this.cycleCount = cycleCount;
this.categoryCounts = Collections.unmodifiableMap(
new EnumMap<ShadowDifferenceCategory, Long>(categoryCounts));
this.evidenceHash = evidenceHash;
}
public long getCycleCount() { return cycleCount; }
public Map<ShadowDifferenceCategory, Long> getCategoryCounts() {
return categoryCounts;
}
public String getEvidenceHash() { return evidenceHash; }
static ShadowEvidenceDigest create(
List<String> cycleHashes,
Map<ShadowDifferenceCategory, Long> categoryCounts,
ObjectMapper objectMapper) {
Map<String, Object> payload = new LinkedHashMap<String, Object>();
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<String, Long> namedCounts(
Map<ShadowDifferenceCategory, Long> values) {
Map<String, Long> result = new LinkedHashMap<String, Long>();
for (ShadowDifferenceCategory category
: ShadowDifferenceCategory.values()) {
Long count = values.get(category);
if (count != null) {
result.put(category.name(), count);
}
}
return result;
}
}

View File

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

View File

@@ -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<SavedCycle> saveAll(List<ShadowCycleEvidence> evidence);
<T> T inTransaction(Supplier<T> 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; }
}
}

View File

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

View File

@@ -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> T required(T value, String name) {
if (value == null) {
throw new IllegalArgumentException(name + " must not be null");
}
return value;
}
}

View File

@@ -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<Integer> 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<Integer> paddedPath,
List<ShadowLegacyQueueResult.LegacyRegisterWrite> writes) {
Map<String, String> fields = new LinkedHashMap<String, String>();
fields.put("taskNo", Integer.toString(taskNo));
fields.put("taskType", Integer.toString(taskType));
List<Integer> path = new ArrayList<Integer>();
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;

View File

@@ -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<Integer> pathPoints;
private final Map<String, String> queueFields;
private final List<LegacyRegisterWrite> writes;
private ShadowLegacyQueueResult(Status status, String reasonCode,
String vehicleId,
List<Integer> pathPoints,
Map<String, String> queueFields,
List<LegacyRegisterWrite> writes) {
this.status = status;
this.reasonCode = reasonCode;
this.vehicleId = vehicleId;
this.pathPoints = Collections.unmodifiableList(
new ArrayList<Integer>(pathPoints));
this.queueFields = Collections.unmodifiableMap(
new LinkedHashMap<String, String>(queueFields));
this.writes = Collections.unmodifiableList(
new ArrayList<LegacyRegisterWrite>(writes));
}
public static ShadowLegacyQueueResult available(
List<LegacyRegisterWrite> writes) {
return available(null, Collections.<Integer>emptyList(),
Collections.<String, String>emptyMap(), writes);
}
public static ShadowLegacyQueueResult available(
String vehicleId, List<Integer> pathPoints,
Map<String, String> queueFields,
List<LegacyRegisterWrite> 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.<Integer>emptyList(),
Collections.<String, String>emptyMap(),
Collections.<LegacyRegisterWrite>emptyList());
}
public Status getStatus() { return status; }
public String getReasonCode() { return reasonCode; }
public String getVehicleId() { return vehicleId; }
public List<Integer> getPathPoints() { return pathPoints; }
public Map<String, String> getQueueFields() { return queueFields; }
public List<LegacyRegisterWrite> 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<Integer> 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<Integer> getValues() { return values; }
public int getWriteSequence() { return writeSequence; }
public boolean isTrigger() { return trigger; }
}
private static <T> T required(T value, String name) {
if (value == null) {
throw new IllegalArgumentException(name + " must not be null");
}
return value;
}
}

View File

@@ -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<ShadowFactBatch, DispatchSnapshot> assembler;
private final ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> dispatchCycle;
private final RecordingFt546CommandGateway commandGateway;
private final ShadowEvidenceStorageHealth storageHealth;
private final ShadowEvidenceRepository evidenceRepository;
private final ShadowCycleEvidenceCollector evidenceCollector;
private final ShadowStartupReport statusReport;
private final AtomicReference<String> degradedReason =
new AtomicReference<String>();
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<ShadowFactBatch, DispatchSnapshot> assembler,
ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> 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<Ft546SrgvCommandIntent> result;
try {
result = dispatchCycle.run(snapshot);
} catch (IllegalArgumentException failure) {
return persistInsufficient(batch,
"INVALID_COMMAND_FACTS:" + message(failure), now);
}
if (result.getDecisions().isRetrySnapshotChanged()) {
List<ShadowCycleEvidence> evidence = evidenceCollector.insufficient(
batch, "SHADOW_TRAFFIC_STATE_CHANGED", now);
evidenceRepository.saveAll(evidence);
return ShadowCycleResult.retry(result.getDecisions());
}
final List<Ft546EncodedCommand> 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) {

View File

@@ -44,16 +44,28 @@ public final class RecordingFt546CommandGateway {
public synchronized List<Ft546EncodedCommand> recordAll(
List<Ft546SrgvCommandIntent> intents) {
List<Ft546EncodedCommand> encoded = previewAll(intents);
rememberAll(encoded);
return encoded;
}
public synchronized List<Ft546EncodedCommand> previewAll(
List<Ft546SrgvCommandIntent> intents) {
Objects.requireNonNull(intents, "intents");
List<Ft546EncodedCommand> encoded =
new ArrayList<Ft546EncodedCommand>(intents.size());
for (Ft546SrgvCommandIntent intent : intents) {
encoded.add(preview(intent));
}
recorded.addAll(encoded);
return Collections.unmodifiableList(encoded);
}
public synchronized void rememberAll(
List<Ft546EncodedCommand> encodedCommands) {
recorded.addAll(new ArrayList<Ft546EncodedCommand>(
Objects.requireNonNull(encodedCommands, "encodedCommands")));
}
public synchronized List<Ft546EncodedCommand> getRecorded() {
return Collections.unmodifiableList(
new ArrayList<Ft546EncodedCommand>(recorded));

View File

@@ -47,6 +47,12 @@ public final class ShadowCycleResult {
encodedCommands);
}
public static ShadowCycleResult degraded(String reasonCode) {
return new ShadowCycleResult("DEGRADED", reasonCode, null,
Collections.<Ft546SrgvCommandIntent>emptyList(),
Collections.<Ft546EncodedCommand>emptyList());
}
public String getStatusCode() { return statusCode; }
public String getReasonCode() { return reasonCode; }
public DispatchCycleResult getDecisions() { return decisions; }

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,3 +14,10 @@ shadow:
jdbc-url:
username:
password:
output-directory:
minimum-free-bytes:
run:
artifact-sha256:
configuration-sha256:
source-server:
source-database:

View File

@@ -1,6 +1,10 @@
package com.ruoyi.shadow;
import com.ruoyi.project.shadowtest.ForbiddenFactoryBean;
import com.ruoyi.shadow.evidence.ShadowCycleEvidenceCollector;
import com.ruoyi.shadow.evidence.ShadowEvidenceRepository;
import com.ruoyi.shadow.evidence.ShadowEvidenceStorageHealth;
import com.ruoyi.shadow.evidence.ShadowRunEvidence;
import com.ruoyi.shadow.startup.ShadowDatabaseGate;
import com.ruoyi.shadow.startup.ShadowStartupReport;
import com.ruoyi.shadow.web.ShadowStatusController;
@@ -23,6 +27,9 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -34,6 +41,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class Ft546ShadowApplicationTest {
private static final String ARTIFACT_SHA256 =
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
private static final String CONFIGURATION_SHA256 =
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
private final WebApplicationContextRunner contextRunner =
new WebApplicationContextRunner()
.withInitializer(
@@ -74,6 +86,29 @@ class Ft546ShadowApplicationTest {
assertNotNull(context.getBean(RecordingFt546CommandGateway.class));
assertNotNull(context.getBean(
JdbcShadowTrafficStateRepository.class));
assertNotNull(context.getBean(ShadowRunEvidence.class));
assertNotNull(context.getBean(ShadowEvidenceRepository.class));
assertNotNull(context.getBean(ShadowEvidenceStorageHealth.class));
assertNotNull(context.getBean(ShadowCycleEvidenceCollector.class));
Set<String> cycleDependencies = Stream.of(context.getBeanFactory()
.getDependenciesForBean("ft546ShadowCycle"))
.collect(Collectors.toSet());
assertTrue(cycleDependencies.contains("shadowEvidenceRepository"));
assertTrue(cycleDependencies.contains(
"shadowEvidenceStorageHealth"));
assertTrue(cycleDependencies.contains(
"shadowCycleEvidenceCollector"));
assertTrue(cycleDependencies.contains("shadowStartupReport"));
Set<String> trafficDependencies = Stream.of(
context.getBeanFactory().getDependenciesForBean(
"shadowTrafficStateRepository"))
.collect(Collectors.toSet());
Set<String> evidenceDependencies = Stream.of(
context.getBeanFactory().getDependenciesForBean(
"shadowEvidenceRepository"))
.collect(Collectors.toSet());
assertTrue(trafficDependencies.contains("shadowEvidenceTx"));
assertTrue(evidenceDependencies.contains("shadowEvidenceTx"));
assertEquals(context.getBean(
JdbcShadowTrafficStateRepository.class),
context.getBean(com.ruoyi.wcs.port.out
@@ -141,13 +176,25 @@ class Ft546ShadowApplicationTest {
assertStartupFailure(configured(contextRunner).withPropertyValues(
"shadow.evidence.username="),
"SHADOW_EVIDENCE_USERNAME_REQUIRED");
assertStartupFailure(contextRunner.withPropertyValues(
"spring.profiles.active=shadow",
"shadow.enabled=true",
"shadow.source.jdbc-url=jdbc:h2:mem:source",
"shadow.evidence.jdbc-url=jdbc:h2:mem:evidence",
"shadow.source.username=source",
"shadow.evidence.username=evidence",
assertStartupFailure(configured(contextRunner).withPropertyValues(
"shadow.evidence.output-directory="),
"SHADOW_EVIDENCE_OUTPUT_DIRECTORY_REQUIRED");
assertStartupFailure(configured(contextRunner).withPropertyValues(
"shadow.evidence.minimum-free-bytes=-1"),
"SHADOW_EVIDENCE_MINIMUM_FREE_BYTES_INVALID");
assertStartupFailure(configured(contextRunner).withPropertyValues(
"shadow.evidence.run.artifact-sha256=invalid"),
"SHADOW_ARTIFACT_SHA256_INVALID");
assertStartupFailure(configured(contextRunner).withPropertyValues(
"shadow.evidence.run.configuration-sha256="),
"SHADOW_CONFIGURATION_SHA256_REQUIRED");
assertStartupFailure(configured(contextRunner).withPropertyValues(
"shadow.evidence.run.source-server="),
"SHADOW_SOURCE_SERVER_REQUIRED");
assertStartupFailure(configured(contextRunner).withPropertyValues(
"shadow.evidence.run.source-database="),
"SHADOW_SOURCE_DATABASE_REQUIRED");
assertStartupFailure(configured(contextRunner).withPropertyValues(
"server.address=0.0.0.0"),
"SHADOW_STATUS_ADDRESS_MUST_BE_LOOPBACK");
}
@@ -185,7 +232,14 @@ class Ft546ShadowApplicationTest {
"shadow.source.jdbc-url=jdbc:h2:mem:source",
"shadow.evidence.jdbc-url=jdbc:h2:mem:evidence",
"shadow.source.username=source",
"shadow.evidence.username=evidence");
"shadow.evidence.username=evidence",
"shadow.evidence.output-directory=.",
"shadow.evidence.minimum-free-bytes=0",
"shadow.evidence.run.artifact-sha256=" + ARTIFACT_SHA256,
"shadow.evidence.run.configuration-sha256="
+ CONFIGURATION_SHA256,
"shadow.evidence.run.source-server=ft546-test-server",
"shadow.evidence.run.source-database=ft546-test-source");
}
private static void assertStartupFailure(

View File

@@ -0,0 +1,470 @@
package com.ruoyi.shadow.evidence;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.ft546.protocol.Ft546EncodedCommand;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
import com.ruoyi.shadow.facts.ShadowFactBatch;
import com.ruoyi.shadow.facts.ShadowLegacyQueueResult;
import com.ruoyi.shadow.facts.ShadowLegacyQueueResult.DataType;
import com.ruoyi.shadow.facts.ShadowLegacyQueueResult.LegacyRegisterWrite;
import com.ruoyi.shadow.facts.ShadowQueueFact;
import com.ruoyi.shadow.kernel.JdbcShadowTrafficStateRepository;
import com.ruoyi.shadow.kernel.RecordingFt546CommandGateway;
import com.ruoyi.shadow.kernel.ShadowCommandIntentFactory;
import com.ruoyi.wcs.application.dispatch.DispatchCommit;
import com.ruoyi.wcs.application.dispatch.HeldDispatch;
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
import com.ruoyi.wcs.domain.topology.TopologyCompiler;
import com.ruoyi.wcs.domain.topology.TopologyDefinition;
import com.ruoyi.wcs.domain.topology.TopologyEdge;
import com.ruoyi.wcs.domain.topology.TopologyNode;
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
import com.ruoyi.wcs.domain.traffic.TrafficDecisionType;
import com.ruoyi.wcs.domain.traffic.WaitForGraph;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.sql.SQLException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JdbcShadowEvidenceRepositoryTest {
private static final Instant NOW =
Instant.parse("2026-08-14T06:00:00Z");
private JdbcTemplate jdbc;
private TransactionTemplate transaction;
private ObjectMapper objectMapper;
private ShadowRunEvidence run;
private JdbcShadowEvidenceRepository repository;
@BeforeEach
void setUp() {
JdbcDataSource dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:" + UUID.randomUUID()
+ ";MODE=MSSQLServer;DB_CLOSE_DELAY=-1");
jdbc = new JdbcTemplate(dataSource);
transaction = new TransactionTemplate(
new DataSourceTransactionManager(dataSource));
objectMapper = new ObjectMapper().findAndRegisterModules();
createSchema();
run = new ShadowRunEvidence(UUID.randomUUID(), NOW.minusSeconds(60),
repeat('a'), repeat('b'), "source-host", "FT546-wcs",
"ft546_shadow_ro", "RUNNING");
repository = new JdbcShadowEvidenceRepository(jdbc, transaction,
objectMapper, run);
}
@Test
void sqlServerDuplicateKeyCodesAreRecognizedWithoutBroadeningIntegrityErrors() {
assertTrue(JdbcShadowEvidenceRepository.isDuplicateKey(
new DataIntegrityViolationException("duplicate",
new SQLException("duplicate", "23000", 2627))));
assertTrue(JdbcShadowEvidenceRepository.isDuplicateKey(
new DataIntegrityViolationException("duplicate",
new SQLException("duplicate", "23000", 2601))));
assertFalse(JdbcShadowEvidenceRepository.isDuplicateKey(
new DataIntegrityViolationException("foreign key",
new SQLException("foreign key", "23000", 547))));
}
@Test
void completeCycleCommitsEveryEvidenceRowAndDailyDigest() {
ShadowCycleEvidence cycle = matchingCycle();
ShadowEvidenceRepository.SavedCycle saved = repository.save(cycle);
assertEquals(cycle.getCycleId(), saved.getCycleId());
assertEquals(1, count("shadow_run"));
assertEquals(1, count("shadow_cycle"));
assertEquals(1, count("shadow_input_batch"));
assertEquals(1, count("shadow_legacy_queue"));
assertEquals(1, count("shadow_decision"));
assertEquals(4, count("shadow_encoded_write"));
assertEquals(1, count("shadow_difference"));
ShadowEvidenceDigest digest = repository.dailyDigest(
LocalDate.of(2026, 8, 14), ZoneOffset.UTC);
assertEquals(1L, digest.getCycleCount());
assertEquals(Long.valueOf(1L), digest.getCategoryCounts().get(
ShadowDifferenceCategory.MATCH));
assertEquals(64, digest.getEvidenceHash().length());
}
@Test
void sameIdentityAndHashReplayReturnsTheExistingCycle() {
ShadowCycleEvidence first = matchingCycle();
ShadowCycleEvidence replay = matchingCycle();
assertNotEquals(first.getCycleId(), replay.getCycleId());
assertEquals(first.getEvidenceHash(), replay.getEvidenceHash());
ShadowEvidenceRepository.SavedCycle firstSaved = repository.save(first);
ShadowEvidenceRepository.SavedCycle replaySaved =
repository.save(replay);
assertEquals(firstSaved.getCycleId(), replaySaved.getCycleId());
assertEquals(first.getEvidenceHash(), replaySaved.getEvidenceHash());
assertEquals(1, count("shadow_cycle"));
}
@Test
void sameIdentityWithDifferentHashFailsClosed() {
ShadowCycleEvidence first = matchingCycle();
ShadowCycleEvidence collision = cycleWithDifferences(
Collections.singletonList(new ShadowDifference(
ShadowDifferenceCategory.ALGORITHM_VEHICLE,
"SELECTED_VEHICLE_MISMATCH", "\"VEH-1\"",
"\"VEH-2\"")));
assertEquals(first.getInputBatchHash(), collision.getInputBatchHash());
assertNotEquals(first.getEvidenceHash(), collision.getEvidenceHash());
repository.save(first);
ShadowEvidenceException failure = assertThrows(
ShadowEvidenceException.class,
() -> repository.save(collision));
assertEquals("EVIDENCE_KEY_COLLISION", failure.getReasonCode());
assertEquals(1, count("shadow_cycle"));
}
@Test
void childInsertFailureRollsBackTheWholeCycleAndRun() {
jdbc.execute("ALTER TABLE shadow_difference ADD CONSTRAINT "
+ "ck_test_category CHECK (category <> 'PROTOCOL_VALUE')");
ShadowCycleEvidence rejected = cycleWithDifferences(
Collections.singletonList(new ShadowDifference(
ShadowDifferenceCategory.PROTOCOL_VALUE,
"REGISTER_VALUE_MISMATCH:4002", "1", "9")));
assertThrows(RuntimeException.class, () -> repository.save(rejected));
assertEquals(0, count("shadow_run"));
assertEquals(0, count("shadow_cycle"));
assertEquals(0, count("shadow_input_batch"));
assertEquals(0, count("shadow_decision"));
}
@Test
void multiDecisionBatchUsesOneOuterEvidenceTransaction() {
jdbc.execute("ALTER TABLE shadow_difference ADD CONSTRAINT "
+ "ck_test_source_gap CHECK (category <> 'SOURCE_GAP')");
ShadowCycleEvidence first = matchingCycle();
ShadowCycleEvidence rejected = ShadowCycleEvidence.sourceGap(
run.getRunId(), "SOURCE_TIMEOUT", NOW.plusMillis(20),
objectMapper);
assertThrows(RuntimeException.class, () -> repository.inTransaction(
() -> repository.saveAll(Arrays.asList(first, rejected))));
assertEquals(0, count("shadow_run"));
assertEquals(0, count("shadow_cycle"));
assertEquals(0, count("shadow_difference"));
}
@Test
void evidenceFailureRollsBackTrafficStateAndEveryEvidenceTable() {
createTrafficSchema();
jdbc.execute("ALTER TABLE shadow_difference ADD CONSTRAINT "
+ "ck_test_outer_tx CHECK (category <> 'SOURCE_GAP')");
JdbcShadowTrafficStateRepository traffic =
new JdbcShadowTrafficStateRepository(jdbc, transaction,
objectMapper, java.time.Clock.fixed(NOW,
ZoneOffset.UTC), new ShadowCommandIntentFactory(),
recordingGateway());
DispatchCommit commit = trafficCommit();
ShadowCycleEvidence first = matchingCycle();
ShadowCycleEvidence rejected = ShadowCycleEvidence.sourceGap(
run.getRunId(), "SOURCE_TIMEOUT", NOW.plusMillis(20),
objectMapper);
AtomicBoolean trafficAccepted = new AtomicBoolean();
assertThrows(RuntimeException.class, () -> repository.inTransaction(
() -> {
trafficAccepted.set(traffic.commit(commit));
repository.saveAll(Arrays.asList(first, rejected));
return null;
}));
assertTrue(trafficAccepted.get());
assertEquals(0, count("shadow_traffic_state"));
assertEquals(0, count("shadow_hold_history"));
assertEquals(0, count("shadow_run"));
assertEquals(0, count("shadow_cycle"));
assertEquals(0, count("shadow_input_batch"));
assertEquals(0, count("shadow_legacy_queue"));
assertEquals(0, count("shadow_decision"));
assertEquals(0, count("shadow_encoded_write"));
assertEquals(0, count("shadow_difference"));
}
@Test
void persistedEvidenceIsQueryableAfterRepositoryRestart() {
ShadowCycleEvidence cycle = matchingCycle();
repository.save(cycle);
JdbcShadowEvidenceRepository restarted =
new JdbcShadowEvidenceRepository(jdbc, transaction,
objectMapper, run);
ShadowEvidenceRepository.SavedCycle found = restarted.findCycle(
run.getRunId(), cycle.getInputBatchHash(),
cycle.getParentTaskId(), cycle.getPhase(),
cycle.getRouteVersion());
ShadowEvidenceDigest first = repository.dailyDigest(
LocalDate.of(2026, 8, 14), ZoneOffset.UTC);
ShadowEvidenceDigest second = restarted.dailyDigest(
LocalDate.of(2026, 8, 14), ZoneOffset.UTC);
assertNotNull(found);
assertEquals(cycle.getEvidenceHash(), found.getEvidenceHash());
assertEquals(first.getEvidenceHash(), second.getEvidenceHash());
assertEquals(first.getCategoryCounts(), second.getCategoryCounts());
}
@Test
void canonicalEvidenceHashIgnoresMapInsertionOrder() {
Map<String, String> forward = new LinkedHashMap<String, String>();
forward.put("taskNo", "30001");
forward.put("taskType", "1");
Map<String, String> reverse = new LinkedHashMap<String, String>();
reverse.put("taskType", "1");
reverse.put("taskNo", "30001");
ShadowCycleEvidence first = cycle(legacy(forward), match());
ShadowCycleEvidence second = cycle(legacy(reverse), match());
assertEquals(first.getEvidenceHash(), second.getEvidenceHash());
assertEquals(first.getInputBatchHash(), second.getInputBatchHash());
}
@Test
void encodedWritesAlwaysParticipateInTheEvidenceHash() {
ShadowCycleEvidence first = cycle(legacyFields(), match(),
Collections.singletonList(match()), encoded("4002"));
ShadowCycleEvidence changed = cycle(legacyFields(), match(),
Collections.singletonList(match()), encoded("4999"));
assertNotEquals(first.getEvidenceHash(), changed.getEvidenceHash());
}
private ShadowCycleEvidence matchingCycle() {
return cycleWithDifferences(Collections.singletonList(match()));
}
private ShadowCycleEvidence cycleWithDifferences(
List<ShadowDifference> differences) {
Map<String, String> fields = new LinkedHashMap<String, String>();
fields.put("taskNo", "30001");
fields.put("taskType", "1");
return cycle(legacy(fields), differences.get(0), differences);
}
private ShadowCycleEvidence cycle(ShadowLegacyQueueResult legacy,
ShadowDifference difference) {
return cycle(legacy, difference,
Collections.singletonList(difference));
}
private ShadowCycleEvidence cycle(ShadowLegacyQueueResult legacy,
ShadowDifference difference,
List<ShadowDifference> differences) {
return cycle(legacy, difference, differences, encoded());
}
private ShadowCycleEvidence cycle(ShadowLegacyQueueResult legacy,
ShadowDifference difference,
List<ShadowDifference> differences,
Ft546EncodedCommand encoded) {
ShadowFactBatch batch = ShadowFactBatch.create(NOW.minusMillis(100), NOW,
Collections.singletonList(ShadowQueueFact.builder(
"shadow_ro.v_wcs_srgv_queue", "QUEUE-1")
.taskNo("30001")
.taskType("2")
.agvId("VEH-1")
.pathInfo("70501,70502")
.default02("1")
.createdAt(NOW.minusSeconds(10))
.build()),
Collections.emptyList(), Collections.emptyList());
ShadowDecision decision = ShadowDecision.granted("PARENT-1",
"LOADED_TO_PLACE", 3L, "VEH-1", points(70501, 70502));
return ShadowCycleEvidence.complete(run.getRunId(), batch, legacy,
decision, encoded, differences, NOW.plusMillis(10),
objectMapper);
}
private ShadowLegacyQueueResult legacy(Map<String, String> fields) {
return ShadowLegacyQueueResult.available("VEH-1",
points(70501, 70502), fields, mainWrites());
}
private static ShadowDifference match() {
return new ShadowDifference(ShadowDifferenceCategory.MATCH,
"LEGACY_AND_SHADOW_MATCH", null, null);
}
private static Ft546EncodedCommand encoded() {
return encoded("4002");
}
private static Ft546EncodedCommand encoded(String taskTypeAddress) {
Set<String> capabilities = new LinkedHashSet<String>(
Collections.singletonList("POINT_TO_POINT_COMMAND"));
Ft546DeviceProfile profile = new Ft546DeviceProfile();
profile.setCapabilities(capabilities);
Map<String, String> registers = new LinkedHashMap<String, String>();
registers.put("taskNo", "4000");
registers.put("taskType", taskTypeAddress);
registers.put("path", "4004");
registers.put("trigger", "4021");
profile.setRegisters(registers);
return new Ft546SrgvCommandEncoder().encode(
new Ft546SrgvCommandIntent("VEH-1", "PARENT-1",
"LOADED_TO_PLACE", "PARENT-1:LOADED_TO_PLACE:3",
3L, 30001, (short) 1, points(70501, 70502),
null, null), profile, capabilities);
}
private ShadowLegacyQueueResult legacyFields() {
Map<String, String> fields = new LinkedHashMap<String, String>();
fields.put("taskNo", "30001");
fields.put("taskType", "1");
return legacy(fields);
}
private static RecordingFt546CommandGateway recordingGateway() {
return new RecordingFt546CommandGateway(
new Ft546SrgvCommandEncoder(), new Ft546DeviceProfile(),
Collections.<String>emptySet());
}
private static DispatchCommit trafficCommit() {
EffectiveTopology topology = new TopologyCompiler().compile(
new TopologyDefinition("FT546", 3L, true,
Arrays.asList(
new TopologyNode("A", "F1", 0, 0, 1,
false, false),
new TopologyNode("B", "F1", 1, 0, 1,
false, false)),
Collections.singletonList(
new TopologyEdge("A", "B", 1, 1)),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList()));
HeldDispatch held = new HeldDispatch("PARENT-1", "STEP-1",
"VEH-1", TrafficDecisionType.HOLD, "CAPACITY_CONFLICT",
new LinkedHashSet<String>(
Collections.singletonList("VEH-2")),
null, null);
return new DispatchCommit("FT546", topology.version(), 0L,
Collections.emptyList(), Collections.singletonList(held),
Collections.emptyList(), Collections.emptyList(),
new ResourceLedger(topology), new WaitForGraph());
}
private static List<LegacyRegisterWrite> mainWrites() {
List<LegacyRegisterWrite> writes =
new ArrayList<LegacyRegisterWrite>();
writes.add(LegacyRegisterWrite.of("4004", DataType.INT32,
Arrays.asList(70501, 70502, 0, 0, 0, 0, 0, 0),
1, false));
writes.add(LegacyRegisterWrite.of("4000", DataType.INT32,
Collections.singletonList(30001), 2, false));
writes.add(LegacyRegisterWrite.of("4002", DataType.INT16,
Collections.singletonList(1), 3, false));
writes.add(LegacyRegisterWrite.of("4021", DataType.INT16,
Collections.singletonList(1), 4, true));
return writes;
}
private int count(String table) {
return jdbc.queryForObject("SELECT COUNT(*) FROM " + table,
Integer.class);
}
private static List<Integer> points(Integer... values) {
return Arrays.asList(values);
}
private static String repeat(char value) {
char[] values = new char[64];
Arrays.fill(values, value);
return new String(values);
}
private void createSchema() {
jdbc.execute("CREATE TABLE shadow_run (run_id UUID PRIMARY KEY, "
+ "started_at TIMESTAMP NOT NULL, artifact_sha256 CHAR(64) NOT NULL, "
+ "configuration_sha256 CHAR(64) NOT NULL, source_server VARCHAR(256) NOT NULL, "
+ "source_database VARCHAR(128) NOT NULL, source_login VARCHAR(128) NOT NULL, "
+ "status VARCHAR(32) NOT NULL)");
jdbc.execute("CREATE TABLE shadow_cycle (cycle_id UUID PRIMARY KEY, "
+ "run_id UUID NOT NULL, input_batch_hash CHAR(64) NOT NULL, "
+ "parent_task_id VARCHAR(128) NOT NULL, phase VARCHAR(64) NOT NULL, "
+ "route_version BIGINT NOT NULL, status VARCHAR(64) NOT NULL, "
+ "reason_code VARCHAR(512), database_captured_at TIMESTAMP, "
+ "completed_at TIMESTAMP NOT NULL, evidence_hash CHAR(64) NOT NULL, "
+ "FOREIGN KEY (run_id) REFERENCES shadow_run(run_id), "
+ "UNIQUE (run_id,input_batch_hash,parent_task_id,phase,route_version))");
jdbc.execute("CREATE TABLE shadow_input_batch (cycle_id UUID PRIMARY KEY, "
+ "content_hash CHAR(64) NOT NULL, captured_json CLOB NOT NULL, "
+ "FOREIGN KEY (cycle_id) REFERENCES shadow_cycle(cycle_id))");
jdbc.execute("CREATE TABLE shadow_legacy_queue (cycle_id UUID PRIMARY KEY, "
+ "vehicle_id VARCHAR(64), path_info CLOB, result_status VARCHAR(64) NOT NULL, "
+ "result_json CLOB NOT NULL, content_hash CHAR(64) NOT NULL, "
+ "FOREIGN KEY (cycle_id) REFERENCES shadow_cycle(cycle_id))");
jdbc.execute("CREATE TABLE shadow_decision (cycle_id UUID PRIMARY KEY, "
+ "decision VARCHAR(64) NOT NULL, vehicle_id VARCHAR(64), "
+ "reason_code VARCHAR(512), decision_json CLOB NOT NULL, "
+ "content_hash CHAR(64) NOT NULL, "
+ "FOREIGN KEY (cycle_id) REFERENCES shadow_cycle(cycle_id))");
jdbc.execute("CREATE TABLE shadow_encoded_write (cycle_id UUID NOT NULL, "
+ "write_sequence INT NOT NULL, register_address VARCHAR(16) NOT NULL, "
+ "data_type VARCHAR(16) NOT NULL, values_json CLOB NOT NULL, "
+ "trigger_value BIGINT, PRIMARY KEY (cycle_id,write_sequence), "
+ "FOREIGN KEY (cycle_id) REFERENCES shadow_cycle(cycle_id))");
jdbc.execute("CREATE TABLE shadow_difference (difference_id BIGINT IDENTITY PRIMARY KEY, "
+ "cycle_id UUID NOT NULL, category VARCHAR(64) NOT NULL, "
+ "reason_code VARCHAR(512) NOT NULL, expected_json CLOB, actual_json CLOB, "
+ "FOREIGN KEY (cycle_id) REFERENCES shadow_cycle(cycle_id))");
}
private void createTrafficSchema() {
jdbc.execute("CREATE TABLE shadow_traffic_state ("
+ "state_key VARCHAR(256) PRIMARY KEY,"
+ "snapshot_version BIGINT NOT NULL,"
+ "state_json CLOB NOT NULL,"
+ "updated_at TIMESTAMP NOT NULL)");
jdbc.execute("CREATE TABLE shadow_hold_history ("
+ "parent_task_id VARCHAR(128) NOT NULL,"
+ "vehicle_id VARCHAR(64) NOT NULL,"
+ "resource_key VARCHAR(256) NOT NULL,"
+ "reason_code VARCHAR(512) NOT NULL,"
+ "consecutive_count INT NOT NULL,"
+ "updated_at TIMESTAMP NOT NULL,"
+ "PRIMARY KEY(parent_task_id,vehicle_id,resource_key,"
+ "reason_code))");
}
}

View File

@@ -0,0 +1,113 @@
package com.ruoyi.shadow.evidence;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.UUID;
import java.util.function.Supplier;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JdbcShadowEvidenceStorageHealthTest {
@TempDir
Path outputDirectory;
@Test
void unavailableDatabaseFailsBeforeDiskCheck() {
Path missing = outputDirectory.resolve("missing");
JdbcShadowEvidenceStorageHealth health =
new JdbcShadowEvidenceStorageHealth(repository(false),
missing, 0L);
ShadowEvidenceStorageHealth.Check result = health.verify();
assertFalse(result.isAvailable());
assertEquals("EVIDENCE_DATABASE_UNAVAILABLE",
result.getReasonCode());
}
@Test
void freeSpaceBelowConfiguredMinimumFailsClosed() {
JdbcShadowEvidenceStorageHealth health =
new JdbcShadowEvidenceStorageHealth(repository(true),
outputDirectory, Long.MAX_VALUE);
ShadowEvidenceStorageHealth.Check result = health.verify();
assertFalse(result.isAvailable());
assertEquals("FREE_SPACE_BELOW_MINIMUM", result.getReasonCode());
}
@Test
void healthyDatabaseAndExistingDirectoryAreAvailable() {
JdbcShadowEvidenceStorageHealth health =
new JdbcShadowEvidenceStorageHealth(repository(true),
outputDirectory, 0L);
ShadowEvidenceStorageHealth.Check result = health.verify();
assertTrue(result.isAvailable());
assertTrue(result.getUsableBytes() >= 0L);
}
@Test
void missingOutputDirectoryFailsClosed() {
JdbcShadowEvidenceStorageHealth health =
new JdbcShadowEvidenceStorageHealth(repository(true),
outputDirectory.resolve("missing"), 0L);
ShadowEvidenceStorageHealth.Check result = health.verify();
assertFalse(result.isAvailable());
assertEquals("EVIDENCE_FREE_SPACE_CHECK_FAILED",
result.getReasonCode());
}
private static ShadowEvidenceRepository repository(boolean healthy) {
return new ShadowEvidenceRepository() {
@Override
public SavedCycle save(ShadowCycleEvidence evidence) {
throw unsupported();
}
@Override
public List<SavedCycle> saveAll(
List<ShadowCycleEvidence> evidence) {
throw unsupported();
}
@Override
public <T> T inTransaction(Supplier<T> work) {
throw unsupported();
}
@Override
public SavedCycle findCycle(
UUID runId, String inputBatchHash,
String parentTaskId, String phase, long routeVersion) {
throw unsupported();
}
@Override
public ShadowEvidenceDigest dailyDigest(
LocalDate date, ZoneId zoneId) {
throw unsupported();
}
@Override
public boolean isHealthy() {
return healthy;
}
};
}
private static UnsupportedOperationException unsupported() {
return new UnsupportedOperationException("not used by health check");
}
}

View File

@@ -0,0 +1,253 @@
package com.ruoyi.shadow.evidence;
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.ft546.protocol.Ft546EncodedCommand;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
import com.ruoyi.shadow.facts.LegacyQueueProtocolProjector;
import com.ruoyi.shadow.facts.ShadowLegacyQueueResult;
import com.ruoyi.shadow.facts.ShadowQueueFact;
import com.ruoyi.shadow.facts.ShadowLegacyQueueResult.DataType;
import com.ruoyi.shadow.facts.ShadowLegacyQueueResult.LegacyRegisterWrite;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.ALGORITHM_HOLD;
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.ALGORITHM_ROUTE;
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.ALGORITHM_VEHICLE;
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.INSUFFICIENT_FACTS;
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.MATCH;
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.PROTOCOL_ADDRESS;
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.PROTOCOL_DATA_TYPE;
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.PROTOCOL_ORDER;
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.PROTOCOL_VALUE;
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.QUEUE_FIELD;
import static com.ruoyi.shadow.evidence.ShadowDifferenceCategory.SOURCE_GAP;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ShadowComparisonServiceTest {
private final ShadowComparisonService comparison =
new ShadowComparisonService();
@Test
void exactLegacyAndShadowResultIsAMatch() {
assertCategories(Collections.singletonList(MATCH),
compare(legacy("VEH-1", points(70501, 70502), "30001", "1",
mainWrites()),
ShadowDecision.granted("PARENT-1", "LOADED_TO_PLACE",
3L, "VEH-1", points(70501, 70502)),
encoded()));
}
@Test
void legacyProjectionCarriesVehicleRouteAndQueueFieldsIntoComparison() {
ShadowQueueFact queue = ShadowQueueFact.builder(
"shadow_ro.v_wcs_srgv_queue", "QUEUE-1")
.taskNo("30001")
.taskType("2")
.agvId("VEH-1")
.pathInfo("70501,70502")
.default02("1")
.build();
assertCategories(Collections.singletonList(MATCH),
compare(new LegacyQueueProtocolProjector().project(queue),
matchingDecision(), encoded()));
}
@Test
void differentVehicleWithMatchingWritesIsAlgorithmOnly() {
assertCategories(Collections.singletonList(ALGORITHM_VEHICLE),
compare(legacy("VEH-1", points(70501, 70502), "30001", "1",
mainWrites()),
ShadowDecision.granted("PARENT-1", "LOADED_TO_PLACE",
3L, "VEH-2", points(70501, 70502)),
encoded()));
}
@Test
void differentSafeRouteIsAnAlgorithmRouteDifference() {
assertCategories(Collections.singletonList(ALGORITHM_ROUTE),
compare(legacy("VEH-1", points(70501, 70503), "30001", "1",
mainWrites()),
ShadowDecision.granted("PARENT-1", "LOADED_TO_PLACE",
3L, "VEH-1", points(70501, 70502)),
encoded()));
}
@Test
void holdInsteadOfLegacyCommandIsAnAlgorithmHoldDifference() {
assertCategories(Collections.singletonList(ALGORITHM_HOLD),
compare(legacy("VEH-1", points(70501, 70502), "30001", "1",
mainWrites()),
ShadowDecision.held("PARENT-1", "LOADED_TO_PLACE", 3L,
"VEH-1", "CAPACITY_CONFLICT"), null));
}
@Test
void unavailableLegacyProjectionIsASourceGap() {
assertCategories(Collections.singletonList(SOURCE_GAP),
compare(ShadowLegacyQueueResult.unavailable("path_info"),
ShadowDecision.granted("PARENT-1", "LOADED_TO_PLACE",
3L, "VEH-1", points(70501, 70502)),
encoded()));
}
@Test
void invalidSnapshotIsClassifiedWithoutProtocolClaims() {
assertCategories(Collections.singletonList(INSUFFICIENT_FACTS),
compare(legacy("VEH-1", points(70501, 70502), "30001", "1",
mainWrites()),
ShadowDecision.insufficient("PARENT-1",
"LOADED_TO_PLACE", 3L,
"MISSING_VEHICLE_FACTS"), null));
}
@Test
void changedAddressIsAProtocolAddressDifference() {
List<LegacyRegisterWrite> writes = mainWrites();
writes.set(2, write("4099", DataType.INT16, 3, false, 1));
assertCategories(Collections.singletonList(PROTOCOL_ADDRESS),
compare(legacy("VEH-1", points(70501, 70502), "30001", "1",
writes), matchingDecision(), encoded()));
}
@Test
void changedDataTypeIsAProtocolDataTypeDifference() {
List<LegacyRegisterWrite> writes = mainWrites();
writes.set(2, write("4002", DataType.INT32, 3, false, 1));
assertCategories(Collections.singletonList(PROTOCOL_DATA_TYPE),
compare(legacy("VEH-1", points(70501, 70502), "30001", "1",
writes), matchingDecision(), encoded()));
}
@Test
void changedRegisterValueIsAProtocolValueDifference() {
List<LegacyRegisterWrite> writes = mainWrites();
writes.set(2, write("4002", DataType.INT16, 3, false, 9));
assertCategories(Collections.singletonList(PROTOCOL_VALUE),
compare(legacy("VEH-1", points(70501, 70502), "30001", "1",
writes), matchingDecision(), encoded()));
}
@Test
void changedWriteOrderIsProtocolOrderEvenWhenValuesMatch() {
List<LegacyRegisterWrite> writes = new ArrayList<LegacyRegisterWrite>();
writes.add(pathWrite());
writes.add(write("4002", DataType.INT16, 2, false, 1));
writes.add(write("4000", DataType.INT32, 3, false, 30001));
writes.add(write("4021", DataType.INT16, 4, true, 1));
assertCategories(Collections.singletonList(PROTOCOL_ORDER),
compare(legacy("VEH-1", points(70501, 70502), "30001", "1",
writes), matchingDecision(), encoded()));
}
@Test
void duplicateRegisterAddressesAreComparedByOccurrence() {
List<LegacyRegisterWrite> writes = mainWrites();
writes.set(2, write("4000", DataType.INT16, 3, false, 1));
assertCategories(Collections.singletonList(MATCH),
compare(legacy("VEH-1", points(70501, 70502), "30001", "1",
writes), matchingDecision(),
encodedWithTaskTypeAddress("4000")));
}
@Test
void changedCapturedQueueFieldDoesNotMasqueradeAsProtocolFailure() {
assertCategories(Collections.singletonList(QUEUE_FIELD),
compare(legacy("VEH-1", points(70501, 70502), "30001", "9",
mainWrites()), matchingDecision(), encoded()));
}
private List<ShadowDifference> compare(ShadowLegacyQueueResult legacy,
ShadowDecision decision,
Ft546EncodedCommand encoded) {
return comparison.compare(legacy, decision, encoded);
}
private static ShadowDecision matchingDecision() {
return ShadowDecision.granted("PARENT-1", "LOADED_TO_PLACE", 3L,
"VEH-1", points(70501, 70502));
}
private static ShadowLegacyQueueResult legacy(
String vehicleId, List<Integer> path, String taskNo,
String taskType, List<LegacyRegisterWrite> writes) {
Map<String, String> fields = new LinkedHashMap<String, String>();
fields.put("taskNo", taskNo);
fields.put("taskType", taskType);
return ShadowLegacyQueueResult.available(vehicleId, path, fields,
writes);
}
private static Ft546EncodedCommand encoded() {
return encodedWithTaskTypeAddress("4002");
}
private static Ft546EncodedCommand encodedWithTaskTypeAddress(
String taskTypeAddress) {
Set<String> capabilities = new LinkedHashSet<String>(
Collections.singletonList("POINT_TO_POINT_COMMAND"));
Ft546DeviceProfile profile = new Ft546DeviceProfile();
profile.setCapabilities(capabilities);
Map<String, String> registers = new LinkedHashMap<String, String>();
registers.put("taskNo", "4000");
registers.put("taskType", taskTypeAddress);
registers.put("path", "4004");
registers.put("trigger", "4021");
profile.setRegisters(registers);
return new Ft546SrgvCommandEncoder().encode(
new Ft546SrgvCommandIntent("VEH-1", "PARENT-1",
"LOADED_TO_PLACE", "PARENT-1:LOADED_TO_PLACE:3",
3L, 30001, (short) 1, points(70501, 70502),
null, null), profile, capabilities);
}
private static List<LegacyRegisterWrite> mainWrites() {
return new ArrayList<LegacyRegisterWrite>(Arrays.asList(
pathWrite(),
write("4000", DataType.INT32, 2, false, 30001),
write("4002", DataType.INT16, 3, false, 1),
write("4021", DataType.INT16, 4, true, 1)));
}
private static LegacyRegisterWrite pathWrite() {
return write("4004", DataType.INT32, 1, false,
70501, 70502, 0, 0, 0, 0, 0, 0);
}
private static LegacyRegisterWrite write(String address, DataType type,
int sequence, boolean trigger,
Integer... values) {
return LegacyRegisterWrite.of(address, type, Arrays.asList(values),
sequence, trigger);
}
private static List<Integer> points(Integer... values) {
return Arrays.asList(values);
}
private static void assertCategories(
List<ShadowDifferenceCategory> expected,
List<ShadowDifference> actual) {
List<ShadowDifferenceCategory> categories =
new ArrayList<ShadowDifferenceCategory>();
for (ShadowDifference difference : actual) {
categories.add(difference.getCategory());
}
assertEquals(expected, categories);
}
}

View File

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

View File

@@ -127,6 +127,54 @@ class SqlServerShadowDatabaseGateTest {
verify(evidenceJdbc, never()).update(anyString(), any(), any(), any());
}
@Test
void runMetadataSourceServerMustMatchTheActualConnection()
throws Exception {
Connection source = connection(
"jdbc:sqlserver://prod:1433;databaseName=FT546-wcs",
"shadow_ro");
Connection evidence = connection(
"jdbc:sqlserver://evidence:1433;databaseName=FT546-shadow",
"shadow_evidence");
SqlServerPermissionProbe probe = mock(SqlServerPermissionProbe.class);
SqlServerShadowDatabaseGate gate = new SqlServerShadowDatabaseGate(
dataSource(source), dataSource(evidence),
mock(JdbcTemplate.class), probe);
ShadowProperties properties = properties();
properties.getEvidence().getRun().setSourceServer("wrong-server");
IllegalStateException failure = assertThrows(
IllegalStateException.class, () -> gate.verify(properties));
assertEquals("SHADOW_RUN_SOURCE_SERVER_MISMATCH",
failure.getMessage());
verify(probe, never()).verifySource(any(Connection.class));
}
@Test
void runMetadataSourceDatabaseMustMatchTheActualConnection()
throws Exception {
Connection source = connection(
"jdbc:sqlserver://prod:1433;databaseName=FT546-wcs",
"shadow_ro");
Connection evidence = connection(
"jdbc:sqlserver://evidence:1433;databaseName=FT546-shadow",
"shadow_evidence");
SqlServerPermissionProbe probe = mock(SqlServerPermissionProbe.class);
SqlServerShadowDatabaseGate gate = new SqlServerShadowDatabaseGate(
dataSource(source), dataSource(evidence),
mock(JdbcTemplate.class), probe);
ShadowProperties properties = properties();
properties.getEvidence().getRun().setSourceDatabase("wrong-db");
IllegalStateException failure = assertThrows(
IllegalStateException.class, () -> gate.verify(properties));
assertEquals("SHADOW_RUN_SOURCE_DATABASE_MISMATCH",
failure.getMessage());
verify(probe, never()).verifySource(any(Connection.class));
}
@Test
void acceptedDatabasesPersistAllSuppliedStartupChecksInEvidenceDatabase()
throws Exception {
@@ -219,6 +267,8 @@ class SqlServerShadowDatabaseGateTest {
properties.getEvidence().setJdbcUrl(
"jdbc:sqlserver://evidence:1433;databaseName=FT546-shadow");
properties.getEvidence().setUsername("shadow_evidence");
properties.getEvidence().getRun().setSourceServer("prod");
properties.getEvidence().getRun().setSourceDatabase("FT546-wcs");
return properties;
}
}

View File

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