feat(ft546): run standard WCS as the single scheduler

This commit is contained in:
zgw
2026-08-11 16:57:55 +08:00
parent f53b250336
commit c80acf0d99
18 changed files with 737 additions and 4 deletions

View File

@@ -0,0 +1,57 @@
package com.ruoyi.wcs.application;
import com.ruoyi.wcs.application.device.OutboxDispatchResult;
import com.ruoyi.wcs.application.dispatch.DispatchCycleResult;
public final class RuntimeTickResult {
public enum Status {
COMPLETED,
RECOVERY_BLOCKED
}
private final Status status;
private final DispatchCycleResult dispatchResult;
private final OutboxDispatchResult outboxResult;
private final int createdCommandCount;
private RuntimeTickResult(Status status,
DispatchCycleResult dispatchResult,
OutboxDispatchResult outboxResult,
int createdCommandCount) {
this.status = status;
this.dispatchResult = dispatchResult;
this.outboxResult = outboxResult;
this.createdCommandCount = createdCommandCount;
}
public static RuntimeTickResult completed(
DispatchCycleResult dispatchResult,
OutboxDispatchResult outboxResult, int createdCommandCount) {
if (dispatchResult == null || outboxResult == null
|| createdCommandCount < 0) {
throw new IllegalArgumentException("completed tick result is invalid");
}
return new RuntimeTickResult(Status.COMPLETED, dispatchResult,
outboxResult, createdCommandCount);
}
public static RuntimeTickResult recoveryBlocked() {
return new RuntimeTickResult(Status.RECOVERY_BLOCKED, null, null, 0);
}
public Status getStatus() {
return status;
}
public DispatchCycleResult getDispatchResult() {
return dispatchResult;
}
public OutboxDispatchResult getOutboxResult() {
return outboxResult;
}
public int getCreatedCommandCount() {
return createdCommandCount;
}
}

View File

@@ -0,0 +1,96 @@
package com.ruoyi.wcs.application;
import com.ruoyi.wcs.application.device.CommandOutboxDispatcher;
import com.ruoyi.wcs.application.device.DeviceExecutionService;
import com.ruoyi.wcs.application.device.OutboxDispatchResult;
import com.ruoyi.wcs.application.device.SnapshotCollector;
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
import com.ruoyi.wcs.application.dispatch.DispatchCycleResult;
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
import com.ruoyi.wcs.application.recovery.RecoveryGate;
import com.ruoyi.wcs.domain.shared.WcsClock;
import com.ruoyi.wcs.port.out.DispatchSnapshotProvider;
import java.time.Instant;
import java.util.Objects;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
public final class WcsRuntimeCoordinator {
private final SnapshotCollector snapshotCollector;
private final RecoveryGate recoveryGate;
private final Supplier<DispatchSnapshot> snapshotLoader;
private final Function<DispatchSnapshot, DispatchCycleResult> dispatcher;
private final Consumer<GrantedDispatch> commandCreator;
private final BiFunction<Integer, Instant, OutboxDispatchResult> outboxSender;
private final WcsClock clock;
private final int outboxBatchLimit;
public WcsRuntimeCoordinator(SnapshotCollector snapshotCollector,
RecoveryGate recoveryGate,
DispatchSnapshotProvider snapshotProvider,
DispatchCoordinator dispatchCoordinator,
DeviceExecutionService executionService,
CommandOutboxDispatcher outboxDispatcher,
WcsClock clock, int outboxBatchLimit) {
this(snapshotCollector, recoveryGate,
Objects.requireNonNull(snapshotProvider,
"snapshotProvider")::load,
Objects.requireNonNull(dispatchCoordinator,
"dispatchCoordinator")::dispatch,
grant -> Objects.requireNonNull(executionService,
"executionService").createCommand(grant),
(limit, now) -> Objects.requireNonNull(outboxDispatcher,
"outboxDispatcher").dispatchDue(limit, now),
clock, outboxBatchLimit);
}
WcsRuntimeCoordinator(SnapshotCollector snapshotCollector,
RecoveryGate recoveryGate,
Supplier<DispatchSnapshot> snapshotLoader,
Function<DispatchSnapshot, DispatchCycleResult> dispatcher,
Consumer<GrantedDispatch> commandCreator,
BiFunction<Integer, Instant, OutboxDispatchResult> outboxSender,
WcsClock clock, int outboxBatchLimit) {
this.snapshotCollector = Objects.requireNonNull(snapshotCollector,
"snapshotCollector");
this.recoveryGate = Objects.requireNonNull(recoveryGate,
"recoveryGate");
this.snapshotLoader = Objects.requireNonNull(snapshotLoader,
"snapshotLoader");
this.dispatcher = Objects.requireNonNull(dispatcher, "dispatcher");
this.commandCreator = Objects.requireNonNull(commandCreator,
"commandCreator");
this.outboxSender = Objects.requireNonNull(outboxSender,
"outboxSender");
this.clock = Objects.requireNonNull(clock, "clock");
if (outboxBatchLimit <= 0) {
throw new IllegalArgumentException(
"outboxBatchLimit must be positive");
}
this.outboxBatchLimit = outboxBatchLimit;
}
public RuntimeTickResult tick() {
snapshotCollector.collectAndAccept();
if (!recoveryGate.allowsDispatch()) {
return RuntimeTickResult.recoveryBlocked();
}
DispatchCycleResult dispatch = Objects.requireNonNull(
dispatcher.apply(Objects.requireNonNull(snapshotLoader.get(),
"snapshot provider returned null")),
"dispatch coordinator returned null");
int createdCommands = 0;
for (GrantedDispatch grant : dispatch.getGranted()) {
commandCreator.accept(grant);
createdCommands++;
}
OutboxDispatchResult outbox = Objects.requireNonNull(
outboxSender.apply(outboxBatchLimit, clock.now()),
"outbox dispatcher returned null");
return RuntimeTickResult.completed(dispatch, outbox, createdCommands);
}
}

View File

@@ -11,7 +11,9 @@ import com.ruoyi.wcs.port.out.DeviceCommandRepository;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import java.util.function.Supplier;
public final class DeviceExecutionService {
@@ -69,10 +71,24 @@ public final class DeviceExecutionService {
}
route.append(requireSafe(node, "route node"));
}
return "orderId=" + requireSafe(grant.getOrderId(), "orderId")
StringBuilder payload = new StringBuilder(
"orderId=" + requireSafe(grant.getOrderId(), "orderId")
+ "|stepId=" + requireSafe(grant.getStepId(), "stepId")
+ "|vehicleId=" + requireSafe(grant.getVehicleId(), "vehicleId")
+ "|route=" + route;
+ "|route=" + route);
for (Map.Entry<String, String> entry : new TreeMap<String, String>(
grant.getCommandParameters()).entrySet()) {
String key = requireSafe(entry.getKey(), "command parameter key");
if ("orderId".equals(key) || "stepId".equals(key)
|| "vehicleId".equals(key) || "route".equals(key)) {
throw new IllegalArgumentException(
"command parameter overrides reserved field " + key);
}
payload.append('|').append(key).append('=')
.append(requireSafe(entry.getValue(),
"command parameter " + key));
}
return payload.toString();
}
private static String requireSafe(String value, String field) {

View File

@@ -133,7 +133,9 @@ public final class FeedbackReconciler
if (!execution.getTaskNumber().equals(snapshot.getTaskNumber())) {
return recovery(snapshot, execution, "TASK_NUMBER_MISMATCH");
}
if (!execution.getExpectedBootId().equals(snapshot.getDeviceBootId())) {
if (snapshot.hasDeviceBootSession()
&& !execution.getExpectedBootId().equals(
snapshot.getDeviceBootId())) {
return recovery(snapshot, execution, "DEVICE_RESTARTED");
}
if (execution.getTopologyVersion() != snapshot.getTopologyVersion()) {
@@ -142,6 +144,11 @@ public final class FeedbackReconciler
if (execution.getFencingToken() != snapshot.getFencingToken()) {
return recovery(snapshot, execution, "FENCING_TOKEN_MISMATCH");
}
if (!snapshot.hasOrderedDeviceSequence()) {
return new ReconcileResult(ReconcileStatus.CONSISTENT,
execution.getCommandKey(), Collections.<String>emptyList(),
"LEGACY_UNORDERED_DIAGNOSTIC_ONLY");
}
if (snapshot.getDeviceSequence() <= execution.getLastDeviceSequence()) {
return new ReconcileResult(ReconcileStatus.IGNORED_STALE,
execution.getCommandKey(), Collections.<String>emptyList(), null);

View File

@@ -0,0 +1,6 @@
package com.ruoyi.wcs.application.device;
@FunctionalInterface
public interface SnapshotCollector {
void collectAndAccept();
}

View File

@@ -114,7 +114,9 @@ public final class DispatchCoordinator {
granted.add(new GrantedDispatch(task.getOrderId(), task.getStepId(),
assignment.getVehicleId(), assignment.getRoute(),
Collections.singletonList(reservation.getReservationId()),
snapshot.getTopology().version(), reservation.getFencingToken()));
snapshot.getTopology().version(),
reservation.getFencingToken(),
task.getCommandParameters()));
} else {
HeldDispatch blocked = new HeldDispatch(task.getOrderId(),
task.getStepId(), assignment.getVehicleId(), traffic.getType(),

View File

@@ -4,7 +4,10 @@ import com.ruoyi.wcs.domain.traffic.RoutePlan;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public final class GrantedDispatch {
private final String orderId;
@@ -14,10 +17,20 @@ public final class GrantedDispatch {
private final List<String> reservationIds;
private final long topologyVersion;
private final long fencingToken;
private final Map<String, String> commandParameters;
public GrantedDispatch(String orderId, String stepId, String vehicleId,
RoutePlan protectedRoute, List<String> reservationIds,
long topologyVersion, long fencingToken) {
this(orderId, stepId, vehicleId, protectedRoute, reservationIds,
topologyVersion, fencingToken,
Collections.<String, String>emptyMap());
}
public GrantedDispatch(String orderId, String stepId, String vehicleId,
RoutePlan protectedRoute, List<String> reservationIds,
long topologyVersion, long fencingToken,
Map<String, String> commandParameters) {
this.orderId = orderId;
this.stepId = stepId;
this.vehicleId = vehicleId;
@@ -26,6 +39,9 @@ public final class GrantedDispatch {
new ArrayList<String>(reservationIds));
this.topologyVersion = topologyVersion;
this.fencingToken = fencingToken;
this.commandParameters = Collections.unmodifiableMap(
new LinkedHashMap<String, String>(Objects.requireNonNull(
commandParameters, "commandParameters")));
}
public String getOrderId() {
@@ -55,4 +71,8 @@ public final class GrantedDispatch {
public long getFencingToken() {
return fencingToken;
}
public Map<String, String> getCommandParameters() {
return commandParameters;
}
}

View File

@@ -0,0 +1,6 @@
package com.ruoyi.wcs.application.recovery;
@FunctionalInterface
public interface RecoveryGate {
boolean allowsDispatch();
}

View File

@@ -4,6 +4,8 @@ import com.ruoyi.wcs.domain.equipment.DeviceCapability;
import java.util.Collections;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@@ -16,11 +18,22 @@ public final class DispatchTask {
private final long waitingAgeSeconds;
private final int minimumBattery;
private final Set<DeviceCapability> requiredCapabilities;
private final Map<String, String> commandParameters;
public DispatchTask(String orderId, String stepId,
String sourceNodeId, String destinationNodeId,
int priority, long waitingAgeSeconds, int minimumBattery,
Set<DeviceCapability> requiredCapabilities) {
this(orderId, stepId, sourceNodeId, destinationNodeId, priority,
waitingAgeSeconds, minimumBattery, requiredCapabilities,
Collections.<String, String>emptyMap());
}
public DispatchTask(String orderId, String stepId,
String sourceNodeId, String destinationNodeId,
int priority, long waitingAgeSeconds, int minimumBattery,
Set<DeviceCapability> requiredCapabilities,
Map<String, String> commandParameters) {
this.orderId = requireText(orderId, "orderId");
this.stepId = requireText(stepId, "stepId");
this.sourceNodeId = requireText(sourceNodeId, "sourceNodeId");
@@ -39,6 +52,7 @@ public final class DispatchTask {
? EnumSet.noneOf(DeviceCapability.class)
: EnumSet.copyOf(requiredCapabilities);
this.requiredCapabilities = Collections.unmodifiableSet(copied);
this.commandParameters = immutableParameters(commandParameters);
}
public String getOrderId() {
@@ -73,6 +87,21 @@ public final class DispatchTask {
return requiredCapabilities;
}
public Map<String, String> getCommandParameters() {
return commandParameters;
}
private static Map<String, String> immutableParameters(
Map<String, String> source) {
Objects.requireNonNull(source, "commandParameters");
Map<String, String> copy = new LinkedHashMap<String, String>();
for (Map.Entry<String, String> entry : source.entrySet()) {
copy.put(requireText(entry.getKey(), "command parameter key"),
requireText(entry.getValue(), "command parameter value"));
}
return Collections.unmodifiableMap(copy);
}
private static String requireText(String value, String field) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(field + " must not be blank");

View File

@@ -0,0 +1,125 @@
package com.ruoyi.wcs.application;
import com.ruoyi.wcs.application.device.OutboxDispatchResult;
import com.ruoyi.wcs.application.dispatch.DispatchCycleResult;
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
import com.ruoyi.wcs.application.device.SnapshotCollector;
import com.ruoyi.wcs.application.recovery.RecoveryGate;
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
import com.ruoyi.wcs.domain.topology.TopologyCompiler;
import com.ruoyi.wcs.domain.topology.TopologyDefinition;
import com.ruoyi.wcs.domain.topology.TopologyNode;
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
import com.ruoyi.wcs.domain.traffic.RoutePlan;
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.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class WcsRuntimeCoordinatorTest {
@Test
void tickRunsSnapshotRecoveryDispatchCommandAndOutboxInOrder() {
List<String> calls = new ArrayList<String>();
SnapshotCollector snapshots = () -> calls.add("snapshot");
RecoveryGate recovery = () -> {
calls.add("recovery");
return true;
};
GrantedDispatch grant = grant();
DispatchCycleResult dispatchResult = DispatchCycleResult.committed(
Collections.singletonList(grant),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList());
OutboxDispatchResult outboxResult = new OutboxDispatchResult();
WcsRuntimeCoordinator coordinator = new WcsRuntimeCoordinator(
snapshots, recovery,
() -> {
calls.add("load");
return snapshot();
},
value -> {
calls.add("dispatch");
return dispatchResult;
},
value -> calls.add("command:" + value.getStepId()),
(limit, now) -> {
calls.add("outbox:" + limit);
return outboxResult;
},
() -> Instant.parse("2026-08-11T08:00:00Z"), 100);
RuntimeTickResult result = coordinator.tick();
assertEquals(Arrays.asList("snapshot", "recovery", "load",
"dispatch", "command:STEP-1", "outbox:100"), calls);
assertEquals(RuntimeTickResult.Status.COMPLETED, result.getStatus());
assertEquals(1, result.getCreatedCommandCount());
assertEquals(dispatchResult, result.getDispatchResult());
assertEquals(outboxResult, result.getOutboxResult());
}
@Test
void recoveryGateStopsBeforeDispatchAndOutbox() {
List<String> calls = new ArrayList<String>();
WcsRuntimeCoordinator coordinator = new WcsRuntimeCoordinator(
() -> calls.add("snapshot"),
() -> {
calls.add("recovery");
return false;
},
() -> {
calls.add("load");
return snapshot();
},
value -> {
calls.add("dispatch");
return DispatchCycleResult.retrySnapshotChanged();
},
value -> calls.add("command"),
(limit, now) -> {
calls.add("outbox");
return new OutboxDispatchResult();
},
Instant::now, 100);
RuntimeTickResult result = coordinator.tick();
assertEquals(Arrays.asList("snapshot", "recovery"), calls);
assertEquals(RuntimeTickResult.Status.RECOVERY_BLOCKED,
result.getStatus());
assertEquals(0, result.getCreatedCommandCount());
assertNull(result.getDispatchResult());
assertNull(result.getOutboxResult());
}
private static GrantedDispatch grant() {
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 DispatchSnapshot snapshot() {
EffectiveTopology topology = new TopologyCompiler().compile(
new TopologyDefinition("FT546", 7L, true,
Arrays.asList(
new TopologyNode("A", "F1", 0, 0, 1,
false, false),
new TopologyNode("B", "F1", 1, 0, 1,
false, false)),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList(), Collections.emptyList()));
return new DispatchSnapshot(Collections.emptyList(),
Collections.emptyList(), topology,
new ResourceLedger(topology), 1L);
}
}

View File

@@ -68,6 +68,26 @@ class CommandOutboxDispatcherTest {
assertEquals(0, gateway.sendCount);
}
@Test
void explicitTaskNumberAndTypeReachTheDevicePayload() {
Map<String, String> commandParameters =
new LinkedHashMap<String, String>();
commandParameters.put("taskNo", "30001");
commandParameters.put("taskType", "1");
RoutePlan route = new RoutePlan(Arrays.asList("A", "B"),
Arrays.asList("NODE:A", "EDGE:A:B", "NODE:B"),
1, 0, 7L);
GrantedDispatch grant = new GrantedDispatch("ORDER-1", "STEP-1",
"VEH-1", route, Collections.singletonList("RES-1"), 7L,
11L, commandParameters);
DeviceCommand command = service.createCommand(grant);
assertEquals("orderId=ORDER-1|stepId=STEP-1|vehicleId=VEH-1"
+ "|route=A>B|taskNo=30001|taskType=1",
command.getPayload());
}
@Test
void acceptedSendIsNotMarkedCompleted() {
service.createCommand(grantedDispatch());

View File

@@ -83,6 +83,24 @@ class FeedbackReconcilerTest {
recovery.reasons);
}
@Test
void unorderedSnapshotWithoutBootSessionIsDiagnosticOnly() {
DeviceSnapshot snapshot = DeviceSnapshot.unorderedWithoutBootSession(
"SNAP-LEGACY-1", "VEH-1", "CMD-KEY-1", "TASK-1",
at(5), true, 7L, 11L, "B", Arrays.asList("B", "C"),
false, Collections.<String, String>emptyMap());
ReconcileResult result = reconciler.reconcile(snapshot);
assertEquals(ReconcileStatus.CONSISTENT, result.getStatus());
assertEquals("LEGACY_UNORDERED_DIAGNOSTIC_ONLY",
result.getRecoveryReason());
assertTrue(recovery.reasons.isEmpty());
assertEquals(0, reservations.releaseCallCount);
assertEquals(-1L, executions.findByCommandKey("CMD-KEY-1")
.getLastDeviceSequence());
}
@Test
void releasesOnlyResourcesStrictlyPassedByMatchingProgress() {
DeviceSnapshot snapshot = snapshot("TASK-1", "B",

View File

@@ -25,6 +25,8 @@ import java.time.Instant;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -77,6 +79,27 @@ class DispatchCoordinatorTest {
assertEquals(1, repository.commitCount);
}
@Test
void carriesExplicitDeviceCommandParametersIntoTheGrant() {
EffectiveTopology topology = topology();
Map<String, String> commandParameters =
new LinkedHashMap<String, String>();
commandParameters.put("taskNo", "30001");
commandParameters.put("taskType", "1");
DispatchTask task = new DispatchTask("O1", "S1", "A", "X", 1,
60L, 30, Collections.<DeviceCapability>emptySet(),
commandParameters);
DispatchCycleResult result = coordinator(
new RecordingTrafficRepository(true)).dispatch(
new DispatchSnapshot(Collections.singletonList(task),
Collections.singletonList(vehicle("V1", "A")),
topology, new ResourceLedger(topology), 11L));
assertEquals(commandParameters,
result.getGranted().get(0).getCommandParameters());
}
private static DispatchCoordinator coordinator(TrafficStateRepository repository) {
AStarRoutePlanner routePlanner = new AStarRoutePlanner();
AtomicInteger sequence = new AtomicInteger();