From 5877d9e60a2e4d726ad1b5e7f4255c591cccbd00 Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Mon, 10 Aug 2026 16:11:21 +0800 Subject: [PATCH] feat(wcs): add resource reservation ledger --- .../domain/traffic/ReservationDecision.java | 57 +++++ .../domain/traffic/ReservationRequest.java | 72 ++++++ .../wcs/domain/traffic/ReservationState.java | 11 + .../wcs/domain/traffic/ResourceLedger.java | 221 ++++++++++++++++++ .../domain/traffic/ResourceReservation.java | 167 +++++++++++++ .../domain/traffic/ResourceLedgerTest.java | 109 +++++++++ 6 files changed, 637 insertions(+) create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ReservationDecision.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ReservationRequest.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ReservationState.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceLedger.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceReservation.java create mode 100644 ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/traffic/ResourceLedgerTest.java diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ReservationDecision.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ReservationDecision.java new file mode 100644 index 0000000..ab592fb --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ReservationDecision.java @@ -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 conflictingResourceKeys; + + private ReservationDecision(String reservationId, boolean granted, + long fencingToken, String reasonCode, + List conflictingResourceKeys) { + this.reservationId = reservationId; + this.granted = granted; + this.fencingToken = fencingToken; + this.reasonCode = reasonCode; + this.conflictingResourceKeys = Collections.unmodifiableList( + new ArrayList(conflictingResourceKeys)); + } + + static ReservationDecision granted(ResourceReservation reservation) { + return new ReservationDecision(reservation.getReservationId(), true, + reservation.getFencingToken(), "GRANTED", + Collections.emptyList()); + } + + static ReservationDecision rejected(String reservationId, + List 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 getConflictingResourceKeys() { + return conflictingResourceKeys; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ReservationRequest.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ReservationRequest.java new file mode 100644 index 0000000..f3b7bac --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ReservationRequest.java @@ -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 resourceKeys; + + public ReservationRequest(String reservationId, String orderId, String stepId, + String vehicleId, List 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 unique = new LinkedHashSet(); + for (String resourceKey : resourceKeys) { + unique.add(requireText(resourceKey, "resourceKey")); + } + if (unique.size() != resourceKeys.size()) { + throw new IllegalArgumentException("resourceKeys must not contain duplicates"); + } + List sorted = new ArrayList(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 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(); + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ReservationState.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ReservationState.java new file mode 100644 index 0000000..1dbd16e --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ReservationState.java @@ -0,0 +1,11 @@ +package com.ruoyi.wcs.domain.traffic; + +public enum ReservationState { + REQUESTED, + GRANTED, + OCCUPIED, + RELEASED, + REJECTED, + EXPIRED, + ABORTED +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceLedger.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceLedger.java new file mode 100644 index 0000000..31d62c5 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceLedger.java @@ -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 capacities; + private final Map usage; + private final Map reservations; + private long nextFencingToken; + + public ResourceLedger(Map capacities) { + if (capacities == null || capacities.isEmpty()) { + throw new IllegalArgumentException("capacities must not be empty"); + } + this.capacities = new LinkedHashMap(); + this.usage = new LinkedHashMap(); + for (Map.Entry 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(); + this.nextFencingToken = 1L; + } + + public ResourceLedger(EffectiveTopology topology) { + this(capacities(topology)); + } + + private ResourceLedger(ResourceLedger source) { + this.capacities = new LinkedHashMap(source.capacities); + this.usage = new LinkedHashMap(source.usage); + this.reservations = new LinkedHashMap(); + for (Map.Entry 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 unknown = new ArrayList(); + List conflicts = new ArrayList(); + 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 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 holders(String resourceKey) { + if (!capacities.containsKey(resourceKey)) { + throw new DomainRuleViolation("unknown resource: " + resourceKey); + } + Set holders = new TreeSet(); + 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(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 capacities(EffectiveTopology topology) { + if (topology == null) { + throw new IllegalArgumentException("topology must not be null"); + } + Map result = new LinkedHashMap(); + 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(); + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceReservation.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceReservation.java new file mode 100644 index 0000000..ccc06d3 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceReservation.java @@ -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 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(); + } +} diff --git a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/traffic/ResourceLedgerTest.java b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/traffic/ResourceLedgerTest.java new file mode 100644 index 0000000..16433ce --- /dev/null +++ b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/traffic/ResourceLedgerTest.java @@ -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 capacities = new LinkedHashMap(); + 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)); + } +}