use std::any::type_name;
use std::collections::BTreeMap;
use std::pin::pin;
use bytes::Bytes;
use futures_util::TryStreamExt;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use taquba::{ExpiryIndex, QueueView};
use taquba_workflow::TerminalStatus;
use crate::graph::Node;
use crate::partition::Partition;
use crate::request::{Request, RequestId};
pub const KV_PREFIX: &str = "swale/";
pub const GRAPHS_PREFIX: &str = "swale/graphs/";
pub const GRAPH_RUNS_PREFIX: &str = "swale/runs/";
pub fn graph_key(graph: &str) -> Vec<u8> {
format!("{GRAPHS_PREFIX}{graph}").into_bytes()
}
pub fn parse_graph_key(key: &[u8]) -> Option<String> {
let graph = std::str::from_utf8(key).ok()?.strip_prefix(GRAPHS_PREFIX)?;
crate::graph::is_name(graph).then(|| graph.to_string())
}
pub fn graph_run_key(graph: &str, partition: &Partition) -> Vec<u8> {
format!("{GRAPH_RUNS_PREFIX}{graph}/{partition}").into_bytes()
}
pub fn parse_graph_run_key(key: &[u8]) -> Option<(String, Partition)> {
let rest = std::str::from_utf8(key)
.ok()?
.strip_prefix(GRAPH_RUNS_PREFIX)?;
let (graph, partition) = rest.split_once('/')?;
Some((graph.to_string(), Partition::new(partition).ok()?))
}
pub fn asset_key(asset: &str, partition: &Partition) -> Vec<u8> {
format!("{KV_PREFIX}assets/{asset}/{partition}").into_bytes()
}
pub fn task_key(graph: &str, partition: &Partition, node: &str) -> Vec<u8> {
format!("{KV_PREFIX}tasks/{graph}/{partition}/{node}").into_bytes()
}
pub const REQUESTS_PREFIX: &str = "swale/requests/";
pub fn request_key(id: &RequestId) -> Vec<u8> {
format!("{REQUESTS_PREFIX}{id}").into_bytes()
}
pub const EXPIRY_PREFIX: &str = "swale/expiry/";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Expiring {
Run {
graph: String,
partition: Partition,
},
Request(RequestId),
}
impl Expiring {
pub fn suffix(&self) -> Vec<u8> {
match self {
Expiring::Run { graph, partition } => format!("runs/{graph}/{partition}"),
Expiring::Request(id) => format!("requests/{id}"),
}
.into_bytes()
}
pub fn parse(suffix: &[u8]) -> Option<Self> {
let (kind, rest) = std::str::from_utf8(suffix).ok()?.split_once('/')?;
Some(match kind {
"runs" => {
let (graph, partition) = rest.split_once('/')?;
Expiring::Run {
graph: graph.to_string(),
partition: Partition::new(partition).ok()?,
}
}
"requests" => Expiring::Request(RequestId::new(rest).ok()?),
_ => return None,
})
}
}
pub fn parse_expiry_key(key: &[u8]) -> Option<(u64, Expiring)> {
let (time_ms, suffix) = ExpiryIndex::new(EXPIRY_PREFIX).parse(key)?;
Some((time_ms, Expiring::parse(suffix)?))
}
pub fn node_record_key(graph: &str, partition: &Partition, node: &Node) -> Vec<u8> {
record_key(graph, partition, node.name(), node.asset())
}
pub fn record_key(graph: &str, partition: &Partition, node: &str, asset: Option<&str>) -> Vec<u8> {
match asset {
Some(asset) => asset_key(asset, partition),
None => task_key(graph, partition, node),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecordStatus {
Succeeded,
Failed,
Cancelled,
}
impl RecordStatus {
pub fn as_str(&self) -> &'static str {
match self {
RecordStatus::Succeeded => "succeeded",
RecordStatus::Failed => "failed",
RecordStatus::Cancelled => "cancelled",
}
}
}
impl std::fmt::Display for RecordStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl From<TerminalStatus> for RecordStatus {
fn from(status: TerminalStatus) -> Self {
match status {
TerminalStatus::Succeeded => RecordStatus::Succeeded,
TerminalStatus::Failed => RecordStatus::Failed,
TerminalStatus::Cancelled => RecordStatus::Cancelled,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeRecord {
pub status: RecordStatus,
pub run_id: String,
pub definition: String,
pub rerun: u32,
pub terminated_at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub output_omitted: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GraphRunState {
Active,
Cancelled,
Complete,
Failed,
}
impl GraphRunState {
pub fn as_str(&self) -> &'static str {
match self {
GraphRunState::Active => "active",
GraphRunState::Cancelled => "cancelled",
GraphRunState::Complete => "complete",
GraphRunState::Failed => "failed",
}
}
}
impl std::fmt::Display for GraphRunState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GraphRunRecord {
pub definition: String,
pub requested_at_ms: u64,
pub state: GraphRunState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub settled_at_ms: Option<u64>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub expected_reruns: BTreeMap<String, u32>,
}
impl GraphRunRecord {
pub fn is_current(&self, node: &str, record: &NodeRecord) -> bool {
record.rerun >= self.expected_reruns.get(node).copied().unwrap_or(0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GraphRecord {
pub definition: String,
pub adopted_at_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RequestOutcome {
Started {
partitions: Vec<Partition>,
},
Rerun {
run_id: String,
},
Cancelled,
Refused {
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RequestRecord {
pub request: Request,
pub handled_at_ms: u64,
pub outcome: RequestOutcome,
}
pub trait JsonBytes: Serialize + DeserializeOwned {
fn to_bytes(&self) -> Vec<u8> {
serde_json::to_vec(self)
.unwrap_or_else(|e| panic!("{} serializes to JSON: {e}", type_name::<Self>()))
}
fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
serde_json::from_slice(bytes)
}
}
impl JsonBytes for NodeRecord {}
impl JsonBytes for GraphRunRecord {}
impl JsonBytes for GraphRecord {}
impl JsonBytes for RequestRecord {}
#[derive(Debug, thiserror::Error)]
#[error("record `{key}` is not a record: {source}")]
pub struct RecordError {
pub key: String,
#[source]
pub source: serde_json::Error,
}
pub fn parse<T: JsonBytes>(key: &[u8], bytes: &[u8]) -> Result<T, RecordError> {
T::from_bytes(bytes).map_err(|source| RecordError {
key: String::from_utf8_lossy(key).into_owned(),
source,
})
}
#[derive(Debug, thiserror::Error)]
pub enum ReadError {
#[error(transparent)]
Queue(#[from] taquba::Error),
#[error(transparent)]
Record(#[from] RecordError),
}
const PAGE: usize = 256;
pub async fn read<T: JsonBytes>(view: &QueueView, key: &[u8]) -> Result<Option<T>, ReadError> {
match view.kv_get(key).await? {
Some(bytes) => Ok(Some(parse(key, &bytes)?)),
None => Ok(None),
}
}
#[derive(Debug, Clone)]
pub struct Entry<T> {
pub key: Vec<u8>,
pub bytes: Bytes,
pub record: T,
}
pub async fn scan<T: JsonBytes>(
view: &QueueView,
prefix: &[u8],
) -> Result<Vec<Entry<T>>, taquba::Error> {
let mut records = Vec::new();
let mut entries = pin!(view.kv_entries(prefix, .., PAGE));
while let Some((key, bytes)) = entries.try_next().await? {
match parse::<T>(&key, &bytes) {
Ok(record) => records.push(Entry { key, bytes, record }),
Err(e) => tracing::warn!(error = %e, "record skipped"),
}
}
Ok(records)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_have_the_documented_layout() {
let partition = Partition::new("20260915").unwrap();
assert_eq!(graph_key("orders_daily"), b"swale/graphs/orders_daily");
assert_eq!(
graph_run_key("orders_daily", &partition),
b"swale/runs/orders_daily/20260915"
);
assert_eq!(
asset_key("orders_raw", &partition),
b"swale/assets/orders_raw/20260915"
);
assert_eq!(
task_key("orders_daily", &partition, "notify"),
b"swale/tasks/orders_daily/20260915/notify"
);
assert_eq!(
request_key(&RequestId::new("01J").unwrap()),
b"swale/requests/01J"
);
assert_eq!(
parse_graph_key(b"swale/graphs/orders_daily"),
Some("orders_daily".to_string())
);
assert_eq!(parse_graph_key(b"swale/runs/orders_daily/20260915"), None);
assert_eq!(
parse_graph_run_key(b"swale/runs/orders_daily/20260915"),
Some(("orders_daily".to_string(), partition.clone()))
);
assert_eq!(
parse_graph_run_key(b"swale/assets/orders_raw/20260915"),
None
);
assert_eq!(
parse_graph_run_key(b"swale/runs/orders_daily/2026-09"),
None
);
let id = RequestId::new("01J").unwrap();
let run = Expiring::Run {
graph: "orders_daily".to_string(),
partition: partition.clone(),
};
let request = Expiring::Request(id);
assert_eq!(run.suffix(), b"runs/orders_daily/20260915");
assert_eq!(request.suffix(), b"requests/01J");
assert_eq!(Expiring::parse(&run.suffix()), Some(run.clone()));
assert_eq!(Expiring::parse(&request.suffix()), Some(request.clone()));
let index = ExpiryIndex::new(EXPIRY_PREFIX);
let key = index.entry_key(7, &run.suffix());
assert_eq!(
key,
b"swale/expiry/\0\0\0\0\0\0\0\x07runs/orders_daily/20260915"
);
assert_eq!(parse_expiry_key(&key), Some((7, run)));
assert_eq!(
parse_expiry_key(&index.entry_key(1_700_000_000_000, &request.suffix())),
Some((1_700_000_000_000, request))
);
for suffix in [
&b"memos/a"[..],
b"runs/orders_daily",
b"requests/a b",
b"runs",
] {
assert_eq!(
Expiring::parse(suffix),
None,
"{}",
String::from_utf8_lossy(suffix)
);
}
assert_eq!(parse_expiry_key(b"swale/expiry/7"), None);
assert_eq!(parse_expiry_key(b"swale/runs/orders_daily/20260915"), None);
}
#[test]
fn records_round_trip_through_json_with_optional_fields_left_out() {
let record = NodeRecord {
status: RecordStatus::Succeeded,
run_id: "g-p-n-r0".into(),
definition: "abc".into(),
rerun: 0,
terminated_at_ms: 5,
output: None,
output_omitted: false,
error: None,
};
let json = String::from_utf8(record.to_bytes()).unwrap();
assert!(!json.contains("output"), "{json}");
assert!(!json.contains("error"), "{json}");
assert_eq!(NodeRecord::from_bytes(json.as_bytes()).unwrap(), record);
let run = GraphRunRecord {
definition: "abc".into(),
requested_at_ms: 7,
state: GraphRunState::Active,
settled_at_ms: None,
expected_reruns: BTreeMap::new(),
};
let json = String::from_utf8(run.to_bytes()).unwrap();
assert!(!json.contains("settled_at_ms"), "{json}");
assert!(!json.contains("expected_reruns"), "{json}");
assert_eq!(GraphRunRecord::from_bytes(json.as_bytes()).unwrap(), run);
let run = GraphRunRecord {
state: GraphRunState::Complete,
settled_at_ms: Some(8),
expected_reruns: BTreeMap::from([("transform".to_string(), 1)]),
..run
};
assert_eq!(GraphRunRecord::from_bytes(&run.to_bytes()).unwrap(), run);
assert!(run.is_current("extract", &record));
assert!(!run.is_current("transform", &record));
assert_eq!(
RecordStatus::from(TerminalStatus::Cancelled),
RecordStatus::Cancelled
);
assert_eq!(RecordStatus::Failed.to_string(), "failed");
assert_eq!(GraphRunState::Complete.to_string(), "complete");
let request = RequestRecord {
request: Request::Cancel {
graph: "g".into(),
partition: Partition::new("20260915").unwrap(),
},
handled_at_ms: 9,
outcome: RequestOutcome::Refused {
reason: "no".into(),
},
};
let json = String::from_utf8(request.to_bytes()).unwrap();
assert_eq!(
json,
r#"{"request":{"kind":"cancel","graph":"g","partition":"20260915"},"handled_at_ms":9,"outcome":{"kind":"refused","reason":"no"}}"#
);
assert_eq!(RequestRecord::from_bytes(json.as_bytes()).unwrap(), request);
}
}