feat(wcs): coordinate lift joint resources
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
package com.ruoyi.wcs.application.device;
|
||||
|
||||
import com.ruoyi.wcs.domain.equipment.LiftDecision;
|
||||
import com.ruoyi.wcs.domain.equipment.LiftDecisionType;
|
||||
import com.ruoyi.wcs.domain.equipment.LiftTransfer;
|
||||
import com.ruoyi.wcs.domain.equipment.LiftTransferState;
|
||||
import com.ruoyi.wcs.domain.execution.CommandOutboxEntry;
|
||||
import com.ruoyi.wcs.domain.execution.DeviceCommand;
|
||||
import com.ruoyi.wcs.domain.execution.DeviceCommandType;
|
||||
import com.ruoyi.wcs.domain.execution.DeviceSnapshot;
|
||||
import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
|
||||
import com.ruoyi.wcs.domain.shared.IdGenerator;
|
||||
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.port.out.CommandOutboxRepository;
|
||||
import com.ruoyi.wcs.port.out.DeviceCommandRepository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public final class LiftCoordinator {
|
||||
private final ResourceLedger ledger;
|
||||
private final DeviceCommandRepository commandRepository;
|
||||
private final CommandOutboxRepository outboxRepository;
|
||||
private final IdGenerator idGenerator;
|
||||
private final long topologyVersion;
|
||||
private final Supplier<Instant> clock;
|
||||
|
||||
public LiftCoordinator(ResourceLedger ledger,
|
||||
DeviceCommandRepository commandRepository,
|
||||
CommandOutboxRepository outboxRepository,
|
||||
IdGenerator idGenerator, long topologyVersion,
|
||||
Supplier<Instant> clock) {
|
||||
this.ledger = Objects.requireNonNull(ledger, "ledger");
|
||||
this.commandRepository = Objects.requireNonNull(commandRepository,
|
||||
"commandRepository");
|
||||
this.outboxRepository = Objects.requireNonNull(outboxRepository,
|
||||
"outboxRepository");
|
||||
this.idGenerator = Objects.requireNonNull(idGenerator, "idGenerator");
|
||||
if (topologyVersion <= 0L) {
|
||||
throw new IllegalArgumentException("topologyVersion must be positive");
|
||||
}
|
||||
this.topologyVersion = topologyVersion;
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public LiftDecision prepare(String stepId, String vehicleId, String liftId,
|
||||
String sourcePort, String targetPort) {
|
||||
String step = requireSafe(stepId, "stepId");
|
||||
String vehicle = requireSafe(vehicleId, "vehicleId");
|
||||
String lift = requireSafe(liftId, "liftId");
|
||||
String source = requireSafe(sourcePort, "sourcePort");
|
||||
String target = requireSafe(targetPort, "targetPort");
|
||||
String reservationId = "LIFT-RES:" + step;
|
||||
ReservationRequest request = new ReservationRequest(reservationId,
|
||||
"LIFT-ORDER:" + step, step, vehicle, Arrays.asList(
|
||||
"LIFT_PORT:" + source, "LIFT:" + lift,
|
||||
"LIFT_PORT:" + target));
|
||||
ReservationDecision reservation = ledger.reserve(request);
|
||||
if (!reservation.isGranted()) {
|
||||
return new LiftDecision(LiftDecisionType.BLOCKED,
|
||||
reservation.getReasonCode(), null);
|
||||
}
|
||||
if (ledger.reservation(reservationId).getState()
|
||||
== ReservationState.GRANTED) {
|
||||
ledger.occupy(reservationId, reservation.getFencingToken());
|
||||
}
|
||||
|
||||
String commandKey = "LIFT:" + step;
|
||||
LiftTransfer transfer = LiftTransfer.prepared(step, vehicle, lift,
|
||||
source, target, commandKey, reservationId,
|
||||
reservation.getFencingToken());
|
||||
DeviceCommand existing = commandRepository.findByCommandKey(commandKey);
|
||||
if (existing == null) {
|
||||
Instant now = Objects.requireNonNull(clock.get(), "clock returned null");
|
||||
String payload = "stepId=" + step + "|vehicleId=" + vehicle
|
||||
+ "|sourcePort=" + source + "|targetPort=" + target;
|
||||
DeviceCommand command = DeviceCommand.pending(
|
||||
requireSafe(idGenerator.nextId(), "generated commandId"),
|
||||
commandKey, lift, DeviceCommandType.LIFT_TRANSFER,
|
||||
payload, 1, now);
|
||||
commandRepository.save(command);
|
||||
outboxRepository.saveIfAbsent(CommandOutboxEntry.pending(
|
||||
command.getCommandId(), commandKey, payload, now,
|
||||
topologyVersion, reservation.getFencingToken()));
|
||||
} else if (!existing.getDeviceId().equals(lift)) {
|
||||
throw new DomainRuleViolation(
|
||||
"lift command key reused for another lift");
|
||||
}
|
||||
return new LiftDecision(LiftDecisionType.READY,
|
||||
"JOINT_RESOURCES_RESERVED", transfer);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public LiftDecision onVehicleLeftTarget(LiftTransfer transfer,
|
||||
DeviceSnapshot snapshot) {
|
||||
Objects.requireNonNull(transfer, "transfer");
|
||||
Objects.requireNonNull(snapshot, "snapshot");
|
||||
String mismatch = mismatchReason(transfer, snapshot);
|
||||
if (mismatch != null) {
|
||||
if (ledger.reservation(transfer.getReservationId()).getState()
|
||||
== ReservationState.OCCUPIED) {
|
||||
ledger.markRecoveryUnresolved(transfer.getReservationId());
|
||||
}
|
||||
return new LiftDecision(LiftDecisionType.RECOVERY_REQUIRED,
|
||||
mismatch, transfer);
|
||||
}
|
||||
transfer.completeAfterVehicleLeft(snapshot.getSnapshotId());
|
||||
com.ruoyi.wcs.domain.traffic.ResourceReservation held =
|
||||
ledger.reservation(transfer.getReservationId());
|
||||
if (held.isCommandUnknown() || held.isRecoveryUnresolved()) {
|
||||
ledger.clearProtectionAfterRecovery(transfer.getReservationId(),
|
||||
snapshot.getSnapshotId());
|
||||
}
|
||||
ledger.release(transfer.getReservationId(), snapshot.getSnapshotId(),
|
||||
transfer.getFencingToken());
|
||||
return new LiftDecision(LiftDecisionType.COMPLETED,
|
||||
"VEHICLE_LEFT_TARGET_PORT", transfer);
|
||||
}
|
||||
|
||||
private String mismatchReason(LiftTransfer transfer, DeviceSnapshot snapshot) {
|
||||
if (transfer.getState() != LiftTransferState.TARGET_EXIT_COMMITTED) {
|
||||
return "TARGET_EXIT_NOT_COMMITTED";
|
||||
}
|
||||
if (!snapshot.isConnected()) {
|
||||
return "COMMUNICATION_LOST";
|
||||
}
|
||||
if (!transfer.getVehicleId().equals(snapshot.getDeviceId())) {
|
||||
return "VEHICLE_MISMATCH";
|
||||
}
|
||||
if (!transfer.getCommandKey().equals(snapshot.getCommandKey())) {
|
||||
return "COMMAND_KEY_MISMATCH";
|
||||
}
|
||||
if (!transfer.getStepId().equals(snapshot.getTaskNumber())) {
|
||||
return "TASK_NUMBER_MISMATCH";
|
||||
}
|
||||
if (snapshot.getTopologyVersion() != topologyVersion) {
|
||||
return "TOPOLOGY_VERSION_MISMATCH";
|
||||
}
|
||||
if (snapshot.getFencingToken() != transfer.getFencingToken()) {
|
||||
return "FENCING_TOKEN_MISMATCH";
|
||||
}
|
||||
if (!transfer.getTargetPort().equals(snapshot.getCurrentNodeId())
|
||||
|| !snapshot.isCommandCompleted()) {
|
||||
return "VEHICLE_NOT_CLEAR_OF_TARGET";
|
||||
}
|
||||
Map<String, String> facts = snapshot.getFacts();
|
||||
if (!transfer.getLiftId().equals(facts.get("liftId"))) {
|
||||
return "LIFT_ID_MISMATCH";
|
||||
}
|
||||
if (!transfer.getTargetPort().equals(facts.get("liftFloor"))) {
|
||||
return "LIFT_FLOOR_MISMATCH";
|
||||
}
|
||||
if (!"true".equals(facts.get("liftDoorOpen"))) {
|
||||
return "LIFT_DOOR_NOT_PROVEN_OPEN";
|
||||
}
|
||||
if (!"true".equals(facts.get("targetPortClear"))) {
|
||||
return "TARGET_PORT_NOT_PROVEN_CLEAR";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String requireSafe(String value, String field) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
String text = value.trim();
|
||||
if (text.indexOf('|') >= 0 || text.indexOf('=') >= 0) {
|
||||
throw new IllegalArgumentException(field + " contains reserved delimiter");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ruoyi.wcs.domain.equipment;
|
||||
|
||||
public final class LiftDecision {
|
||||
private final LiftDecisionType type;
|
||||
private final String reasonCode;
|
||||
private final LiftTransfer transfer;
|
||||
|
||||
public LiftDecision(LiftDecisionType type, String reasonCode,
|
||||
LiftTransfer transfer) {
|
||||
this.type = java.util.Objects.requireNonNull(type, "type");
|
||||
this.reasonCode = requireText(reasonCode, "reasonCode");
|
||||
this.transfer = transfer;
|
||||
}
|
||||
|
||||
public LiftDecisionType getType() { return type; }
|
||||
public String getReasonCode() { return reasonCode; }
|
||||
public LiftTransfer getTransfer() { return transfer; }
|
||||
|
||||
private static String requireText(String value, String field) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.ruoyi.wcs.domain.equipment;
|
||||
|
||||
public enum LiftDecisionType {
|
||||
READY,
|
||||
BLOCKED,
|
||||
ADVANCED,
|
||||
COMPLETED,
|
||||
RECOVERY_REQUIRED
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.ruoyi.wcs.domain.equipment;
|
||||
|
||||
import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
|
||||
|
||||
public final class LiftTransfer {
|
||||
private final String stepId;
|
||||
private final String vehicleId;
|
||||
private final String liftId;
|
||||
private final String sourcePort;
|
||||
private final String targetPort;
|
||||
private final String commandKey;
|
||||
private final String reservationId;
|
||||
private final long fencingToken;
|
||||
private LiftTransferState state;
|
||||
private String lastEvidenceId;
|
||||
|
||||
private LiftTransfer(String stepId, String vehicleId, String liftId,
|
||||
String sourcePort, String targetPort, String commandKey,
|
||||
String reservationId, long fencingToken) {
|
||||
this.stepId = requireText(stepId, "stepId");
|
||||
this.vehicleId = requireText(vehicleId, "vehicleId");
|
||||
this.liftId = requireText(liftId, "liftId");
|
||||
this.sourcePort = requireText(sourcePort, "sourcePort");
|
||||
this.targetPort = requireText(targetPort, "targetPort");
|
||||
this.commandKey = requireText(commandKey, "commandKey");
|
||||
this.reservationId = requireText(reservationId, "reservationId");
|
||||
if (fencingToken <= 0L) {
|
||||
throw new IllegalArgumentException("fencingToken must be positive");
|
||||
}
|
||||
this.fencingToken = fencingToken;
|
||||
this.state = LiftTransferState.SOURCE_ENTRY_COMMITTED;
|
||||
}
|
||||
|
||||
public static LiftTransfer prepared(String stepId, String vehicleId,
|
||||
String liftId, String sourcePort,
|
||||
String targetPort, String commandKey,
|
||||
String reservationId,
|
||||
long fencingToken) {
|
||||
return new LiftTransfer(stepId, vehicleId, liftId, sourcePort,
|
||||
targetPort, commandKey, reservationId, fencingToken);
|
||||
}
|
||||
|
||||
public void vehicleEnteredLift(String evidenceId) {
|
||||
transition(LiftTransferState.SOURCE_ENTRY_COMMITTED,
|
||||
LiftTransferState.VEHICLE_IN_LIFT, evidenceId);
|
||||
}
|
||||
|
||||
public void liftStartedMoving(String evidenceId) {
|
||||
transition(LiftTransferState.VEHICLE_IN_LIFT,
|
||||
LiftTransferState.LIFT_MOVING, evidenceId);
|
||||
}
|
||||
|
||||
public void arrivedAtTarget(String evidenceId) {
|
||||
transition(LiftTransferState.LIFT_MOVING,
|
||||
LiftTransferState.AT_TARGET, evidenceId);
|
||||
}
|
||||
|
||||
public void commitTargetExit(String evidenceId) {
|
||||
transition(LiftTransferState.AT_TARGET,
|
||||
LiftTransferState.TARGET_EXIT_COMMITTED, evidenceId);
|
||||
}
|
||||
|
||||
public void completeAfterVehicleLeft(String evidenceId) {
|
||||
transition(LiftTransferState.TARGET_EXIT_COMMITTED,
|
||||
LiftTransferState.VEHICLE_LEFT_LIFT, evidenceId);
|
||||
state = LiftTransferState.COMPLETED;
|
||||
}
|
||||
|
||||
private void transition(LiftTransferState required, LiftTransferState next,
|
||||
String evidenceId) {
|
||||
if (state != required) {
|
||||
throw new DomainRuleViolation("lift transfer state must be " + required
|
||||
+ " but was " + state);
|
||||
}
|
||||
lastEvidenceId = requireText(evidenceId, "evidenceId");
|
||||
state = next;
|
||||
}
|
||||
|
||||
public String getStepId() { return stepId; }
|
||||
public String getVehicleId() { return vehicleId; }
|
||||
public String getLiftId() { return liftId; }
|
||||
public String getSourcePort() { return sourcePort; }
|
||||
public String getTargetPort() { return targetPort; }
|
||||
public String getCommandKey() { return commandKey; }
|
||||
public String getReservationId() { return reservationId; }
|
||||
public long getFencingToken() { return fencingToken; }
|
||||
public LiftTransferState getState() { return state; }
|
||||
public String getLastEvidenceId() { return lastEvidenceId; }
|
||||
|
||||
private static String requireText(String value, String field) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.ruoyi.wcs.domain.equipment;
|
||||
|
||||
public enum LiftTransferState {
|
||||
WAITING_SOURCE,
|
||||
SOURCE_ENTRY_COMMITTED,
|
||||
VEHICLE_IN_LIFT,
|
||||
LIFT_MOVING,
|
||||
AT_TARGET,
|
||||
TARGET_EXIT_COMMITTED,
|
||||
VEHICLE_LEFT_LIFT,
|
||||
COMPLETED
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class DeviceSnapshot {
|
||||
@@ -20,6 +22,7 @@ public final class DeviceSnapshot {
|
||||
private final String currentNodeId;
|
||||
private final List<String> remainingRouteNodes;
|
||||
private final boolean commandCompleted;
|
||||
private final Map<String, String> facts;
|
||||
|
||||
public DeviceSnapshot(String snapshotId, String deviceId, String commandKey,
|
||||
String taskNumber, long deviceSequence,
|
||||
@@ -28,6 +31,19 @@ public final class DeviceSnapshot {
|
||||
long fencingToken, String currentNodeId,
|
||||
List<String> remainingRouteNodes,
|
||||
boolean commandCompleted) {
|
||||
this(snapshotId, deviceId, commandKey, taskNumber, deviceSequence,
|
||||
observedAt, connected, deviceBootId, topologyVersion,
|
||||
fencingToken, currentNodeId, remainingRouteNodes,
|
||||
commandCompleted, Collections.<String, String>emptyMap());
|
||||
}
|
||||
|
||||
public DeviceSnapshot(String snapshotId, String deviceId, String commandKey,
|
||||
String taskNumber, long deviceSequence,
|
||||
Instant observedAt, boolean connected,
|
||||
String deviceBootId, long topologyVersion,
|
||||
long fencingToken, String currentNodeId,
|
||||
List<String> remainingRouteNodes,
|
||||
boolean commandCompleted, Map<String, String> facts) {
|
||||
this.snapshotId = requireText(snapshotId, "snapshotId");
|
||||
this.deviceId = requireText(deviceId, "deviceId");
|
||||
this.commandKey = requireText(commandKey, "commandKey");
|
||||
@@ -44,6 +60,7 @@ public final class DeviceSnapshot {
|
||||
this.currentNodeId = requireText(currentNodeId, "currentNodeId");
|
||||
this.remainingRouteNodes = immutable(remainingRouteNodes);
|
||||
this.commandCompleted = commandCompleted;
|
||||
this.facts = immutableFacts(facts);
|
||||
}
|
||||
|
||||
public String getSnapshotId() { return snapshotId; }
|
||||
@@ -59,6 +76,19 @@ public final class DeviceSnapshot {
|
||||
public String getCurrentNodeId() { return currentNodeId; }
|
||||
public List<String> getRemainingRouteNodes() { return remainingRouteNodes; }
|
||||
public boolean isCommandCompleted() { return commandCompleted; }
|
||||
public Map<String, String> getFacts() { return facts; }
|
||||
|
||||
private static Map<String, String> immutableFacts(Map<String, String> source) {
|
||||
if (source == null) {
|
||||
throw new IllegalArgumentException("facts must not be null");
|
||||
}
|
||||
Map<String, String> copy = new LinkedHashMap<String, String>();
|
||||
for (Map.Entry<String, String> entry : source.entrySet()) {
|
||||
copy.put(requireText(entry.getKey(), "fact key"),
|
||||
requireText(entry.getValue(), "fact value"));
|
||||
}
|
||||
return Collections.unmodifiableMap(copy);
|
||||
}
|
||||
|
||||
private static List<String> immutable(List<String> source) {
|
||||
if (source == null || source.isEmpty()) {
|
||||
|
||||
@@ -62,11 +62,16 @@ public final class ResourceLedger {
|
||||
if (!existing.matches(request)) {
|
||||
throw new DomainRuleViolation("reservation id reused with different request");
|
||||
}
|
||||
return existing.getState() == ReservationState.GRANTED
|
||||
|| existing.getState() == ReservationState.OCCUPIED
|
||||
? ReservationDecision.granted(existing)
|
||||
: ReservationDecision.rejected(existing.getReservationId(),
|
||||
existing.getResourceKeys(), existing.getState().name());
|
||||
if (existing.getState() == ReservationState.REJECTED
|
||||
&& "CAPACITY_CONFLICT".equals(existing.getTerminalReason())) {
|
||||
reservations.remove(request.getReservationId());
|
||||
} else {
|
||||
return existing.getState() == ReservationState.GRANTED
|
||||
|| existing.getState() == ReservationState.OCCUPIED
|
||||
? ReservationDecision.granted(existing)
|
||||
: ReservationDecision.rejected(existing.getReservationId(),
|
||||
existing.getResourceKeys(), existing.getState().name());
|
||||
}
|
||||
}
|
||||
|
||||
ResourceReservation reservation = new ResourceReservation(request);
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package com.ruoyi.wcs.application.device;
|
||||
|
||||
import com.ruoyi.wcs.domain.equipment.LiftDecision;
|
||||
import com.ruoyi.wcs.domain.equipment.LiftDecisionType;
|
||||
import com.ruoyi.wcs.domain.equipment.LiftTransfer;
|
||||
import com.ruoyi.wcs.domain.equipment.LiftTransferState;
|
||||
import com.ruoyi.wcs.domain.execution.CommandOutboxEntry;
|
||||
import com.ruoyi.wcs.domain.execution.DeviceCommand;
|
||||
import com.ruoyi.wcs.domain.execution.DeviceSnapshot;
|
||||
import com.ruoyi.wcs.domain.shared.IdGenerator;
|
||||
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.port.out.CommandOutboxRepository;
|
||||
import com.ruoyi.wcs.port.out.DeviceCommandRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class LiftCoordinatorTest {
|
||||
private ResourceLedger ledger;
|
||||
private FakeCommandRepository commands;
|
||||
private FakeOutboxRepository outbox;
|
||||
private LiftCoordinator coordinator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
Map<String, Integer> capacities = new LinkedHashMap<String, Integer>();
|
||||
capacities.put("LIFT_PORT:P1", 1);
|
||||
capacities.put("LIFT:L1", 1);
|
||||
capacities.put("LIFT_PORT:P2", 1);
|
||||
ledger = new ResourceLedger(capacities);
|
||||
commands = new FakeCommandRepository();
|
||||
outbox = new FakeOutboxRepository();
|
||||
coordinator = new LiftCoordinator(ledger, commands, outbox,
|
||||
new IdGenerator() {
|
||||
@Override
|
||||
public String nextId() {
|
||||
return "LIFT-CMD-1";
|
||||
}
|
||||
}, 7L, new java.util.function.Supplier<Instant>() {
|
||||
@Override
|
||||
public Instant get() {
|
||||
return Instant.EPOCH;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotEnterLiftWhenTargetExitCannotBeReserved() {
|
||||
ReservationDecision occupied = ledger.reserve(new ReservationRequest(
|
||||
"OTHER-RES", "OTHER-ORDER", "OTHER-STEP", "OTHER",
|
||||
Collections.singletonList("LIFT_PORT:P2")));
|
||||
ledger.occupy("OTHER-RES", occupied.getFencingToken());
|
||||
|
||||
LiftDecision decision = coordinator.prepare(
|
||||
"STEP-1", "V1", "L1", "P1", "P2");
|
||||
|
||||
assertEquals(LiftDecisionType.BLOCKED, decision.getType());
|
||||
assertEquals(0, commands.values.size());
|
||||
assertEquals(0, outbox.values.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void blockedPreparationCanRetryAfterTheTargetPortBecomesAvailable() {
|
||||
ReservationDecision occupied = ledger.reserve(new ReservationRequest(
|
||||
"OTHER-RES", "OTHER-ORDER", "OTHER-STEP", "OTHER",
|
||||
Collections.singletonList("LIFT_PORT:P2")));
|
||||
ledger.occupy("OTHER-RES", occupied.getFencingToken());
|
||||
LiftDecision blocked = coordinator.prepare(
|
||||
"STEP-1", "V1", "L1", "P1", "P2");
|
||||
ledger.release("OTHER-RES", "OTHER-LEFT",
|
||||
occupied.getFencingToken());
|
||||
|
||||
LiftDecision retried = coordinator.prepare(
|
||||
"STEP-1", "V1", "L1", "P1", "P2");
|
||||
|
||||
assertEquals(LiftDecisionType.BLOCKED, blocked.getType());
|
||||
assertEquals(LiftDecisionType.READY, retried.getType());
|
||||
assertEquals(1, commands.values.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void releasesLiftOnlyAfterVehicleLeavesTargetPort() {
|
||||
LiftDecision prepared = coordinator.prepare(
|
||||
"STEP-1", "V1", "L1", "P1", "P2");
|
||||
LiftTransfer transfer = prepared.getTransfer();
|
||||
transfer.vehicleEnteredLift("VEHICLE-IN-LIFT");
|
||||
transfer.liftStartedMoving("LIFT-MOVING");
|
||||
transfer.arrivedAtTarget("AT-TARGET");
|
||||
transfer.commitTargetExit("TARGET-EXIT");
|
||||
|
||||
LiftDecision completed = coordinator.onVehicleLeftTarget(
|
||||
transfer, matchingFacts());
|
||||
|
||||
assertEquals(LiftDecisionType.COMPLETED, completed.getType());
|
||||
assertEquals(LiftTransferState.COMPLETED, transfer.getState());
|
||||
assertEquals(1, ledger.availableCapacity("LIFT:L1"));
|
||||
assertEquals(1, ledger.availableCapacity("LIFT_PORT:P2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mismatchingDoorOrFloorFactsKeepAllJointResourcesIsolated() {
|
||||
LiftTransfer transfer = coordinator.prepare(
|
||||
"STEP-1", "V1", "L1", "P1", "P2").getTransfer();
|
||||
transfer.vehicleEnteredLift("VEHICLE-IN-LIFT");
|
||||
transfer.liftStartedMoving("LIFT-MOVING");
|
||||
transfer.arrivedAtTarget("AT-TARGET");
|
||||
transfer.commitTargetExit("TARGET-EXIT");
|
||||
Map<String, String> facts = matchingFactMap();
|
||||
facts.put("liftDoorOpen", "false");
|
||||
|
||||
LiftDecision decision = coordinator.onVehicleLeftTarget(
|
||||
transfer, snapshot(facts));
|
||||
|
||||
assertEquals(LiftDecisionType.RECOVERY_REQUIRED, decision.getType());
|
||||
assertEquals(0, ledger.availableCapacity("LIFT:L1"));
|
||||
assertEquals(0, ledger.availableCapacity("LIFT_PORT:P2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void laterMatchingCombinedFactsCanResolveTheIsolationAndRelease() {
|
||||
LiftTransfer transfer = coordinator.prepare(
|
||||
"STEP-1", "V1", "L1", "P1", "P2").getTransfer();
|
||||
transfer.vehicleEnteredLift("VEHICLE-IN-LIFT");
|
||||
transfer.liftStartedMoving("LIFT-MOVING");
|
||||
transfer.arrivedAtTarget("AT-TARGET");
|
||||
transfer.commitTargetExit("TARGET-EXIT");
|
||||
Map<String, String> mismatch = matchingFactMap();
|
||||
mismatch.put("liftFloor", "P1");
|
||||
coordinator.onVehicleLeftTarget(transfer, snapshot(mismatch));
|
||||
|
||||
LiftDecision recovered = coordinator.onVehicleLeftTarget(
|
||||
transfer, matchingFacts());
|
||||
|
||||
assertEquals(LiftDecisionType.COMPLETED, recovered.getType());
|
||||
assertEquals(1, ledger.availableCapacity("LIFT:L1"));
|
||||
}
|
||||
|
||||
private static DeviceSnapshot matchingFacts() {
|
||||
return snapshot(matchingFactMap());
|
||||
}
|
||||
|
||||
private static Map<String, String> matchingFactMap() {
|
||||
Map<String, String> facts = new LinkedHashMap<String, String>();
|
||||
facts.put("liftId", "L1");
|
||||
facts.put("liftFloor", "P2");
|
||||
facts.put("liftDoorOpen", "true");
|
||||
facts.put("targetPortClear", "true");
|
||||
return facts;
|
||||
}
|
||||
|
||||
private static DeviceSnapshot snapshot(Map<String, String> facts) {
|
||||
return new DeviceSnapshot("LIFT-SNAPSHOT-1", "V1",
|
||||
"LIFT:STEP-1", "STEP-1", 10L, Instant.EPOCH.plusSeconds(10),
|
||||
true, "BOOT-1", 7L, 1L, "P2",
|
||||
Collections.singletonList("P2"), true, facts);
|
||||
}
|
||||
|
||||
private static final class FakeCommandRepository
|
||||
implements DeviceCommandRepository {
|
||||
private final Map<String, DeviceCommand> values =
|
||||
new LinkedHashMap<String, DeviceCommand>();
|
||||
|
||||
@Override
|
||||
public DeviceCommand findByCommandKey(String commandKey) {
|
||||
for (DeviceCommand command : values.values()) {
|
||||
if (command.getCommandKey().equals(commandKey)) {
|
||||
return command;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DeviceCommand findByCommandId(String commandId) {
|
||||
return values.get(commandId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(DeviceCommand command) {
|
||||
values.put(command.getCommandId(), command);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FakeOutboxRepository
|
||||
implements CommandOutboxRepository {
|
||||
private final Map<String, CommandOutboxEntry> values =
|
||||
new LinkedHashMap<String, CommandOutboxEntry>();
|
||||
|
||||
@Override
|
||||
public void saveIfAbsent(CommandOutboxEntry entry) {
|
||||
if (!values.containsKey(entry.getCommandId())) {
|
||||
values.put(entry.getCommandId(), entry);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(CommandOutboxEntry entry) {
|
||||
values.put(entry.getCommandId(), entry);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CommandOutboxEntry> claimDue(int limit, Instant now) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user