diff --git a/docs/runbooks/ft546-shadow-20260907-repair-notes.md b/docs/runbooks/ft546-shadow-20260907-repair-notes.md new file mode 100644 index 0000000..8598067 --- /dev/null +++ b/docs/runbooks/ft546-shadow-20260907-repair-notes.md @@ -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:` 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:` 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=`. + +- 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. diff --git a/docs/superpowers/plans/2026-09-07-shadow-field-repair.md b/docs/superpowers/plans/2026-09-07-shadow-field-repair.md new file mode 100644 index 0000000..956ec47 --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-shadow-field-repair.md @@ -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: 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. diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowEvidenceConfiguration.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowEvidenceConfiguration.java index 8661bec..457cdab 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowEvidenceConfiguration.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowEvidenceConfiguration.java @@ -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()); } } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowKernelConfiguration.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowKernelConfiguration.java index 7c9c44d..63a5a5b 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowKernelConfiguration.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowKernelConfiguration.java @@ -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); } } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java index e7c06c8..546aaa8 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowProperties.java @@ -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 { diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepository.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepository.java index c0b4e05..f6da36a 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepository.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepository.java @@ -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 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 values = new LinkedHashMap(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> 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 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 saveAllInTransaction( List 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 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," diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceStorageHealth.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceStorageHealth.java index 30fa3e7..5badf15 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceStorageHealth.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceStorageHealth.java @@ -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(); diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowCycleEvidence.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowCycleEvidence.java index 4c68dd2..af931a3 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowCycleEvidence.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/evidence/ShadowCycleEvidence.java @@ -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); } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/LegacyQueueProtocolProjector.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/LegacyQueueProtocolProjector.java index 6923ce4..fe71803 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/LegacyQueueProtocolProjector.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/LegacyQueueProtocolProjector.java @@ -121,13 +121,21 @@ public final class LegacyQueueProtocolProjector { } private static List ordinaryPath(ShadowQueueFact queue) { - List executedPath = executedPath(queue.getPathInfo()); - if (executedPath == null || executedPath.size() < 2) { + List 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 result = new ArrayList(); - 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 executedPath(String value) { + private static List executedPath(String value) { if (!notBlank(value)) { return null; } - List result = new ArrayList(); + List result = new ArrayList(); 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; } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowFactBatch.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowFactBatch.java index 1961fb9..3ba852e 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowFactBatch.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowFactBatch.java @@ -47,6 +47,11 @@ public final class ShadowFactBatch { public List 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; diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowFactBatchValidator.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowFactBatchValidator.java index f0aa8b9..536eddb 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowFactBatchValidator.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowFactBatchValidator.java @@ -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 hashes = new HashMap(); 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())) { diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowQueueEndpoints.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowQueueEndpoints.java new file mode 100644 index 0000000..6a2d5e8 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowQueueEndpoints.java @@ -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(); + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowQueueFact.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowQueueFact.java index 6f1b007..aee7099 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowQueueFact.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/ShadowQueueFact.java @@ -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; } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/SqlServerShadowFactReader.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/SqlServerShadowFactReader.java index 24b088c..9e8e33f 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/SqlServerShadowFactReader.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/facts/SqlServerShadowFactReader.java @@ -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")) diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java index b933d6a..8a5894b 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java @@ -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 degradedReason = new AtomicReference(); @@ -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 assembler, + ReadOnlyDispatchCycle 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 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 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 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 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 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 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 encoded; + progress.enter("ENCODING"); try { encoded = commandGateway.recordAll(result.getIntents()); } catch (RuntimeException failure) { diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowScheduler.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowScheduler.java index bc36398..0e62440 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowScheduler.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowScheduler.java @@ -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> 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> 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> 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 future = cycleExecutor.submit( - () -> executeCycle(now)); + ShadowCycleDiagnostics diagnostics = new ShadowCycleDiagnostics(); + lastAttemptAt = now; + if (!publishRuntimeState()) { + inFlight.set(false); + return false; + } + cycleWorker = null; + String outcome = "FAILED"; + Future 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 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; } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/JdbcShadowTrafficStateRepository.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/JdbcShadowTrafficStateRepository.java index cb8e298..46a3998 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/JdbcShadowTrafficStateRepository.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/JdbcShadowTrafficStateRepository.java @@ -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 protectedReservations = new ArrayList(); + Set predictedVehicles = new LinkedHashSet(); + Set protectedVehicles = new LinkedHashSet(); + 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> remainingWaits = new LinkedHashMap>(); + for (Map.Entry> entry : persisted.waitForEdges.entrySet()) { + if (!predictedVehicles.contains(entry.getKey())) { + Set holders = new LinkedHashSet(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 nextHolds = nextHoldCounts(previousHolds, commit.getHeld()); long nextVersion = increment(commit.getSnapshotVersion()); + Set predictionIds = new TreeSet(); + 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> waitForEdges, - Map holdCounts) { + Map holdCounts, + Set predictionIds) { Map root = new LinkedHashMap(); root.put("schemaVersion", 1); + root.put("evaluationMode", "PER_SNAPSHOT_PREDICTION"); + root.put("predictionReservationIds", new ArrayList(predictionIds)); root.put("ledger", ledgerMap(ledger)); Map edges = new TreeMap(); for (Map.Entry> entry : waitForEdges.entrySet()) { @@ -267,7 +297,13 @@ public final class JdbcShadowTrafficStateRepository requiredObject(root, "waitForEdges")); Map holds = integerMap( requiredObject(root, "holdCounts")); - return new PersistedState(ledgerState, edges, holds); + Set predictionIds = new LinkedHashSet(); + 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> waitForEdges; private final Map holdCounts; + private final Set predictionIds; private PersistedState(ResourceLedgerState ledger, Map> waitForEdges, - Map holdCounts) { + Map holdCounts, + Set predictionIds) { this.ledger = ledger; this.waitForEdges = waitForEdges; this.holdCounts = holdCounts; + this.predictionIds = predictionIds; } } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCommandIntentFactory.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCommandIntentFactory.java index 3bd4cc4..9cb15dc 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCommandIntentFactory.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCommandIntentFactory.java @@ -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 path = path(grant); + List 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 path(GrantedDispatch grant) { + private static List path(GrantedDispatch grant, boolean sameSiteCharge) { + RoutePlan route = grant.getProtectedRoute(); + List 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 points = new ArrayList(); - 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 parameters, short taskType, Integer stage) { String value = parameters.get("crossFloorTarget"); diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCycleDiagnostics.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCycleDiagnostics.java new file mode 100644 index 0000000..dfbd585 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCycleDiagnostics.java @@ -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 phaseNanos = new LinkedHashMap(); + private Map 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 finish(String outcome, Thread worker) { + if (completed != null) return completed; + long now = ticker.getAsLong(); + accumulate(now); + Map snapshot = new LinkedHashMap(); + snapshot.put("outcome", outcome); + snapshot.put("phase", phase); + snapshot.put("elapsedMillis", TimeUnit.NANOSECONDS.toMillis(now - started)); + Map timings = new LinkedHashMap(); + 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 stack = new ArrayList(); + 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 cancel(String outcome, Thread worker) { + cancelled = true; + return finish(outcome, worker); + } + + private void accumulate(long now) { + phaseNanos.merge(phase, now - phaseStarted, Long::sum); + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowDispatchSnapshotAssembler.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowDispatchSnapshotAssembler.java index 7e8435f..9be97eb 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowDispatchSnapshotAssembler.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowDispatchSnapshotAssembler.java @@ -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()); diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupReport.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupReport.java index 147d14d..6da2fe7 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupReport.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/startup/ShadowStartupReport.java @@ -16,6 +16,7 @@ public final class ShadowStartupReport { private volatile String reasonCode; private volatile String permanentDegradedReason; private final Map startupChecks; + private volatile Map 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 getStartupChecks() { return startupChecks; } + public Map getCycleDiagnostics() { return cycleDiagnostics; } + + public void recordCycleDiagnostics(Map diagnostics) { + cycleDiagnostics = Collections.unmodifiableMap(new LinkedHashMap(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 snapshotRuntimeState( + Instant heartbeatAt, Instant lastAttemptAt, boolean collectionStopped) { + Map state = new LinkedHashMap(); + 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( diff --git a/ruoyi-wcs-shadow/src/main/resources/application-shadow.yml b/ruoyi-wcs-shadow/src/main/resources/application-shadow.yml index 2185f9a..a84882f 100644 --- a/ruoyi-wcs-shadow/src/main/resources/application-shadow.yml +++ b/ruoyi-wcs-shadow/src/main/resources/application-shadow.yml @@ -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: diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowFaultIsolationTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowFaultIsolationTest.java index 586bedc..6886077 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowFaultIsolationTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowFaultIsolationTest.java @@ -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> heartbeats = new ArrayList>(); + 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 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 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> heartbeats = new ArrayList>(); + 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 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 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> heartbeats = new ArrayList>(); + 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()); diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/config/ShadowIdGeneratorTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/config/ShadowIdGeneratorTest.java new file mode 100644 index 0000000..4cee6ee --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/config/ShadowIdGeneratorTest.java @@ -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()); + } +} diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepositoryTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepositoryTest.java index 984fb74..dbde6fc 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepositoryTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceRepositoryTest.java @@ -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 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( diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceStorageHealthTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceStorageHealthTest.java index 6101e91..6e032a2 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceStorageHealthTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/evidence/JdbcShadowEvidenceStorageHealthTest.java @@ -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"); diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/facts/SqlServerShadowFactReaderTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/facts/SqlServerShadowFactReaderTest.java index 0135761..ae4c226 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/facts/SqlServerShadowFactReaderTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/facts/SqlServerShadowFactReaderTest.java @@ -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 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 { diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/Ft546ShadowCycleTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/Ft546ShadowCycleTest.java index 019c61c..fee2458 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/Ft546ShadowCycleTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/Ft546ShadowCycleTest.java @@ -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 time = + new java.util.concurrent.atomic.AtomicReference(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 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; } diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/JdbcShadowTrafficStateRepositoryTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/JdbcShadowTrafficStateRepositoryTest.java index f92fc41..658f698 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/JdbcShadowTrafficStateRepositoryTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/JdbcShadowTrafficStateRepositoryTest.java @@ -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 parameters = new LinkedHashMap(); + 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( diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowCapturedInputReplayTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowCapturedInputReplayTest.java new file mode 100644 index 0000000..c360c17 --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowCapturedInputReplayTest.java @@ -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 holdReasons = new TreeMap(); + 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 result = + new ReadOnlyDispatchCycle(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 queues = new ArrayList(); + 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 vehicles = new ArrayList(); + 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 maps = new ArrayList(); + 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 registers = new LinkedHashMap(); + 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")); + } +} diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowCycleDiagnosticsTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowCycleDiagnosticsTest.java new file mode 100644 index 0000000..f9e6eb9 --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowCycleDiagnosticsTest.java @@ -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 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 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)); + } +} diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowFieldRegressionTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowFieldRegressionTest.java new file mode 100644 index 0000000..c3af139 --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowFieldRegressionTest.java @@ -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 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 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 registers = new LinkedHashMap(); + 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(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 command = new LinkedHashMap(); + 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 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)); + } +} diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowLivePathRegressionTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowLivePathRegressionTest.java new file mode 100644 index 0000000..e9c42b8 --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/ShadowLivePathRegressionTest.java @@ -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 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 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 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 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 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)); + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/dispatch/DefaultVehicleAssignmentPolicy.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/dispatch/DefaultVehicleAssignmentPolicy.java index 449c96b..5d7e710 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/dispatch/DefaultVehicleAssignmentPolicy.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/dispatch/DefaultVehicleAssignmentPolicy.java @@ -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 resources = new ArrayList(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 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(new LinkedHashSet(resources)), + approach.getDistanceCost() + execution.getDistanceCost(), topology); } private static final class ScoredVehicle { diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/AStarRoutePlanner.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/AStarRoutePlanner.java index b385747..1acce03 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/AStarRoutePlanner.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/AStarRoutePlanner.java @@ -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() { diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/RoutePlan.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/RoutePlan.java index 31aa449..65d587a 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/RoutePlan.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/RoutePlan.java @@ -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 turnNodeIds; public RoutePlan(List nodeIds, List resourceKeys, int distanceCost, int turnCount, long topologyVersion) { + this(nodeIds, resourceKeys, distanceCost, turnCount, topologyVersion, + nodeIds != null && nodeIds.size() <= 2 && turnCount == 0 + ? Collections.emptyList() : null); + } + + private RoutePlan(List nodeIds, List resourceKeys, + int distanceCost, int turnCount, long topologyVersion, + List 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 nodes, List resources, + int distanceCost, EffectiveTopology topology) { + List turns = new ArrayList(); + 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 getTurnNodeIds() { + if (turnNodeIds == null) throw new IllegalStateException("route turn geometry is unavailable"); + return turnNodeIds; } public List getNodeIds() { diff --git a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/dispatch/DefaultVehicleAssignmentPolicyTest.java b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/dispatch/DefaultVehicleAssignmentPolicyTest.java index 5036963..ebd8e32 100644 --- a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/dispatch/DefaultVehicleAssignmentPolicyTest.java +++ b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/dispatch/DefaultVehicleAssignmentPolicyTest.java @@ -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.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(),