test(ft546): verify read-only shadow operation
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user