feat(ft546): run shared dispatch kernel in shadow
This commit is contained in:
@@ -4,6 +4,11 @@ import com.ruoyi.project.shadowtest.ForbiddenFactoryBean;
|
||||
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.JdbcShadowTrafficStateRepository;
|
||||
import com.ruoyi.shadow.kernel.RecordingFt546CommandGateway;
|
||||
import com.ruoyi.shadow.kernel.ShadowDispatchSnapshotAssembler;
|
||||
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
|
||||
import com.ruoyi.wcs.port.out.DeviceCommandGateway;
|
||||
import com.ruoyi.wcs.port.out.WmsNotificationGateway;
|
||||
@@ -63,6 +68,16 @@ class Ft546ShadowApplicationTest {
|
||||
assertEquals("PASS", context.getBean(ShadowStartupReport.class)
|
||||
.getStartupChecks().get("EVIDENCE_DATABASE_VERIFIED"));
|
||||
assertNotNull(context.getBean(DispatchCoordinator.class));
|
||||
assertNotNull(context.getBean(ReadOnlyDispatchCycle.class));
|
||||
assertNotNull(context.getBean(Ft546ShadowCycle.class));
|
||||
assertNotNull(context.getBean(ShadowDispatchSnapshotAssembler.class));
|
||||
assertNotNull(context.getBean(RecordingFt546CommandGateway.class));
|
||||
assertNotNull(context.getBean(
|
||||
JdbcShadowTrafficStateRepository.class));
|
||||
assertEquals(context.getBean(
|
||||
JdbcShadowTrafficStateRepository.class),
|
||||
context.getBean(com.ruoyi.wcs.port.out
|
||||
.TrafficStateRepository.class));
|
||||
assertNotNull(context.getBean(ShadowStatusController.class));
|
||||
Map<String, String> persisted = context.getBean(
|
||||
RecordingShadowDatabaseGate.class).getPersistedChecks();
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
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.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.wcs.application.ReadOnlyDispatchCycle;
|
||||
import com.ruoyi.wcs.application.ShadowDispatchResult;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchCycleResult;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
|
||||
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
|
||||
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.ResourceLedger;
|
||||
import com.ruoyi.wcs.domain.traffic.RoutePlan;
|
||||
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 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.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class Ft546ShadowCycleTest {
|
||||
|
||||
@Test
|
||||
void ordinaryGrantUsesTheLegacyMainRegisterSequence() {
|
||||
Ft546EncodedCommand encoded = encode(grant("VEH-2", points("70511", "70512"),
|
||||
parameters("1", null, null)));
|
||||
|
||||
assertEquals(Arrays.asList("4004", "4000", "4002", "4021"),
|
||||
addresses(encoded));
|
||||
assertEquals("VEH-2", encoded.getDeviceId());
|
||||
assertEquals(points(70511, 70512), encoded.getPathPoints());
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossFloorGrantAddsOnlyTheLegacyCrossFloorRegisters() {
|
||||
Ft546EncodedCommand encoded = encode(grant("VEH-3", points("70501", "70502"),
|
||||
parameters("8", "8", null)));
|
||||
|
||||
assertEquals(Arrays.asList("4004", "4000", "4002", "4021",
|
||||
"4020", "4022"), addresses(encoded));
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoStageGrantsUseTheMatchingLegacyRegisterSequences() {
|
||||
Ft546EncodedCommand first = encode(grant("VEH-1",
|
||||
points("70501", "70503"), parameters("1", null, "1")));
|
||||
Ft546EncodedCommand second = encode(grant("VEH-1",
|
||||
points("70503", "70504"), parameters("1", null, "2")));
|
||||
|
||||
assertEquals(Arrays.asList("4404", "4422", "4402", "4400"),
|
||||
addresses(first));
|
||||
assertEquals(Arrays.asList("4404", "4422", "4423", "4402", "4400"),
|
||||
addresses(second));
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedExplicitPhaseParametersFailBeforeAnythingIsRecorded() {
|
||||
RecordingFt546CommandGateway gateway = gateway();
|
||||
ShadowCommandIntentFactory factory = new ShadowCommandIntentFactory();
|
||||
GrantedDispatch invalid = grant("VEH-1", points("70501", "70502"),
|
||||
parameters("8", null, null));
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> gateway.record(factory.create(invalid)));
|
||||
assertEquals(Collections.emptyList(), gateway.getRecorded());
|
||||
}
|
||||
|
||||
@Test
|
||||
void recorderKeepsImmutableEncodedEvidenceWithoutSending() {
|
||||
RecordingFt546CommandGateway gateway = gateway();
|
||||
Ft546SrgvCommandIntent intent = new ShadowCommandIntentFactory().create(
|
||||
grant("VEH-1", points("70501", "70502"),
|
||||
parameters("1", null, null)));
|
||||
|
||||
Ft546EncodedCommand encoded = gateway.record(intent);
|
||||
|
||||
assertEquals(Collections.singletonList(encoded), gateway.getRecorded());
|
||||
assertThrows(UnsupportedOperationException.class,
|
||||
() -> gateway.getRecorded().clear());
|
||||
}
|
||||
|
||||
@Test
|
||||
void assemblerUsesOnlyActiveQueueAndIndependentVehicleFacts() {
|
||||
Instant now = Instant.parse("2026-08-14T06:00:00Z");
|
||||
ShadowFactBatch batch = ShadowFactBatch.create(now.minusSeconds(1), now,
|
||||
Arrays.asList(queue("shadow_ro.v_wcs_srgv_queue", "ACTIVE", now),
|
||||
queue("shadow_ro.v_wcs_srgv_queue_finish", "FINISH", now)),
|
||||
Collections.singletonList(vehicle(now)), maps());
|
||||
ShadowDispatchSnapshotAssembler assembler =
|
||||
new ShadowDispatchSnapshotAssembler("FT546", 3L,
|
||||
topology -> new ShadowTrafficState(
|
||||
new ResourceLedger(topology),
|
||||
new WaitForGraph(), 9L,
|
||||
Collections.emptyMap()),
|
||||
Clock.fixed(now, ZoneOffset.UTC));
|
||||
|
||||
DispatchSnapshot snapshot = assembler.assemble(batch);
|
||||
|
||||
assertEquals(1, snapshot.getReadySteps().size());
|
||||
assertEquals("ACTIVE", snapshot.getReadySteps().get(0).getStepId());
|
||||
assertEquals("70501",
|
||||
snapshot.getReadySteps().get(0).getSourceNodeId());
|
||||
assertEquals("70502",
|
||||
snapshot.getReadySteps().get(0).getDestinationNodeId());
|
||||
assertEquals("30001", snapshot.getReadySteps().get(0)
|
||||
.getCommandParameters().get("parentTaskId"));
|
||||
assertEquals(1, snapshot.getVehicleSnapshots().size());
|
||||
assertEquals("VEH-9", snapshot.getVehicleSnapshots().get(0)
|
||||
.getVehicleId());
|
||||
assertEquals(9L, snapshot.getSnapshotVersion());
|
||||
assertEquals(Arrays.asList("70502"),
|
||||
snapshot.getTopology().neighbors("70501"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidFactsProduceNoDispatchIntentOrEncodedEvidence() {
|
||||
Instant now = Instant.parse("2026-08-14T06:00:00Z");
|
||||
ShadowFactBatch invalid = ShadowFactBatch.create(now.minusSeconds(1), now,
|
||||
Collections.singletonList(
|
||||
queue("shadow_ro.v_wcs_srgv_queue", "ACTIVE", now)),
|
||||
Collections.emptyList(), maps());
|
||||
AtomicInteger dispatchCalls = new AtomicInteger();
|
||||
ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> readOnly =
|
||||
new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(
|
||||
snapshot -> {
|
||||
dispatchCalls.incrementAndGet();
|
||||
return DispatchCycleResult.retrySnapshotChanged();
|
||||
}, new ShadowCommandIntentFactory()::create);
|
||||
RecordingFt546CommandGateway gateway = gateway();
|
||||
Ft546ShadowCycle cycle = new Ft546ShadowCycle(
|
||||
new ShadowFactBatchValidator(Duration.ofSeconds(4),
|
||||
Duration.ofSeconds(30)),
|
||||
batch -> {
|
||||
throw new AssertionError("invalid facts must not assemble");
|
||||
}, readOnly, gateway);
|
||||
|
||||
ShadowCycleResult result = cycle.run(invalid, now);
|
||||
|
||||
assertEquals("INSUFFICIENT_FACTS", result.getStatusCode());
|
||||
assertEquals("MISSING_VEHICLE_FACTS", result.getReasonCode());
|
||||
assertEquals(0, dispatchCalls.get());
|
||||
assertEquals(Collections.emptyList(), result.getIntents());
|
||||
assertEquals(Collections.emptyList(), result.getEncodedCommands());
|
||||
assertEquals(Collections.emptyList(), gateway.getRecorded());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validFactsRunTheRealKernelThroughVirtualRegisterEvidence() {
|
||||
Instant now = Instant.parse("2026-08-14T06:00:00Z");
|
||||
ShadowFactBatch batch = ShadowFactBatch.create(now.minusSeconds(1), now,
|
||||
Collections.singletonList(queueWithoutLegacyAssignment(now)),
|
||||
Collections.singletonList(vehicle(now)), maps());
|
||||
AtomicInteger commitCalls = new AtomicInteger();
|
||||
AtomicInteger generatedIds = new AtomicInteger();
|
||||
RoutePlanner routePlanner = new AStarRoutePlanner();
|
||||
DispatchCoordinator coordinator = new DispatchCoordinator(
|
||||
new DefaultVehicleAssignmentPolicy(routePlanner,
|
||||
DispatchPolicy.NONE),
|
||||
new DeadlockGuard(new WaitForGraph(), routePlanner),
|
||||
commit -> {
|
||||
commitCalls.incrementAndGet();
|
||||
return true;
|
||||
},
|
||||
() -> "SHADOW-" + generatedIds.incrementAndGet());
|
||||
ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> readOnly =
|
||||
new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(
|
||||
coordinator::dispatch,
|
||||
new ShadowCommandIntentFactory()::create);
|
||||
ShadowDispatchSnapshotAssembler assembler =
|
||||
new ShadowDispatchSnapshotAssembler("FT546", 3L,
|
||||
topology -> new ShadowTrafficState(
|
||||
new ResourceLedger(topology),
|
||||
new WaitForGraph(), 0L,
|
||||
Collections.emptyMap()),
|
||||
Clock.fixed(now, ZoneOffset.UTC));
|
||||
RecordingFt546CommandGateway gateway = gateway();
|
||||
Ft546ShadowCycle cycle = new Ft546ShadowCycle(
|
||||
new ShadowFactBatchValidator(Duration.ofSeconds(4),
|
||||
Duration.ofSeconds(30)),
|
||||
assembler::assemble, readOnly, gateway);
|
||||
|
||||
ShadowCycleResult result = cycle.run(batch, now);
|
||||
|
||||
assertEquals("COMPARABLE", result.getStatusCode());
|
||||
assertEquals(1, result.getDecisions().getGranted().size());
|
||||
assertEquals("VEH-9", result.getDecisions().getGranted().get(0)
|
||||
.getVehicleId());
|
||||
assertEquals("ACTIVE", result.getDecisions().getGranted().get(0)
|
||||
.getStepId());
|
||||
assertEquals(1, commitCalls.get());
|
||||
assertEquals(1, result.getIntents().size());
|
||||
assertEquals(1, result.getEncodedCommands().size());
|
||||
assertEquals("VEH-9", result.getEncodedCommands().get(0)
|
||||
.getDeviceId());
|
||||
assertEquals(Arrays.asList("4004", "4000", "4002", "4021"),
|
||||
addresses(result.getEncodedCommands().get(0)));
|
||||
assertEquals(1, gateway.getRecorded().size());
|
||||
}
|
||||
|
||||
private static Ft546EncodedCommand encode(GrantedDispatch grant) {
|
||||
RecordingFt546CommandGateway gateway = gateway();
|
||||
Ft546SrgvCommandIntent intent =
|
||||
new ShadowCommandIntentFactory().create(grant);
|
||||
return gateway.record(intent);
|
||||
}
|
||||
|
||||
private static RecordingFt546CommandGateway gateway() {
|
||||
return new RecordingFt546CommandGateway(new Ft546SrgvCommandEncoder(),
|
||||
profile(), capabilities("POINT_TO_POINT_COMMAND",
|
||||
"TWO_STAGE_ATOMIC_TASK"));
|
||||
}
|
||||
|
||||
private static GrantedDispatch grant(String vehicleId,
|
||||
List<String> routeNodes,
|
||||
Map<String, String> parameters) {
|
||||
RoutePlan route = new RoutePlan(routeNodes,
|
||||
resourceKeys(routeNodes), 1, 0, 7L);
|
||||
return new GrantedDispatch("PARENT-1", "STEP-1", vehicleId, route,
|
||||
Collections.singletonList("RES-1"), 7L, 11L, parameters);
|
||||
}
|
||||
|
||||
private static Map<String, String> parameters(String taskType,
|
||||
String crossFloorTarget,
|
||||
String stage) {
|
||||
Map<String, String> values = new LinkedHashMap<String, String>();
|
||||
values.put("parentTaskId", "PARENT-1");
|
||||
values.put("phase", stage == null ? "LOADED_TO_PLACE"
|
||||
: "TWO_STAGE_" + stage);
|
||||
values.put("routeVersion", "3");
|
||||
values.put("taskNo", "30001");
|
||||
values.put("taskType", taskType);
|
||||
if (crossFloorTarget != null) {
|
||||
values.put("crossFloorTarget", crossFloorTarget);
|
||||
}
|
||||
if (stage != null) {
|
||||
values.put("stage", stage);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private static List<String> resourceKeys(List<String> nodes) {
|
||||
return Arrays.asList("NODE:" + nodes.get(0),
|
||||
"EDGE:" + nodes.get(0) + ':' + nodes.get(1),
|
||||
"NODE:" + nodes.get(1));
|
||||
}
|
||||
|
||||
private static Ft546DeviceProfile profile() {
|
||||
Ft546DeviceProfile profile = new Ft546DeviceProfile();
|
||||
profile.setDeviceId("SHADOW");
|
||||
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);
|
||||
profile.setCapabilities(capabilities("POINT_TO_POINT_COMMAND",
|
||||
"TWO_STAGE_ATOMIC_TASK"));
|
||||
return profile;
|
||||
}
|
||||
|
||||
private static Set<String> capabilities(String... values) {
|
||||
return new LinkedHashSet<String>(Arrays.asList(values));
|
||||
}
|
||||
|
||||
private static List<String> points(String... values) {
|
||||
return Arrays.asList(values);
|
||||
}
|
||||
|
||||
private static List<Integer> points(Integer... values) {
|
||||
return Arrays.asList(values);
|
||||
}
|
||||
|
||||
private static List<String> addresses(Ft546EncodedCommand encoded) {
|
||||
java.util.ArrayList<String> values = new java.util.ArrayList<String>();
|
||||
encoded.getWrites().forEach(write -> values.add(write.getAddress()));
|
||||
return values;
|
||||
}
|
||||
|
||||
private static ShadowQueueFact queue(String table, String guid,
|
||||
Instant createdAt) {
|
||||
return ShadowQueueFact.builder(table, guid)
|
||||
.taskNo("30001")
|
||||
.taskType("2")
|
||||
.priority(2L)
|
||||
.cargoPickSite("70501")
|
||||
.cargoPlaceSite("70502")
|
||||
.startSite("70501")
|
||||
.endSite("70502")
|
||||
.createdAt(createdAt.minusSeconds(10))
|
||||
.agvId("LEGACY-VEHICLE-MUST-NOT-BE-CANDIDATE")
|
||||
.pathInfo("99999,99998")
|
||||
.turnPath("99997")
|
||||
.default02("1")
|
||||
.build();
|
||||
}
|
||||
|
||||
private static ShadowQueueFact queueWithoutLegacyAssignment(
|
||||
Instant createdAt) {
|
||||
return ShadowQueueFact.builder("shadow_ro.v_wcs_srgv_queue", "ACTIVE")
|
||||
.taskNo("30001")
|
||||
.taskType("2")
|
||||
.priority(2L)
|
||||
.cargoPickSite("70501")
|
||||
.cargoPlaceSite("70502")
|
||||
.startSite("70501")
|
||||
.endSite("70502")
|
||||
.createdAt(createdAt.minusSeconds(10))
|
||||
.default02("1")
|
||||
.build();
|
||||
}
|
||||
|
||||
private static ShadowVehicleFact vehicle(Instant capturedAt) {
|
||||
return ShadowVehicleFact.builder("VEH-GUID", "VEH-9")
|
||||
.agvStatus("1")
|
||||
.currentSite("70501")
|
||||
.battery(80)
|
||||
.workMode(5)
|
||||
.alarm(0)
|
||||
.status(1)
|
||||
.errorCode(0)
|
||||
.allowSend(1)
|
||||
.currentFloor(1)
|
||||
.capturedAt(capturedAt)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static List<ShadowMapFact> maps() {
|
||||
return Arrays.asList(
|
||||
new ShadowMapFact("MAP-1", "70501", 0, 0, 1, 1),
|
||||
new ShadowMapFact("MAP-2", "70502", 1, 0, 1, 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package com.ruoyi.shadow.kernel;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
|
||||
import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
|
||||
import com.ruoyi.wcs.application.dispatch.DispatchCommit;
|
||||
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
|
||||
import com.ruoyi.wcs.application.dispatch.HeldDispatch;
|
||||
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyCompiler;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyDefinition;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyEdge;
|
||||
import com.ruoyi.wcs.domain.topology.TopologyNode;
|
||||
import com.ruoyi.wcs.domain.traffic.ReservationDecision;
|
||||
import com.ruoyi.wcs.domain.traffic.ReservationRequest;
|
||||
import com.ruoyi.wcs.domain.traffic.ReservationState;
|
||||
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
|
||||
import com.ruoyi.wcs.domain.traffic.RoutePlan;
|
||||
import com.ruoyi.wcs.domain.traffic.TrafficDecisionType;
|
||||
import com.ruoyi.wcs.domain.traffic.WaitForGraph;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class JdbcShadowTrafficStateRepositoryTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private TransactionTemplate transaction;
|
||||
private Clock clock;
|
||||
private EffectiveTopology topology;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
DriverManagerDataSource dataSource = new DriverManagerDataSource(
|
||||
"jdbc:h2:mem:" + UUID.randomUUID()
|
||||
+ ";MODE=MSSQLServer;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
jdbc = new JdbcTemplate(dataSource);
|
||||
transaction = new TransactionTemplate(
|
||||
new DataSourceTransactionManager(dataSource));
|
||||
clock = Clock.fixed(Instant.parse("2026-08-14T06:00:00Z"),
|
||||
ZoneOffset.UTC);
|
||||
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))");
|
||||
topology = topology();
|
||||
}
|
||||
|
||||
@Test
|
||||
void restartReloadPreservesReservationsWaitEdgesHoldsAndFencing() {
|
||||
JdbcShadowTrafficStateRepository first = repository();
|
||||
DispatchCommit initial = commit(0L, protectedLedger(), waitGraph(),
|
||||
held("CAPACITY_CONFLICT"));
|
||||
|
||||
assertTrue(first.commit(initial));
|
||||
|
||||
JdbcShadowTrafficStateRepository restarted = repository();
|
||||
ShadowTrafficState loaded = restarted.load(topology);
|
||||
assertEquals(1L, loaded.getSnapshotVersion());
|
||||
assertEquals(ReservationState.OCCUPIED,
|
||||
loaded.getLedger().reservation("RES-1").getState());
|
||||
assertTrue(loaded.getLedger().reservation("RES-1").isCommandUnknown());
|
||||
assertTrue(loaded.getLedger().reservation("RES-1")
|
||||
.isRecoveryUnresolved());
|
||||
assertEquals(Collections.singleton("VEH-2"),
|
||||
loaded.getWaitForGraph().holdersWaitedOnBy("VEH-1"));
|
||||
assertEquals(Collections.singletonList(Integer.valueOf(1)),
|
||||
Arrays.asList(loaded.getConsecutiveHoldCounts().values()
|
||||
.toArray(new Integer[0])));
|
||||
|
||||
assertTrue(restarted.commit(commit(1L, loaded.getLedger(),
|
||||
loaded.getWaitForGraph(), held("CAPACITY_CONFLICT"))));
|
||||
ShadowTrafficState twice = repository().load(topology);
|
||||
assertEquals(2L, twice.getSnapshotVersion());
|
||||
assertEquals(Collections.singletonList(Integer.valueOf(2)),
|
||||
Arrays.asList(twice.getConsecutiveHoldCounts().values()
|
||||
.toArray(new Integer[0])));
|
||||
|
||||
ReservationDecision next = twice.getLedger().reserve(
|
||||
new ReservationRequest("RES-2", "ORDER-2", "STEP-2",
|
||||
"VEH-2", Collections.singletonList("NODE:B")));
|
||||
assertTrue(next.isGranted());
|
||||
assertEquals(2L, next.getFencingToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleSnapshotVersionIsRejectedWithoutChangingStoredState() {
|
||||
JdbcShadowTrafficStateRepository repository = repository();
|
||||
DispatchCommit initial = commit(0L, protectedLedger(), waitGraph(),
|
||||
held("CAPACITY_CONFLICT"));
|
||||
assertTrue(repository.commit(initial));
|
||||
|
||||
assertFalse(repository.commit(initial));
|
||||
|
||||
assertEquals(1L, repository.load(topology).getSnapshotVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void holdWriteFailureRollsBackTrafficStateUpdate() {
|
||||
JdbcShadowTrafficStateRepository repository = repository();
|
||||
assertTrue(repository.commit(commit(0L, protectedLedger(), waitGraph(),
|
||||
held("CAPACITY_CONFLICT"))));
|
||||
String tooLong = String.join("", Collections.nCopies(600, "X"));
|
||||
|
||||
assertThrows(RuntimeException.class, () -> repository.commit(
|
||||
commit(1L, repository.load(topology).getLedger(), waitGraph(),
|
||||
held(tooLong))));
|
||||
|
||||
assertEquals(1L, repository.load(topology).getSnapshotVersion());
|
||||
assertEquals(1, jdbc.queryForObject(
|
||||
"SELECT consecutive_count FROM shadow_hold_history",
|
||||
Integer.class).intValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unencodableGrantedRouteNeverAdvancesTrafficState() {
|
||||
JdbcShadowTrafficStateRepository repository = repository();
|
||||
ListBuilder route = new ListBuilder();
|
||||
for (int index = 1; index <= 9; index++) {
|
||||
route.add(Integer.toString(70000 + index));
|
||||
}
|
||||
Map<String, String> parameters = new LinkedHashMap<String, String>();
|
||||
parameters.put("parentTaskId", "PARENT-1");
|
||||
parameters.put("phase", "LOADED_TO_PLACE");
|
||||
parameters.put("routeVersion", "3");
|
||||
parameters.put("taskNo", "30001");
|
||||
parameters.put("taskType", "1");
|
||||
GrantedDispatch invalid = new GrantedDispatch("PARENT-1", "STEP-1",
|
||||
"VEH-1", new RoutePlan(route.values,
|
||||
Collections.singletonList("NODE:A"), 8, 0, 7L),
|
||||
Collections.singletonList("RES-1"), 7L, 1L, parameters);
|
||||
DispatchCommit commit = new DispatchCommit("FT546", 7L, 0L,
|
||||
Collections.singletonList(invalid), Collections.emptyList(),
|
||||
Collections.emptyList(), Collections.emptyList(),
|
||||
new ResourceLedger(topology), new WaitForGraph());
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> repository.commit(commit));
|
||||
|
||||
assertEquals(0L, repository.load(topology).getSnapshotVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void protocolEncoderRejectionNeverAdvancesTrafficState() {
|
||||
ShadowCommandIntentFactory intentFactory =
|
||||
new ShadowCommandIntentFactory();
|
||||
Ft546DeviceProfile missingPathRegister = new Ft546DeviceProfile();
|
||||
missingPathRegister.setRegisters(Collections.emptyMap());
|
||||
RecordingFt546CommandGateway rejectingGateway =
|
||||
new RecordingFt546CommandGateway(
|
||||
new Ft546SrgvCommandEncoder(), missingPathRegister,
|
||||
Collections.singleton("POINT_TO_POINT_COMMAND"));
|
||||
JdbcShadowTrafficStateRepository repository =
|
||||
new JdbcShadowTrafficStateRepository(jdbc, transaction,
|
||||
new ObjectMapper(), clock, intentFactory,
|
||||
rejectingGateway);
|
||||
Map<String, String> parameters = new LinkedHashMap<String, String>();
|
||||
parameters.put("parentTaskId", "PARENT-1");
|
||||
parameters.put("phase", "LOADED_TO_PLACE");
|
||||
parameters.put("routeVersion", "3");
|
||||
parameters.put("taskNo", "30001");
|
||||
parameters.put("taskType", "1");
|
||||
GrantedDispatch grant = new GrantedDispatch("PARENT-1", "STEP-1",
|
||||
"VEH-1", new RoutePlan(Arrays.asList("70001", "70002"),
|
||||
Collections.singletonList("NODE:A"), 1, 0, 7L),
|
||||
Collections.singletonList("RES-1"), 7L, 1L, parameters);
|
||||
DispatchCommit commit = new DispatchCommit("FT546", 7L, 0L,
|
||||
Collections.singletonList(grant), Collections.emptyList(),
|
||||
Collections.emptyList(), Collections.emptyList(),
|
||||
new ResourceLedger(topology), new WaitForGraph());
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> repository.commit(commit));
|
||||
|
||||
assertEquals(0L, repository.load(topology).getSnapshotVersion());
|
||||
assertEquals(Collections.emptyList(), rejectingGateway.getRecorded());
|
||||
}
|
||||
|
||||
private JdbcShadowTrafficStateRepository repository() {
|
||||
ShadowCommandIntentFactory intentFactory =
|
||||
new ShadowCommandIntentFactory();
|
||||
return new JdbcShadowTrafficStateRepository(jdbc, transaction,
|
||||
new ObjectMapper(), clock, intentFactory,
|
||||
commandGateway());
|
||||
}
|
||||
|
||||
private static RecordingFt546CommandGateway commandGateway() {
|
||||
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);
|
||||
return new RecordingFt546CommandGateway(
|
||||
new Ft546SrgvCommandEncoder(), profile,
|
||||
new LinkedHashSet<String>(Arrays.asList(
|
||||
"POINT_TO_POINT_COMMAND",
|
||||
"TWO_STAGE_ATOMIC_TASK")));
|
||||
}
|
||||
|
||||
private DispatchCommit commit(long snapshotVersion, ResourceLedger ledger,
|
||||
WaitForGraph graph, HeldDispatch held) {
|
||||
return new DispatchCommit("FT546", topology.version(), snapshotVersion,
|
||||
Collections.emptyList(), Collections.singletonList(held),
|
||||
Collections.emptyList(), Collections.emptyList(), ledger, graph);
|
||||
}
|
||||
|
||||
private ResourceLedger protectedLedger() {
|
||||
ResourceLedger ledger = new ResourceLedger(topology);
|
||||
ReservationDecision reserved = ledger.reserve(new ReservationRequest(
|
||||
"RES-1", "ORDER-1", "STEP-1", "VEH-1",
|
||||
Collections.singletonList("NODE:A")));
|
||||
ledger.occupy("RES-1", reserved.getFencingToken());
|
||||
ledger.markCommandUnknown("RES-1");
|
||||
ledger.markRecoveryUnresolved("RES-1");
|
||||
return ledger;
|
||||
}
|
||||
|
||||
private static WaitForGraph waitGraph() {
|
||||
WaitForGraph graph = new WaitForGraph();
|
||||
graph.addWait("VEH-1", "VEH-2");
|
||||
return graph;
|
||||
}
|
||||
|
||||
private static HeldDispatch held(String reason) {
|
||||
return new HeldDispatch("PARENT-1", "STEP-1", "VEH-1",
|
||||
TrafficDecisionType.HOLD, reason,
|
||||
new LinkedHashSet<String>(Collections.singleton("VEH-2")),
|
||||
null, null);
|
||||
}
|
||||
|
||||
private static EffectiveTopology topology() {
|
||||
return new TopologyCompiler().compile(new TopologyDefinition("FT546",
|
||||
7L, true,
|
||||
Arrays.asList(
|
||||
new TopologyNode("A", "F1", 0, 0, 1, false, false),
|
||||
new TopologyNode("B", "F1", 1, 0, 1, false, false)),
|
||||
Collections.singletonList(new TopologyEdge("A", "B", 1, 1)),
|
||||
Collections.emptyList(), Collections.emptyList(),
|
||||
Collections.emptyList()));
|
||||
}
|
||||
|
||||
private static final class ListBuilder {
|
||||
private final java.util.List<String> values =
|
||||
new java.util.ArrayList<String>();
|
||||
|
||||
private void add(String value) {
|
||||
values.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user