feat(wcs): deliver device commands through outbox
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.ruoyi.wcs.domain.execution;
|
|||||||
|
|
||||||
public enum OutboxDeliveryState {
|
public enum OutboxDeliveryState {
|
||||||
PENDING,
|
PENDING,
|
||||||
|
IN_FLIGHT,
|
||||||
DELIVERED,
|
DELIVERED,
|
||||||
DEAD
|
DEAD
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user