fix(shadow): protect observed vehicle traffic

This commit is contained in:
zgw
2026-09-09 15:42:09 +08:00
parent edb13450a6
commit 4408958443
12 changed files with 545 additions and 4 deletions

View File

@@ -102,6 +102,16 @@ public final class DispatchCoordinator {
VehicleSnapshot vehicle = removeVehicle(
available, assignment.getVehicleId());
Set<String> observedBlockers = snapshot.observedBlockers(
assignment.getRoute().getResourceKeys(), assignment.getVehicleId());
if (!observedBlockers.isEmpty()) {
held.add(new HeldDispatch(task.getOrderId(), task.getStepId(),
assignment.getVehicleId(), TrafficDecisionType.HOLD,
"OBSERVED_TRAFFIC_CONFLICT", observedBlockers, null, null));
events.add(event(task, assignment.getVehicleId(), TrafficDecisionType.HOLD,
"OBSERVED_TRAFFIC_CONFLICT", snapshot));
continue;
}
String reservationId = requireGeneratedId(idGenerator.nextId());
ReservationRequest request = new ReservationRequest(reservationId,
task.getOrderId(), task.getStepId(), assignment.getVehicleId(),
@@ -109,6 +119,13 @@ public final class DispatchCoordinator {
TrafficDecision traffic = cycleGuard.decide(new TrafficCandidate(
request, vehicle.getLocationNodeId(), assignment.getRoute()),
ledger, snapshot.getTopology());
if (traffic.getType() == TrafficDecisionType.YIELD) {
Set<String> yieldBlockers = snapshot.observedBlockers(
traffic.getYieldRoute().getResourceKeys(), assignment.getVehicleId());
if (!yieldBlockers.isEmpty()) {
traffic = TrafficDecision.hold("OBSERVED_TRAFFIC_CONFLICT", yieldBlockers);
}
}
if (traffic.getType() == TrafficDecisionType.GRANT) {
ReservationDecision reservation = traffic.getReservationDecision();
granted.add(new GrantedDispatch(task.getOrderId(), task.getStepId(),

View File

@@ -10,6 +10,10 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Map;
import java.util.Set;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
public final class DispatchSnapshot {
private final List<DispatchTask> readySteps;
@@ -18,6 +22,7 @@ public final class DispatchSnapshot {
private final ResourceLedger resourceLedger;
private final WaitForGraph waitForGraph;
private final long snapshotVersion;
private final Map<String, Set<String>> observedResourceOwners;
public DispatchSnapshot(List<DispatchTask> readySteps,
List<VehicleSnapshot> vehicleSnapshots,
@@ -34,6 +39,17 @@ public final class DispatchSnapshot {
ResourceLedger resourceLedger,
WaitForGraph waitForGraph,
long snapshotVersion) {
this(readySteps, vehicleSnapshots, topology, resourceLedger, waitForGraph,
snapshotVersion, Collections.<String, Set<String>>emptyMap());
}
public DispatchSnapshot(List<DispatchTask> readySteps,
List<VehicleSnapshot> vehicleSnapshots,
EffectiveTopology topology,
ResourceLedger resourceLedger,
WaitForGraph waitForGraph,
long snapshotVersion,
Map<String, Set<String>> observedResourceOwners) {
this.readySteps = immutable(readySteps, "readySteps");
this.vehicleSnapshots = immutable(vehicleSnapshots, "vehicleSnapshots");
this.topology = Objects.requireNonNull(topology, "topology");
@@ -44,6 +60,22 @@ public final class DispatchSnapshot {
throw new IllegalArgumentException("snapshotVersion must not be negative");
}
this.snapshotVersion = snapshotVersion;
Map<String, Set<String>> owners = new LinkedHashMap<String, Set<String>>();
for (Map.Entry<String, Set<String>> entry : Objects.requireNonNull(
observedResourceOwners, "observedResourceOwners").entrySet()) {
if (topology.resource(entry.getKey()) == null) {
throw new IllegalArgumentException("unknown observed resource: " + entry.getKey());
}
Set<String> vehicles = new LinkedHashSet<String>();
for (String owner : Objects.requireNonNull(entry.getValue(), "resource owners")) {
if (owner == null || owner.trim().isEmpty()) {
throw new IllegalArgumentException("observed vehicle id must not be blank");
}
vehicles.add(owner.trim());
}
owners.put(entry.getKey(), Collections.unmodifiableSet(vehicles));
}
this.observedResourceOwners = Collections.unmodifiableMap(owners);
}
public List<DispatchTask> getReadySteps() {
@@ -70,6 +102,17 @@ public final class DispatchSnapshot {
return snapshotVersion;
}
/** Observation-only constraints, deliberately excluded from the persistent ledger. */
public Set<String> observedBlockers(List<String> resourceKeys, String vehicleId) {
Set<String> blockers = new LinkedHashSet<String>();
for (String key : resourceKeys) {
Set<String> owners = observedResourceOwners.get(key);
if (owners != null) blockers.addAll(owners);
}
blockers.remove(vehicleId);
return Collections.unmodifiableSet(blockers);
}
private static <T> List<T> immutable(List<T> source, String field) {
if (source == null) {
throw new IllegalArgumentException(field + " must not be null");

View File

@@ -27,11 +27,14 @@ import java.util.Collections;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.LinkedHashSet;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
class DispatchCoordinatorTest {
@@ -100,6 +103,63 @@ class DispatchCoordinatorTest {
result.getGranted().get(0).getCommandParameters());
}
@Test
void oldSnapshotConstructorsRemainUnconstrainedAndObservationCopiesCannotBeMutated() {
EffectiveTopology topology = topology();
Set<String> owners = new LinkedHashSet<String>(Collections.singleton("V2"));
Map<String, Set<String>> observations = new LinkedHashMap<String, Set<String>>();
observations.put("NODE:X", owners);
DispatchSnapshot guarded = new DispatchSnapshot(Collections.singletonList(task("O1", "S1", "A", 1)),
Collections.singletonList(vehicle("V1", "A")), topology, new ResourceLedger(topology),
new WaitForGraph(), 11L, observations);
owners.clear();
observations.clear();
assertThrows(UnsupportedOperationException.class, () -> guarded.observedBlockers(
Collections.singletonList("NODE:X"), "V1").clear());
RecordingTrafficRepository repository = new RecordingTrafficRepository(true);
assertEquals("OBSERVED_TRAFFIC_CONFLICT", coordinator(repository).dispatch(guarded).getHeld().get(0).getReasonCode());
assertTrue(repository.lastCommit.getResultingLedger().snapshotState().getReservations().isEmpty());
assertEquals(1, coordinator(repository).dispatch(new DispatchSnapshot(guarded.getReadySteps(),
guarded.getVehicleSnapshots(), topology, guarded.workingLedger(), 11L)).getGranted().size());
assertEquals(1, coordinator(repository).dispatch(new DispatchSnapshot(guarded.getReadySteps(),
guarded.getVehicleSnapshots(), topology, guarded.workingLedger(), new WaitForGraph(), 11L)).getGranted().size());
}
@Test
void invalidObservationKeysAreRejectedRatherThanSilentlyIgnored() {
EffectiveTopology topology = topology();
assertThrows(IllegalArgumentException.class, () -> new DispatchSnapshot(Collections.emptyList(),
Collections.emptyList(), topology, new ResourceLedger(topology), new WaitForGraph(), 0L,
Collections.singletonMap("NODE:missing", Collections.singleton("V2"))));
}
@Test
void yieldMustNotBypassObservedTrafficAndPersistedReservationsStayIntact() {
EffectiveTopology topology = new TopologyCompiler().compile(new TopologyDefinition("FT546", 7L, true,
Arrays.asList(new TopologyNode("A", "F1", 0, 0, 1, false, false),
new TopologyNode("X", "F1", 1, 0, 1, false, false),
new TopologyNode("H", "F1", 0, 1, 1, true, true),
new TopologyNode("Y", "F1", 1, 1, 1, false, false)),
Arrays.asList(new TopologyEdge("A", "X", 1, 1), new TopologyEdge("A", "H", 1, 1),
new TopologyEdge("H", "Y", 1, 1), new TopologyEdge("Y", "X", 1, 1)),
Collections.emptyList(), Collections.emptyList(), Collections.emptyList()));
ResourceLedger ledger = new ResourceLedger(topology);
ledger.reserve(new com.ruoyi.wcs.domain.traffic.ReservationRequest("existing", "O2", "S2", "V2",
Collections.singletonList("NODE:X")));
DispatchSnapshot unguarded = new DispatchSnapshot(Collections.singletonList(task("O1", "S1", "A", 1)),
Collections.singletonList(vehicle("V1", "A")), topology, ledger, 11L);
RecordingTrafficRepository repository = new RecordingTrafficRepository(true);
assertEquals("H", coordinator(repository).dispatch(unguarded).getYielded().get(0).getYieldNodeId());
DispatchCycleResult result = coordinator(repository).dispatch(new DispatchSnapshot(unguarded.getReadySteps(),
unguarded.getVehicleSnapshots(), topology, ledger, new WaitForGraph(), 11L,
Collections.singletonMap("NODE:H", Collections.singleton("V3"))));
assertTrue(result.getGranted().isEmpty());
assertTrue(result.getYielded().isEmpty());
assertEquals(Collections.singleton("V3"), result.getHeld().get(0).getBlockingVehicleIds());
assertEquals(1, repository.lastCommit.getResultingLedger().usage("NODE:X"));
assertEquals(0, repository.lastCommit.getResultingLedger().usage("NODE:H"));
}
private static DispatchCoordinator coordinator(TrafficStateRepository repository) {
AStarRoutePlanner routePlanner = new AStarRoutePlanner();
AtomicInteger sequence = new AtomicInteger();