feat(wcs): add resource reservation ledger

This commit is contained in:
zgw
2026-08-10 16:11:21 +08:00
parent 42374c24a7
commit 5877d9e60a
6 changed files with 637 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
package com.ruoyi.wcs.domain.traffic;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class ReservationDecision {
private final String reservationId;
private final boolean granted;
private final long fencingToken;
private final String reasonCode;
private final List<String> conflictingResourceKeys;
private ReservationDecision(String reservationId, boolean granted,
long fencingToken, String reasonCode,
List<String> conflictingResourceKeys) {
this.reservationId = reservationId;
this.granted = granted;
this.fencingToken = fencingToken;
this.reasonCode = reasonCode;
this.conflictingResourceKeys = Collections.unmodifiableList(
new ArrayList<String>(conflictingResourceKeys));
}
static ReservationDecision granted(ResourceReservation reservation) {
return new ReservationDecision(reservation.getReservationId(), true,
reservation.getFencingToken(), "GRANTED",
Collections.<String>emptyList());
}
static ReservationDecision rejected(String reservationId,
List<String> conflicts,
String reasonCode) {
return new ReservationDecision(reservationId, false, 0L,
reasonCode, conflicts);
}
public String getReservationId() {
return reservationId;
}
public boolean isGranted() {
return granted;
}
public long getFencingToken() {
return fencingToken;
}
public String getReasonCode() {
return reasonCode;
}
public List<String> getConflictingResourceKeys() {
return conflictingResourceKeys;
}
}

View File

@@ -0,0 +1,72 @@
package com.ruoyi.wcs.domain.traffic;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
public final class ReservationRequest {
private final String reservationId;
private final String orderId;
private final String stepId;
private final String vehicleId;
private final List<String> resourceKeys;
public ReservationRequest(String reservationId, String orderId, String stepId,
String vehicleId, List<String> resourceKeys) {
this.reservationId = requireText(reservationId, "reservationId");
this.orderId = requireText(orderId, "orderId");
this.stepId = requireText(stepId, "stepId");
this.vehicleId = requireText(vehicleId, "vehicleId");
if (resourceKeys == null || resourceKeys.isEmpty()) {
throw new IllegalArgumentException("resourceKeys must not be empty");
}
Set<String> unique = new LinkedHashSet<String>();
for (String resourceKey : resourceKeys) {
unique.add(requireText(resourceKey, "resourceKey"));
}
if (unique.size() != resourceKeys.size()) {
throw new IllegalArgumentException("resourceKeys must not contain duplicates");
}
List<String> sorted = new ArrayList<String>(unique);
Collections.sort(sorted);
this.resourceKeys = Collections.unmodifiableList(sorted);
}
public String getReservationId() {
return reservationId;
}
public String getOrderId() {
return orderId;
}
public String getStepId() {
return stepId;
}
public String getVehicleId() {
return vehicleId;
}
public List<String> getResourceKeys() {
return resourceKeys;
}
boolean sameAs(ReservationRequest other) {
return other != null
&& reservationId.equals(other.reservationId)
&& orderId.equals(other.orderId)
&& stepId.equals(other.stepId)
&& vehicleId.equals(other.vehicleId)
&& resourceKeys.equals(other.resourceKeys);
}
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();
}
}

View File

@@ -0,0 +1,11 @@
package com.ruoyi.wcs.domain.traffic;
public enum ReservationState {
REQUESTED,
GRANTED,
OCCUPIED,
RELEASED,
REJECTED,
EXPIRED,
ABORTED
}

View File

@@ -0,0 +1,221 @@
package com.ruoyi.wcs.domain.traffic;
import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
import com.ruoyi.wcs.domain.topology.ResourceDefinition;
import java.util.ArrayList;
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.TreeSet;
public final class ResourceLedger {
private final Map<String, Integer> capacities;
private final Map<String, Integer> usage;
private final Map<String, ResourceReservation> reservations;
private long nextFencingToken;
public ResourceLedger(Map<String, Integer> capacities) {
if (capacities == null || capacities.isEmpty()) {
throw new IllegalArgumentException("capacities must not be empty");
}
this.capacities = new LinkedHashMap<String, Integer>();
this.usage = new LinkedHashMap<String, Integer>();
for (Map.Entry<String, Integer> entry : capacities.entrySet()) {
String key = requireText(entry.getKey(), "resourceKey");
Integer capacity = entry.getValue();
if (capacity == null || capacity <= 0) {
throw new IllegalArgumentException("resource capacity must be positive");
}
this.capacities.put(key, capacity);
this.usage.put(key, 0);
}
this.reservations = new LinkedHashMap<String, ResourceReservation>();
this.nextFencingToken = 1L;
}
public ResourceLedger(EffectiveTopology topology) {
this(capacities(topology));
}
private ResourceLedger(ResourceLedger source) {
this.capacities = new LinkedHashMap<String, Integer>(source.capacities);
this.usage = new LinkedHashMap<String, Integer>(source.usage);
this.reservations = new LinkedHashMap<String, ResourceReservation>();
for (Map.Entry<String, ResourceReservation> entry
: source.reservations.entrySet()) {
this.reservations.put(entry.getKey(), entry.getValue().copy());
}
this.nextFencingToken = source.nextFencingToken;
}
public synchronized ReservationDecision reserve(ReservationRequest request) {
if (request == null) {
throw new IllegalArgumentException("request must not be null");
}
ResourceReservation existing = reservations.get(request.getReservationId());
if (existing != null) {
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());
}
ResourceReservation reservation = new ResourceReservation(request);
List<String> unknown = new ArrayList<String>();
List<String> conflicts = new ArrayList<String>();
for (String resourceKey : request.getResourceKeys()) {
Integer capacity = capacities.get(resourceKey);
if (capacity == null) {
unknown.add(resourceKey);
} else if (usage.get(resourceKey) >= capacity) {
conflicts.add(resourceKey);
}
}
if (!unknown.isEmpty() || !conflicts.isEmpty()) {
List<String> rejectedResources = unknown.isEmpty() ? conflicts : unknown;
String reason = unknown.isEmpty() ? "CAPACITY_CONFLICT" : "UNKNOWN_RESOURCE";
reservation.reject(reason);
reservations.put(request.getReservationId(), reservation);
return ReservationDecision.rejected(request.getReservationId(),
rejectedResources, reason);
}
reservation.grant(nextFencingToken++);
for (String resourceKey : request.getResourceKeys()) {
usage.put(resourceKey, usage.get(resourceKey) + 1);
}
reservations.put(request.getReservationId(), reservation);
return ReservationDecision.granted(reservation);
}
public synchronized void occupy(String reservationId, long fencingToken) {
requireReservation(reservationId).occupy(fencingToken);
}
public synchronized void release(String reservationId, String releaseEvidenceId,
long fencingToken) {
ResourceReservation reservation = requireReservation(reservationId);
reservation.release(releaseEvidenceId, fencingToken);
decrementUsage(reservation);
}
public synchronized void abortUnoccupied(String reservationId, String reason) {
ResourceReservation reservation = requireReservation(reservationId);
boolean heldCapacity = reservation.getState() == ReservationState.GRANTED;
reservation.abort(reason);
if (heldCapacity) {
decrementUsage(reservation);
}
}
public synchronized void expireUnoccupied(String reservationId) {
ResourceReservation reservation = requireReservation(reservationId);
boolean heldCapacity = reservation.getState() == ReservationState.GRANTED;
reservation.expire();
if (heldCapacity) {
decrementUsage(reservation);
}
}
public synchronized void markCommandUnknown(String reservationId) {
requireReservation(reservationId).markCommandUnknown();
}
public synchronized void markRecoveryUnresolved(String reservationId) {
requireReservation(reservationId).markRecoveryUnresolved();
}
public synchronized void clearProtectionAfterRecovery(
String reservationId, String evidenceId) {
requireReservation(reservationId).clearProtectionAfterRecovery(evidenceId);
}
public synchronized ResourceReservation reservation(String reservationId) {
return requireReservation(reservationId);
}
public synchronized int usage(String resourceKey) {
Integer count = usage.get(resourceKey);
if (count == null) {
throw new DomainRuleViolation("unknown resource: " + resourceKey);
}
return count;
}
public synchronized int availableCapacity(String resourceKey) {
Integer capacity = capacities.get(resourceKey);
if (capacity == null) {
throw new DomainRuleViolation("unknown resource: " + resourceKey);
}
return capacity - usage.get(resourceKey);
}
public synchronized Set<String> holders(String resourceKey) {
if (!capacities.containsKey(resourceKey)) {
throw new DomainRuleViolation("unknown resource: " + resourceKey);
}
Set<String> holders = new TreeSet<String>();
for (ResourceReservation reservation : reservations.values()) {
if ((reservation.getState() == ReservationState.GRANTED
|| reservation.getState() == ReservationState.OCCUPIED)
&& reservation.getResourceKeys().contains(resourceKey)) {
holders.add(reservation.getVehicleId());
}
}
return Collections.unmodifiableSet(new LinkedHashSet<String>(holders));
}
public synchronized boolean containsResource(String resourceKey) {
return capacities.containsKey(resourceKey);
}
public synchronized ResourceLedger copy() {
return new ResourceLedger(this);
}
private void decrementUsage(ResourceReservation reservation) {
for (String resourceKey : reservation.getResourceKeys()) {
int current = usage.get(resourceKey);
if (current <= 0) {
throw new IllegalStateException("resource usage underflow: " + resourceKey);
}
usage.put(resourceKey, current - 1);
}
}
private ResourceReservation requireReservation(String reservationId) {
ResourceReservation reservation = reservations.get(
requireText(reservationId, "reservationId"));
if (reservation == null) {
throw new DomainRuleViolation("reservation not found: " + reservationId);
}
return reservation;
}
private static Map<String, Integer> capacities(EffectiveTopology topology) {
if (topology == null) {
throw new IllegalArgumentException("topology must not be null");
}
Map<String, Integer> result = new LinkedHashMap<String, Integer>();
for (ResourceDefinition definition : topology.getResources().values()) {
result.put(definition.getResourceKey(), definition.getCapacity());
}
return result;
}
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();
}
}

View File

@@ -0,0 +1,167 @@
package com.ruoyi.wcs.domain.traffic;
import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
import java.util.List;
public final class ResourceReservation {
private final ReservationRequest request;
private ReservationState state;
private long fencingToken;
private String terminalReason;
private String releaseEvidenceId;
private boolean commandUnknown;
private boolean recoveryUnresolved;
ResourceReservation(ReservationRequest request) {
this.request = request;
this.state = ReservationState.REQUESTED;
}
private ResourceReservation(ResourceReservation source) {
this.request = source.request;
this.state = source.state;
this.fencingToken = source.fencingToken;
this.terminalReason = source.terminalReason;
this.releaseEvidenceId = source.releaseEvidenceId;
this.commandUnknown = source.commandUnknown;
this.recoveryUnresolved = source.recoveryUnresolved;
}
void grant(long token) {
requireState(ReservationState.REQUESTED);
if (token <= 0L) {
throw new IllegalArgumentException("fencingToken must be positive");
}
fencingToken = token;
state = ReservationState.GRANTED;
}
void reject(String reason) {
requireState(ReservationState.REQUESTED);
terminalReason = requireText(reason, "reason");
state = ReservationState.REJECTED;
}
void occupy(long token) {
requireState(ReservationState.GRANTED);
requireToken(token);
state = ReservationState.OCCUPIED;
}
void release(String evidenceId, long token) {
requireState(ReservationState.OCCUPIED);
requireToken(token);
String evidence = requireText(evidenceId, "releaseEvidenceId");
if (commandUnknown || recoveryUnresolved) {
throw new DomainRuleViolation(
"occupied reservation is protected by unknown command or recovery");
}
releaseEvidenceId = evidence;
state = ReservationState.RELEASED;
}
void abort(String reason) {
if (state != ReservationState.REQUESTED && state != ReservationState.GRANTED) {
throw new DomainRuleViolation("only unoccupied reservation can be aborted");
}
terminalReason = requireText(reason, "reason");
state = ReservationState.ABORTED;
}
void expire() {
if (state != ReservationState.REQUESTED && state != ReservationState.GRANTED) {
throw new DomainRuleViolation("only unoccupied reservation can expire");
}
state = ReservationState.EXPIRED;
}
void markCommandUnknown() {
requireState(ReservationState.OCCUPIED);
commandUnknown = true;
}
void markRecoveryUnresolved() {
requireState(ReservationState.OCCUPIED);
recoveryUnresolved = true;
}
void clearProtectionAfterRecovery(String evidenceId) {
requireState(ReservationState.OCCUPIED);
requireText(evidenceId, "recoveryEvidenceId");
commandUnknown = false;
recoveryUnresolved = false;
}
ResourceReservation copy() {
return new ResourceReservation(this);
}
public String getReservationId() {
return request.getReservationId();
}
public String getVehicleId() {
return request.getVehicleId();
}
public String getOrderId() {
return request.getOrderId();
}
public String getStepId() {
return request.getStepId();
}
public List<String> getResourceKeys() {
return request.getResourceKeys();
}
public ReservationState getState() {
return state;
}
public long getFencingToken() {
return fencingToken;
}
public String getTerminalReason() {
return terminalReason;
}
public String getReleaseEvidenceId() {
return releaseEvidenceId;
}
public boolean isCommandUnknown() {
return commandUnknown;
}
public boolean isRecoveryUnresolved() {
return recoveryUnresolved;
}
boolean matches(ReservationRequest candidate) {
return request.sameAs(candidate);
}
private void requireState(ReservationState required) {
if (state != required) {
throw new DomainRuleViolation("reservation state must be " + required
+ " but was " + state);
}
}
private void requireToken(long token) {
if (token != fencingToken) {
throw new DomainRuleViolation("stale reservation fencing token");
}
}
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();
}
}

View File

@@ -0,0 +1,109 @@
package com.ruoyi.wcs.domain.traffic;
import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
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 ResourceLedgerTest {
@Test
void grantsOnlyOneReservationForCapacityOneResource() {
ResourceLedger ledger = ledger("NODE:A", 1);
assertTrue(ledger.reserve(request("R1", "V1", "NODE:A")).isGranted());
ReservationDecision rejected = ledger.reserve(request("R2", "V2", "NODE:A"));
assertFalse(rejected.isGranted());
assertEquals(Collections.singletonList("NODE:A"),
rejected.getConflictingResourceKeys());
assertEquals(Collections.singleton("V1"), ledger.holders("NODE:A"));
}
@Test
void rejectsWholeMultiResourceRequestWithoutLeakingCapacity() {
Map<String, Integer> capacities = new LinkedHashMap<String, Integer>();
capacities.put("NODE:A", 1);
capacities.put("NODE:B", 1);
ResourceLedger ledger = new ResourceLedger(capacities);
ledger.reserve(request("R1", "V1", "NODE:B"));
ReservationDecision rejected = ledger.reserve(new ReservationRequest(
"R2", "O2", "S2", "V2", Arrays.asList("NODE:B", "NODE:A")));
assertFalse(rejected.isGranted());
assertEquals(0, ledger.usage("NODE:A"));
assertTrue(ledger.reserve(request("R3", "V3", "NODE:A")).isGranted());
}
@Test
void occupiedReservationCannotExpireByTime() {
ResourceLedger ledger = ledger("NODE:A", 1);
ReservationDecision decision = ledger.reserve(request("R1", "V1", "NODE:A"));
ledger.occupy("R1", decision.getFencingToken());
ResourceReservation reservation = ledger.reservation("R1");
assertThrows(DomainRuleViolation.class, reservation::expire);
}
@Test
void unknownCommandKeepsOccupiedResourcesProtected() {
ResourceLedger ledger = ledger("NODE:A", 1);
ReservationDecision decision = ledger.reserve(request("R1", "V1", "NODE:A"));
ledger.occupy("R1", decision.getFencingToken());
ledger.markCommandUnknown("R1");
assertThrows(DomainRuleViolation.class, () -> ledger.release(
"R1", "PLC-SNAPSHOT-1", decision.getFencingToken()));
assertEquals(1, ledger.usage("NODE:A"));
assertEquals(ReservationState.OCCUPIED, ledger.reservation("R1").getState());
}
@Test
void releaseRequiresMatchingFencingTokenAndPhysicalEvidence() {
ResourceLedger ledger = ledger("NODE:A", 1);
ReservationDecision decision = ledger.reserve(request("R1", "V1", "NODE:A"));
ledger.occupy("R1", decision.getFencingToken());
assertThrows(DomainRuleViolation.class, () -> ledger.release(
"R1", "PLC-SNAPSHOT-1", decision.getFencingToken() + 1));
assertThrows(IllegalArgumentException.class, () -> ledger.release(
"R1", "", decision.getFencingToken()));
ledger.release("R1", "PLC-SNAPSHOT-1", decision.getFencingToken());
assertEquals(0, ledger.usage("NODE:A"));
assertEquals(ReservationState.RELEASED, ledger.reservation("R1").getState());
}
@Test
void abortingAnUnoccupiedGrantReturnsCapacity() {
ResourceLedger ledger = ledger("NODE:A", 1);
ledger.reserve(request("R1", "V1", "NODE:A"));
ledger.abortUnoccupied("R1", "DISPATCH_CANCELLED");
assertEquals(0, ledger.usage("NODE:A"));
assertTrue(ledger.reserve(request("R2", "V2", "NODE:A")).isGranted());
}
private static ResourceLedger ledger(String resourceKey, int capacity) {
return new ResourceLedger(Collections.singletonMap(resourceKey, capacity));
}
private static ReservationRequest request(String reservationId,
String vehicleId,
String resourceKey) {
return new ReservationRequest(reservationId, "O-" + reservationId,
"S-" + reservationId, vehicleId,
Collections.singletonList(resourceKey));
}
}