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