feat(javaTest): arbitrate corridor direction ownership

This commit is contained in:
zgw
2026-07-31 14:14:30 +08:00
parent a4e63ebb9a
commit 1ede0c7fc9
4 changed files with 227 additions and 33 deletions

View File

@@ -0,0 +1,155 @@
package com.ft546.wcs.shadow.history.simulation;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public final class CorridorReservationManager {
private final Map<String, EdgeReservation> edges =
new LinkedHashMap<String, EdgeReservation>();
private final Map<String, Set<String>> taskEdges =
new LinkedHashMap<String, Set<String>>();
private final Map<String, Rank> ranks = new LinkedHashMap<String, Rank>();
public ReservationDecision reserve(String taskId, List<String> route,
int effectivePriority, long arrivalMillis) {
if (taskId == null || taskId.trim().isEmpty() || route == null || route.isEmpty()) {
throw new IllegalArgumentException("task id and route are required");
}
Rank rank = new Rank(taskId, effectivePriority, arrivalMillis);
for (int i = 1; i < route.size(); i++) {
EdgeRequest request = request(route.get(i - 1), route.get(i));
EdgeReservation existing = edges.get(request.edgeId);
if (existing != null && existing.direction != request.direction) {
return ReservationDecision.denied(bestOwner(existing.holders),
"CORRIDOR_DIRECTION_OWNED");
}
}
ranks.put(taskId, rank);
Set<String> reserved = taskEdges.computeIfAbsent(
taskId, ignored -> new LinkedHashSet<String>());
for (int i = 1; i < route.size(); i++) {
EdgeRequest request = request(route.get(i - 1), route.get(i));
EdgeReservation reservation = edges.computeIfAbsent(
request.edgeId, ignored -> new EdgeReservation(request.direction));
reservation.holders.add(taskId);
reserved.add(request.edgeId);
}
return ReservationDecision.granted();
}
public void releasePassedEdge(String taskId, String from, String to) {
String edgeId = request(from, to).edgeId;
EdgeReservation reservation = edges.get(edgeId);
if (reservation != null) {
reservation.holders.remove(taskId);
if (reservation.holders.isEmpty()) edges.remove(edgeId);
}
Set<String> reserved = taskEdges.get(taskId);
if (reserved != null) {
reserved.remove(edgeId);
if (reserved.isEmpty()) {
taskEdges.remove(taskId);
ranks.remove(taskId);
}
}
}
public void releaseTask(String taskId) {
Set<String> reserved = taskEdges.remove(taskId);
if (reserved != null) {
for (String edgeId : new ArrayList<String>(reserved)) {
EdgeReservation reservation = edges.get(edgeId);
if (reservation == null) continue;
reservation.holders.remove(taskId);
if (reservation.holders.isEmpty()) edges.remove(edgeId);
}
}
ranks.remove(taskId);
}
public String ownerFor(String left, String right) {
EdgeReservation reservation = edges.get(request(left, right).edgeId);
return reservation == null ? null : bestOwner(reservation.holders);
}
private String bestOwner(Set<String> holders) {
List<Rank> candidates = new ArrayList<Rank>();
for (String holder : holders) {
Rank rank = ranks.get(holder);
if (rank != null) candidates.add(rank);
}
if (candidates.isEmpty()) return null;
Collections.sort(candidates, Comparator.comparingInt((Rank value) -> value.priority)
.thenComparingLong(value -> value.arrivalMillis)
.thenComparing(value -> value.taskId));
return candidates.get(0).taskId;
}
private EdgeRequest request(String from, String to) {
if (from == null || to == null || from.equals(to)) {
throw new IllegalArgumentException("corridor edge endpoints must differ");
}
boolean canonical = from.compareTo(to) <= 0;
return new EdgeRequest(canonical ? from + "|" + to : to + "|" + from,
canonical ? 1 : -1);
}
public static final class ReservationDecision {
private final boolean granted;
private final String ownerTaskId;
private final String reason;
private ReservationDecision(boolean granted, String ownerTaskId, String reason) {
this.granted = granted;
this.ownerTaskId = ownerTaskId;
this.reason = reason;
}
private static ReservationDecision granted() {
return new ReservationDecision(true, null, "GRANTED");
}
private static ReservationDecision denied(String ownerTaskId, String reason) {
return new ReservationDecision(false, ownerTaskId, reason);
}
public boolean isGranted() { return granted; }
public String getOwnerTaskId() { return ownerTaskId; }
public String getReason() { return reason; }
}
private static final class EdgeReservation {
private final int direction;
private final Set<String> holders = new LinkedHashSet<String>();
private EdgeReservation(int direction) { this.direction = direction; }
}
private static final class EdgeRequest {
private final String edgeId;
private final int direction;
private EdgeRequest(String edgeId, int direction) {
this.edgeId = edgeId;
this.direction = direction;
}
}
private static final class Rank {
private final String taskId;
private final int priority;
private final long arrivalMillis;
private Rank(String taskId, int priority, long arrivalMillis) {
this.taskId = taskId;
this.priority = priority;
this.arrivalMillis = arrivalMillis;
}
}
}

View File

@@ -74,6 +74,7 @@ public final class HistoricalDispatchSimulator {
IdempotentWmsCompletionReceiver wms = new IdempotentWmsCompletionReceiver(); IdempotentWmsCompletionReceiver wms = new IdempotentWmsCompletionReceiver();
GridPathPlanner planner = new GridPathPlanner(); GridPathPlanner planner = new GridPathPlanner();
VehicleAssignmentService assignment = new VehicleAssignmentService(); VehicleAssignmentService assignment = new VehicleAssignmentService();
CorridorReservationManager reservations = new CorridorReservationManager();
long now = 0L; long now = 0L;
long tick = 0L; long tick = 0L;
@@ -82,13 +83,13 @@ public final class HistoricalDispatchSimulator {
config.getNoProgressLimit(), terminal.size()); config.getNoProgressLimit(), terminal.size());
while (terminal.size() < expected && tick < config.getTickLimit()) { while (terminal.size() < expected && tick < config.getTickLimit()) {
applyVehicleEvents(tick, now, faults, vehicles, active, applyVehicleEvents(tick, now, faults, vehicles, active,
queue, executions, events); queue, executions, reservations, events);
assignReadyTasks(tick, now, input, queue, vehicles, active, assignReadyTasks(tick, now, input, queue, vehicles, active,
executions, terminal, planner, assignment, plc, events); executions, terminal, planner, assignment, plc, reservations, events);
Set<String> traversedEdges = new LinkedHashSet<String>(); Set<String> traversedEdges = new LinkedHashSet<String>();
advanceActiveTasks(tick, now, input, config, queue, vehicles, advanceActiveTasks(tick, now, input, config, queue, vehicles,
active, executions, terminal, planner, ledger, plc, archive, wms, active, executions, terminal, planner, ledger, plc, archive, wms,
invariants, events, traversedEdges); invariants, reservations, events, traversedEdges);
verifyLedger(ledger, invariants); verifyLedger(ledger, invariants);
if (watchdog.observe(terminal.size())) { if (watchdog.observe(terminal.size())) {
@@ -139,6 +140,7 @@ public final class HistoricalDispatchSimulator {
Map<String, ActiveTask> active, Map<String, ActiveTask> active,
SimulationTaskQueue queue, SimulationTaskQueue queue,
Map<String, Execution> executions, Map<String, Execution> executions,
CorridorReservationManager reservations,
List<SimulationTickEvent> events) { List<SimulationTickEvent> events) {
boolean changed = false; boolean changed = false;
for (DeterministicFaultPlan.VehicleEvent event : faults.eventsAt(tick)) { for (DeterministicFaultPlan.VehicleEvent event : faults.eventsAt(tick)) {
@@ -161,6 +163,7 @@ public final class HistoricalDispatchSimulator {
if (!vehicle.isAssignmentEligible()) { if (!vehicle.isAssignmentEligible()) {
ActiveTask stopped = active.remove(vehicle.getVehicleId()); ActiveTask stopped = active.remove(vehicle.getVehicleId());
if (stopped != null) { if (stopped != null) {
reservations.releaseTask(stopped.execution.task.getSimulationTaskId());
queue.requeue(stopped.execution.task); queue.requeue(stopped.execution.task);
hold(stopped.execution, event.getType().name()); hold(stopped.execution, event.getType().name());
events.add(event(tick, now, "HOLD", stopped.execution.task.getSimulationTaskId(), events.add(event(tick, now, "HOLD", stopped.execution.task.getSimulationTaskId(),
@@ -181,6 +184,7 @@ public final class HistoricalDispatchSimulator {
GridPathPlanner planner, GridPathPlanner planner,
VehicleAssignmentService assignment, VehicleAssignmentService assignment,
SimulatedPlcGateway plc, SimulatedPlcGateway plc,
CorridorReservationManager reservations,
List<SimulationTickEvent> events) { List<SimulationTickEvent> events) {
boolean progress = false; boolean progress = false;
List<HistoricalSimulationTask> ready = new ArrayList<HistoricalSimulationTask>( List<HistoricalSimulationTask> ready = new ArrayList<HistoricalSimulationTask>(
@@ -233,10 +237,14 @@ public final class HistoricalDispatchSimulator {
List<String> route = new ArrayList<String>(approaches.get(vehicleId)); List<String> route = new ArrayList<String>(approaches.get(vehicleId));
for (int i = 1; i < servicePath.size(); i++) route.add(servicePath.get(i)); for (int i = 1; i < servicePath.size(); i++) route.add(servicePath.get(i));
Execution execution = executions.get(task.getSimulationTaskId()); Execution execution = executions.get(task.getSimulationTaskId());
if (hasOppositeRouteConflict(route, active.values())) { CorridorReservationManager.ReservationDecision reservation = reservations.reserve(
hold(execution, "OPPOSITE_ROUTE_CONFLICT"); task.getSimulationTaskId(), route,
queue.effectivePriorityFor(task, now),
queue.arrivalMillis(task.getSimulationTaskId()));
if (!reservation.isGranted()) {
hold(execution, reservation.getReason());
events.add(event(tick, now, "HOLD", task.getSimulationTaskId(), vehicleId, events.add(event(tick, now, "HOLD", task.getSimulationTaskId(), vehicleId,
"OPPOSITE_ROUTE_CONFLICT", route.get(0), route.get(route.size() - 1))); reservation.getReason(), route.get(0), route.get(route.size() - 1)));
continue; continue;
} }
if (execution.firstAssignedMillis == null) execution.firstAssignedMillis = now; if (execution.firstAssignedMillis == null) execution.firstAssignedMillis = now;
@@ -252,27 +260,6 @@ public final class HistoricalDispatchSimulator {
return progress; return progress;
} }
private boolean hasOppositeRouteConflict(List<String> proposed,
Iterable<ActiveTask> activeTasks) {
Set<String> directions = directedEdges(proposed, 0);
for (ActiveTask activeTask : activeTasks) {
Set<String> activeDirections = directedEdges(activeTask.route, activeTask.index);
for (String direction : directions) {
String[] pair = direction.split("->", -1);
if (activeDirections.contains(pair[1] + "->" + pair[0])) return true;
}
}
return false;
}
private Set<String> directedEdges(List<String> path, int startIndex) {
Set<String> edges = new LinkedHashSet<String>();
for (int i = Math.max(1, startIndex + 1); i < path.size(); i++) {
edges.add(path.get(i - 1) + "->" + path.get(i));
}
return edges;
}
private boolean fleetCanReach(HistoricalSimulationTask task, private boolean fleetCanReach(HistoricalSimulationTask task,
Map<String, SimulatedVehicleRuntime> vehicles, Map<String, SimulatedVehicleRuntime> vehicles,
HistoricalSimulationInput input, HistoricalSimulationInput input,
@@ -298,6 +285,7 @@ public final class HistoricalDispatchSimulator {
SimulationArchive archive, SimulationArchive archive,
IdempotentWmsCompletionReceiver wms, IdempotentWmsCompletionReceiver wms,
SimulationInvariantSummary invariants, SimulationInvariantSummary invariants,
CorridorReservationManager reservations,
List<SimulationTickEvent> events, List<SimulationTickEvent> events,
Set<String> traversedEdges) { Set<String> traversedEdges) {
boolean progress = false; boolean progress = false;
@@ -310,7 +298,7 @@ public final class HistoricalDispatchSimulator {
if (!vehicle.isAssignmentEligible()) continue; if (!vehicle.isAssignmentEligible()) continue;
if (current.index >= current.route.size() - 1) { if (current.index >= current.route.size() - 1) {
complete(tick, now, current, vehicle, active, terminal, plc, complete(tick, now, current, vehicle, active, terminal, plc,
archive, wms, invariants, events); archive, wms, invariants, reservations, events);
progress = true; progress = true;
continue; continue;
} }
@@ -321,7 +309,7 @@ public final class HistoricalDispatchSimulator {
String reverse = to + "->" + from; String reverse = to + "->" + from;
if (traversedEdges.contains(reverse) || traversedEdges.contains(direction)) { if (traversedEdges.contains(reverse) || traversedEdges.contains(direction)) {
recordHold(tick, now, current, vehicle, active, queue, recordHold(tick, now, current, vehicle, active, queue,
"OPPOSITE_EDGE_WINDOW", events); "OPPOSITE_EDGE_WINDOW", reservations, events);
continue; continue;
} }
List<ResourceKey> resources = Arrays.asList( List<ResourceKey> resources = Arrays.asList(
@@ -347,14 +335,14 @@ public final class HistoricalDispatchSimulator {
ShadowDecision decision = decisionEvent.getDecisions().get(0); ShadowDecision decision = decisionEvent.getDecisions().get(0);
if (decision.getType() != DecisionType.GRANT) { if (decision.getType() != DecisionType.GRANT) {
recordHold(tick, now, current, vehicle, active, queue, recordHold(tick, now, current, vehicle, active, queue,
decision.getReasonCode(), events); decision.getReasonCode(), reservations, events);
continue; continue;
} }
ResourceLedger.ProtectionResult protection = ledger.evaluate(vehicleId, resources); ResourceLedger.ProtectionResult protection = ledger.evaluate(vehicleId, resources);
if (!protection.isGranted()) { if (!protection.isGranted()) {
invariants.capacityViolation(); invariants.capacityViolation();
recordHold(tick, now, current, vehicle, active, queue, recordHold(tick, now, current, vehicle, active, queue,
"AUTHORITATIVE_RESOURCE_REJECTED", events); "AUTHORITATIVE_RESOURCE_REJECTED", reservations, events);
continue; continue;
} }
SimulatedPlcGateway.PlcResponse sent = plc.send( SimulatedPlcGateway.PlcResponse sent = plc.send(
@@ -369,6 +357,7 @@ public final class HistoricalDispatchSimulator {
if (plc.attemptsFor(current.execution.task.getSimulationTaskId()) if (plc.attemptsFor(current.execution.task.getSimulationTaskId())
% config.getRetryLimit() == 0) { % config.getRetryLimit() == 0) {
active.remove(vehicleId); active.remove(vehicleId);
reservations.releaseTask(current.execution.task.getSimulationTaskId());
queue.requeue(current.execution.task); queue.requeue(current.execution.task);
hold(current.execution, "RETRY_EXHAUSTED_REDISPATCH"); hold(current.execution, "RETRY_EXHAUSTED_REDISPATCH");
events.add(event(tick, now, "HOLD", events.add(event(tick, now, "HOLD",
@@ -381,6 +370,8 @@ public final class HistoricalDispatchSimulator {
Site nextSite = input.getGridMap().getSite(to); Site nextSite = input.getGridMap().getSite(to);
vehicle.moveTo(to, nextSite.getFloor()); vehicle.moveTo(to, nextSite.getFloor());
ledger.release(vehicleId, Arrays.asList(ResourceKey.node(from), edge)); ledger.release(vehicleId, Arrays.asList(ResourceKey.node(from), edge));
reservations.releasePassedEdge(
current.execution.task.getSimulationTaskId(), from, to);
traversedEdges.add(direction); traversedEdges.add(direction);
current.index++; current.index++;
current.holdStreak = 0; current.holdStreak = 0;
@@ -390,7 +381,7 @@ public final class HistoricalDispatchSimulator {
progress = true; progress = true;
if (current.index >= current.route.size() - 1) { if (current.index >= current.route.size() - 1) {
complete(tick, now, current, vehicle, active, terminal, plc, complete(tick, now, current, vehicle, active, terminal, plc,
archive, wms, invariants, events); archive, wms, invariants, reservations, events);
} }
} }
return progress; return progress;
@@ -406,6 +397,7 @@ public final class HistoricalDispatchSimulator {
SimulatedVehicleRuntime vehicle, SimulatedVehicleRuntime vehicle,
Map<String, ActiveTask> active, Map<String, ActiveTask> active,
SimulationTaskQueue queue, String reason, SimulationTaskQueue queue, String reason,
CorridorReservationManager reservations,
List<SimulationTickEvent> events) { List<SimulationTickEvent> events) {
hold(current.execution, reason); hold(current.execution, reason);
current.holdStreak++; current.holdStreak++;
@@ -413,6 +405,7 @@ public final class HistoricalDispatchSimulator {
vehicle.getVehicleId(), reason, vehicle.getCurrentSite(), null)); vehicle.getVehicleId(), reason, vehicle.getCurrentSite(), null));
if (current.holdStreak >= HOLD_REDISPATCH_THRESHOLD) { if (current.holdStreak >= HOLD_REDISPATCH_THRESHOLD) {
active.remove(vehicle.getVehicleId()); active.remove(vehicle.getVehicleId());
reservations.releaseTask(current.execution.task.getSimulationTaskId());
queue.requeue(current.execution.task); queue.requeue(current.execution.task);
events.add(event(tick, now, "REDISPATCH", events.add(event(tick, now, "REDISPATCH",
current.execution.task.getSimulationTaskId(), vehicle.getVehicleId(), current.execution.task.getSimulationTaskId(), vehicle.getVehicleId(),
@@ -428,8 +421,10 @@ public final class HistoricalDispatchSimulator {
SimulationArchive archive, SimulationArchive archive,
IdempotentWmsCompletionReceiver wms, IdempotentWmsCompletionReceiver wms,
SimulationInvariantSummary invariants, SimulationInvariantSummary invariants,
CorridorReservationManager reservations,
List<SimulationTickEvent> events) { List<SimulationTickEvent> events) {
String taskId = activeTask.execution.task.getSimulationTaskId(); String taskId = activeTask.execution.task.getSimulationTaskId();
reservations.releaseTask(taskId);
if (!archive.complete(taskId, vehicle.getVehicleId(), now)) { if (!archive.complete(taskId, vehicle.getVehicleId(), now)) {
invariants.duplicateArchiveAttempt(); invariants.duplicateArchiveAttempt();
} }

View File

@@ -0,0 +1,43 @@
package com.ft546.wcs.shadow.history.simulation;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
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 CorridorReservationManagerTest {
@Test
void keepsOneDirectionUntilTheOwnerLeavesTheSharedCorridor() {
CorridorReservationManager manager = new CorridorReservationManager();
assertTrue(manager.reserve("HIGH", Arrays.asList("A", "B", "C"), 1, 20L)
.isGranted());
CorridorReservationManager.ReservationDecision denied = manager.reserve(
"LOW", Arrays.asList("C", "B", "A"), 5, 10L);
assertFalse(denied.isGranted());
assertEquals("HIGH", denied.getOwnerTaskId());
assertEquals("HIGH", manager.ownerFor("B", "C"));
manager.releasePassedEdge("HIGH", "A", "B");
assertFalse(manager.reserve("LOW", Arrays.asList("C", "B", "A"), 0, 10L)
.isGranted());
manager.releasePassedEdge("HIGH", "B", "C");
manager.releaseTask("HIGH");
assertTrue(manager.reserve("LOW", Arrays.asList("C", "B", "A"), 0, 10L)
.isGranted());
}
@Test
void admitsSameDirectionWhilePhysicalResourcesRemainCapacityOne() {
CorridorReservationManager manager = new CorridorReservationManager();
assertTrue(manager.reserve("FIRST", Arrays.asList("A", "B", "C"), 1, 0L)
.isGranted());
assertTrue(manager.reserve("FOLLOWER", Arrays.asList("A", "B", "C"), 2, 1L)
.isGranted());
assertEquals("FIRST", manager.ownerFor("A", "B"));
}
}

View File

@@ -53,7 +53,8 @@ class HistoricalDispatchSimulatorTest {
assertEquals(0, result.getWaitingCount()); assertEquals(0, result.getWaitingCount());
assertTrue(result.getEvents().stream().anyMatch(event -> assertTrue(result.getEvents().stream().anyMatch(event ->
"HOLD".equals(event.getType()) "HOLD".equals(event.getType())
&& "OPPOSITE_ROUTE_CONFLICT".equals(event.getReason()))); && "CORRIDOR_DIRECTION_OWNED".equals(event.getReason())));
assertEquals(0, result.getInvariants().totalSafetyViolations());
} }
@Test @Test