test(ft546): verify read-only shadow operation

This commit is contained in:
zgw
2026-08-14 19:08:01 +08:00
parent 240f5b0808
commit 01bdf37b06
22 changed files with 2377 additions and 6 deletions

View File

@@ -9,6 +9,7 @@ import com.ruoyi.shadow.startup.ShadowDatabaseGate;
import com.ruoyi.shadow.startup.ShadowStartupReport;
import com.ruoyi.shadow.web.ShadowStatusController;
import com.ruoyi.shadow.kernel.Ft546ShadowCycle;
import com.ruoyi.shadow.kernel.Ft546ShadowScheduler;
import com.ruoyi.shadow.kernel.JdbcShadowTrafficStateRepository;
import com.ruoyi.shadow.kernel.RecordingFt546CommandGateway;
import com.ruoyi.shadow.kernel.ShadowDispatchSnapshotAssembler;
@@ -82,6 +83,7 @@ class Ft546ShadowApplicationTest {
assertNotNull(context.getBean(DispatchCoordinator.class));
assertNotNull(context.getBean(ReadOnlyDispatchCycle.class));
assertNotNull(context.getBean(Ft546ShadowCycle.class));
assertNotNull(context.getBean(Ft546ShadowScheduler.class));
assertNotNull(context.getBean(ShadowDispatchSnapshotAssembler.class));
assertNotNull(context.getBean(RecordingFt546CommandGateway.class));
assertNotNull(context.getBean(

View File

@@ -0,0 +1,315 @@
package com.ruoyi.shadow;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.ft546.protocol.Ft546EncodedCommand;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
import com.ruoyi.shadow.evidence.JdbcShadowEvidenceRepository;
import com.ruoyi.shadow.evidence.ShadowCycleEvidenceCollector;
import com.ruoyi.shadow.evidence.ShadowEvidenceRepository;
import com.ruoyi.shadow.evidence.ShadowEvidenceStorageHealth;
import com.ruoyi.shadow.evidence.ShadowRunEvidence;
import com.ruoyi.shadow.facts.ShadowFactBatch;
import com.ruoyi.shadow.facts.ShadowFactBatchValidator;
import com.ruoyi.shadow.facts.ShadowMapFact;
import com.ruoyi.shadow.facts.ShadowQueueFact;
import com.ruoyi.shadow.facts.ShadowVehicleFact;
import com.ruoyi.shadow.kernel.Ft546ShadowCycle;
import com.ruoyi.shadow.kernel.JdbcShadowTrafficStateRepository;
import com.ruoyi.shadow.kernel.RecordingFt546CommandGateway;
import com.ruoyi.shadow.kernel.ShadowCommandIntentFactory;
import com.ruoyi.shadow.kernel.ShadowCycleResult;
import com.ruoyi.shadow.kernel.ShadowDispatchSnapshotAssembler;
import com.ruoyi.shadow.startup.ShadowStartupReport;
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
import com.ruoyi.wcs.application.dispatch.DispatchCommit;
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.domain.traffic.ReservationDecision;
import com.ruoyi.wcs.domain.traffic.ReservationRequest;
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
import com.ruoyi.wcs.domain.traffic.RoutePlanner;
import com.ruoyi.wcs.domain.traffic.WaitForGraph;
import com.ruoyi.wcs.port.out.DispatchPolicy;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.support.TransactionTemplate;
import java.io.InputStream;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class Ft546ShadowEndToEndTest {
private static final String FIXTURES = "/fixtures/";
@Test
void ordinaryCrossFloorAndTwoStageFixturesReachVirtualPlcEvidence()
throws Exception {
ScenarioResult ordinary = run("ordinary-cycle.json");
ScenarioResult crossFloor = run("cross-floor-cycle.json");
ScenarioResult twoStage = run("two-stage-cycle.json");
assertComparable(ordinary, "4004", "4000", "4002", "4021");
assertComparable(crossFloor, "4004", "4000", "4002", "4021",
"4020", "4022");
assertComparable(twoStage, "4404", "4422", "4423", "4402",
"4400");
assertEquals(3, ordinary.persistedCycles + crossFloor.persistedCycles
+ twoStage.persistedCycles);
}
@Test
void avoidanceFixtureProducesHoldWithoutVirtualWrites() throws Exception {
ScenarioResult avoidance = run("hold-avoidance-cycle.json");
assertEquals("COMPARABLE", avoidance.cycle.getStatusCode());
assertEquals(1, avoidance.cycle.getDecisions().getHeld().size(),
"granted=" + avoidance.cycle.getDecisions().getGranted().size()
+ ", yielded=" + avoidance.cycle.getDecisions()
.getYielded().size());
assertEquals("HOLD", avoidance.cycle.getDecisions().getHeld().get(0)
.getDecisionType().name());
assertEquals(Collections.emptyList(),
avoidance.cycle.getEncodedCommands());
assertEquals(1, avoidance.persistedCycles);
}
private static ScenarioResult run(String resource) throws Exception {
ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
Scenario fixture;
try (InputStream input = Ft546ShadowEndToEndTest.class
.getResourceAsStream(FIXTURES + resource)) {
assertNotNull(input, "missing fixture " + resource);
fixture = mapper.readValue(input, Scenario.class);
}
Instant now = Instant.parse(fixture.capturedAt);
DriverManagerDataSource dataSource = new DriverManagerDataSource(
"jdbc:h2:mem:" + UUID.randomUUID()
+ ";MODE=MSSQLServer;DB_CLOSE_DELAY=-1", "sa", "");
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
createSchema(jdbc);
TransactionTemplate transaction = new TransactionTemplate(
new DataSourceTransactionManager(dataSource));
RecordingFt546CommandGateway gateway = gateway();
ShadowCommandIntentFactory intentFactory =
new ShadowCommandIntentFactory();
JdbcShadowTrafficStateRepository traffic =
new JdbcShadowTrafficStateRepository(jdbc, transaction,
mapper, Clock.fixed(now, ZoneOffset.UTC),
intentFactory, gateway);
AtomicInteger ids = new AtomicInteger();
RoutePlanner routePlanner = new AStarRoutePlanner();
DispatchCoordinator coordinator = new DispatchCoordinator(
new DefaultVehicleAssignmentPolicy(routePlanner,
DispatchPolicy.NONE),
new DeadlockGuard(new WaitForGraph(), routePlanner), traffic,
() -> "SHADOW-E2E-" + ids.incrementAndGet());
ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> dispatch =
new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(
coordinator::dispatch, intentFactory::create);
ShadowDispatchSnapshotAssembler assembler =
new ShadowDispatchSnapshotAssembler("FT546", 3L,
topology -> {
com.ruoyi.shadow.kernel.ShadowTrafficState state =
traffic.load(topology);
if (fixture.blockedNode != null
&& state.getSnapshotVersion() == 0L) {
ResourceLedger blockedLedger =
state.getLedger();
ReservationDecision blocked = blockedLedger
.reserve(new ReservationRequest(
"BLOCKER-RES", "BLOCKER-ORDER",
"BLOCKER-STEP", "VEH-BLOCKER",
Collections.singletonList(
"NODE:" + fixture.blockedNode)));
assertTrue(blocked.isGranted());
assertTrue(traffic.commit(new DispatchCommit(
"FT546", topology.version(), 0L,
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList(),
blockedLedger,
state.getWaitForGraph())));
state = traffic.load(topology);
assertEquals(0, state.getLedger()
.availableCapacity("NODE:"
+ fixture.blockedNode));
}
return state;
}, Clock.fixed(now, ZoneOffset.UTC));
UUID runId = UUID.randomUUID();
ShadowRunEvidence run = new ShadowRunEvidence(runId, now,
repeat('a'), repeat('b'), "LOCAL", "SHADOW_E2E", "sa",
"RUNNING");
ShadowEvidenceRepository evidence =
new JdbcShadowEvidenceRepository(jdbc, transaction, mapper,
run);
ShadowStartupReport report = ShadowStartupReport.starting(now,
Collections.emptyMap());
Ft546ShadowCycle cycle = new Ft546ShadowCycle(
new ShadowFactBatchValidator(Duration.ofSeconds(4),
Duration.ofSeconds(30)),
assembler::assemble, dispatch, gateway,
() -> ShadowEvidenceStorageHealth.Check.available(
Long.MAX_VALUE),
evidence, new ShadowCycleEvidenceCollector(runId, 3L, mapper),
report);
ShadowCycleResult result = cycle.run(batch(fixture, now), now);
int persisted = jdbc.queryForObject("SELECT COUNT(*) FROM shadow_cycle",
Integer.class).intValue();
return new ScenarioResult(result, persisted);
}
private static ShadowFactBatch batch(Scenario fixture, Instant now) {
ShadowQueueFact.Builder queue = ShadowQueueFact.builder(
"shadow_ro.v_wcs_srgv_queue", fixture.queueGuid)
.taskNo(fixture.taskNo)
.taskType(fixture.taskType)
.priority(Long.valueOf(fixture.priority))
.cargoPickSite(fixture.startSite)
.cargoPlaceSite(fixture.endSite)
.startSite(fixture.startSite)
.endSite(fixture.endSite)
.createdAt(now.minusSeconds(10))
.default02(fixture.protocolTaskType);
if (fixture.crossFloorTarget != null) {
queue.default07(fixture.crossFloorTarget);
}
if (fixture.stage != null) {
queue.default08(fixture.stage);
}
ShadowVehicleFact vehicle = ShadowVehicleFact.builder(
fixture.vehicleGuid, fixture.vehicleId)
.agvStatus("1")
.currentSite(fixture.vehicleSite)
.battery(80)
.workMode(5)
.alarm(0)
.status(1)
.errorCode(0)
.allowSend(1)
.currentFloor(fixture.vehicleFloor)
.capturedAt(now)
.build();
List<ShadowMapFact> maps = new ArrayList<ShadowMapFact>();
for (MapNode node : fixture.map) {
maps.add(new ShadowMapFact(node.guid, node.siteId, node.x,
node.y, node.z, node.value));
}
return ShadowFactBatch.create(now.minusSeconds(1), now,
Collections.singletonList(queue.build()),
Collections.singletonList(vehicle), maps);
}
private static void assertComparable(ScenarioResult result,
String... addresses) {
assertEquals("COMPARABLE", result.cycle.getStatusCode());
assertEquals(1, result.cycle.getDecisions().getGranted().size());
assertEquals(1, result.cycle.getEncodedCommands().size());
Ft546EncodedCommand encoded = result.cycle.getEncodedCommands().get(0);
List<String> actual = new ArrayList<String>();
encoded.getWrites().forEach(write -> actual.add(write.getAddress()));
assertEquals(Arrays.asList(addresses), actual);
}
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");
registers.put("crossFloorFlag", "4020");
registers.put("crossFloorTarget", "4022");
registers.put("secondTaskNo", "4400");
registers.put("secondTaskType", "4402");
registers.put("secondPath", "4404");
registers.put("secondStageFlag", "4422");
registers.put("secondComplete", "4423");
profile.setRegisters(registers);
Set<String> capabilities = new LinkedHashSet<String>(Arrays.asList(
"POINT_TO_POINT_COMMAND", "TWO_STAGE_ATOMIC_TASK"));
profile.setCapabilities(capabilities);
return new RecordingFt546CommandGateway(
new Ft546SrgvCommandEncoder(), profile, capabilities);
}
private static void createSchema(JdbcTemplate jdbc) {
jdbc.execute("CREATE TABLE shadow_traffic_state (state_key nvarchar(256) PRIMARY KEY,snapshot_version bigint NOT NULL,state_json nvarchar(max) NOT NULL,updated_at timestamp NOT NULL)");
jdbc.execute("CREATE TABLE shadow_hold_history (parent_task_id nvarchar(128) NOT NULL,vehicle_id nvarchar(64) NOT NULL,resource_key nvarchar(256) NOT NULL,reason_code nvarchar(512) NOT NULL,consecutive_count int NOT NULL,updated_at timestamp NOT NULL,PRIMARY KEY(parent_task_id,vehicle_id,resource_key,reason_code))");
jdbc.execute("CREATE TABLE shadow_run (run_id UUID PRIMARY KEY,started_at TIMESTAMP NOT NULL,artifact_sha256 CHAR(64) NOT NULL,configuration_sha256 CHAR(64) NOT NULL,source_server VARCHAR(256) NOT NULL,source_database VARCHAR(128) NOT NULL,source_login VARCHAR(128) NOT NULL,status VARCHAR(32) NOT NULL)");
jdbc.execute("CREATE TABLE shadow_cycle (cycle_id UUID PRIMARY KEY,run_id UUID NOT NULL,input_batch_hash CHAR(64) NOT NULL,parent_task_id VARCHAR(128) NOT NULL,phase VARCHAR(64) NOT NULL,route_version BIGINT NOT NULL,status VARCHAR(64) NOT NULL,reason_code VARCHAR(512),database_captured_at TIMESTAMP,completed_at TIMESTAMP NOT NULL,evidence_hash CHAR(64) NOT NULL,FOREIGN KEY (run_id) REFERENCES shadow_run(run_id),UNIQUE (run_id,input_batch_hash,parent_task_id,phase,route_version))");
jdbc.execute("CREATE TABLE shadow_input_batch (cycle_id UUID PRIMARY KEY,content_hash CHAR(64) NOT NULL,captured_json CLOB NOT NULL,FOREIGN KEY (cycle_id) REFERENCES shadow_cycle(cycle_id))");
jdbc.execute("CREATE TABLE shadow_legacy_queue (cycle_id UUID PRIMARY KEY,vehicle_id VARCHAR(64),path_info CLOB,result_status VARCHAR(64) NOT NULL,result_json CLOB NOT NULL,content_hash CHAR(64) NOT NULL,FOREIGN KEY (cycle_id) REFERENCES shadow_cycle(cycle_id))");
jdbc.execute("CREATE TABLE shadow_decision (cycle_id UUID PRIMARY KEY,decision VARCHAR(64) NOT NULL,vehicle_id VARCHAR(64),reason_code VARCHAR(512),decision_json CLOB NOT NULL,content_hash CHAR(64) NOT NULL,FOREIGN KEY (cycle_id) REFERENCES shadow_cycle(cycle_id))");
jdbc.execute("CREATE TABLE shadow_encoded_write (cycle_id UUID NOT NULL,write_sequence INT NOT NULL,register_address VARCHAR(16) NOT NULL,data_type VARCHAR(16) NOT NULL,values_json CLOB NOT NULL,trigger_value BIGINT,PRIMARY KEY (cycle_id,write_sequence),FOREIGN KEY (cycle_id) REFERENCES shadow_cycle(cycle_id))");
jdbc.execute("CREATE TABLE shadow_difference (difference_id BIGINT IDENTITY PRIMARY KEY,cycle_id UUID NOT NULL,category VARCHAR(64) NOT NULL,reason_code VARCHAR(512) NOT NULL,expected_json CLOB,actual_json CLOB,FOREIGN KEY (cycle_id) REFERENCES shadow_cycle(cycle_id))");
}
private static String repeat(char value) {
char[] chars = new char[64];
Arrays.fill(chars, value);
return new String(chars);
}
public static final class Scenario {
public String capturedAt;
public String queueGuid;
public String taskNo;
public String taskType;
public long priority;
public String protocolTaskType;
public String crossFloorTarget;
public String stage;
public String vehicleGuid;
public String vehicleId;
public String vehicleSite;
public int vehicleFloor;
public String startSite;
public String endSite;
public String blockedNode;
public List<MapNode> map;
}
public static final class MapNode {
public String guid;
public String siteId;
public int x;
public int y;
public int z;
public int value;
}
private static final class ScenarioResult {
private final ShadowCycleResult cycle;
private final int persistedCycles;
private ScenarioResult(ShadowCycleResult cycle, int persistedCycles) {
this.cycle = cycle;
this.persistedCycles = persistedCycles;
}
}
}

View File

@@ -0,0 +1,440 @@
package com.ruoyi.shadow;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
import com.ruoyi.shadow.evidence.ShadowCycleEvidence;
import com.ruoyi.shadow.evidence.ShadowCycleEvidenceCollector;
import com.ruoyi.shadow.evidence.ShadowEvidenceDigest;
import com.ruoyi.shadow.evidence.ShadowEvidenceException;
import com.ruoyi.shadow.evidence.ShadowEvidenceRepository;
import com.ruoyi.shadow.evidence.ShadowEvidenceStorageHealth;
import com.ruoyi.shadow.facts.ShadowFactBatchValidator;
import com.ruoyi.shadow.facts.ShadowMapFact;
import com.ruoyi.shadow.facts.ShadowQueueFact;
import com.ruoyi.shadow.facts.ShadowReadCursor;
import com.ruoyi.shadow.facts.ShadowVehicleFact;
import com.ruoyi.shadow.facts.SqlServerShadowFactReader;
import com.ruoyi.shadow.kernel.Ft546ShadowCycle;
import com.ruoyi.shadow.kernel.Ft546ShadowScheduler;
import com.ruoyi.shadow.kernel.RecordingFt546CommandGateway;
import com.ruoyi.shadow.kernel.ShadowCommandIntentFactory;
import com.ruoyi.shadow.startup.ShadowStartupReport;
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
import com.ruoyi.wcs.application.dispatch.DispatchCycleResult;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.env.EnumerablePropertySource;
import org.springframework.core.env.PropertySource;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.dao.QueryTimeoutException;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class Ft546ShadowFaultIsolationTest {
private static final Instant NOW = Instant.parse("2026-08-14T06:00:00Z");
@Test
void sourceTimeoutNeverEntersCycleOrRecordsACommand() {
MutableClock clock = new MutableClock(NOW);
AtomicInteger sourceCalls = new AtomicInteger();
SqlServerShadowFactReader reader = reader(timeoutSource(sourceCalls), clock);
RecordingEvidenceRepository evidence = new RecordingEvidenceRepository();
RecordingFt546CommandGateway gateway = gateway();
ShadowStartupReport report = report();
Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(reader,
evidenceCycle(report, evidence, gateway,
() -> ShadowEvidenceStorageHealth.Check.available(
Long.MAX_VALUE)), report, clock);
assertFalse(scheduler.runOnce());
assertEquals(1, sourceCalls.get());
assertEquals(0, evidence.transactionCalls.get());
assertEquals(Collections.emptyList(), gateway.getRecorded());
assertEquals(ShadowStartupReport.Status.DEGRADED, report.getStatus());
assertEquals("SOURCE_QUERY_TIMEOUT", report.getReasonCode());
assertEquals(Duration.ofSeconds(1), scheduler.getRemainingBackoff());
}
@Test
void evidenceFailurePermanentlyStopsLaterSourceReads() {
MutableClock clock = new MutableClock(NOW);
AtomicInteger sourceCalls = new AtomicInteger();
RecordingEvidenceRepository evidence = new RecordingEvidenceRepository();
evidence.saveFailure = new ShadowEvidenceException(
"EVIDENCE_WRITE_FAILED");
RecordingFt546CommandGateway gateway = gateway();
ShadowStartupReport report = report();
Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(
reader(emptySource(sourceCalls), clock),
evidenceCycle(report, evidence, gateway,
() -> ShadowEvidenceStorageHealth.Check.available(
Long.MAX_VALUE)), report, clock);
assertTrue(scheduler.runOnce());
int callsAfterFailure = sourceCalls.get();
clock.advance(Duration.ofMinutes(1));
assertFalse(scheduler.runOnce());
assertEquals(callsAfterFailure, sourceCalls.get());
assertEquals(1, evidence.transactionCalls.get());
assertEquals(Collections.emptyList(), gateway.getRecorded());
assertEquals("EVIDENCE_STORAGE_UNAVAILABLE", report.getReasonCode());
assertTrue(scheduler.isPermanentlyStopped());
}
@Test
void shadowCrashBacksOffWithoutRecordingCommands() {
MutableClock clock = new MutableClock(NOW);
AtomicInteger sourceCalls = new AtomicInteger();
RecordingEvidenceRepository evidence = new RecordingEvidenceRepository();
RecordingFt546CommandGateway gateway = gateway();
ShadowStartupReport report = report();
Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(
reader(emptySource(sourceCalls), clock),
evidenceCycle(report, evidence, gateway,
() -> { throw new IllegalStateException("disk probe crash"); }),
report, clock);
assertFalse(scheduler.runOnce());
int callsAfterCrash = sourceCalls.get();
assertFalse(scheduler.runOnce());
assertEquals(callsAfterCrash, sourceCalls.get());
assertEquals(Collections.emptyList(), gateway.getRecorded());
assertEquals("SHADOW_CYCLE_FAILED", report.getReasonCode());
assertEquals(Duration.ofSeconds(1), scheduler.getRemainingBackoff());
}
@Test
void blockingShadowCycleTimesOutAndPermanentlyStopsCollection()
throws Exception {
MutableClock clock = new MutableClock(NOW);
AtomicInteger sourceCalls = new AtomicInteger();
RecordingEvidenceRepository evidence = new RecordingEvidenceRepository();
RecordingFt546CommandGateway gateway = gateway();
ShadowStartupReport report = report();
CountDownLatch cycleEntered = new CountDownLatch(1);
CountDownLatch releaseCycle = new CountDownLatch(1);
ShadowEvidenceStorageHealth blockingHealth = () -> {
cycleEntered.countDown();
boolean interrupted = false;
while (true) {
try {
releaseCycle.await();
break;
} catch (InterruptedException ignored) {
interrupted = true;
}
}
if (interrupted) {
Thread.currentThread().interrupt();
}
return ShadowEvidenceStorageHealth.Check.available(Long.MAX_VALUE);
};
Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(
reader(emptySource(sourceCalls), clock),
evidenceCycle(report, evidence, gateway, blockingHealth),
report, clock, Duration.ofSeconds(1));
Thread delayedRelease = new Thread(() -> {
try {
Thread.sleep(2500L);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
} finally {
releaseCycle.countDown();
}
}, "shadow-cycle-test-release");
delayedRelease.setDaemon(true);
delayedRelease.start();
long startedAt = System.nanoTime();
try {
assertFalse(scheduler.runOnce());
long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(
System.nanoTime() - startedAt);
int callsAfterTimeout = sourceCalls.get();
assertTrue(cycleEntered.await(0L, TimeUnit.MILLISECONDS));
assertTrue(elapsedMillis < 1800L,
"runOnce must not wait for the blocked cycle");
assertTrue(scheduler.isPermanentlyStopped());
assertEquals(ShadowStartupReport.Status.DEGRADED,
report.getStatus());
assertEquals("SHADOW_CYCLE_TIMEOUT", report.getReasonCode());
assertFalse(scheduler.runOnce());
assertEquals(callsAfterTimeout, sourceCalls.get());
assertEquals(Collections.emptyList(), gateway.getRecorded());
report.markReady(NOW.plusSeconds(1), Long.valueOf(0L));
assertEquals(ShadowStartupReport.Status.DEGRADED,
report.getStatus());
assertEquals("SHADOW_CYCLE_TIMEOUT", report.getReasonCode());
} finally {
releaseCycle.countDown();
scheduler.close();
}
}
@Test
void shippedShadowConfigurationContainsNoPlcOrWmsEndpoint()
throws Exception {
List<PropertySource<?>> sources = new YamlPropertySourceLoader().load(
"shadow", new ClassPathResource("application-shadow.yml"));
List<String> configuredKeys = new ArrayList<String>();
for (PropertySource<?> source : sources) {
if (source instanceof EnumerablePropertySource) {
configuredKeys.addAll(Arrays.asList(
((EnumerablePropertySource<?>) source)
.getPropertyNames()));
}
}
assertTrue(configuredKeys.contains("shadow.source.jdbc-url"));
assertTrue(configuredKeys.contains("shadow.evidence.jdbc-url"));
assertFalse(configuredKeys.stream().anyMatch(
Ft546ShadowFaultIsolationTest::isOutboundEndpointKey));
}
@Test
void activeQueueCannotAdvanceTheFinishHistoryCursor() {
MutableClock clock = new MutableClock(NOW);
Instant finishCreatedAt = NOW.minusSeconds(30);
ShadowQueueFact active = queue(
"shadow_ro.v_wcs_srgv_queue", "ACTIVE-NEW", NOW);
ShadowQueueFact finished = queue(
"shadow_ro.v_wcs_srgv_queue_finish", "FINISH-NEW",
finishCreatedAt);
List<ShadowReadCursor> observed = new ArrayList<ShadowReadCursor>();
SqlServerShadowFactReader.FactSource source = new FactSourceAdapter() {
@Override
public Instant readDatabaseClock(Instant deadline) { return NOW; }
@Override
public List<ShadowQueueFact> readQueues(ShadowReadCursor cursor,
Instant deadline) {
observed.add(cursor);
return Arrays.asList(active, finished);
}
};
RecordingEvidenceRepository evidence = new RecordingEvidenceRepository();
ShadowStartupReport report = report();
Ft546ShadowScheduler scheduler = new Ft546ShadowScheduler(
reader(source, clock),
evidenceCycle(report, evidence, gateway(),
() -> ShadowEvidenceStorageHealth.Check.available(
Long.MAX_VALUE)), report, clock);
assertTrue(scheduler.runOnce());
assertTrue(scheduler.runOnce());
assertEquals(2, observed.size());
assertEquals(finishCreatedAt.minusSeconds(5),
observed.get(1).getLastCreateDate());
assertEquals("", observed.get(1).getLastGuid());
}
private static SqlServerShadowFactReader reader(
SqlServerShadowFactReader.FactSource source, Clock clock) {
DriverManagerDataSource dataSource = new DriverManagerDataSource(
"jdbc:h2:mem:" + UUID.randomUUID()
+ ";MODE=MSSQLServer;DB_CLOSE_DELAY=-1", "sa", "");
return new SqlServerShadowFactReader(source,
new TransactionTemplate(
new DataSourceTransactionManager(dataSource)), clock);
}
private static SqlServerShadowFactReader.FactSource timeoutSource(
AtomicInteger calls) {
return new FactSourceAdapter() {
@Override
public Instant readDatabaseClock(Instant deadline) {
calls.incrementAndGet();
throw new QueryTimeoutException("simulated source timeout");
}
};
}
private static SqlServerShadowFactReader.FactSource emptySource(
AtomicInteger calls) {
return new FactSourceAdapter() {
@Override
public Instant readDatabaseClock(Instant deadline) {
calls.incrementAndGet();
return NOW;
}
};
}
private static Ft546ShadowCycle evidenceCycle(
ShadowStartupReport report, RecordingEvidenceRepository evidence,
RecordingFt546CommandGateway gateway,
ShadowEvidenceStorageHealth storageHealth) {
return new Ft546ShadowCycle(
new ShadowFactBatchValidator(Duration.ofSeconds(4),
Duration.ofSeconds(30)),
batch -> { throw new AssertionError("empty facts must not assemble"); },
new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(
snapshot -> DispatchCycleResult.retrySnapshotChanged(),
new ShadowCommandIntentFactory()::create),
gateway, storageHealth, evidence,
new ShadowCycleEvidenceCollector(evidence.runId, 1L,
new ObjectMapper().findAndRegisterModules()), report);
}
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);
Set<String> capabilities = new LinkedHashSet<String>(
Collections.singletonList("POINT_TO_POINT_COMMAND"));
profile.setCapabilities(capabilities);
return new RecordingFt546CommandGateway(
new Ft546SrgvCommandEncoder(), profile, capabilities);
}
private static ShadowStartupReport report() {
return ShadowStartupReport.starting(NOW, Collections.emptyMap());
}
private static boolean isOutboundEndpointKey(String key) {
String normalized = key.toLowerCase(java.util.Locale.ROOT)
.replace('-', '.').replace('_', '.');
boolean networkOwner = normalized.contains(".plc.")
|| normalized.startsWith("plc.")
|| normalized.contains(".wms.")
|| normalized.startsWith("wms.");
return networkOwner && (normalized.endsWith(".ip")
|| normalized.endsWith(".host")
|| normalized.endsWith(".port")
|| normalized.endsWith(".url"));
}
private static ShadowQueueFact queue(String table, String guid,
Instant createdAt) {
return ShadowQueueFact.builder(table, guid)
.taskNo("30001")
.taskType("2")
.priority(1L)
.startSite("70501")
.endSite("70502")
.createdAt(createdAt)
.default02("1")
.build();
}
private abstract static class FactSourceAdapter
implements SqlServerShadowFactReader.FactSource {
@Override
public List<ShadowQueueFact> readQueues(ShadowReadCursor cursor,
Instant deadline) {
return Collections.emptyList();
}
@Override
public List<ShadowVehicleFact> readVehicles(Instant deadline) {
return Collections.emptyList();
}
@Override
public List<ShadowMapFact> readMaps(Instant deadline) {
return Collections.emptyList();
}
}
private static final class MutableClock extends Clock {
private Instant now;
private MutableClock(Instant now) { this.now = now; }
private void advance(Duration duration) { now = now.plus(duration); }
@Override
public ZoneId getZone() { return ZoneOffset.UTC; }
@Override
public Clock withZone(ZoneId zone) { return this; }
@Override
public Instant instant() { return now; }
}
private static final class RecordingEvidenceRepository
implements ShadowEvidenceRepository {
private final UUID runId = UUID.randomUUID();
private final AtomicInteger transactionCalls = new AtomicInteger();
private RuntimeException saveFailure;
@Override
public SavedCycle save(ShadowCycleEvidence evidence) {
return saveAll(Collections.singletonList(evidence)).get(0);
}
@Override
public List<SavedCycle> saveAll(List<ShadowCycleEvidence> evidence) {
if (saveFailure != null) {
throw saveFailure;
}
List<SavedCycle> saved = new ArrayList<SavedCycle>();
for (ShadowCycleEvidence value : evidence) {
saved.add(new SavedCycle(value.getCycleId(),
value.getEvidenceHash(), value.getStatus(),
value.getReasonCode()));
}
return saved;
}
@Override
public <T> T inTransaction(Supplier<T> work) {
transactionCalls.incrementAndGet();
return work.get();
}
@Override
public SavedCycle findCycle(UUID runId, String inputBatchHash,
String parentTaskId, String phase,
long routeVersion) {
return null;
}
@Override
public ShadowEvidenceDigest dailyDigest(LocalDate date,
ZoneId zoneId) {
throw new UnsupportedOperationException();
}
@Override
public boolean isHealthy() { return true; }
}
}

View File

@@ -73,7 +73,7 @@ class JdbcShadowTrafficStateRepositoryTest {
}
@Test
void restartReloadPreservesReservationsWaitEdgesHoldsAndFencing() {
void restartReloadPreservesReservationsWaitEdgesAndThreeHoldCount() {
JdbcShadowTrafficStateRepository first = repository();
DispatchCommit initial = commit(0L, protectedLedger(), waitGraph(),
held("CAPACITY_CONFLICT"));
@@ -102,7 +102,17 @@ class JdbcShadowTrafficStateRepositoryTest {
Arrays.asList(twice.getConsecutiveHoldCounts().values()
.toArray(new Integer[0])));
ReservationDecision next = twice.getLedger().reserve(
assertTrue(repository().commit(commit(2L, twice.getLedger(),
twice.getWaitForGraph(), held("CAPACITY_CONFLICT"))));
ShadowTrafficState threeTimes = repository().load(topology);
assertEquals(3L, threeTimes.getSnapshotVersion());
assertEquals(Collections.singleton("VEH-2"),
threeTimes.getWaitForGraph().holdersWaitedOnBy("VEH-1"));
assertEquals(Collections.singletonList(Integer.valueOf(3)),
Arrays.asList(threeTimes.getConsecutiveHoldCounts().values()
.toArray(new Integer[0])));
ReservationDecision next = threeTimes.getLedger().reserve(
new ReservationRequest("RES-2", "ORDER-2", "STEP-2",
"VEH-2", Collections.singletonList("NODE:B")));
assertTrue(next.isGranted());