feat: validate shadow segment snapshots

This commit is contained in:
zgw
2026-07-30 13:42:28 +08:00
parent 0a5d3c5cea
commit 03964643e6
8 changed files with 324 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
package com.ft546.wcs.shadow.segment;
public enum ReservationState {
SOFT, COMMIT_GUARD, HARD, UNKNOWN_HARD, RELEASED
}

View File

@@ -0,0 +1,6 @@
package com.ft546.wcs.shadow.segment;
public enum SegmentEvent {
PREPARE, PREPARED, BEGIN_COMMIT, COMMITTED, EXECUTING, COMPLETED,
COMMIT_UNKNOWN, FAULT, QUARANTINE
}

View File

@@ -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
}

View File

@@ -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; }
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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);
}
}