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