feat: validate shadow segment snapshots
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
package com.ft546.wcs.shadow.segment;
|
||||||
|
|
||||||
|
public enum ReservationState {
|
||||||
|
SOFT, COMMIT_GUARD, HARD, UNKNOWN_HARD, RELEASED
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.ft546.wcs.shadow.segment;
|
||||||
|
|
||||||
|
public enum SegmentEvent {
|
||||||
|
PREPARE, PREPARED, BEGIN_COMMIT, COMMITTED, EXECUTING, COMPLETED,
|
||||||
|
COMMIT_UNKNOWN, FAULT, QUARANTINE
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<String> 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<String> 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<String>(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<String> 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<String> getPath() { return path; }
|
||||||
|
public SegmentState getState() { return state; }
|
||||||
|
public ReservationState getReservationState() { return reservationState; }
|
||||||
|
public long getLastSnapshotSeq() { return lastSnapshotSeq; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TrafficVehicleSnapshot> vehicles;
|
||||||
|
|
||||||
|
public TrafficSnapshot(long tickSeq, Instant decisionTime, String mapCode, long mapVersion,
|
||||||
|
String policyVersion, List<TrafficVehicleSnapshot> vehicles) {
|
||||||
|
this.tickSeq = tickSeq;
|
||||||
|
this.decisionTime = decisionTime;
|
||||||
|
this.mapCode = mapCode;
|
||||||
|
this.mapVersion = mapVersion;
|
||||||
|
this.policyVersion = policyVersion;
|
||||||
|
this.vehicles = Collections.unmodifiableList(new ArrayList<TrafficVehicleSnapshot>(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<TrafficVehicleSnapshot> getVehicles() { return vehicles; }
|
||||||
|
}
|
||||||
@@ -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<String> 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<String> 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<String>(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<String> getRemainingPath() { return remainingPath; }
|
||||||
|
public boolean isConnected() { return connected; }
|
||||||
|
public boolean isFaulted() { return faulted; }
|
||||||
|
public boolean isManual() { return manual; }
|
||||||
|
}
|
||||||
@@ -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<TrafficVehicleSnapshot> vehicles = new ArrayList<TrafficVehicleSnapshot>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user