fix(shadow): repair field routing and harden evidence collection
This commit is contained in:
108
docs/runbooks/ft546-shadow-20260907-repair-notes.md
Normal file
108
docs/runbooks/ft546-shadow-20260907-repair-notes.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# FT546 Shadow Repair: 2026-09-07
|
||||
|
||||
## Delivery Boundary
|
||||
|
||||
Source changes in the existing `optimize/wcs` worktree, based on `9829b9c0fa71d8a4623a9f968178183217724db1`.
|
||||
Prior uncommitted field fixes are preserved. During the repair/validation stage, no commit,
|
||||
push, deployment, live SQL write, schema migration, production application startup,
|
||||
PLC command or WMS call was performed. The user subsequently authorized committing and
|
||||
pushing the repaired source to `origin/optimize/wcs`; this does not authorize deployment.
|
||||
No deployment scripts or JAR deliverables are included.
|
||||
|
||||
This is a shadow repair, not approval to replace the production scheduler.
|
||||
|
||||
## Repairs
|
||||
|
||||
- Keep virtual TD/WY transit nodes in route planning and resource protection. Generate
|
||||
PLC preview points from numeric start/end/turn nodes, rather than every transit node.
|
||||
Missing geometry, nonnumeric command points and more than six turns remain rejected.
|
||||
- Deduplicate protected resource keys when approach and execution routes overlap.
|
||||
Preserve ordered route nodes, including repeated visits and turns.
|
||||
- Tag new hypothetical reservations with `predictionReservationIds`. On the next poll
|
||||
or restart, remove only tagged predictions from the working ledger. Preserve untagged
|
||||
legacy reservations and all OCCUPIED, command-unknown or recovery-unresolved protection.
|
||||
Keep snapshot CAS, fencing tokens, HOLD counters and transactional evidence rollback.
|
||||
- Use a UUID startup prefix for new reservation IDs, preventing reuse after restart.
|
||||
- Bound evidence growth and persist current run state, including empty polls and shutdown.
|
||||
|
||||
## Capacity Settings
|
||||
|
||||
Existing local external configuration remains usable; omitted new settings use these defaults:
|
||||
|
||||
```yaml
|
||||
shadow:
|
||||
evidence:
|
||||
maximum-database-bytes: 17179869184
|
||||
maximum-batch-payload-bytes: 33554432
|
||||
```
|
||||
|
||||
The database limit is 16 GiB of allocated data plus log files, read from the evidence
|
||||
database's `sys.database_files`. It is not a table row-count limit, used-page measurement,
|
||||
SQL Server MAXSIZE setting or exact physical disk quota. A batch/file-growth increment can
|
||||
overshoot the threshold before the next poll. The existing local `minimum-free-bytes`
|
||||
check still applies to `output-directory`; it does not measure every remote SQL volume.
|
||||
|
||||
The 32 MiB batch limit counts newly inserted large text payloads as UTF-16 bytes.
|
||||
It excludes database page, index and transaction-log overhead. Existing identical replays
|
||||
do not consume the insert budget. Over-budget batches roll back with their traffic state.
|
||||
|
||||
Collection stops with a specific reason: `EVIDENCE_DATABASE_SIZE_LIMIT`,
|
||||
`EVIDENCE_DATABASE_SIZE_CHECK_FAILED`, `EVIDENCE_BATCH_PAYLOAD_LIMIT`, or the existing
|
||||
storage availability/free-space reason. It does not automatically resume or delete rows.
|
||||
Raw `captured_json`, its content hash, evidence hashes and existing replay identity remain
|
||||
unchanged. Lossless deduplication, archival and retention require a separate reviewed
|
||||
migration/operational decision. Existing evidence is not compacted by this repair.
|
||||
|
||||
## Durable Runtime State
|
||||
|
||||
Each new process uses its own run UUID. In the already-authorized evidence tables:
|
||||
|
||||
- `shadow_run.status` records STARTING, RUNNING (status endpoint READY), DEGRADED or STOPPED.
|
||||
- `shadow_traffic_state.state_key = FT546:RUN:<runId>` contains a `RUN_HEARTBEAT` JSON record.
|
||||
- `heartbeatAt`, `lastAttemptAt`, `lastCompletedCycle` and `checkedAt` use UTC ISO timestamps.
|
||||
- `collectionStopped`, `reasonCode` and `cycleDiagnostics` distinguish process liveness,
|
||||
attempted collection, completed collection and permanent collection failure.
|
||||
- Pre/post-poll writes and scheduled backoff/stopped ticks refresh the same bounded row.
|
||||
Runtime JSON is limited to 32,768 characters, independently of business evidence size.
|
||||
- Runtime persistence failure stops further source reads. Failed writes cannot guarantee
|
||||
that the database contains the latest stop reason; check logs and heartbeat freshness.
|
||||
- Normal close records STOPPED. Forced termination/power loss can only be detected by a
|
||||
stale heartbeat. A RUNNING row alone is not proof of a live process or successful work.
|
||||
|
||||
Traffic-ledger readers should select `FT546:TOPOLOGY:<version>` rows; runtime rows have
|
||||
their own key namespace and `kind`. Historical runs without a heartbeat are not rewritten
|
||||
or retrospectively marked stopped. With default polling, investigate a heartbeat older
|
||||
than 60 seconds; adjust this operational threshold when poll/deadline settings change.
|
||||
|
||||
## Local Verification
|
||||
|
||||
Final offline Maven command used Maven 3.6.0 and the supplied complete-v2 capture ZIP:
|
||||
`mvn -o -pl ruoyi-wcs-shadow -am test -Dshadow.replay.zip=<complete-v2-zip>`.
|
||||
|
||||
- 298 tests passed: 128 WCS, 10 protocol, 160 shadow; zero failures, errors or skipped tests.
|
||||
- 300 independent captured inputs: 600 available legacy projections, 300 normal
|
||||
NO_ELIGIBLE_VEHICLE holds, zero new grants. This sample does not prove live dispatch readiness.
|
||||
- 10 additional captured field batches: 9 encodable grants, 9 normal holds,
|
||||
zero input or grant-projection errors. Independent empty initial traffic state per batch.
|
||||
- 6 historical queue paths: zero legacy, routing or command-point projection errors.
|
||||
These checks are protocol-only, not a reconstruction of historical vehicle eligibility.
|
||||
- Focused regressions cover overlap, virtual transit nodes, invalid command points,
|
||||
prediction expiry, protected state, ID restart, storage limits, atomic rollback,
|
||||
heartbeat, retries, timeout permanence, normal stop and runtime write failure.
|
||||
- Independent read-only review found no blocking issues. `git diff --check` passed.
|
||||
Legacy Admin and existing PLC/WMS implementation files have no changes.
|
||||
|
||||
Detailed local logs are under
|
||||
`D:/workSpace/PROJECT_SPACE/FT546/outputs/wcs-shadow-review-20260907/`:
|
||||
`repair-full-with-captured-replay.log` and `live-database-repair-final-replay.log`.
|
||||
|
||||
## Remaining Field Validation
|
||||
|
||||
SQL persistence tests use isolated H2 databases. The new metadata query and runtime writes
|
||||
have not been exercised against SQL Server 2016 under the deployed shadow login in this
|
||||
repair turn. No physical PLC/HIL test, forced network-stall shutdown test or sustained
|
||||
production-load test was run. Query/pool timeouts are not an OS-level hard shutdown bound.
|
||||
|
||||
After an explicitly authorized shadow update, verify the new run's heartbeat, collection
|
||||
progress, capacity reasons, source latency and evidence completeness under real load.
|
||||
Review new shadow evidence before deciding on any production scheduler cutover.
|
||||
66
docs/superpowers/plans/2026-09-07-shadow-field-repair.md
Normal file
66
docs/superpowers/plans/2026-09-07-shadow-field-repair.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Shadow Field Repair Implementation Plan
|
||||
|
||||
> **For agentic workers:** Execute inline with superpowers:executing-plans. Scouts are read-only; the main agent owns edits and verification.
|
||||
|
||||
**Goal:** Repair the field-reproduced shadow failures without changing legacy dispatch or granting production write authority.
|
||||
|
||||
**Architecture:** Keep topology routes separate from FT546 command points. Shadow predictions must not become permanent physical reservations. Preserve existing evidence JSON and database schema, add bounded collection and durable runtime metadata using existing authorized tables.
|
||||
|
||||
**Tech Stack:** Java 8-compatible source, Maven 3.6.0, JUnit 5, Spring JDBC, SQL Server 2016; H2 for isolated persistence tests.
|
||||
|
||||
**Spec:** Existing `docs/superpowers/specs/2026-08-12-ft546-shadow-first-safe-cutover-design.md`, plus the read-only field assessment at `D:/workSpace/PROJECT_SPACE/FT546/outputs/wcs-shadow-review-20260907/live-database-assessment.md` and the user's approval to repair the shadow version.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Work only in the existing optimize/wcs worktree. Preserve prior uncommitted fixes and unrelated files.
|
||||
- No production SQL writes, migrations, PLC/WMS connections, application startup or deployment. The repair/validation stage excluded commit/push; subsequent user approval authorizes publishing only the repaired source to origin/optimize/wcs.
|
||||
- Keep legacy Admin/PLC behavior unchanged. Generic route changes must be backward compatible and tested across ruoyi-wcs.
|
||||
- Do not delete, truncate, silently sample, or change the raw evidence JSON/hash contract.
|
||||
- Keep unsupported phase/route representations explicitly unavailable; do not manufacture successful comparisons.
|
||||
|
||||
## Task 1: Field Path and Protocol Regression
|
||||
|
||||
Files: `ruoyi-wcs-shadow/.../facts/LegacyQueueProtocolProjector.java`, `.../kernel/ShadowCommandIntentFactory.java`, `ruoyi-wcs/.../domain/traffic/RoutePlan.java`, `AStarRoutePlanner.java`, `.../domain/dispatch/DefaultVehicleAssignmentPolicy.java`, focused tests in both modules.
|
||||
|
||||
- [x] Add literal expected 4004 payload tests for task 31312, straight numeric paths, long virtual paths, invalid endpoint/turn and overflow cases.
|
||||
- [x] Add the overlapping approach/execution reservation regression; verify ordered nodes survive while unique resources are reserved.
|
||||
- [x] Run the tests red against current code.
|
||||
- [x] Carry geometry-derived turn-node metadata on planned routes; project start/end/turns in the shadow factory. Accept only known virtual transit tokens in historical reconstruction, with numeric validated endpoints/turns.
|
||||
- [x] Deduplicate reservation resources at route combination, not ordered route nodes or the reservation safety boundary.
|
||||
- [x] Run focused tests green and repeat the captured database probe.
|
||||
|
||||
## Task 2: Shadow Prediction Lifecycle
|
||||
|
||||
Files: `ruoyi-wcs-shadow/.../kernel/JdbcShadowTrafficStateRepository.java`, snapshot assembly and associated H2/end-to-end tests.
|
||||
|
||||
- [x] Write a repeated-poll/restart test showing a prior hypothetical grant cannot consume capacity forever.
|
||||
- [x] Keep physical/unknown protection distinct from hypothetical reservations; never interpret a new prediction as PLC execution.
|
||||
- [x] Preserve snapshot CAS, evidence transaction rollback and hold-count behavior; tag new state JSON with PER_SNAPSHOT_PREDICTION and explicit predictionReservationIds. Preserve all untagged legacy state and OCCUPIED/unknown/recovery protection; remove only tagged predictions from the next working ledger.
|
||||
- [x] Run repeat, restart, conflict and rollback regressions. Add per-startup reservation ID prefix to avoid collision with retained state.
|
||||
|
||||
## Task 3: Evidence Growth Guard
|
||||
|
||||
Files: shadow evidence storage health/configuration, repository metadata access, property defaults and tests.
|
||||
|
||||
- [x] Add tests for database allocation threshold, unavailable capacity metadata, local free-space threshold and preservation of existing evidence rows.
|
||||
- [x] Use bounded database-file metadata reads, not whole captured_json scans or broad server-state permission grants.
|
||||
- [x] Stop new collection with a specific durable reason when limits are reached. Keep raw evidence/export compatibility unchanged.
|
||||
- [x] Document that lossless deduplication/archive remains a separate migration decision, not an automatic deletion policy.
|
||||
|
||||
## Task 4: Durable Run State
|
||||
|
||||
Files: shadow evidence repository/runtime monitor, scheduler, status report/configuration and tests.
|
||||
|
||||
- [x] Test heartbeat on empty polls, last success vs attempt, retryable failure, permanent timeout, orderly stop and persistence failure.
|
||||
- [x] Persist bounded run metadata in FT546:RUN:<runId> in shadow_traffic_state and update shadow_run status. No DDL or new privileges.
|
||||
- [x] Ensure timeout/stop reasons cannot be overwritten by a late worker completion; monitor failure must not claim healthy collection.
|
||||
- [x] Keep timestamps explicitly UTC in serialized metadata and expose freshness for external stale detection.
|
||||
|
||||
## Task 5: Acceptance
|
||||
|
||||
- [x] Run the full offline Maven reactor tests for shadow and its dependencies, plus existing charging and new field-input replay. 298 tests passed, none skipped; 300 captured inputs and 10 additional field batches replayed without processing/projection errors.
|
||||
- [x] Request independent read-only code review of safety boundaries, protocol payloads and new operational paths; fix important findings. No blocking findings; SQL Server integration and blocked-shutdown coverage limits recorded.
|
||||
- [x] Verify no legacy source edits, no secrets in outputs, no new database/PLC authority and no background test jobs left running.
|
||||
- [x] Record results and remaining field/HIL boundaries; deliver source only, without claiming production-scheduler acceptance. See docs/runbooks/ft546-shadow-20260907-repair-notes.md.
|
||||
|
||||
Baseline: offline Maven reactor test command succeeded on 2026-09-07 before this repair. The field probe from the preceding turn reproducibly failed on virtual nodes and duplicate route resources.
|
||||
@@ -62,21 +62,31 @@ public class ShadowEvidenceConfiguration {
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ShadowEvidenceRepository shadowEvidenceRepository(
|
||||
public JdbcShadowEvidenceRepository shadowEvidenceRepository(
|
||||
@Qualifier("shadowEvidenceJdbc") JdbcTemplate evidenceJdbc,
|
||||
@Qualifier("shadowEvidenceTx")
|
||||
DataSourceTransactionManager evidenceTx,
|
||||
ObjectMapper objectMapper, ShadowRunEvidence run) {
|
||||
ObjectMapper objectMapper, ShadowRunEvidence run, ShadowProperties properties) {
|
||||
return new JdbcShadowEvidenceRepository(evidenceJdbc,
|
||||
new TransactionTemplate(evidenceTx), objectMapper, run);
|
||||
new TransactionTemplate(evidenceTx), objectMapper, run,
|
||||
properties.getEvidence().getMaximumBatchPayloadBytes());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ShadowEvidenceStorageHealth shadowEvidenceStorageHealth(
|
||||
ShadowEvidenceRepository repository,
|
||||
@Qualifier("shadowEvidenceJdbc") JdbcTemplate evidenceJdbc,
|
||||
ShadowProperties properties) {
|
||||
return new JdbcShadowEvidenceStorageHealth(repository,
|
||||
Paths.get(properties.getEvidence().getOutputDirectory()),
|
||||
properties.getEvidence().getMinimumFreeBytes().longValue());
|
||||
properties.getEvidence().getMinimumFreeBytes().longValue(),
|
||||
() -> {
|
||||
Long allocated = evidenceJdbc.queryForObject(
|
||||
"SELECT SUM(CAST(size AS bigint)) * 8192 FROM sys.database_files", Long.class);
|
||||
if (allocated == null || allocated <= 0L) {
|
||||
throw new IllegalStateException("evidence database allocation unavailable");
|
||||
}
|
||||
return allocated;
|
||||
}, properties.getEvidence().getMaximumDatabaseBytes());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.ruoyi.shadow.evidence.ShadowCycleEvidenceCollector;
|
||||
import com.ruoyi.shadow.evidence.ShadowEvidenceRepository;
|
||||
import com.ruoyi.shadow.evidence.ShadowEvidenceStorageHealth;
|
||||
import com.ruoyi.shadow.evidence.ShadowRunEvidence;
|
||||
import com.ruoyi.shadow.evidence.JdbcShadowEvidenceRepository;
|
||||
import com.ruoyi.shadow.facts.ShadowFactBatchValidator;
|
||||
import com.ruoyi.shadow.kernel.Ft546ShadowCycle;
|
||||
import com.ruoyi.shadow.kernel.Ft546ShadowScheduler;
|
||||
@@ -43,6 +44,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@@ -69,7 +71,8 @@ public class ShadowKernelConfiguration {
|
||||
@Bean
|
||||
public IdGenerator shadowIdGenerator() {
|
||||
AtomicLong sequence = new AtomicLong();
|
||||
return () -> "SHADOW-" + sequence.incrementAndGet();
|
||||
String runPrefix = "SHADOW-" + UUID.randomUUID() + "-";
|
||||
return () -> runPrefix + sequence.incrementAndGet();
|
||||
}
|
||||
|
||||
@Bean(name = "shadowClock")
|
||||
@@ -173,11 +176,12 @@ public class ShadowKernelConfiguration {
|
||||
ShadowEvidenceStorageHealth shadowEvidenceStorageHealth,
|
||||
ShadowEvidenceRepository shadowEvidenceRepository,
|
||||
ShadowCycleEvidenceCollector shadowCycleEvidenceCollector,
|
||||
ShadowStartupReport shadowStartupReport) {
|
||||
ShadowStartupReport shadowStartupReport,
|
||||
@Qualifier("shadowClock") Clock clock) {
|
||||
return new Ft546ShadowCycle(validator, assembler::assemble,
|
||||
dispatchCycle, commandGateway, shadowEvidenceStorageHealth,
|
||||
shadowEvidenceRepository, shadowCycleEvidenceCollector,
|
||||
shadowStartupReport);
|
||||
shadowStartupReport, clock);
|
||||
}
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
@@ -185,11 +189,12 @@ public class ShadowKernelConfiguration {
|
||||
SqlServerShadowFactReader factReader,
|
||||
Ft546ShadowCycle shadowCycle,
|
||||
ShadowStartupReport shadowStartupReport,
|
||||
JdbcShadowEvidenceRepository evidenceRepository,
|
||||
@Qualifier("shadowClock") Clock clock,
|
||||
@Value("${shadow.cycle.deadline-ms:10000}")
|
||||
long cycleDeadlineMs) {
|
||||
return new Ft546ShadowScheduler(factReader, shadowCycle,
|
||||
shadowStartupReport, clock,
|
||||
Duration.ofMillis(cycleDeadlineMs));
|
||||
Duration.ofMillis(cycleDeadlineMs), evidenceRepository::saveRuntimeState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ public class ShadowProperties {
|
||||
public static final class EvidenceEndpoint extends JdbcEndpoint {
|
||||
private String outputDirectory;
|
||||
private Long minimumFreeBytes;
|
||||
private long maximumDatabaseBytes = 16L * 1024L * 1024L * 1024L;
|
||||
private long maximumBatchPayloadBytes = 32L * 1024L * 1024L;
|
||||
private final RunMetadata run = new RunMetadata();
|
||||
|
||||
public String getOutputDirectory() { return outputDirectory; }
|
||||
@@ -40,6 +42,10 @@ public class ShadowProperties {
|
||||
this.minimumFreeBytes = minimumFreeBytes;
|
||||
}
|
||||
public RunMetadata getRun() { return run; }
|
||||
public long getMaximumDatabaseBytes() { return maximumDatabaseBytes; }
|
||||
public void setMaximumDatabaseBytes(long value) { maximumDatabaseBytes = value; }
|
||||
public long getMaximumBatchPayloadBytes() { return maximumBatchPayloadBytes; }
|
||||
public void setMaximumBatchPayloadBytes(long value) { maximumBatchPayloadBytes = value; }
|
||||
}
|
||||
|
||||
public static final class RunMetadata {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ruoyi.shadow.evidence;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.ruoyi.ft546.protocol.Ft546RegisterWrite;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
@@ -28,16 +29,27 @@ public final class JdbcShadowEvidenceRepository
|
||||
private final TransactionTemplate transaction;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ShadowRunEvidence run;
|
||||
private final long maximumBatchPayloadBytes;
|
||||
|
||||
public JdbcShadowEvidenceRepository(JdbcTemplate jdbc,
|
||||
TransactionTemplate transaction,
|
||||
ObjectMapper objectMapper,
|
||||
ShadowRunEvidence run) {
|
||||
this(jdbc, transaction, objectMapper, run, 32L * 1024L * 1024L);
|
||||
}
|
||||
|
||||
public JdbcShadowEvidenceRepository(JdbcTemplate jdbc, TransactionTemplate transaction,
|
||||
ObjectMapper objectMapper, ShadowRunEvidence run,
|
||||
long maximumBatchPayloadBytes) {
|
||||
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
|
||||
this.transaction = Objects.requireNonNull(transaction, "transaction");
|
||||
this.objectMapper = Objects.requireNonNull(objectMapper,
|
||||
"objectMapper");
|
||||
this.run = Objects.requireNonNull(run, "run");
|
||||
if (maximumBatchPayloadBytes <= 0L) {
|
||||
throw new IllegalArgumentException("maximumBatchPayloadBytes must be positive");
|
||||
}
|
||||
this.maximumBatchPayloadBytes = maximumBatchPayloadBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -174,6 +186,55 @@ public final class JdbcShadowEvidenceRepository
|
||||
}
|
||||
}
|
||||
|
||||
public void saveRuntimeState(Map<String, Object> state) {
|
||||
Objects.requireNonNull(state, "state");
|
||||
String status = Objects.toString(state.get("status"), "");
|
||||
if (!java.util.Arrays.asList("STARTING", "READY", "DEGRADED", "STOPPED").contains(status)
|
||||
|| !"RUN_HEARTBEAT".equals(state.get("kind"))
|
||||
|| !(state.get("collectionStopped") instanceof Boolean)) {
|
||||
throw new ShadowEvidenceException("EVIDENCE_RUNTIME_STATE_INVALID");
|
||||
}
|
||||
Instant heartbeatAt = Instant.parse(Objects.toString(state.get("heartbeatAt")));
|
||||
Map<String, Object> values = new LinkedHashMap<String, Object>(state);
|
||||
values.put("runId", run.getRunId().toString());
|
||||
String json = ShadowEvidenceDigest.canonicalJson(objectMapper, values);
|
||||
if (json.length() > 32768) {
|
||||
throw new ShadowEvidenceException("EVIDENCE_RUNTIME_PAYLOAD_LIMIT");
|
||||
}
|
||||
String key = "FT546:RUN:" + run.getRunId();
|
||||
transaction.execute(tx -> {
|
||||
ensureRun();
|
||||
List<Map<String, Object>> rows = jdbc.queryForList(
|
||||
"SELECT snapshot_version,state_json FROM shadow_traffic_state WHERE state_key=?", key);
|
||||
if (rows.isEmpty()) {
|
||||
jdbc.update("INSERT INTO shadow_traffic_state "
|
||||
+ "(state_key,snapshot_version,state_json,updated_at) VALUES (?,?,?,?)",
|
||||
key, 1L, json, Timestamp.from(heartbeatAt));
|
||||
} else {
|
||||
Map<String, Object> previous = rows.get(0);
|
||||
JsonNode previousState;
|
||||
try {
|
||||
previousState = objectMapper.readTree(Objects.toString(previous.get("state_json")));
|
||||
} catch (Exception failure) {
|
||||
throw new ShadowEvidenceException("EVIDENCE_RUNTIME_STATE_INVALID", failure);
|
||||
}
|
||||
if (previousState.path("collectionStopped").asBoolean()
|
||||
&& !Boolean.TRUE.equals(state.get("collectionStopped"))) {
|
||||
throw new ShadowEvidenceException("EVIDENCE_RUNTIME_STATE_REGRESSION");
|
||||
}
|
||||
long version = ((Number) previous.get("snapshot_version")).longValue();
|
||||
int updated = jdbc.update("UPDATE shadow_traffic_state "
|
||||
+ "SET snapshot_version=?,state_json=?,updated_at=? "
|
||||
+ "WHERE state_key=? AND snapshot_version=?",
|
||||
Math.addExact(version, 1L), json, Timestamp.from(heartbeatAt), key, version);
|
||||
if (updated != 1) throw new ShadowEvidenceException("EVIDENCE_RUNTIME_STATE_CHANGED");
|
||||
}
|
||||
jdbc.update("UPDATE shadow_run SET status=? WHERE run_id=?",
|
||||
"READY".equals(status) ? "RUNNING" : status, run.getRunId());
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private List<SavedCycle> saveAllInTransaction(
|
||||
List<ShadowCycleEvidence> evidence) {
|
||||
ensureRun();
|
||||
@@ -189,6 +250,7 @@ public final class JdbcShadowEvidenceRepository
|
||||
result.add(verifyReplay(existing, item));
|
||||
}
|
||||
}
|
||||
verifyPayloadBudget(inserts);
|
||||
for (ShadowCycleEvidence item : inserts) {
|
||||
insert(item);
|
||||
}
|
||||
@@ -200,6 +262,33 @@ public final class JdbcShadowEvidenceRepository
|
||||
return Collections.unmodifiableList(result);
|
||||
}
|
||||
|
||||
private void verifyPayloadBudget(List<ShadowCycleEvidence> inserts) {
|
||||
long remaining = maximumBatchPayloadBytes;
|
||||
for (ShadowCycleEvidence item : inserts) {
|
||||
remaining = consumePayload(remaining, item.getInputJson());
|
||||
remaining = consumePayload(remaining, item.getLegacyJson());
|
||||
remaining = consumePayload(remaining, item.getLegacyPathInfo());
|
||||
remaining = consumePayload(remaining, item.getDecisionJson());
|
||||
for (Ft546RegisterWrite write : item.getEncodedWrites()) {
|
||||
remaining = consumePayload(remaining,
|
||||
ShadowEvidenceDigest.canonicalJson(objectMapper, write.getValues()));
|
||||
}
|
||||
for (ShadowDifference difference : item.getDifferences()) {
|
||||
remaining = consumePayload(remaining, difference.getExpectedJson());
|
||||
remaining = consumePayload(remaining, difference.getActualJson());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long consumePayload(long remaining, String value) {
|
||||
// SQL Server nvarchar stores UTF-16 code units; page/index/log overhead is separate.
|
||||
long bytes = value == null ? 0L : 2L * value.length();
|
||||
if (bytes > remaining) {
|
||||
throw new ShadowEvidenceException("EVIDENCE_BATCH_PAYLOAD_LIMIT");
|
||||
}
|
||||
return remaining - bytes;
|
||||
}
|
||||
|
||||
private void insert(ShadowCycleEvidence evidence) {
|
||||
jdbc.update("INSERT INTO shadow_cycle "
|
||||
+ "(cycle_id,run_id,input_batch_hash,parent_task_id,"
|
||||
|
||||
@@ -4,16 +4,25 @@ import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
public final class JdbcShadowEvidenceStorageHealth
|
||||
implements ShadowEvidenceStorageHealth {
|
||||
private final ShadowEvidenceRepository repository;
|
||||
private final Path outputDirectory;
|
||||
private final long minimumFreeBytes;
|
||||
private final LongSupplier allocatedDatabaseBytes;
|
||||
private final long maximumDatabaseBytes;
|
||||
|
||||
public JdbcShadowEvidenceStorageHealth(
|
||||
ShadowEvidenceRepository repository, Path outputDirectory,
|
||||
long minimumFreeBytes) {
|
||||
this(repository, outputDirectory, minimumFreeBytes, () -> 0L, Long.MAX_VALUE);
|
||||
}
|
||||
|
||||
public JdbcShadowEvidenceStorageHealth(
|
||||
ShadowEvidenceRepository repository, Path outputDirectory,
|
||||
long minimumFreeBytes, LongSupplier allocatedDatabaseBytes, long maximumDatabaseBytes) {
|
||||
this.repository = Objects.requireNonNull(repository, "repository");
|
||||
this.outputDirectory = Objects.requireNonNull(outputDirectory,
|
||||
"outputDirectory").toAbsolutePath().normalize();
|
||||
@@ -22,6 +31,11 @@ public final class JdbcShadowEvidenceStorageHealth
|
||||
"minimumFreeBytes must not be negative");
|
||||
}
|
||||
this.minimumFreeBytes = minimumFreeBytes;
|
||||
this.allocatedDatabaseBytes = Objects.requireNonNull(allocatedDatabaseBytes, "allocatedDatabaseBytes");
|
||||
if (maximumDatabaseBytes <= 0L) {
|
||||
throw new IllegalArgumentException("maximumDatabaseBytes must be positive");
|
||||
}
|
||||
this.maximumDatabaseBytes = maximumDatabaseBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -29,6 +43,17 @@ public final class JdbcShadowEvidenceStorageHealth
|
||||
if (!repository.isHealthy()) {
|
||||
return Check.unavailable("EVIDENCE_DATABASE_UNAVAILABLE");
|
||||
}
|
||||
try {
|
||||
long allocated = allocatedDatabaseBytes.getAsLong();
|
||||
if (allocated < 0L) {
|
||||
return Check.unavailable("EVIDENCE_DATABASE_SIZE_CHECK_FAILED");
|
||||
}
|
||||
if (allocated >= maximumDatabaseBytes) {
|
||||
return Check.unavailable("EVIDENCE_DATABASE_SIZE_LIMIT");
|
||||
}
|
||||
} catch (RuntimeException failure) {
|
||||
return Check.unavailable("EVIDENCE_DATABASE_SIZE_CHECK_FAILED");
|
||||
}
|
||||
try {
|
||||
long usableBytes = Files.getFileStore(outputDirectory)
|
||||
.getUsableSpace();
|
||||
|
||||
@@ -195,6 +195,7 @@ public final class ShadowCycleEvidence {
|
||||
row.put("tableName", value.getTableName());
|
||||
row.put("taskNo", value.getTaskNo());
|
||||
row.put("taskType", value.getTaskType());
|
||||
row.put("theFloor", value.getTheFloor());
|
||||
row.put("turnPath", value.getTurnPath());
|
||||
result.add(row);
|
||||
}
|
||||
|
||||
@@ -121,13 +121,21 @@ public final class LegacyQueueProtocolProjector {
|
||||
}
|
||||
|
||||
private static List<Integer> ordinaryPath(ShadowQueueFact queue) {
|
||||
List<Integer> executedPath = executedPath(queue.getPathInfo());
|
||||
if (executedPath == null || executedPath.size() < 2) {
|
||||
List<String> executedPath = executedPath(queue.getPathInfo());
|
||||
if (executedPath == null || executedPath.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (executedPath.size() == 1
|
||||
&& !("3".equals(trim(queue.getTaskType()))
|
||||
&& "3".equals(trim(queue.getDefault02()))
|
||||
&& trim(queue.getDefault08()).isEmpty()
|
||||
&& site(executedPath.get(0)).equals(site(queue.getStartSite()))
|
||||
&& site(executedPath.get(0)).equals(site(queue.getEndSite())))) {
|
||||
return null;
|
||||
}
|
||||
List<Integer> result = new ArrayList<Integer>();
|
||||
result.add(executedPath.get(0));
|
||||
result.add(executedPath.get(executedPath.size() - 1));
|
||||
result.add(site(executedPath.get(0)));
|
||||
result.add(site(executedPath.get(executedPath.size() - 1)));
|
||||
if (notBlank(queue.getTurnPath())) {
|
||||
for (String token : queue.getTurnPath().split(",")) {
|
||||
if (!notBlank(token)) {
|
||||
@@ -149,21 +157,23 @@ public final class LegacyQueueProtocolProjector {
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<Integer> executedPath(String value) {
|
||||
private static List<String> executedPath(String value) {
|
||||
if (!notBlank(value)) {
|
||||
return null;
|
||||
}
|
||||
List<Integer> result = new ArrayList<Integer>();
|
||||
List<String> result = new ArrayList<String>();
|
||||
for (String token : value.split(",")) {
|
||||
if (!notBlank(token)) {
|
||||
continue;
|
||||
}
|
||||
Integer point = site(token);
|
||||
if (point == null) {
|
||||
if (point == null && !token.trim().matches("(?:TD[0-9]{3}|WY[0-9]{4})")) {
|
||||
return null;
|
||||
}
|
||||
result.add(point);
|
||||
result.add(token.trim());
|
||||
}
|
||||
if (result.isEmpty() || site(result.get(0)) == null
|
||||
|| site(result.get(result.size() - 1)) == null) return null;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,11 @@ public final class ShadowFactBatch {
|
||||
public List<ShadowMapFact> getMaps() { return maps; }
|
||||
|
||||
public ShadowQueueFact queue(String guid) {
|
||||
for (ShadowQueueFact fact : queues) {
|
||||
if (fact.isActive() && fact.getGuid().equals(guid)) {
|
||||
return fact;
|
||||
}
|
||||
}
|
||||
for (ShadowQueueFact fact : queues) {
|
||||
if (fact.getGuid().equals(guid)) {
|
||||
return fact;
|
||||
|
||||
@@ -110,14 +110,23 @@ public final class ShadowFactBatchValidator {
|
||||
return ShadowFactValidation.insufficient("MISSING_MAP_FACTS");
|
||||
}
|
||||
for (ShadowQueueFact queue : batch.getQueues()) {
|
||||
// Finished rows are historical versions, not current dispatch inputs.
|
||||
if (!queue.isActive()) {
|
||||
continue;
|
||||
}
|
||||
if (queue.getCreatedAt() == null) {
|
||||
return ShadowFactValidation.insufficient(
|
||||
"MISSING_QUEUE_CREATED_AT:" + queue.getTableName()
|
||||
+ ':' + queue.getGuid());
|
||||
}
|
||||
String missing = firstMissingSite(sites, queue.getStartSite(),
|
||||
queue.getEndSite(), queue.getCargoPickSite(),
|
||||
queue.getCargoPlaceSite());
|
||||
String missing;
|
||||
try {
|
||||
missing = firstMissingSite(sites, ShadowQueueEndpoints.start(queue, batch),
|
||||
ShadowQueueEndpoints.end(queue, batch), queue.getCargoPickSite(),
|
||||
queue.getCargoPlaceSite());
|
||||
} catch (IllegalArgumentException unresolved) {
|
||||
return ShadowFactValidation.insufficient(unresolved.getMessage());
|
||||
}
|
||||
if (missing != null) {
|
||||
return ShadowFactValidation.insufficient("MISSING_MAP_SITE:" + missing);
|
||||
}
|
||||
@@ -141,6 +150,9 @@ public final class ShadowFactBatchValidator {
|
||||
private static ShadowFactValidation queueMutations(ShadowFactBatch batch) {
|
||||
Map<String, String> hashes = new HashMap<String, String>();
|
||||
for (ShadowQueueFact queue : batch.getSourceQueues()) {
|
||||
if (!queue.isActive()) {
|
||||
continue;
|
||||
}
|
||||
String key = queue.getTableName() + ':' + queue.getGuid();
|
||||
String existing = hashes.putIfAbsent(key, queue.getContentHash());
|
||||
if (existing != null && !existing.equals(queue.getContentHash())) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ruoyi.shadow.facts;
|
||||
|
||||
/** Resolves legacy business placeholders without modifying captured evidence. */
|
||||
public final class ShadowQueueEndpoints {
|
||||
private ShadowQueueEndpoints() { }
|
||||
|
||||
public static String start(ShadowQueueFact queue, ShadowFactBatch batch) {
|
||||
String site = trimmed(queue.getStartSite());
|
||||
if (!"tsj".equals(site)) return site;
|
||||
if ("4".equals(trimmed(queue.getDefault02()))
|
||||
&& ("1".equals(queue.getTaskType()) || "2".equals(queue.getTaskType()))) {
|
||||
int floor = sourceFloor(queue);
|
||||
// sendPointToPointTask starts at the assigned vehicle's live currSite.
|
||||
for (ShadowVehicleFact vehicle : batch.getVehicles()) {
|
||||
if (vehicle.getDeviceId().equals(queue.getAgvId())
|
||||
&& Integer.valueOf(floor).equals(vehicle.getCurrentFloor())) {
|
||||
return requireFloorSite(vehicle.getCurrentSite(), floor, batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("UNRESOLVED_TSJ_START:" + queue.getGuid());
|
||||
}
|
||||
|
||||
public static String end(ShadowQueueFact queue, ShadowFactBatch batch) {
|
||||
String site = trimmed(queue.getEndSite());
|
||||
if (!"tsj".equals(site)) return site;
|
||||
String type = trimmed(queue.getDefault02());
|
||||
String prefix;
|
||||
if ("8".equals(type)) prefix = "07050";
|
||||
else if ("1".equals(type) || "2".equals(type)) prefix = "09050";
|
||||
else throw new IllegalArgumentException("UNRESOLVED_TSJ_END:" + queue.getGuid());
|
||||
int floor = sourceFloor(queue);
|
||||
return requireFloorSite(prefix + floor, floor, batch);
|
||||
}
|
||||
|
||||
private static int sourceFloor(ShadowQueueFact queue) {
|
||||
String floor = trimmed(queue.getTheFloor());
|
||||
if (floor == null || !floor.matches("[1-9]")) {
|
||||
throw new IllegalArgumentException("INVALID_TSJ_SOURCE_FLOOR:" + queue.getGuid());
|
||||
}
|
||||
return Integer.parseInt(floor);
|
||||
}
|
||||
|
||||
private static String requireFloorSite(String site, int floor, ShadowFactBatch batch) {
|
||||
for (ShadowMapFact map : batch.getMaps()) {
|
||||
if (map.getSiteId().equals(site) && map.getZ() == floor
|
||||
&& (map.getValue() == 1 || map.getValue() == 2)) return site;
|
||||
}
|
||||
throw new IllegalArgumentException("MISSING_TSJ_MAP_SITE:" + site);
|
||||
}
|
||||
|
||||
private static String trimmed(String value) {
|
||||
return value == null ? null : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ public final class ShadowQueueFact {
|
||||
private final String cargoPlaceSite;
|
||||
private final String startSite;
|
||||
private final String endSite;
|
||||
private final String theFloor;
|
||||
private final Instant createdAt;
|
||||
private final String agvId;
|
||||
private final String pathInfo;
|
||||
@@ -35,6 +36,7 @@ public final class ShadowQueueFact {
|
||||
this.cargoPlaceSite = builder.cargoPlaceSite;
|
||||
this.startSite = builder.startSite;
|
||||
this.endSite = builder.endSite;
|
||||
this.theFloor = builder.theFloor;
|
||||
this.createdAt = builder.createdAt;
|
||||
this.agvId = builder.agvId;
|
||||
this.pathInfo = builder.pathInfo;
|
||||
@@ -49,7 +51,7 @@ public final class ShadowQueueFact {
|
||||
this.contentHash = FactHash.sha256(tableName, guid, taskNo, taskType,
|
||||
priority, cargoPickSite, cargoPlaceSite, startSite, endSite,
|
||||
createdAt, agvId, pathInfo, turnPath, default02, default03,
|
||||
default04, default05, default06, default07, default08);
|
||||
default04, default05, default06, default07, default08, theFloor);
|
||||
}
|
||||
|
||||
public static Builder builder(String tableName, String guid) {
|
||||
@@ -57,6 +59,7 @@ public final class ShadowQueueFact {
|
||||
}
|
||||
|
||||
public String getTableName() { return tableName; }
|
||||
public boolean isActive() { return "shadow_ro.v_wcs_srgv_queue".equals(tableName); }
|
||||
public String getGuid() { return guid; }
|
||||
public String getTaskNo() { return taskNo; }
|
||||
public String getTaskType() { return taskType; }
|
||||
@@ -65,6 +68,7 @@ public final class ShadowQueueFact {
|
||||
public String getCargoPlaceSite() { return cargoPlaceSite; }
|
||||
public String getStartSite() { return startSite; }
|
||||
public String getEndSite() { return endSite; }
|
||||
public String getTheFloor() { return theFloor; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public String getAgvId() { return agvId; }
|
||||
public String getPathInfo() { return pathInfo; }
|
||||
@@ -95,6 +99,7 @@ public final class ShadowQueueFact {
|
||||
private String cargoPlaceSite;
|
||||
private String startSite;
|
||||
private String endSite;
|
||||
private String theFloor;
|
||||
private Instant createdAt;
|
||||
private String agvId;
|
||||
private String pathInfo;
|
||||
@@ -119,6 +124,7 @@ public final class ShadowQueueFact {
|
||||
public Builder cargoPlaceSite(String value) { cargoPlaceSite = value; return this; }
|
||||
public Builder startSite(String value) { startSite = value; return this; }
|
||||
public Builder endSite(String value) { endSite = value; return this; }
|
||||
public Builder theFloor(String value) { theFloor = value; return this; }
|
||||
public Builder createdAt(Instant value) { createdAt = value; return this; }
|
||||
public Builder agvId(String value) { agvId = value; return this; }
|
||||
public Builder pathInfo(String value) { pathInfo = value; return this; }
|
||||
|
||||
@@ -181,7 +181,7 @@ public final class SqlServerShadowFactReader {
|
||||
"SELECT database_utc_time FROM shadow_ro.v_current_database_clock";
|
||||
static final String QUEUE_SQL_PREFIX =
|
||||
"SELECT TOP (?) guid, task_no, task_type, priority, "
|
||||
+ "cargo_pick_site, cargo_place_site, start_site, end_site, "
|
||||
+ "cargo_pick_site, cargo_place_site, start_site, end_site, the_floor, "
|
||||
+ "create_date_utc, agv_id, path_info, trun_path, default02, "
|
||||
+ "default03, default04, default05, default06, default07, default08 ";
|
||||
static final String QUEUE_SQL_SUFFIX =
|
||||
@@ -345,6 +345,7 @@ public final class SqlServerShadowFactReader {
|
||||
.cargoPlaceSite(result.getString("cargo_place_site"))
|
||||
.startSite(result.getString("start_site"))
|
||||
.endSite(result.getString("end_site"))
|
||||
.theFloor(result.getString("the_floor"))
|
||||
.createdAt(instant(result, "create_date_utc"))
|
||||
.agvId(result.getString("agv_id"))
|
||||
.pathInfo(result.getString("path_info"))
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.ruoyi.wcs.application.ShadowDispatchResult;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
@@ -30,6 +31,7 @@ public final class Ft546ShadowCycle {
|
||||
private final ShadowEvidenceRepository evidenceRepository;
|
||||
private final ShadowCycleEvidenceCollector evidenceCollector;
|
||||
private final ShadowStartupReport statusReport;
|
||||
private final Clock clock;
|
||||
private final AtomicReference<String> degradedReason =
|
||||
new AtomicReference<String>();
|
||||
|
||||
@@ -48,6 +50,7 @@ public final class Ft546ShadowCycle {
|
||||
this.evidenceRepository = null;
|
||||
this.evidenceCollector = null;
|
||||
this.statusReport = null;
|
||||
this.clock = Clock.systemUTC();
|
||||
}
|
||||
|
||||
public Ft546ShadowCycle(
|
||||
@@ -59,6 +62,19 @@ public final class Ft546ShadowCycle {
|
||||
ShadowEvidenceRepository evidenceRepository,
|
||||
ShadowCycleEvidenceCollector evidenceCollector,
|
||||
ShadowStartupReport statusReport) {
|
||||
this(validator, assembler, dispatchCycle, commandGateway, storageHealth,
|
||||
evidenceRepository, evidenceCollector, statusReport, Clock.systemUTC());
|
||||
}
|
||||
|
||||
public Ft546ShadowCycle(
|
||||
ShadowFactBatchValidator validator,
|
||||
Function<ShadowFactBatch, DispatchSnapshot> assembler,
|
||||
ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> dispatchCycle,
|
||||
RecordingFt546CommandGateway commandGateway,
|
||||
ShadowEvidenceStorageHealth storageHealth,
|
||||
ShadowEvidenceRepository evidenceRepository,
|
||||
ShadowCycleEvidenceCollector evidenceCollector,
|
||||
ShadowStartupReport statusReport, Clock clock) {
|
||||
this.validator = Objects.requireNonNull(validator, "validator");
|
||||
this.assembler = Objects.requireNonNull(assembler, "assembler");
|
||||
this.dispatchCycle = Objects.requireNonNull(dispatchCycle,
|
||||
@@ -73,86 +89,103 @@ public final class Ft546ShadowCycle {
|
||||
"evidenceCollector");
|
||||
this.statusReport = Objects.requireNonNull(statusReport,
|
||||
"statusReport");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
public ShadowCycleResult run(ShadowFactBatch batch, Instant now) {
|
||||
return run(batch, now, new ShadowCycleDiagnostics());
|
||||
}
|
||||
|
||||
ShadowCycleResult run(ShadowFactBatch batch, Instant now, ShadowCycleDiagnostics progress) {
|
||||
if (evidenceRepository == null) {
|
||||
return runWithoutEvidence(batch, now);
|
||||
return runWithoutEvidence(batch, now, progress);
|
||||
}
|
||||
String stoppedReason = degradedReason.get();
|
||||
if (stoppedReason != null) {
|
||||
return ShadowCycleResult.degraded(stoppedReason);
|
||||
}
|
||||
progress.enter("STORAGE_HEALTH");
|
||||
ShadowEvidenceStorageHealth.Check health = storageHealth.verify();
|
||||
if (!health.isAvailable()) {
|
||||
return degrade(now);
|
||||
return degrade(health.getReasonCode(), clock.instant());
|
||||
}
|
||||
try {
|
||||
progress.enter("TRANSACTION_BEGIN");
|
||||
ShadowCycleResult result = evidenceRepository.inTransaction(
|
||||
() -> runAndPersist(batch, now));
|
||||
() -> {
|
||||
ShadowCycleResult saved = runAndPersist(batch, now, progress);
|
||||
progress.enter("TRANSACTION_COMMIT");
|
||||
return saved;
|
||||
});
|
||||
progress.enter("RECORDING");
|
||||
commandGateway.rememberAll(result.getEncodedCommands());
|
||||
Instant completedAt = clock.instant();
|
||||
Long lag = batch.getDatabaseCapturedAt() == null ? null
|
||||
: Long.valueOf(Math.max(0L,
|
||||
java.time.Duration.between(
|
||||
batch.getDatabaseCapturedAt(), now).toMillis()));
|
||||
statusReport.markReady(now, lag);
|
||||
batch.getDatabaseCapturedAt(), completedAt).toMillis()));
|
||||
statusReport.markReady(completedAt, lag);
|
||||
return result;
|
||||
} catch (RuntimeException evidenceFailure) {
|
||||
return degrade(failureReason(evidenceFailure), now);
|
||||
return degrade(failureReason(evidenceFailure), clock.instant());
|
||||
}
|
||||
}
|
||||
|
||||
private ShadowCycleResult runAndPersist(ShadowFactBatch batch,
|
||||
Instant now) {
|
||||
Instant now, ShadowCycleDiagnostics progress) {
|
||||
progress.enter("VALIDATION");
|
||||
ShadowFactValidation validation = validator.validate(batch, now);
|
||||
if (validation.getStatus()
|
||||
!= ShadowFactValidation.Status.COMPARABLE) {
|
||||
return persistInsufficient(batch, validation.getReasonCode(), now);
|
||||
return persistInsufficient(batch, validation.getReasonCode(), progress);
|
||||
}
|
||||
final DispatchSnapshot snapshot;
|
||||
progress.enter("ASSEMBLY_AND_STATE_LOAD");
|
||||
try {
|
||||
snapshot = assembler.apply(batch);
|
||||
} catch (RuntimeException failure) {
|
||||
return persistInsufficient(batch,
|
||||
"INVALID_KERNEL_FACTS:" + message(failure), now);
|
||||
"INVALID_KERNEL_FACTS:" + message(failure), progress);
|
||||
}
|
||||
ShadowDispatchResult<Ft546SrgvCommandIntent> result;
|
||||
progress.enter("DISPATCH_AND_STATE_SAVE");
|
||||
try {
|
||||
result = dispatchCycle.run(snapshot);
|
||||
} catch (IllegalArgumentException failure) {
|
||||
return persistInsufficient(batch,
|
||||
"INVALID_COMMAND_FACTS:" + message(failure), now);
|
||||
"INVALID_COMMAND_FACTS:" + message(failure), progress);
|
||||
}
|
||||
if (result.getDecisions().isRetrySnapshotChanged()) {
|
||||
List<ShadowCycleEvidence> evidence = evidenceCollector.insufficient(
|
||||
batch, "SHADOW_TRAFFIC_STATE_CHANGED", now);
|
||||
evidenceRepository.saveAll(evidence);
|
||||
persistInsufficient(batch, "SHADOW_TRAFFIC_STATE_CHANGED", progress);
|
||||
return ShadowCycleResult.retry(result.getDecisions());
|
||||
}
|
||||
final List<Ft546EncodedCommand> encoded;
|
||||
progress.enter("ENCODING");
|
||||
try {
|
||||
encoded = commandGateway.previewAll(result.getIntents());
|
||||
} catch (RuntimeException failure) {
|
||||
return persistInsufficient(batch,
|
||||
"INVALID_COMMAND_FACTS:" + message(failure), now);
|
||||
"INVALID_COMMAND_FACTS:" + message(failure), progress);
|
||||
}
|
||||
evidenceRepository.saveAll(evidenceCollector.collect(batch,
|
||||
result.getDecisions(), encoded, now));
|
||||
progress.enter("EVIDENCE_BUILD");
|
||||
List<ShadowCycleEvidence> evidence = evidenceCollector.collect(batch,
|
||||
result.getDecisions(), encoded, clock.instant());
|
||||
progress.enter("EVIDENCE_SAVE");
|
||||
evidenceRepository.saveAll(evidence);
|
||||
return ShadowCycleResult.comparable(result.getDecisions(),
|
||||
result.getIntents(), encoded);
|
||||
}
|
||||
|
||||
private ShadowCycleResult persistInsufficient(
|
||||
ShadowFactBatch batch, String reasonCode, Instant now) {
|
||||
evidenceRepository.saveAll(evidenceCollector.insufficient(batch,
|
||||
reasonCode, now));
|
||||
ShadowFactBatch batch, String reasonCode, ShadowCycleDiagnostics progress) {
|
||||
progress.enter("EVIDENCE_BUILD");
|
||||
List<ShadowCycleEvidence> evidence = evidenceCollector.insufficient(batch,
|
||||
reasonCode, clock.instant());
|
||||
progress.enter("EVIDENCE_SAVE");
|
||||
evidenceRepository.saveAll(evidence);
|
||||
return ShadowCycleResult.insufficient(reasonCode);
|
||||
}
|
||||
|
||||
private ShadowCycleResult degrade(Instant now) {
|
||||
return degrade("EVIDENCE_STORAGE_UNAVAILABLE", now);
|
||||
}
|
||||
|
||||
private ShadowCycleResult degrade(String reasonCode, Instant now) {
|
||||
degradedReason.compareAndSet(null, reasonCode);
|
||||
String stoppedReason = degradedReason.get();
|
||||
@@ -165,7 +198,8 @@ public final class Ft546ShadowCycle {
|
||||
String reasonCode = ((ShadowEvidenceException) failure)
|
||||
.getReasonCode();
|
||||
if ("EVIDENCE_KEY_COLLISION".equals(reasonCode)
|
||||
|| "EVIDENCE_RUN_COLLISION".equals(reasonCode)) {
|
||||
|| "EVIDENCE_RUN_COLLISION".equals(reasonCode)
|
||||
|| "EVIDENCE_BATCH_PAYLOAD_LIMIT".equals(reasonCode)) {
|
||||
return reasonCode;
|
||||
}
|
||||
}
|
||||
@@ -173,13 +207,15 @@ public final class Ft546ShadowCycle {
|
||||
}
|
||||
|
||||
private ShadowCycleResult runWithoutEvidence(ShadowFactBatch batch,
|
||||
Instant now) {
|
||||
Instant now, ShadowCycleDiagnostics progress) {
|
||||
progress.enter("VALIDATION");
|
||||
ShadowFactValidation validation = validator.validate(batch, now);
|
||||
if (validation.getStatus()
|
||||
!= ShadowFactValidation.Status.COMPARABLE) {
|
||||
return ShadowCycleResult.insufficient(validation.getReasonCode());
|
||||
}
|
||||
final DispatchSnapshot snapshot;
|
||||
progress.enter("ASSEMBLY_AND_STATE_LOAD");
|
||||
try {
|
||||
snapshot = assembler.apply(batch);
|
||||
} catch (RuntimeException failure) {
|
||||
@@ -187,6 +223,7 @@ public final class Ft546ShadowCycle {
|
||||
"INVALID_KERNEL_FACTS:" + message(failure));
|
||||
}
|
||||
ShadowDispatchResult<Ft546SrgvCommandIntent> result;
|
||||
progress.enter("DISPATCH_AND_STATE_SAVE");
|
||||
try {
|
||||
result = dispatchCycle.run(snapshot);
|
||||
} catch (IllegalArgumentException failure) {
|
||||
@@ -197,6 +234,7 @@ public final class Ft546ShadowCycle {
|
||||
return ShadowCycleResult.retry(result.getDecisions());
|
||||
}
|
||||
final List<Ft546EncodedCommand> encoded;
|
||||
progress.enter("ENCODING");
|
||||
try {
|
||||
encoded = commandGateway.recordAll(result.getIntents());
|
||||
} catch (RuntimeException failure) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
@@ -22,6 +23,7 @@ import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class Ft546ShadowScheduler implements AutoCloseable {
|
||||
private static final Logger LOGGER =
|
||||
@@ -39,11 +41,17 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
|
||||
private final Duration cycleDeadline;
|
||||
private final Duration sourceReadDeadline;
|
||||
private final ExecutorService cycleExecutor;
|
||||
private final Consumer<Map<String, Object>> runtimeWriter;
|
||||
private final Object runtimeLock = new Object();
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
private volatile Instant lastAttemptAt;
|
||||
private final AtomicBoolean inFlight = new AtomicBoolean();
|
||||
private final AtomicBoolean permanentlyStopped = new AtomicBoolean();
|
||||
private volatile CycleStage activeStage = CycleStage.IDLE;
|
||||
private volatile ShadowReadCursor cursor = ShadowReadCursor.initial();
|
||||
private volatile Instant nextAttemptAt = Instant.EPOCH;
|
||||
private volatile Thread cycleWorker;
|
||||
private long lastTimingLogNanos;
|
||||
|
||||
public Ft546ShadowScheduler(SqlServerShadowFactReader factReader,
|
||||
Ft546ShadowCycle shadowCycle,
|
||||
@@ -67,6 +75,21 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
|
||||
Clock clock,
|
||||
Duration cycleDeadline,
|
||||
Duration sourceReadDeadline) {
|
||||
this(factReader, shadowCycle, statusReport, clock, cycleDeadline, sourceReadDeadline, state -> { });
|
||||
}
|
||||
|
||||
public Ft546ShadowScheduler(SqlServerShadowFactReader factReader,
|
||||
Ft546ShadowCycle shadowCycle, ShadowStartupReport statusReport,
|
||||
Clock clock, Duration cycleDeadline,
|
||||
Consumer<Map<String, Object>> runtimeWriter) {
|
||||
this(factReader, shadowCycle, statusReport, clock, cycleDeadline,
|
||||
shorter(cycleDeadline, SOURCE_READ_DEADLINE), runtimeWriter);
|
||||
}
|
||||
|
||||
private Ft546ShadowScheduler(SqlServerShadowFactReader factReader,
|
||||
Ft546ShadowCycle shadowCycle, ShadowStartupReport statusReport,
|
||||
Clock clock, Duration cycleDeadline, Duration sourceReadDeadline,
|
||||
Consumer<Map<String, Object>> runtimeWriter) {
|
||||
this.factReader = Objects.requireNonNull(factReader, "factReader");
|
||||
this.shadowCycle = Objects.requireNonNull(shadowCycle, "shadowCycle");
|
||||
this.statusReport = Objects.requireNonNull(statusReport,
|
||||
@@ -74,6 +97,7 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
this.cycleDeadline = requirePositive(cycleDeadline);
|
||||
this.sourceReadDeadline = requirePositive(sourceReadDeadline);
|
||||
this.runtimeWriter = Objects.requireNonNull(runtimeWriter, "runtimeWriter");
|
||||
this.cycleExecutor = Executors.newSingleThreadExecutor(
|
||||
shadowCycleThreadFactory());
|
||||
}
|
||||
@@ -82,7 +106,11 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
|
||||
initialDelayString = "${shadow.poll.initial-delay-ms:5000}",
|
||||
fixedDelayString = "${shadow.poll.fixed-delay-ms:5000}")
|
||||
public void runScheduledCycle() {
|
||||
runOnce();
|
||||
if (permanentlyStopped.get() || clock.instant().isBefore(nextAttemptAt)) {
|
||||
publishRuntimeState();
|
||||
} else {
|
||||
runOnce();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean runOnce() {
|
||||
@@ -91,11 +119,22 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
|
||||
|| !inFlight.compareAndSet(false, true)) {
|
||||
return false;
|
||||
}
|
||||
Future<CycleAttempt> future = cycleExecutor.submit(
|
||||
() -> executeCycle(now));
|
||||
ShadowCycleDiagnostics diagnostics = new ShadowCycleDiagnostics();
|
||||
lastAttemptAt = now;
|
||||
if (!publishRuntimeState()) {
|
||||
inFlight.set(false);
|
||||
return false;
|
||||
}
|
||||
cycleWorker = null;
|
||||
String outcome = "FAILED";
|
||||
Future<CycleAttempt> future = null;
|
||||
try {
|
||||
if (permanentlyStopped.get()) return false;
|
||||
future = cycleExecutor.submit(() -> executeCycle(now, diagnostics));
|
||||
CycleAttempt attempt = future.get(cycleDeadline.toMillis(),
|
||||
TimeUnit.MILLISECONDS);
|
||||
outcome = attempt.result.getStatusCode();
|
||||
statusReport.recordCycleDiagnostics(diagnostics.finish(outcome, null));
|
||||
if ("DEGRADED".equals(attempt.result.getStatusCode())) {
|
||||
permanentlyStopped.set(true);
|
||||
statusReport.markPermanentlyDegraded(
|
||||
@@ -109,20 +148,34 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
|
||||
return true;
|
||||
} catch (TimeoutException timeout) {
|
||||
CycleStage timedOutStage = activeStage;
|
||||
future.cancel(true);
|
||||
statusReport.recordCycleDiagnostics(diagnostics.cancel("TIMEOUT", cycleWorker));
|
||||
if (future != null) future.cancel(true);
|
||||
return stopPermanently("SHADOW_CYCLE_TIMEOUT", timeout,
|
||||
timedOutStage);
|
||||
} catch (InterruptedException interrupted) {
|
||||
CycleStage interruptedStage = activeStage;
|
||||
future.cancel(true);
|
||||
statusReport.recordCycleDiagnostics(diagnostics.cancel("INTERRUPTED", cycleWorker));
|
||||
if (future != null) future.cancel(true);
|
||||
Thread.currentThread().interrupt();
|
||||
return stopPermanently("SHADOW_CYCLE_INTERRUPTED", interrupted,
|
||||
interruptedStage);
|
||||
} catch (ExecutionException wrapped) {
|
||||
statusReport.recordCycleDiagnostics(diagnostics.finish("FAILED", null));
|
||||
return handleFailure(wrapped.getCause());
|
||||
} catch (java.util.concurrent.RejectedExecutionException shutdown) {
|
||||
return stopPermanently("SHADOW_EXECUTOR_STOPPED", shutdown, activeStage);
|
||||
} finally {
|
||||
Map<String, Object> completed = diagnostics.finish(outcome, null);
|
||||
statusReport.recordCycleDiagnostics(completed);
|
||||
long logTime = System.nanoTime();
|
||||
if (!permanentlyStopped.get() && (lastTimingLogNanos == 0L
|
||||
|| logTime - lastTimingLogNanos >= TimeUnit.MINUTES.toNanos(1))) {
|
||||
LOGGER.info("FT546 shadow cycle diagnostics: {}", completed);
|
||||
lastTimingLogNanos = logTime;
|
||||
}
|
||||
inFlight.set(false);
|
||||
activeStage = CycleStage.IDLE;
|
||||
publishRuntimeState();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,17 +190,37 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (!closed.compareAndSet(false, true)) return;
|
||||
permanentlyStopped.set(true);
|
||||
statusReport.markStopped(clock.instant());
|
||||
cycleExecutor.shutdownNow();
|
||||
publishRuntimeState();
|
||||
}
|
||||
|
||||
private CycleAttempt executeCycle(Instant startedAt) {
|
||||
private boolean publishRuntimeState() {
|
||||
synchronized (runtimeLock) {
|
||||
try {
|
||||
runtimeWriter.accept(statusReport.snapshotRuntimeState(
|
||||
clock.instant(), lastAttemptAt, permanentlyStopped.get()));
|
||||
return true;
|
||||
} catch (RuntimeException failure) {
|
||||
permanentlyStopped.set(true);
|
||||
statusReport.markPermanentlyDegraded("EVIDENCE_RUNTIME_WRITE_FAILED", clock.instant());
|
||||
LOGGER.error("FT546 shadow runtime state unavailable; collection stopped", failure);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private CycleAttempt executeCycle(Instant startedAt, ShadowCycleDiagnostics diagnostics) {
|
||||
cycleWorker = Thread.currentThread();
|
||||
activeStage = CycleStage.SOURCE_READ;
|
||||
diagnostics.enter("SOURCE_READ");
|
||||
ShadowFactBatch batch = factReader.read(cursor,
|
||||
startedAt.plus(sourceReadDeadline));
|
||||
activeStage = CycleStage.SHADOW_CYCLE;
|
||||
return new CycleAttempt(batch,
|
||||
shadowCycle.run(batch, clock.instant()));
|
||||
shadowCycle.run(batch, clock.instant(), diagnostics));
|
||||
}
|
||||
|
||||
private boolean handleFailure(Throwable failure) {
|
||||
@@ -208,8 +281,8 @@ public final class Ft546ShadowScheduler implements AutoCloseable {
|
||||
permanentlyStopped.set(true);
|
||||
nextAttemptAt = Instant.EPOCH;
|
||||
statusReport.markPermanentlyDegraded(reasonCode, clock.instant());
|
||||
LOGGER.error("FT546 shadow collection stopped: {}; stage={}",
|
||||
reasonCode, stage, failure);
|
||||
LOGGER.error("FT546 shadow collection stopped: {}; stage={}; diagnostics={}",
|
||||
reasonCode, stage, statusReport.getCycleDiagnostics(), failure);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -88,8 +88,33 @@ public final class JdbcShadowTrafficStateRepository
|
||||
throw new IllegalStateException(
|
||||
"persisted traffic resources do not match topology");
|
||||
}
|
||||
return new ShadowTrafficState(ledger,
|
||||
WaitForGraph.restore(persisted.waitForEdges), row.version,
|
||||
// Only explicitly tagged predictions expire; untagged state is preserved conservatively.
|
||||
List<ResourceReservationState> protectedReservations = new ArrayList<ResourceReservationState>();
|
||||
Set<String> predictedVehicles = new LinkedHashSet<String>();
|
||||
Set<String> protectedVehicles = new LinkedHashSet<String>();
|
||||
for (ResourceReservationState reservation : persisted.ledger.getReservations()) {
|
||||
if (!persisted.predictionIds.contains(reservation.getReservationId())
|
||||
|| reservation.getState() == ReservationState.OCCUPIED
|
||||
|| reservation.isCommandUnknown() || reservation.isRecoveryUnresolved()) {
|
||||
protectedReservations.add(reservation);
|
||||
protectedVehicles.add(reservation.getVehicleId());
|
||||
} else {
|
||||
predictedVehicles.add(reservation.getVehicleId());
|
||||
}
|
||||
}
|
||||
predictedVehicles.removeAll(protectedVehicles);
|
||||
Map<String, Set<String>> remainingWaits = new LinkedHashMap<String, Set<String>>();
|
||||
for (Map.Entry<String, Set<String>> entry : persisted.waitForEdges.entrySet()) {
|
||||
if (!predictedVehicles.contains(entry.getKey())) {
|
||||
Set<String> holders = new LinkedHashSet<String>(entry.getValue());
|
||||
holders.removeAll(predictedVehicles);
|
||||
if (!holders.isEmpty()) remainingWaits.put(entry.getKey(), holders);
|
||||
}
|
||||
}
|
||||
WaitForGraph graph = WaitForGraph.restore(remainingWaits);
|
||||
ledger = ResourceLedger.restore(new ResourceLedgerState(persisted.ledger.getCapacities(),
|
||||
protectedReservations, persisted.ledger.getNextFencingToken()));
|
||||
return new ShadowTrafficState(ledger, graph, row.version,
|
||||
persisted.holdCounts);
|
||||
}
|
||||
|
||||
@@ -110,8 +135,10 @@ public final class JdbcShadowTrafficStateRepository
|
||||
Map<String, Integer> nextHolds = nextHoldCounts(previousHolds,
|
||||
commit.getHeld());
|
||||
long nextVersion = increment(commit.getSnapshotVersion());
|
||||
Set<String> predictionIds = new TreeSet<String>();
|
||||
commit.getGranted().forEach(grant -> predictionIds.addAll(grant.getReservationIds()));
|
||||
String stateJson = encode(commit.getResultingLedger().snapshotState(),
|
||||
commit.getResultingWaitForGraph().snapshotEdges(), nextHolds);
|
||||
commit.getResultingWaitForGraph().snapshotEdges(), nextHolds, predictionIds);
|
||||
Timestamp now = Timestamp.from(clock.instant());
|
||||
if (current == null) {
|
||||
jdbc.update("INSERT INTO shadow_traffic_state "
|
||||
@@ -197,9 +224,12 @@ public final class JdbcShadowTrafficStateRepository
|
||||
|
||||
private String encode(ResourceLedgerState ledger,
|
||||
Map<String, Set<String>> waitForEdges,
|
||||
Map<String, Integer> holdCounts) {
|
||||
Map<String, Integer> holdCounts,
|
||||
Set<String> predictionIds) {
|
||||
Map<String, Object> root = new LinkedHashMap<String, Object>();
|
||||
root.put("schemaVersion", 1);
|
||||
root.put("evaluationMode", "PER_SNAPSHOT_PREDICTION");
|
||||
root.put("predictionReservationIds", new ArrayList<String>(predictionIds));
|
||||
root.put("ledger", ledgerMap(ledger));
|
||||
Map<String, Object> edges = new TreeMap<String, Object>();
|
||||
for (Map.Entry<String, Set<String>> entry : waitForEdges.entrySet()) {
|
||||
@@ -267,7 +297,13 @@ public final class JdbcShadowTrafficStateRepository
|
||||
requiredObject(root, "waitForEdges"));
|
||||
Map<String, Integer> holds = integerMap(
|
||||
requiredObject(root, "holdCounts"));
|
||||
return new PersistedState(ledgerState, edges, holds);
|
||||
Set<String> predictionIds = new LinkedHashSet<String>();
|
||||
if (root.has("predictionReservationIds")) {
|
||||
for (JsonNode id : requiredArray(root, "predictionReservationIds")) {
|
||||
predictionIds.add(requiredNodeText(id, "predictionReservationId"));
|
||||
}
|
||||
}
|
||||
return new PersistedState(ledgerState, edges, holds, predictionIds);
|
||||
} catch (RuntimeException failure) {
|
||||
throw failure;
|
||||
} catch (Exception failure) {
|
||||
@@ -426,13 +462,16 @@ public final class JdbcShadowTrafficStateRepository
|
||||
private final ResourceLedgerState ledger;
|
||||
private final Map<String, Set<String>> waitForEdges;
|
||||
private final Map<String, Integer> holdCounts;
|
||||
private final Set<String> predictionIds;
|
||||
|
||||
private PersistedState(ResourceLedgerState ledger,
|
||||
Map<String, Set<String>> waitForEdges,
|
||||
Map<String, Integer> holdCounts) {
|
||||
Map<String, Integer> holdCounts,
|
||||
Set<String> predictionIds) {
|
||||
this.ledger = ledger;
|
||||
this.waitForEdges = waitForEdges;
|
||||
this.holdCounts = holdCounts;
|
||||
this.predictionIds = predictionIds;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.ruoyi.shadow.kernel;
|
||||
|
||||
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
|
||||
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
|
||||
import com.ruoyi.wcs.domain.traffic.RoutePlan;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -20,7 +21,8 @@ public final class ShadowCommandIntentFactory {
|
||||
short taskType = signedShort(parameters, "taskType");
|
||||
Integer stage = optionalStage(parameters.get("stage"));
|
||||
Short crossFloorTarget = crossFloorTarget(parameters, taskType, stage);
|
||||
List<Integer> path = path(grant);
|
||||
List<Integer> path = path(grant, taskType == 3 && stage == null
|
||||
&& "LEGACY_PHASE_3".equals(phase));
|
||||
String commandKey = parentTaskId + ':' + phase + ':' + routeVersion
|
||||
+ ':' + requiredText(grant.getStepId(), "stepId");
|
||||
return new Ft546SrgvCommandIntent(
|
||||
@@ -29,20 +31,33 @@ public final class ShadowCommandIntentFactory {
|
||||
taskType, path, crossFloorTarget, stage);
|
||||
}
|
||||
|
||||
private static List<Integer> path(GrantedDispatch grant) {
|
||||
private static List<Integer> path(GrantedDispatch grant, boolean sameSiteCharge) {
|
||||
RoutePlan route = grant.getProtectedRoute();
|
||||
List<String> nodes = route.getNodeIds();
|
||||
if (!route.hasTurnGeometry()) {
|
||||
throw new IllegalArgumentException("SHADOW_ROUTE_GEOMETRY_UNAVAILABLE");
|
||||
}
|
||||
if (nodes.size() == 1 && !sameSiteCharge) {
|
||||
throw new IllegalArgumentException("SHADOW_SINGLE_NODE_TRANSPORT_ROUTE");
|
||||
}
|
||||
List<Integer> points = new ArrayList<Integer>();
|
||||
for (String nodeId : grant.getProtectedRoute().getNodeIds()) {
|
||||
try {
|
||||
points.add(Integer.valueOf(requiredText(nodeId, "route node")));
|
||||
} catch (NumberFormatException failure) {
|
||||
throw new IllegalArgumentException(
|
||||
"route node must be a numeric FT546 site: " + nodeId,
|
||||
failure);
|
||||
}
|
||||
points.add(site(nodes.get(0)));
|
||||
points.add(site(nodes.get(nodes.size() - 1)));
|
||||
for (String turn : route.getTurnNodeIds()) points.add(site(turn));
|
||||
if (points.size() > 8) {
|
||||
throw new IllegalArgumentException("SHADOW_ROUTE_EXCEEDS_SIX_TURNS");
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
private static Integer site(String node) {
|
||||
String value = requiredText(node, "command point");
|
||||
if (!value.matches("[0-9]{1,6}") || Integer.parseInt(value) == 0) {
|
||||
throw new IllegalArgumentException("SHADOW_COMMAND_POINT_NOT_NUMERIC:" + value);
|
||||
}
|
||||
return Integer.valueOf(value);
|
||||
}
|
||||
|
||||
private static Short crossFloorTarget(Map<String, String> parameters,
|
||||
short taskType, Integer stage) {
|
||||
String value = parameters.get("crossFloorTarget");
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** One bounded diagnostic snapshot per poll, including polls with no task evidence. */
|
||||
final class ShadowCycleDiagnostics {
|
||||
private final LongSupplier ticker;
|
||||
private final long started;
|
||||
private long phaseStarted;
|
||||
private String phase = "QUEUED";
|
||||
private final Map<String, Long> phaseNanos = new LinkedHashMap<String, Long>();
|
||||
private Map<String, Object> completed;
|
||||
private boolean cancelled;
|
||||
|
||||
ShadowCycleDiagnostics() { this(System::nanoTime); }
|
||||
|
||||
ShadowCycleDiagnostics(LongSupplier ticker) {
|
||||
this.ticker = ticker;
|
||||
this.started = ticker.getAsLong();
|
||||
this.phaseStarted = started;
|
||||
}
|
||||
|
||||
synchronized void enter(String next) {
|
||||
if (cancelled || Thread.currentThread().isInterrupted()) {
|
||||
throw new CancellationException("SHADOW_CYCLE_CANCELLED");
|
||||
}
|
||||
if (completed != null) return;
|
||||
long now = ticker.getAsLong();
|
||||
accumulate(now);
|
||||
phase = next;
|
||||
phaseStarted = now;
|
||||
}
|
||||
|
||||
synchronized Map<String, Object> finish(String outcome, Thread worker) {
|
||||
if (completed != null) return completed;
|
||||
long now = ticker.getAsLong();
|
||||
accumulate(now);
|
||||
Map<String, Object> snapshot = new LinkedHashMap<String, Object>();
|
||||
snapshot.put("outcome", outcome);
|
||||
snapshot.put("phase", phase);
|
||||
snapshot.put("elapsedMillis", TimeUnit.NANOSECONDS.toMillis(now - started));
|
||||
Map<String, Long> timings = new LinkedHashMap<String, Long>();
|
||||
phaseNanos.forEach((key, value) -> timings.put(key, TimeUnit.NANOSECONDS.toMillis(value)));
|
||||
snapshot.put("phaseMillis", Collections.unmodifiableMap(timings));
|
||||
if (worker != null) {
|
||||
snapshot.put("workerState", worker.getState().name());
|
||||
List<String> stack = new ArrayList<String>();
|
||||
StackTraceElement[] frames = worker.getStackTrace();
|
||||
for (int i = 0; i < Math.min(16, frames.length); i++) stack.add(frames[i].toString());
|
||||
snapshot.put("workerStack", Collections.unmodifiableList(stack));
|
||||
}
|
||||
completed = Collections.unmodifiableMap(snapshot);
|
||||
return completed;
|
||||
}
|
||||
|
||||
synchronized Map<String, Object> cancel(String outcome, Thread worker) {
|
||||
cancelled = true;
|
||||
return finish(outcome, worker);
|
||||
}
|
||||
|
||||
private void accumulate(long now) {
|
||||
phaseNanos.merge(phase, now - phaseStarted, Long::sum);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.ruoyi.shadow.kernel;
|
||||
import com.ruoyi.shadow.facts.ShadowFactBatch;
|
||||
import com.ruoyi.shadow.facts.ShadowMapFact;
|
||||
import com.ruoyi.shadow.facts.ShadowQueueFact;
|
||||
import com.ruoyi.shadow.facts.ShadowQueueEndpoints;
|
||||
import com.ruoyi.shadow.facts.ShadowVehicleFact;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
|
||||
import com.ruoyi.wcs.domain.dispatch.DispatchTask;
|
||||
@@ -106,8 +107,8 @@ public final class ShadowDispatchSnapshotAssembler {
|
||||
if (!ACTIVE_QUEUE.equals(fact.getTableName())) {
|
||||
continue;
|
||||
}
|
||||
String source = required(fact.getStartSite(), "startSite");
|
||||
String destination = required(fact.getEndSite(), "endSite");
|
||||
String source = required(ShadowQueueEndpoints.start(fact, batch), "startSite");
|
||||
String destination = required(ShadowQueueEndpoints.end(fact, batch), "endSite");
|
||||
if (!topology.containsNode(source)
|
||||
|| !topology.containsNode(destination)) {
|
||||
throw new IllegalArgumentException(
|
||||
@@ -174,8 +175,8 @@ public final class ShadowDispatchSnapshotAssembler {
|
||||
EnumSet.of(DeviceCapability.FOUR_WAY_MOVE,
|
||||
DeviceCapability.LOAD_HANDLING,
|
||||
DeviceCapability.LIFT_TRANSFER));
|
||||
boolean connectedAndSendable = "1".equals(optional(
|
||||
fact.getAgvStatus()))
|
||||
// Read-only projection of the legacy PLC gate; agvStatus is not refreshed by PLC polling.
|
||||
boolean connectedAndSendable = Integer.valueOf(5).equals(fact.getWorkMode())
|
||||
&& Integer.valueOf(1).equals(fact.getAllowSend());
|
||||
boolean healthy = zeroOrMissing(fact.getAlarm())
|
||||
&& zeroOrMissing(fact.getErrorCode());
|
||||
|
||||
@@ -16,6 +16,7 @@ public final class ShadowStartupReport {
|
||||
private volatile String reasonCode;
|
||||
private volatile String permanentDegradedReason;
|
||||
private final Map<String, String> startupChecks;
|
||||
private volatile Map<String, Object> cycleDiagnostics = Collections.emptyMap();
|
||||
|
||||
private ShadowStartupReport(Status status, Instant checkedAt,
|
||||
Instant lastCompletedCycle,
|
||||
@@ -44,10 +45,15 @@ public final class ShadowStartupReport {
|
||||
public long getEvidenceBacklog() { return evidenceBacklog; }
|
||||
public String getReasonCode() { return reasonCode; }
|
||||
public Map<String, String> getStartupChecks() { return startupChecks; }
|
||||
public Map<String, Object> getCycleDiagnostics() { return cycleDiagnostics; }
|
||||
|
||||
public void recordCycleDiagnostics(Map<String, Object> diagnostics) {
|
||||
cycleDiagnostics = Collections.unmodifiableMap(new LinkedHashMap<String, Object>(diagnostics));
|
||||
}
|
||||
|
||||
public synchronized void markReady(Instant completedAt,
|
||||
Long sourceLagMillis) {
|
||||
if (permanentDegradedReason != null) {
|
||||
if (permanentDegradedReason != null || status == Status.STOPPED) {
|
||||
return;
|
||||
}
|
||||
this.status = Status.READY;
|
||||
@@ -60,7 +66,7 @@ public final class ShadowStartupReport {
|
||||
public synchronized void markDegraded(String reasonCode,
|
||||
Instant checkedAt) {
|
||||
String normalized = requireReasonCode(reasonCode);
|
||||
if (permanentDegradedReason != null) {
|
||||
if (permanentDegradedReason != null || status == Status.STOPPED) {
|
||||
return;
|
||||
}
|
||||
this.status = Status.DEGRADED;
|
||||
@@ -71,6 +77,7 @@ public final class ShadowStartupReport {
|
||||
public synchronized void markPermanentlyDegraded(String reasonCode,
|
||||
Instant checkedAt) {
|
||||
String normalized = requireReasonCode(reasonCode);
|
||||
if (status == Status.STOPPED) return;
|
||||
if (permanentDegradedReason == null) {
|
||||
permanentDegradedReason = normalized;
|
||||
this.checkedAt = checkedAt;
|
||||
@@ -79,6 +86,30 @@ public final class ShadowStartupReport {
|
||||
this.reasonCode = permanentDegradedReason;
|
||||
}
|
||||
|
||||
public synchronized void markStopped(Instant stoppedAt) {
|
||||
status = Status.STOPPED;
|
||||
checkedAt = stoppedAt;
|
||||
if (reasonCode == null) reasonCode = "SHADOW_APPLICATION_STOPPED";
|
||||
}
|
||||
|
||||
public synchronized Map<String, Object> snapshotRuntimeState(
|
||||
Instant heartbeatAt, Instant lastAttemptAt, boolean collectionStopped) {
|
||||
Map<String, Object> state = new LinkedHashMap<String, Object>();
|
||||
state.put("schemaVersion", 1);
|
||||
state.put("kind", "RUN_HEARTBEAT");
|
||||
state.put("heartbeatAt", heartbeatAt.toString());
|
||||
state.put("lastAttemptAt", lastAttemptAt == null ? null : lastAttemptAt.toString());
|
||||
state.put("lastCompletedCycle", lastCompletedCycle == null ? null : lastCompletedCycle.toString());
|
||||
state.put("checkedAt", checkedAt == null ? null : checkedAt.toString());
|
||||
state.put("status", status.name());
|
||||
state.put("reasonCode", reasonCode);
|
||||
state.put("collectionStopped", collectionStopped || status == Status.STOPPED
|
||||
|| permanentDegradedReason != null);
|
||||
state.put("sourceLagMillis", sourceLagMillis);
|
||||
state.put("cycleDiagnostics", cycleDiagnostics);
|
||||
return Collections.unmodifiableMap(state);
|
||||
}
|
||||
|
||||
private static String requireReasonCode(String reasonCode) {
|
||||
if (reasonCode == null || reasonCode.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
|
||||
@@ -21,6 +21,8 @@ shadow:
|
||||
password:
|
||||
output-directory:
|
||||
minimum-free-bytes:
|
||||
maximum-database-bytes: 17179869184
|
||||
maximum-batch-payload-bytes: 33554432
|
||||
run:
|
||||
artifact-sha256:
|
||||
configuration-sha256:
|
||||
|
||||
@@ -68,6 +68,109 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
class Ft546ShadowFaultIsolationTest {
|
||||
private static final Instant NOW = Instant.parse("2026-08-14T06:00:00Z");
|
||||
|
||||
@Test
|
||||
void emptyPollPublishesHeartbeatAndOrderlyCloseIsSticky() {
|
||||
MutableClock clock = new MutableClock(NOW);
|
||||
ShadowStartupReport report = report();
|
||||
List<Map<String, Object>> heartbeats = new ArrayList<Map<String, Object>>();
|
||||
Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(
|
||||
reader(emptySource(new AtomicInteger()), clock),
|
||||
evidenceCycle(report, new RecordingEvidenceRepository(), gateway(),
|
||||
() -> ShadowEvidenceStorageHealth.Check.available(Long.MAX_VALUE)),
|
||||
report, clock, Duration.ofSeconds(10), heartbeats::add);
|
||||
try {
|
||||
assertTrue(scheduler.runOnce());
|
||||
assertEquals("STARTING", heartbeats.get(0).get("status"));
|
||||
Map<String, Object> ready = heartbeats.get(heartbeats.size() - 1);
|
||||
assertEquals("READY", ready.get("status"));
|
||||
assertEquals(NOW.toString(), ready.get("lastAttemptAt"));
|
||||
assertTrue(ready.get("lastCompletedCycle") != null);
|
||||
assertEquals(Boolean.FALSE, ready.get("collectionStopped"));
|
||||
} finally {
|
||||
scheduler.close();
|
||||
}
|
||||
report.markReady(NOW.plusSeconds(5), 0L);
|
||||
report.markPermanentlyDegraded("LATE_FAILURE", NOW.plusSeconds(6));
|
||||
assertEquals(ShadowStartupReport.Status.STOPPED, report.getStatus());
|
||||
Map<String, Object> stopped = heartbeats.get(heartbeats.size() - 1);
|
||||
assertEquals("STOPPED", stopped.get("status"));
|
||||
assertEquals(Boolean.TRUE, stopped.get("collectionStopped"));
|
||||
assertFalse(scheduler.runOnce());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runtimeWriteFailureStopsBeforeReadingSource() {
|
||||
MutableClock clock = new MutableClock(NOW);
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
ShadowStartupReport report = report();
|
||||
try (Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(
|
||||
reader(emptySource(calls), clock),
|
||||
evidenceCycle(report, new RecordingEvidenceRepository(), gateway(),
|
||||
() -> ShadowEvidenceStorageHealth.Check.available(Long.MAX_VALUE)),
|
||||
report, clock, Duration.ofSeconds(10), state -> { throw new IllegalStateException("offline"); })) {
|
||||
assertFalse(scheduler.runOnce());
|
||||
assertEquals(0, calls.get());
|
||||
assertTrue(scheduler.isPermanentlyStopped());
|
||||
assertEquals("EVIDENCE_RUNTIME_WRITE_FAILED", report.getReasonCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryAndPermanentFailurePublishDifferentCollectionStates() {
|
||||
for (boolean timeout : new boolean[] {false, true}) {
|
||||
MutableClock clock = new MutableClock(NOW);
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
ShadowStartupReport report = report();
|
||||
List<Map<String, Object>> heartbeats = new ArrayList<Map<String, Object>>();
|
||||
SqlServerShadowFactReader.FactSource source = new FactSourceAdapter() {
|
||||
@Override
|
||||
public Instant readDatabaseClock(Instant deadline) {
|
||||
calls.incrementAndGet();
|
||||
if (timeout) throw new QueryTimeoutException("timeout");
|
||||
throw new DataAccessResourceFailureException("offline");
|
||||
}
|
||||
};
|
||||
try (Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(reader(source, clock),
|
||||
evidenceCycle(report, new RecordingEvidenceRepository(), gateway(),
|
||||
() -> ShadowEvidenceStorageHealth.Check.available(Long.MAX_VALUE)),
|
||||
report, clock, Duration.ofSeconds(10), heartbeats::add)) {
|
||||
assertFalse(scheduler.runOnce());
|
||||
Map<String, Object> failed = heartbeats.get(heartbeats.size() - 1);
|
||||
assertEquals("DEGRADED", failed.get("status"));
|
||||
assertEquals(Boolean.valueOf(timeout), failed.get("collectionStopped"));
|
||||
assertEquals(null, failed.get("lastCompletedCycle"));
|
||||
clock.advance(Duration.ofMillis(100));
|
||||
scheduler.runScheduledCycle();
|
||||
Map<String, Object> waiting = heartbeats.get(heartbeats.size() - 1);
|
||||
assertEquals(clock.instant().toString(), waiting.get("heartbeatAt"));
|
||||
assertEquals(NOW.toString(), waiting.get("lastAttemptAt"));
|
||||
assertEquals(1, calls.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void capacityLimitPublishesSpecificPermanentReasonWithoutEvidenceWrites() {
|
||||
for (String reason : Arrays.asList("EVIDENCE_DATABASE_SIZE_LIMIT", "EVIDENCE_BATCH_PAYLOAD_LIMIT")) {
|
||||
MutableClock clock = new MutableClock(NOW);
|
||||
ShadowStartupReport report = report();
|
||||
RecordingEvidenceRepository evidence = new RecordingEvidenceRepository();
|
||||
if (reason.contains("PAYLOAD")) evidence.saveFailure = new ShadowEvidenceException(reason);
|
||||
List<Map<String, Object>> heartbeats = new ArrayList<Map<String, Object>>();
|
||||
try (Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(
|
||||
reader(emptySource(new AtomicInteger()), clock), evidenceCycle(report, evidence, gateway(),
|
||||
() -> reason.contains("DATABASE") ? ShadowEvidenceStorageHealth.Check.unavailable(reason)
|
||||
: ShadowEvidenceStorageHealth.Check.available(Long.MAX_VALUE)),
|
||||
report, clock, Duration.ofSeconds(10), heartbeats::add)) {
|
||||
assertTrue(scheduler.runOnce());
|
||||
assertEquals(reason, report.getReasonCode());
|
||||
assertTrue(scheduler.isPermanentlyStopped());
|
||||
assertEquals(reason, heartbeats.get(heartbeats.size() - 1).get("reasonCode"));
|
||||
if (reason.contains("DATABASE")) assertEquals(0, evidence.transactionCalls.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceQueryTimeoutPermanentlyStopsCollectionBeforeCycle() {
|
||||
MutableClock clock = new MutableClock(NOW);
|
||||
@@ -89,6 +192,8 @@ class Ft546ShadowFaultIsolationTest {
|
||||
|
||||
try {
|
||||
assertFalse(scheduler.runOnce());
|
||||
assertEquals("SOURCE_READ", report.getCycleDiagnostics().get("phase"));
|
||||
assertEquals("FAILED", report.getCycleDiagnostics().get("outcome"));
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
|
||||
assertFalse(scheduler.runOnce());
|
||||
@@ -364,6 +469,11 @@ class Ft546ShadowFaultIsolationTest {
|
||||
event.getFormattedMessage()
|
||||
.contains("stage=SHADOW_CYCLE")),
|
||||
"timeout log must identify the blocked stage");
|
||||
assertTrue(logAppender.list.stream().anyMatch(event ->
|
||||
event.getFormattedMessage().contains("STORAGE_HEALTH")
|
||||
&& event.getFormattedMessage().contains("elapsedMillis")
|
||||
&& event.getFormattedMessage().contains("workerStack")),
|
||||
"timeout must capture subphase, monotonic elapsed time and worker stack before cancellation");
|
||||
assertFalse(scheduler.runOnce());
|
||||
assertEquals(callsAfterTimeout, sourceCalls.get());
|
||||
assertEquals(Collections.emptyList(), gateway.getRecorded());
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ruoyi.shadow.config;
|
||||
|
||||
import com.ruoyi.wcs.domain.shared.IdGenerator;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
|
||||
class ShadowIdGeneratorTest {
|
||||
@Test
|
||||
void separateApplicationRunsCannotReuseReservationIds() {
|
||||
IdGenerator first = new ShadowKernelConfiguration().shadowIdGenerator();
|
||||
IdGenerator restarted = new ShadowKernelConfiguration().shadowIdGenerator();
|
||||
assertNotEquals(first.nextId(), restarted.nextId());
|
||||
assertNotEquals(first.nextId(), first.nextId());
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,77 @@ class JdbcShadowEvidenceRepositoryTest {
|
||||
objectMapper, run);
|
||||
}
|
||||
|
||||
@Test
|
||||
void heartbeatPersistsEmptyRunWithoutInventingCycleEvidence() throws Exception {
|
||||
createTrafficSchema();
|
||||
com.ruoyi.shadow.startup.ShadowStartupReport report =
|
||||
com.ruoyi.shadow.startup.ShadowStartupReport.starting(NOW, Collections.emptyMap());
|
||||
repository.saveRuntimeState(report.snapshotRuntimeState(NOW, null, false));
|
||||
assertEquals(1, count("shadow_run"));
|
||||
assertEquals(0, count("shadow_cycle"));
|
||||
assertEquals(1, count("shadow_traffic_state"));
|
||||
assertEquals("FT546:RUN:" + run.getRunId(), jdbc.queryForObject(
|
||||
"SELECT state_key FROM shadow_traffic_state", String.class));
|
||||
report.markReady(NOW.plusSeconds(5), 0L);
|
||||
repository.saveRuntimeState(report.snapshotRuntimeState(NOW.plusSeconds(5), NOW, false));
|
||||
assertEquals("RUNNING", jdbc.queryForObject("SELECT status FROM shadow_run", String.class));
|
||||
com.fasterxml.jackson.databind.JsonNode state = objectMapper.readTree(jdbc.queryForObject(
|
||||
"SELECT state_json FROM shadow_traffic_state", String.class));
|
||||
assertEquals(NOW.plusSeconds(5).toString(), state.path("heartbeatAt").asText());
|
||||
assertEquals(NOW.toString(), state.path("lastAttemptAt").asText());
|
||||
assertEquals(NOW.plusSeconds(5).toString(), state.path("lastCompletedCycle").asText());
|
||||
assertEquals(1, count("shadow_traffic_state"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void stoppedRunCannotBeResurrectedAndRuntimeWritesAreAtomic() {
|
||||
createTrafficSchema();
|
||||
com.ruoyi.shadow.startup.ShadowStartupReport report =
|
||||
com.ruoyi.shadow.startup.ShadowStartupReport.starting(NOW, Collections.emptyMap());
|
||||
Map<String, Object> old = report.snapshotRuntimeState(NOW, null, false);
|
||||
report.markStopped(NOW.plusSeconds(1));
|
||||
repository.saveRuntimeState(report.snapshotRuntimeState(NOW.plusSeconds(1), null, true));
|
||||
JdbcShadowEvidenceRepository restarted = new JdbcShadowEvidenceRepository(jdbc, transaction, objectMapper, run);
|
||||
assertThrows(ShadowEvidenceException.class, () -> restarted.saveRuntimeState(old));
|
||||
assertEquals("STOPPED", jdbc.queryForObject("SELECT status FROM shadow_run", String.class));
|
||||
jdbc.execute("ALTER TABLE shadow_traffic_state ADD CONSTRAINT ck_runtime_write CHECK (snapshot_version < 2)");
|
||||
assertThrows(RuntimeException.class, () -> repository.saveRuntimeState(
|
||||
report.snapshotRuntimeState(NOW.plusSeconds(2), null, true)));
|
||||
assertEquals(1L, jdbc.queryForObject("SELECT snapshot_version FROM shadow_traffic_state", Long.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void oversizedEvidenceRollsBackTrafficAndRunWithoutPartialRows() {
|
||||
createTrafficSchema();
|
||||
JdbcShadowEvidenceRepository bounded = new JdbcShadowEvidenceRepository(jdbc, transaction,
|
||||
objectMapper, run, 1L);
|
||||
JdbcShadowTrafficStateRepository traffic = new JdbcShadowTrafficStateRepository(jdbc, transaction,
|
||||
objectMapper, java.time.Clock.fixed(NOW, ZoneOffset.UTC),
|
||||
new ShadowCommandIntentFactory(), recordingGateway());
|
||||
ShadowEvidenceException failure = assertThrows(ShadowEvidenceException.class,
|
||||
() -> bounded.inTransaction(() -> {
|
||||
assertTrue(traffic.commit(trafficCommit()));
|
||||
return bounded.save(matchingCycle());
|
||||
}));
|
||||
assertEquals("EVIDENCE_BATCH_PAYLOAD_LIMIT", failure.getReasonCode());
|
||||
assertEquals(0, count("shadow_run"));
|
||||
assertEquals(0, count("shadow_cycle"));
|
||||
assertEquals(0, count("shadow_traffic_state"));
|
||||
assertEquals(0, count("shadow_hold_history"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void payloadLimitDoesNotRejectAnAlreadyPersistedReplay() {
|
||||
ShadowCycleEvidence evidence = matchingCycle();
|
||||
repository.save(evidence);
|
||||
JdbcShadowEvidenceRepository bounded = new JdbcShadowEvidenceRepository(jdbc, transaction,
|
||||
objectMapper, run, 1L);
|
||||
assertEquals(evidence.getCycleId(), bounded.save(evidence).getCycleId());
|
||||
assertEquals(1, count("shadow_cycle"));
|
||||
assertEquals(evidence.getInputJson(), jdbc.queryForObject(
|
||||
"SELECT captured_json FROM shadow_input_batch", String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sqlServerDuplicateKeyCodesAreRecognizedWithoutBroadeningIntegrityErrors() {
|
||||
assertTrue(JdbcShadowEvidenceRepository.isDuplicateKey(
|
||||
|
||||
@@ -18,6 +18,28 @@ class JdbcShadowEvidenceStorageHealthTest {
|
||||
@TempDir
|
||||
Path outputDirectory;
|
||||
|
||||
@Test
|
||||
void databaseAllocationAtLimitStopsCollection() {
|
||||
JdbcShadowEvidenceStorageHealth health = new JdbcShadowEvidenceStorageHealth(
|
||||
repository(true), outputDirectory, 0L, () -> 1000L, 1000L);
|
||||
assertEquals("EVIDENCE_DATABASE_SIZE_LIMIT", health.verify().getReasonCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void databaseAllocationBelowLimitIsAvailable() {
|
||||
assertTrue(new JdbcShadowEvidenceStorageHealth(repository(true), outputDirectory,
|
||||
0L, () -> 999L, 1000L).verify().isAvailable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreadableOrInvalidDatabaseSizeFailsClosed() {
|
||||
assertEquals("EVIDENCE_DATABASE_SIZE_CHECK_FAILED", new JdbcShadowEvidenceStorageHealth(
|
||||
repository(true), outputDirectory, 0L, () -> { throw new IllegalStateException(); },
|
||||
1000L).verify().getReasonCode());
|
||||
assertEquals("EVIDENCE_DATABASE_SIZE_CHECK_FAILED", new JdbcShadowEvidenceStorageHealth(
|
||||
repository(true), outputDirectory, 0L, () -> -1L, 1000L).verify().getReasonCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unavailableDatabaseFailsBeforeDiskCheck() {
|
||||
Path missing = outputDirectory.resolve("missing");
|
||||
|
||||
@@ -264,6 +264,27 @@ class SqlServerShadowFactReaderTest {
|
||||
org.mockito.ArgumentMatchers.contains("dbo."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jdbcQueueProjectionCapturesSourceFloorFromExistingReadOnlyView() throws Exception {
|
||||
DataSource dataSource = mock(DataSource.class);
|
||||
Connection connection = mock(Connection.class);
|
||||
PreparedStatement statement = mock(PreparedStatement.class);
|
||||
ResultSet result = mock(ResultSet.class);
|
||||
when(dataSource.getConnection()).thenReturn(connection);
|
||||
when(connection.prepareStatement(org.mockito.ArgumentMatchers.anyString())).thenReturn(statement);
|
||||
when(statement.executeQuery()).thenReturn(result);
|
||||
when(result.next()).thenReturn(true, false, false);
|
||||
when(result.getString("guid")).thenReturn("LIFT");
|
||||
when(result.getString("the_floor")).thenReturn("2");
|
||||
SqlServerShadowFactReader.JdbcFactSource source = new SqlServerShadowFactReader.JdbcFactSource(
|
||||
new JdbcTemplate(dataSource), Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
List<ShadowQueueFact> queues = source.readQueues(ShadowReadCursor.initial(), NOW.plusSeconds(4));
|
||||
assertEquals(1, queues.size());
|
||||
assertEquals("2", queues.get(0).getTheFloor());
|
||||
assertTrue(SqlServerShadowFactReader.JdbcFactSource.QUEUE_SQL_PREFIX.contains("the_floor"));
|
||||
verify(result).getString("the_floor");
|
||||
}
|
||||
|
||||
@Test
|
||||
void jdbcVehicleProjectionCapturesDispatchAndLegacyGateFacts()
|
||||
throws Exception {
|
||||
|
||||
@@ -254,6 +254,41 @@ class Ft546ShadowCycleTest {
|
||||
assertEquals(now, report.getLastCompletedCycle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void evidenceCompletionAndReadyTimeIncludeWorkInsteadOfReusingEntryTime() {
|
||||
Instant now = Instant.parse("2026-09-06T23:59:59Z");
|
||||
java.util.concurrent.atomic.AtomicReference<Instant> time =
|
||||
new java.util.concurrent.atomic.AtomicReference<Instant>(now);
|
||||
Clock clock = new Clock() {
|
||||
public java.time.ZoneId getZone() { return ZoneOffset.UTC; }
|
||||
public Clock withZone(java.time.ZoneId zone) { return this; }
|
||||
public Instant instant() { return time.get(); }
|
||||
};
|
||||
RecordingEvidenceRepository evidence = new RecordingEvidenceRepository();
|
||||
java.util.concurrent.atomic.AtomicLong nanos = new java.util.concurrent.atomic.AtomicLong();
|
||||
ShadowCycleDiagnostics progress = new ShadowCycleDiagnostics(nanos::get);
|
||||
evidence.beforeCommit = () -> {
|
||||
time.set(now.plusSeconds(3));
|
||||
nanos.set(3000000000L);
|
||||
};
|
||||
ShadowStartupReport report = ShadowStartupReport.starting(now, Collections.emptyMap());
|
||||
Ft546ShadowCycle cycle = evidenceCycle(now, evidence, report, gateway(), clock,
|
||||
() -> {
|
||||
time.set(now.plusSeconds(2));
|
||||
nanos.set(2000000000L);
|
||||
});
|
||||
ShadowFactBatch batch = ShadowFactBatch.create(now.minusMillis(50), now,
|
||||
Collections.singletonList(queueWithoutLegacyAssignment(now)),
|
||||
Collections.singletonList(vehicle(now)), maps());
|
||||
assertEquals("COMPARABLE", cycle.run(batch, now, progress).getStatusCode());
|
||||
assertEquals(now.plusSeconds(2), evidence.saved.get(0).getCompletedAt());
|
||||
assertEquals(now.plusSeconds(3), report.getLastCompletedCycle());
|
||||
assertEquals(Long.valueOf(3000), report.getSourceLagMillis());
|
||||
Map<?, ?> timings = (Map<?, ?>) progress.finish("COMPARABLE", null).get("phaseMillis");
|
||||
assertEquals(1000L, timings.get("TRANSACTION_COMMIT"));
|
||||
assertEquals(2000L, timings.get("DISPATCH_AND_STATE_SAVE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void evidenceFailureRecordsNoCommandAndStopsLaterCalculation() {
|
||||
Instant now = Instant.parse("2026-08-14T06:00:00Z");
|
||||
@@ -345,7 +380,7 @@ class Ft546ShadowCycleTest {
|
||||
ShadowCycleResult second = cycle.run(batch, now.plusSeconds(1));
|
||||
|
||||
assertEquals("DEGRADED", first.getStatusCode());
|
||||
assertEquals("EVIDENCE_STORAGE_UNAVAILABLE", first.getReasonCode());
|
||||
assertEquals("FREE_SPACE_BELOW_MINIMUM", first.getReasonCode());
|
||||
assertEquals("DEGRADED", second.getStatusCode());
|
||||
assertEquals(1, healthChecks.get());
|
||||
assertEquals(0, assemblyCalls.get());
|
||||
@@ -357,6 +392,14 @@ class Ft546ShadowCycleTest {
|
||||
Instant now, RecordingEvidenceRepository evidence,
|
||||
ShadowStartupReport report,
|
||||
RecordingFt546CommandGateway gateway) {
|
||||
return evidenceCycle(now, evidence, report, gateway,
|
||||
Clock.fixed(now, ZoneOffset.UTC), () -> { });
|
||||
}
|
||||
|
||||
private static Ft546ShadowCycle evidenceCycle(
|
||||
Instant now, RecordingEvidenceRepository evidence,
|
||||
ShadowStartupReport report, RecordingFt546CommandGateway gateway,
|
||||
Clock clock, Runnable onDispatch) {
|
||||
RoutePlanner routePlanner = new AStarRoutePlanner();
|
||||
DispatchCoordinator coordinator = new DispatchCoordinator(
|
||||
new DefaultVehicleAssignmentPolicy(routePlanner,
|
||||
@@ -365,6 +408,7 @@ class Ft546ShadowCycleTest {
|
||||
commit -> {
|
||||
evidence.dispatchObservedInsideTransaction =
|
||||
evidence.insideTransaction;
|
||||
onDispatch.run();
|
||||
return true;
|
||||
}, () -> "SHADOW-1");
|
||||
ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> readOnly =
|
||||
@@ -384,7 +428,7 @@ class Ft546ShadowCycleTest {
|
||||
assembler::assemble, readOnly, gateway,
|
||||
() -> ShadowEvidenceStorageHealth.Check.available(
|
||||
Long.MAX_VALUE),
|
||||
evidence, collector(evidence.runId, 3L), report);
|
||||
evidence, collector(evidence.runId, 3L), report, clock);
|
||||
}
|
||||
|
||||
private static ShadowCycleEvidenceCollector collector(
|
||||
@@ -542,6 +586,7 @@ class Ft546ShadowCycleTest {
|
||||
private boolean insideTransaction;
|
||||
private boolean dispatchObservedInsideTransaction;
|
||||
private RuntimeException saveFailure;
|
||||
private Runnable beforeCommit = () -> { };
|
||||
|
||||
@Override
|
||||
public SavedCycle save(ShadowCycleEvidence evidence) {
|
||||
@@ -570,7 +615,9 @@ class Ft546ShadowCycleTest {
|
||||
transactionCalls.incrementAndGet();
|
||||
insideTransaction = true;
|
||||
try {
|
||||
return work.get();
|
||||
T result = work.get();
|
||||
beforeCommit.run();
|
||||
return result;
|
||||
} finally {
|
||||
insideTransaction = false;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,38 @@ class JdbcShadowTrafficStateRepositoryTest {
|
||||
topology = topology();
|
||||
}
|
||||
|
||||
@Test
|
||||
void hypotheticalGrantDoesNotBlockNextPollOrRestart() {
|
||||
ResourceLedger ledger = new ResourceLedger(topology);
|
||||
assertTrue(ledger.reserve(new ReservationRequest("PREDICTED", "ORDER-1", "STEP-1",
|
||||
"VEH-1", Collections.singletonList("NODE:A"))).isGranted());
|
||||
WaitForGraph graph = new WaitForGraph();
|
||||
graph.addWait("VEH-2", "VEH-1");
|
||||
assertTrue(repository().commit(predictionCommit(ledger, graph, "PREDICTED")));
|
||||
ShadowTrafficState next = repository().load(topology);
|
||||
assertEquals(1L, next.getSnapshotVersion());
|
||||
assertTrue(next.getLedger().reserve(new ReservationRequest("NEXT-PREDICTION", "ORDER-2", "STEP-2",
|
||||
"VEH-2", Collections.singletonList("NODE:A"))).isGranted());
|
||||
assertTrue(next.getWaitForGraph().holdersWaitedOnBy("VEH-2").isEmpty());
|
||||
assertEquals(1, next.getConsecutiveHoldCounts().values().iterator().next().intValue());
|
||||
assertTrue(jdbc.queryForObject("SELECT state_json FROM shadow_traffic_state", String.class)
|
||||
.contains("PREDICTED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownOccupiedReservationRemainsProtectedAcrossPredictions() {
|
||||
ResourceLedger ledger = new ResourceLedger(topology);
|
||||
ReservationDecision reserved = ledger.reserve(new ReservationRequest("UNKNOWN", "ORDER-1", "STEP-1",
|
||||
"VEH-1", Collections.singletonList("NODE:A")));
|
||||
ledger.occupy("UNKNOWN", reserved.getFencingToken());
|
||||
ledger.markCommandUnknown("UNKNOWN");
|
||||
assertTrue(repository().commit(predictionCommit(ledger, waitGraph(), "UNKNOWN")));
|
||||
ShadowTrafficState next = repository().load(topology);
|
||||
assertTrue(next.getLedger().reservation("UNKNOWN").isCommandUnknown());
|
||||
assertFalse(next.getLedger().reserve(new ReservationRequest("NEXT", "ORDER-2", "STEP-2",
|
||||
"VEH-2", Collections.singletonList("NODE:A"))).isGranted());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restartReloadPreservesReservationsWaitEdgesAndThreeHoldCount() {
|
||||
JdbcShadowTrafficStateRepository first = repository();
|
||||
@@ -249,6 +281,22 @@ class JdbcShadowTrafficStateRepositoryTest {
|
||||
Collections.emptyList(), Collections.emptyList(), ledger, graph);
|
||||
}
|
||||
|
||||
private DispatchCommit predictionCommit(ResourceLedger ledger, WaitForGraph graph, String reservationId) {
|
||||
Map<String, String> parameters = new LinkedHashMap<String, String>();
|
||||
parameters.put("parentTaskId", "ORDER-1");
|
||||
parameters.put("phase", "LOADED_TO_PLACE");
|
||||
parameters.put("routeVersion", "3");
|
||||
parameters.put("taskNo", "30001");
|
||||
parameters.put("taskType", "1");
|
||||
GrantedDispatch grant = new GrantedDispatch("ORDER-1", "STEP-1", "VEH-1",
|
||||
new RoutePlan(Arrays.asList("70001", "70002"), Collections.singletonList("NODE:A"),
|
||||
1, 0, topology.version()), Collections.singletonList(reservationId),
|
||||
topology.version(), 1L, parameters);
|
||||
return new DispatchCommit("FT546", topology.version(), 0L,
|
||||
Collections.singletonList(grant), Collections.singletonList(held("CAPACITY_CONFLICT")),
|
||||
Collections.emptyList(), Collections.emptyList(), ledger, graph);
|
||||
}
|
||||
|
||||
private ResourceLedger protectedLedger() {
|
||||
ResourceLedger ledger = new ResourceLedger(topology);
|
||||
ReservationDecision reserved = ledger.reserve(new ReservationRequest(
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.ft546.protocol.*;
|
||||
import com.ruoyi.shadow.facts.*;
|
||||
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
|
||||
import com.ruoyi.wcs.application.ShadowDispatchResult;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
|
||||
import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy;
|
||||
import com.ruoyi.wcs.port.out.DispatchPolicy;
|
||||
import com.ruoyi.wcs.domain.traffic.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** Independent input replay, not a reconstruction of historical traffic state. */
|
||||
@EnabledIfSystemProperty(named = "shadow.replay.zip", matches = ".+")
|
||||
class ShadowCapturedInputReplayTest {
|
||||
@Test
|
||||
void capturedInputsRetainSafetyGatesAndReconstructLegacyChargingCommands() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
int inputs = 0, legacyAvailable = 0, grants = 0, holds = 0, writes = 0;
|
||||
Map<String, Integer> holdReasons = new TreeMap<String, Integer>();
|
||||
try (ZipFile zip = new ZipFile(System.getProperty("shadow.replay.zip"))) {
|
||||
ZipEntry entry = Collections.list(zip.entries()).stream()
|
||||
.filter(e -> e.getName().replace('\\', '/').endsWith("/13_tail300_input.jsonl"))
|
||||
.findFirst().orElseThrow(() -> new AssertionError("missing captured inputs"));
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(
|
||||
zip.getInputStream(entry), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.trim().isEmpty()) continue;
|
||||
JsonNode row = mapper.readTree(line.replace("\uFEFF", ""));
|
||||
String raw = row.get("captured_json").asText();
|
||||
assertEquals(row.get("content_hash").asText().toLowerCase(Locale.ROOT), sha256(raw));
|
||||
ShadowFactBatch batch = batch(mapper.readTree(raw));
|
||||
Instant now = batch.getDatabaseCapturedAt();
|
||||
ShadowFactValidation validation = new ShadowFactBatchValidator(
|
||||
Duration.ofSeconds(4), Duration.ofSeconds(30)).validate(batch, now);
|
||||
assertEquals(ShadowFactValidation.Status.COMPARABLE, validation.getStatus(),
|
||||
row.get("cycle_id") + ": " + validation.getReasonCode());
|
||||
for (ShadowQueueFact queue : batch.getQueues()) {
|
||||
ShadowLegacyQueueResult legacy = new LegacyQueueProtocolProjector().project(queue);
|
||||
assertEquals(ShadowLegacyQueueResult.Status.AVAILABLE, legacy.getStatus());
|
||||
legacyAvailable++;
|
||||
}
|
||||
ShadowDispatchSnapshotAssembler assembler = new ShadowDispatchSnapshotAssembler(
|
||||
"FT546", 1L, topology -> new ShadowTrafficState(new ResourceLedger(topology),
|
||||
new WaitForGraph(), 0L, Collections.emptyMap()), Clock.fixed(now, ZoneOffset.UTC));
|
||||
DispatchSnapshot snapshot = assembler.assemble(batch);
|
||||
RoutePlanner planner = new AStarRoutePlanner();
|
||||
DispatchCoordinator coordinator = new DispatchCoordinator(
|
||||
new DefaultVehicleAssignmentPolicy(planner, DispatchPolicy.NONE),
|
||||
new DeadlockGuard(new WaitForGraph(), planner), commit -> true,
|
||||
() -> UUID.randomUUID().toString());
|
||||
ShadowDispatchResult<Ft546SrgvCommandIntent> result =
|
||||
new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(coordinator::dispatch,
|
||||
new ShadowCommandIntentFactory()::create).run(snapshot);
|
||||
grants += result.getDecisions().getGranted().size();
|
||||
holds += result.getDecisions().getHeld().size();
|
||||
result.getDecisions().getHeld().forEach(h ->
|
||||
holdReasons.merge(h.getReasonCode(), 1, Integer::sum));
|
||||
for (Ft546SrgvCommandIntent intent : result.getIntents()) {
|
||||
writes += gateway().previewAll(Collections.singletonList(intent)).get(0).getWrites().size();
|
||||
}
|
||||
inputs++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(300, inputs);
|
||||
assertEquals(600, legacyAvailable);
|
||||
System.out.println("CAPTURED_REPLAY independentEmptyTrafficState inputs=" + inputs
|
||||
+ " legacyAvailable=" + legacyAvailable + " grants=" + grants
|
||||
+ " holds=" + holds + " encodedWrites=" + writes + " holdReasons=" + holdReasons);
|
||||
}
|
||||
|
||||
static ShadowFactBatch batch(JsonNode input) {
|
||||
List<ShadowQueueFact> queues = new ArrayList<ShadowQueueFact>();
|
||||
for (JsonNode q : input.get("queues")) {
|
||||
queues.add(ShadowQueueFact.builder(text(q, "tableName"), text(q, "guid"))
|
||||
.taskNo(text(q, "taskNo")).taskType(text(q, "taskType"))
|
||||
.priority(q.path("priority").isNumber() ? q.get("priority").longValue() : null)
|
||||
.cargoPickSite(text(q, "cargoPickSite")).cargoPlaceSite(text(q, "cargoPlaceSite"))
|
||||
.startSite(text(q, "startSite")).endSite(text(q, "endSite")).theFloor(text(q, "theFloor"))
|
||||
.createdAt(instant(q, "createdAt")).agvId(text(q, "agvId"))
|
||||
.pathInfo(text(q, "pathInfo")).turnPath(text(q, "turnPath"))
|
||||
.default02(text(q, "default02")).default03(text(q, "default03"))
|
||||
.default04(text(q, "default04")).default05(text(q, "default05"))
|
||||
.default06(text(q, "default06")).default07(text(q, "default07"))
|
||||
.default08(text(q, "default08")).build());
|
||||
}
|
||||
List<ShadowVehicleFact> vehicles = new ArrayList<ShadowVehicleFact>();
|
||||
for (JsonNode v : input.get("vehicles")) {
|
||||
vehicles.add(ShadowVehicleFact.builder(text(v, "guid"), text(v, "deviceId"))
|
||||
.agvStatus(text(v, "agvStatus")).taskNo(number(v, "taskNo"))
|
||||
.sourceSite(text(v, "sourceSite")).targetSite(text(v, "targetSite"))
|
||||
.currentSite(text(v, "currentSite")).taskType(number(v, "taskType"))
|
||||
.battery(number(v, "battery")).workMode(number(v, "workMode"))
|
||||
.alarm(number(v, "alarm")).status(number(v, "status")).finish(number(v, "finish"))
|
||||
.chargeFlag(number(v, "chargeFlag")).workPhase(number(v, "workPhase"))
|
||||
.errorCode(number(v, "errorCode")).changeLayer(number(v, "changeLayer"))
|
||||
.cargoFlag(number(v, "cargoFlag")).path(text(v, "path")).allowSend(number(v, "allowSend"))
|
||||
.currentFloor(number(v, "currentFloor")).locked(number(v, "locked"))
|
||||
.spare2(text(v, "spare2")).spare5(text(v, "spare5"))
|
||||
.capturedAt(instant(v, "capturedAt")).build());
|
||||
}
|
||||
List<ShadowMapFact> maps = new ArrayList<ShadowMapFact>();
|
||||
for (JsonNode m : input.get("maps")) {
|
||||
maps.add(new ShadowMapFact(text(m, "guid"), text(m, "siteId"), m.get("x").intValue(),
|
||||
m.get("y").intValue(), m.get("z").intValue(), m.get("value").intValue()));
|
||||
}
|
||||
return ShadowFactBatch.create(instant(input, "databaseCaptureStartedAt"),
|
||||
instant(input, "databaseCapturedAt"), queues, vehicles, maps);
|
||||
}
|
||||
|
||||
private static String text(JsonNode row, String key) {
|
||||
return row.path(key).isNull() || row.path(key).isMissingNode() ? null : row.get(key).asText();
|
||||
}
|
||||
private static Integer number(JsonNode row, String key) {
|
||||
return text(row, key) == null ? null : Integer.valueOf(text(row, key));
|
||||
}
|
||||
private static Instant instant(JsonNode row, String key) {
|
||||
return text(row, key) == null ? null : Instant.parse(text(row, key));
|
||||
}
|
||||
private static String sha256(String value) throws Exception {
|
||||
StringBuilder hex = new StringBuilder();
|
||||
for (byte b : MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))) {
|
||||
hex.append(String.format(Locale.ROOT, "%02x", b & 255));
|
||||
}
|
||||
return hex.toString();
|
||||
}
|
||||
private static RecordingFt546CommandGateway gateway() {
|
||||
Ft546DeviceProfile profile = new Ft546DeviceProfile();
|
||||
Map<String, String> registers = new LinkedHashMap<String, String>();
|
||||
registers.put("taskNo", "4000"); registers.put("taskType", "4002");
|
||||
registers.put("path", "4004"); registers.put("trigger", "4021");
|
||||
profile.setRegisters(registers);
|
||||
return new RecordingFt546CommandGateway(new Ft546SrgvCommandEncoder(), profile,
|
||||
Collections.singleton("POINT_TO_POINT_COMMAND"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ShadowCycleDiagnosticsTest {
|
||||
@Test
|
||||
void monotonicDurationsIncludeQueueReadComputeAndCommit() {
|
||||
AtomicLong nanos = new AtomicLong();
|
||||
ShadowCycleDiagnostics diagnostics = new ShadowCycleDiagnostics(nanos::get);
|
||||
nanos.set(2000000L);
|
||||
diagnostics.enter("SOURCE_READ");
|
||||
nanos.set(7000000L);
|
||||
diagnostics.enter("DISPATCH_AND_STATE_SAVE");
|
||||
nanos.set(17000000L);
|
||||
diagnostics.enter("TRANSACTION_COMMIT");
|
||||
nanos.set(23000000L);
|
||||
Map<String, Object> result = diagnostics.finish("COMPARABLE", null);
|
||||
assertEquals(23L, result.get("elapsedMillis"));
|
||||
Map<?, ?> phases = (Map<?, ?>) result.get("phaseMillis");
|
||||
assertEquals(2L, phases.get("QUEUED"));
|
||||
assertEquals(5L, phases.get("SOURCE_READ"));
|
||||
assertEquals(10L, phases.get("DISPATCH_AND_STATE_SAVE"));
|
||||
assertEquals(6L, phases.get("TRANSACTION_COMMIT"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void timeoutSnapshotIsFrozenAndLateWorkerCannotEnterPersistence() {
|
||||
ShadowCycleDiagnostics diagnostics = new ShadowCycleDiagnostics();
|
||||
diagnostics.enter("DISPATCH_AND_STATE_SAVE");
|
||||
Map<String, Object> timeout = diagnostics.cancel("TIMEOUT", Thread.currentThread());
|
||||
assertEquals("DISPATCH_AND_STATE_SAVE", timeout.get("phase"));
|
||||
assertNotNull(timeout.get("workerStack"));
|
||||
assertThrows(CancellationException.class, () -> diagnostics.enter("EVIDENCE_SAVE"));
|
||||
assertSame(timeout, diagnostics.finish("COMPARABLE", null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import com.ruoyi.shadow.facts.LegacyQueueProtocolProjector;
|
||||
import com.ruoyi.shadow.facts.ShadowFactBatch;
|
||||
import com.ruoyi.shadow.facts.ShadowFactBatchValidator;
|
||||
import com.ruoyi.shadow.facts.ShadowFactValidation;
|
||||
import com.ruoyi.shadow.facts.ShadowLegacyQueueResult;
|
||||
import com.ruoyi.shadow.facts.ShadowMapFact;
|
||||
import com.ruoyi.shadow.facts.ShadowQueueFact;
|
||||
import com.ruoyi.shadow.facts.ShadowVehicleFact;
|
||||
import com.ruoyi.wcs.domain.equipment.VehicleSnapshot;
|
||||
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
|
||||
import com.ruoyi.wcs.domain.traffic.RoutePlan;
|
||||
import com.ruoyi.wcs.domain.dispatch.DispatchTask;
|
||||
import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
|
||||
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
|
||||
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
|
||||
import com.ruoyi.shadow.evidence.ShadowCycleEvidenceCollector;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
|
||||
import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy;
|
||||
import com.ruoyi.wcs.domain.traffic.AStarRoutePlanner;
|
||||
import com.ruoyi.wcs.domain.traffic.DeadlockGuard;
|
||||
import com.ruoyi.wcs.port.out.DispatchPolicy;
|
||||
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
|
||||
import com.ruoyi.wcs.domain.traffic.WaitForGraph;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.LinkedHashMap;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ShadowFieldRegressionTest {
|
||||
private static final Instant NOW = Instant.parse("2026-09-06T18:41:57Z");
|
||||
private static final String ACTIVE = "shadow_ro.v_wcs_srgv_queue";
|
||||
private static final String FINISHED = "shadow_ro.v_wcs_srgv_queue_finish";
|
||||
|
||||
@Test
|
||||
void initializedAgvFlagDoesNotHideAnAutomaticIdleSendableVehicle() {
|
||||
VehicleSnapshot vehicle = assemble(vehicle().agvStatus("0").build());
|
||||
assertTrue(vehicle.isDispatchable(31));
|
||||
}
|
||||
|
||||
@Test
|
||||
void plcSafetyGatesStillExcludeUnsafeOrIncompleteVehicles() {
|
||||
List<ShadowVehicleFact> blocked = Arrays.asList(
|
||||
vehicle().workMode(4).build(), vehicle().status(2).build(),
|
||||
vehicle().allowSend(0).build(), vehicle().allowSend(null).build(),
|
||||
vehicle().alarm(1).build(), vehicle().errorCode(1).build(),
|
||||
vehicle().battery(30).build());
|
||||
for (ShadowVehicleFact fact : blocked) {
|
||||
assertFalse(assemble(fact).isDispatchable(31));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleSiteChargingQueueReconstructsBothLegacyEndpoints() {
|
||||
ShadowQueueFact queue = queue(ACTIVE, "CHARGE", "080202", "080202")
|
||||
.taskType("3").default02("3").pathInfo("080202,").build();
|
||||
ShadowLegacyQueueResult legacy = new LegacyQueueProtocolProjector().project(queue);
|
||||
assertEquals(ShadowLegacyQueueResult.Status.AVAILABLE, legacy.getStatus());
|
||||
assertEquals(Arrays.asList(80202, 80202, 0, 0, 0, 0, 0, 0),
|
||||
legacy.getWrites().get(0).getValues());
|
||||
assertEquals(Arrays.asList(80202, 80202), legacy.getPathPoints());
|
||||
assertEquals(4, legacy.getWrites().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleNodeCannotInventAMissingTransportRouteOrChargingDestination() {
|
||||
LegacyQueueProtocolProjector projector = new LegacyQueueProtocolProjector();
|
||||
assertEquals(ShadowLegacyQueueResult.Status.LEGACY_RESULT_UNAVAILABLE,
|
||||
projector.project(queue(ACTIVE, "MOVE", "080202", "080203")
|
||||
.pathInfo("080202,").build()).getStatus());
|
||||
assertEquals(ShadowLegacyQueueResult.Status.LEGACY_RESULT_UNAVAILABLE,
|
||||
projector.project(queue(ACTIVE, "CHARGE", "080202", "080203")
|
||||
.taskType("3").default02("3").pathInfo("080202,")
|
||||
.build()).getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void historicalVersionsStayInEvidenceWithoutInvalidatingLiveDispatchFacts() {
|
||||
ShadowQueueFact active = queue(ACTIVE, "LIVE", "080202", "080203").build();
|
||||
ShadowQueueFact old = queue(FINISHED, "HISTORY", "080202", "tsj").build();
|
||||
ShadowQueueFact later = queue(FINISHED, "HISTORY", "tsj", "080203").build();
|
||||
ShadowFactBatch batch = batch(Arrays.asList(old, active, later), vehicle().build());
|
||||
assertEquals(ShadowFactValidation.Status.COMPARABLE,
|
||||
validator().validate(batch, NOW).getStatus());
|
||||
assertEquals(3, batch.getSourceQueues().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeIdentityConflictStillFailsClosed() {
|
||||
ShadowFactBatch batch = batch(Arrays.asList(
|
||||
queue(ACTIVE, "LIVE", "080202", "080203").build(),
|
||||
queue(ACTIVE, "LIVE", "080203", "080202").build()), vehicle().build());
|
||||
assertEquals(ShadowFactValidation.Status.INSUFFICIENT_FACTS,
|
||||
validator().validate(batch, NOW).getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void lookupUsesTheActiveQueueWhenHistoryReusesItsGuid() {
|
||||
ShadowQueueFact active = queue(ACTIVE, "SAME", "080202", "080203").build();
|
||||
ShadowQueueFact history = queue(FINISHED, "SAME", "080203", "080202")
|
||||
.createdAt(NOW.minusSeconds(20)).build();
|
||||
assertSame(active, batch(Arrays.asList(history, active), vehicle().build()).queue("SAME"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void liftEndUsesSourceFloorAndPreservesRawFacts() {
|
||||
for (String type : Arrays.asList("8", "1", "2")) {
|
||||
ShadowQueueFact queue = queue(ACTIVE, "LIFT", "080202", "tsj")
|
||||
.cargoPlaceSite("080203").theFloor("2")
|
||||
.default02(type).default07("1").build();
|
||||
ShadowFactBatch batch = liftBatch(queue);
|
||||
assertEquals(ShadowFactValidation.Status.COMPARABLE, validator().validate(batch, NOW).getStatus());
|
||||
DispatchTask task = assembler().assemble(batch).getReadySteps().get(0);
|
||||
assertEquals("8".equals(type) ? "070502" : "090502", task.getDestinationNodeId());
|
||||
assertEquals("tsj", batch.queue("LIFT").getEndSite());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void liftStartUsesAssignedVehicleCurrentSiteOnlyForKnownHandoff() {
|
||||
ShadowQueueFact queue = queue(ACTIVE, "HANDOFF", "tsj", "080203")
|
||||
.cargoPickSite("080202").theFloor("2").default02("4").build();
|
||||
ShadowFactBatch batch = liftBatch(queue);
|
||||
assertEquals(ShadowFactValidation.Status.COMPARABLE, validator().validate(batch, NOW).getStatus());
|
||||
assertEquals("080202", assembler().assemble(batch).getReadySteps().get(0).getSourceNodeId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingFloorUnknownLiftTypeAndCargoPlaceholderStayInsufficient() {
|
||||
List<ShadowQueueFact> invalid = Arrays.asList(
|
||||
queue(ACTIVE, "LIFT", "080202", "tsj").cargoPlaceSite("080203").default02("8").build(),
|
||||
queue(ACTIVE, "LIFT", "080202", "tsj").cargoPlaceSite("080203").theFloor("2").default02("7").build(),
|
||||
queue(ACTIVE, "LIFT", "080202", "tsj").theFloor("2").default02("8").build(),
|
||||
queue(ACTIVE, "HANDOFF", "tsj", "080203").cargoPickSite("080202").theFloor("2").default02("4").agvId(null).build(),
|
||||
queue(ACTIVE, "HANDOFF", "tsj", "080203").cargoPickSite("080202").theFloor("1").default02("4").build());
|
||||
for (ShadowQueueFact queue : invalid) {
|
||||
assertEquals(ShadowFactValidation.Status.INSUFFICIENT_FACTS, validator().validate(liftBatch(queue), NOW).getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleNodeChargingGrantGeneratesTwoProtocolEndpoints() {
|
||||
assertEquals(Arrays.asList(80202, 80202), new ShadowCommandIntentFactory()
|
||||
.create(singletonGrant("3", null)).getPathPoints());
|
||||
assertThrows(IllegalArgumentException.class, () -> new ShadowCommandIntentFactory()
|
||||
.create(singletonGrant("1", null)));
|
||||
assertThrows(IllegalArgumentException.class, () -> new ShadowCommandIntentFactory()
|
||||
.create(singletonGrant("3", "1")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameSiteChargeRunsThroughRealSelectionRoutingAndEncoding() {
|
||||
AStarRoutePlanner planner = new AStarRoutePlanner();
|
||||
DispatchCoordinator coordinator = new DispatchCoordinator(
|
||||
new DefaultVehicleAssignmentPolicy(planner, DispatchPolicy.NONE),
|
||||
new DeadlockGuard(new WaitForGraph(), planner), commit -> true, () -> "CHARGE-RES");
|
||||
Ft546DeviceProfile profile = new Ft546DeviceProfile();
|
||||
Map<String, String> registers = new LinkedHashMap<String, String>();
|
||||
registers.put("taskNo", "4000"); registers.put("taskType", "4002");
|
||||
registers.put("path", "4004"); registers.put("trigger", "4021");
|
||||
profile.setRegisters(registers);
|
||||
Ft546ShadowCycle cycle = new Ft546ShadowCycle(validator(), assembler()::assemble,
|
||||
new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(coordinator::dispatch,
|
||||
new ShadowCommandIntentFactory()::create),
|
||||
new RecordingFt546CommandGateway(new Ft546SrgvCommandEncoder(), profile,
|
||||
Collections.singleton("POINT_TO_POINT_COMMAND")));
|
||||
ShadowQueueFact charge = queue(ACTIVE, "CHARGE", "080202", "080202")
|
||||
.taskType("3").default02("3").pathInfo("080202,").build();
|
||||
ShadowCycleResult result = cycle.run(batch(Collections.singletonList(charge), vehicle().build()), NOW);
|
||||
assertEquals("COMPARABLE", result.getStatusCode(), result.getReasonCode());
|
||||
assertEquals(1, result.getDecisions().getGranted().size());
|
||||
assertEquals(Arrays.asList(80202, 80202), result.getIntents().get(0).getPathPoints());
|
||||
assertEquals(4, result.getEncodedCommands().get(0).getWrites().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceFloorParticipatesInHashAndRawEvidence() throws Exception {
|
||||
ShadowQueueFact second = queue(ACTIVE, "LIFT", "080202", "tsj").theFloor("2").build();
|
||||
ShadowQueueFact third = queue(ACTIVE, "LIFT", "080202", "tsj").theFloor("3").build();
|
||||
assertNotEquals(second.getContentHash(), third.getContentHash());
|
||||
ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
|
||||
String raw = new ShadowCycleEvidenceCollector(java.util.UUID.randomUUID(), 1L, mapper)
|
||||
.insufficient(liftBatch(second), "TEST", NOW).get(0).getInputJson();
|
||||
assertEquals("2", mapper.readTree(raw).get("queues").get(0).get("theFloor").asText());
|
||||
assertEquals("tsj", mapper.readTree(raw).get("queues").get(0).get("endSite").asText());
|
||||
}
|
||||
|
||||
private static GrantedDispatch singletonGrant(String type, String stage) {
|
||||
Map<String, String> command = new LinkedHashMap<String, String>();
|
||||
command.put("parentTaskId", "502");
|
||||
command.put("phase", "LEGACY_PHASE_3");
|
||||
command.put("routeVersion", "1");
|
||||
command.put("taskNo", "502");
|
||||
command.put("taskType", type);
|
||||
if (stage != null) command.put("stage", stage);
|
||||
return new GrantedDispatch("502", "CHARGE", "2",
|
||||
new RoutePlan(Collections.singletonList("080202"),
|
||||
Collections.singletonList("NODE:080202"), 0, 0, 1L),
|
||||
Collections.singletonList("RES-1"), 1L, 1L, command);
|
||||
}
|
||||
|
||||
private static ShadowFactBatch liftBatch(ShadowQueueFact queue) {
|
||||
return ShadowFactBatch.create(NOW.minusMillis(50), NOW, Collections.singletonList(queue),
|
||||
Collections.singletonList(vehicle().build()), Arrays.asList(
|
||||
new ShadowMapFact("M1", "080202", 0, 0, 2, 1),
|
||||
new ShadowMapFact("M2", "080203", 1, 0, 2, 1),
|
||||
new ShadowMapFact("M3", "070502", 2, 0, 2, 1),
|
||||
new ShadowMapFact("M4", "090502", 3, 0, 2, 1)));
|
||||
}
|
||||
|
||||
private static ShadowDispatchSnapshotAssembler assembler() {
|
||||
return new ShadowDispatchSnapshotAssembler("FT546", 1L,
|
||||
topology -> new ShadowTrafficState(new ResourceLedger(topology),
|
||||
new WaitForGraph(), 0L, Collections.emptyMap()), Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
private static VehicleSnapshot assemble(ShadowVehicleFact vehicle) {
|
||||
ShadowDispatchSnapshotAssembler assembler = new ShadowDispatchSnapshotAssembler(
|
||||
"FT546", 1L, topology -> new ShadowTrafficState(new ResourceLedger(topology),
|
||||
new WaitForGraph(), 0L, Collections.emptyMap()), Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
return assembler.assemble(batch(Collections.emptyList(), vehicle)).getVehicleSnapshots().get(0);
|
||||
}
|
||||
|
||||
private static ShadowFactBatch batch(List<ShadowQueueFact> queues, ShadowVehicleFact vehicle) {
|
||||
return ShadowFactBatch.create(NOW.minusMillis(50), NOW, queues,
|
||||
Collections.singletonList(vehicle), Arrays.asList(
|
||||
new ShadowMapFact("M1", "080202", 0, 0, 2, 1),
|
||||
new ShadowMapFact("M2", "080203", 1, 0, 2, 1)));
|
||||
}
|
||||
|
||||
private static ShadowFactBatchValidator validator() {
|
||||
return new ShadowFactBatchValidator(Duration.ofSeconds(4), Duration.ofSeconds(30));
|
||||
}
|
||||
|
||||
private static ShadowQueueFact.Builder queue(String table, String guid, String start, String end) {
|
||||
return ShadowQueueFact.builder(table, guid).taskNo("502").taskType("2").priority(2L)
|
||||
.startSite(start).endSite(end).cargoPickSite(start).cargoPlaceSite(end)
|
||||
.createdAt(NOW.minusSeconds(10)).agvId("2").pathInfo(start + "," + end + ",")
|
||||
.turnPath("").default02("1");
|
||||
}
|
||||
|
||||
private static ShadowVehicleFact.Builder vehicle() {
|
||||
return ShadowVehicleFact.builder("2", "2").agvStatus("0").taskNo(502)
|
||||
.currentSite("080202").sourceSite("080202").targetSite("080202")
|
||||
.taskType(3).battery(80).workMode(5).alarm(0).status(1)
|
||||
.finish(0).chargeFlag(0).workPhase(0).errorCode(0)
|
||||
.changeLayer(0).cargoFlag(0).allowSend(1).currentFloor(2)
|
||||
.locked(0).spare2("0").spare5("0").capturedAt(NOW.minusSeconds(7200));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
|
||||
import com.ruoyi.shadow.config.ShadowKernelConfiguration;
|
||||
import com.ruoyi.shadow.facts.*;
|
||||
import com.ruoyi.wcs.application.dispatch.*;
|
||||
import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy;
|
||||
import com.ruoyi.wcs.domain.traffic.*;
|
||||
import com.ruoyi.wcs.port.out.DispatchPolicy;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ShadowLivePathRegressionTest {
|
||||
private static final Instant NOW = Instant.parse("2026-09-07T03:51:00Z");
|
||||
private static final String FIELD_PATH = "090501,TD002,080501,070501,060501,WY0006,"
|
||||
+ "060401,WY0004,060301,WY0002,060201,WY0001,060101,070101,";
|
||||
|
||||
@Test
|
||||
void completedFieldPathReconstructsLiteralLegacyRegisterValues() {
|
||||
ShadowQueueFact queue = queue("090501", "070101")
|
||||
.pathInfo(FIELD_PATH).turnPath("060501,060101,0,0,0,0").build();
|
||||
ShadowLegacyQueueResult result = new LegacyQueueProtocolProjector().project(queue);
|
||||
assertEquals(ShadowLegacyQueueResult.Status.AVAILABLE, result.getStatus(), result.getReasonCode());
|
||||
assertEquals(Arrays.asList(90501, 70101, 60501, 60101, 0, 0, 0, 0),
|
||||
result.write("4004").getValues());
|
||||
assertEquals("4004,4000,4002,4021", result.getWriteAddresses());
|
||||
}
|
||||
|
||||
@Test
|
||||
void plannedFieldPathKeepsVirtualResourcesButEncodesOnlyEndpointsAndTurns() {
|
||||
DispatchSnapshot snapshot = snapshot(fieldMap(), "090501", "090501", "070101");
|
||||
RoutePlan route = new AStarRoutePlanner().plan(snapshot.getTopology(),
|
||||
"090501", "070101", Collections.emptySet());
|
||||
assertEquals(Arrays.asList(FIELD_PATH.substring(0, FIELD_PATH.length() - 1).split(",")),
|
||||
route.getNodeIds());
|
||||
assertTrue(route.getResourceKeys().contains("NODE:TD002"));
|
||||
assertTrue(route.getResourceKeys().contains("NODE:WY0006"));
|
||||
Ft546SrgvCommandIntent intent = new ShadowCommandIntentFactory().create(grant(route));
|
||||
assertEquals(Arrays.asList(90501, 70101, 60501, 60101), intent.getPathPoints());
|
||||
assertEquals(Arrays.asList(90501, 70101, 60501, 60101, 0, 0, 0, 0),
|
||||
new ShadowKernelConfiguration().recordingFt546CommandGateway()
|
||||
.preview(intent).getWrites().get(0).getValues());
|
||||
}
|
||||
|
||||
@Test
|
||||
void longStraightNumericPathHasTwoCommandPointsAndNoIntermediateDestination() {
|
||||
List<ShadowMapFact> maps = new ArrayList<>();
|
||||
for (int i = 1; i <= 12; i++) maps.add(map(String.format(Locale.ROOT, "%02d0101", i), i, 0));
|
||||
DispatchSnapshot snapshot = snapshot(maps, "010101", "010101", "120101");
|
||||
RoutePlan route = new AStarRoutePlanner().plan(snapshot.getTopology(),
|
||||
"010101", "120101", Collections.emptySet());
|
||||
assertEquals(Arrays.asList(10101, 120101),
|
||||
new ShadowCommandIntentFactory().create(grant(route)).getPathPoints());
|
||||
}
|
||||
|
||||
@Test
|
||||
void overlappingApproachAndExecutionReserveEachResourceOnceWithoutLosingRouteOrder() {
|
||||
DispatchSnapshot snapshot = snapshot(fieldMap(), "060301", "070101", "060201");
|
||||
AStarRoutePlanner planner = new AStarRoutePlanner();
|
||||
DispatchCoordinator coordinator = new DispatchCoordinator(
|
||||
new DefaultVehicleAssignmentPolicy(planner, DispatchPolicy.NONE),
|
||||
new DeadlockGuard(new WaitForGraph(), planner), commit -> true,
|
||||
() -> UUID.randomUUID().toString());
|
||||
DispatchCycleResult result = coordinator.dispatch(snapshot);
|
||||
assertEquals(1, result.getGranted().size());
|
||||
RoutePlan route = result.getGranted().get(0).getProtectedRoute();
|
||||
assertEquals(Arrays.asList("060301", "WY0002", "060201", "WY0001", "060101",
|
||||
"070101", "060101", "WY0001", "060201"), route.getNodeIds());
|
||||
assertEquals(new HashSet<>(route.getResourceKeys()).size(), route.getResourceKeys().size());
|
||||
assertEquals(Arrays.asList(60301, 60201, 60101, 70101, 60101),
|
||||
new ShadowCommandIntentFactory().create(result.getGranted().get(0)).getPathPoints());
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidHistoricalTransitEndpointsAndTurnsRemainUnavailable() {
|
||||
for (String path : Arrays.asList("TD002,080501,", "080501,TD002,",
|
||||
"080501,UNKNOWN,070501,", "080501,-1,070501,")) {
|
||||
assertEquals(ShadowLegacyQueueResult.Status.LEGACY_RESULT_UNAVAILABLE,
|
||||
new LegacyQueueProtocolProjector().project(queue("080501", "070501")
|
||||
.pathInfo(path).build()).getStatus());
|
||||
}
|
||||
assertEquals(ShadowLegacyQueueResult.Status.LEGACY_RESULT_UNAVAILABLE,
|
||||
new LegacyQueueProtocolProjector().project(queue("090501", "070101")
|
||||
.pathInfo(FIELD_PATH).turnPath("TD002,060101,0,0,0,0").build()).getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void virtualTurningPointCannotBeSilentlyDroppedFromCommand() {
|
||||
DispatchSnapshot snapshot = snapshot(Arrays.asList(map("010101", 0, 0),
|
||||
map("WY0001", 1, 0), map("020201", 1, 1)), "010101", "010101", "020201");
|
||||
RoutePlan route = new AStarRoutePlanner().plan(snapshot.getTopology(),
|
||||
"010101", "020201", Collections.emptySet());
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ShadowCommandIntentFactory().create(grant(route)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void moreThanSixTurnsCannotBeTruncatedToFitRegisters() {
|
||||
List<ShadowMapFact> maps = new ArrayList<>();
|
||||
for (int i = 0; i < 10; i++) maps.add(map(String.format(Locale.ROOT, "%06d", 10101 + i),
|
||||
(i + 1) / 2, i / 2));
|
||||
DispatchSnapshot snapshot = snapshot(maps, "010101", "010101", "010110");
|
||||
RoutePlan route = new AStarRoutePlanner().plan(snapshot.getTopology(),
|
||||
"010101", "010110", Collections.emptySet());
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ShadowCommandIntentFactory().create(grant(route)));
|
||||
}
|
||||
|
||||
private static GrantedDispatch grant(RoutePlan route) {
|
||||
Map<String, String> command = new LinkedHashMap<>();
|
||||
command.put("parentTaskId", "31312"); command.put("phase", "EMPTY_TO_PICKUP");
|
||||
command.put("routeVersion", "1"); command.put("taskNo", "31312"); command.put("taskType", "4");
|
||||
return new GrantedDispatch("31312", "FIELD", "3", route,
|
||||
Collections.singletonList("RES"), 1L, 1L, command);
|
||||
}
|
||||
|
||||
private static ShadowQueueFact.Builder queue(String start, String end) {
|
||||
return ShadowQueueFact.builder("shadow_ro.v_wcs_srgv_queue", "FIELD")
|
||||
.taskNo("31312").taskType("1").priority(2L).startSite(start).endSite(end)
|
||||
.cargoPickSite(start).cargoPlaceSite(end).theFloor("1").agvId("3")
|
||||
.createdAt(NOW.minusSeconds(10)).default02("4").default08("").turnPath("");
|
||||
}
|
||||
|
||||
private static DispatchSnapshot snapshot(List<ShadowMapFact> maps, String location,
|
||||
String source, String destination) {
|
||||
ShadowVehicleFact vehicle = ShadowVehicleFact.builder("3", "3").agvStatus("0")
|
||||
.currentSite(location).currentFloor(1).workMode(5).status(1).allowSend(1)
|
||||
.alarm(0).errorCode(0).battery(80).cargoFlag(0).capturedAt(NOW).build();
|
||||
ShadowFactBatch batch = ShadowFactBatch.create(NOW, NOW,
|
||||
Collections.singletonList(queue(source, destination).build()),
|
||||
Collections.singletonList(vehicle), maps);
|
||||
return new ShadowDispatchSnapshotAssembler("FT546", 1L,
|
||||
topology -> new ShadowTrafficState(new ResourceLedger(topology),
|
||||
new WaitForGraph(), 0L, Collections.emptyMap()), Clock.fixed(NOW, ZoneOffset.UTC))
|
||||
.assemble(batch);
|
||||
}
|
||||
|
||||
private static ShadowMapFact map(String site, int x, int y) {
|
||||
return new ShadowMapFact(site, site, x, y, 1, 1);
|
||||
}
|
||||
|
||||
private static List<ShadowMapFact> fieldMap() {
|
||||
return Arrays.asList(map("090501", 11, 10), map("TD002", 11, 9),
|
||||
map("080501", 11, 8), map("070501", 11, 7), map("060501", 11, 6),
|
||||
map("WY0006", 10, 6), map("060401", 9, 6), map("WY0004", 8, 6),
|
||||
map("060301", 7, 6), map("WY0002", 6, 6), map("060201", 5, 6),
|
||||
map("WY0001", 4, 6), map("060101", 3, 6), map("070101", 3, 7));
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@@ -114,27 +115,9 @@ public final class DefaultVehicleAssignmentPolicy implements VehicleAssignmentPo
|
||||
List<String> resources = new ArrayList<String>(approach.getResourceKeys());
|
||||
resources.addAll(execution.getResourceKeys().subList(
|
||||
1, execution.getResourceKeys().size()));
|
||||
return new RoutePlan(nodes, resources,
|
||||
approach.getDistanceCost() + execution.getDistanceCost(),
|
||||
countTurns(nodes, topology), topology.version());
|
||||
}
|
||||
|
||||
private static int countTurns(List<String> nodes, EffectiveTopology topology) {
|
||||
int turns = 0;
|
||||
int previousX = 0;
|
||||
int previousY = 0;
|
||||
for (int i = 1; i < nodes.size(); i++) {
|
||||
TopologyNode from = topology.node(nodes.get(i - 1));
|
||||
TopologyNode to = topology.node(nodes.get(i));
|
||||
int directionX = Integer.compare(to.getX(), from.getX());
|
||||
int directionY = Integer.compare(to.getY(), from.getY());
|
||||
if (i > 1 && (previousX != directionX || previousY != directionY)) {
|
||||
turns++;
|
||||
}
|
||||
previousX = directionX;
|
||||
previousY = directionY;
|
||||
}
|
||||
return turns;
|
||||
// Returning over the approach is valid geometry, but reserves each resource once.
|
||||
return RoutePlan.fromTopology(nodes, new ArrayList<String>(new LinkedHashSet<String>(resources)),
|
||||
approach.getDistanceCost() + execution.getDistanceCost(), topology);
|
||||
}
|
||||
|
||||
private static final class ScoredVehicle {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.ruoyi.wcs.domain.traffic;
|
||||
|
||||
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyNode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -10,9 +12,18 @@ public final class RoutePlan {
|
||||
private final int distanceCost;
|
||||
private final int turnCount;
|
||||
private final long topologyVersion;
|
||||
private final List<String> turnNodeIds;
|
||||
|
||||
public RoutePlan(List<String> nodeIds, List<String> resourceKeys,
|
||||
int distanceCost, int turnCount, long topologyVersion) {
|
||||
this(nodeIds, resourceKeys, distanceCost, turnCount, topologyVersion,
|
||||
nodeIds != null && nodeIds.size() <= 2 && turnCount == 0
|
||||
? Collections.<String>emptyList() : null);
|
||||
}
|
||||
|
||||
private RoutePlan(List<String> nodeIds, List<String> resourceKeys,
|
||||
int distanceCost, int turnCount, long topologyVersion,
|
||||
List<String> turnNodeIds) {
|
||||
this.nodeIds = immutable(nodeIds, "nodeIds");
|
||||
this.resourceKeys = immutable(resourceKeys, "resourceKeys");
|
||||
if (this.nodeIds.isEmpty()) {
|
||||
@@ -24,6 +35,34 @@ public final class RoutePlan {
|
||||
this.distanceCost = distanceCost;
|
||||
this.turnCount = turnCount;
|
||||
this.topologyVersion = topologyVersion;
|
||||
this.turnNodeIds = turnNodeIds == null ? null : immutable(turnNodeIds, "turnNodeIds");
|
||||
}
|
||||
|
||||
public static RoutePlan fromTopology(List<String> nodes, List<String> resources,
|
||||
int distanceCost, EffectiveTopology topology) {
|
||||
List<String> turns = new ArrayList<String>();
|
||||
int previousX = 0, previousY = 0;
|
||||
for (int i = 1; i < nodes.size(); i++) {
|
||||
TopologyNode from = topology.node(nodes.get(i - 1));
|
||||
TopologyNode to = topology.node(nodes.get(i));
|
||||
int directionX = Integer.compare(to.getX(), from.getX());
|
||||
int directionY = Integer.compare(to.getY(), from.getY());
|
||||
if (i > 1 && (previousX != directionX || previousY != directionY)) {
|
||||
turns.add(nodes.get(i - 1));
|
||||
}
|
||||
previousX = directionX;
|
||||
previousY = directionY;
|
||||
}
|
||||
return new RoutePlan(nodes, resources, distanceCost, turns.size(), topology.version(), turns);
|
||||
}
|
||||
|
||||
public boolean hasTurnGeometry() {
|
||||
return turnNodeIds != null;
|
||||
}
|
||||
|
||||
public List<String> getTurnNodeIds() {
|
||||
if (turnNodeIds == null) throw new IllegalStateException("route turn geometry is unavailable");
|
||||
return turnNodeIds;
|
||||
}
|
||||
|
||||
public List<String> getNodeIds() {
|
||||
|
||||
@@ -28,6 +28,21 @@ class DefaultVehicleAssignmentPolicyTest {
|
||||
private final VehicleAssignmentPolicy policy = new DefaultVehicleAssignmentPolicy(
|
||||
new AStarRoutePlanner(), DispatchPolicy.NONE);
|
||||
|
||||
@Test
|
||||
void returningOverApproachRouteDoesNotDuplicateReservationResources() {
|
||||
DispatchTask task = new DispatchTask("O1", "S1", "A", "B", 10, 60L, 30,
|
||||
Collections.<DeviceCapability>emptySet());
|
||||
VehicleAssignmentDecision assigned = policy.assign(task,
|
||||
Collections.singletonList(vehicle("V1", "C", 90, true,
|
||||
VehicleSnapshot.ConnectionState.ONLINE)), topology);
|
||||
assertEquals(Arrays.asList("C", "B", "A", "B"), assigned.getRoute().getNodeIds());
|
||||
assertEquals(5, assigned.getRoute().getResourceKeys().size());
|
||||
com.ruoyi.wcs.domain.traffic.ReservationRequest request =
|
||||
new com.ruoyi.wcs.domain.traffic.ReservationRequest("R1", "O1", "S1", "V1",
|
||||
assigned.getRoute().getResourceKeys());
|
||||
assertTrue(new com.ruoyi.wcs.domain.traffic.ResourceLedger(topology).reserve(request).isGranted());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCloserVehicleWithoutRequiredCapability() {
|
||||
VehicleAssignmentDecision decision = policy.assign(taskRequiringLoad(),
|
||||
|
||||
Reference in New Issue
Block a user