feat(javaTest): scope PLC retries by business phase

This commit is contained in:
zgw
2026-08-07 14:12:09 +08:00
parent 48f0cad832
commit 6169fbcb46
4 changed files with 177 additions and 31 deletions

View File

@@ -44,7 +44,8 @@ public final class DeterministicFaultPlan {
public VehicleEventType getType() { return type; } public VehicleEventType getType() { return type; }
} }
private final Map<String, Map<Integer, SimulatedPlcGateway.PlcReason>> plcFailures; private final Map<String, Map<Integer, SimulatedPlcGateway.PlcReason>> plcTaskFailures;
private final Map<String, Map<Integer, SimulatedPlcGateway.PlcReason>> plcCommandFailures;
private final Map<Long, List<VehicleEvent>> vehicleEvents; private final Map<Long, List<VehicleEvent>> vehicleEvents;
/** /**
@@ -55,9 +56,11 @@ public final class DeterministicFaultPlan {
* @param vehicleEvents 执行该处理所需的 vehicle events 输入。 * @param vehicleEvents 执行该处理所需的 vehicle events 输入。
*/ */
private DeterministicFaultPlan( private DeterministicFaultPlan(
Map<String, Map<Integer, SimulatedPlcGateway.PlcReason>> plcFailures, Map<String, Map<Integer, SimulatedPlcGateway.PlcReason>> plcTaskFailures,
Map<String, Map<Integer, SimulatedPlcGateway.PlcReason>> plcCommandFailures,
Map<Long, List<VehicleEvent>> vehicleEvents) { Map<Long, List<VehicleEvent>> vehicleEvents) {
this.plcFailures = plcFailures; this.plcTaskFailures = plcTaskFailures;
this.plcCommandFailures = plcCommandFailures;
this.vehicleEvents = vehicleEvents; this.vehicleEvents = vehicleEvents;
} }
@@ -86,10 +89,23 @@ public final class DeterministicFaultPlan {
* @return 本次处理产生的结果;调用方据此继续后续流程。 * @return 本次处理产生的结果;调用方据此继续后续流程。
*/ */
SimulatedPlcGateway.PlcReason plcReason(String taskId, int attempt) { SimulatedPlcGateway.PlcReason plcReason(String taskId, int attempt) {
Map<Integer, SimulatedPlcGateway.PlcReason> byAttempt = plcFailures.get(taskId); Map<Integer, SimulatedPlcGateway.PlcReason> byAttempt = plcTaskFailures.get(taskId);
return byAttempt == null ? null : byAttempt.get(attempt); return byAttempt == null ? null : byAttempt.get(attempt);
} }
/**
* 先匹配具体业务阶段命令的第 N 次发送;没有阶段配置时,再按父任务总发送次数兼容旧故障计划。
*/
SimulatedPlcGateway.PlcReason plcReason(String parentTaskId, String commandId,
int commandAttempt, int taskAttempt) {
Map<Integer, SimulatedPlcGateway.PlcReason> byCommand =
plcCommandFailures.get(commandId);
SimulatedPlcGateway.PlcReason commandReason = byCommand == null
? null : byCommand.get(commandAttempt);
return commandReason == null
? plcReason(parentTaskId, taskAttempt) : commandReason;
}
/** /**
* 执行“events at”所表示的处理流程。 * 执行“events at”所表示的处理流程。
* *
@@ -103,7 +119,9 @@ public final class DeterministicFaultPlan {
} }
public static final class Builder { public static final class Builder {
private final Map<String, Map<Integer, SimulatedPlcGateway.PlcReason>> plcFailures = private final Map<String, Map<Integer, SimulatedPlcGateway.PlcReason>> plcTaskFailures =
new LinkedHashMap<String, Map<Integer, SimulatedPlcGateway.PlcReason>>();
private final Map<String, Map<Integer, SimulatedPlcGateway.PlcReason>> plcCommandFailures =
new LinkedHashMap<String, Map<Integer, SimulatedPlcGateway.PlcReason>>(); new LinkedHashMap<String, Map<Integer, SimulatedPlcGateway.PlcReason>>();
private final Map<Long, List<VehicleEvent>> vehicleEvents = private final Map<Long, List<VehicleEvent>> vehicleEvents =
new LinkedHashMap<Long, List<VehicleEvent>>(); new LinkedHashMap<Long, List<VehicleEvent>>();
@@ -119,18 +137,31 @@ public final class DeterministicFaultPlan {
*/ */
public Builder plcFailure(String taskId, int attempt, public Builder plcFailure(String taskId, int attempt,
SimulatedPlcGateway.PlcReason reason) { SimulatedPlcGateway.PlcReason reason) {
if (taskId == null || attempt <= 0 || reason == null addPlcFailure(plcTaskFailures, taskId, attempt, reason);
return this;
}
/** 为某一业务阶段命令配置独立失败,不消耗其他阶段的重试序号。 */
public Builder plcCommandFailure(String commandId, int attempt,
SimulatedPlcGateway.PlcReason reason) {
addPlcFailure(plcCommandFailures, commandId, attempt, reason);
return this;
}
private void addPlcFailure(
Map<String, Map<Integer, SimulatedPlcGateway.PlcReason>> failures,
String key, int attempt, SimulatedPlcGateway.PlcReason reason) {
if (key == null || key.trim().isEmpty() || attempt <= 0 || reason == null
|| reason == SimulatedPlcGateway.PlcReason.ACK || reason == SimulatedPlcGateway.PlcReason.ACK
|| reason == SimulatedPlcGateway.PlcReason.STALE_BOOT_ID) { || reason == SimulatedPlcGateway.PlcReason.STALE_BOOT_ID) {
throw new IllegalArgumentException("valid PLC failure is required"); throw new IllegalArgumentException("valid PLC failure is required");
} }
Map<Integer, SimulatedPlcGateway.PlcReason> byAttempt = plcFailures.get(taskId); Map<Integer, SimulatedPlcGateway.PlcReason> byAttempt = failures.get(key);
if (byAttempt == null) { if (byAttempt == null) {
byAttempt = new LinkedHashMap<Integer, SimulatedPlcGateway.PlcReason>(); byAttempt = new LinkedHashMap<Integer, SimulatedPlcGateway.PlcReason>();
plcFailures.put(taskId, byAttempt); failures.put(key, byAttempt);
} }
byAttempt.put(attempt, reason); byAttempt.put(attempt, reason);
return this;
} }
/** /**
@@ -162,21 +193,30 @@ public final class DeterministicFaultPlan {
* @return 本次处理产生的结果;调用方据此继续后续流程。 * @return 本次处理产生的结果;调用方据此继续后续流程。
*/ */
public DeterministicFaultPlan build() { public DeterministicFaultPlan build() {
Map<String, Map<Integer, SimulatedPlcGateway.PlcReason>> plc = Map<String, Map<Integer, SimulatedPlcGateway.PlcReason>> taskPlc =
new LinkedHashMap<String, Map<Integer, SimulatedPlcGateway.PlcReason>>(); immutableFailures(plcTaskFailures);
for (Map.Entry<String, Map<Integer, SimulatedPlcGateway.PlcReason>> entry Map<String, Map<Integer, SimulatedPlcGateway.PlcReason>> commandPlc =
: plcFailures.entrySet()) { immutableFailures(plcCommandFailures);
plc.put(entry.getKey(), Collections.unmodifiableMap(
new LinkedHashMap<Integer, SimulatedPlcGateway.PlcReason>(entry.getValue())));
}
Map<Long, List<VehicleEvent>> vehicles = Map<Long, List<VehicleEvent>> vehicles =
new LinkedHashMap<Long, List<VehicleEvent>>(); new LinkedHashMap<Long, List<VehicleEvent>>();
for (Map.Entry<Long, List<VehicleEvent>> entry : vehicleEvents.entrySet()) { for (Map.Entry<Long, List<VehicleEvent>> entry : vehicleEvents.entrySet()) {
vehicles.put(entry.getKey(), Collections.unmodifiableList( vehicles.put(entry.getKey(), Collections.unmodifiableList(
new ArrayList<VehicleEvent>(entry.getValue()))); new ArrayList<VehicleEvent>(entry.getValue())));
} }
return new DeterministicFaultPlan(Collections.unmodifiableMap(plc), return new DeterministicFaultPlan(taskPlc, commandPlc,
Collections.unmodifiableMap(vehicles)); Collections.unmodifiableMap(vehicles));
} }
private Map<String, Map<Integer, SimulatedPlcGateway.PlcReason>> immutableFailures(
Map<String, Map<Integer, SimulatedPlcGateway.PlcReason>> source) {
Map<String, Map<Integer, SimulatedPlcGateway.PlcReason>> copy =
new LinkedHashMap<String, Map<Integer, SimulatedPlcGateway.PlcReason>>();
for (Map.Entry<String, Map<Integer, SimulatedPlcGateway.PlcReason>> entry
: source.entrySet()) {
copy.put(entry.getKey(), Collections.unmodifiableMap(
new LinkedHashMap<Integer, SimulatedPlcGateway.PlcReason>(entry.getValue())));
}
return Collections.unmodifiableMap(copy);
}
} }
} }

View File

@@ -10,7 +10,8 @@ public final class SimulatedPlcGateway {
} }
public static final class PlcResponse { public static final class PlcResponse {
private final String taskId; private final String parentTaskId;
private final String commandId;
private final String vehicleId; private final String vehicleId;
private final String from; private final String from;
private final String to; private final String to;
@@ -30,9 +31,11 @@ public final class SimulatedPlcGateway {
* @param bootId 执行该处理所需的 boot id 输入。 * @param bootId 执行该处理所需的 boot id 输入。
* @param reason 执行该处理所需的 reason 输入。 * @param reason 执行该处理所需的 reason 输入。
*/ */
private PlcResponse(String taskId, String vehicleId, String from, String to, private PlcResponse(String parentTaskId, String commandId,
String vehicleId, String from, String to,
int attempt, long bootId, PlcReason reason) { int attempt, long bootId, PlcReason reason) {
this.taskId = taskId; this.parentTaskId = parentTaskId;
this.commandId = commandId;
this.vehicleId = vehicleId; this.vehicleId = vehicleId;
this.from = from; this.from = from;
this.to = to; this.to = to;
@@ -47,7 +50,13 @@ public final class SimulatedPlcGateway {
* <p>该方法属于 {@code PlcResponse},用于保持当前模块的职责边界清晰。 * <p>该方法属于 {@code PlcResponse},用于保持当前模块的职责边界清晰。
* @return 本次处理产生的结果;调用方据此继续后续流程。 * @return 本次处理产生的结果;调用方据此继续后续流程。
*/ */
public String getTaskId() { return taskId; } public String getTaskId() { return parentTaskId; }
/** 返回归档和 WMS 幂等使用的业务父任务 ID。 */
public String getParentTaskId() { return parentTaskId; }
/** 返回包含业务阶段和路线版本的 PLC 命令 ID。 */
public String getCommandId() { return commandId; }
/** /**
* 返回当前对象保存的“vehicle id”信息。 * 返回当前对象保存的“vehicle id”信息。
* *
@@ -93,7 +102,8 @@ public final class SimulatedPlcGateway {
} }
private final DeterministicFaultPlan faults; private final DeterministicFaultPlan faults;
private final Map<String, Integer> attempts = new LinkedHashMap<String, Integer>(); private final Map<String, Integer> taskAttempts = new LinkedHashMap<String, Integer>();
private final Map<String, Integer> commandAttempts = new LinkedHashMap<String, Integer>();
/** /**
* 创建 PLC 协议模拟器,并绑定确定性故障计划以便重复测试发送、超时和丢 ACK 场景。 * 创建 PLC 协议模拟器,并绑定确定性故障计划以便重复测试发送、超时和丢 ACK 场景。
@@ -116,13 +126,27 @@ public final class SimulatedPlcGateway {
*/ */
public PlcResponse send(String taskId, SimulatedVehicleRuntime vehicle, public PlcResponse send(String taskId, SimulatedVehicleRuntime vehicle,
String from, String to) { String from, String to) {
// 按任务累计协议尝试次数,使故障计划能精确指定第 N 次发送失败或超时。 return send(taskId, taskId, vehicle, from, to);
int attempt = attempts.containsKey(taskId) ? attempts.get(taskId) + 1 : 1; }
attempts.put(taskId, attempt);
PlcReason reason = faults.plcReason(taskId, attempt); /**
* 向 PLC 发送某个父任务的阶段命令;命令尝试次数用于阶段重试,任务尝试次数只用于汇总和旧故障配置。
*/
public PlcResponse send(String parentTaskId, String commandId,
SimulatedVehicleRuntime vehicle, String from, String to) {
if (blank(parentTaskId) || blank(commandId) || vehicle == null
|| blank(from) || blank(to)) {
throw new IllegalArgumentException(
"parent task, command, vehicle and movement endpoints are required");
}
// 每个阶段命令从 1 独立计数,因此载货阶段的首发失败不会继承空车阶段的次数。
int commandAttempt = increment(commandAttempts, commandId);
int taskAttempt = increment(taskAttempts, parentTaskId);
PlcReason reason = faults.plcReason(
parentTaskId, commandId, commandAttempt, taskAttempt);
if (reason == null) reason = PlcReason.ACK; if (reason == null) reason = PlcReason.ACK;
return new PlcResponse(taskId, vehicle.getVehicleId(), from, to, return new PlcResponse(parentTaskId, commandId, vehicle.getVehicleId(), from, to,
attempt, vehicle.getBootId(), reason); commandAttempt, vehicle.getBootId(), reason);
} }
/** /**
@@ -135,7 +159,8 @@ public final class SimulatedPlcGateway {
if (response.getReason() != PlcReason.ACK) return response; if (response.getReason() != PlcReason.ACK) return response;
// 车辆重启后 bootId 会变化,旧 ACK 即使迟到也不能再提交路线索引和车位。 // 车辆重启后 bootId 会变化,旧 ACK 即使迟到也不能再提交路线索引和车位。
if (response.getBootId() != vehicle.getBootId()) { if (response.getBootId() != vehicle.getBootId()) {
return new PlcResponse(response.getTaskId(), response.getVehicleId(), return new PlcResponse(response.getParentTaskId(), response.getCommandId(),
response.getVehicleId(),
response.getFrom(), response.getTo(), response.getAttempt(), response.getFrom(), response.getTo(), response.getAttempt(),
response.getBootId(), PlcReason.STALE_BOOT_ID); response.getBootId(), PlcReason.STALE_BOOT_ID);
} }
@@ -148,7 +173,18 @@ public final class SimulatedPlcGateway {
* @return 本次处理产生的结果;调用方据此继续后续流程。 * @return 本次处理产生的结果;调用方据此继续后续流程。
*/ */
public int attemptsFor(String taskId) { public int attemptsFor(String taskId) {
Integer value = attempts.get(taskId); return attemptsForTask(taskId);
}
/** 返回父任务跨所有阶段累计发送 PLC 指令的次数。 */
public int attemptsForTask(String parentTaskId) {
Integer value = taskAttempts.get(parentTaskId);
return value == null ? 0 : value;
}
/** 返回单个阶段命令自己的发送次数,供当前阶段的重试上限判断。 */
public int attemptsForCommand(String commandId) {
Integer value = commandAttempts.get(commandId);
return value == null ? 0 : value; return value == null ? 0 : value;
} }
@@ -159,6 +195,21 @@ public final class SimulatedPlcGateway {
* @return 本次处理产生的结果;调用方据此继续后续流程。 * @return 本次处理产生的结果;调用方据此继续后续流程。
*/ */
public Map<String, Integer> getAttempts() { public Map<String, Integer> getAttempts() {
return Collections.unmodifiableMap(attempts); return Collections.unmodifiableMap(taskAttempts);
}
/** 返回只读的阶段命令尝试次数,供报告按阶段汇总。 */
public Map<String, Integer> getCommandAttempts() {
return Collections.unmodifiableMap(commandAttempts);
}
private int increment(Map<String, Integer> values, String key) {
int next = values.containsKey(key) ? values.get(key) + 1 : 1;
values.put(key, next);
return next;
}
private boolean blank(String value) {
return value == null || value.trim().isEmpty();
} }
} }

View File

@@ -0,0 +1,51 @@
package com.ft546.wcs.shadow.history.simulation;
import org.junit.jupiter.api.Test;
import static com.ft546.wcs.shadow.history.simulation.SimulatedPlcGateway.PlcReason.ACK;
import static com.ft546.wcs.shadow.history.simulation.SimulatedPlcGateway.PlcReason.ACK_TIMEOUT;
import static com.ft546.wcs.shadow.history.simulation.SimulatedPlcGateway.PlcReason.SEND_FAILURE;
import static org.junit.jupiter.api.Assertions.assertEquals;
class PhaseAwarePlcGatewayTest {
/** 载货阶段自己的首发失败,不得继承空车阶段已经使用过的尝试序号。 */
@Test
void failureOnLoadedPhaseDoesNotConsumeEmptyPhaseAttemptNumbers() {
String emptyCommand = "B1:EMPTY_TO_PICKUP:v1:e0";
String loadedCommand = "B1:LOADED_TO_PLACE:v1:e0";
DeterministicFaultPlan plan = DeterministicFaultPlan.builder()
.plcCommandFailure(loadedCommand, 1, SEND_FAILURE)
.build();
SimulatedPlcGateway gateway = new SimulatedPlcGateway(plan);
SimulatedVehicleRuntime vehicle = new SimulatedVehicleRuntime("1", "A", 1);
assertEquals(ACK, gateway.send("B1", emptyCommand,
vehicle, "A", "B").getReason());
SimulatedPlcGateway.PlcResponse failed = gateway.send(
"B1", loadedCommand, vehicle, "B", "C");
assertEquals(SEND_FAILURE, failed.getReason());
assertEquals("B1", failed.getParentTaskId());
assertEquals(loadedCommand, failed.getCommandId());
assertEquals(1, failed.getAttempt());
assertEquals(2, gateway.attemptsForTask("B1"));
assertEquals(1, gateway.attemptsForCommand(emptyCommand));
assertEquals(1, gateway.attemptsForCommand(loadedCommand));
}
/** 旧的任务级故障配置仍按父任务总发送次数生效,可继续驱动原始回放测试。 */
@Test
void taskWideFailureRemainsACompatibleFallbackAcrossCommands() {
DeterministicFaultPlan plan = DeterministicFaultPlan.builder()
.plcFailure("B1", 2, ACK_TIMEOUT)
.build();
SimulatedPlcGateway gateway = new SimulatedPlcGateway(plan);
SimulatedVehicleRuntime vehicle = new SimulatedVehicleRuntime("1", "A", 1);
assertEquals(ACK, gateway.send("B1", "B1:EMPTY_TO_PICKUP:v1:e0",
vehicle, "A", "B").getReason());
assertEquals(ACK_TIMEOUT, gateway.send("B1", "B1:LOADED_TO_PLACE:v1:e0",
vehicle, "B", "C").getReason());
assertEquals(2, gateway.attemptsForTask("B1"));
}
}

View File

@@ -25,6 +25,10 @@ class SimulationAdaptersTest {
assertEquals(SEND_FAILURE, plc.send("T1", vehicle, "A", "B").getReason()); assertEquals(SEND_FAILURE, plc.send("T1", vehicle, "A", "B").getReason());
SimulatedPlcGateway.PlcResponse ack = plc.send("T1", vehicle, "A", "B"); SimulatedPlcGateway.PlcResponse ack = plc.send("T1", vehicle, "A", "B");
assertEquals(ACK, ack.getReason()); assertEquals(ACK, ack.getReason());
assertEquals("T1", ack.getParentTaskId());
assertEquals("T1", ack.getCommandId());
assertEquals(2, plc.attemptsForTask("T1"));
assertEquals(2, plc.attemptsForCommand("T1"));
assertEquals(ACK, plc.accept(ack, vehicle).getReason()); assertEquals(ACK, plc.accept(ack, vehicle).getReason());
vehicle.restart(); vehicle.restart();