fix(shadow): repair field routing and harden evidence collection

This commit is contained in:
zgw
2026-09-07 12:42:17 +08:00
parent 9829b9c0fa
commit 414b3bf4a9
37 changed files with 1751 additions and 103 deletions

View File

@@ -0,0 +1,108 @@
# FT546 Shadow Repair: 2026-09-07
## Delivery Boundary
Source changes in the existing `optimize/wcs` worktree, based on `9829b9c0fa71d8a4623a9f968178183217724db1`.
Prior uncommitted field fixes are preserved. During the repair/validation stage, no commit,
push, deployment, live SQL write, schema migration, production application startup,
PLC command or WMS call was performed. The user subsequently authorized committing and
pushing the repaired source to `origin/optimize/wcs`; this does not authorize deployment.
No deployment scripts or JAR deliverables are included.
This is a shadow repair, not approval to replace the production scheduler.
## Repairs
- Keep virtual TD/WY transit nodes in route planning and resource protection. Generate
PLC preview points from numeric start/end/turn nodes, rather than every transit node.
Missing geometry, nonnumeric command points and more than six turns remain rejected.
- Deduplicate protected resource keys when approach and execution routes overlap.
Preserve ordered route nodes, including repeated visits and turns.
- Tag new hypothetical reservations with `predictionReservationIds`. On the next poll
or restart, remove only tagged predictions from the working ledger. Preserve untagged
legacy reservations and all OCCUPIED, command-unknown or recovery-unresolved protection.
Keep snapshot CAS, fencing tokens, HOLD counters and transactional evidence rollback.
- Use a UUID startup prefix for new reservation IDs, preventing reuse after restart.
- Bound evidence growth and persist current run state, including empty polls and shutdown.
## Capacity Settings
Existing local external configuration remains usable; omitted new settings use these defaults:
```yaml
shadow:
evidence:
maximum-database-bytes: 17179869184
maximum-batch-payload-bytes: 33554432
```
The database limit is 16 GiB of allocated data plus log files, read from the evidence
database's `sys.database_files`. It is not a table row-count limit, used-page measurement,
SQL Server MAXSIZE setting or exact physical disk quota. A batch/file-growth increment can
overshoot the threshold before the next poll. The existing local `minimum-free-bytes`
check still applies to `output-directory`; it does not measure every remote SQL volume.
The 32 MiB batch limit counts newly inserted large text payloads as UTF-16 bytes.
It excludes database page, index and transaction-log overhead. Existing identical replays
do not consume the insert budget. Over-budget batches roll back with their traffic state.
Collection stops with a specific reason: `EVIDENCE_DATABASE_SIZE_LIMIT`,
`EVIDENCE_DATABASE_SIZE_CHECK_FAILED`, `EVIDENCE_BATCH_PAYLOAD_LIMIT`, or the existing
storage availability/free-space reason. It does not automatically resume or delete rows.
Raw `captured_json`, its content hash, evidence hashes and existing replay identity remain
unchanged. Lossless deduplication, archival and retention require a separate reviewed
migration/operational decision. Existing evidence is not compacted by this repair.
## Durable Runtime State
Each new process uses its own run UUID. In the already-authorized evidence tables:
- `shadow_run.status` records STARTING, RUNNING (status endpoint READY), DEGRADED or STOPPED.
- `shadow_traffic_state.state_key = FT546:RUN:<runId>` contains a `RUN_HEARTBEAT` JSON record.
- `heartbeatAt`, `lastAttemptAt`, `lastCompletedCycle` and `checkedAt` use UTC ISO timestamps.
- `collectionStopped`, `reasonCode` and `cycleDiagnostics` distinguish process liveness,
attempted collection, completed collection and permanent collection failure.
- Pre/post-poll writes and scheduled backoff/stopped ticks refresh the same bounded row.
Runtime JSON is limited to 32,768 characters, independently of business evidence size.
- Runtime persistence failure stops further source reads. Failed writes cannot guarantee
that the database contains the latest stop reason; check logs and heartbeat freshness.
- Normal close records STOPPED. Forced termination/power loss can only be detected by a
stale heartbeat. A RUNNING row alone is not proof of a live process or successful work.
Traffic-ledger readers should select `FT546:TOPOLOGY:<version>` rows; runtime rows have
their own key namespace and `kind`. Historical runs without a heartbeat are not rewritten
or retrospectively marked stopped. With default polling, investigate a heartbeat older
than 60 seconds; adjust this operational threshold when poll/deadline settings change.
## Local Verification
Final offline Maven command used Maven 3.6.0 and the supplied complete-v2 capture ZIP:
`mvn -o -pl ruoyi-wcs-shadow -am test -Dshadow.replay.zip=<complete-v2-zip>`.
- 298 tests passed: 128 WCS, 10 protocol, 160 shadow; zero failures, errors or skipped tests.
- 300 independent captured inputs: 600 available legacy projections, 300 normal
NO_ELIGIBLE_VEHICLE holds, zero new grants. This sample does not prove live dispatch readiness.
- 10 additional captured field batches: 9 encodable grants, 9 normal holds,
zero input or grant-projection errors. Independent empty initial traffic state per batch.
- 6 historical queue paths: zero legacy, routing or command-point projection errors.
These checks are protocol-only, not a reconstruction of historical vehicle eligibility.
- Focused regressions cover overlap, virtual transit nodes, invalid command points,
prediction expiry, protected state, ID restart, storage limits, atomic rollback,
heartbeat, retries, timeout permanence, normal stop and runtime write failure.
- Independent read-only review found no blocking issues. `git diff --check` passed.
Legacy Admin and existing PLC/WMS implementation files have no changes.
Detailed local logs are under
`D:/workSpace/PROJECT_SPACE/FT546/outputs/wcs-shadow-review-20260907/`:
`repair-full-with-captured-replay.log` and `live-database-repair-final-replay.log`.
## Remaining Field Validation
SQL persistence tests use isolated H2 databases. The new metadata query and runtime writes
have not been exercised against SQL Server 2016 under the deployed shadow login in this
repair turn. No physical PLC/HIL test, forced network-stall shutdown test or sustained
production-load test was run. Query/pool timeouts are not an OS-level hard shutdown bound.
After an explicitly authorized shadow update, verify the new run's heartbeat, collection
progress, capacity reasons, source latency and evidence completeness under real load.
Review new shadow evidence before deciding on any production scheduler cutover.

View File

@@ -0,0 +1,66 @@
# Shadow Field Repair Implementation Plan
> **For agentic workers:** Execute inline with superpowers:executing-plans. Scouts are read-only; the main agent owns edits and verification.
**Goal:** Repair the field-reproduced shadow failures without changing legacy dispatch or granting production write authority.
**Architecture:** Keep topology routes separate from FT546 command points. Shadow predictions must not become permanent physical reservations. Preserve existing evidence JSON and database schema, add bounded collection and durable runtime metadata using existing authorized tables.
**Tech Stack:** Java 8-compatible source, Maven 3.6.0, JUnit 5, Spring JDBC, SQL Server 2016; H2 for isolated persistence tests.
**Spec:** Existing `docs/superpowers/specs/2026-08-12-ft546-shadow-first-safe-cutover-design.md`, plus the read-only field assessment at `D:/workSpace/PROJECT_SPACE/FT546/outputs/wcs-shadow-review-20260907/live-database-assessment.md` and the user's approval to repair the shadow version.
## Global Constraints
- Work only in the existing optimize/wcs worktree. Preserve prior uncommitted fixes and unrelated files.
- No production SQL writes, migrations, PLC/WMS connections, application startup or deployment. The repair/validation stage excluded commit/push; subsequent user approval authorizes publishing only the repaired source to origin/optimize/wcs.
- Keep legacy Admin/PLC behavior unchanged. Generic route changes must be backward compatible and tested across ruoyi-wcs.
- Do not delete, truncate, silently sample, or change the raw evidence JSON/hash contract.
- Keep unsupported phase/route representations explicitly unavailable; do not manufacture successful comparisons.
## Task 1: Field Path and Protocol Regression
Files: `ruoyi-wcs-shadow/.../facts/LegacyQueueProtocolProjector.java`, `.../kernel/ShadowCommandIntentFactory.java`, `ruoyi-wcs/.../domain/traffic/RoutePlan.java`, `AStarRoutePlanner.java`, `.../domain/dispatch/DefaultVehicleAssignmentPolicy.java`, focused tests in both modules.
- [x] Add literal expected 4004 payload tests for task 31312, straight numeric paths, long virtual paths, invalid endpoint/turn and overflow cases.
- [x] Add the overlapping approach/execution reservation regression; verify ordered nodes survive while unique resources are reserved.
- [x] Run the tests red against current code.
- [x] Carry geometry-derived turn-node metadata on planned routes; project start/end/turns in the shadow factory. Accept only known virtual transit tokens in historical reconstruction, with numeric validated endpoints/turns.
- [x] Deduplicate reservation resources at route combination, not ordered route nodes or the reservation safety boundary.
- [x] Run focused tests green and repeat the captured database probe.
## Task 2: Shadow Prediction Lifecycle
Files: `ruoyi-wcs-shadow/.../kernel/JdbcShadowTrafficStateRepository.java`, snapshot assembly and associated H2/end-to-end tests.
- [x] Write a repeated-poll/restart test showing a prior hypothetical grant cannot consume capacity forever.
- [x] Keep physical/unknown protection distinct from hypothetical reservations; never interpret a new prediction as PLC execution.
- [x] Preserve snapshot CAS, evidence transaction rollback and hold-count behavior; tag new state JSON with PER_SNAPSHOT_PREDICTION and explicit predictionReservationIds. Preserve all untagged legacy state and OCCUPIED/unknown/recovery protection; remove only tagged predictions from the next working ledger.
- [x] Run repeat, restart, conflict and rollback regressions. Add per-startup reservation ID prefix to avoid collision with retained state.
## Task 3: Evidence Growth Guard
Files: shadow evidence storage health/configuration, repository metadata access, property defaults and tests.
- [x] Add tests for database allocation threshold, unavailable capacity metadata, local free-space threshold and preservation of existing evidence rows.
- [x] Use bounded database-file metadata reads, not whole captured_json scans or broad server-state permission grants.
- [x] Stop new collection with a specific durable reason when limits are reached. Keep raw evidence/export compatibility unchanged.
- [x] Document that lossless deduplication/archive remains a separate migration decision, not an automatic deletion policy.
## Task 4: Durable Run State
Files: shadow evidence repository/runtime monitor, scheduler, status report/configuration and tests.
- [x] Test heartbeat on empty polls, last success vs attempt, retryable failure, permanent timeout, orderly stop and persistence failure.
- [x] Persist bounded run metadata in FT546:RUN:<runId> in shadow_traffic_state and update shadow_run status. No DDL or new privileges.
- [x] Ensure timeout/stop reasons cannot be overwritten by a late worker completion; monitor failure must not claim healthy collection.
- [x] Keep timestamps explicitly UTC in serialized metadata and expose freshness for external stale detection.
## Task 5: Acceptance
- [x] Run the full offline Maven reactor tests for shadow and its dependencies, plus existing charging and new field-input replay. 298 tests passed, none skipped; 300 captured inputs and 10 additional field batches replayed without processing/projection errors.
- [x] Request independent read-only code review of safety boundaries, protocol payloads and new operational paths; fix important findings. No blocking findings; SQL Server integration and blocked-shutdown coverage limits recorded.
- [x] Verify no legacy source edits, no secrets in outputs, no new database/PLC authority and no background test jobs left running.
- [x] Record results and remaining field/HIL boundaries; deliver source only, without claiming production-scheduler acceptance. See docs/runbooks/ft546-shadow-20260907-repair-notes.md.
Baseline: offline Maven reactor test command succeeded on 2026-09-07 before this repair. The field probe from the preceding turn reproducibly failed on virtual nodes and duplicate route resources.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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())) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
@@ -114,27 +115,9 @@ public final class DefaultVehicleAssignmentPolicy implements VehicleAssignmentPo
List<String> resources = new ArrayList<String>(approach.getResourceKeys());
resources.addAll(execution.getResourceKeys().subList(
1, execution.getResourceKeys().size()));
return new RoutePlan(nodes, resources,
approach.getDistanceCost() + execution.getDistanceCost(),
countTurns(nodes, topology), topology.version());
}
private static int countTurns(List<String> nodes, EffectiveTopology topology) {
int turns = 0;
int previousX = 0;
int previousY = 0;
for (int i = 1; i < nodes.size(); i++) {
TopologyNode from = topology.node(nodes.get(i - 1));
TopologyNode to = topology.node(nodes.get(i));
int directionX = Integer.compare(to.getX(), from.getX());
int directionY = Integer.compare(to.getY(), from.getY());
if (i > 1 && (previousX != directionX || previousY != directionY)) {
turns++;
}
previousX = directionX;
previousY = directionY;
}
return turns;
// Returning over the approach is valid geometry, but reserves each resource once.
return RoutePlan.fromTopology(nodes, new ArrayList<String>(new LinkedHashSet<String>(resources)),
approach.getDistanceCost() + execution.getDistanceCost(), topology);
}
private static final class ScoredVehicle {

View File

@@ -163,8 +163,7 @@ public final class AStarRoutePlanner implements RoutePlanner {
}
private RoutePlan toRoutePlan(EffectiveTopology topology) {
return new RoutePlan(nodeIds, resourceKeys, distanceCost,
turnCount, topology.version());
return RoutePlan.fromTopology(nodeIds, resourceKeys, distanceCost, topology);
}
private int getEstimatedTotalCost() {

View File

@@ -1,5 +1,7 @@
package com.ruoyi.wcs.domain.traffic;
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
import com.ruoyi.wcs.domain.topology.TopologyNode;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -10,9 +12,18 @@ public final class RoutePlan {
private final int distanceCost;
private final int turnCount;
private final long topologyVersion;
private final List<String> turnNodeIds;
public RoutePlan(List<String> nodeIds, List<String> resourceKeys,
int distanceCost, int turnCount, long topologyVersion) {
this(nodeIds, resourceKeys, distanceCost, turnCount, topologyVersion,
nodeIds != null && nodeIds.size() <= 2 && turnCount == 0
? Collections.<String>emptyList() : null);
}
private RoutePlan(List<String> nodeIds, List<String> resourceKeys,
int distanceCost, int turnCount, long topologyVersion,
List<String> turnNodeIds) {
this.nodeIds = immutable(nodeIds, "nodeIds");
this.resourceKeys = immutable(resourceKeys, "resourceKeys");
if (this.nodeIds.isEmpty()) {
@@ -24,6 +35,34 @@ public final class RoutePlan {
this.distanceCost = distanceCost;
this.turnCount = turnCount;
this.topologyVersion = topologyVersion;
this.turnNodeIds = turnNodeIds == null ? null : immutable(turnNodeIds, "turnNodeIds");
}
public static RoutePlan fromTopology(List<String> nodes, List<String> resources,
int distanceCost, EffectiveTopology topology) {
List<String> turns = new ArrayList<String>();
int previousX = 0, previousY = 0;
for (int i = 1; i < nodes.size(); i++) {
TopologyNode from = topology.node(nodes.get(i - 1));
TopologyNode to = topology.node(nodes.get(i));
int directionX = Integer.compare(to.getX(), from.getX());
int directionY = Integer.compare(to.getY(), from.getY());
if (i > 1 && (previousX != directionX || previousY != directionY)) {
turns.add(nodes.get(i - 1));
}
previousX = directionX;
previousY = directionY;
}
return new RoutePlan(nodes, resources, distanceCost, turns.size(), topology.version(), turns);
}
public boolean hasTurnGeometry() {
return turnNodeIds != null;
}
public List<String> getTurnNodeIds() {
if (turnNodeIds == null) throw new IllegalStateException("route turn geometry is unavailable");
return turnNodeIds;
}
public List<String> getNodeIds() {

View File

@@ -28,6 +28,21 @@ class DefaultVehicleAssignmentPolicyTest {
private final VehicleAssignmentPolicy policy = new DefaultVehicleAssignmentPolicy(
new AStarRoutePlanner(), DispatchPolicy.NONE);
@Test
void returningOverApproachRouteDoesNotDuplicateReservationResources() {
DispatchTask task = new DispatchTask("O1", "S1", "A", "B", 10, 60L, 30,
Collections.<DeviceCapability>emptySet());
VehicleAssignmentDecision assigned = policy.assign(task,
Collections.singletonList(vehicle("V1", "C", 90, true,
VehicleSnapshot.ConnectionState.ONLINE)), topology);
assertEquals(Arrays.asList("C", "B", "A", "B"), assigned.getRoute().getNodeIds());
assertEquals(5, assigned.getRoute().getResourceKeys().size());
com.ruoyi.wcs.domain.traffic.ReservationRequest request =
new com.ruoyi.wcs.domain.traffic.ReservationRequest("R1", "O1", "S1", "V1",
assigned.getRoute().getResourceKeys());
assertTrue(new com.ruoyi.wcs.domain.traffic.ResourceLedger(topology).reserve(request).isGranted());
}
@Test
void rejectsCloserVehicleWithoutRequiredCapability() {
VehicleAssignmentDecision decision = policy.assign(taskRequiringLoad(),