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,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;
}
}
}