fix(ft546): serialize equipment command sends

This commit is contained in:
zgw
2026-08-11 14:39:26 +08:00
parent 14a5a6174e
commit e5f91de1a0
4 changed files with 213 additions and 65 deletions

View File

@@ -15,6 +15,7 @@ import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
public abstract class AbstractFt546EquipmentAdapter public abstract class AbstractFt546EquipmentAdapter
implements DeviceCommandGateway, DeviceSnapshotGateway { implements DeviceCommandGateway, DeviceSnapshotGateway {
@@ -25,6 +26,7 @@ public abstract class AbstractFt546EquipmentAdapter
private final Set<String> locallyUnknownDevices = private final Set<String> locallyUnknownDevices =
Collections.newSetFromMap( Collections.newSetFromMap(
new ConcurrentHashMap<String, Boolean>()); new ConcurrentHashMap<String, Boolean>());
private final ReentrantLock sendLock = new ReentrantLock();
protected AbstractFt546EquipmentAdapter( protected AbstractFt546EquipmentAdapter(
Ft546DeviceProfile sourceProfile, String expectedDeviceType, Ft546DeviceProfile sourceProfile, String expectedDeviceType,
@@ -62,32 +64,41 @@ public abstract class AbstractFt546EquipmentAdapter
return SendReceipt.rejected(receiptId, return SendReceipt.rejected(receiptId,
"command type is not supported by this adapter"); "command type is not supported by this adapter");
} }
if (locallyUnknownDevices.contains(envelope.getDeviceId()) if (!sendLock.tryLock()) {
|| motionCommandGuard.hasBlockingMotionCommand(
envelope.getDeviceId(), envelope.getCommandId())) {
return SendReceipt.rejected(receiptId, return SendReceipt.rejected(receiptId,
"device has another active command or unresolved UNKNOWN"); "device command send is already in progress");
}
final byte[] payload;
try {
payload = encode(envelope.getPayload());
} catch (IllegalArgumentException rejected) {
return SendReceipt.rejected(receiptId, rejected.getMessage());
}
if (!isConnected()) {
return SendReceipt.notSent("equipment PLC is disconnected");
} }
try { try {
int result = write(commandAddress(), payload); if (locallyUnknownDevices.contains(envelope.getDeviceId())
if (result <= 0) { || motionCommandGuard.hasBlockingMotionCommand(
return markUnknown(envelope.getDeviceId(), receiptId, envelope.getDeviceId(), envelope.getCommandId())) {
"equipment write result is ambiguous"); return SendReceipt.rejected(receiptId,
"device has another active command or unresolved UNKNOWN");
} }
return SendReceipt.accepted(receiptId); final byte[] payload;
} catch (RuntimeException ambiguous) { try {
return markUnknown(envelope.getDeviceId(), receiptId, payload = encode(envelope.getPayload());
"equipment write raised " } catch (IllegalArgumentException rejected) {
+ ambiguous.getClass().getSimpleName()); return SendReceipt.rejected(receiptId,
rejected.getMessage());
}
if (!isConnected()) {
return SendReceipt.notSent("equipment PLC is disconnected");
}
try {
int result = write(commandAddress(), payload);
if (result <= 0) {
return markUnknown(envelope.getDeviceId(), receiptId,
"equipment write result is ambiguous");
}
return SendReceipt.accepted(receiptId);
} catch (RuntimeException ambiguous) {
return markUnknown(envelope.getDeviceId(), receiptId,
"equipment write raised "
+ ambiguous.getClass().getSimpleName());
}
} finally {
sendLock.unlock();
} }
} }

View File

@@ -17,6 +17,7 @@ import java.util.Collections;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
public final class Ft546LegacySrgvAdapter public final class Ft546LegacySrgvAdapter
implements DeviceCommandGateway, DeviceSnapshotGateway { implements DeviceCommandGateway, DeviceSnapshotGateway {
@@ -32,6 +33,7 @@ public final class Ft546LegacySrgvAdapter
private final Set<String> locallyUnknownDevices = private final Set<String> locallyUnknownDevices =
Collections.newSetFromMap( Collections.newSetFromMap(
new ConcurrentHashMap<String, Boolean>()); new ConcurrentHashMap<String, Boolean>());
private final ReentrantLock sendLock = new ReentrantLock();
public Ft546LegacySrgvAdapter(Ft546DeviceRuntimeRegistry registry, public Ft546LegacySrgvAdapter(Ft546DeviceRuntimeRegistry registry,
Ft546DeviceProfile profile, Ft546DeviceProfile profile,
@@ -66,49 +68,58 @@ public final class Ft546LegacySrgvAdapter
return SendReceipt.rejected(receiptId, return SendReceipt.rejected(receiptId,
"profile belongs to another device"); "profile belongs to another device");
} }
if (locallyUnknownDevices.contains(envelope.getDeviceId()) if (!sendLock.tryLock()) {
|| motionCommandGuard.hasBlockingMotionCommand(
envelope.getDeviceId(), envelope.getCommandId())) {
return SendReceipt.rejected(receiptId, return SendReceipt.rejected(receiptId,
"device has another active motion command or an " "device command send is already in progress");
+ "unresolved UNKNOWN command");
} }
final Ft546SrgvCommandEncoder.EncodedCommand command;
try { try {
command = encoder.encode(envelope, profile, capabilities); if (locallyUnknownDevices.contains(envelope.getDeviceId())
} catch (IllegalArgumentException | IllegalStateException rejected) { || motionCommandGuard.hasBlockingMotionCommand(
return SendReceipt.rejected(receiptId, rejected.getMessage()); envelope.getDeviceId(), envelope.getCommandId())) {
} return SendReceipt.rejected(receiptId,
"device has another active motion command or an "
final PlcSrgvRead runtime; + "unresolved UNKNOWN command");
try {
runtime = registry.requireSrgv(envelope.getDeviceId());
} catch (IllegalArgumentException missingRuntime) {
return SendReceipt.notSent(missingRuntime.getMessage());
}
if (!runtime.IsConnect()) {
return SendReceipt.notSent("shuttle PLC is disconnected");
}
boolean wroteAny = false;
for (Ft546SrgvCommandEncoder.PlcWrite write
: command.getWrites()) {
try {
if (!write.writeTo(runtime)) {
return markUnknown(envelope.getDeviceId(), receiptId,
wroteAny
? "PLC rejected a partial register sequence"
: "first register write result is ambiguous");
}
wroteAny = true;
} catch (RuntimeException ambiguous) {
return markUnknown(envelope.getDeviceId(), receiptId,
"register write result is ambiguous: "
+ ambiguous.getClass().getSimpleName());
} }
final Ft546SrgvCommandEncoder.EncodedCommand command;
try {
command = encoder.encode(envelope, profile, capabilities);
} catch (IllegalArgumentException | IllegalStateException rejected) {
return SendReceipt.rejected(receiptId,
rejected.getMessage());
}
final PlcSrgvRead runtime;
try {
runtime = registry.requireSrgv(envelope.getDeviceId());
} catch (IllegalArgumentException missingRuntime) {
return SendReceipt.notSent(missingRuntime.getMessage());
}
if (!runtime.IsConnect()) {
return SendReceipt.notSent("shuttle PLC is disconnected");
}
boolean wroteAny = false;
for (Ft546SrgvCommandEncoder.PlcWrite write
: command.getWrites()) {
try {
if (!write.writeTo(runtime)) {
return markUnknown(envelope.getDeviceId(), receiptId,
wroteAny
? "PLC rejected a partial register sequence"
: "first register write result is ambiguous");
}
wroteAny = true;
} catch (RuntimeException ambiguous) {
return markUnknown(envelope.getDeviceId(), receiptId,
"register write result is ambiguous: "
+ ambiguous.getClass().getSimpleName());
}
}
return SendReceipt.accepted(receiptId);
} finally {
sendLock.unlock();
} }
return SendReceipt.accepted(receiptId);
} }
@Override @Override

View File

@@ -24,10 +24,17 @@ import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class Ft546EquipmentAdaptersTest { class Ft546EquipmentAdaptersTest {
private static final Instant NOW = Instant.parse("2026-08-11T05:00:00Z"); private static final Instant NOW = Instant.parse("2026-08-11T05:00:00Z");
@@ -153,6 +160,35 @@ class Ft546EquipmentAdaptersTest {
assertEquals(1, runtime.payloads.size()); assertEquals(1, runtime.payloads.size());
} }
@Test
void rejectsConcurrentEquipmentSendWhileFirstWriteIsInProgress()
throws Exception {
FakeCdzRead runtime = new FakeCdzRead("1", chargerDevice());
runtime.blockFirstWrite();
Ft546ChargerAdapter adapter = new Ft546ChargerAdapter(runtime,
profile("1", "CHARGER", "CHARGE", "DB701.0"),
(deviceId, commandId) -> false, contextProvider());
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
Future<SendReceipt> first = executor.submit(() -> adapter.send(
envelope("CHARGE-1", "1", DeviceCommandType.CHARGE,
"enabled=1")));
assertTrue(runtime.firstWriteEntered.await(2, TimeUnit.SECONDS));
SendReceipt second = adapter.send(envelope("CHARGE-2", "1",
DeviceCommandType.CHARGE, "enabled=1"));
runtime.releaseFirstWrite.countDown();
assertEquals(SendReceipt.Status.ACCEPTED,
first.get(2, TimeUnit.SECONDS).getStatus());
assertEquals(SendReceipt.Status.REJECTED, second.getStatus());
assertEquals(1, runtime.writeCalls.get());
} finally {
runtime.releaseFirstWrite.countDown();
executor.shutdownNow();
}
}
@Test @Test
void factReadersPublishAConnectionDropAfterTheyWereConnected() { void factReadersPublishAConnectionDropAfterTheyWereConnected() {
FakeTsjRead lift = new FakeTsjRead("1", liftDevice()); FakeTsjRead lift = new FakeTsjRead("1", liftDevice());
@@ -266,9 +302,17 @@ class Ft546EquipmentAdaptersTest {
} }
private static final class FakeCdzRead extends PlcCdzRead { private static final class FakeCdzRead extends PlcCdzRead {
private final List<String> addresses = new ArrayList<String>(); private final List<String> addresses = Collections.synchronizedList(
private final List<byte[]> payloads = new ArrayList<byte[]>(); new ArrayList<String>());
private final List<byte[]> payloads = Collections.synchronizedList(
new ArrayList<byte[]>());
private final CountDownLatch firstWriteEntered =
new CountDownLatch(1);
private final CountDownLatch releaseFirstWrite =
new CountDownLatch(1);
private int writeResult = 1; private int writeResult = 1;
private volatile boolean blockFirstWrite;
private final AtomicInteger writeCalls = new AtomicInteger();
private FakeCdzRead(String id, DeviceCdz device) { private FakeCdzRead(String id, DeviceCdz device) {
super(map(id, device), new PlcConnectInfo("127.0.0.1", 102)); super(map(id, device), new PlcConnectInfo("127.0.0.1", 102));
@@ -277,10 +321,28 @@ class Ft546EquipmentAdaptersTest {
@Override @Override
public int write(String address, byte[] payload) { public int write(String address, byte[] payload) {
int call = writeCalls.incrementAndGet();
if (blockFirstWrite && call == 1) {
firstWriteEntered.countDown();
await(releaseFirstWrite);
}
addresses.add(address); addresses.add(address);
payloads.add(payload.clone()); payloads.add(payload.clone());
return writeResult; return writeResult;
} }
private void blockFirstWrite() {
blockFirstWrite = true;
}
private static void await(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException(interrupted);
}
}
} }
private static ConcurrentHashMap<String, DeviceTsj> map( private static ConcurrentHashMap<String, DeviceTsj> map(

View File

@@ -22,6 +22,12 @@ import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -149,6 +155,34 @@ class Ft546LegacySrgvAdapterTest {
assertTrue(runtime.writes.isEmpty()); assertTrue(runtime.writes.isEmpty());
} }
@Test
void rejectsConcurrentShuttleSendWithoutInterleavingRegisterSequences()
throws Exception {
FakePlcSrgvRead runtime = connectedRuntime("1");
runtime.blockFirstWrite();
Ft546LegacySrgvAdapter adapter = adapter(runtime, pointProfile(),
null, (deviceId, commandId) -> false);
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
Future<SendReceipt> first = executor.submit(() -> adapter.send(
envelope("CMD-1",
"taskNo=30001|taskType=1|route=070501>070502")));
assertTrue(runtime.firstWriteEntered.await(2, TimeUnit.SECONDS));
SendReceipt second = adapter.send(envelope("CMD-2",
"taskNo=30002|taskType=1|route=070503>070504"));
runtime.releaseFirstWrite.countDown();
assertEquals(SendReceipt.Status.ACCEPTED,
first.get(2, TimeUnit.SECONDS).getStatus());
assertEquals(SendReceipt.Status.REJECTED, second.getStatus());
assertEquals(4, runtime.writes.size());
} finally {
runtime.releaseFirstWrite.countDown();
executor.shutdownNow();
}
}
@Test @Test
void twoStageProfileIsRejectedUntilExactEvidencePasses() { void twoStageProfileIsRejectedUntilExactEvidencePasses() {
FakePlcSrgvRead runtime = connectedRuntime("1"); FakePlcSrgvRead runtime = connectedRuntime("1");
@@ -340,23 +374,32 @@ class Ft546LegacySrgvAdapterTest {
private static final class FakePlcSrgvRead extends PlcSrgvRead { private static final class FakePlcSrgvRead extends PlcSrgvRead {
private final List<RecordedWrite> writes = private final List<RecordedWrite> writes =
new ArrayList<RecordedWrite>(); Collections.synchronizedList(
new ArrayList<RecordedWrite>());
private final CountDownLatch firstWriteEntered =
new CountDownLatch(1);
private final CountDownLatch releaseFirstWrite =
new CountDownLatch(1);
private String throwAfterAddress; private String throwAfterAddress;
private String returnFalseAfterAddress; private String returnFalseAfterAddress;
private volatile boolean blockFirstWrite;
private final AtomicInteger writeCalls = new AtomicInteger();
private FakePlcSrgvRead(DeviceSrgv device) { private FakePlcSrgvRead(DeviceSrgv device) {
super(device, new PlcConnectInfo("127.0.0.1", 502)); super(device, new PlcConnectInfo("127.0.0.1", 502));
} }
@Override @Override
public synchronized boolean Write(String address, int[] value) { public boolean Write(String address, int[] value) {
beforeWrite();
record(address, "INT32", value); record(address, "INT32", value);
maybeThrow(address); maybeThrow(address);
return !address.equals(returnFalseAfterAddress); return !address.equals(returnFalseAfterAddress);
} }
@Override @Override
public synchronized boolean Write(String address, short[] value) { public boolean Write(String address, short[] value) {
beforeWrite();
List<Integer> values = new ArrayList<Integer>(); List<Integer> values = new ArrayList<Integer>();
for (short item : value) { for (short item : value) {
values.add((int) item); values.add((int) item);
@@ -366,6 +409,27 @@ class Ft546LegacySrgvAdapterTest {
return !address.equals(returnFalseAfterAddress); return !address.equals(returnFalseAfterAddress);
} }
private void blockFirstWrite() {
blockFirstWrite = true;
}
private void beforeWrite() {
int call = writeCalls.incrementAndGet();
if (blockFirstWrite && call == 1) {
firstWriteEntered.countDown();
await(releaseFirstWrite);
}
}
private static void await(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException(interrupted);
}
}
private void record(String address, String kind, int[] values) { private void record(String address, String kind, int[] values) {
List<Integer> converted = new ArrayList<Integer>(); List<Integer> converted = new ArrayList<Integer>();
Arrays.stream(values).forEach(converted::add); Arrays.stream(values).forEach(converted::add);