use core::time::Duration;
use routers_network::Entry;
use routers_transition::matcher::{Continuation, Origin};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::bus::{Wire, postcard_wire};
use crate::event::VehicleId;
use crate::protocol::ids::{
self, GraphVersion, JobId, Lane, ObservationId, RegionId, Revision, SCHEMA_VERSION,
SchemaVersion, SegmentId,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct BaseState {
pub revision: Revision,
pub segment: SegmentId,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct JobIdentity {
pub schema: SchemaVersion,
pub vehicle_id: VehicleId,
pub observation: ObservationId,
pub base: Option<BaseState>,
pub graph: GraphVersion,
pub region: RegionId,
}
const JOB_ID_DOMAIN: &[u8] = b"routers.solve-job.v1";
const CONTINUATION_DOMAIN: &[u8] = b"routers.solve-job.continuation.v1";
impl JobIdentity {
#[must_use]
pub fn local_decision_id(&self) -> JobId {
let bytes = postcard::to_allocvec(self).expect("JobIdentity is infallibly serialisable");
JobId(ids::digest128(&[
b"routers.local-decision.v1",
bytes.as_slice(),
]))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
struct ContinuationDigest(u128);
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct JobProof {
pub identity: JobIdentity,
pub lane: Lane,
pub freshness_target_us: i64,
continuation: ContinuationDigest,
}
impl JobProof {
fn for_job<E: Entry>(
identity: &JobIdentity,
lane: Lane,
freshness_target_us: i64,
context: &Continuation<E>,
) -> Self {
let bytes = postcard::to_allocvec(context)
.expect("Continuation is infallibly serialisable for an Entry");
Self {
identity: identity.clone(),
lane,
freshness_target_us,
continuation: ContinuationDigest(ids::digest128(&[
CONTINUATION_DOMAIN,
bytes.as_slice(),
])),
}
}
#[must_use]
pub fn job_id(&self) -> JobId {
let bytes = postcard::to_allocvec(self).expect("JobProof is infallibly serialisable");
JobId(ids::digest128(&[JOB_ID_DOMAIN, bytes.as_slice()]))
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(bound(serialize = "E: Serialize", deserialize = "E: Deserialize<'de>"))]
pub struct SolveJob<E: Entry> {
pub id: JobId,
pub identity: JobIdentity,
pub lane: Lane,
pub freshness_target_us: i64,
pub context: Continuation<E>,
}
impl<E: Entry> SolveJob<E> {
pub fn new(
identity: JobIdentity,
lane: Lane,
freshness_target_us: i64,
context: Continuation<E>,
) -> Self {
let id = JobProof::for_job(&identity, lane, freshness_target_us, &context).job_id();
Self {
id,
identity,
lane,
freshness_target_us,
context,
}
}
#[must_use]
pub fn proof(&self) -> JobProof {
JobProof::for_job(
&self.identity,
self.lane,
self.freshness_target_us,
&self.context,
)
}
#[must_use]
pub fn computed_id(&self) -> JobId {
self.proof().job_id()
}
pub fn verify(&self) -> Result<(), JobError> {
let expected = self.computed_id();
if self.id != expected {
return Err(JobError::IdMismatch {
expected,
got: self.id,
});
}
if self.identity.schema != SCHEMA_VERSION {
return Err(JobError::Schema {
expected: SCHEMA_VERSION,
got: self.identity.schema,
});
}
Ok(())
}
pub fn remaining(&self, now_us: i64) -> Option<Duration> {
let micros = self.freshness_target_us.checked_sub(now_us)?;
(micros > 0).then(|| Duration::from_micros(micros as u64))
}
#[must_use]
pub fn msg_id(&self) -> String {
self.id.to_string()
}
pub fn head(&self) -> Option<&Origin> {
let fresh = match &self.context {
Continuation::Resume { fresh, .. } => fresh,
Continuation::Restart { fresh } => fresh,
};
fresh.last()
}
}
impl<E: Entry + serde::de::DeserializeOwned> SolveJob<E> {
pub fn decode_verified(bytes: &[u8]) -> Result<Self, JobError> {
let job = <Self as Wire>::decode(bytes).map_err(JobError::Decode)?;
job.verify()?;
Ok(job)
}
}
postcard_wire!(SolveJob<E: Entry>);
#[derive(Debug, Error)]
pub enum JobError {
#[error("job id mismatch: expected {expected}, got {got}")]
IdMismatch {
expected: JobId,
got: JobId,
},
#[error("schema mismatch: expected {expected}, got {got}")]
Schema {
expected: SchemaVersion,
got: SchemaVersion,
},
#[error("could not decode solve job: {0}")]
Decode(anyhow::Error),
}
#[cfg(test)]
mod tests {
use geo::Point;
use routers_network::mock::MockEntryId;
use routers_transition::matcher::Trip;
use super::*;
fn sample_identity() -> JobIdentity {
JobIdentity {
schema: SCHEMA_VERSION,
vehicle_id: VehicleId(1),
observation: ObservationId {
partition: 485,
sequence: 7,
},
base: None,
graph: GraphVersion::new("g1").unwrap(),
region: RegionId::new("r1").unwrap(),
}
}
fn origin(ts: i64) -> Origin {
Origin::new(Point::new(1.0, 2.0), ts)
}
fn restart(fresh: Vec<Origin>) -> Continuation<MockEntryId> {
Continuation::Restart { fresh }
}
fn sample_job() -> SolveJob<MockEntryId> {
SolveJob::new(
sample_identity(),
Lane::DEFAULT,
10,
restart(vec![origin(1)]),
)
}
#[test]
fn same_complete_envelope_yields_same_id() {
assert_eq!(sample_job().id, sample_job().id);
}
#[test]
fn every_solve_affecting_field_changes_the_id() {
let base = sample_job();
let base_id = base.id;
let mut mutations: Vec<(&str, SolveJob<MockEntryId>)> = Vec::new();
let mut changed = base.clone();
changed.identity.schema = SchemaVersion(SCHEMA_VERSION.0 + 1);
mutations.push(("schema", changed));
let mut changed = base.clone();
changed.identity.vehicle_id = VehicleId(2);
mutations.push(("vehicle_id", changed));
let mut changed = base.clone();
changed.identity.observation.partition += 1;
mutations.push(("observation.partition", changed));
let mut changed = base.clone();
changed.identity.observation.sequence += 1;
mutations.push(("observation.sequence", changed));
let mut changed = base.clone();
changed.identity.base = Some(BaseState {
revision: Revision(3),
segment: SegmentId(3),
});
mutations.push(("base", changed));
let mut changed = base.clone();
changed.identity.graph = GraphVersion::new("g2").unwrap();
mutations.push(("graph", changed));
let mut changed = base.clone();
changed.identity.region = RegionId::new("r2").unwrap();
mutations.push(("region", changed));
let mut changed = base.clone();
changed.lane = Lane(1);
mutations.push(("lane", changed));
let mut changed = base.clone();
changed.freshness_target_us += 1;
mutations.push(("freshness_target_us", changed));
let mut changed = base.clone();
changed.context = restart(vec![origin(2)]);
mutations.push(("continuation", changed));
for (field, changed) in mutations {
assert_ne!(
changed.computed_id(),
base_id,
"changing {field} left the id unchanged"
);
assert!(
matches!(changed.verify(), Err(JobError::IdMismatch { .. })),
"changing {field} without replacing id must fail verification"
);
}
}
#[test]
fn base_state_variation_changes_the_id() {
let with_base = |revision, segment| JobIdentity {
base: Some(BaseState {
revision: Revision(revision),
segment: SegmentId(segment),
}),
..sample_identity()
};
let a = SolveJob::new(with_base(1, 1), Lane::DEFAULT, 10, restart(vec![])).id;
let b = SolveJob::new(with_base(2, 1), Lane::DEFAULT, 10, restart(vec![])).id;
let c = SolveJob::new(with_base(1, 2), Lane::DEFAULT, 10, restart(vec![])).id;
assert_ne!(a, b, "revision must affect the id");
assert_ne!(a, c, "segment must affect the id");
assert_ne!(b, c);
}
#[test]
fn verify_accepts_a_well_formed_job() {
let job = SolveJob::new(
sample_identity(),
Lane::DEFAULT,
100,
restart(vec![origin(1)]),
);
assert!(job.verify().is_ok());
}
#[test]
fn verify_catches_a_tampered_id() {
let mut job = SolveJob::new(
sample_identity(),
Lane::DEFAULT,
100,
restart(vec![origin(1)]),
);
let good = job.id;
job.id = JobId(job.id.0 ^ 1);
match job.verify() {
Err(JobError::IdMismatch { expected, got }) => {
assert_eq!(expected, good);
assert_eq!(got, job.id);
}
other => panic!("expected IdMismatch, got {other:?}"),
}
}
#[test]
fn verify_catches_a_wrong_schema() {
let identity = JobIdentity {
schema: SchemaVersion(SCHEMA_VERSION.0 + 1),
..sample_identity()
};
let job = SolveJob::new(identity, Lane::DEFAULT, 100, restart(vec![origin(1)]));
assert!(matches!(
job.verify(),
Err(JobError::Schema { expected, got })
if expected == SCHEMA_VERSION && got == SchemaVersion(SCHEMA_VERSION.0 + 1)
));
}
#[test]
fn wire_round_trip_restart() {
let job = SolveJob::new(
sample_identity(),
Lane(2),
12_345,
restart(vec![origin(1), origin(2)]),
);
let bytes = job.encode().unwrap();
let decoded = SolveJob::<MockEntryId>::decode_verified(&bytes).unwrap();
assert_eq!(decoded.id, job.id);
assert_eq!(decoded.identity, job.identity);
assert_eq!(decoded.lane, Lane(2));
assert_eq!(decoded.freshness_target_us, 12_345);
assert_eq!(decoded.head(), Some(&origin(2)));
}
#[test]
fn wire_round_trip_resume() {
let context = Continuation::<MockEntryId>::Resume {
trip: Trip::new(),
fresh: vec![origin(5)],
};
let job = SolveJob::new(sample_identity(), Lane::DEFAULT, 7, context);
let bytes = job.encode().unwrap();
let decoded = SolveJob::<MockEntryId>::decode_verified(&bytes).unwrap();
assert_eq!(decoded.id, job.id);
assert_eq!(decoded.identity, job.identity);
assert!(matches!(decoded.context, Continuation::Resume { .. }));
assert_eq!(decoded.head(), Some(&origin(5)));
}
#[test]
fn decode_verified_rejects_a_tampered_envelope() {
let mut job = SolveJob::new(
sample_identity(),
Lane::DEFAULT,
100,
restart(vec![origin(1)]),
);
job.id = JobId(job.id.0 ^ 1);
let bytes = job.encode().unwrap();
assert!(matches!(
SolveJob::<MockEntryId>::decode_verified(&bytes),
Err(JobError::IdMismatch { .. })
));
}
#[test]
fn decode_verified_rejects_garbage() {
assert!(matches!(
SolveJob::<MockEntryId>::decode_verified(&[0xff, 0xff, 0xff, 0xff]),
Err(JobError::Decode(_))
));
}
#[test]
fn msg_id_is_the_hex_id() {
let job = SolveJob::new(
sample_identity(),
Lane::DEFAULT,
100,
restart(vec![origin(1)]),
);
assert_eq!(job.msg_id(), job.id.to_string());
assert_eq!(job.msg_id().len(), 32);
}
#[test]
fn head_is_the_newest_fresh_origin() {
let job = SolveJob::new(
sample_identity(),
Lane::DEFAULT,
100,
restart(vec![origin(1), origin(2), origin(9)]),
);
assert_eq!(job.head(), Some(&origin(9)));
let empty = SolveJob::new(sample_identity(), Lane::DEFAULT, 100, restart(vec![]));
assert_eq!(empty.head(), None);
}
#[test]
fn remaining_before_at_and_after_target() {
let job = SolveJob::new(
sample_identity(),
Lane::DEFAULT,
1_000,
restart(vec![origin(1)]),
);
assert_eq!(job.remaining(400), Some(Duration::from_micros(600)));
assert_eq!(job.remaining(1_000), None, "at the target nothing remains");
assert_eq!(
job.remaining(1_500),
None,
"past the target nothing remains"
);
}
#[test]
fn job_id_is_wire_law() {
let identity = JobIdentity {
schema: SchemaVersion(1),
vehicle_id: VehicleId(1),
observation: ObservationId {
partition: 485,
sequence: 7,
},
base: None,
graph: GraphVersion::new("g1").unwrap(),
region: RegionId::new("r1").unwrap(),
};
let job = SolveJob::new(identity, Lane::DEFAULT, 10, restart(vec![origin(1)]));
assert_eq!(job.computed_id(), job.id);
assert_eq!(job.id.to_string().len(), 32);
}
}