use crate::error::fatal;
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use spate_core::coordination::{
CoordinationError, PlanFinality, SplitId, SplitProgress, SplitSpec,
};
use std::hash::BuildHasher as _;
pub(crate) const SCHEMA: u32 = 3;
pub(crate) const PLAN_KEY: &str = "plan";
pub(crate) const LEADER_KEY: &str = "leader";
pub(crate) const SPLIT_PREFIX: &str = "split.";
pub(crate) const SPEC_PREFIX: &str = "spec.";
pub(crate) const WORKER_PREFIX: &str = "worker.";
pub(crate) const ASSIGN_PREFIX: &str = "assign.";
pub(crate) fn split_key(id: &SplitId) -> String {
format!("{SPLIT_PREFIX}{id}")
}
pub(crate) fn split_key_str(id: &str) -> String {
format!("{SPLIT_PREFIX}{id}")
}
pub(crate) fn spec_key(id: &SplitId) -> String {
format!("{SPEC_PREFIX}{id}")
}
pub(crate) fn worker_key(instance: &str) -> String {
format!("{WORKER_PREFIX}{instance}")
}
pub(crate) fn parse_split_key(key: &str) -> Option<&str> {
key.strip_prefix(SPLIT_PREFIX)
}
pub(crate) fn parse_spec_key(key: &str) -> Option<&str> {
key.strip_prefix(SPEC_PREFIX)
}
pub(crate) fn parse_worker_key(key: &str) -> Option<&str> {
key.strip_prefix(WORKER_PREFIX)
}
pub(crate) fn assign_key(instance: &str) -> String {
format!("{ASSIGN_PREFIX}{instance}")
}
pub(crate) fn parse_assign_key(key: &str) -> Option<&str> {
key.strip_prefix(ASSIGN_PREFIX)
}
pub(crate) fn fingerprint_hash(fingerprint: &str) -> u64 {
foldhash::fast::FixedState::with_seed(0).hash_one(fingerprint)
}
pub(crate) fn now_ms() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum SplitStatus {
Runnable,
Completed,
Quarantined,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct SplitSpecRecord {
pub(crate) schema: u32,
pub(crate) id: String,
pub(crate) fp: u64,
pub(crate) generation: u64,
pub(crate) weight: u64,
pub(crate) descriptor: String,
}
impl SplitSpecRecord {
pub(crate) fn planned(spec: &SplitSpec, fp: u64, generation: u64) -> SplitSpecRecord {
SplitSpecRecord {
schema: SCHEMA,
id: spec.id.as_str().to_string(),
fp,
generation,
weight: spec.weight.max(1),
descriptor: b64_encode(&spec.descriptor),
}
}
pub(crate) fn spec(&self) -> Result<SplitSpec, CoordinationError> {
let id = SplitId::new(self.id.clone())?;
let descriptor = b64_decode(&self.descriptor).map_err(|e| {
fatal(format!(
"spec record {}: corrupt descriptor ({e}); the store prefix holds records \
this build cannot read",
self.id
))
})?;
Ok(SplitSpec::new(id, descriptor).with_weight(self.weight))
}
pub(crate) fn encode(&self) -> Vec<u8> {
serde_json::to_vec(self).expect("spec record serializes")
}
pub(crate) fn parse(
key: &str,
bytes: &[u8],
fp: u64,
) -> Result<SplitSpecRecord, CoordinationError> {
let record: SplitSpecRecord = serde_json::from_slice(bytes).map_err(|e| {
fatal(format!(
"spec record at {key}: not a schema-{SCHEMA} record ({e}); another system \
or an incompatible build shares this store prefix"
))
})?;
validate_pins(key, "spec record", record.schema, record.fp, fp)?;
if parse_spec_key(key) != Some(record.id.as_str()) {
return Err(fatal(format!(
"spec record at {key} claims id {}; the store prefix is corrupt",
record.id
)));
}
Ok(record)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct SplitProgressRecord {
pub(crate) schema: u32,
pub(crate) id: String,
pub(crate) fp: u64,
pub(crate) epoch: u64,
pub(crate) status: SplitStatus,
pub(crate) owner: Option<String>,
pub(crate) attempts: u32,
pub(crate) watermark: Option<i64>,
pub(crate) state: Option<String>,
pub(crate) completed: bool,
pub(crate) written_at_ms: i64,
}
impl SplitProgressRecord {
pub(crate) fn planned(
id: &SplitId,
fp: u64,
seed: Option<&SplitProgress>,
) -> SplitProgressRecord {
SplitProgressRecord {
schema: SCHEMA,
id: id.as_str().to_string(),
fp,
epoch: 0,
status: if seed.is_some_and(|s| s.completed) {
SplitStatus::Completed
} else {
SplitStatus::Runnable
},
owner: None,
attempts: 0,
watermark: seed.map(|s| s.watermark),
state: seed.map(|s| b64_encode(&s.state)),
completed: seed.is_some_and(|s| s.completed),
written_at_ms: now_ms(),
}
}
pub(crate) fn progress(&self) -> Result<Option<SplitProgress>, CoordinationError> {
let Some(watermark) = self.watermark else {
return Ok(None);
};
let state = match &self.state {
Some(encoded) => b64_decode(encoded).map_err(|e| {
fatal(format!(
"split record {}: corrupt resume state ({e}); the store prefix holds \
records this build cannot read",
self.id
))
})?,
None => Vec::new(),
};
Ok(Some(if self.completed {
SplitProgress::completed(watermark, state)
} else {
SplitProgress::new(watermark, state)
}))
}
pub(crate) fn encode(&self) -> Vec<u8> {
serde_json::to_vec(self).expect("progress record serializes")
}
pub(crate) fn parse(
key: &str,
bytes: &[u8],
fp: u64,
) -> Result<SplitProgressRecord, CoordinationError> {
let record: SplitProgressRecord = serde_json::from_slice(bytes).map_err(|e| {
fatal(format!(
"split record at {key}: not a schema-{SCHEMA} record ({e}); another system \
or an incompatible build shares this store prefix"
))
})?;
validate_pins(key, "split record", record.schema, record.fp, fp)?;
if parse_split_key(key) != Some(record.id.as_str()) {
return Err(fatal(format!(
"split record at {key} claims id {}; the store prefix is corrupt",
record.id
)));
}
Ok(record)
}
}
fn validate_pins(
key: &str,
what: &str,
schema: u32,
record_fp: u64,
fp: u64,
) -> Result<(), CoordinationError> {
if schema != SCHEMA {
return Err(fatal(format!(
"{what} at {key}: schema {schema} but this build reads schema {SCHEMA}; \
upgrade or isolate the store prefix"
)));
}
if record_fp != fp {
return Err(fatal(format!(
"{what} at {key}: job fingerprint mismatch — two differently-configured \
pipelines share this store prefix; give each job its own prefix"
)));
}
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct PlanRecord {
pub(crate) schema: u32,
pub(crate) fingerprint: String,
pub(crate) generation: u64,
pub(crate) finality: PlanFinalityRepr,
pub(crate) planned: u64,
pub(crate) planner_state: Option<String>,
pub(crate) updated_at_ms: i64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum PlanFinalityRepr {
Open,
Final,
}
impl From<PlanFinality> for PlanFinalityRepr {
fn from(f: PlanFinality) -> PlanFinalityRepr {
match f {
PlanFinality::Open => PlanFinalityRepr::Open,
PlanFinality::Final => PlanFinalityRepr::Final,
}
}
}
impl PlanRecord {
pub(crate) fn new(fingerprint: String) -> PlanRecord {
PlanRecord {
schema: SCHEMA,
fingerprint,
generation: 0,
finality: PlanFinalityRepr::Open,
planned: 0,
planner_state: None,
updated_at_ms: now_ms(),
}
}
pub(crate) fn encode(&self) -> Vec<u8> {
serde_json::to_vec(self).expect("plan record serializes")
}
pub(crate) fn parse(bytes: &[u8], fingerprint: &str) -> Result<PlanRecord, CoordinationError> {
let record: PlanRecord = serde_json::from_slice(bytes).map_err(|e| {
fatal(format!(
"plan record: not a schema-{SCHEMA} record ({e}); another system or an \
incompatible build shares this store prefix"
))
})?;
if record.schema != SCHEMA {
return Err(fatal(format!(
"plan record: schema {} but this build reads schema {SCHEMA}; upgrade or \
isolate the store prefix",
record.schema
)));
}
if record.fingerprint != fingerprint {
return Err(fatal(format!(
"job fingerprint mismatch: this worker is configured as\n {fingerprint}\nbut \
the store prefix belongs to\n {}\nDivergent configurations can never share \
a coordinated job; fix the configuration or give this job its own prefix",
record.fingerprint
)));
}
Ok(record)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct LeaseVal {
pub(crate) schema: u32,
pub(crate) owner: String,
pub(crate) nonce: String,
pub(crate) epoch: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct LeaderVal {
pub(crate) schema: u32,
pub(crate) owner: String,
pub(crate) nonce: String,
pub(crate) generation: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct WorkerVal {
pub(crate) schema: u32,
pub(crate) nonce: String,
#[serde(default)]
pub(crate) max_in_flight: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct AssignmentVal {
pub(crate) schema: u32,
pub(crate) generation: u64,
pub(crate) splits: Vec<String>,
}
pub(crate) fn encode_val<T: Serialize>(value: &T) -> Vec<u8> {
serde_json::to_vec(value).expect("lease value serializes")
}
pub(crate) fn parse_val<T: for<'de> Deserialize<'de>>(
key: &str,
bytes: &[u8],
) -> Result<T, CoordinationError> {
serde_json::from_slice(bytes).map_err(|e| {
fatal(format!(
"lease value at {key}: unreadable ({e}); another system or an incompatible \
build shares this store prefix"
))
})
}
pub(crate) fn validate_instance_id(id: &str) -> Result<(), CoordinationError> {
if id.is_empty() || id.len() > 128 {
return Err(fatal(format!(
"instance_id must be 1..=128 bytes, got {} ({id:?})",
id.len()
)));
}
if let Some(bad) = id
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || *c == '_' || *c == '-'))
{
return Err(fatal(format!(
"instance_id may only contain [A-Za-z0-9_-], got {bad:?} in {id:?} (pod names \
with dots: replace them, e.g. with '-')"
)));
}
Ok(())
}
pub(crate) fn b64_encode(bytes: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(bytes)
}
pub(crate) fn b64_decode(s: &str) -> Result<Vec<u8>, String> {
base64::engine::general_purpose::STANDARD
.decode(s)
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn split_records_round_trip_and_validate() {
let spec = SplitSpec::new(
SplitId::new("rows-0-100").unwrap(),
b"\x00\x01binary descriptor".to_vec(),
)
.with_weight(64 << 20);
let fp = fingerprint_hash("job:v1");
let spec_record = SplitSpecRecord::planned(&spec, fp, 1);
let parsed = SplitSpecRecord::parse("spec.rows-0-100", &spec_record.encode(), fp).unwrap();
assert_eq!(parsed, spec_record);
assert_eq!(parsed.spec().unwrap(), spec);
let progress_record = SplitProgressRecord::planned(
&spec.id,
fp,
Some(&SplitProgress::new(7, b"\xffstate".to_vec())),
);
let parsed =
SplitProgressRecord::parse("split.rows-0-100", &progress_record.encode(), fp).unwrap();
assert_eq!(parsed, progress_record);
let progress = parsed.progress().unwrap().unwrap();
assert_eq!(progress.watermark, 7);
assert_eq!(progress.state, b"\xffstate");
assert!(!progress.completed);
assert_eq!(parsed.status, SplitStatus::Runnable);
assert_eq!(parsed.epoch, 0, "never owned");
}
#[test]
fn parse_failures_are_distinct_and_actionable() {
let fp = fingerprint_hash("job:v1");
let spec = SplitSpec::new(SplitId::new("a").unwrap(), vec![]);
let spec_record = SplitSpecRecord::planned(&spec, fp, 1);
let progress_record = SplitProgressRecord::planned(&spec.id, fp, None);
let garbage = SplitProgressRecord::parse("split.a", b"not json", fp).unwrap_err();
assert!(garbage.to_string().contains("another system"), "{garbage}");
let mut wrong_schema = progress_record.clone();
wrong_schema.schema = 99;
let err = SplitProgressRecord::parse("split.a", &wrong_schema.encode(), fp).unwrap_err();
assert!(err.to_string().contains("schema 99"), "{err}");
let err = SplitProgressRecord::parse("split.b", &progress_record.encode(), fp).unwrap_err();
assert!(err.to_string().contains("claims id a"), "{err}");
let err =
SplitProgressRecord::parse("split.a", &progress_record.encode(), fp + 1).unwrap_err();
assert!(err.to_string().contains("fingerprint mismatch"), "{err}");
let err = SplitSpecRecord::parse("spec.b", &spec_record.encode(), fp).unwrap_err();
assert!(err.to_string().contains("claims id a"), "{err}");
let err = SplitSpecRecord::parse("spec.a", &spec_record.encode(), fp + 1).unwrap_err();
assert!(err.to_string().contains("fingerprint mismatch"), "{err}");
let plan = PlanRecord::new("job:v1".into());
let err = PlanRecord::parse(&plan.encode(), "job:v2").unwrap_err();
assert!(err.to_string().contains("job:v1"), "{err}");
assert!(err.to_string().contains("job:v2"), "{err}");
}
#[test]
fn seeded_completed_records_are_terminal() {
let id = SplitId::new("done").unwrap();
let record =
SplitProgressRecord::planned(&id, 0, Some(&SplitProgress::completed(10, vec![])));
assert_eq!(record.status, SplitStatus::Completed);
assert!(record.progress().unwrap().unwrap().completed);
}
#[test]
fn assignment_values_round_trip_and_reject_alien_shapes() {
let val = AssignmentVal {
schema: SCHEMA,
generation: 4,
splits: vec!["split-7".to_string(), "split-9".to_string()],
};
let parsed: AssignmentVal = parse_val(&assign_key("worker-a"), &encode_val(&val)).unwrap();
assert_eq!(parsed, val);
let err = parse_val::<AssignmentVal>("assign.worker-a", b"not json").unwrap_err();
assert!(err.to_string().contains("another system"), "{err}");
let err = parse_val::<AssignmentVal>("assign.worker-a", br#"{"schema":3,"who":"x"}"#)
.unwrap_err();
assert!(err.to_string().contains("unreadable"), "{err}");
let err = parse_val::<AssignmentVal>(
"assign.worker-a",
br#"{"schema":2,"requester":"b","nonce":"n","granted":[]}"#,
)
.unwrap_err();
assert!(err.to_string().contains("unreadable"), "{err}");
assert_eq!(assign_key("worker-a"), "assign.worker-a");
assert_eq!(parse_assign_key("assign.worker-a"), Some("worker-a"));
assert_eq!(parse_assign_key("worker.worker-a"), None);
assert_eq!(parse_assign_key("split.abc"), None);
assert_eq!(parse_split_key("assign.worker-a"), None);
}
#[test]
fn instance_ids_validate() {
validate_instance_id("pipeline-7f9c").unwrap();
for bad in ["", "a.b", "a b", "π"] {
assert!(validate_instance_id(bad).is_err(), "{bad:?}");
}
}
proptest! {
#[test]
fn base64_round_trips(bytes in proptest::collection::vec(any::<u8>(), 0..256)) {
let encoded = b64_encode(&bytes);
prop_assert_eq!(b64_decode(&encoded).unwrap(), bytes);
}
#[test]
fn records_round_trip(
id in "[A-Za-z0-9_-]{1,32}",
descriptor in proptest::collection::vec(any::<u8>(), 0..512),
weight in 1u64..u64::MAX,
watermark in proptest::option::of(any::<i64>()),
state in proptest::collection::vec(any::<u8>(), 0..128),
completed in any::<bool>(),
) {
let spec = SplitSpec::new(SplitId::new(id.clone()).unwrap(), descriptor)
.with_weight(weight);
let seed = watermark.map(|w| {
if completed {
SplitProgress::completed(w, state)
} else {
SplitProgress::new(w, state)
}
});
let fp = fingerprint_hash("prop:v1");
let spec_record = SplitSpecRecord::planned(&spec, fp, 3);
let parsed = SplitSpecRecord::parse(&spec_key(&spec.id), &spec_record.encode(), fp)
.unwrap();
prop_assert_eq!(&parsed, &spec_record);
prop_assert_eq!(parsed.spec().unwrap(), spec.clone());
let progress_record = SplitProgressRecord::planned(&spec.id, fp, seed.as_ref());
let parsed = SplitProgressRecord::parse(
&split_key(&spec.id),
&progress_record.encode(),
fp,
).unwrap();
prop_assert_eq!(&parsed, &progress_record);
prop_assert_eq!(parsed.progress().unwrap(), seed);
}
}
}