feat(wcs): add resource reservation ledger
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.ruoyi.wcs.domain.traffic;
|
||||||
|
|
||||||
|
public enum ReservationState {
|
||||||
|
REQUESTED,
|
||||||
|
GRANTED,
|
||||||
|
OCCUPIED,
|
||||||
|
RELEASED,
|
||||||
|
REJECTED,
|
||||||
|
EXPIRED,
|
||||||
|
ABORTED
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user