feat(wcs): deliver device commands through outbox

This commit is contained in:
zgw
2026-08-11 10:39:15 +08:00
parent f191d49319
commit 0d09eb93da
15 changed files with 941 additions and 10 deletions

View File

@@ -0,0 +1,111 @@
package com.ruoyi.wcs.application.device;
import com.ruoyi.wcs.domain.execution.CommandOutboxEntry;
import com.ruoyi.wcs.domain.execution.DeviceCommand;
import com.ruoyi.wcs.domain.execution.DeviceCommandState;
import com.ruoyi.wcs.port.out.CommandOutboxRepository;
import com.ruoyi.wcs.port.out.DeviceCommandRepository;
import com.ruoyi.wcs.port.out.RecoveryCaseOpener;
import com.ruoyi.wcs.port.out.ResourceReservationRepository;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Collections;
import java.util.Objects;
public final class CommandDeliveryRecorder {
private final DeviceCommandRepository commandRepository;
private final CommandOutboxRepository outboxRepository;
private final RecoveryCaseOpener recoveryCaseOpener;
private final ResourceReservationRepository reservationRepository;
public CommandDeliveryRecorder(DeviceCommandRepository commandRepository,
CommandOutboxRepository outboxRepository,
RecoveryCaseOpener recoveryCaseOpener,
ResourceReservationRepository reservationRepository) {
this.commandRepository = Objects.requireNonNull(commandRepository,
"commandRepository");
this.outboxRepository = Objects.requireNonNull(outboxRepository,
"outboxRepository");
this.recoveryCaseOpener = Objects.requireNonNull(recoveryCaseOpener,
"recoveryCaseOpener");
this.reservationRepository = Objects.requireNonNull(reservationRepository,
"reservationRepository");
}
@Transactional
public OutboxDispatchResult.Outcome record(CommandOutboxEntry entry,
SendReceipt receipt,
Instant now) {
Objects.requireNonNull(entry, "entry");
Objects.requireNonNull(receipt, "receipt");
Objects.requireNonNull(now, "now");
DeviceCommand command = commandRepository.findByCommandId(
entry.getCommandId());
if (command == null || command.getState() != DeviceCommandState.PENDING) {
entry.markDead("COMMAND_NOT_PENDING", now);
outboxRepository.save(entry);
return OutboxDispatchResult.Outcome.DEAD;
}
switch (receipt.getStatus()) {
case NOT_SENT:
entry.reschedule(detail(receipt, "NOT_SENT"),
now.plusSeconds(backoffSeconds(entry.getAttemptCount())));
outboxRepository.save(entry);
return OutboxDispatchResult.Outcome.RETRY_SCHEDULED;
case ACCEPTED:
command.markSent(receipt.getReceiptId(), now);
entry.markDelivered(now);
commandRepository.save(command);
outboxRepository.save(entry);
return OutboxDispatchResult.Outcome.SENT;
case ACCEPTED_UNKNOWN:
String reason = detail(receipt, "WRITE_RESULT_AMBIGUOUS");
command.markSent(receipt.getReceiptId(), now);
command.markUnknown(reason, now);
entry.markDelivered(now);
reservationRepository.protectForUnknown(command.getCommandKey(),
receipt.getReceiptId());
recoveryCaseOpener.open(command.getDeviceId(),
command.getCommandKey(), reason,
Collections.singletonList(receipt.getReceiptId()));
commandRepository.save(command);
outboxRepository.save(entry);
return OutboxDispatchResult.Outcome.ISOLATED_UNKNOWN;
case REJECTED:
command.markSent(receipt.getReceiptId(), now);
command.reject(detail(receipt, "DEVICE_REJECTED"), now);
entry.markDelivered(now);
commandRepository.save(command);
outboxRepository.save(entry);
return OutboxDispatchResult.Outcome.REJECTED;
default:
throw new IllegalStateException("unsupported send status");
}
}
DeviceCommand findPendingCommand(String commandId) {
DeviceCommand command = commandRepository.findByCommandId(commandId);
return command != null && command.getState() == DeviceCommandState.PENDING
? command : null;
}
@Transactional
public OutboxDispatchResult.Outcome discardInvalid(
CommandOutboxEntry entry, Instant now) {
entry.markDead("COMMAND_NOT_PENDING", now);
outboxRepository.save(entry);
return OutboxDispatchResult.Outcome.DEAD;
}
private static long backoffSeconds(int attemptCount) {
int exponent = Math.min(Math.max(attemptCount, 1), 8);
return Math.min(300L, 1L << exponent);
}
private static String detail(SendReceipt receipt, String fallback) {
String detail = receipt.getDetail();
return detail == null || detail.trim().isEmpty() ? fallback : detail.trim();
}
}

View File

@@ -0,0 +1,58 @@
package com.ruoyi.wcs.application.device;
import com.ruoyi.wcs.domain.execution.CommandOutboxEntry;
import com.ruoyi.wcs.domain.execution.DeviceCommand;
import com.ruoyi.wcs.port.out.CommandOutboxRepository;
import com.ruoyi.wcs.port.out.DeviceCommandGateway;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
public final class CommandOutboxDispatcher {
private final CommandOutboxRepository outboxRepository;
private final DeviceCommandGateway commandGateway;
private final CommandDeliveryRecorder recorder;
public CommandOutboxDispatcher(CommandOutboxRepository outboxRepository,
DeviceCommandGateway commandGateway,
CommandDeliveryRecorder recorder) {
this.outboxRepository = Objects.requireNonNull(outboxRepository,
"outboxRepository");
this.commandGateway = Objects.requireNonNull(commandGateway,
"commandGateway");
this.recorder = Objects.requireNonNull(recorder, "recorder");
}
public OutboxDispatchResult dispatchDue(int limit, Instant now) {
if (limit <= 0) {
throw new IllegalArgumentException("limit must be positive");
}
Objects.requireNonNull(now, "now");
List<CommandOutboxEntry> entries = outboxRepository.claimDue(limit, now);
OutboxDispatchResult result = new OutboxDispatchResult();
for (CommandOutboxEntry entry : entries) {
DeviceCommand command = recorder.findPendingCommand(entry.getCommandId());
if (command == null) {
result.record(recorder.discardInvalid(entry, now));
continue;
}
SendReceipt receipt;
try {
receipt = commandGateway.send(DeviceCommandEnvelope.from(command,
entry.getAttemptCount(), entry.getTopologyVersion(),
entry.getFencingToken()));
if (receipt == null) {
throw new IllegalStateException("gateway returned null receipt");
}
} catch (RuntimeException ambiguous) {
receipt = SendReceipt.acceptedUnknown(
"AMBIGUOUS:" + entry.getCommandId() + ":"
+ entry.getAttemptCount(),
"GATEWAY_EXCEPTION_AFTER_SEND_ATTEMPT");
}
result.record(recorder.record(entry, receipt, now));
}
return result;
}
}

View File

@@ -33,9 +33,16 @@ public final class DeviceCommandEnvelope {
public static DeviceCommandEnvelope from(DeviceCommand command, public static DeviceCommandEnvelope from(DeviceCommand command,
long topologyVersion, long topologyVersion,
long fencingToken) { long fencingToken) {
return from(command, command.getAttempt(), topologyVersion, fencingToken);
}
public static DeviceCommandEnvelope from(DeviceCommand command,
int deliveryAttempt,
long topologyVersion,
long fencingToken) {
return new DeviceCommandEnvelope(command.getCommandId(), return new DeviceCommandEnvelope(command.getCommandId(),
command.getCommandKey(), command.getDeviceId(), command.getType(), command.getCommandKey(), command.getDeviceId(), command.getType(),
command.getPayload(), command.getAttempt(), topologyVersion, command.getPayload(), deliveryAttempt, topologyVersion,
fencingToken); fencingToken);
} }

View File

@@ -0,0 +1,93 @@
package com.ruoyi.wcs.application.device;
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
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.shared.DomainRuleViolation;
import com.ruoyi.wcs.domain.shared.IdGenerator;
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.Objects;
import java.util.function.Supplier;
public final class DeviceExecutionService {
private final DeviceCommandRepository commandRepository;
private final CommandOutboxRepository outboxRepository;
private final IdGenerator idGenerator;
private final Supplier<Instant> clock;
public DeviceExecutionService(DeviceCommandRepository commandRepository,
CommandOutboxRepository outboxRepository,
IdGenerator idGenerator,
Supplier<Instant> clock) {
this.commandRepository = Objects.requireNonNull(commandRepository,
"commandRepository");
this.outboxRepository = Objects.requireNonNull(outboxRepository,
"outboxRepository");
this.idGenerator = Objects.requireNonNull(idGenerator, "idGenerator");
this.clock = Objects.requireNonNull(clock, "clock");
}
@Transactional
public DeviceCommand createCommand(GrantedDispatch grant) {
Objects.requireNonNull(grant, "grant");
String commandKey = "DISPATCH:" + requireText(grant.getOrderId(), "orderId")
+ ":" + requireText(grant.getStepId(), "stepId");
String payload = encode(grant);
DeviceCommand existing = commandRepository.findByCommandKey(commandKey);
if (existing != null) {
if (!existing.getDeviceId().equals(grant.getVehicleId())
|| !existing.getPayload().equals(payload)) {
throw new DomainRuleViolation(
"command key reused with a different dispatch grant");
}
return existing;
}
Instant now = Objects.requireNonNull(clock.get(), "clock returned null");
DeviceCommand command = DeviceCommand.pending(
requireText(idGenerator.nextId(), "generated commandId"),
commandKey, grant.getVehicleId(), DeviceCommandType.MOVE_ROUTE,
payload, 1, now);
CommandOutboxEntry entry = CommandOutboxEntry.pending(
command.getCommandId(), command.getCommandKey(), payload, now,
grant.getTopologyVersion(), grant.getFencingToken());
commandRepository.save(command);
outboxRepository.saveIfAbsent(entry);
return command;
}
private static String encode(GrantedDispatch grant) {
StringBuilder route = new StringBuilder();
for (String node : grant.getProtectedRoute().getNodeIds()) {
if (route.length() > 0) {
route.append('>');
}
route.append(requireSafe(node, "route node"));
}
return "orderId=" + requireSafe(grant.getOrderId(), "orderId")
+ "|stepId=" + requireSafe(grant.getStepId(), "stepId")
+ "|vehicleId=" + requireSafe(grant.getVehicleId(), "vehicleId")
+ "|route=" + route;
}
private static String requireSafe(String value, String field) {
String text = requireText(value, field);
if (text.indexOf('|') >= 0 || text.indexOf('>') >= 0
|| text.indexOf('=') >= 0) {
throw new IllegalArgumentException(field + " contains reserved delimiter");
}
return text;
}
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,35 @@
package com.ruoyi.wcs.application.device;
import java.util.EnumMap;
import java.util.Map;
public final class OutboxDispatchResult {
public enum Outcome {
SENT,
RETRY_SCHEDULED,
ISOLATED_UNKNOWN,
REJECTED,
DEAD
}
private final Map<Outcome, Integer> counts =
new EnumMap<Outcome, Integer>(Outcome.class);
void record(Outcome outcome) {
Integer count = counts.get(outcome);
counts.put(outcome, count == null ? 1 : count + 1);
}
public int count(Outcome outcome) {
Integer count = counts.get(outcome);
return count == null ? 0 : count;
}
public int getTotal() {
int total = 0;
for (Integer count : counts.values()) {
total += count;
}
return total;
}
}

View File

@@ -16,7 +16,8 @@ public final class SendReceipt {
this.status = java.util.Objects.requireNonNull(status, "status"); this.status = java.util.Objects.requireNonNull(status, "status");
this.receiptId = receiptId; this.receiptId = receiptId;
this.detail = detail; this.detail = detail;
if ((status == Status.ACCEPTED || status == Status.ACCEPTED_UNKNOWN) if ((status == Status.ACCEPTED || status == Status.ACCEPTED_UNKNOWN
|| status == Status.REJECTED)
&& isBlank(receiptId)) { && isBlank(receiptId)) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"accepted send receipt must have a receiptId"); "accepted send receipt must have a receiptId");
@@ -35,8 +36,8 @@ public final class SendReceipt {
return new SendReceipt(Status.NOT_SENT, null, detail); return new SendReceipt(Status.NOT_SENT, null, detail);
} }
public static SendReceipt rejected(String detail) { public static SendReceipt rejected(String receiptId, String detail) {
return new SendReceipt(Status.REJECTED, null, detail); return new SendReceipt(Status.REJECTED, receiptId, detail);
} }
public Status getStatus() { return status; } public Status getStatus() { return status; }

View File

@@ -0,0 +1,77 @@
package com.ruoyi.wcs.application.device;
import com.ruoyi.wcs.domain.execution.WmsNotificationOutboxEntry;
import com.ruoyi.wcs.port.out.WmsNotificationGateway;
import com.ruoyi.wcs.port.out.WmsNotificationOutboxRepository;
import com.ruoyi.wcs.port.out.WmsNotificationReceipt;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
public final class WmsNotificationOutboxDispatcher {
private final WmsNotificationOutboxRepository repository;
private final WmsNotificationGateway gateway;
public WmsNotificationOutboxDispatcher(
WmsNotificationOutboxRepository repository,
WmsNotificationGateway gateway) {
this.repository = Objects.requireNonNull(repository, "repository");
this.gateway = Objects.requireNonNull(gateway, "gateway");
}
public OutboxDispatchResult dispatchDue(int limit, Instant now) {
if (limit <= 0) {
throw new IllegalArgumentException("limit must be positive");
}
Objects.requireNonNull(now, "now");
List<WmsNotificationOutboxEntry> entries = repository.claimDue(limit, now);
OutboxDispatchResult result = new OutboxDispatchResult();
for (WmsNotificationOutboxEntry entry : entries) {
WmsNotificationReceipt receipt;
try {
receipt = gateway.notify(entry.getNotification());
if (receipt == null) {
throw new IllegalStateException("WMS gateway returned null receipt");
}
} catch (RuntimeException transportFailure) {
receipt = new WmsNotificationReceipt(
WmsNotificationReceipt.Status.RETRYABLE_FAILURE,
"WMS_TRANSPORT_EXCEPTION");
}
switch (receipt.getStatus()) {
case DELIVERED:
entry.markDelivered(nonBlank(receipt.getDetail(),
"DELIVERED:" + entry.getEventId()), now);
repository.save(entry);
result.record(OutboxDispatchResult.Outcome.SENT);
break;
case RETRYABLE_FAILURE:
entry.reschedule(nonBlank(receipt.getDetail(),
"WMS_RETRYABLE_FAILURE"),
now.plusSeconds(backoffSeconds(entry.getAttemptCount())));
repository.save(entry);
result.record(OutboxDispatchResult.Outcome.RETRY_SCHEDULED);
break;
case PERMANENT_FAILURE:
entry.markDead(nonBlank(receipt.getDetail(),
"WMS_PERMANENT_FAILURE"), now);
repository.save(entry);
result.record(OutboxDispatchResult.Outcome.DEAD);
break;
default:
throw new IllegalStateException("unsupported WMS receipt");
}
}
return result;
}
private static long backoffSeconds(int attemptCount) {
int exponent = Math.min(Math.max(attemptCount, 1), 8);
return Math.min(300L, 1L << exponent);
}
private static String nonBlank(String value, String fallback) {
return value == null || value.trim().isEmpty() ? fallback : value.trim();
}
}

View File

@@ -0,0 +1,25 @@
package com.ruoyi.wcs.application.device;
import com.ruoyi.wcs.domain.execution.WmsNotificationOutboxEntry;
import com.ruoyi.wcs.port.out.WmsNotification;
import com.ruoyi.wcs.port.out.WmsNotificationOutboxRepository;
import java.time.Instant;
import java.util.Objects;
import org.springframework.transaction.annotation.Transactional;
public final class WmsNotificationOutboxProjector {
private final WmsNotificationOutboxRepository repository;
public WmsNotificationOutboxProjector(
WmsNotificationOutboxRepository repository) {
this.repository = Objects.requireNonNull(repository, "repository");
}
@Transactional
public void project(WmsNotification notification, Instant nextAttemptAt) {
Objects.requireNonNull(notification, "notification");
repository.saveIfAbsent(WmsNotificationOutboxEntry.pending(
notification.getEventId(), notification, nextAttemptAt));
}
}

View File

@@ -7,23 +7,73 @@ public final class CommandOutboxEntry {
private final String commandId; private final String commandId;
private final String commandKey; private final String commandKey;
private final String payload; private final String payload;
private final long topologyVersion;
private final long fencingToken;
private OutboxDeliveryState state; private OutboxDeliveryState state;
private int attemptCount; private int attemptCount;
private Instant nextAttemptAt; private Instant nextAttemptAt;
private String lastError; private String lastError;
private Instant deliveredAt;
private CommandOutboxEntry(String commandId, String commandKey, private CommandOutboxEntry(String commandId, String commandKey,
String payload, Instant nextAttemptAt) { String payload, Instant nextAttemptAt,
long topologyVersion, long fencingToken) {
this.commandId = requireText(commandId, "commandId"); this.commandId = requireText(commandId, "commandId");
this.commandKey = requireText(commandKey, "commandKey"); this.commandKey = requireText(commandKey, "commandKey");
this.payload = requireText(payload, "payload"); this.payload = requireText(payload, "payload");
if (topologyVersion <= 0L || fencingToken <= 0L) {
throw new IllegalArgumentException("outbox versions are invalid");
}
this.topologyVersion = topologyVersion;
this.fencingToken = fencingToken;
this.nextAttemptAt = Objects.requireNonNull(nextAttemptAt, "nextAttemptAt"); this.nextAttemptAt = Objects.requireNonNull(nextAttemptAt, "nextAttemptAt");
this.state = OutboxDeliveryState.PENDING; this.state = OutboxDeliveryState.PENDING;
} }
public static CommandOutboxEntry pending(String commandId, String commandKey, public static CommandOutboxEntry pending(String commandId, String commandKey,
String payload, Instant nextAttemptAt) { String payload, Instant nextAttemptAt) {
return new CommandOutboxEntry(commandId, commandKey, payload, nextAttemptAt); return pending(commandId, commandKey, payload, nextAttemptAt, 1L, 1L);
}
public static CommandOutboxEntry pending(String commandId, String commandKey,
String payload, Instant nextAttemptAt,
long topologyVersion,
long fencingToken) {
return new CommandOutboxEntry(commandId, commandKey, payload,
nextAttemptAt, topologyVersion, fencingToken);
}
public boolean claim(Instant now) {
Instant claimTime = Objects.requireNonNull(now, "now");
if (state != OutboxDeliveryState.PENDING
|| nextAttemptAt.isAfter(claimTime)) {
return false;
}
state = OutboxDeliveryState.IN_FLIGHT;
attemptCount++;
return true;
}
public void markDelivered(Instant at) {
requireInFlight();
deliveredAt = Objects.requireNonNull(at, "at");
lastError = null;
state = OutboxDeliveryState.DELIVERED;
}
public void reschedule(String error, Instant nextAttemptAt) {
requireInFlight();
lastError = requireText(error, "error");
this.nextAttemptAt = Objects.requireNonNull(nextAttemptAt,
"nextAttemptAt");
state = OutboxDeliveryState.PENDING;
}
public void markDead(String error, Instant at) {
requireInFlight();
lastError = requireText(error, "error");
deliveredAt = Objects.requireNonNull(at, "at");
state = OutboxDeliveryState.DEAD;
} }
public String getCommandId() { public String getCommandId() {
@@ -54,6 +104,17 @@ public final class CommandOutboxEntry {
return lastError; return lastError;
} }
public long getTopologyVersion() { return topologyVersion; }
public long getFencingToken() { return fencingToken; }
public Instant getDeliveredAt() { return deliveredAt; }
private void requireInFlight() {
if (state != OutboxDeliveryState.IN_FLIGHT) {
throw new com.ruoyi.wcs.domain.shared.DomainRuleViolation(
"outbox entry must be IN_FLIGHT");
}
}
private static String requireText(String value, String field) { private static String requireText(String value, String field) {
if (value == null || value.trim().isEmpty()) { if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(field + " must not be blank"); throw new IllegalArgumentException(field + " must not be blank");

View File

@@ -2,6 +2,7 @@ package com.ruoyi.wcs.domain.execution;
public enum OutboxDeliveryState { public enum OutboxDeliveryState {
PENDING, PENDING,
IN_FLIGHT,
DELIVERED, DELIVERED,
DEAD DEAD
} }

View File

@@ -8,8 +8,12 @@ import java.util.Objects;
public final class WmsNotificationOutboxEntry { public final class WmsNotificationOutboxEntry {
private final String eventId; private final String eventId;
private final WmsNotification notification; private final WmsNotification notification;
private final Instant nextAttemptAt; private Instant nextAttemptAt;
private final OutboxDeliveryState state; private OutboxDeliveryState state;
private int attemptCount;
private String lastError;
private String externalReceipt;
private Instant deliveredAt;
private WmsNotificationOutboxEntry(String eventId, private WmsNotificationOutboxEntry(String eventId,
WmsNotification notification, WmsNotification notification,
@@ -29,6 +33,40 @@ public final class WmsNotificationOutboxEntry {
return new WmsNotificationOutboxEntry(eventId, notification, nextAttemptAt); return new WmsNotificationOutboxEntry(eventId, notification, nextAttemptAt);
} }
public boolean claim(Instant now) {
Instant claimTime = Objects.requireNonNull(now, "now");
if (state != OutboxDeliveryState.PENDING
|| nextAttemptAt.isAfter(claimTime)) {
return false;
}
state = OutboxDeliveryState.IN_FLIGHT;
attemptCount++;
return true;
}
public void markDelivered(String receipt, Instant at) {
requireInFlight();
externalReceipt = requireText(receipt, "externalReceipt");
deliveredAt = Objects.requireNonNull(at, "at");
lastError = null;
state = OutboxDeliveryState.DELIVERED;
}
public void reschedule(String error, Instant nextAttemptAt) {
requireInFlight();
lastError = requireText(error, "error");
this.nextAttemptAt = Objects.requireNonNull(nextAttemptAt,
"nextAttemptAt");
state = OutboxDeliveryState.PENDING;
}
public void markDead(String error, Instant at) {
requireInFlight();
lastError = requireText(error, "error");
deliveredAt = Objects.requireNonNull(at, "at");
state = OutboxDeliveryState.DEAD;
}
public String getEventId() { public String getEventId() {
return eventId; return eventId;
} }
@@ -45,6 +83,18 @@ public final class WmsNotificationOutboxEntry {
return state; return state;
} }
public int getAttemptCount() { return attemptCount; }
public String getLastError() { return lastError; }
public String getExternalReceipt() { return externalReceipt; }
public Instant getDeliveredAt() { return deliveredAt; }
private void requireInFlight() {
if (state != OutboxDeliveryState.IN_FLIGHT) {
throw new com.ruoyi.wcs.domain.shared.DomainRuleViolation(
"WMS outbox entry must be IN_FLIGHT");
}
}
private static String requireText(String value, String field) { private static String requireText(String value, String field) {
if (value == null || value.trim().isEmpty()) { if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(field + " must not be blank"); throw new IllegalArgumentException(field + " must not be blank");

View File

@@ -8,5 +8,7 @@ import java.util.List;
public interface CommandOutboxRepository { public interface CommandOutboxRepository {
void saveIfAbsent(CommandOutboxEntry entry); void saveIfAbsent(CommandOutboxEntry entry);
List<CommandOutboxEntry> findDue(int limit, Instant now); void save(CommandOutboxEntry entry);
List<CommandOutboxEntry> claimDue(int limit, Instant now);
} }

View File

@@ -8,5 +8,7 @@ import java.util.List;
public interface WmsNotificationOutboxRepository { public interface WmsNotificationOutboxRepository {
void saveIfAbsent(WmsNotificationOutboxEntry entry); void saveIfAbsent(WmsNotificationOutboxEntry entry);
List<WmsNotificationOutboxEntry> findDue(int limit, Instant now); void save(WmsNotificationOutboxEntry entry);
List<WmsNotificationOutboxEntry> claimDue(int limit, Instant now);
} }

View File

@@ -0,0 +1,278 @@
package com.ruoyi.wcs.application.device;
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
import com.ruoyi.wcs.domain.execution.CommandOutboxEntry;
import com.ruoyi.wcs.domain.execution.DeviceCommand;
import com.ruoyi.wcs.domain.execution.DeviceCommandState;
import com.ruoyi.wcs.domain.execution.OutboxDeliveryState;
import com.ruoyi.wcs.domain.shared.IdGenerator;
import com.ruoyi.wcs.domain.traffic.RoutePlan;
import com.ruoyi.wcs.port.out.CommandOutboxRepository;
import com.ruoyi.wcs.port.out.DeviceCommandGateway;
import com.ruoyi.wcs.port.out.DeviceCommandRepository;
import com.ruoyi.wcs.port.out.RecoveryCaseOpener;
import com.ruoyi.wcs.port.out.ResourceReservationRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.ArrayList;
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;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CommandOutboxDispatcherTest {
private FakeCommandRepository commands;
private FakeCommandOutboxRepository outbox;
private FakeGateway gateway;
private FakeIsolation isolation;
private DeviceExecutionService service;
private CommandOutboxDispatcher dispatcher;
@BeforeEach
void setUp() {
commands = new FakeCommandRepository();
outbox = new FakeCommandOutboxRepository();
gateway = new FakeGateway();
isolation = new FakeIsolation();
IdGenerator ids = new IdGenerator() {
@Override
public String nextId() {
return "CMD-1";
}
};
service = new DeviceExecutionService(commands, outbox, ids,
new java.util.function.Supplier<Instant>() {
@Override
public Instant get() {
return at(0);
}
});
CommandDeliveryRecorder recorder = new CommandDeliveryRecorder(
commands, outbox, isolation, isolation);
dispatcher = new CommandOutboxDispatcher(outbox, gateway, recorder);
}
@Test
void persistsCommandAndOutboxBeforeCallingTheGateway() {
DeviceCommand created = service.createCommand(grantedDispatch());
assertNotNull(commands.findByCommandId(created.getCommandId()));
assertNotNull(outbox.byCommandId(created.getCommandId()));
assertEquals(0, gateway.sendCount);
}
@Test
void acceptedSendIsNotMarkedCompleted() {
service.createCommand(grantedDispatch());
gateway.receipt = SendReceipt.accepted("PLC-WRITE-1");
dispatcher.dispatchDue(10, at(0));
assertEquals(DeviceCommandState.SENT,
commands.findByCommandId("CMD-1").getState());
assertEquals(OutboxDeliveryState.DELIVERED,
outbox.byCommandId("CMD-1").getState());
}
@Test
void acceptedUnknownIsIsolatedAndNeverAutomaticallyRetried() {
service.createCommand(grantedDispatch());
gateway.receipt = SendReceipt.acceptedUnknown(
"PLC-WRITE-UNKNOWN-1", "WRITE_RESULT_AMBIGUOUS");
dispatcher.dispatchDue(10, at(0));
dispatcher.dispatchDue(10, at(100));
assertEquals(DeviceCommandState.UNKNOWN,
commands.findByCommandId("CMD-1").getState());
assertEquals(1, gateway.sendCount);
assertEquals(Collections.singletonList("WRITE_RESULT_AMBIGUOUS"),
isolation.reasons);
assertEquals(1, isolation.protectCount);
}
@Test
void onlyNotSentOutcomeIsAutomaticallyRetried() {
service.createCommand(grantedDispatch());
gateway.receipt = SendReceipt.notSent("SOCKET_NOT_OPEN");
dispatcher.dispatchDue(10, at(0));
dispatcher.dispatchDue(10, at(1));
assertEquals(DeviceCommandState.PENDING,
commands.findByCommandId("CMD-1").getState());
assertEquals(1, gateway.sendCount);
assertEquals(OutboxDeliveryState.PENDING,
outbox.byCommandId("CMD-1").getState());
assertTrue(outbox.byCommandId("CMD-1").getNextAttemptAt()
.isAfter(at(1)));
}
@Test
void retryKeepsCommandKeyButIncrementsTheDeliveryAttempt() {
service.createCommand(grantedDispatch());
gateway.receipts.add(SendReceipt.notSent("SOCKET_NOT_OPEN"));
gateway.receipts.add(SendReceipt.accepted("PLC-WRITE-2"));
dispatcher.dispatchDue(10, at(0));
dispatcher.dispatchDue(10, at(2));
assertEquals(Arrays.asList(1, 2), gateway.deliveryAttempts);
assertEquals(DeviceCommandState.SENT,
commands.findByCommandId("CMD-1").getState());
assertEquals("DISPATCH:ORDER-1:STEP-1",
commands.findByCommandId("CMD-1").getCommandKey());
}
@Test
void unexpectedGatewayExceptionIsAmbiguousAndNotRetried() {
service.createCommand(grantedDispatch());
gateway.failure = new IllegalStateException("connection reset after write");
dispatcher.dispatchDue(10, at(0));
dispatcher.dispatchDue(10, at(100));
assertEquals(DeviceCommandState.UNKNOWN,
commands.findByCommandId("CMD-1").getState());
assertEquals(1, gateway.sendCount);
assertEquals(OutboxDeliveryState.DELIVERED,
outbox.byCommandId("CMD-1").getState());
}
@Test
void staleOutboxCannotSendAfterTheCommandAlreadyLeftPending() {
DeviceCommand command = service.createCommand(grantedDispatch());
command.markSent("ALREADY-SENT", at(0));
commands.save(command);
dispatcher.dispatchDue(10, at(0));
assertEquals(0, gateway.sendCount);
assertEquals(OutboxDeliveryState.DEAD,
outbox.byCommandId("CMD-1").getState());
}
private static GrantedDispatch grantedDispatch() {
RoutePlan route = new RoutePlan(Arrays.asList("A", "B"),
Arrays.asList("NODE:A", "EDGE:A:B", "NODE:B"),
1, 0, 7L);
return new GrantedDispatch("ORDER-1", "STEP-1", "VEH-1", route,
Collections.singletonList("RES-1"), 7L, 11L);
}
private static Instant at(long seconds) {
return Instant.EPOCH.plusSeconds(seconds);
}
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 FakeCommandOutboxRepository
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) {
List<CommandOutboxEntry> result =
new ArrayList<CommandOutboxEntry>();
for (CommandOutboxEntry entry : values.values()) {
if (result.size() >= limit) {
break;
}
if (entry.claim(now)) {
result.add(entry);
}
}
return result;
}
CommandOutboxEntry byCommandId(String commandId) {
return values.get(commandId);
}
}
private static final class FakeGateway implements DeviceCommandGateway {
private SendReceipt receipt = SendReceipt.accepted("PLC-WRITE-1");
private RuntimeException failure;
private int sendCount;
private final List<SendReceipt> receipts = new ArrayList<SendReceipt>();
private final List<Integer> deliveryAttempts = new ArrayList<Integer>();
@Override
public SendReceipt send(DeviceCommandEnvelope envelope) {
sendCount++;
deliveryAttempts.add(envelope.getAttempt());
if (failure != null) {
throw failure;
}
return receipts.isEmpty() ? receipt : receipts.remove(0);
}
}
private static final class FakeIsolation implements RecoveryCaseOpener,
ResourceReservationRepository {
private final List<String> reasons = new ArrayList<String>();
private int protectCount;
@Override
public void open(String deviceId, String commandKey, String reason,
List<String> evidenceIds) {
reasons.add(reason);
}
@Override
public void releasePassedResources(String commandKey,
List<String> resourceKeys,
String releaseEvidenceId,
long fencingToken) {
}
@Override
public void protectForUnknown(String commandKey, String evidenceId) {
protectCount++;
}
}
}

View File

@@ -0,0 +1,130 @@
package com.ruoyi.wcs.application.device;
import com.ruoyi.wcs.domain.execution.OutboxDeliveryState;
import com.ruoyi.wcs.domain.execution.WmsNotificationOutboxEntry;
import com.ruoyi.wcs.port.out.WmsNotification;
import com.ruoyi.wcs.port.out.WmsNotificationGateway;
import com.ruoyi.wcs.port.out.WmsNotificationOutboxRepository;
import com.ruoyi.wcs.port.out.WmsNotificationReceipt;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.ArrayList;
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 WmsNotificationOutboxDispatcherTest {
@Test
void duplicateBusinessEventCreatesOneWmsOutboxEntry() {
FakeWmsOutbox repository = new FakeWmsOutbox();
WmsNotificationOutboxProjector projector =
new WmsNotificationOutboxProjector(repository);
WmsNotification notification = notification("EVENT-1");
projector.project(notification, at(0));
projector.project(notification, at(0));
assertEquals(1, repository.values.size());
}
@Test
void transportFailureRetriesWithoutChangingTheBusinessEventKey() {
FakeWmsOutbox repository = new FakeWmsOutbox();
repository.saveIfAbsent(WmsNotificationOutboxEntry.pending(
"EVENT-1", notification("EVENT-1"), at(0)));
FakeWmsGateway gateway = new FakeWmsGateway();
gateway.receipt = new WmsNotificationReceipt(
WmsNotificationReceipt.Status.RETRYABLE_FAILURE, "HTTP_503");
WmsNotificationOutboxDispatcher dispatcher =
new WmsNotificationOutboxDispatcher(repository, gateway);
dispatcher.dispatchDue(10, at(0));
dispatcher.dispatchDue(10, at(1));
WmsNotificationOutboxEntry entry = repository.values.get("EVENT-1");
assertEquals("EVENT-1", entry.getEventId());
assertEquals(1, gateway.calls);
assertEquals(OutboxDeliveryState.PENDING, entry.getState());
}
@Test
void deliveredCallbackIsNeverSentAgain() {
FakeWmsOutbox repository = new FakeWmsOutbox();
repository.saveIfAbsent(WmsNotificationOutboxEntry.pending(
"EVENT-1", notification("EVENT-1"), at(0)));
FakeWmsGateway gateway = new FakeWmsGateway();
gateway.receipt = new WmsNotificationReceipt(
WmsNotificationReceipt.Status.DELIVERED, "WMS-RECEIPT-1");
WmsNotificationOutboxDispatcher dispatcher =
new WmsNotificationOutboxDispatcher(repository, gateway);
dispatcher.dispatchDue(10, at(0));
dispatcher.dispatchDue(10, at(100));
assertEquals(1, gateway.calls);
assertEquals(OutboxDeliveryState.DELIVERED,
repository.values.get("EVENT-1").getState());
assertEquals("WMS-RECEIPT-1",
repository.values.get("EVENT-1").getExternalReceipt());
}
private static WmsNotification notification(String eventId) {
return new WmsNotification(eventId, "REQ-1", "ORDER-1", at(0),
"OrderCompleted", "OK", null,
Collections.<String, String>emptyMap());
}
private static Instant at(long seconds) {
return Instant.EPOCH.plusSeconds(seconds);
}
private static final class FakeWmsOutbox
implements WmsNotificationOutboxRepository {
private final Map<String, WmsNotificationOutboxEntry> values =
new LinkedHashMap<String, WmsNotificationOutboxEntry>();
@Override
public void saveIfAbsent(WmsNotificationOutboxEntry entry) {
if (!values.containsKey(entry.getEventId())) {
values.put(entry.getEventId(), entry);
}
}
@Override
public void save(WmsNotificationOutboxEntry entry) {
values.put(entry.getEventId(), entry);
}
@Override
public List<WmsNotificationOutboxEntry> claimDue(int limit, Instant now) {
List<WmsNotificationOutboxEntry> result =
new ArrayList<WmsNotificationOutboxEntry>();
for (WmsNotificationOutboxEntry entry : values.values()) {
if (result.size() >= limit) {
break;
}
if (entry.claim(now)) {
result.add(entry);
}
}
return result;
}
}
private static final class FakeWmsGateway
implements WmsNotificationGateway {
private WmsNotificationReceipt receipt;
private int calls;
@Override
public WmsNotificationReceipt notify(WmsNotification notification) {
calls++;
return receipt;
}
}
}