diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/segment/ReservationState.java b/javaTest/src/main/java/com/ft546/wcs/shadow/segment/ReservationState.java new file mode 100644 index 0000000..69a36bc --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/segment/ReservationState.java @@ -0,0 +1,5 @@ +package com.ft546.wcs.shadow.segment; + +public enum ReservationState { + SOFT, COMMIT_GUARD, HARD, UNKNOWN_HARD, RELEASED +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/segment/SegmentEvent.java b/javaTest/src/main/java/com/ft546/wcs/shadow/segment/SegmentEvent.java new file mode 100644 index 0000000..5e22af3 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/segment/SegmentEvent.java @@ -0,0 +1,6 @@ +package com.ft546.wcs.shadow.segment; + +public enum SegmentEvent { + PREPARE, PREPARED, BEGIN_COMMIT, COMMITTED, EXECUTING, COMPLETED, + COMMIT_UNKNOWN, FAULT, QUARANTINE +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/segment/SegmentState.java b/javaTest/src/main/java/com/ft546/wcs/shadow/segment/SegmentState.java new file mode 100644 index 0000000..09da077 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/segment/SegmentState.java @@ -0,0 +1,6 @@ +package com.ft546.wcs.shadow.segment; + +public enum SegmentState { + SOFT_PLANNED, PREPARING, PREPARED, COMMITTING, COMMITTED, EXECUTING, + COMPLETED, COMMIT_UNCERTAIN, FAULT_LOCKED, QUARANTINED +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/segment/TrafficSegment.java b/javaTest/src/main/java/com/ft546/wcs/shadow/segment/TrafficSegment.java new file mode 100644 index 0000000..cadb2dd --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/segment/TrafficSegment.java @@ -0,0 +1,101 @@ +package com.ft546.wcs.shadow.segment; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class TrafficSegment { + private final String segmentId; + private final String vehicleId; + private final String plcBootId; + private final long plannerEpoch; + private final long planVersion; + private final long segmentSeq; + private final List path; + private final SegmentState state; + private final ReservationState reservationState; + private final long lastSnapshotSeq; + + private TrafficSegment(String segmentId, String vehicleId, String plcBootId, long plannerEpoch, + long planVersion, long segmentSeq, List path, SegmentState state, + ReservationState reservationState, long lastSnapshotSeq) { + this.segmentId = segmentId; + this.vehicleId = vehicleId; + this.plcBootId = plcBootId; + this.plannerEpoch = plannerEpoch; + this.planVersion = planVersion; + this.segmentSeq = segmentSeq; + this.path = Collections.unmodifiableList(new ArrayList(path)); + this.state = state; + this.reservationState = reservationState; + this.lastSnapshotSeq = lastSnapshotSeq; + } + + public static TrafficSegment planned(String segmentId, String vehicleId, String plcBootId, + long plannerEpoch, long planVersion, long segmentSeq, + List path) { + if (path == null || path.size() < 2) { + throw new IllegalArgumentException("segment path requires at least two sites"); + } + return new TrafficSegment(segmentId, vehicleId, plcBootId, plannerEpoch, planVersion, + segmentSeq, path, SegmentState.SOFT_PLANNED, ReservationState.SOFT, -1L); + } + + public TrafficSegment transition(SegmentEvent event) { + SegmentState next; + ReservationState reservation = reservationState; + switch (event) { + case PREPARE: + require(SegmentState.SOFT_PLANNED); next = SegmentState.PREPARING; break; + case PREPARED: + require(SegmentState.PREPARING); next = SegmentState.PREPARED; break; + case BEGIN_COMMIT: + require(SegmentState.PREPARED); next = SegmentState.COMMITTING; + reservation = ReservationState.COMMIT_GUARD; break; + case COMMITTED: + require(SegmentState.COMMITTING); next = SegmentState.COMMITTED; + reservation = ReservationState.HARD; break; + case EXECUTING: + require(SegmentState.COMMITTED); next = SegmentState.EXECUTING; break; + case COMPLETED: + require(SegmentState.EXECUTING); next = SegmentState.COMPLETED; + reservation = ReservationState.RELEASED; break; + case COMMIT_UNKNOWN: + require(SegmentState.COMMITTING); next = SegmentState.COMMIT_UNCERTAIN; + reservation = ReservationState.UNKNOWN_HARD; break; + case FAULT: + if (state != SegmentState.COMMITTED && state != SegmentState.EXECUTING) { + throw new IllegalStateException("FAULT requires committed segment"); + } + next = SegmentState.FAULT_LOCKED; reservation = ReservationState.UNKNOWN_HARD; break; + case QUARANTINE: + next = SegmentState.QUARANTINED; reservation = ReservationState.UNKNOWN_HARD; break; + default: + throw new IllegalStateException("unsupported event " + event); + } + return new TrafficSegment(segmentId, vehicleId, plcBootId, plannerEpoch, planVersion, + segmentSeq, path, next, reservation, lastSnapshotSeq); + } + + public TrafficSegment withLastSnapshotSeq(long snapshotSeq) { + return new TrafficSegment(segmentId, vehicleId, plcBootId, plannerEpoch, planVersion, + segmentSeq, path, state, reservationState, snapshotSeq); + } + + private void require(SegmentState required) { + if (state != required) { + throw new IllegalStateException(state + " cannot accept transition requiring " + required); + } + } + + public String getSegmentId() { return segmentId; } + public String getVehicleId() { return vehicleId; } + public String getPlcBootId() { return plcBootId; } + public long getPlannerEpoch() { return plannerEpoch; } + public long getPlanVersion() { return planVersion; } + public long getSegmentSeq() { return segmentSeq; } + public List getPath() { return path; } + public SegmentState getState() { return state; } + public ReservationState getReservationState() { return reservationState; } + public long getLastSnapshotSeq() { return lastSnapshotSeq; } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/snapshot/SnapshotFreshnessPolicy.java b/javaTest/src/main/java/com/ft546/wcs/shadow/snapshot/SnapshotFreshnessPolicy.java new file mode 100644 index 0000000..7f7ceac --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/snapshot/SnapshotFreshnessPolicy.java @@ -0,0 +1,45 @@ +package com.ft546.wcs.shadow.snapshot; + +import java.time.Duration; +import java.time.Instant; + +public final class SnapshotFreshnessPolicy { + private final Duration maximumAge; + + public SnapshotFreshnessPolicy(Duration maximumAge) { + this.maximumAge = maximumAge; + } + + public SnapshotHealth validate(TrafficVehicleSnapshot previous, + TrafficVehicleSnapshot current, Instant decisionTime) { + if (!current.isConnected()) return SnapshotHealth.unhealthy("COMMUNICATION_LOST"); + if (current.isFaulted()) return SnapshotHealth.unhealthy("VEHICLE_FAULT"); + if (current.isManual()) return SnapshotHealth.unhealthy("MANUAL_MODE"); + if (Duration.between(current.getReceivedAt(), decisionTime).compareTo(maximumAge) > 0) { + return SnapshotHealth.unhealthy("STALE_SNAPSHOT"); + } + if (previous != null && !previous.getPlcBootId().equals(current.getPlcBootId())) { + return SnapshotHealth.unhealthy("PLC_RESTARTED"); + } + if (previous != null && (current.getSnapshotSeq() <= previous.getSnapshotSeq() + || current.getHeartbeatSeq() <= previous.getHeartbeatSeq())) { + return SnapshotHealth.unhealthy("SNAPSHOT_SEQUENCE_ROLLBACK"); + } + return SnapshotHealth.healthy(); + } + + public static final class SnapshotHealth { + private final boolean healthy; + private final String reasonCode; + + private SnapshotHealth(boolean healthy, String reasonCode) { + this.healthy = healthy; + this.reasonCode = reasonCode; + } + + static SnapshotHealth healthy() { return new SnapshotHealth(true, "OK"); } + static SnapshotHealth unhealthy(String reason) { return new SnapshotHealth(false, reason); } + public boolean isHealthy() { return healthy; } + public String getReasonCode() { return reasonCode; } + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/snapshot/TrafficSnapshot.java b/javaTest/src/main/java/com/ft546/wcs/shadow/snapshot/TrafficSnapshot.java new file mode 100644 index 0000000..ef5cf89 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/snapshot/TrafficSnapshot.java @@ -0,0 +1,32 @@ +package com.ft546.wcs.shadow.snapshot; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class TrafficSnapshot { + private final long tickSeq; + private final Instant decisionTime; + private final String mapCode; + private final long mapVersion; + private final String policyVersion; + private final List vehicles; + + public TrafficSnapshot(long tickSeq, Instant decisionTime, String mapCode, long mapVersion, + String policyVersion, List vehicles) { + this.tickSeq = tickSeq; + this.decisionTime = decisionTime; + this.mapCode = mapCode; + this.mapVersion = mapVersion; + this.policyVersion = policyVersion; + this.vehicles = Collections.unmodifiableList(new ArrayList(vehicles)); + } + + public long getTickSeq() { return tickSeq; } + public Instant getDecisionTime() { return decisionTime; } + public String getMapCode() { return mapCode; } + public long getMapVersion() { return mapVersion; } + public String getPolicyVersion() { return policyVersion; } + public List getVehicles() { return vehicles; } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/snapshot/TrafficVehicleSnapshot.java b/javaTest/src/main/java/com/ft546/wcs/shadow/snapshot/TrafficVehicleSnapshot.java new file mode 100644 index 0000000..0bcaa5e --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/snapshot/TrafficVehicleSnapshot.java @@ -0,0 +1,49 @@ +package com.ft546.wcs.shadow.snapshot; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class TrafficVehicleSnapshot { + private final String vehicleId; + private final String plcBootId; + private final long heartbeatSeq; + private final long snapshotSeq; + private final Instant receivedAt; + private final String currentSite; + private final String nextSite; + private final List remainingPath; + private final boolean connected; + private final boolean faulted; + private final boolean manual; + + public TrafficVehicleSnapshot(String vehicleId, String plcBootId, long heartbeatSeq, + long snapshotSeq, Instant receivedAt, String currentSite, + String nextSite, List remainingPath, boolean connected, + boolean faulted, boolean manual) { + this.vehicleId = vehicleId; + this.plcBootId = plcBootId; + this.heartbeatSeq = heartbeatSeq; + this.snapshotSeq = snapshotSeq; + this.receivedAt = receivedAt; + this.currentSite = currentSite; + this.nextSite = nextSite; + this.remainingPath = Collections.unmodifiableList(new ArrayList(remainingPath)); + this.connected = connected; + this.faulted = faulted; + this.manual = manual; + } + + public String getVehicleId() { return vehicleId; } + public String getPlcBootId() { return plcBootId; } + public long getHeartbeatSeq() { return heartbeatSeq; } + public long getSnapshotSeq() { return snapshotSeq; } + public Instant getReceivedAt() { return receivedAt; } + public String getCurrentSite() { return currentSite; } + public String getNextSite() { return nextSite; } + public List getRemainingPath() { return remainingPath; } + public boolean isConnected() { return connected; } + public boolean isFaulted() { return faulted; } + public boolean isManual() { return manual; } +} diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/SegmentAndSnapshotTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/SegmentAndSnapshotTest.java new file mode 100644 index 0000000..07c6938 --- /dev/null +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/SegmentAndSnapshotTest.java @@ -0,0 +1,80 @@ +package com.ft546.wcs.shadow; + +import com.ft546.wcs.shadow.segment.ReservationState; +import com.ft546.wcs.shadow.segment.SegmentEvent; +import com.ft546.wcs.shadow.segment.SegmentState; +import com.ft546.wcs.shadow.segment.TrafficSegment; +import com.ft546.wcs.shadow.snapshot.SnapshotFreshnessPolicy; +import com.ft546.wcs.shadow.snapshot.TrafficSnapshot; +import com.ft546.wcs.shadow.snapshot.TrafficVehicleSnapshot; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SegmentAndSnapshotTest { + + @Test + void forbidsPreparedToExecutingAndKeepsUnknownCommitProtected() { + TrafficSegment prepared = segment() + .transition(SegmentEvent.PREPARE) + .transition(SegmentEvent.PREPARED); + + assertThrows(IllegalStateException.class, () -> prepared.transition(SegmentEvent.EXECUTING)); + + TrafficSegment uncertain = prepared + .transition(SegmentEvent.BEGIN_COMMIT) + .transition(SegmentEvent.COMMIT_UNKNOWN); + assertEquals(SegmentState.COMMIT_UNCERTAIN, uncertain.getState()); + assertEquals(ReservationState.UNKNOWN_HARD, uncertain.getReservationState()); + } + + @Test + void freezesCompositeSnapshot() { + List vehicles = new ArrayList(); + vehicles.add(vehicle("BOOT-1", 3L, Instant.parse("2026-07-30T05:00:00Z"))); + TrafficSnapshot snapshot = new TrafficSnapshot( + 8L, Instant.parse("2026-07-30T05:00:01Z"), "FT546", 2L, "POLICY-1", vehicles); + vehicles.clear(); + + assertEquals(1, snapshot.getVehicles().size()); + assertThrows(UnsupportedOperationException.class, + () -> snapshot.getVehicles().add(vehicle("BOOT-1", 4L, Instant.now()))); + } + + @Test + void rejectsStaleRollbackAndRestartSnapshots() { + SnapshotFreshnessPolicy policy = new SnapshotFreshnessPolicy(Duration.ofSeconds(2)); + Instant now = Instant.parse("2026-07-30T05:00:05Z"); + TrafficVehicleSnapshot previous = vehicle("BOOT-1", 5L, now.minusSeconds(1)); + + assertFalse(policy.validate(previous, vehicle("BOOT-1", 6L, now.minusSeconds(3)), now).isHealthy()); + assertEquals("STALE_SNAPSHOT", + policy.validate(previous, vehicle("BOOT-1", 6L, now.minusSeconds(3)), now).getReasonCode()); + assertEquals("SNAPSHOT_SEQUENCE_ROLLBACK", + policy.validate(previous, vehicle("BOOT-1", 4L, now), now).getReasonCode()); + assertEquals("PLC_RESTARTED", + policy.validate(previous, vehicle("BOOT-2", 6L, now), now).getReasonCode()); + assertTrue(policy.validate(previous, vehicle("BOOT-1", 6L, now), now).isHealthy()); + } + + private TrafficSegment segment() { + return TrafficSegment.planned("SEG-1", "V1", "BOOT-1", 7L, 2L, 1L, + Arrays.asList("A", "B", "C")); + } + + private TrafficVehicleSnapshot vehicle(String bootId, long seq, Instant receivedAt) { + return new TrafficVehicleSnapshot( + "V1", bootId, seq, seq, receivedAt, "B", "C", + Collections.singletonList("C"), true, false, false); + } +}