fix(shadow): repair field routing and harden evidence collection
This commit is contained in:
@@ -62,21 +62,31 @@ public class ShadowEvidenceConfiguration {
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ShadowEvidenceRepository shadowEvidenceRepository(
|
||||
public JdbcShadowEvidenceRepository shadowEvidenceRepository(
|
||||
@Qualifier("shadowEvidenceJdbc") JdbcTemplate evidenceJdbc,
|
||||
@Qualifier("shadowEvidenceTx")
|
||||
DataSourceTransactionManager evidenceTx,
|
||||
ObjectMapper objectMapper, ShadowRunEvidence run) {
|
||||
ObjectMapper objectMapper, ShadowRunEvidence run, ShadowProperties properties) {
|
||||
return new JdbcShadowEvidenceRepository(evidenceJdbc,
|
||||
new TransactionTemplate(evidenceTx), objectMapper, run);
|
||||
new TransactionTemplate(evidenceTx), objectMapper, run,
|
||||
properties.getEvidence().getMaximumBatchPayloadBytes());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ShadowEvidenceStorageHealth shadowEvidenceStorageHealth(
|
||||
ShadowEvidenceRepository repository,
|
||||
@Qualifier("shadowEvidenceJdbc") JdbcTemplate evidenceJdbc,
|
||||
ShadowProperties properties) {
|
||||
return new JdbcShadowEvidenceStorageHealth(repository,
|
||||
Paths.get(properties.getEvidence().getOutputDirectory()),
|
||||
properties.getEvidence().getMinimumFreeBytes().longValue());
|
||||
properties.getEvidence().getMinimumFreeBytes().longValue(),
|
||||
() -> {
|
||||
Long allocated = evidenceJdbc.queryForObject(
|
||||
"SELECT SUM(CAST(size AS bigint)) * 8192 FROM sys.database_files", Long.class);
|
||||
if (allocated == null || allocated <= 0L) {
|
||||
throw new IllegalStateException("evidence database allocation unavailable");
|
||||
}
|
||||
return allocated;
|
||||
}, properties.getEvidence().getMaximumDatabaseBytes());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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.evidence.JdbcShadowEvidenceRepository;
|
||||
import com.ruoyi.shadow.facts.ShadowFactBatchValidator;
|
||||
import com.ruoyi.shadow.kernel.Ft546ShadowCycle;
|
||||
import com.ruoyi.shadow.kernel.Ft546ShadowScheduler;
|
||||
@@ -43,6 +44,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@@ -69,7 +71,8 @@ public class ShadowKernelConfiguration {
|
||||
@Bean
|
||||
public IdGenerator shadowIdGenerator() {
|
||||
AtomicLong sequence = new AtomicLong();
|
||||
return () -> "SHADOW-" + sequence.incrementAndGet();
|
||||
String runPrefix = "SHADOW-" + UUID.randomUUID() + "-";
|
||||
return () -> runPrefix + sequence.incrementAndGet();
|
||||
}
|
||||
|
||||
@Bean(name = "shadowClock")
|
||||
@@ -173,11 +176,12 @@ public class ShadowKernelConfiguration {
|
||||
ShadowEvidenceStorageHealth shadowEvidenceStorageHealth,
|
||||
ShadowEvidenceRepository shadowEvidenceRepository,
|
||||
ShadowCycleEvidenceCollector shadowCycleEvidenceCollector,
|
||||
ShadowStartupReport shadowStartupReport) {
|
||||
ShadowStartupReport shadowStartupReport,
|
||||
@Qualifier("shadowClock") Clock clock) {
|
||||
return new Ft546ShadowCycle(validator, assembler::assemble,
|
||||
dispatchCycle, commandGateway, shadowEvidenceStorageHealth,
|
||||
shadowEvidenceRepository, shadowCycleEvidenceCollector,
|
||||
shadowStartupReport);
|
||||
shadowStartupReport, clock);
|
||||
}
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
@@ -185,11 +189,12 @@ public class ShadowKernelConfiguration {
|
||||
SqlServerShadowFactReader factReader,
|
||||
Ft546ShadowCycle shadowCycle,
|
||||
ShadowStartupReport shadowStartupReport,
|
||||
JdbcShadowEvidenceRepository evidenceRepository,
|
||||
@Qualifier("shadowClock") Clock clock,
|
||||
@Value("${shadow.cycle.deadline-ms:10000}")
|
||||
long cycleDeadlineMs) {
|
||||
return new Ft546ShadowScheduler(factReader, shadowCycle,
|
||||
shadowStartupReport, clock,
|
||||
Duration.ofMillis(cycleDeadlineMs));
|
||||
Duration.ofMillis(cycleDeadlineMs), evidenceRepository::saveRuntimeState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ public class ShadowProperties {
|
||||
public static final class EvidenceEndpoint extends JdbcEndpoint {
|
||||
private String outputDirectory;
|
||||
private Long minimumFreeBytes;
|
||||
private long maximumDatabaseBytes = 16L * 1024L * 1024L * 1024L;
|
||||
private long maximumBatchPayloadBytes = 32L * 1024L * 1024L;
|
||||
private final RunMetadata run = new RunMetadata();
|
||||
|
||||
public String getOutputDirectory() { return outputDirectory; }
|
||||
@@ -40,6 +42,10 @@ public class ShadowProperties {
|
||||
this.minimumFreeBytes = minimumFreeBytes;
|
||||
}
|
||||
public RunMetadata getRun() { return run; }
|
||||
public long getMaximumDatabaseBytes() { return maximumDatabaseBytes; }
|
||||
public void setMaximumDatabaseBytes(long value) { maximumDatabaseBytes = value; }
|
||||
public long getMaximumBatchPayloadBytes() { return maximumBatchPayloadBytes; }
|
||||
public void setMaximumBatchPayloadBytes(long value) { maximumBatchPayloadBytes = value; }
|
||||
}
|
||||
|
||||
public static final class RunMetadata {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ruoyi.shadow.evidence;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.ruoyi.ft546.protocol.Ft546RegisterWrite;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
@@ -28,16 +29,27 @@ public final class JdbcShadowEvidenceRepository
|
||||
private final TransactionTemplate transaction;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ShadowRunEvidence run;
|
||||
private final long maximumBatchPayloadBytes;
|
||||
|
||||
public JdbcShadowEvidenceRepository(JdbcTemplate jdbc,
|
||||
TransactionTemplate transaction,
|
||||
ObjectMapper objectMapper,
|
||||
ShadowRunEvidence run) {
|
||||
this(jdbc, transaction, objectMapper, run, 32L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
public JdbcShadowEvidenceRepository(JdbcTemplate jdbc, TransactionTemplate transaction,
|
||||
ObjectMapper objectMapper, ShadowRunEvidence run,
|
||||
long maximumBatchPayloadBytes) {
|
||||
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
|
||||
this.transaction = Objects.requireNonNull(transaction, "transaction");
|
||||
this.objectMapper = Objects.requireNonNull(objectMapper,
|
||||
"objectMapper");
|
||||
this.run = Objects.requireNonNull(run, "run");
|
||||
if (maximumBatchPayloadBytes <= 0L) {
|
||||
throw new IllegalArgumentException("maximumBatchPayloadBytes must be positive");
|
||||
}
|
||||
this.maximumBatchPayloadBytes = maximumBatchPayloadBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -174,6 +186,55 @@ public final class JdbcShadowEvidenceRepository
|
||||
}
|
||||
}
|
||||
|
||||
public void saveRuntimeState(Map<String, Object> state) {
|
||||
Objects.requireNonNull(state, "state");
|
||||
String status = Objects.toString(state.get("status"), "");
|
||||
if (!java.util.Arrays.asList("STARTING", "READY", "DEGRADED", "STOPPED").contains(status)
|
||||
|| !"RUN_HEARTBEAT".equals(state.get("kind"))
|
||||
|| !(state.get("collectionStopped") instanceof Boolean)) {
|
||||
throw new ShadowEvidenceException("EVIDENCE_RUNTIME_STATE_INVALID");
|
||||
}
|
||||
Instant heartbeatAt = Instant.parse(Objects.toString(state.get("heartbeatAt")));
|
||||
Map<String, Object> values = new LinkedHashMap<String, Object>(state);
|
||||
values.put("runId", run.getRunId().toString());
|
||||
String json = ShadowEvidenceDigest.canonicalJson(objectMapper, values);
|
||||
if (json.length() > 32768) {
|
||||
throw new ShadowEvidenceException("EVIDENCE_RUNTIME_PAYLOAD_LIMIT");
|
||||
}
|
||||
String key = "FT546:RUN:" + run.getRunId();
|
||||
transaction.execute(tx -> {
|
||||
ensureRun();
|
||||
List<Map<String, Object>> rows = jdbc.queryForList(
|
||||
"SELECT snapshot_version,state_json FROM shadow_traffic_state WHERE state_key=?", key);
|
||||
if (rows.isEmpty()) {
|
||||
jdbc.update("INSERT INTO shadow_traffic_state "
|
||||
+ "(state_key,snapshot_version,state_json,updated_at) VALUES (?,?,?,?)",
|
||||
key, 1L, json, Timestamp.from(heartbeatAt));
|
||||
} else {
|
||||
Map<String, Object> previous = rows.get(0);
|
||||
JsonNode previousState;
|
||||
try {
|
||||
previousState = objectMapper.readTree(Objects.toString(previous.get("state_json")));
|
||||
} catch (Exception failure) {
|
||||
throw new ShadowEvidenceException("EVIDENCE_RUNTIME_STATE_INVALID", failure);
|
||||
}
|
||||
if (previousState.path("collectionStopped").asBoolean()
|
||||
&& !Boolean.TRUE.equals(state.get("collectionStopped"))) {
|
||||
throw new ShadowEvidenceException("EVIDENCE_RUNTIME_STATE_REGRESSION");
|
||||
}
|
||||
long version = ((Number) previous.get("snapshot_version")).longValue();
|
||||
int updated = jdbc.update("UPDATE shadow_traffic_state "
|
||||
+ "SET snapshot_version=?,state_json=?,updated_at=? "
|
||||
+ "WHERE state_key=? AND snapshot_version=?",
|
||||
Math.addExact(version, 1L), json, Timestamp.from(heartbeatAt), key, version);
|
||||
if (updated != 1) throw new ShadowEvidenceException("EVIDENCE_RUNTIME_STATE_CHANGED");
|
||||
}
|
||||
jdbc.update("UPDATE shadow_run SET status=? WHERE run_id=?",
|
||||
"READY".equals(status) ? "RUNNING" : status, run.getRunId());
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private List<SavedCycle> saveAllInTransaction(
|
||||
List<ShadowCycleEvidence> evidence) {
|
||||
ensureRun();
|
||||
@@ -189,6 +250,7 @@ public final class JdbcShadowEvidenceRepository
|
||||
result.add(verifyReplay(existing, item));
|
||||
}
|
||||
}
|
||||
verifyPayloadBudget(inserts);
|
||||
for (ShadowCycleEvidence item : inserts) {
|
||||
insert(item);
|
||||
}
|
||||
@@ -200,6 +262,33 @@ public final class JdbcShadowEvidenceRepository
|
||||
return Collections.unmodifiableList(result);
|
||||
}
|
||||
|
||||
private void verifyPayloadBudget(List<ShadowCycleEvidence> inserts) {
|
||||
long remaining = maximumBatchPayloadBytes;
|
||||
for (ShadowCycleEvidence item : inserts) {
|
||||
remaining = consumePayload(remaining, item.getInputJson());
|
||||
remaining = consumePayload(remaining, item.getLegacyJson());
|
||||
remaining = consumePayload(remaining, item.getLegacyPathInfo());
|
||||
remaining = consumePayload(remaining, item.getDecisionJson());
|
||||
for (Ft546RegisterWrite write : item.getEncodedWrites()) {
|
||||
remaining = consumePayload(remaining,
|
||||
ShadowEvidenceDigest.canonicalJson(objectMapper, write.getValues()));
|
||||
}
|
||||
for (ShadowDifference difference : item.getDifferences()) {
|
||||
remaining = consumePayload(remaining, difference.getExpectedJson());
|
||||
remaining = consumePayload(remaining, difference.getActualJson());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long consumePayload(long remaining, String value) {
|
||||
// SQL Server nvarchar stores UTF-16 code units; page/index/log overhead is separate.
|
||||
long bytes = value == null ? 0L : 2L * value.length();
|
||||
if (bytes > remaining) {
|
||||
throw new ShadowEvidenceException("EVIDENCE_BATCH_PAYLOAD_LIMIT");
|
||||
}
|
||||
return remaining - bytes;
|
||||
}
|
||||
|
||||
private void insert(ShadowCycleEvidence evidence) {
|
||||
jdbc.update("INSERT INTO shadow_cycle "
|
||||
+ "(cycle_id,run_id,input_batch_hash,parent_task_id,"
|
||||
|
||||
@@ -4,16 +4,25 @@ import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
public final class JdbcShadowEvidenceStorageHealth
|
||||
implements ShadowEvidenceStorageHealth {
|
||||
private final ShadowEvidenceRepository repository;
|
||||
private final Path outputDirectory;
|
||||
private final long minimumFreeBytes;
|
||||
private final LongSupplier allocatedDatabaseBytes;
|
||||
private final long maximumDatabaseBytes;
|
||||
|
||||
public JdbcShadowEvidenceStorageHealth(
|
||||
ShadowEvidenceRepository repository, Path outputDirectory,
|
||||
long minimumFreeBytes) {
|
||||
this(repository, outputDirectory, minimumFreeBytes, () -> 0L, Long.MAX_VALUE);
|
||||
}
|
||||
|
||||
public JdbcShadowEvidenceStorageHealth(
|
||||
ShadowEvidenceRepository repository, Path outputDirectory,
|
||||
long minimumFreeBytes, LongSupplier allocatedDatabaseBytes, long maximumDatabaseBytes) {
|
||||
this.repository = Objects.requireNonNull(repository, "repository");
|
||||
this.outputDirectory = Objects.requireNonNull(outputDirectory,
|
||||
"outputDirectory").toAbsolutePath().normalize();
|
||||
@@ -22,6 +31,11 @@ public final class JdbcShadowEvidenceStorageHealth
|
||||
"minimumFreeBytes must not be negative");
|
||||
}
|
||||
this.minimumFreeBytes = minimumFreeBytes;
|
||||
this.allocatedDatabaseBytes = Objects.requireNonNull(allocatedDatabaseBytes, "allocatedDatabaseBytes");
|
||||
if (maximumDatabaseBytes <= 0L) {
|
||||
throw new IllegalArgumentException("maximumDatabaseBytes must be positive");
|
||||
}
|
||||
this.maximumDatabaseBytes = maximumDatabaseBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -29,6 +43,17 @@ public final class JdbcShadowEvidenceStorageHealth
|
||||
if (!repository.isHealthy()) {
|
||||
return Check.unavailable("EVIDENCE_DATABASE_UNAVAILABLE");
|
||||
}
|
||||
try {
|
||||
long allocated = allocatedDatabaseBytes.getAsLong();
|
||||
if (allocated < 0L) {
|
||||
return Check.unavailable("EVIDENCE_DATABASE_SIZE_CHECK_FAILED");
|
||||
}
|
||||
if (allocated >= maximumDatabaseBytes) {
|
||||
return Check.unavailable("EVIDENCE_DATABASE_SIZE_LIMIT");
|
||||
}
|
||||
} catch (RuntimeException failure) {
|
||||
return Check.unavailable("EVIDENCE_DATABASE_SIZE_CHECK_FAILED");
|
||||
}
|
||||
try {
|
||||
long usableBytes = Files.getFileStore(outputDirectory)
|
||||
.getUsableSpace();
|
||||
|
||||
@@ -195,6 +195,7 @@ public final class ShadowCycleEvidence {
|
||||
row.put("tableName", value.getTableName());
|
||||
row.put("taskNo", value.getTaskNo());
|
||||
row.put("taskType", value.getTaskType());
|
||||
row.put("theFloor", value.getTheFloor());
|
||||
row.put("turnPath", value.getTurnPath());
|
||||
result.add(row);
|
||||
}
|
||||
|
||||
@@ -121,13 +121,21 @@ public final class LegacyQueueProtocolProjector {
|
||||
}
|
||||
|
||||
private static List<Integer> ordinaryPath(ShadowQueueFact queue) {
|
||||
List<Integer> executedPath = executedPath(queue.getPathInfo());
|
||||
if (executedPath == null || executedPath.size() < 2) {
|
||||
List<String> executedPath = executedPath(queue.getPathInfo());
|
||||
if (executedPath == null || executedPath.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (executedPath.size() == 1
|
||||
&& !("3".equals(trim(queue.getTaskType()))
|
||||
&& "3".equals(trim(queue.getDefault02()))
|
||||
&& trim(queue.getDefault08()).isEmpty()
|
||||
&& site(executedPath.get(0)).equals(site(queue.getStartSite()))
|
||||
&& site(executedPath.get(0)).equals(site(queue.getEndSite())))) {
|
||||
return null;
|
||||
}
|
||||
List<Integer> result = new ArrayList<Integer>();
|
||||
result.add(executedPath.get(0));
|
||||
result.add(executedPath.get(executedPath.size() - 1));
|
||||
result.add(site(executedPath.get(0)));
|
||||
result.add(site(executedPath.get(executedPath.size() - 1)));
|
||||
if (notBlank(queue.getTurnPath())) {
|
||||
for (String token : queue.getTurnPath().split(",")) {
|
||||
if (!notBlank(token)) {
|
||||
@@ -149,21 +157,23 @@ public final class LegacyQueueProtocolProjector {
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<Integer> executedPath(String value) {
|
||||
private static List<String> executedPath(String value) {
|
||||
if (!notBlank(value)) {
|
||||
return null;
|
||||
}
|
||||
List<Integer> result = new ArrayList<Integer>();
|
||||
List<String> result = new ArrayList<String>();
|
||||
for (String token : value.split(",")) {
|
||||
if (!notBlank(token)) {
|
||||
continue;
|
||||
}
|
||||
Integer point = site(token);
|
||||
if (point == null) {
|
||||
if (point == null && !token.trim().matches("(?:TD[0-9]{3}|WY[0-9]{4})")) {
|
||||
return null;
|
||||
}
|
||||
result.add(point);
|
||||
result.add(token.trim());
|
||||
}
|
||||
if (result.isEmpty() || site(result.get(0)) == null
|
||||
|| site(result.get(result.size() - 1)) == null) return null;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,11 @@ public final class ShadowFactBatch {
|
||||
public List<ShadowMapFact> getMaps() { return maps; }
|
||||
|
||||
public ShadowQueueFact queue(String guid) {
|
||||
for (ShadowQueueFact fact : queues) {
|
||||
if (fact.isActive() && fact.getGuid().equals(guid)) {
|
||||
return fact;
|
||||
}
|
||||
}
|
||||
for (ShadowQueueFact fact : queues) {
|
||||
if (fact.getGuid().equals(guid)) {
|
||||
return fact;
|
||||
|
||||
@@ -110,14 +110,23 @@ public final class ShadowFactBatchValidator {
|
||||
return ShadowFactValidation.insufficient("MISSING_MAP_FACTS");
|
||||
}
|
||||
for (ShadowQueueFact queue : batch.getQueues()) {
|
||||
// Finished rows are historical versions, not current dispatch inputs.
|
||||
if (!queue.isActive()) {
|
||||
continue;
|
||||
}
|
||||
if (queue.getCreatedAt() == null) {
|
||||
return ShadowFactValidation.insufficient(
|
||||
"MISSING_QUEUE_CREATED_AT:" + queue.getTableName()
|
||||
+ ':' + queue.getGuid());
|
||||
}
|
||||
String missing = firstMissingSite(sites, queue.getStartSite(),
|
||||
queue.getEndSite(), queue.getCargoPickSite(),
|
||||
queue.getCargoPlaceSite());
|
||||
String missing;
|
||||
try {
|
||||
missing = firstMissingSite(sites, ShadowQueueEndpoints.start(queue, batch),
|
||||
ShadowQueueEndpoints.end(queue, batch), queue.getCargoPickSite(),
|
||||
queue.getCargoPlaceSite());
|
||||
} catch (IllegalArgumentException unresolved) {
|
||||
return ShadowFactValidation.insufficient(unresolved.getMessage());
|
||||
}
|
||||
if (missing != null) {
|
||||
return ShadowFactValidation.insufficient("MISSING_MAP_SITE:" + missing);
|
||||
}
|
||||
@@ -141,6 +150,9 @@ public final class ShadowFactBatchValidator {
|
||||
private static ShadowFactValidation queueMutations(ShadowFactBatch batch) {
|
||||
Map<String, String> hashes = new HashMap<String, String>();
|
||||
for (ShadowQueueFact queue : batch.getSourceQueues()) {
|
||||
if (!queue.isActive()) {
|
||||
continue;
|
||||
}
|
||||
String key = queue.getTableName() + ':' + queue.getGuid();
|
||||
String existing = hashes.putIfAbsent(key, queue.getContentHash());
|
||||
if (existing != null && !existing.equals(queue.getContentHash())) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ruoyi.shadow.facts;
|
||||
|
||||
/** Resolves legacy business placeholders without modifying captured evidence. */
|
||||
public final class ShadowQueueEndpoints {
|
||||
private ShadowQueueEndpoints() { }
|
||||
|
||||
public static String start(ShadowQueueFact queue, ShadowFactBatch batch) {
|
||||
String site = trimmed(queue.getStartSite());
|
||||
if (!"tsj".equals(site)) return site;
|
||||
if ("4".equals(trimmed(queue.getDefault02()))
|
||||
&& ("1".equals(queue.getTaskType()) || "2".equals(queue.getTaskType()))) {
|
||||
int floor = sourceFloor(queue);
|
||||
// sendPointToPointTask starts at the assigned vehicle's live currSite.
|
||||
for (ShadowVehicleFact vehicle : batch.getVehicles()) {
|
||||
if (vehicle.getDeviceId().equals(queue.getAgvId())
|
||||
&& Integer.valueOf(floor).equals(vehicle.getCurrentFloor())) {
|
||||
return requireFloorSite(vehicle.getCurrentSite(), floor, batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("UNRESOLVED_TSJ_START:" + queue.getGuid());
|
||||
}
|
||||
|
||||
public static String end(ShadowQueueFact queue, ShadowFactBatch batch) {
|
||||
String site = trimmed(queue.getEndSite());
|
||||
if (!"tsj".equals(site)) return site;
|
||||
String type = trimmed(queue.getDefault02());
|
||||
String prefix;
|
||||
if ("8".equals(type)) prefix = "07050";
|
||||
else if ("1".equals(type) || "2".equals(type)) prefix = "09050";
|
||||
else throw new IllegalArgumentException("UNRESOLVED_TSJ_END:" + queue.getGuid());
|
||||
int floor = sourceFloor(queue);
|
||||
return requireFloorSite(prefix + floor, floor, batch);
|
||||
}
|
||||
|
||||
private static int sourceFloor(ShadowQueueFact queue) {
|
||||
String floor = trimmed(queue.getTheFloor());
|
||||
if (floor == null || !floor.matches("[1-9]")) {
|
||||
throw new IllegalArgumentException("INVALID_TSJ_SOURCE_FLOOR:" + queue.getGuid());
|
||||
}
|
||||
return Integer.parseInt(floor);
|
||||
}
|
||||
|
||||
private static String requireFloorSite(String site, int floor, ShadowFactBatch batch) {
|
||||
for (ShadowMapFact map : batch.getMaps()) {
|
||||
if (map.getSiteId().equals(site) && map.getZ() == floor
|
||||
&& (map.getValue() == 1 || map.getValue() == 2)) return site;
|
||||
}
|
||||
throw new IllegalArgumentException("MISSING_TSJ_MAP_SITE:" + site);
|
||||
}
|
||||
|
||||
private static String trimmed(String value) {
|
||||
return value == null ? null : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ public final class ShadowQueueFact {
|
||||
private final String cargoPlaceSite;
|
||||
private final String startSite;
|
||||
private final String endSite;
|
||||
private final String theFloor;
|
||||
private final Instant createdAt;
|
||||
private final String agvId;
|
||||
private final String pathInfo;
|
||||
@@ -35,6 +36,7 @@ public final class ShadowQueueFact {
|
||||
this.cargoPlaceSite = builder.cargoPlaceSite;
|
||||
this.startSite = builder.startSite;
|
||||
this.endSite = builder.endSite;
|
||||
this.theFloor = builder.theFloor;
|
||||
this.createdAt = builder.createdAt;
|
||||
this.agvId = builder.agvId;
|
||||
this.pathInfo = builder.pathInfo;
|
||||
@@ -49,7 +51,7 @@ public final class ShadowQueueFact {
|
||||
this.contentHash = FactHash.sha256(tableName, guid, taskNo, taskType,
|
||||
priority, cargoPickSite, cargoPlaceSite, startSite, endSite,
|
||||
createdAt, agvId, pathInfo, turnPath, default02, default03,
|
||||
default04, default05, default06, default07, default08);
|
||||
default04, default05, default06, default07, default08, theFloor);
|
||||
}
|
||||
|
||||
public static Builder builder(String tableName, String guid) {
|
||||
@@ -57,6 +59,7 @@ public final class ShadowQueueFact {
|
||||
}
|
||||
|
||||
public String getTableName() { return tableName; }
|
||||
public boolean isActive() { return "shadow_ro.v_wcs_srgv_queue".equals(tableName); }
|
||||
public String getGuid() { return guid; }
|
||||
public String getTaskNo() { return taskNo; }
|
||||
public String getTaskType() { return taskType; }
|
||||
@@ -65,6 +68,7 @@ public final class ShadowQueueFact {
|
||||
public String getCargoPlaceSite() { return cargoPlaceSite; }
|
||||
public String getStartSite() { return startSite; }
|
||||
public String getEndSite() { return endSite; }
|
||||
public String getTheFloor() { return theFloor; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public String getAgvId() { return agvId; }
|
||||
public String getPathInfo() { return pathInfo; }
|
||||
@@ -95,6 +99,7 @@ public final class ShadowQueueFact {
|
||||
private String cargoPlaceSite;
|
||||
private String startSite;
|
||||
private String endSite;
|
||||
private String theFloor;
|
||||
private Instant createdAt;
|
||||
private String agvId;
|
||||
private String pathInfo;
|
||||
@@ -119,6 +124,7 @@ public final class ShadowQueueFact {
|
||||
public Builder cargoPlaceSite(String value) { cargoPlaceSite = value; return this; }
|
||||
public Builder startSite(String value) { startSite = value; return this; }
|
||||
public Builder endSite(String value) { endSite = value; return this; }
|
||||
public Builder theFloor(String value) { theFloor = value; return this; }
|
||||
public Builder createdAt(Instant value) { createdAt = value; return this; }
|
||||
public Builder agvId(String value) { agvId = value; return this; }
|
||||
public Builder pathInfo(String value) { pathInfo = value; return this; }
|
||||
|
||||
@@ -181,7 +181,7 @@ public final class SqlServerShadowFactReader {
|
||||
"SELECT database_utc_time FROM shadow_ro.v_current_database_clock";
|
||||
static final String QUEUE_SQL_PREFIX =
|
||||
"SELECT TOP (?) guid, task_no, task_type, priority, "
|
||||
+ "cargo_pick_site, cargo_place_site, start_site, end_site, "
|
||||
+ "cargo_pick_site, cargo_place_site, start_site, end_site, the_floor, "
|
||||
+ "create_date_utc, agv_id, path_info, trun_path, default02, "
|
||||
+ "default03, default04, default05, default06, default07, default08 ";
|
||||
static final String QUEUE_SQL_SUFFIX =
|
||||
@@ -345,6 +345,7 @@ public final class SqlServerShadowFactReader {
|
||||
.cargoPlaceSite(result.getString("cargo_place_site"))
|
||||
.startSite(result.getString("start_site"))
|
||||
.endSite(result.getString("end_site"))
|
||||
.theFloor(result.getString("the_floor"))
|
||||
.createdAt(instant(result, "create_date_utc"))
|
||||
.agvId(result.getString("agv_id"))
|
||||
.pathInfo(result.getString("path_info"))
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.ruoyi.wcs.application.ShadowDispatchResult;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
@@ -30,6 +31,7 @@ public final class Ft546ShadowCycle {
|
||||
private final ShadowEvidenceRepository evidenceRepository;
|
||||
private final ShadowCycleEvidenceCollector evidenceCollector;
|
||||
private final ShadowStartupReport statusReport;
|
||||
private final Clock clock;
|
||||
private final AtomicReference<String> degradedReason =
|
||||
new AtomicReference<String>();
|
||||
|
||||
@@ -48,6 +50,7 @@ public final class Ft546ShadowCycle {
|
||||
this.evidenceRepository = null;
|
||||
this.evidenceCollector = null;
|
||||
this.statusReport = null;
|
||||
this.clock = Clock.systemUTC();
|
||||
}
|
||||
|
||||
public Ft546ShadowCycle(
|
||||
@@ -59,6 +62,19 @@ public final class Ft546ShadowCycle {
|
||||
ShadowEvidenceRepository evidenceRepository,
|
||||
ShadowCycleEvidenceCollector evidenceCollector,
|
||||
ShadowStartupReport statusReport) {
|
||||
this(validator, assembler, dispatchCycle, commandGateway, storageHealth,
|
||||
evidenceRepository, evidenceCollector, statusReport, Clock.systemUTC());
|
||||
}
|
||||
|
||||
public Ft546ShadowCycle(
|
||||
ShadowFactBatchValidator validator,
|
||||
Function<ShadowFactBatch, DispatchSnapshot> assembler,
|
||||
ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> dispatchCycle,
|
||||
RecordingFt546CommandGateway commandGateway,
|
||||
ShadowEvidenceStorageHealth storageHealth,
|
||||
ShadowEvidenceRepository evidenceRepository,
|
||||
ShadowCycleEvidenceCollector evidenceCollector,
|
||||
ShadowStartupReport statusReport, Clock clock) {
|
||||
this.validator = Objects.requireNonNull(validator, "validator");
|
||||
this.assembler = Objects.requireNonNull(assembler, "assembler");
|
||||
this.dispatchCycle = Objects.requireNonNull(dispatchCycle,
|
||||
@@ -73,86 +89,103 @@ public final class Ft546ShadowCycle {
|
||||
"evidenceCollector");
|
||||
this.statusReport = Objects.requireNonNull(statusReport,
|
||||
"statusReport");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
public ShadowCycleResult run(ShadowFactBatch batch, Instant now) {
|
||||
return run(batch, now, new ShadowCycleDiagnostics());
|
||||
}
|
||||
|
||||
ShadowCycleResult run(ShadowFactBatch batch, Instant now, ShadowCycleDiagnostics progress) {
|
||||
if (evidenceRepository == null) {
|
||||
return runWithoutEvidence(batch, now);
|
||||
return runWithoutEvidence(batch, now, progress);
|
||||
}
|
||||
String stoppedReason = degradedReason.get();
|
||||
if (stoppedReason != null) {
|
||||
return ShadowCycleResult.degraded(stoppedReason);
|
||||
}
|
||||
progress.enter("STORAGE_HEALTH");
|
||||
ShadowEvidenceStorageHealth.Check health = storageHealth.verify();
|
||||
if (!health.isAvailable()) {
|
||||
return degrade(now);
|
||||
return degrade(health.getReasonCode(), clock.instant());
|
||||
}
|
||||
try {
|
||||
progress.enter("TRANSACTION_BEGIN");
|
||||
ShadowCycleResult result = evidenceRepository.inTransaction(
|
||||
() -> runAndPersist(batch, now));
|
||||
() -> {
|
||||
ShadowCycleResult saved = runAndPersist(batch, now, progress);
|
||||
progress.enter("TRANSACTION_COMMIT");
|
||||
return saved;
|
||||
});
|
||||
progress.enter("RECORDING");
|
||||
commandGateway.rememberAll(result.getEncodedCommands());
|
||||
Instant completedAt = clock.instant();
|
||||
Long lag = batch.getDatabaseCapturedAt() == null ? null
|
||||
: Long.valueOf(Math.max(0L,
|
||||
java.time.Duration.between(
|
||||
batch.getDatabaseCapturedAt(), now).toMillis()));
|
||||
statusReport.markReady(now, lag);
|
||||
batch.getDatabaseCapturedAt(), completedAt).toMillis()));
|
||||
statusReport.markReady(completedAt, lag);
|
||||
return result;
|
||||
} catch (RuntimeException evidenceFailure) {
|
||||
return degrade(failureReason(evidenceFailure), now);
|
||||
return degrade(failureReason(evidenceFailure), clock.instant());
|
||||
}
|
||||
}
|
||||
|
||||
private ShadowCycleResult runAndPersist(ShadowFactBatch batch,
|
||||
Instant now) {
|
||||
Instant now, ShadowCycleDiagnostics progress) {
|
||||
progress.enter("VALIDATION");
|
||||
ShadowFactValidation validation = validator.validate(batch, now);
|
||||
if (validation.getStatus()
|
||||
!= ShadowFactValidation.Status.COMPARABLE) {
|
||||
return persistInsufficient(batch, validation.getReasonCode(), now);
|
||||
return persistInsufficient(batch, validation.getReasonCode(), progress);
|
||||
}
|
||||
final DispatchSnapshot snapshot;
|
||||
progress.enter("ASSEMBLY_AND_STATE_LOAD");
|
||||
try {
|
||||
snapshot = assembler.apply(batch);
|
||||
} catch (RuntimeException failure) {
|
||||
return persistInsufficient(batch,
|
||||
"INVALID_KERNEL_FACTS:" + message(failure), now);
|
||||
"INVALID_KERNEL_FACTS:" + message(failure), progress);
|
||||
}
|
||||
ShadowDispatchResult<Ft546SrgvCommandIntent> result;
|
||||
progress.enter("DISPATCH_AND_STATE_SAVE");
|
||||
try {
|
||||
result = dispatchCycle.run(snapshot);
|
||||
} catch (IllegalArgumentException failure) {
|
||||
return persistInsufficient(batch,
|
||||
"INVALID_COMMAND_FACTS:" + message(failure), now);
|
||||
"INVALID_COMMAND_FACTS:" + message(failure), progress);
|
||||
}
|
||||
if (result.getDecisions().isRetrySnapshotChanged()) {
|
||||
List<ShadowCycleEvidence> evidence = evidenceCollector.insufficient(
|
||||
batch, "SHADOW_TRAFFIC_STATE_CHANGED", now);
|
||||
evidenceRepository.saveAll(evidence);
|
||||
persistInsufficient(batch, "SHADOW_TRAFFIC_STATE_CHANGED", progress);
|
||||
return ShadowCycleResult.retry(result.getDecisions());
|
||||
}
|
||||
final List<Ft546EncodedCommand> encoded;
|
||||
progress.enter("ENCODING");
|
||||
try {
|
||||
encoded = commandGateway.previewAll(result.getIntents());
|
||||
} catch (RuntimeException failure) {
|
||||
return persistInsufficient(batch,
|
||||
"INVALID_COMMAND_FACTS:" + message(failure), now);
|
||||
"INVALID_COMMAND_FACTS:" + message(failure), progress);
|
||||
}
|
||||
evidenceRepository.saveAll(evidenceCollector.collect(batch,
|
||||
result.getDecisions(), encoded, now));
|
||||
progress.enter("EVIDENCE_BUILD");
|
||||
List<ShadowCycleEvidence> evidence = evidenceCollector.collect(batch,
|
||||
result.getDecisions(), encoded, clock.instant());
|
||||
progress.enter("EVIDENCE_SAVE");
|
||||
evidenceRepository.saveAll(evidence);
|
||||
return ShadowCycleResult.comparable(result.getDecisions(),
|
||||
result.getIntents(), encoded);
|
||||
}
|
||||
|
||||
private ShadowCycleResult persistInsufficient(
|
||||
ShadowFactBatch batch, String reasonCode, Instant now) {
|
||||
evidenceRepository.saveAll(evidenceCollector.insufficient(batch,
|
||||
reasonCode, now));
|
||||
ShadowFactBatch batch, String reasonCode, ShadowCycleDiagnostics progress) {
|
||||
progress.enter("EVIDENCE_BUILD");
|
||||
List<ShadowCycleEvidence> evidence = evidenceCollector.insufficient(batch,
|
||||
reasonCode, clock.instant());
|
||||
progress.enter("EVIDENCE_SAVE");
|
||||
evidenceRepository.saveAll(evidence);
|
||||
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();
|
||||
@@ -165,7 +198,8 @@ public final class Ft546ShadowCycle {
|
||||
String reasonCode = ((ShadowEvidenceException) failure)
|
||||
.getReasonCode();
|
||||
if ("EVIDENCE_KEY_COLLISION".equals(reasonCode)
|
||||
|| "EVIDENCE_RUN_COLLISION".equals(reasonCode)) {
|
||||
|| "EVIDENCE_RUN_COLLISION".equals(reasonCode)
|
||||
|| "EVIDENCE_BATCH_PAYLOAD_LIMIT".equals(reasonCode)) {
|
||||
return reasonCode;
|
||||
}
|
||||
}
|
||||
@@ -173,13 +207,15 @@ public final class Ft546ShadowCycle {
|
||||
}
|
||||
|
||||
private ShadowCycleResult runWithoutEvidence(ShadowFactBatch batch,
|
||||
Instant now) {
|
||||
Instant now, ShadowCycleDiagnostics progress) {
|
||||
progress.enter("VALIDATION");
|
||||
ShadowFactValidation validation = validator.validate(batch, now);
|
||||
if (validation.getStatus()
|
||||
!= ShadowFactValidation.Status.COMPARABLE) {
|
||||
return ShadowCycleResult.insufficient(validation.getReasonCode());
|
||||
}
|
||||
final DispatchSnapshot snapshot;
|
||||
progress.enter("ASSEMBLY_AND_STATE_LOAD");
|
||||
try {
|
||||
snapshot = assembler.apply(batch);
|
||||
} catch (RuntimeException failure) {
|
||||
@@ -187,6 +223,7 @@ public final class Ft546ShadowCycle {
|
||||
"INVALID_KERNEL_FACTS:" + message(failure));
|
||||
}
|
||||
ShadowDispatchResult<Ft546SrgvCommandIntent> result;
|
||||
progress.enter("DISPATCH_AND_STATE_SAVE");
|
||||
try {
|
||||
result = dispatchCycle.run(snapshot);
|
||||
} catch (IllegalArgumentException failure) {
|
||||
@@ -197,6 +234,7 @@ public final class Ft546ShadowCycle {
|
||||
return ShadowCycleResult.retry(result.getDecisions());
|
||||
}
|
||||
final List<Ft546EncodedCommand> encoded;
|
||||
progress.enter("ENCODING");
|
||||
try {
|
||||
encoded = commandGateway.recordAll(result.getIntents());
|
||||
} catch (RuntimeException failure) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
@@ -22,6 +23,7 @@ import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class Ft546ShadowScheduler implements AutoCloseable {
|
||||
private static final Logger LOGGER =
|
||||
@@ -39,11 +41,17 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
|
||||
private final Duration cycleDeadline;
|
||||
private final Duration sourceReadDeadline;
|
||||
private final ExecutorService cycleExecutor;
|
||||
private final Consumer<Map<String, Object>> runtimeWriter;
|
||||
private final Object runtimeLock = new Object();
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
private volatile Instant lastAttemptAt;
|
||||
private final AtomicBoolean inFlight = new AtomicBoolean();
|
||||
private final AtomicBoolean permanentlyStopped = new AtomicBoolean();
|
||||
private volatile CycleStage activeStage = CycleStage.IDLE;
|
||||
private volatile ShadowReadCursor cursor = ShadowReadCursor.initial();
|
||||
private volatile Instant nextAttemptAt = Instant.EPOCH;
|
||||
private volatile Thread cycleWorker;
|
||||
private long lastTimingLogNanos;
|
||||
|
||||
public Ft546ShadowScheduler(SqlServerShadowFactReader factReader,
|
||||
Ft546ShadowCycle shadowCycle,
|
||||
@@ -67,6 +75,21 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
|
||||
Clock clock,
|
||||
Duration cycleDeadline,
|
||||
Duration sourceReadDeadline) {
|
||||
this(factReader, shadowCycle, statusReport, clock, cycleDeadline, sourceReadDeadline, state -> { });
|
||||
}
|
||||
|
||||
public Ft546ShadowScheduler(SqlServerShadowFactReader factReader,
|
||||
Ft546ShadowCycle shadowCycle, ShadowStartupReport statusReport,
|
||||
Clock clock, Duration cycleDeadline,
|
||||
Consumer<Map<String, Object>> runtimeWriter) {
|
||||
this(factReader, shadowCycle, statusReport, clock, cycleDeadline,
|
||||
shorter(cycleDeadline, SOURCE_READ_DEADLINE), runtimeWriter);
|
||||
}
|
||||
|
||||
private Ft546ShadowScheduler(SqlServerShadowFactReader factReader,
|
||||
Ft546ShadowCycle shadowCycle, ShadowStartupReport statusReport,
|
||||
Clock clock, Duration cycleDeadline, Duration sourceReadDeadline,
|
||||
Consumer<Map<String, Object>> runtimeWriter) {
|
||||
this.factReader = Objects.requireNonNull(factReader, "factReader");
|
||||
this.shadowCycle = Objects.requireNonNull(shadowCycle, "shadowCycle");
|
||||
this.statusReport = Objects.requireNonNull(statusReport,
|
||||
@@ -74,6 +97,7 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
this.cycleDeadline = requirePositive(cycleDeadline);
|
||||
this.sourceReadDeadline = requirePositive(sourceReadDeadline);
|
||||
this.runtimeWriter = Objects.requireNonNull(runtimeWriter, "runtimeWriter");
|
||||
this.cycleExecutor = Executors.newSingleThreadExecutor(
|
||||
shadowCycleThreadFactory());
|
||||
}
|
||||
@@ -82,7 +106,11 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
|
||||
initialDelayString = "${shadow.poll.initial-delay-ms:5000}",
|
||||
fixedDelayString = "${shadow.poll.fixed-delay-ms:5000}")
|
||||
public void runScheduledCycle() {
|
||||
runOnce();
|
||||
if (permanentlyStopped.get() || clock.instant().isBefore(nextAttemptAt)) {
|
||||
publishRuntimeState();
|
||||
} else {
|
||||
runOnce();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean runOnce() {
|
||||
@@ -91,11 +119,22 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
|
||||
|| !inFlight.compareAndSet(false, true)) {
|
||||
return false;
|
||||
}
|
||||
Future<CycleAttempt> future = cycleExecutor.submit(
|
||||
() -> executeCycle(now));
|
||||
ShadowCycleDiagnostics diagnostics = new ShadowCycleDiagnostics();
|
||||
lastAttemptAt = now;
|
||||
if (!publishRuntimeState()) {
|
||||
inFlight.set(false);
|
||||
return false;
|
||||
}
|
||||
cycleWorker = null;
|
||||
String outcome = "FAILED";
|
||||
Future<CycleAttempt> future = null;
|
||||
try {
|
||||
if (permanentlyStopped.get()) return false;
|
||||
future = cycleExecutor.submit(() -> executeCycle(now, diagnostics));
|
||||
CycleAttempt attempt = future.get(cycleDeadline.toMillis(),
|
||||
TimeUnit.MILLISECONDS);
|
||||
outcome = attempt.result.getStatusCode();
|
||||
statusReport.recordCycleDiagnostics(diagnostics.finish(outcome, null));
|
||||
if ("DEGRADED".equals(attempt.result.getStatusCode())) {
|
||||
permanentlyStopped.set(true);
|
||||
statusReport.markPermanentlyDegraded(
|
||||
@@ -109,20 +148,34 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
|
||||
return true;
|
||||
} catch (TimeoutException timeout) {
|
||||
CycleStage timedOutStage = activeStage;
|
||||
future.cancel(true);
|
||||
statusReport.recordCycleDiagnostics(diagnostics.cancel("TIMEOUT", cycleWorker));
|
||||
if (future != null) future.cancel(true);
|
||||
return stopPermanently("SHADOW_CYCLE_TIMEOUT", timeout,
|
||||
timedOutStage);
|
||||
} catch (InterruptedException interrupted) {
|
||||
CycleStage interruptedStage = activeStage;
|
||||
future.cancel(true);
|
||||
statusReport.recordCycleDiagnostics(diagnostics.cancel("INTERRUPTED", cycleWorker));
|
||||
if (future != null) future.cancel(true);
|
||||
Thread.currentThread().interrupt();
|
||||
return stopPermanently("SHADOW_CYCLE_INTERRUPTED", interrupted,
|
||||
interruptedStage);
|
||||
} catch (ExecutionException wrapped) {
|
||||
statusReport.recordCycleDiagnostics(diagnostics.finish("FAILED", null));
|
||||
return handleFailure(wrapped.getCause());
|
||||
} catch (java.util.concurrent.RejectedExecutionException shutdown) {
|
||||
return stopPermanently("SHADOW_EXECUTOR_STOPPED", shutdown, activeStage);
|
||||
} finally {
|
||||
Map<String, Object> completed = diagnostics.finish(outcome, null);
|
||||
statusReport.recordCycleDiagnostics(completed);
|
||||
long logTime = System.nanoTime();
|
||||
if (!permanentlyStopped.get() && (lastTimingLogNanos == 0L
|
||||
|| logTime - lastTimingLogNanos >= TimeUnit.MINUTES.toNanos(1))) {
|
||||
LOGGER.info("FT546 shadow cycle diagnostics: {}", completed);
|
||||
lastTimingLogNanos = logTime;
|
||||
}
|
||||
inFlight.set(false);
|
||||
activeStage = CycleStage.IDLE;
|
||||
publishRuntimeState();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,17 +190,37 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (!closed.compareAndSet(false, true)) return;
|
||||
permanentlyStopped.set(true);
|
||||
statusReport.markStopped(clock.instant());
|
||||
cycleExecutor.shutdownNow();
|
||||
publishRuntimeState();
|
||||
}
|
||||
|
||||
private CycleAttempt executeCycle(Instant startedAt) {
|
||||
private boolean publishRuntimeState() {
|
||||
synchronized (runtimeLock) {
|
||||
try {
|
||||
runtimeWriter.accept(statusReport.snapshotRuntimeState(
|
||||
clock.instant(), lastAttemptAt, permanentlyStopped.get()));
|
||||
return true;
|
||||
} catch (RuntimeException failure) {
|
||||
permanentlyStopped.set(true);
|
||||
statusReport.markPermanentlyDegraded("EVIDENCE_RUNTIME_WRITE_FAILED", clock.instant());
|
||||
LOGGER.error("FT546 shadow runtime state unavailable; collection stopped", failure);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private CycleAttempt executeCycle(Instant startedAt, ShadowCycleDiagnostics diagnostics) {
|
||||
cycleWorker = Thread.currentThread();
|
||||
activeStage = CycleStage.SOURCE_READ;
|
||||
diagnostics.enter("SOURCE_READ");
|
||||
ShadowFactBatch batch = factReader.read(cursor,
|
||||
startedAt.plus(sourceReadDeadline));
|
||||
activeStage = CycleStage.SHADOW_CYCLE;
|
||||
return new CycleAttempt(batch,
|
||||
shadowCycle.run(batch, clock.instant()));
|
||||
shadowCycle.run(batch, clock.instant(), diagnostics));
|
||||
}
|
||||
|
||||
private boolean handleFailure(Throwable failure) {
|
||||
@@ -208,8 +281,8 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
|
||||
permanentlyStopped.set(true);
|
||||
nextAttemptAt = Instant.EPOCH;
|
||||
statusReport.markPermanentlyDegraded(reasonCode, clock.instant());
|
||||
LOGGER.error("FT546 shadow collection stopped: {}; stage={}",
|
||||
reasonCode, stage, failure);
|
||||
LOGGER.error("FT546 shadow collection stopped: {}; stage={}; diagnostics={}",
|
||||
reasonCode, stage, statusReport.getCycleDiagnostics(), failure);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -88,8 +88,33 @@ public final class JdbcShadowTrafficStateRepository
|
||||
throw new IllegalStateException(
|
||||
"persisted traffic resources do not match topology");
|
||||
}
|
||||
return new ShadowTrafficState(ledger,
|
||||
WaitForGraph.restore(persisted.waitForEdges), row.version,
|
||||
// Only explicitly tagged predictions expire; untagged state is preserved conservatively.
|
||||
List<ResourceReservationState> protectedReservations = new ArrayList<ResourceReservationState>();
|
||||
Set<String> predictedVehicles = new LinkedHashSet<String>();
|
||||
Set<String> protectedVehicles = new LinkedHashSet<String>();
|
||||
for (ResourceReservationState reservation : persisted.ledger.getReservations()) {
|
||||
if (!persisted.predictionIds.contains(reservation.getReservationId())
|
||||
|| reservation.getState() == ReservationState.OCCUPIED
|
||||
|| reservation.isCommandUnknown() || reservation.isRecoveryUnresolved()) {
|
||||
protectedReservations.add(reservation);
|
||||
protectedVehicles.add(reservation.getVehicleId());
|
||||
} else {
|
||||
predictedVehicles.add(reservation.getVehicleId());
|
||||
}
|
||||
}
|
||||
predictedVehicles.removeAll(protectedVehicles);
|
||||
Map<String, Set<String>> remainingWaits = new LinkedHashMap<String, Set<String>>();
|
||||
for (Map.Entry<String, Set<String>> entry : persisted.waitForEdges.entrySet()) {
|
||||
if (!predictedVehicles.contains(entry.getKey())) {
|
||||
Set<String> holders = new LinkedHashSet<String>(entry.getValue());
|
||||
holders.removeAll(predictedVehicles);
|
||||
if (!holders.isEmpty()) remainingWaits.put(entry.getKey(), holders);
|
||||
}
|
||||
}
|
||||
WaitForGraph graph = WaitForGraph.restore(remainingWaits);
|
||||
ledger = ResourceLedger.restore(new ResourceLedgerState(persisted.ledger.getCapacities(),
|
||||
protectedReservations, persisted.ledger.getNextFencingToken()));
|
||||
return new ShadowTrafficState(ledger, graph, row.version,
|
||||
persisted.holdCounts);
|
||||
}
|
||||
|
||||
@@ -110,8 +135,10 @@ public final class JdbcShadowTrafficStateRepository
|
||||
Map<String, Integer> nextHolds = nextHoldCounts(previousHolds,
|
||||
commit.getHeld());
|
||||
long nextVersion = increment(commit.getSnapshotVersion());
|
||||
Set<String> predictionIds = new TreeSet<String>();
|
||||
commit.getGranted().forEach(grant -> predictionIds.addAll(grant.getReservationIds()));
|
||||
String stateJson = encode(commit.getResultingLedger().snapshotState(),
|
||||
commit.getResultingWaitForGraph().snapshotEdges(), nextHolds);
|
||||
commit.getResultingWaitForGraph().snapshotEdges(), nextHolds, predictionIds);
|
||||
Timestamp now = Timestamp.from(clock.instant());
|
||||
if (current == null) {
|
||||
jdbc.update("INSERT INTO shadow_traffic_state "
|
||||
@@ -197,9 +224,12 @@ public final class JdbcShadowTrafficStateRepository
|
||||
|
||||
private String encode(ResourceLedgerState ledger,
|
||||
Map<String, Set<String>> waitForEdges,
|
||||
Map<String, Integer> holdCounts) {
|
||||
Map<String, Integer> holdCounts,
|
||||
Set<String> predictionIds) {
|
||||
Map<String, Object> root = new LinkedHashMap<String, Object>();
|
||||
root.put("schemaVersion", 1);
|
||||
root.put("evaluationMode", "PER_SNAPSHOT_PREDICTION");
|
||||
root.put("predictionReservationIds", new ArrayList<String>(predictionIds));
|
||||
root.put("ledger", ledgerMap(ledger));
|
||||
Map<String, Object> edges = new TreeMap<String, Object>();
|
||||
for (Map.Entry<String, Set<String>> entry : waitForEdges.entrySet()) {
|
||||
@@ -267,7 +297,13 @@ public final class JdbcShadowTrafficStateRepository
|
||||
requiredObject(root, "waitForEdges"));
|
||||
Map<String, Integer> holds = integerMap(
|
||||
requiredObject(root, "holdCounts"));
|
||||
return new PersistedState(ledgerState, edges, holds);
|
||||
Set<String> predictionIds = new LinkedHashSet<String>();
|
||||
if (root.has("predictionReservationIds")) {
|
||||
for (JsonNode id : requiredArray(root, "predictionReservationIds")) {
|
||||
predictionIds.add(requiredNodeText(id, "predictionReservationId"));
|
||||
}
|
||||
}
|
||||
return new PersistedState(ledgerState, edges, holds, predictionIds);
|
||||
} catch (RuntimeException failure) {
|
||||
throw failure;
|
||||
} catch (Exception failure) {
|
||||
@@ -426,13 +462,16 @@ public final class JdbcShadowTrafficStateRepository
|
||||
private final ResourceLedgerState ledger;
|
||||
private final Map<String, Set<String>> waitForEdges;
|
||||
private final Map<String, Integer> holdCounts;
|
||||
private final Set<String> predictionIds;
|
||||
|
||||
private PersistedState(ResourceLedgerState ledger,
|
||||
Map<String, Set<String>> waitForEdges,
|
||||
Map<String, Integer> holdCounts) {
|
||||
Map<String, Integer> holdCounts,
|
||||
Set<String> predictionIds) {
|
||||
this.ledger = ledger;
|
||||
this.waitForEdges = waitForEdges;
|
||||
this.holdCounts = holdCounts;
|
||||
this.predictionIds = predictionIds;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.ruoyi.shadow.kernel;
|
||||
|
||||
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
|
||||
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
|
||||
import com.ruoyi.wcs.domain.traffic.RoutePlan;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -20,7 +21,8 @@ public final class ShadowCommandIntentFactory {
|
||||
short taskType = signedShort(parameters, "taskType");
|
||||
Integer stage = optionalStage(parameters.get("stage"));
|
||||
Short crossFloorTarget = crossFloorTarget(parameters, taskType, stage);
|
||||
List<Integer> path = path(grant);
|
||||
List<Integer> path = path(grant, taskType == 3 && stage == null
|
||||
&& "LEGACY_PHASE_3".equals(phase));
|
||||
String commandKey = parentTaskId + ':' + phase + ':' + routeVersion
|
||||
+ ':' + requiredText(grant.getStepId(), "stepId");
|
||||
return new Ft546SrgvCommandIntent(
|
||||
@@ -29,20 +31,33 @@ public final class ShadowCommandIntentFactory {
|
||||
taskType, path, crossFloorTarget, stage);
|
||||
}
|
||||
|
||||
private static List<Integer> path(GrantedDispatch grant) {
|
||||
private static List<Integer> path(GrantedDispatch grant, boolean sameSiteCharge) {
|
||||
RoutePlan route = grant.getProtectedRoute();
|
||||
List<String> nodes = route.getNodeIds();
|
||||
if (!route.hasTurnGeometry()) {
|
||||
throw new IllegalArgumentException("SHADOW_ROUTE_GEOMETRY_UNAVAILABLE");
|
||||
}
|
||||
if (nodes.size() == 1 && !sameSiteCharge) {
|
||||
throw new IllegalArgumentException("SHADOW_SINGLE_NODE_TRANSPORT_ROUTE");
|
||||
}
|
||||
List<Integer> points = new ArrayList<Integer>();
|
||||
for (String nodeId : grant.getProtectedRoute().getNodeIds()) {
|
||||
try {
|
||||
points.add(Integer.valueOf(requiredText(nodeId, "route node")));
|
||||
} catch (NumberFormatException failure) {
|
||||
throw new IllegalArgumentException(
|
||||
"route node must be a numeric FT546 site: " + nodeId,
|
||||
failure);
|
||||
}
|
||||
points.add(site(nodes.get(0)));
|
||||
points.add(site(nodes.get(nodes.size() - 1)));
|
||||
for (String turn : route.getTurnNodeIds()) points.add(site(turn));
|
||||
if (points.size() > 8) {
|
||||
throw new IllegalArgumentException("SHADOW_ROUTE_EXCEEDS_SIX_TURNS");
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
private static Integer site(String node) {
|
||||
String value = requiredText(node, "command point");
|
||||
if (!value.matches("[0-9]{1,6}") || Integer.parseInt(value) == 0) {
|
||||
throw new IllegalArgumentException("SHADOW_COMMAND_POINT_NOT_NUMERIC:" + value);
|
||||
}
|
||||
return Integer.valueOf(value);
|
||||
}
|
||||
|
||||
private static Short crossFloorTarget(Map<String, String> parameters,
|
||||
short taskType, Integer stage) {
|
||||
String value = parameters.get("crossFloorTarget");
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** One bounded diagnostic snapshot per poll, including polls with no task evidence. */
|
||||
final class ShadowCycleDiagnostics {
|
||||
private final LongSupplier ticker;
|
||||
private final long started;
|
||||
private long phaseStarted;
|
||||
private String phase = "QUEUED";
|
||||
private final Map<String, Long> phaseNanos = new LinkedHashMap<String, Long>();
|
||||
private Map<String, Object> completed;
|
||||
private boolean cancelled;
|
||||
|
||||
ShadowCycleDiagnostics() { this(System::nanoTime); }
|
||||
|
||||
ShadowCycleDiagnostics(LongSupplier ticker) {
|
||||
this.ticker = ticker;
|
||||
this.started = ticker.getAsLong();
|
||||
this.phaseStarted = started;
|
||||
}
|
||||
|
||||
synchronized void enter(String next) {
|
||||
if (cancelled || Thread.currentThread().isInterrupted()) {
|
||||
throw new CancellationException("SHADOW_CYCLE_CANCELLED");
|
||||
}
|
||||
if (completed != null) return;
|
||||
long now = ticker.getAsLong();
|
||||
accumulate(now);
|
||||
phase = next;
|
||||
phaseStarted = now;
|
||||
}
|
||||
|
||||
synchronized Map<String, Object> finish(String outcome, Thread worker) {
|
||||
if (completed != null) return completed;
|
||||
long now = ticker.getAsLong();
|
||||
accumulate(now);
|
||||
Map<String, Object> snapshot = new LinkedHashMap<String, Object>();
|
||||
snapshot.put("outcome", outcome);
|
||||
snapshot.put("phase", phase);
|
||||
snapshot.put("elapsedMillis", TimeUnit.NANOSECONDS.toMillis(now - started));
|
||||
Map<String, Long> timings = new LinkedHashMap<String, Long>();
|
||||
phaseNanos.forEach((key, value) -> timings.put(key, TimeUnit.NANOSECONDS.toMillis(value)));
|
||||
snapshot.put("phaseMillis", Collections.unmodifiableMap(timings));
|
||||
if (worker != null) {
|
||||
snapshot.put("workerState", worker.getState().name());
|
||||
List<String> stack = new ArrayList<String>();
|
||||
StackTraceElement[] frames = worker.getStackTrace();
|
||||
for (int i = 0; i < Math.min(16, frames.length); i++) stack.add(frames[i].toString());
|
||||
snapshot.put("workerStack", Collections.unmodifiableList(stack));
|
||||
}
|
||||
completed = Collections.unmodifiableMap(snapshot);
|
||||
return completed;
|
||||
}
|
||||
|
||||
synchronized Map<String, Object> cancel(String outcome, Thread worker) {
|
||||
cancelled = true;
|
||||
return finish(outcome, worker);
|
||||
}
|
||||
|
||||
private void accumulate(long now) {
|
||||
phaseNanos.merge(phase, now - phaseStarted, Long::sum);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.ruoyi.shadow.kernel;
|
||||
import com.ruoyi.shadow.facts.ShadowFactBatch;
|
||||
import com.ruoyi.shadow.facts.ShadowMapFact;
|
||||
import com.ruoyi.shadow.facts.ShadowQueueFact;
|
||||
import com.ruoyi.shadow.facts.ShadowQueueEndpoints;
|
||||
import com.ruoyi.shadow.facts.ShadowVehicleFact;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
|
||||
import com.ruoyi.wcs.domain.dispatch.DispatchTask;
|
||||
@@ -106,8 +107,8 @@ public final class ShadowDispatchSnapshotAssembler {
|
||||
if (!ACTIVE_QUEUE.equals(fact.getTableName())) {
|
||||
continue;
|
||||
}
|
||||
String source = required(fact.getStartSite(), "startSite");
|
||||
String destination = required(fact.getEndSite(), "endSite");
|
||||
String source = required(ShadowQueueEndpoints.start(fact, batch), "startSite");
|
||||
String destination = required(ShadowQueueEndpoints.end(fact, batch), "endSite");
|
||||
if (!topology.containsNode(source)
|
||||
|| !topology.containsNode(destination)) {
|
||||
throw new IllegalArgumentException(
|
||||
@@ -174,8 +175,8 @@ public final class ShadowDispatchSnapshotAssembler {
|
||||
EnumSet.of(DeviceCapability.FOUR_WAY_MOVE,
|
||||
DeviceCapability.LOAD_HANDLING,
|
||||
DeviceCapability.LIFT_TRANSFER));
|
||||
boolean connectedAndSendable = "1".equals(optional(
|
||||
fact.getAgvStatus()))
|
||||
// Read-only projection of the legacy PLC gate; agvStatus is not refreshed by PLC polling.
|
||||
boolean connectedAndSendable = Integer.valueOf(5).equals(fact.getWorkMode())
|
||||
&& Integer.valueOf(1).equals(fact.getAllowSend());
|
||||
boolean healthy = zeroOrMissing(fact.getAlarm())
|
||||
&& zeroOrMissing(fact.getErrorCode());
|
||||
|
||||
@@ -16,6 +16,7 @@ public final class ShadowStartupReport {
|
||||
private volatile String reasonCode;
|
||||
private volatile String permanentDegradedReason;
|
||||
private final Map<String, String> startupChecks;
|
||||
private volatile Map<String, Object> cycleDiagnostics = Collections.emptyMap();
|
||||
|
||||
private ShadowStartupReport(Status status, Instant checkedAt,
|
||||
Instant lastCompletedCycle,
|
||||
@@ -44,10 +45,15 @@ public final class ShadowStartupReport {
|
||||
public long getEvidenceBacklog() { return evidenceBacklog; }
|
||||
public String getReasonCode() { return reasonCode; }
|
||||
public Map<String, String> getStartupChecks() { return startupChecks; }
|
||||
public Map<String, Object> getCycleDiagnostics() { return cycleDiagnostics; }
|
||||
|
||||
public void recordCycleDiagnostics(Map<String, Object> diagnostics) {
|
||||
cycleDiagnostics = Collections.unmodifiableMap(new LinkedHashMap<String, Object>(diagnostics));
|
||||
}
|
||||
|
||||
public synchronized void markReady(Instant completedAt,
|
||||
Long sourceLagMillis) {
|
||||
if (permanentDegradedReason != null) {
|
||||
if (permanentDegradedReason != null || status == Status.STOPPED) {
|
||||
return;
|
||||
}
|
||||
this.status = Status.READY;
|
||||
@@ -60,7 +66,7 @@ public final class ShadowStartupReport {
|
||||
public synchronized void markDegraded(String reasonCode,
|
||||
Instant checkedAt) {
|
||||
String normalized = requireReasonCode(reasonCode);
|
||||
if (permanentDegradedReason != null) {
|
||||
if (permanentDegradedReason != null || status == Status.STOPPED) {
|
||||
return;
|
||||
}
|
||||
this.status = Status.DEGRADED;
|
||||
@@ -71,6 +77,7 @@ public final class ShadowStartupReport {
|
||||
public synchronized void markPermanentlyDegraded(String reasonCode,
|
||||
Instant checkedAt) {
|
||||
String normalized = requireReasonCode(reasonCode);
|
||||
if (status == Status.STOPPED) return;
|
||||
if (permanentDegradedReason == null) {
|
||||
permanentDegradedReason = normalized;
|
||||
this.checkedAt = checkedAt;
|
||||
@@ -79,6 +86,30 @@ public final class ShadowStartupReport {
|
||||
this.reasonCode = permanentDegradedReason;
|
||||
}
|
||||
|
||||
public synchronized void markStopped(Instant stoppedAt) {
|
||||
status = Status.STOPPED;
|
||||
checkedAt = stoppedAt;
|
||||
if (reasonCode == null) reasonCode = "SHADOW_APPLICATION_STOPPED";
|
||||
}
|
||||
|
||||
public synchronized Map<String, Object> snapshotRuntimeState(
|
||||
Instant heartbeatAt, Instant lastAttemptAt, boolean collectionStopped) {
|
||||
Map<String, Object> state = new LinkedHashMap<String, Object>();
|
||||
state.put("schemaVersion", 1);
|
||||
state.put("kind", "RUN_HEARTBEAT");
|
||||
state.put("heartbeatAt", heartbeatAt.toString());
|
||||
state.put("lastAttemptAt", lastAttemptAt == null ? null : lastAttemptAt.toString());
|
||||
state.put("lastCompletedCycle", lastCompletedCycle == null ? null : lastCompletedCycle.toString());
|
||||
state.put("checkedAt", checkedAt == null ? null : checkedAt.toString());
|
||||
state.put("status", status.name());
|
||||
state.put("reasonCode", reasonCode);
|
||||
state.put("collectionStopped", collectionStopped || status == Status.STOPPED
|
||||
|| permanentDegradedReason != null);
|
||||
state.put("sourceLagMillis", sourceLagMillis);
|
||||
state.put("cycleDiagnostics", cycleDiagnostics);
|
||||
return Collections.unmodifiableMap(state);
|
||||
}
|
||||
|
||||
private static String requireReasonCode(String reasonCode) {
|
||||
if (reasonCode == null || reasonCode.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
|
||||
@@ -21,6 +21,8 @@ shadow:
|
||||
password:
|
||||
output-directory:
|
||||
minimum-free-bytes:
|
||||
maximum-database-bytes: 17179869184
|
||||
maximum-batch-payload-bytes: 33554432
|
||||
run:
|
||||
artifact-sha256:
|
||||
configuration-sha256:
|
||||
|
||||
@@ -68,6 +68,109 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
class Ft546ShadowFaultIsolationTest {
|
||||
private static final Instant NOW = Instant.parse("2026-08-14T06:00:00Z");
|
||||
|
||||
@Test
|
||||
void emptyPollPublishesHeartbeatAndOrderlyCloseIsSticky() {
|
||||
MutableClock clock = new MutableClock(NOW);
|
||||
ShadowStartupReport report = report();
|
||||
List<Map<String, Object>> heartbeats = new ArrayList<Map<String, Object>>();
|
||||
Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(
|
||||
reader(emptySource(new AtomicInteger()), clock),
|
||||
evidenceCycle(report, new RecordingEvidenceRepository(), gateway(),
|
||||
() -> ShadowEvidenceStorageHealth.Check.available(Long.MAX_VALUE)),
|
||||
report, clock, Duration.ofSeconds(10), heartbeats::add);
|
||||
try {
|
||||
assertTrue(scheduler.runOnce());
|
||||
assertEquals("STARTING", heartbeats.get(0).get("status"));
|
||||
Map<String, Object> ready = heartbeats.get(heartbeats.size() - 1);
|
||||
assertEquals("READY", ready.get("status"));
|
||||
assertEquals(NOW.toString(), ready.get("lastAttemptAt"));
|
||||
assertTrue(ready.get("lastCompletedCycle") != null);
|
||||
assertEquals(Boolean.FALSE, ready.get("collectionStopped"));
|
||||
} finally {
|
||||
scheduler.close();
|
||||
}
|
||||
report.markReady(NOW.plusSeconds(5), 0L);
|
||||
report.markPermanentlyDegraded("LATE_FAILURE", NOW.plusSeconds(6));
|
||||
assertEquals(ShadowStartupReport.Status.STOPPED, report.getStatus());
|
||||
Map<String, Object> stopped = heartbeats.get(heartbeats.size() - 1);
|
||||
assertEquals("STOPPED", stopped.get("status"));
|
||||
assertEquals(Boolean.TRUE, stopped.get("collectionStopped"));
|
||||
assertFalse(scheduler.runOnce());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runtimeWriteFailureStopsBeforeReadingSource() {
|
||||
MutableClock clock = new MutableClock(NOW);
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
ShadowStartupReport report = report();
|
||||
try (Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(
|
||||
reader(emptySource(calls), clock),
|
||||
evidenceCycle(report, new RecordingEvidenceRepository(), gateway(),
|
||||
() -> ShadowEvidenceStorageHealth.Check.available(Long.MAX_VALUE)),
|
||||
report, clock, Duration.ofSeconds(10), state -> { throw new IllegalStateException("offline"); })) {
|
||||
assertFalse(scheduler.runOnce());
|
||||
assertEquals(0, calls.get());
|
||||
assertTrue(scheduler.isPermanentlyStopped());
|
||||
assertEquals("EVIDENCE_RUNTIME_WRITE_FAILED", report.getReasonCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryAndPermanentFailurePublishDifferentCollectionStates() {
|
||||
for (boolean timeout : new boolean[] {false, true}) {
|
||||
MutableClock clock = new MutableClock(NOW);
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
ShadowStartupReport report = report();
|
||||
List<Map<String, Object>> heartbeats = new ArrayList<Map<String, Object>>();
|
||||
SqlServerShadowFactReader.FactSource source = new FactSourceAdapter() {
|
||||
@Override
|
||||
public Instant readDatabaseClock(Instant deadline) {
|
||||
calls.incrementAndGet();
|
||||
if (timeout) throw new QueryTimeoutException("timeout");
|
||||
throw new DataAccessResourceFailureException("offline");
|
||||
}
|
||||
};
|
||||
try (Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(reader(source, clock),
|
||||
evidenceCycle(report, new RecordingEvidenceRepository(), gateway(),
|
||||
() -> ShadowEvidenceStorageHealth.Check.available(Long.MAX_VALUE)),
|
||||
report, clock, Duration.ofSeconds(10), heartbeats::add)) {
|
||||
assertFalse(scheduler.runOnce());
|
||||
Map<String, Object> failed = heartbeats.get(heartbeats.size() - 1);
|
||||
assertEquals("DEGRADED", failed.get("status"));
|
||||
assertEquals(Boolean.valueOf(timeout), failed.get("collectionStopped"));
|
||||
assertEquals(null, failed.get("lastCompletedCycle"));
|
||||
clock.advance(Duration.ofMillis(100));
|
||||
scheduler.runScheduledCycle();
|
||||
Map<String, Object> waiting = heartbeats.get(heartbeats.size() - 1);
|
||||
assertEquals(clock.instant().toString(), waiting.get("heartbeatAt"));
|
||||
assertEquals(NOW.toString(), waiting.get("lastAttemptAt"));
|
||||
assertEquals(1, calls.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void capacityLimitPublishesSpecificPermanentReasonWithoutEvidenceWrites() {
|
||||
for (String reason : Arrays.asList("EVIDENCE_DATABASE_SIZE_LIMIT", "EVIDENCE_BATCH_PAYLOAD_LIMIT")) {
|
||||
MutableClock clock = new MutableClock(NOW);
|
||||
ShadowStartupReport report = report();
|
||||
RecordingEvidenceRepository evidence = new RecordingEvidenceRepository();
|
||||
if (reason.contains("PAYLOAD")) evidence.saveFailure = new ShadowEvidenceException(reason);
|
||||
List<Map<String, Object>> heartbeats = new ArrayList<Map<String, Object>>();
|
||||
try (Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(
|
||||
reader(emptySource(new AtomicInteger()), clock), evidenceCycle(report, evidence, gateway(),
|
||||
() -> reason.contains("DATABASE") ? ShadowEvidenceStorageHealth.Check.unavailable(reason)
|
||||
: ShadowEvidenceStorageHealth.Check.available(Long.MAX_VALUE)),
|
||||
report, clock, Duration.ofSeconds(10), heartbeats::add)) {
|
||||
assertTrue(scheduler.runOnce());
|
||||
assertEquals(reason, report.getReasonCode());
|
||||
assertTrue(scheduler.isPermanentlyStopped());
|
||||
assertEquals(reason, heartbeats.get(heartbeats.size() - 1).get("reasonCode"));
|
||||
if (reason.contains("DATABASE")) assertEquals(0, evidence.transactionCalls.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceQueryTimeoutPermanentlyStopsCollectionBeforeCycle() {
|
||||
MutableClock clock = new MutableClock(NOW);
|
||||
@@ -89,6 +192,8 @@ class Ft546ShadowFaultIsolationTest {
|
||||
|
||||
try {
|
||||
assertFalse(scheduler.runOnce());
|
||||
assertEquals("SOURCE_READ", report.getCycleDiagnostics().get("phase"));
|
||||
assertEquals("FAILED", report.getCycleDiagnostics().get("outcome"));
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
|
||||
assertFalse(scheduler.runOnce());
|
||||
@@ -364,6 +469,11 @@ class Ft546ShadowFaultIsolationTest {
|
||||
event.getFormattedMessage()
|
||||
.contains("stage=SHADOW_CYCLE")),
|
||||
"timeout log must identify the blocked stage");
|
||||
assertTrue(logAppender.list.stream().anyMatch(event ->
|
||||
event.getFormattedMessage().contains("STORAGE_HEALTH")
|
||||
&& event.getFormattedMessage().contains("elapsedMillis")
|
||||
&& event.getFormattedMessage().contains("workerStack")),
|
||||
"timeout must capture subphase, monotonic elapsed time and worker stack before cancellation");
|
||||
assertFalse(scheduler.runOnce());
|
||||
assertEquals(callsAfterTimeout, sourceCalls.get());
|
||||
assertEquals(Collections.emptyList(), gateway.getRecorded());
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ruoyi.shadow.config;
|
||||
|
||||
import com.ruoyi.wcs.domain.shared.IdGenerator;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
|
||||
class ShadowIdGeneratorTest {
|
||||
@Test
|
||||
void separateApplicationRunsCannotReuseReservationIds() {
|
||||
IdGenerator first = new ShadowKernelConfiguration().shadowIdGenerator();
|
||||
IdGenerator restarted = new ShadowKernelConfiguration().shadowIdGenerator();
|
||||
assertNotEquals(first.nextId(), restarted.nextId());
|
||||
assertNotEquals(first.nextId(), first.nextId());
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,77 @@ class JdbcShadowEvidenceRepositoryTest {
|
||||
objectMapper, run);
|
||||
}
|
||||
|
||||
@Test
|
||||
void heartbeatPersistsEmptyRunWithoutInventingCycleEvidence() throws Exception {
|
||||
createTrafficSchema();
|
||||
com.ruoyi.shadow.startup.ShadowStartupReport report =
|
||||
com.ruoyi.shadow.startup.ShadowStartupReport.starting(NOW, Collections.emptyMap());
|
||||
repository.saveRuntimeState(report.snapshotRuntimeState(NOW, null, false));
|
||||
assertEquals(1, count("shadow_run"));
|
||||
assertEquals(0, count("shadow_cycle"));
|
||||
assertEquals(1, count("shadow_traffic_state"));
|
||||
assertEquals("FT546:RUN:" + run.getRunId(), jdbc.queryForObject(
|
||||
"SELECT state_key FROM shadow_traffic_state", String.class));
|
||||
report.markReady(NOW.plusSeconds(5), 0L);
|
||||
repository.saveRuntimeState(report.snapshotRuntimeState(NOW.plusSeconds(5), NOW, false));
|
||||
assertEquals("RUNNING", jdbc.queryForObject("SELECT status FROM shadow_run", String.class));
|
||||
com.fasterxml.jackson.databind.JsonNode state = objectMapper.readTree(jdbc.queryForObject(
|
||||
"SELECT state_json FROM shadow_traffic_state", String.class));
|
||||
assertEquals(NOW.plusSeconds(5).toString(), state.path("heartbeatAt").asText());
|
||||
assertEquals(NOW.toString(), state.path("lastAttemptAt").asText());
|
||||
assertEquals(NOW.plusSeconds(5).toString(), state.path("lastCompletedCycle").asText());
|
||||
assertEquals(1, count("shadow_traffic_state"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void stoppedRunCannotBeResurrectedAndRuntimeWritesAreAtomic() {
|
||||
createTrafficSchema();
|
||||
com.ruoyi.shadow.startup.ShadowStartupReport report =
|
||||
com.ruoyi.shadow.startup.ShadowStartupReport.starting(NOW, Collections.emptyMap());
|
||||
Map<String, Object> old = report.snapshotRuntimeState(NOW, null, false);
|
||||
report.markStopped(NOW.plusSeconds(1));
|
||||
repository.saveRuntimeState(report.snapshotRuntimeState(NOW.plusSeconds(1), null, true));
|
||||
JdbcShadowEvidenceRepository restarted = new JdbcShadowEvidenceRepository(jdbc, transaction, objectMapper, run);
|
||||
assertThrows(ShadowEvidenceException.class, () -> restarted.saveRuntimeState(old));
|
||||
assertEquals("STOPPED", jdbc.queryForObject("SELECT status FROM shadow_run", String.class));
|
||||
jdbc.execute("ALTER TABLE shadow_traffic_state ADD CONSTRAINT ck_runtime_write CHECK (snapshot_version < 2)");
|
||||
assertThrows(RuntimeException.class, () -> repository.saveRuntimeState(
|
||||
report.snapshotRuntimeState(NOW.plusSeconds(2), null, true)));
|
||||
assertEquals(1L, jdbc.queryForObject("SELECT snapshot_version FROM shadow_traffic_state", Long.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedEvidenceRollsBackTrafficAndRunWithoutPartialRows() {
|
||||
createTrafficSchema();
|
||||
JdbcShadowEvidenceRepository bounded = new JdbcShadowEvidenceRepository(jdbc, transaction,
|
||||
objectMapper, run, 1L);
|
||||
JdbcShadowTrafficStateRepository traffic = new JdbcShadowTrafficStateRepository(jdbc, transaction,
|
||||
objectMapper, java.time.Clock.fixed(NOW, ZoneOffset.UTC),
|
||||
new ShadowCommandIntentFactory(), recordingGateway());
|
||||
ShadowEvidenceException failure = assertThrows(ShadowEvidenceException.class,
|
||||
() -> bounded.inTransaction(() -> {
|
||||
assertTrue(traffic.commit(trafficCommit()));
|
||||
return bounded.save(matchingCycle());
|
||||
}));
|
||||
assertEquals("EVIDENCE_BATCH_PAYLOAD_LIMIT", failure.getReasonCode());
|
||||
assertEquals(0, count("shadow_run"));
|
||||
assertEquals(0, count("shadow_cycle"));
|
||||
assertEquals(0, count("shadow_traffic_state"));
|
||||
assertEquals(0, count("shadow_hold_history"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void payloadLimitDoesNotRejectAnAlreadyPersistedReplay() {
|
||||
ShadowCycleEvidence evidence = matchingCycle();
|
||||
repository.save(evidence);
|
||||
JdbcShadowEvidenceRepository bounded = new JdbcShadowEvidenceRepository(jdbc, transaction,
|
||||
objectMapper, run, 1L);
|
||||
assertEquals(evidence.getCycleId(), bounded.save(evidence).getCycleId());
|
||||
assertEquals(1, count("shadow_cycle"));
|
||||
assertEquals(evidence.getInputJson(), jdbc.queryForObject(
|
||||
"SELECT captured_json FROM shadow_input_batch", String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sqlServerDuplicateKeyCodesAreRecognizedWithoutBroadeningIntegrityErrors() {
|
||||
assertTrue(JdbcShadowEvidenceRepository.isDuplicateKey(
|
||||
|
||||
@@ -18,6 +18,28 @@ class JdbcShadowEvidenceStorageHealthTest {
|
||||
@TempDir
|
||||
Path outputDirectory;
|
||||
|
||||
@Test
|
||||
void databaseAllocationAtLimitStopsCollection() {
|
||||
JdbcShadowEvidenceStorageHealth health = new JdbcShadowEvidenceStorageHealth(
|
||||
repository(true), outputDirectory, 0L, () -> 1000L, 1000L);
|
||||
assertEquals("EVIDENCE_DATABASE_SIZE_LIMIT", health.verify().getReasonCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void databaseAllocationBelowLimitIsAvailable() {
|
||||
assertTrue(new JdbcShadowEvidenceStorageHealth(repository(true), outputDirectory,
|
||||
0L, () -> 999L, 1000L).verify().isAvailable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreadableOrInvalidDatabaseSizeFailsClosed() {
|
||||
assertEquals("EVIDENCE_DATABASE_SIZE_CHECK_FAILED", new JdbcShadowEvidenceStorageHealth(
|
||||
repository(true), outputDirectory, 0L, () -> { throw new IllegalStateException(); },
|
||||
1000L).verify().getReasonCode());
|
||||
assertEquals("EVIDENCE_DATABASE_SIZE_CHECK_FAILED", new JdbcShadowEvidenceStorageHealth(
|
||||
repository(true), outputDirectory, 0L, () -> -1L, 1000L).verify().getReasonCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unavailableDatabaseFailsBeforeDiskCheck() {
|
||||
Path missing = outputDirectory.resolve("missing");
|
||||
|
||||
@@ -264,6 +264,27 @@ class SqlServerShadowFactReaderTest {
|
||||
org.mockito.ArgumentMatchers.contains("dbo."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jdbcQueueProjectionCapturesSourceFloorFromExistingReadOnlyView() throws Exception {
|
||||
DataSource dataSource = mock(DataSource.class);
|
||||
Connection connection = mock(Connection.class);
|
||||
PreparedStatement statement = mock(PreparedStatement.class);
|
||||
ResultSet result = mock(ResultSet.class);
|
||||
when(dataSource.getConnection()).thenReturn(connection);
|
||||
when(connection.prepareStatement(org.mockito.ArgumentMatchers.anyString())).thenReturn(statement);
|
||||
when(statement.executeQuery()).thenReturn(result);
|
||||
when(result.next()).thenReturn(true, false, false);
|
||||
when(result.getString("guid")).thenReturn("LIFT");
|
||||
when(result.getString("the_floor")).thenReturn("2");
|
||||
SqlServerShadowFactReader.JdbcFactSource source = new SqlServerShadowFactReader.JdbcFactSource(
|
||||
new JdbcTemplate(dataSource), Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
List<ShadowQueueFact> queues = source.readQueues(ShadowReadCursor.initial(), NOW.plusSeconds(4));
|
||||
assertEquals(1, queues.size());
|
||||
assertEquals("2", queues.get(0).getTheFloor());
|
||||
assertTrue(SqlServerShadowFactReader.JdbcFactSource.QUEUE_SQL_PREFIX.contains("the_floor"));
|
||||
verify(result).getString("the_floor");
|
||||
}
|
||||
|
||||
@Test
|
||||
void jdbcVehicleProjectionCapturesDispatchAndLegacyGateFacts()
|
||||
throws Exception {
|
||||
|
||||
@@ -254,6 +254,41 @@ class Ft546ShadowCycleTest {
|
||||
assertEquals(now, report.getLastCompletedCycle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void evidenceCompletionAndReadyTimeIncludeWorkInsteadOfReusingEntryTime() {
|
||||
Instant now = Instant.parse("2026-09-06T23:59:59Z");
|
||||
java.util.concurrent.atomic.AtomicReference<Instant> time =
|
||||
new java.util.concurrent.atomic.AtomicReference<Instant>(now);
|
||||
Clock clock = new Clock() {
|
||||
public java.time.ZoneId getZone() { return ZoneOffset.UTC; }
|
||||
public Clock withZone(java.time.ZoneId zone) { return this; }
|
||||
public Instant instant() { return time.get(); }
|
||||
};
|
||||
RecordingEvidenceRepository evidence = new RecordingEvidenceRepository();
|
||||
java.util.concurrent.atomic.AtomicLong nanos = new java.util.concurrent.atomic.AtomicLong();
|
||||
ShadowCycleDiagnostics progress = new ShadowCycleDiagnostics(nanos::get);
|
||||
evidence.beforeCommit = () -> {
|
||||
time.set(now.plusSeconds(3));
|
||||
nanos.set(3000000000L);
|
||||
};
|
||||
ShadowStartupReport report = ShadowStartupReport.starting(now, Collections.emptyMap());
|
||||
Ft546ShadowCycle cycle = evidenceCycle(now, evidence, report, gateway(), clock,
|
||||
() -> {
|
||||
time.set(now.plusSeconds(2));
|
||||
nanos.set(2000000000L);
|
||||
});
|
||||
ShadowFactBatch batch = ShadowFactBatch.create(now.minusMillis(50), now,
|
||||
Collections.singletonList(queueWithoutLegacyAssignment(now)),
|
||||
Collections.singletonList(vehicle(now)), maps());
|
||||
assertEquals("COMPARABLE", cycle.run(batch, now, progress).getStatusCode());
|
||||
assertEquals(now.plusSeconds(2), evidence.saved.get(0).getCompletedAt());
|
||||
assertEquals(now.plusSeconds(3), report.getLastCompletedCycle());
|
||||
assertEquals(Long.valueOf(3000), report.getSourceLagMillis());
|
||||
Map<?, ?> timings = (Map<?, ?>) progress.finish("COMPARABLE", null).get("phaseMillis");
|
||||
assertEquals(1000L, timings.get("TRANSACTION_COMMIT"));
|
||||
assertEquals(2000L, timings.get("DISPATCH_AND_STATE_SAVE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void evidenceFailureRecordsNoCommandAndStopsLaterCalculation() {
|
||||
Instant now = Instant.parse("2026-08-14T06:00:00Z");
|
||||
@@ -345,7 +380,7 @@ class Ft546ShadowCycleTest {
|
||||
ShadowCycleResult second = cycle.run(batch, now.plusSeconds(1));
|
||||
|
||||
assertEquals("DEGRADED", first.getStatusCode());
|
||||
assertEquals("EVIDENCE_STORAGE_UNAVAILABLE", first.getReasonCode());
|
||||
assertEquals("FREE_SPACE_BELOW_MINIMUM", first.getReasonCode());
|
||||
assertEquals("DEGRADED", second.getStatusCode());
|
||||
assertEquals(1, healthChecks.get());
|
||||
assertEquals(0, assemblyCalls.get());
|
||||
@@ -357,6 +392,14 @@ class Ft546ShadowCycleTest {
|
||||
Instant now, RecordingEvidenceRepository evidence,
|
||||
ShadowStartupReport report,
|
||||
RecordingFt546CommandGateway gateway) {
|
||||
return evidenceCycle(now, evidence, report, gateway,
|
||||
Clock.fixed(now, ZoneOffset.UTC), () -> { });
|
||||
}
|
||||
|
||||
private static Ft546ShadowCycle evidenceCycle(
|
||||
Instant now, RecordingEvidenceRepository evidence,
|
||||
ShadowStartupReport report, RecordingFt546CommandGateway gateway,
|
||||
Clock clock, Runnable onDispatch) {
|
||||
RoutePlanner routePlanner = new AStarRoutePlanner();
|
||||
DispatchCoordinator coordinator = new DispatchCoordinator(
|
||||
new DefaultVehicleAssignmentPolicy(routePlanner,
|
||||
@@ -365,6 +408,7 @@ class Ft546ShadowCycleTest {
|
||||
commit -> {
|
||||
evidence.dispatchObservedInsideTransaction =
|
||||
evidence.insideTransaction;
|
||||
onDispatch.run();
|
||||
return true;
|
||||
}, () -> "SHADOW-1");
|
||||
ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> readOnly =
|
||||
@@ -384,7 +428,7 @@ class Ft546ShadowCycleTest {
|
||||
assembler::assemble, readOnly, gateway,
|
||||
() -> ShadowEvidenceStorageHealth.Check.available(
|
||||
Long.MAX_VALUE),
|
||||
evidence, collector(evidence.runId, 3L), report);
|
||||
evidence, collector(evidence.runId, 3L), report, clock);
|
||||
}
|
||||
|
||||
private static ShadowCycleEvidenceCollector collector(
|
||||
@@ -542,6 +586,7 @@ class Ft546ShadowCycleTest {
|
||||
private boolean insideTransaction;
|
||||
private boolean dispatchObservedInsideTransaction;
|
||||
private RuntimeException saveFailure;
|
||||
private Runnable beforeCommit = () -> { };
|
||||
|
||||
@Override
|
||||
public SavedCycle save(ShadowCycleEvidence evidence) {
|
||||
@@ -570,7 +615,9 @@ class Ft546ShadowCycleTest {
|
||||
transactionCalls.incrementAndGet();
|
||||
insideTransaction = true;
|
||||
try {
|
||||
return work.get();
|
||||
T result = work.get();
|
||||
beforeCommit.run();
|
||||
return result;
|
||||
} finally {
|
||||
insideTransaction = false;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,38 @@ class JdbcShadowTrafficStateRepositoryTest {
|
||||
topology = topology();
|
||||
}
|
||||
|
||||
@Test
|
||||
void hypotheticalGrantDoesNotBlockNextPollOrRestart() {
|
||||
ResourceLedger ledger = new ResourceLedger(topology);
|
||||
assertTrue(ledger.reserve(new ReservationRequest("PREDICTED", "ORDER-1", "STEP-1",
|
||||
"VEH-1", Collections.singletonList("NODE:A"))).isGranted());
|
||||
WaitForGraph graph = new WaitForGraph();
|
||||
graph.addWait("VEH-2", "VEH-1");
|
||||
assertTrue(repository().commit(predictionCommit(ledger, graph, "PREDICTED")));
|
||||
ShadowTrafficState next = repository().load(topology);
|
||||
assertEquals(1L, next.getSnapshotVersion());
|
||||
assertTrue(next.getLedger().reserve(new ReservationRequest("NEXT-PREDICTION", "ORDER-2", "STEP-2",
|
||||
"VEH-2", Collections.singletonList("NODE:A"))).isGranted());
|
||||
assertTrue(next.getWaitForGraph().holdersWaitedOnBy("VEH-2").isEmpty());
|
||||
assertEquals(1, next.getConsecutiveHoldCounts().values().iterator().next().intValue());
|
||||
assertTrue(jdbc.queryForObject("SELECT state_json FROM shadow_traffic_state", String.class)
|
||||
.contains("PREDICTED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownOccupiedReservationRemainsProtectedAcrossPredictions() {
|
||||
ResourceLedger ledger = new ResourceLedger(topology);
|
||||
ReservationDecision reserved = ledger.reserve(new ReservationRequest("UNKNOWN", "ORDER-1", "STEP-1",
|
||||
"VEH-1", Collections.singletonList("NODE:A")));
|
||||
ledger.occupy("UNKNOWN", reserved.getFencingToken());
|
||||
ledger.markCommandUnknown("UNKNOWN");
|
||||
assertTrue(repository().commit(predictionCommit(ledger, waitGraph(), "UNKNOWN")));
|
||||
ShadowTrafficState next = repository().load(topology);
|
||||
assertTrue(next.getLedger().reservation("UNKNOWN").isCommandUnknown());
|
||||
assertFalse(next.getLedger().reserve(new ReservationRequest("NEXT", "ORDER-2", "STEP-2",
|
||||
"VEH-2", Collections.singletonList("NODE:A"))).isGranted());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restartReloadPreservesReservationsWaitEdgesAndThreeHoldCount() {
|
||||
JdbcShadowTrafficStateRepository first = repository();
|
||||
@@ -249,6 +281,22 @@ class JdbcShadowTrafficStateRepositoryTest {
|
||||
Collections.emptyList(), Collections.emptyList(), ledger, graph);
|
||||
}
|
||||
|
||||
private DispatchCommit predictionCommit(ResourceLedger ledger, WaitForGraph graph, String reservationId) {
|
||||
Map<String, String> parameters = new LinkedHashMap<String, String>();
|
||||
parameters.put("parentTaskId", "ORDER-1");
|
||||
parameters.put("phase", "LOADED_TO_PLACE");
|
||||
parameters.put("routeVersion", "3");
|
||||
parameters.put("taskNo", "30001");
|
||||
parameters.put("taskType", "1");
|
||||
GrantedDispatch grant = new GrantedDispatch("ORDER-1", "STEP-1", "VEH-1",
|
||||
new RoutePlan(Arrays.asList("70001", "70002"), Collections.singletonList("NODE:A"),
|
||||
1, 0, topology.version()), Collections.singletonList(reservationId),
|
||||
topology.version(), 1L, parameters);
|
||||
return new DispatchCommit("FT546", topology.version(), 0L,
|
||||
Collections.singletonList(grant), Collections.singletonList(held("CAPACITY_CONFLICT")),
|
||||
Collections.emptyList(), Collections.emptyList(), ledger, graph);
|
||||
}
|
||||
|
||||
private ResourceLedger protectedLedger() {
|
||||
ResourceLedger ledger = new ResourceLedger(topology);
|
||||
ReservationDecision reserved = ledger.reserve(new ReservationRequest(
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.ft546.protocol.*;
|
||||
import com.ruoyi.shadow.facts.*;
|
||||
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
|
||||
import com.ruoyi.wcs.application.ShadowDispatchResult;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
|
||||
import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy;
|
||||
import com.ruoyi.wcs.port.out.DispatchPolicy;
|
||||
import com.ruoyi.wcs.domain.traffic.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** Independent input replay, not a reconstruction of historical traffic state. */
|
||||
@EnabledIfSystemProperty(named = "shadow.replay.zip", matches = ".+")
|
||||
class ShadowCapturedInputReplayTest {
|
||||
@Test
|
||||
void capturedInputsRetainSafetyGatesAndReconstructLegacyChargingCommands() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
int inputs = 0, legacyAvailable = 0, grants = 0, holds = 0, writes = 0;
|
||||
Map<String, Integer> holdReasons = new TreeMap<String, Integer>();
|
||||
try (ZipFile zip = new ZipFile(System.getProperty("shadow.replay.zip"))) {
|
||||
ZipEntry entry = Collections.list(zip.entries()).stream()
|
||||
.filter(e -> e.getName().replace('\\', '/').endsWith("/13_tail300_input.jsonl"))
|
||||
.findFirst().orElseThrow(() -> new AssertionError("missing captured inputs"));
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(
|
||||
zip.getInputStream(entry), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.trim().isEmpty()) continue;
|
||||
JsonNode row = mapper.readTree(line.replace("\uFEFF", ""));
|
||||
String raw = row.get("captured_json").asText();
|
||||
assertEquals(row.get("content_hash").asText().toLowerCase(Locale.ROOT), sha256(raw));
|
||||
ShadowFactBatch batch = batch(mapper.readTree(raw));
|
||||
Instant now = batch.getDatabaseCapturedAt();
|
||||
ShadowFactValidation validation = new ShadowFactBatchValidator(
|
||||
Duration.ofSeconds(4), Duration.ofSeconds(30)).validate(batch, now);
|
||||
assertEquals(ShadowFactValidation.Status.COMPARABLE, validation.getStatus(),
|
||||
row.get("cycle_id") + ": " + validation.getReasonCode());
|
||||
for (ShadowQueueFact queue : batch.getQueues()) {
|
||||
ShadowLegacyQueueResult legacy = new LegacyQueueProtocolProjector().project(queue);
|
||||
assertEquals(ShadowLegacyQueueResult.Status.AVAILABLE, legacy.getStatus());
|
||||
legacyAvailable++;
|
||||
}
|
||||
ShadowDispatchSnapshotAssembler assembler = new ShadowDispatchSnapshotAssembler(
|
||||
"FT546", 1L, topology -> new ShadowTrafficState(new ResourceLedger(topology),
|
||||
new WaitForGraph(), 0L, Collections.emptyMap()), Clock.fixed(now, ZoneOffset.UTC));
|
||||
DispatchSnapshot snapshot = assembler.assemble(batch);
|
||||
RoutePlanner planner = new AStarRoutePlanner();
|
||||
DispatchCoordinator coordinator = new DispatchCoordinator(
|
||||
new DefaultVehicleAssignmentPolicy(planner, DispatchPolicy.NONE),
|
||||
new DeadlockGuard(new WaitForGraph(), planner), commit -> true,
|
||||
() -> UUID.randomUUID().toString());
|
||||
ShadowDispatchResult<Ft546SrgvCommandIntent> result =
|
||||
new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(coordinator::dispatch,
|
||||
new ShadowCommandIntentFactory()::create).run(snapshot);
|
||||
grants += result.getDecisions().getGranted().size();
|
||||
holds += result.getDecisions().getHeld().size();
|
||||
result.getDecisions().getHeld().forEach(h ->
|
||||
holdReasons.merge(h.getReasonCode(), 1, Integer::sum));
|
||||
for (Ft546SrgvCommandIntent intent : result.getIntents()) {
|
||||
writes += gateway().previewAll(Collections.singletonList(intent)).get(0).getWrites().size();
|
||||
}
|
||||
inputs++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(300, inputs);
|
||||
assertEquals(600, legacyAvailable);
|
||||
System.out.println("CAPTURED_REPLAY independentEmptyTrafficState inputs=" + inputs
|
||||
+ " legacyAvailable=" + legacyAvailable + " grants=" + grants
|
||||
+ " holds=" + holds + " encodedWrites=" + writes + " holdReasons=" + holdReasons);
|
||||
}
|
||||
|
||||
static ShadowFactBatch batch(JsonNode input) {
|
||||
List<ShadowQueueFact> queues = new ArrayList<ShadowQueueFact>();
|
||||
for (JsonNode q : input.get("queues")) {
|
||||
queues.add(ShadowQueueFact.builder(text(q, "tableName"), text(q, "guid"))
|
||||
.taskNo(text(q, "taskNo")).taskType(text(q, "taskType"))
|
||||
.priority(q.path("priority").isNumber() ? q.get("priority").longValue() : null)
|
||||
.cargoPickSite(text(q, "cargoPickSite")).cargoPlaceSite(text(q, "cargoPlaceSite"))
|
||||
.startSite(text(q, "startSite")).endSite(text(q, "endSite")).theFloor(text(q, "theFloor"))
|
||||
.createdAt(instant(q, "createdAt")).agvId(text(q, "agvId"))
|
||||
.pathInfo(text(q, "pathInfo")).turnPath(text(q, "turnPath"))
|
||||
.default02(text(q, "default02")).default03(text(q, "default03"))
|
||||
.default04(text(q, "default04")).default05(text(q, "default05"))
|
||||
.default06(text(q, "default06")).default07(text(q, "default07"))
|
||||
.default08(text(q, "default08")).build());
|
||||
}
|
||||
List<ShadowVehicleFact> vehicles = new ArrayList<ShadowVehicleFact>();
|
||||
for (JsonNode v : input.get("vehicles")) {
|
||||
vehicles.add(ShadowVehicleFact.builder(text(v, "guid"), text(v, "deviceId"))
|
||||
.agvStatus(text(v, "agvStatus")).taskNo(number(v, "taskNo"))
|
||||
.sourceSite(text(v, "sourceSite")).targetSite(text(v, "targetSite"))
|
||||
.currentSite(text(v, "currentSite")).taskType(number(v, "taskType"))
|
||||
.battery(number(v, "battery")).workMode(number(v, "workMode"))
|
||||
.alarm(number(v, "alarm")).status(number(v, "status")).finish(number(v, "finish"))
|
||||
.chargeFlag(number(v, "chargeFlag")).workPhase(number(v, "workPhase"))
|
||||
.errorCode(number(v, "errorCode")).changeLayer(number(v, "changeLayer"))
|
||||
.cargoFlag(number(v, "cargoFlag")).path(text(v, "path")).allowSend(number(v, "allowSend"))
|
||||
.currentFloor(number(v, "currentFloor")).locked(number(v, "locked"))
|
||||
.spare2(text(v, "spare2")).spare5(text(v, "spare5"))
|
||||
.capturedAt(instant(v, "capturedAt")).build());
|
||||
}
|
||||
List<ShadowMapFact> maps = new ArrayList<ShadowMapFact>();
|
||||
for (JsonNode m : input.get("maps")) {
|
||||
maps.add(new ShadowMapFact(text(m, "guid"), text(m, "siteId"), m.get("x").intValue(),
|
||||
m.get("y").intValue(), m.get("z").intValue(), m.get("value").intValue()));
|
||||
}
|
||||
return ShadowFactBatch.create(instant(input, "databaseCaptureStartedAt"),
|
||||
instant(input, "databaseCapturedAt"), queues, vehicles, maps);
|
||||
}
|
||||
|
||||
private static String text(JsonNode row, String key) {
|
||||
return row.path(key).isNull() || row.path(key).isMissingNode() ? null : row.get(key).asText();
|
||||
}
|
||||
private static Integer number(JsonNode row, String key) {
|
||||
return text(row, key) == null ? null : Integer.valueOf(text(row, key));
|
||||
}
|
||||
private static Instant instant(JsonNode row, String key) {
|
||||
return text(row, key) == null ? null : Instant.parse(text(row, key));
|
||||
}
|
||||
private static String sha256(String value) throws Exception {
|
||||
StringBuilder hex = new StringBuilder();
|
||||
for (byte b : MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))) {
|
||||
hex.append(String.format(Locale.ROOT, "%02x", b & 255));
|
||||
}
|
||||
return hex.toString();
|
||||
}
|
||||
private static RecordingFt546CommandGateway gateway() {
|
||||
Ft546DeviceProfile profile = new Ft546DeviceProfile();
|
||||
Map<String, String> registers = new LinkedHashMap<String, String>();
|
||||
registers.put("taskNo", "4000"); registers.put("taskType", "4002");
|
||||
registers.put("path", "4004"); registers.put("trigger", "4021");
|
||||
profile.setRegisters(registers);
|
||||
return new RecordingFt546CommandGateway(new Ft546SrgvCommandEncoder(), profile,
|
||||
Collections.singleton("POINT_TO_POINT_COMMAND"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ShadowCycleDiagnosticsTest {
|
||||
@Test
|
||||
void monotonicDurationsIncludeQueueReadComputeAndCommit() {
|
||||
AtomicLong nanos = new AtomicLong();
|
||||
ShadowCycleDiagnostics diagnostics = new ShadowCycleDiagnostics(nanos::get);
|
||||
nanos.set(2000000L);
|
||||
diagnostics.enter("SOURCE_READ");
|
||||
nanos.set(7000000L);
|
||||
diagnostics.enter("DISPATCH_AND_STATE_SAVE");
|
||||
nanos.set(17000000L);
|
||||
diagnostics.enter("TRANSACTION_COMMIT");
|
||||
nanos.set(23000000L);
|
||||
Map<String, Object> result = diagnostics.finish("COMPARABLE", null);
|
||||
assertEquals(23L, result.get("elapsedMillis"));
|
||||
Map<?, ?> phases = (Map<?, ?>) result.get("phaseMillis");
|
||||
assertEquals(2L, phases.get("QUEUED"));
|
||||
assertEquals(5L, phases.get("SOURCE_READ"));
|
||||
assertEquals(10L, phases.get("DISPATCH_AND_STATE_SAVE"));
|
||||
assertEquals(6L, phases.get("TRANSACTION_COMMIT"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void timeoutSnapshotIsFrozenAndLateWorkerCannotEnterPersistence() {
|
||||
ShadowCycleDiagnostics diagnostics = new ShadowCycleDiagnostics();
|
||||
diagnostics.enter("DISPATCH_AND_STATE_SAVE");
|
||||
Map<String, Object> timeout = diagnostics.cancel("TIMEOUT", Thread.currentThread());
|
||||
assertEquals("DISPATCH_AND_STATE_SAVE", timeout.get("phase"));
|
||||
assertNotNull(timeout.get("workerStack"));
|
||||
assertThrows(CancellationException.class, () -> diagnostics.enter("EVIDENCE_SAVE"));
|
||||
assertSame(timeout, diagnostics.finish("COMPARABLE", null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import com.ruoyi.shadow.facts.LegacyQueueProtocolProjector;
|
||||
import com.ruoyi.shadow.facts.ShadowFactBatch;
|
||||
import com.ruoyi.shadow.facts.ShadowFactBatchValidator;
|
||||
import com.ruoyi.shadow.facts.ShadowFactValidation;
|
||||
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 com.ruoyi.wcs.domain.equipment.VehicleSnapshot;
|
||||
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
|
||||
import com.ruoyi.wcs.domain.traffic.RoutePlan;
|
||||
import com.ruoyi.wcs.domain.dispatch.DispatchTask;
|
||||
import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
|
||||
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
|
||||
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
|
||||
import com.ruoyi.shadow.evidence.ShadowCycleEvidenceCollector;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
|
||||
import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy;
|
||||
import com.ruoyi.wcs.domain.traffic.AStarRoutePlanner;
|
||||
import com.ruoyi.wcs.domain.traffic.DeadlockGuard;
|
||||
import com.ruoyi.wcs.port.out.DispatchPolicy;
|
||||
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
|
||||
import com.ruoyi.wcs.domain.traffic.WaitForGraph;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.LinkedHashMap;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ShadowFieldRegressionTest {
|
||||
private static final Instant NOW = Instant.parse("2026-09-06T18:41:57Z");
|
||||
private static final String ACTIVE = "shadow_ro.v_wcs_srgv_queue";
|
||||
private static final String FINISHED = "shadow_ro.v_wcs_srgv_queue_finish";
|
||||
|
||||
@Test
|
||||
void initializedAgvFlagDoesNotHideAnAutomaticIdleSendableVehicle() {
|
||||
VehicleSnapshot vehicle = assemble(vehicle().agvStatus("0").build());
|
||||
assertTrue(vehicle.isDispatchable(31));
|
||||
}
|
||||
|
||||
@Test
|
||||
void plcSafetyGatesStillExcludeUnsafeOrIncompleteVehicles() {
|
||||
List<ShadowVehicleFact> blocked = Arrays.asList(
|
||||
vehicle().workMode(4).build(), vehicle().status(2).build(),
|
||||
vehicle().allowSend(0).build(), vehicle().allowSend(null).build(),
|
||||
vehicle().alarm(1).build(), vehicle().errorCode(1).build(),
|
||||
vehicle().battery(30).build());
|
||||
for (ShadowVehicleFact fact : blocked) {
|
||||
assertFalse(assemble(fact).isDispatchable(31));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleSiteChargingQueueReconstructsBothLegacyEndpoints() {
|
||||
ShadowQueueFact queue = queue(ACTIVE, "CHARGE", "080202", "080202")
|
||||
.taskType("3").default02("3").pathInfo("080202,").build();
|
||||
ShadowLegacyQueueResult legacy = new LegacyQueueProtocolProjector().project(queue);
|
||||
assertEquals(ShadowLegacyQueueResult.Status.AVAILABLE, legacy.getStatus());
|
||||
assertEquals(Arrays.asList(80202, 80202, 0, 0, 0, 0, 0, 0),
|
||||
legacy.getWrites().get(0).getValues());
|
||||
assertEquals(Arrays.asList(80202, 80202), legacy.getPathPoints());
|
||||
assertEquals(4, legacy.getWrites().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleNodeCannotInventAMissingTransportRouteOrChargingDestination() {
|
||||
LegacyQueueProtocolProjector projector = new LegacyQueueProtocolProjector();
|
||||
assertEquals(ShadowLegacyQueueResult.Status.LEGACY_RESULT_UNAVAILABLE,
|
||||
projector.project(queue(ACTIVE, "MOVE", "080202", "080203")
|
||||
.pathInfo("080202,").build()).getStatus());
|
||||
assertEquals(ShadowLegacyQueueResult.Status.LEGACY_RESULT_UNAVAILABLE,
|
||||
projector.project(queue(ACTIVE, "CHARGE", "080202", "080203")
|
||||
.taskType("3").default02("3").pathInfo("080202,")
|
||||
.build()).getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void historicalVersionsStayInEvidenceWithoutInvalidatingLiveDispatchFacts() {
|
||||
ShadowQueueFact active = queue(ACTIVE, "LIVE", "080202", "080203").build();
|
||||
ShadowQueueFact old = queue(FINISHED, "HISTORY", "080202", "tsj").build();
|
||||
ShadowQueueFact later = queue(FINISHED, "HISTORY", "tsj", "080203").build();
|
||||
ShadowFactBatch batch = batch(Arrays.asList(old, active, later), vehicle().build());
|
||||
assertEquals(ShadowFactValidation.Status.COMPARABLE,
|
||||
validator().validate(batch, NOW).getStatus());
|
||||
assertEquals(3, batch.getSourceQueues().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeIdentityConflictStillFailsClosed() {
|
||||
ShadowFactBatch batch = batch(Arrays.asList(
|
||||
queue(ACTIVE, "LIVE", "080202", "080203").build(),
|
||||
queue(ACTIVE, "LIVE", "080203", "080202").build()), vehicle().build());
|
||||
assertEquals(ShadowFactValidation.Status.INSUFFICIENT_FACTS,
|
||||
validator().validate(batch, NOW).getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void lookupUsesTheActiveQueueWhenHistoryReusesItsGuid() {
|
||||
ShadowQueueFact active = queue(ACTIVE, "SAME", "080202", "080203").build();
|
||||
ShadowQueueFact history = queue(FINISHED, "SAME", "080203", "080202")
|
||||
.createdAt(NOW.minusSeconds(20)).build();
|
||||
assertSame(active, batch(Arrays.asList(history, active), vehicle().build()).queue("SAME"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void liftEndUsesSourceFloorAndPreservesRawFacts() {
|
||||
for (String type : Arrays.asList("8", "1", "2")) {
|
||||
ShadowQueueFact queue = queue(ACTIVE, "LIFT", "080202", "tsj")
|
||||
.cargoPlaceSite("080203").theFloor("2")
|
||||
.default02(type).default07("1").build();
|
||||
ShadowFactBatch batch = liftBatch(queue);
|
||||
assertEquals(ShadowFactValidation.Status.COMPARABLE, validator().validate(batch, NOW).getStatus());
|
||||
DispatchTask task = assembler().assemble(batch).getReadySteps().get(0);
|
||||
assertEquals("8".equals(type) ? "070502" : "090502", task.getDestinationNodeId());
|
||||
assertEquals("tsj", batch.queue("LIFT").getEndSite());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void liftStartUsesAssignedVehicleCurrentSiteOnlyForKnownHandoff() {
|
||||
ShadowQueueFact queue = queue(ACTIVE, "HANDOFF", "tsj", "080203")
|
||||
.cargoPickSite("080202").theFloor("2").default02("4").build();
|
||||
ShadowFactBatch batch = liftBatch(queue);
|
||||
assertEquals(ShadowFactValidation.Status.COMPARABLE, validator().validate(batch, NOW).getStatus());
|
||||
assertEquals("080202", assembler().assemble(batch).getReadySteps().get(0).getSourceNodeId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingFloorUnknownLiftTypeAndCargoPlaceholderStayInsufficient() {
|
||||
List<ShadowQueueFact> invalid = Arrays.asList(
|
||||
queue(ACTIVE, "LIFT", "080202", "tsj").cargoPlaceSite("080203").default02("8").build(),
|
||||
queue(ACTIVE, "LIFT", "080202", "tsj").cargoPlaceSite("080203").theFloor("2").default02("7").build(),
|
||||
queue(ACTIVE, "LIFT", "080202", "tsj").theFloor("2").default02("8").build(),
|
||||
queue(ACTIVE, "HANDOFF", "tsj", "080203").cargoPickSite("080202").theFloor("2").default02("4").agvId(null).build(),
|
||||
queue(ACTIVE, "HANDOFF", "tsj", "080203").cargoPickSite("080202").theFloor("1").default02("4").build());
|
||||
for (ShadowQueueFact queue : invalid) {
|
||||
assertEquals(ShadowFactValidation.Status.INSUFFICIENT_FACTS, validator().validate(liftBatch(queue), NOW).getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleNodeChargingGrantGeneratesTwoProtocolEndpoints() {
|
||||
assertEquals(Arrays.asList(80202, 80202), new ShadowCommandIntentFactory()
|
||||
.create(singletonGrant("3", null)).getPathPoints());
|
||||
assertThrows(IllegalArgumentException.class, () -> new ShadowCommandIntentFactory()
|
||||
.create(singletonGrant("1", null)));
|
||||
assertThrows(IllegalArgumentException.class, () -> new ShadowCommandIntentFactory()
|
||||
.create(singletonGrant("3", "1")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameSiteChargeRunsThroughRealSelectionRoutingAndEncoding() {
|
||||
AStarRoutePlanner planner = new AStarRoutePlanner();
|
||||
DispatchCoordinator coordinator = new DispatchCoordinator(
|
||||
new DefaultVehicleAssignmentPolicy(planner, DispatchPolicy.NONE),
|
||||
new DeadlockGuard(new WaitForGraph(), planner), commit -> true, () -> "CHARGE-RES");
|
||||
Ft546DeviceProfile profile = new Ft546DeviceProfile();
|
||||
Map<String, String> registers = new LinkedHashMap<String, String>();
|
||||
registers.put("taskNo", "4000"); registers.put("taskType", "4002");
|
||||
registers.put("path", "4004"); registers.put("trigger", "4021");
|
||||
profile.setRegisters(registers);
|
||||
Ft546ShadowCycle cycle = new Ft546ShadowCycle(validator(), assembler()::assemble,
|
||||
new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(coordinator::dispatch,
|
||||
new ShadowCommandIntentFactory()::create),
|
||||
new RecordingFt546CommandGateway(new Ft546SrgvCommandEncoder(), profile,
|
||||
Collections.singleton("POINT_TO_POINT_COMMAND")));
|
||||
ShadowQueueFact charge = queue(ACTIVE, "CHARGE", "080202", "080202")
|
||||
.taskType("3").default02("3").pathInfo("080202,").build();
|
||||
ShadowCycleResult result = cycle.run(batch(Collections.singletonList(charge), vehicle().build()), NOW);
|
||||
assertEquals("COMPARABLE", result.getStatusCode(), result.getReasonCode());
|
||||
assertEquals(1, result.getDecisions().getGranted().size());
|
||||
assertEquals(Arrays.asList(80202, 80202), result.getIntents().get(0).getPathPoints());
|
||||
assertEquals(4, result.getEncodedCommands().get(0).getWrites().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceFloorParticipatesInHashAndRawEvidence() throws Exception {
|
||||
ShadowQueueFact second = queue(ACTIVE, "LIFT", "080202", "tsj").theFloor("2").build();
|
||||
ShadowQueueFact third = queue(ACTIVE, "LIFT", "080202", "tsj").theFloor("3").build();
|
||||
assertNotEquals(second.getContentHash(), third.getContentHash());
|
||||
ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
|
||||
String raw = new ShadowCycleEvidenceCollector(java.util.UUID.randomUUID(), 1L, mapper)
|
||||
.insufficient(liftBatch(second), "TEST", NOW).get(0).getInputJson();
|
||||
assertEquals("2", mapper.readTree(raw).get("queues").get(0).get("theFloor").asText());
|
||||
assertEquals("tsj", mapper.readTree(raw).get("queues").get(0).get("endSite").asText());
|
||||
}
|
||||
|
||||
private static GrantedDispatch singletonGrant(String type, String stage) {
|
||||
Map<String, String> command = new LinkedHashMap<String, String>();
|
||||
command.put("parentTaskId", "502");
|
||||
command.put("phase", "LEGACY_PHASE_3");
|
||||
command.put("routeVersion", "1");
|
||||
command.put("taskNo", "502");
|
||||
command.put("taskType", type);
|
||||
if (stage != null) command.put("stage", stage);
|
||||
return new GrantedDispatch("502", "CHARGE", "2",
|
||||
new RoutePlan(Collections.singletonList("080202"),
|
||||
Collections.singletonList("NODE:080202"), 0, 0, 1L),
|
||||
Collections.singletonList("RES-1"), 1L, 1L, command);
|
||||
}
|
||||
|
||||
private static ShadowFactBatch liftBatch(ShadowQueueFact queue) {
|
||||
return ShadowFactBatch.create(NOW.minusMillis(50), NOW, Collections.singletonList(queue),
|
||||
Collections.singletonList(vehicle().build()), Arrays.asList(
|
||||
new ShadowMapFact("M1", "080202", 0, 0, 2, 1),
|
||||
new ShadowMapFact("M2", "080203", 1, 0, 2, 1),
|
||||
new ShadowMapFact("M3", "070502", 2, 0, 2, 1),
|
||||
new ShadowMapFact("M4", "090502", 3, 0, 2, 1)));
|
||||
}
|
||||
|
||||
private static ShadowDispatchSnapshotAssembler assembler() {
|
||||
return new ShadowDispatchSnapshotAssembler("FT546", 1L,
|
||||
topology -> new ShadowTrafficState(new ResourceLedger(topology),
|
||||
new WaitForGraph(), 0L, Collections.emptyMap()), Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
private static VehicleSnapshot assemble(ShadowVehicleFact vehicle) {
|
||||
ShadowDispatchSnapshotAssembler assembler = new ShadowDispatchSnapshotAssembler(
|
||||
"FT546", 1L, topology -> new ShadowTrafficState(new ResourceLedger(topology),
|
||||
new WaitForGraph(), 0L, Collections.emptyMap()), Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
return assembler.assemble(batch(Collections.emptyList(), vehicle)).getVehicleSnapshots().get(0);
|
||||
}
|
||||
|
||||
private static ShadowFactBatch batch(List<ShadowQueueFact> queues, ShadowVehicleFact vehicle) {
|
||||
return ShadowFactBatch.create(NOW.minusMillis(50), NOW, queues,
|
||||
Collections.singletonList(vehicle), Arrays.asList(
|
||||
new ShadowMapFact("M1", "080202", 0, 0, 2, 1),
|
||||
new ShadowMapFact("M2", "080203", 1, 0, 2, 1)));
|
||||
}
|
||||
|
||||
private static ShadowFactBatchValidator validator() {
|
||||
return new ShadowFactBatchValidator(Duration.ofSeconds(4), Duration.ofSeconds(30));
|
||||
}
|
||||
|
||||
private static ShadowQueueFact.Builder queue(String table, String guid, String start, String end) {
|
||||
return ShadowQueueFact.builder(table, guid).taskNo("502").taskType("2").priority(2L)
|
||||
.startSite(start).endSite(end).cargoPickSite(start).cargoPlaceSite(end)
|
||||
.createdAt(NOW.minusSeconds(10)).agvId("2").pathInfo(start + "," + end + ",")
|
||||
.turnPath("").default02("1");
|
||||
}
|
||||
|
||||
private static ShadowVehicleFact.Builder vehicle() {
|
||||
return ShadowVehicleFact.builder("2", "2").agvStatus("0").taskNo(502)
|
||||
.currentSite("080202").sourceSite("080202").targetSite("080202")
|
||||
.taskType(3).battery(80).workMode(5).alarm(0).status(1)
|
||||
.finish(0).chargeFlag(0).workPhase(0).errorCode(0)
|
||||
.changeLayer(0).cargoFlag(0).allowSend(1).currentFloor(2)
|
||||
.locked(0).spare2("0").spare5("0").capturedAt(NOW.minusSeconds(7200));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
|
||||
import com.ruoyi.shadow.config.ShadowKernelConfiguration;
|
||||
import com.ruoyi.shadow.facts.*;
|
||||
import com.ruoyi.wcs.application.dispatch.*;
|
||||
import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy;
|
||||
import com.ruoyi.wcs.domain.traffic.*;
|
||||
import com.ruoyi.wcs.port.out.DispatchPolicy;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ShadowLivePathRegressionTest {
|
||||
private static final Instant NOW = Instant.parse("2026-09-07T03:51:00Z");
|
||||
private static final String FIELD_PATH = "090501,TD002,080501,070501,060501,WY0006,"
|
||||
+ "060401,WY0004,060301,WY0002,060201,WY0001,060101,070101,";
|
||||
|
||||
@Test
|
||||
void completedFieldPathReconstructsLiteralLegacyRegisterValues() {
|
||||
ShadowQueueFact queue = queue("090501", "070101")
|
||||
.pathInfo(FIELD_PATH).turnPath("060501,060101,0,0,0,0").build();
|
||||
ShadowLegacyQueueResult result = new LegacyQueueProtocolProjector().project(queue);
|
||||
assertEquals(ShadowLegacyQueueResult.Status.AVAILABLE, result.getStatus(), result.getReasonCode());
|
||||
assertEquals(Arrays.asList(90501, 70101, 60501, 60101, 0, 0, 0, 0),
|
||||
result.write("4004").getValues());
|
||||
assertEquals("4004,4000,4002,4021", result.getWriteAddresses());
|
||||
}
|
||||
|
||||
@Test
|
||||
void plannedFieldPathKeepsVirtualResourcesButEncodesOnlyEndpointsAndTurns() {
|
||||
DispatchSnapshot snapshot = snapshot(fieldMap(), "090501", "090501", "070101");
|
||||
RoutePlan route = new AStarRoutePlanner().plan(snapshot.getTopology(),
|
||||
"090501", "070101", Collections.emptySet());
|
||||
assertEquals(Arrays.asList(FIELD_PATH.substring(0, FIELD_PATH.length() - 1).split(",")),
|
||||
route.getNodeIds());
|
||||
assertTrue(route.getResourceKeys().contains("NODE:TD002"));
|
||||
assertTrue(route.getResourceKeys().contains("NODE:WY0006"));
|
||||
Ft546SrgvCommandIntent intent = new ShadowCommandIntentFactory().create(grant(route));
|
||||
assertEquals(Arrays.asList(90501, 70101, 60501, 60101), intent.getPathPoints());
|
||||
assertEquals(Arrays.asList(90501, 70101, 60501, 60101, 0, 0, 0, 0),
|
||||
new ShadowKernelConfiguration().recordingFt546CommandGateway()
|
||||
.preview(intent).getWrites().get(0).getValues());
|
||||
}
|
||||
|
||||
@Test
|
||||
void longStraightNumericPathHasTwoCommandPointsAndNoIntermediateDestination() {
|
||||
List<ShadowMapFact> maps = new ArrayList<>();
|
||||
for (int i = 1; i <= 12; i++) maps.add(map(String.format(Locale.ROOT, "%02d0101", i), i, 0));
|
||||
DispatchSnapshot snapshot = snapshot(maps, "010101", "010101", "120101");
|
||||
RoutePlan route = new AStarRoutePlanner().plan(snapshot.getTopology(),
|
||||
"010101", "120101", Collections.emptySet());
|
||||
assertEquals(Arrays.asList(10101, 120101),
|
||||
new ShadowCommandIntentFactory().create(grant(route)).getPathPoints());
|
||||
}
|
||||
|
||||
@Test
|
||||
void overlappingApproachAndExecutionReserveEachResourceOnceWithoutLosingRouteOrder() {
|
||||
DispatchSnapshot snapshot = snapshot(fieldMap(), "060301", "070101", "060201");
|
||||
AStarRoutePlanner planner = new AStarRoutePlanner();
|
||||
DispatchCoordinator coordinator = new DispatchCoordinator(
|
||||
new DefaultVehicleAssignmentPolicy(planner, DispatchPolicy.NONE),
|
||||
new DeadlockGuard(new WaitForGraph(), planner), commit -> true,
|
||||
() -> UUID.randomUUID().toString());
|
||||
DispatchCycleResult result = coordinator.dispatch(snapshot);
|
||||
assertEquals(1, result.getGranted().size());
|
||||
RoutePlan route = result.getGranted().get(0).getProtectedRoute();
|
||||
assertEquals(Arrays.asList("060301", "WY0002", "060201", "WY0001", "060101",
|
||||
"070101", "060101", "WY0001", "060201"), route.getNodeIds());
|
||||
assertEquals(new HashSet<>(route.getResourceKeys()).size(), route.getResourceKeys().size());
|
||||
assertEquals(Arrays.asList(60301, 60201, 60101, 70101, 60101),
|
||||
new ShadowCommandIntentFactory().create(result.getGranted().get(0)).getPathPoints());
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidHistoricalTransitEndpointsAndTurnsRemainUnavailable() {
|
||||
for (String path : Arrays.asList("TD002,080501,", "080501,TD002,",
|
||||
"080501,UNKNOWN,070501,", "080501,-1,070501,")) {
|
||||
assertEquals(ShadowLegacyQueueResult.Status.LEGACY_RESULT_UNAVAILABLE,
|
||||
new LegacyQueueProtocolProjector().project(queue("080501", "070501")
|
||||
.pathInfo(path).build()).getStatus());
|
||||
}
|
||||
assertEquals(ShadowLegacyQueueResult.Status.LEGACY_RESULT_UNAVAILABLE,
|
||||
new LegacyQueueProtocolProjector().project(queue("090501", "070101")
|
||||
.pathInfo(FIELD_PATH).turnPath("TD002,060101,0,0,0,0").build()).getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void virtualTurningPointCannotBeSilentlyDroppedFromCommand() {
|
||||
DispatchSnapshot snapshot = snapshot(Arrays.asList(map("010101", 0, 0),
|
||||
map("WY0001", 1, 0), map("020201", 1, 1)), "010101", "010101", "020201");
|
||||
RoutePlan route = new AStarRoutePlanner().plan(snapshot.getTopology(),
|
||||
"010101", "020201", Collections.emptySet());
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ShadowCommandIntentFactory().create(grant(route)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void moreThanSixTurnsCannotBeTruncatedToFitRegisters() {
|
||||
List<ShadowMapFact> maps = new ArrayList<>();
|
||||
for (int i = 0; i < 10; i++) maps.add(map(String.format(Locale.ROOT, "%06d", 10101 + i),
|
||||
(i + 1) / 2, i / 2));
|
||||
DispatchSnapshot snapshot = snapshot(maps, "010101", "010101", "010110");
|
||||
RoutePlan route = new AStarRoutePlanner().plan(snapshot.getTopology(),
|
||||
"010101", "010110", Collections.emptySet());
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ShadowCommandIntentFactory().create(grant(route)));
|
||||
}
|
||||
|
||||
private static GrantedDispatch grant(RoutePlan route) {
|
||||
Map<String, String> command = new LinkedHashMap<>();
|
||||
command.put("parentTaskId", "31312"); command.put("phase", "EMPTY_TO_PICKUP");
|
||||
command.put("routeVersion", "1"); command.put("taskNo", "31312"); command.put("taskType", "4");
|
||||
return new GrantedDispatch("31312", "FIELD", "3", route,
|
||||
Collections.singletonList("RES"), 1L, 1L, command);
|
||||
}
|
||||
|
||||
private static ShadowQueueFact.Builder queue(String start, String end) {
|
||||
return ShadowQueueFact.builder("shadow_ro.v_wcs_srgv_queue", "FIELD")
|
||||
.taskNo("31312").taskType("1").priority(2L).startSite(start).endSite(end)
|
||||
.cargoPickSite(start).cargoPlaceSite(end).theFloor("1").agvId("3")
|
||||
.createdAt(NOW.minusSeconds(10)).default02("4").default08("").turnPath("");
|
||||
}
|
||||
|
||||
private static DispatchSnapshot snapshot(List<ShadowMapFact> maps, String location,
|
||||
String source, String destination) {
|
||||
ShadowVehicleFact vehicle = ShadowVehicleFact.builder("3", "3").agvStatus("0")
|
||||
.currentSite(location).currentFloor(1).workMode(5).status(1).allowSend(1)
|
||||
.alarm(0).errorCode(0).battery(80).cargoFlag(0).capturedAt(NOW).build();
|
||||
ShadowFactBatch batch = ShadowFactBatch.create(NOW, NOW,
|
||||
Collections.singletonList(queue(source, destination).build()),
|
||||
Collections.singletonList(vehicle), maps);
|
||||
return new ShadowDispatchSnapshotAssembler("FT546", 1L,
|
||||
topology -> new ShadowTrafficState(new ResourceLedger(topology),
|
||||
new WaitForGraph(), 0L, Collections.emptyMap()), Clock.fixed(NOW, ZoneOffset.UTC))
|
||||
.assemble(batch);
|
||||
}
|
||||
|
||||
private static ShadowMapFact map(String site, int x, int y) {
|
||||
return new ShadowMapFact(site, site, x, y, 1, 1);
|
||||
}
|
||||
|
||||
private static List<ShadowMapFact> fieldMap() {
|
||||
return Arrays.asList(map("090501", 11, 10), map("TD002", 11, 9),
|
||||
map("080501", 11, 8), map("070501", 11, 7), map("060501", 11, 6),
|
||||
map("WY0006", 10, 6), map("060401", 9, 6), map("WY0004", 8, 6),
|
||||
map("060301", 7, 6), map("WY0002", 6, 6), map("060201", 5, 6),
|
||||
map("WY0001", 4, 6), map("060101", 3, 6), map("070101", 3, 7));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user