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