feat(wcs): deliver device commands through outbox
This commit is contained in:
@@ -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