From 98ba4169fa4bc91846fbd5da3f789c79d7746cdb Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Mon, 26 Oct 2026 22:11:19 -0700 Subject: [PATCH 01/12] Added the Stream ids, offsets, epochs and batch digest, with vectors. These are the rules that every SDK, every Store and the server must compute the same way. `testdata/gen_vectors.py` writes the shared vectors from a second implementation, and `tests/vectors.rs` checks the crate on them. --- crates/streams/Cargo.toml | 4 + crates/streams/README.md | 6 +- crates/streams/src/digest.rs | 43 + crates/streams/src/epoch.rs | 158 +++ crates/streams/src/ids.rs | 344 ++++++ crates/streams/src/lib.rs | 4 + crates/streams/src/offset.rs | 47 + crates/streams/testdata/digests.json | 92 ++ crates/streams/testdata/epochs.json | 55 + crates/streams/testdata/fields.json | 10 + crates/streams/testdata/gen_vectors.py | 405 +++++++ crates/streams/testdata/ids.json | 68 ++ crates/streams/testdata/offsets.json | 22 + crates/streams/testdata/withdrawals.json | 1390 ++++++++++++++++++++++ crates/streams/tests/vectors.rs | 248 ++++ 15 files changed, 2895 insertions(+), 1 deletion(-) create mode 100644 crates/streams/src/digest.rs create mode 100644 crates/streams/src/epoch.rs create mode 100644 crates/streams/src/ids.rs create mode 100644 crates/streams/src/offset.rs create mode 100644 crates/streams/testdata/digests.json create mode 100644 crates/streams/testdata/epochs.json create mode 100644 crates/streams/testdata/fields.json create mode 100644 crates/streams/testdata/gen_vectors.py create mode 100644 crates/streams/testdata/ids.json create mode 100644 crates/streams/testdata/offsets.json create mode 100644 crates/streams/testdata/withdrawals.json create mode 100644 crates/streams/tests/vectors.rs diff --git a/crates/streams/Cargo.toml b/crates/streams/Cargo.toml index 3153fccf2..277de8918 100644 --- a/crates/streams/Cargo.toml +++ b/crates/streams/Cargo.toml @@ -14,6 +14,7 @@ categories = ["development-tools"] [dependencies] prost = { workspace = true } prost-types = { workspace = true } +sha2 = "0.10" thiserror = { workspace = true } tonic = { workspace = true } @@ -32,5 +33,8 @@ version = "~0.10.0" protox = "0.9.1" tonic-prost-build = { workspace = true } +[dev-dependencies] +serde_json = { workspace = true } + [lints] workspace = true diff --git a/crates/streams/README.md b/crates/streams/README.md index d1102dd09..b93aea8ac 100644 --- a/crates/streams/README.md +++ b/crates/streams/README.md @@ -4,7 +4,7 @@ Experimental. The Stream runtime that the Temporal SDKs link into their bridges. the runtime per client, as a plain object, and calls it with a method name and proto bytes. A Workflow makes each Stream call from a Local Activity. Core has no Stream code. -## Protos +## Protos and shared vectors - `protos/temporal/api/stream/v1/message.proto`, `protos/temporal/api/streamservice/v1/request_response.proto` and @@ -15,3 +15,7 @@ Workflow makes each Stream call from a Local Activity. Core has no Stream code. needs no file outside itself. - `protos/temporal/sdk/streams/v1/runtime.proto` has the crate's own calls, such as the writer calls. +- `testdata/*.json` are the shared vectors. They cover Stream ids, Producer ids, Store keys and + meta fields, offsets, epoch keys, the withdrawal rule and the batch digest of a dedupe entry. + `testdata/gen_vectors.py` writes them from a second implementation. Each SDK, each Store and the + server run the same files. diff --git a/crates/streams/src/digest.rs b/crates/streams/src/digest.rs new file mode 100644 index 000000000..d2441af72 --- /dev/null +++ b/crates/streams/src/digest.rs @@ -0,0 +1,43 @@ +//! The content digest of a batch. Each dedupe entry stores it, so that a resend with the same +//! `sequence` and other content fails instead of being dropped without an error. The server +//! computes the same digest, and `testdata/digests.json` has the expected values. + +use crate::proto::AppendRecord; +use sha2::{Digest, Sha256}; + +/// SHA-256 over the Records in order, as 64 lowercase hex characters. Each part has its length +/// before it, so that two different batches never hash the same bytes. The metadata is hashed in +/// key order, since a map has no order on the wire. +pub fn batch_digest(records: &[AppendRecord]) -> String { + let mut hasher = Sha256::new(); + for record in records { + // A Record with no body and one with an empty body are two different stored Records. + let Some(body) = &record.body else { + hasher.update([0]); + continue; + }; + hasher.update([1]); + let mut metadata: Vec<_> = body.metadata.iter().collect(); + metadata.sort(); + hasher.update((metadata.len() as u64).to_be_bytes()); + for (key, value) in metadata { + part(&mut hasher, key.as_bytes()); + part(&mut hasher, value); + } + part(&mut hasher, &body.data); + hasher.update((body.external_payloads.len() as u64).to_be_bytes()); + for external in &body.external_payloads { + hasher.update(external.size_bytes.to_be_bytes()); + } + } + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn part(hasher: &mut Sha256, bytes: &[u8]) { + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); +} diff --git a/crates/streams/src/epoch.rs b/crates/streams/src/epoch.rs new file mode 100644 index 000000000..795edaf0e --- /dev/null +++ b/crates/streams/src/epoch.rs @@ -0,0 +1,158 @@ +//! Producer epochs, their order, and the withdrawal rule of a `Superseded` item. +//! +//! The Lua scripts compare epochs as strings. So an epoch is stored in Redis as a key of +//! fixed-width hex fields, in the order of comparison. A string comparison of two keys then orders +//! the epochs field by field. `testdata/epochs.json` has the shared vectors of the keys. + +use crate::proto::{ + Producer, ProducerEpoch, StreamProducerKind, StreamRecord, Supersession, WorkflowTaskEpoch, + stream_record::Item, +}; +use prost_types::Timestamp; +use std::{ + cmp::Ordering, + time::{SystemTime, UNIX_EPOCH}, +}; + +fn hex64(value: i64) -> String { + format!("{:016x}", value.max(0)) +} + +fn hex32(value: i32) -> String { + format!("{:08x}", value.max(0)) +} + +fn time_fields(time: Option<&Timestamp>) -> [String; 2] { + let (seconds, nanos) = time.map_or((0, 0), |time| (time.seconds, time.nanos)); + [hex64(seconds), hex32(nanos)] +} + +/// The key of a Workflow Task attempt, from its started event id, attempt and started time. The +/// epoch keys of the run and of the task's Local Activities start with it. +pub fn task_key(task: &WorkflowTaskEpoch) -> String { + let [seconds, nanos] = time_fields(task.started_time.as_ref()); + [ + hex64(task.started_event_id), + hex32(task.attempt), + seconds, + nanos, + ] + .join(".") +} + +/// The key of an epoch. Fields that a kind doesn't use compare as zero. +pub fn epoch_key(epoch: &ProducerEpoch) -> String { + let [scheduled_seconds, scheduled_nanos] = time_fields(epoch.attempt_scheduled_time.as_ref()); + [ + task_key(&task_of(epoch)), + scheduled_seconds, + scheduled_nanos, + hex32(epoch.attempt), + ] + .join(".") +} + +/// The Workflow Task fields of an epoch. +pub fn task_of(epoch: &ProducerEpoch) -> WorkflowTaskEpoch { + WorkflowTaskEpoch { + started_event_id: epoch.task_started_event_id, + attempt: epoch.task_attempt, + started_time: epoch.task_started_time, + } +} + +/// Orders two epochs field by field. +pub fn compare_epochs(a: &ProducerEpoch, b: &ProducerEpoch) -> Ordering { + epoch_key(a).cmp(&epoch_key(b)) +} + +fn compare_tasks(a: &WorkflowTaskEpoch, b: &WorkflowTaskEpoch) -> Ordering { + task_key(a).cmp(&task_key(b)) +} + +/// A proto timestamp of a system time. +pub fn timestamp(time: SystemTime) -> Timestamp { + let since = time.duration_since(UNIX_EPOCH).unwrap_or_default(); + Timestamp { + seconds: since.as_secs() as i64, + nanos: since.subsec_nanos() as i32, + } +} + +/// Whether a `Superseded` item withdraws an earlier Record. +/// +/// For an Activity or a Local Activity, it withdraws that Producer's Records with a lower epoch. +/// For a Workflow task t with the previous completed task c, it withdraws the Records of the run +/// and of its Local Activities whose task is in `failed_tasks` or strictly between c and t, and +/// those at c or at t with a lower epoch. The item of a retry run withdraws every Record of the +/// previous run. A client's newer epoch fences the earlier ones but withdraws nothing, since a +/// restarted client's earlier Records are real messages, not a failed attempt's. +pub fn withdraws(supersession: &Supersession, record: &StreamRecord) -> bool { + let Some(Item::Data(data)) = &record.item else { + return false; + }; + let Some(producer) = data.producer.as_ref() else { + return false; + }; + if !supersession.withdraw_run.is_empty() && producer.run_id == supersession.withdraw_run { + return true; + } + let kind = StreamProducerKind::try_from(supersession.producer_kind).unwrap_or_default(); + if kind == StreamProducerKind::Client { + return false; + } + let (Some(epoch), Some(old)) = (supersession.epoch.as_ref(), producer.epoch.as_ref()) else { + return false; + }; + if kind != StreamProducerKind::Workflow { + return producer.kind == supersession.producer_kind + && producer.id == supersession.producer_id + && compare_epochs(old, epoch) == Ordering::Less; + } + if !of_run(producer, supersession) { + return false; + } + let (task, old) = (task_of(epoch), task_of(old)); + let started = old.started_event_id; + if supersession.failed_tasks.contains(&started) { + return true; + } + let completed = supersession.previous_completed_task.as_ref(); + let after = completed.map_or(0, |c| c.started_event_id); + if after < started && started < task.started_event_id { + return true; + } + let lower_at = |at: &WorkflowTaskEpoch| { + started == at.started_event_id && compare_tasks(&old, at) == Ordering::Less + }; + lower_at(&task) || completed.is_some_and(lower_at) +} + +/// Whether a Producer is the Workflow run of a `Superseded` item, or one of its Local +/// Activities. +fn of_run(producer: &Producer, supersession: &Supersession) -> bool { + let kind = StreamProducerKind::try_from(producer.kind).unwrap_or_default(); + match kind { + StreamProducerKind::Workflow => producer.id == supersession.producer_id, + StreamProducerKind::LocalActivity => producer.run_id == supersession.run_id, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Without the clamp, the hex of a negative field would outrank every real epoch. + #[test] + fn a_negative_field_compares_as_zero() { + let negative = ProducerEpoch { + attempt: -1, + ..Default::default() + }; + assert_eq!( + compare_epochs(&negative, &ProducerEpoch::default()), + Ordering::Equal + ); + } +} diff --git a/crates/streams/src/ids.rs b/crates/streams/src/ids.rs new file mode 100644 index 000000000..1ef228b22 --- /dev/null +++ b/crates/streams/src/ids.rs @@ -0,0 +1,344 @@ +//! Stream ids, Producer ids and the Redis key layout. Every SDK must build them the same way, and +//! `testdata/ids.json` has the shared vectors that check it. +//! +//! ```text +//! :{:}:meta the create key, the fence, dedupe and close state +//! :{:}:log the log, one entry per Record +//! :{:}:producers each Producer's last append, to prune its fields +//! ``` +//! +//! The part in braces is the Redis Cluster hash tag, so that the keys are in one slot for one +//! script. Each part is percent-encoded, so a `:` or a brace in an id can't make two Streams share +//! a key. + +use crate::{ + StreamError, StreamResult, + proto::{Producer, StreamProducerKind}, +}; + +/// The longest Stream id, in bytes. +pub const MAX_STREAM_ID_BYTES: usize = 4096; + +/// The key prefix when the backend sets none. +pub const DEFAULT_KEY_PREFIX: &str = "temporal-streams"; + +/// Percent-encodes every byte but the unreserved ones, as Python's `quote(text, safe="")` does. +pub fn encode_part(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for byte in text.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-' | b'~') { + out.push(byte as char); + } else { + out.push_str(&format!("%{byte:02X}")); + } + } + out +} + +/// Decodes the text of [encode_part]. Returns `None` for text that [encode_part] can't make. +pub fn decode_part(text: &str) -> Option { + let mut bytes = Vec::with_capacity(text.len()); + let mut rest = text.as_bytes(); + while let Some((&byte, tail)) = rest.split_first() { + if byte == b'%' { + let hex = std::str::from_utf8(tail.get(..2)?).ok()?; + bytes.push(u8::from_str_radix(hex, 16).ok()?); + rest = &tail[2..]; + } else { + bytes.push(byte); + rest = tail; + } + } + String::from_utf8(bytes).ok() +} + +fn join(parts: &[&str]) -> String { + parts + .iter() + .map(|part| encode_part(part)) + .collect::>() + .join(":") +} + +/// The id of a Workflow's default Stream, `:`. +pub fn default_stream_id(workflow_id: &str, first_run_id: &str) -> String { + join(&[workflow_id, first_run_id]) +} + +/// The id of a Stream that a Workflow creates with a name, `::`. +pub fn scoped_stream_id(workflow_id: &str, first_run_id: &str, name: &str) -> String { + join(&[workflow_id, first_run_id, name]) +} + +/// Refuses an empty or too long Stream id. +pub fn check_stream_id(id: &str) -> StreamResult<()> { + if id.is_empty() { + return Err(StreamError::invalid("The Stream id is empty")); + } + if id.len() > MAX_STREAM_ID_BYTES { + return Err(StreamError::invalid(format!( + "The Stream id has {} bytes, more than {MAX_STREAM_ID_BYTES}", + id.len() + ))); + } + Ok(()) +} + +/// The Producer id of a Workflow run, `wf:`. +pub fn workflow_producer_id(run_id: &str) -> String { + format!("wf:{}", join(&[run_id])) +} + +/// The Producer id of a Local Activity call, `la::`. +pub fn local_activity_producer_id(run_id: &str, call_key: &str) -> String { + format!("la:{}", join(&[run_id, call_key])) +} + +/// The Producer id of an Activity, `act:::`. +pub fn activity_producer_id(run_id: &str, activity_id: &str, schedule_key: &str) -> String { + format!("act:{}", join(&[run_id, activity_id, schedule_key])) +} + +/// The Producer id of a Standalone Activity, `sa::`. +pub fn standalone_activity_producer_id(activity_id: &str, activity_run_id: &str) -> String { + format!("sa:{}", join(&[activity_id, activity_run_id])) +} + +/// The Producer id of a client process, `client::`. +pub fn client_producer_id(identity: &str, process_key: &str) -> String { + format!("client:{}", join(&[identity, process_key])) +} + +/// The Producer id that a client names itself, `client:`. The caller keeps the name across +/// restarts and raises the epoch on each one, so that a new incarnation fences the earlier one. +pub fn named_client_producer_id(name: &str) -> String { + format!("client:{}", join(&[name])) +} + +/// The id prefix of a Producer kind. +pub(crate) fn kind_prefix(kind: StreamProducerKind) -> Option<&'static str> { + match kind { + StreamProducerKind::Workflow => Some("wf:"), + StreamProducerKind::LocalActivity => Some("la:"), + StreamProducerKind::Activity => Some("act:"), + StreamProducerKind::StandaloneActivity => Some("sa:"), + StreamProducerKind::Client => Some("client:"), + StreamProducerKind::Unspecified => None, + } +} + +/// Checks that a Producer's id has the prefix of its kind, and that the run in the id is its +/// `run_id`. The kind prefix stops a Producer from using the id of another kind. +pub fn check_producer(producer: &Producer) -> StreamResult<()> { + let kind = + StreamProducerKind::try_from(producer.kind).unwrap_or(StreamProducerKind::Unspecified); + let Some(prefix) = kind_prefix(kind) else { + return Err(StreamError::invalid("The Producer has no kind")); + }; + let Some(rest) = producer.id.strip_prefix(prefix) else { + return Err(StreamError::invalid(format!( + "The Producer id {:?} doesn't start with {prefix:?}, as its kind requires", + producer.id + ))); + }; + let parts: Vec<_> = rest.split(':').collect(); + let expected = match kind { + StreamProducerKind::Workflow => 1, + StreamProducerKind::LocalActivity | StreamProducerKind::StandaloneActivity => 2, + // A named client id has one part. A process client id has two. + StreamProducerKind::Client if parts.len() == 1 => 1, + StreamProducerKind::Client => 2, + StreamProducerKind::Activity => 3, + StreamProducerKind::Unspecified => unreachable!("refused above"), + }; + // The meta field names use `|` as a separator, so a part must be in the form of `encode_part`. + let encoded = |part: &&str| decode_part(part).is_some_and(|text| encode_part(&text) == *part); + if parts.len() != expected || !parts.iter().all(encoded) { + return Err(StreamError::invalid(format!( + "The Producer id {:?} must have {expected} encoded parts after {prefix:?}", + producer.id + ))); + } + let runs = matches!( + kind, + StreamProducerKind::Workflow + | StreamProducerKind::LocalActivity + | StreamProducerKind::Activity + ); + if runs && decode_part(parts[0]).as_deref() != Some(producer.run_id.as_str()) { + return Err(StreamError::invalid(format!( + "The Producer id {:?} names another run than its run id {:?}", + producer.id, producer.run_id + ))); + } + if kind == StreamProducerKind::Client && !producer.run_id.is_empty() { + return Err(StreamError::invalid("A client Producer has no run id")); + } + Ok(()) +} + +/// The run id in a Workflow Producer id, or `None` for another id. +pub fn run_of_workflow_producer(id: &str) -> Option { + decode_part(id.strip_prefix("wf:")?) +} + +/// The fields of the `:meta` hash. The field names hold encoded ids only, so a `|` in a field +/// name is always a separator. +pub mod fields { + use super::encode_part; + + /// The format version of the Stream. + pub const VERSION: &str = "v"; + + /// The newest epoch key of a Producer, the fence. + pub fn fence(kind: i32, producer_id: &str) -> String { + format!("e|{kind}|{producer_id}") + } + + /// The last batch of a Producer's newest epoch, as + /// `||||`. These are the dedupe key, the first and + /// last entry ids of the batch, and the content digest that a resend must match. + pub fn dedupe(kind: i32, producer_id: &str) -> String { + format!("d|{kind}|{producer_id}") + } + + /// The newest epoch key of a Producer that still has Records in the Stream. + pub fn holds(kind: i32, producer_id: &str) -> String { + format!("h|{kind}|{producer_id}") + } + + /// Set when a Producer of the run wrote a Record to the Stream. + pub fn run_wrote(run_id: &str) -> String { + format!("run|{}", encode_part(run_id)) + } + + /// Set when a Workflow retry withdrew the run. + pub fn withdrawn_run(run_id: &str) -> String { + format!("wr|{}", encode_part(run_id)) + } +} + +/// The keys of one Stream. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StreamKeys { + base: String, +} + +impl StreamKeys { + /// The keys of `stream_id` in `namespace`, under `prefix`. + pub fn new(prefix: &str, namespace: &str, stream_id: &str) -> Self { + Self { + base: format!( + "{}:{{{}}}", + encode_part(prefix), + join(&[namespace, stream_id]) + ), + } + } + + /// The hash with the format version, the fence, dedupe and close state. + pub fn meta(&self) -> String { + format!("{}:meta", self.base) + } + + /// The log. + pub fn log(&self) -> String { + format!("{}:log", self.base) + } + + /// The sorted set of each Producer's last append time. An append finds in it the Producers + /// whose last append is older than the Retention. + pub fn producers(&self) -> String { + format!("{}:producers", self.base) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn producer(kind: StreamProducerKind, id: &str, run_id: &str) -> Producer { + Producer { + kind: kind as i32, + id: id.to_string(), + epoch: None, + run_id: run_id.to_string(), + } + } + + #[test] + fn a_part_round_trips_and_bad_text_does_not_decode() { + for text in ["plain", "a:b", "é {x}%", ""] { + assert_eq!(decode_part(&encode_part(text)).as_deref(), Some(text)); + } + assert_eq!(decode_part("%zz"), None); + assert_eq!(decode_part("%4"), None); + } + + #[test] + fn the_kind_prefix_and_the_run_must_match_the_producer() { + let run = "run:1"; + let ok = [ + producer( + StreamProducerKind::Workflow, + &workflow_producer_id(run), + run, + ), + producer( + StreamProducerKind::LocalActivity, + &local_activity_producer_id(run, "k"), + run, + ), + producer( + StreamProducerKind::Activity, + &activity_producer_id(run, "a:1", "s"), + run, + ), + producer( + StreamProducerKind::StandaloneActivity, + &standalone_activity_producer_id("a", "r"), + "", + ), + producer( + StreamProducerKind::Client, + &client_producer_id("me@host", "p"), + "", + ), + ]; + for producer in &ok { + check_producer(producer).unwrap(); + } + let bad = [ + producer(StreamProducerKind::Client, &workflow_producer_id(run), run), + producer( + StreamProducerKind::Workflow, + &workflow_producer_id(run), + "other", + ), + producer(StreamProducerKind::Activity, "act:run%3A1:a", run), + producer(StreamProducerKind::Unspecified, "wf:x", "x"), + producer(StreamProducerKind::Client, "client:a:b", "run"), + producer(StreamProducerKind::Client, "client:a|b", ""), + producer(StreamProducerKind::Client, "client:run%3a1", ""), + ]; + for producer in &bad { + assert_eq!( + check_producer(producer).unwrap_err().reason, + crate::Reason::Invalid, + "{producer:?}" + ); + } + assert_eq!( + run_of_workflow_producer(&workflow_producer_id(run)).as_deref(), + Some(run) + ); + } + + #[test] + fn a_stream_id_is_bounded() { + check_stream_id("a").unwrap(); + check_stream_id(&"a".repeat(MAX_STREAM_ID_BYTES)).unwrap(); + assert!(check_stream_id("").is_err()); + assert!(check_stream_id(&"a".repeat(MAX_STREAM_ID_BYTES + 1)).is_err()); + } +} diff --git a/crates/streams/src/lib.rs b/crates/streams/src/lib.rs index 95e6f23b4..d4879e593 100644 --- a/crates/streams/src/lib.rs +++ b/crates/streams/src/lib.rs @@ -6,7 +6,11 @@ //! client and calls it with an operation name and proto bytes. Core has no hook for it. A Workflow //! makes each call from a Local Activity. +pub mod digest; +pub mod epoch; mod error; +pub mod ids; +pub mod offset; pub mod protos; pub use error::{Reason, StreamError, StreamResult}; diff --git a/crates/streams/src/offset.rs b/crates/streams/src/offset.rs new file mode 100644 index 000000000..a61ddaed5 --- /dev/null +++ b/crates/streams/src/offset.rs @@ -0,0 +1,47 @@ +//! Offsets. An offset is the Redis entry id `-` packed as `ms << 20 | seq` in an `i64`. +//! It is ordered and unique in one Stream, and sparse. Zero is never an offset. + +use crate::{StreamError, StreamResult}; + +/// The number of bits of the entry id's sequence part. +pub const SEQ_BITS: u32 = 20; + +const SEQ_MASK: i64 = (1 << SEQ_BITS) - 1; +/// The largest millisecond part that fits, which is past the year 2200. +const MAX_MS: u64 = (i64::MAX >> SEQ_BITS) as u64; + +/// Packs a Redis entry id. +pub fn offset_of_entry_id(id: &str) -> StreamResult { + let bad = || StreamError::storage(format!("The Store gave the entry id {id:?}")); + let (ms, seq) = id.split_once('-').ok_or_else(bad)?; + let ms: u64 = ms.parse().map_err(|_| bad())?; + let seq: u64 = seq.parse().map_err(|_| bad())?; + if ms > MAX_MS || seq > SEQ_MASK as u64 { + return Err(StreamError::storage(format!( + "The entry id {id:?} doesn't fit in an offset" + ))); + } + Ok(((ms as i64) << SEQ_BITS) | seq as i64) +} + +/// Unpacks an offset into its Redis entry id. The offset must be above zero. +pub fn entry_id_of_offset(offset: i64) -> String { + debug_assert!(offset > 0); + format!("{}-{}", offset >> SEQ_BITS, offset & SEQ_MASK) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_entry_id_that_doesnt_fit_is_refused() { + assert!(offset_of_entry_id("1-1048576").is_err()); + assert!(offset_of_entry_id(&format!("{}-0", MAX_MS + 1)).is_err()); + assert!(offset_of_entry_id("x").is_err()); + assert_eq!( + offset_of_entry_id(&format!("{MAX_MS}-{SEQ_MASK}")).unwrap(), + i64::MAX + ); + } +} diff --git a/crates/streams/testdata/digests.json b/crates/streams/testdata/digests.json new file mode 100644 index 000000000..b44ec94ad --- /dev/null +++ b/crates/streams/testdata/digests.json @@ -0,0 +1,92 @@ +[ + { + "name": "empty batch", + "records": [], + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "name": "one record, no body", + "records": [ + {} + ], + "digest": "6e340b9cffb37a989ca544e6bb780a2c78901d3fb33738768511a30617afa01d" + }, + { + "name": "one record, an empty body", + "records": [ + { + "data": "" + } + ], + "digest": "6bb0f5246ac6ee3fa6a6879c512f393f05348f9cbb2144fe34dc8a8a4354f208" + }, + { + "name": "one record", + "records": [ + { + "data": "68656c6c6f" + } + ], + "digest": "00cedb65fe85721791cf031124cc4ba2722ab1ba174277c3d79d9b2ceba637a1" + }, + { + "name": "metadata in key order", + "records": [ + { + "metadata": { + "encoding": "6a736f6e2f706c61696e", + "a": "00ff" + }, + "data": "7b2278223a317d" + } + ], + "digest": "eea3c073d22bc6bd16ffd5c5a18a290037e9b4df892664c75ed41192a5ce1542" + }, + { + "name": "two records", + "records": [ + { + "data": "01" + }, + { + "data": "0203" + } + ], + "digest": "b75e10295b9753dbbc5774e454c9a7fbbfac2aa5775d9525e2b8cddff8ed9106" + }, + { + "name": "external payloads", + "records": [ + { + "data": "01", + "external_payloads": [ + 10, + 20 + ] + } + ], + "digest": "381530dbc9c2fb0d5ddefbc99e72c6e1f79e6f4127536a74b10f20e568440f64" + }, + { + "name": "metadata key and value split one way", + "records": [ + { + "metadata": { + "ab": "63" + } + } + ], + "digest": "be5bd276e8b11daeb753e3d3b6d6f93d6ece8a9e53fe665356d7f946739c86aa" + }, + { + "name": "metadata key and value split another way", + "records": [ + { + "metadata": { + "a": "6263" + } + } + ], + "digest": "ddc7d1a81ecebb12548d3adae06622b809df0494090db434f2f98c0a46aa3d32" + } +] diff --git a/crates/streams/testdata/epochs.json b/crates/streams/testdata/epochs.json new file mode 100644 index 000000000..ce1fe0735 --- /dev/null +++ b/crates/streams/testdata/epochs.json @@ -0,0 +1,55 @@ +[ + { + "kind": "workflow", + "epoch": { + "task_started_event_id": 8, + "task_attempt": 2, + "task_started_time": [ + 1760000000, + 5 + ] + }, + "key": "0000000000000008.00000002.0000000068e77800.00000005.0000000000000000.00000000.00000000", + "task_key": "0000000000000008.00000002.0000000068e77800.00000005" + }, + { + "kind": "local_activity", + "epoch": { + "task_started_event_id": 8, + "task_attempt": 1, + "task_started_time": [ + 1760000000, + 5 + ], + "attempt": 3 + }, + "key": "0000000000000008.00000001.0000000068e77800.00000005.0000000000000000.00000000.00000003", + "task_key": "0000000000000008.00000001.0000000068e77800.00000005" + }, + { + "kind": "activity", + "epoch": { + "attempt_scheduled_time": [ + 1760000001, + 999999999 + ], + "attempt": 2 + }, + "key": "0000000000000000.00000000.0000000000000000.00000000.0000000068e77801.3b9ac9ff.00000002", + "task_key": "0000000000000000.00000000.0000000000000000.00000000" + }, + { + "kind": "client", + "epoch": { + "attempt": 1 + }, + "key": "0000000000000000.00000000.0000000000000000.00000000.0000000000000000.00000000.00000001", + "task_key": "0000000000000000.00000000.0000000000000000.00000000" + }, + { + "kind": "empty", + "epoch": {}, + "key": "0000000000000000.00000000.0000000000000000.00000000.0000000000000000.00000000.00000000", + "task_key": "0000000000000000.00000000.0000000000000000.00000000" + } +] diff --git a/crates/streams/testdata/fields.json b/crates/streams/testdata/fields.json new file mode 100644 index 000000000..93517dbde --- /dev/null +++ b/crates/streams/testdata/fields.json @@ -0,0 +1,10 @@ +{ + "kind": 3, + "producer_id": "act:run%3A1:a:s", + "run_id": "run:1", + "fence": "e|3|act:run%3A1:a:s", + "dedupe": "d|3|act:run%3A1:a:s", + "holds": "h|3|act:run%3A1:a:s", + "run_wrote": "run|run%3A1", + "withdrawn_run": "wr|run%3A1" +} diff --git a/crates/streams/testdata/gen_vectors.py b/crates/streams/testdata/gen_vectors.py new file mode 100644 index 000000000..c18d1a6cf --- /dev/null +++ b/crates/streams/testdata/gen_vectors.py @@ -0,0 +1,405 @@ +"""Writes the shared test vectors of the Stream runtime. + +This is a second implementation of the layout, in Python, so the crate's tests check the Rust +code against it. Each SDK runs the same JSON files. Run it from this directory: + + python3 gen_vectors.py +""" + +import hashlib +import json +from urllib.parse import quote + +SEQ_BITS = 20 + + +def part(text): + return quote(text, safe="") + + +def join(*parts): + return ":".join(part(p) for p in parts) + + +def keys(prefix, namespace, stream_id): + base = f"{part(prefix)}:{{{join(namespace, stream_id)}}}" + return {"meta": f"{base}:meta", "log": f"{base}:log", "producers": f"{base}:producers"} + + +def hex64(value): + return f"{max(value, 0):016x}" + + +def hex32(value): + return f"{max(value, 0):08x}" + + +def task_key(started_event_id=0, attempt=0, started_time=(0, 0)): + return ".".join( + [hex64(started_event_id), hex32(attempt), hex64(started_time[0]), hex32(started_time[1])] + ) + + +def epoch_key(epoch): + return ".".join( + [ + task_key( + epoch.get("task_started_event_id", 0), + epoch.get("task_attempt", 0), + tuple(epoch.get("task_started_time", (0, 0))), + ), + hex64(epoch.get("attempt_scheduled_time", (0, 0))[0]), + hex32(epoch.get("attempt_scheduled_time", (0, 0))[1]), + hex32(epoch.get("attempt", 0)), + ] + ) + + +def ids(): + cases = [] + for prefix, namespace, workflow_id, first_run_id, name in [ + ("temporal-streams", "default", "agent-1", "run-1", None), + ("temporal-streams", "default", "agent-1", "run-1", "turn-3"), + ("p:x", "ns{1}", "a:b/c", "r%1", "é name"), + ]: + if name is None: + stream_id = join(workflow_id, first_run_id) + else: + stream_id = join(workflow_id, first_run_id, name) + cases.append( + { + "prefix": prefix, + "namespace": namespace, + "workflow_id": workflow_id, + "first_run_id": first_run_id, + "name": name, + "stream_id": stream_id, + **keys(prefix, namespace, stream_id), + } + ) + producers = { + "workflow": {"run_id": "run:1", "id": "wf:" + join("run:1")}, + "local_activity": { + "run_id": "run:1", + "call_key": "k-1", + "id": "la:" + join("run:1", "k-1"), + }, + "activity": { + "run_id": "run:1", + "activity_id": "a/1", + "schedule_key": "s-1", + "id": "act:" + join("run:1", "a/1", "s-1"), + }, + "standalone_activity": { + "activity_id": "sa 1", + "activity_run_id": "ar-1", + "id": "sa:" + join("sa 1", "ar-1"), + }, + "client": { + "identity": "1@host", + "process_key": "p-1", + "id": "client:" + join("1@host", "p-1"), + }, + "named_client": {"name": "ingest:eu/1", "id": "client:" + join("ingest:eu/1")}, + } + return {"streams": cases, "producers": producers} + + +def offsets(): + cases = [] + for entry_id in ["0-1", "1-0", "1700000000000-0", "1700000000000-7", "1760000000123-1048575"]: + ms, seq = (int(x) for x in entry_id.split("-")) + cases.append({"entry_id": entry_id, "offset": (ms << SEQ_BITS) | seq}) + return cases + + +def epochs(): + cases = [ + {"kind": "workflow", "epoch": {"task_started_event_id": 8, "task_attempt": 2, + "task_started_time": [1760000000, 5]}}, + {"kind": "local_activity", "epoch": {"task_started_event_id": 8, "task_attempt": 1, + "task_started_time": [1760000000, 5], + "attempt": 3}}, + {"kind": "activity", "epoch": {"attempt_scheduled_time": [1760000001, 999999999], + "attempt": 2}}, + {"kind": "client", "epoch": {"attempt": 1}}, + {"kind": "empty", "epoch": {}}, + ] + for case in cases: + case["key"] = epoch_key(case["epoch"]) + epoch = case["epoch"] + case["task_key"] = task_key( + epoch.get("task_started_event_id", 0), + epoch.get("task_attempt", 0), + tuple(epoch.get("task_started_time", (0, 0))), + ) + return cases + + +def fields(): + run = "run:1" + producer = "act:" + join(run, "a", "s") + return { + "kind": 3, + "producer_id": producer, + "run_id": run, + "fence": f"e|3|{producer}", + "dedupe": f"d|3|{producer}", + "holds": f"h|3|{producer}", + "run_wrote": f"run|{part(run)}", + "withdrawn_run": f"wr|{part(run)}", + } + + +def digest(records): + """The content digest of a batch, which a dedupe entry keeps.""" + out = hashlib.sha256() + + def part(data): + out.update(len(data).to_bytes(8, "big")) + out.update(data) + + for record in records: + # A record with none of these keys has no body. + if not {"metadata", "data", "external_payloads"} & record.keys(): + out.update(b"\x00") + continue + out.update(b"\x01") + metadata = record.get("metadata", {}) + metadata = sorted((key.encode(), bytes.fromhex(value)) for key, value in metadata.items()) + out.update(len(metadata).to_bytes(8, "big")) + for key, value in metadata: + part(key) + part(value) + part(bytes.fromhex(record.get("data", ""))) + external = record.get("external_payloads", []) + out.update(len(external).to_bytes(8, "big")) + for size in external: + out.update(size.to_bytes(8, "big", signed=True)) + return out.hexdigest() + + +def digests(): + """Batches as hex bytes, so the server and every SDK compute the same digest.""" + cases = [ + {"name": "empty batch", "records": []}, + {"name": "one record, no body", "records": [{}]}, + {"name": "one record, an empty body", "records": [{"data": ""}]}, + {"name": "one record", "records": [{"data": b"hello".hex()}]}, + { + "name": "metadata in key order", + "records": [ + { + "metadata": {"encoding": b"json/plain".hex(), "a": "00ff"}, + "data": b'{"x":1}'.hex(), + } + ], + }, + {"name": "two records", "records": [{"data": "01"}, {"data": "0203"}]}, + {"name": "external payloads", "records": [{"data": "01", "external_payloads": [10, 20]}]}, + # The length prefixes give these two different digests, though their joined bytes are equal. + { + "name": "metadata key and value split one way", + "records": [{"metadata": {"ab": "63"}}], + }, + { + "name": "metadata key and value split another way", + "records": [{"metadata": {"a": "6263"}}], + }, + ] + for case in cases: + case["digest"] = digest(case["records"]) + assert cases[-1]["digest"] != cases[-2]["digest"] + assert cases[1]["digest"] != cases[2]["digest"] + return cases + + +def withdraws(supersession, record): + """The reader rule of a Superseded item, as the API proposal states it.""" + producer = record["producer"] + if supersession.get("withdraw_run") and producer.get("run_id") == supersession["withdraw_run"]: + return True + # A client's newer epoch only fences. Its earlier Records are real messages. + if supersession["producer_kind"] == "client": + return False + # An item with no epoch, such as a create's, withdraws only a run. + if "epoch" not in supersession or "epoch" not in producer: + return False + old, new = producer["epoch"], supersession["epoch"] + if supersession["producer_kind"] != "workflow": + return ( + producer["kind"] == supersession["producer_kind"] + and producer["id"] == supersession["producer_id"] + and epoch_key(old) < epoch_key(new) + ) + if producer["kind"] == "workflow": + if producer["id"] != supersession["producer_id"]: + return False + elif producer["kind"] == "local_activity": + if producer.get("run_id", "") != supersession["run_id"]: + return False + else: + return False + + def task(epoch): + return ( + epoch.get("task_started_event_id", 0), + task_key( + epoch.get("task_started_event_id", 0), + epoch.get("task_attempt", 0), + tuple(epoch.get("task_started_time", (0, 0))), + ), + ) + + started, old_key = task(old) + t_started, t_key = task(new) + c = supersession.get("previous_completed_task") + c_started = c["started_event_id"] if c else 0 + if started in supersession.get("failed_tasks", []): + return True + if c_started < started < t_started: + return True + if started == t_started and old_key < t_key: + return True + if c and started == c_started: + c_key = task_key(c_started, c.get("attempt", 0), tuple(c.get("started_time", (0, 0)))) + if old_key < c_key: + return True + return False + + +def wf(run, started, attempt=1, time=(100, 0)): + return {"kind": "workflow", "id": "wf:" + part(run), "run_id": run, + "epoch": {"task_started_event_id": started, "task_attempt": attempt, + "task_started_time": list(time)}} + + +def withdrawal_examples(): + """Appendix E of the API proposal, one case per Record that a Superseded item meets.""" + r1 = "r1" + cases = [] + + def case(name, supersession, producer, expected): + record = {"producer": producer} + assert withdraws(supersession, record) == expected, name + cases.append({"name": name, "supersession": supersession, "record": record, + "withdraws": expected}) + + def sup(task, c=None, failed=(), withdraw_run=None, run=r1): + s = {"producer_kind": "workflow", "producer_id": "wf:" + part(run), "run_id": run, + "epoch": task["epoch"], "failed_tasks": list(failed)} + if c: + s["previous_completed_task"] = c + if withdraw_run: + s["withdraw_run"] = withdraw_run + return s + + def completed(started, attempt=1, time=(100, 0)): + return {"started_event_id": started, "attempt": attempt, "started_time": list(time)} + + # 1. A retried task writes nothing. B at [5] failed, [8] wrote nothing, C at [11], c = [8]. + s = sup(wf(r1, 11), completed(8), failed=[5]) + case("e1 B at the failed task 5 goes", s, wf(r1, 5), True) + case("e1 a Record at the completed task 8 stays", s, wf(r1, 8), False) + case("e1 a Record before c stays", s, wf(r1, 3), False) + # 2. A refused completion. B at [5] refused, B' at [8] with failed [5]. + s = sup(wf(r1, 8), completed(2), failed=[5]) + case("e2 B goes", s, wf(r1, 5), True) + case("e2 an older completed Record stays", s, wf(r1, 2), False) + # 3. A transient retry. D at [8] attempt 2, D' at [8] attempt 3. + s = sup(wf(r1, 8, attempt=3), completed(5)) + case("e3 D at a lower attempt of t goes", s, wf(r1, 8, attempt=2), True) + case("e3 a Record of t itself stays", s, wf(r1, 8, attempt=3), False) + # 4. A dropped speculative task. U at [9] T1, the next task at [9] T2. + s = sup(wf(r1, 9, time=(200, 0)), completed(6)) + case("e4 U at an earlier started time goes", s, wf(r1, 9, time=(150, 0)), True) + # 5. A refused transient attempt. c = [5], D at [11] refused, D' at [14]. + s = sup(wf(r1, 14), completed(5)) + case("e5 D between c and t goes", s, wf(r1, 11, attempt=2), True) + case("e5 a Record at c stays", s, wf(r1, 5), False) + # c is the third attempt of task [8], as Core sends it. The transient attempts 1 and 2 have + # no failed event, so only c's attempt withdraws them. + s = sup(wf(r1, 14), completed(8, attempt=3, time=(300, 0))) + case("c attempt 3, attempt 2 at c goes", s, wf(r1, 8, attempt=2, time=(200, 0)), True) + case("c attempt 3, c itself stays", s, wf(r1, 8, attempt=3, time=(300, 0)), False) + # A c with only its started id, from a Core that sends no attempt, withdraws nothing at c. + s = sup(wf(r1, 14), {"started_event_id": 8}) + case("c with no attempt keeps every Record at c", s, wf(r1, 8, attempt=1, time=(200, 0)), + False) + # A Local Activity of a withdrawn task is withdrawn too. + la = {"kind": "local_activity", "id": "la:" + join(r1, "k"), "run_id": r1, + "epoch": {"task_started_event_id": 5, "task_attempt": 1, + "task_started_time": [100, 0], "attempt": 2}} + case("e1 a Local Activity of the failed task goes", sup(wf(r1, 11), completed(8), [5]), la, + True) + other_run = wf("r9", 5) + case("another run's Record stays", sup(wf(r1, 11), completed(8), [5]), other_run, False) + # 6. A Workflow retry. r2's first batch withdraws r1 and its Activity. + act = {"kind": "activity", "id": "act:" + join(r1, "a", "s"), "run_id": r1, + "epoch": {"attempt_scheduled_time": [100, 0], "attempt": 1}} + s = sup(wf("r2", 3), None, withdraw_run=r1, run="r2") + case("e6 r1's Record goes", s, wf(r1, 20), True) + case("e6 r1's Activity Record goes", s, act, True) + case("e6 r2's own earlier Record of t stays", s, wf("r2", 3), False) + # An Activity retry withdraws only its own earlier attempt. + s = {"producer_kind": "activity", "producer_id": act["id"], "run_id": r1, + "epoch": {"attempt_scheduled_time": [130, 0], "attempt": 2}} + case("an Activity retry withdraws attempt 1", s, act, True) + other = dict(act, id="act:" + join(r1, "b", "s")) + case("an Activity retry keeps another Activity", s, other, False) + # A client's newer epoch fences its earlier one and withdraws nothing. + named = {"kind": "client", "id": "client:" + join("ingest"), "epoch": {"attempt": 1}} + s = {"producer_kind": "client", "producer_id": named["id"], "epoch": {"attempt": 2}} + case("a client's newer epoch keeps its earlier Records", s, named, False) + + def la_at(started, task_attempt=1, attempt=1, run=r1, call="k"): + return {"kind": "local_activity", "id": "la:" + join(run, call), "run_id": run, + "epoch": {"task_started_event_id": started, "task_attempt": task_attempt, + "task_started_time": [100, 0], "attempt": attempt}} + + # A retry run's create writes an item with no epoch. It withdraws only the previous run, even + # when it names failed tasks. + s = {"producer_kind": "workflow", "producer_id": "wf:" + part("r2"), "run_id": "r2", + "withdraw_run": r1, "failed_tasks": [5]} + case("no epoch, r1's Record goes", s, wf(r1, 5), True) + case("no epoch, r1's Local Activity Record goes", s, la_at(5), True) + case("no epoch, r2's Local Activity Record at a failed task stays", s, la_at(5, run="r2"), + False) + case("no epoch, r2's Record stays", s, wf("r2", 5), False) + # A Local Activity's newer epoch withdraws only its own lower epochs. + s = {"producer_kind": "local_activity", "producer_id": "la:" + join(r1, "k"), "run_id": r1, + "epoch": la_at(8, attempt=3)["epoch"]} + case("a Local Activity's newer epoch withdraws a lower attempt", s, la_at(8, attempt=2), True) + case("a Local Activity's newer epoch withdraws an earlier task", s, la_at(5, attempt=4), True) + case("a Local Activity's newer epoch keeps its own epoch", s, la_at(8, attempt=3), False) + case("a Local Activity's newer epoch keeps another call", s, la_at(5, call="j"), False) + case("a Local Activity's item keeps the run's Workflow Record", s, wf(r1, 5), False) + # Local Activity Records under a Workflow item with c = [8], t = [14] attempt 2, failed [5]. + s = sup(wf(r1, 14, attempt=2), completed(8), failed=[5]) + case("a Local Activity before c stays", s, la_at(3), False) + case("a Local Activity at c stays", s, la_at(8), False) + case("a Local Activity between c and t goes", s, la_at(11), True) + case("a Local Activity of a lower attempt of t goes", s, la_at(14, task_attempt=1), True) + case("a Local Activity of t itself stays", s, la_at(14, task_attempt=2), False) + case("a Local Activity of another run stays", s, la_at(11, run="r9"), False) + no_epoch = {"kind": "local_activity", "id": "la:" + join(r1, "k"), "run_id": r1} + case("a Local Activity Record with no epoch stays", s, no_epoch, False) + return cases + + +def main(): + for name, value in [ + ("ids.json", ids()), + ("offsets.json", offsets()), + ("epochs.json", epochs()), + ("fields.json", fields()), + ("withdrawals.json", withdrawal_examples()), + ("digests.json", digests()), + ]: + with open(name, "w") as out: + json.dump(value, out, indent=2, ensure_ascii=False) + out.write("\n") + + +if __name__ == "__main__": + main() diff --git a/crates/streams/testdata/ids.json b/crates/streams/testdata/ids.json new file mode 100644 index 000000000..9275767d7 --- /dev/null +++ b/crates/streams/testdata/ids.json @@ -0,0 +1,68 @@ +{ + "streams": [ + { + "prefix": "temporal-streams", + "namespace": "default", + "workflow_id": "agent-1", + "first_run_id": "run-1", + "name": null, + "stream_id": "agent-1:run-1", + "meta": "temporal-streams:{default:agent-1%3Arun-1}:meta", + "log": "temporal-streams:{default:agent-1%3Arun-1}:log", + "producers": "temporal-streams:{default:agent-1%3Arun-1}:producers" + }, + { + "prefix": "temporal-streams", + "namespace": "default", + "workflow_id": "agent-1", + "first_run_id": "run-1", + "name": "turn-3", + "stream_id": "agent-1:run-1:turn-3", + "meta": "temporal-streams:{default:agent-1%3Arun-1%3Aturn-3}:meta", + "log": "temporal-streams:{default:agent-1%3Arun-1%3Aturn-3}:log", + "producers": "temporal-streams:{default:agent-1%3Arun-1%3Aturn-3}:producers" + }, + { + "prefix": "p:x", + "namespace": "ns{1}", + "workflow_id": "a:b/c", + "first_run_id": "r%1", + "name": "é name", + "stream_id": "a%3Ab%2Fc:r%251:%C3%A9%20name", + "meta": "p%3Ax:{ns%7B1%7D:a%253Ab%252Fc%3Ar%25251%3A%25C3%25A9%2520name}:meta", + "log": "p%3Ax:{ns%7B1%7D:a%253Ab%252Fc%3Ar%25251%3A%25C3%25A9%2520name}:log", + "producers": "p%3Ax:{ns%7B1%7D:a%253Ab%252Fc%3Ar%25251%3A%25C3%25A9%2520name}:producers" + } + ], + "producers": { + "workflow": { + "run_id": "run:1", + "id": "wf:run%3A1" + }, + "local_activity": { + "run_id": "run:1", + "call_key": "k-1", + "id": "la:run%3A1:k-1" + }, + "activity": { + "run_id": "run:1", + "activity_id": "a/1", + "schedule_key": "s-1", + "id": "act:run%3A1:a%2F1:s-1" + }, + "standalone_activity": { + "activity_id": "sa 1", + "activity_run_id": "ar-1", + "id": "sa:sa%201:ar-1" + }, + "client": { + "identity": "1@host", + "process_key": "p-1", + "id": "client:1%40host:p-1" + }, + "named_client": { + "name": "ingest:eu/1", + "id": "client:ingest%3Aeu%2F1" + } + } +} diff --git a/crates/streams/testdata/offsets.json b/crates/streams/testdata/offsets.json new file mode 100644 index 000000000..c2f529e70 --- /dev/null +++ b/crates/streams/testdata/offsets.json @@ -0,0 +1,22 @@ +[ + { + "entry_id": "0-1", + "offset": 1 + }, + { + "entry_id": "1-0", + "offset": 1048576 + }, + { + "entry_id": "1700000000000-0", + "offset": 1782579200000000000 + }, + { + "entry_id": "1700000000000-7", + "offset": 1782579200000000007 + }, + { + "entry_id": "1760000000123-1048575", + "offset": 1845493760130023423 + } +] diff --git a/crates/streams/testdata/withdrawals.json b/crates/streams/testdata/withdrawals.json new file mode 100644 index 000000000..3913d1015 --- /dev/null +++ b/crates/streams/testdata/withdrawals.json @@ -0,0 +1,1390 @@ +[ + { + "name": "e1 B at the failed task 5 goes", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 11, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [ + 5 + ], + "previous_completed_task": { + "started_event_id": 8, + "attempt": 1, + "started_time": [ + 100, + 0 + ] + } + }, + "record": { + "producer": { + "kind": "workflow", + "id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 5, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + } + } + }, + "withdraws": true + }, + { + "name": "e1 a Record at the completed task 8 stays", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 11, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [ + 5 + ], + "previous_completed_task": { + "started_event_id": 8, + "attempt": 1, + "started_time": [ + 100, + 0 + ] + } + }, + "record": { + "producer": { + "kind": "workflow", + "id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 8, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + } + } + }, + "withdraws": false + }, + { + "name": "e1 a Record before c stays", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 11, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [ + 5 + ], + "previous_completed_task": { + "started_event_id": 8, + "attempt": 1, + "started_time": [ + 100, + 0 + ] + } + }, + "record": { + "producer": { + "kind": "workflow", + "id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 3, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + } + } + }, + "withdraws": false + }, + { + "name": "e2 B goes", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 8, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [ + 5 + ], + "previous_completed_task": { + "started_event_id": 2, + "attempt": 1, + "started_time": [ + 100, + 0 + ] + } + }, + "record": { + "producer": { + "kind": "workflow", + "id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 5, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + } + } + }, + "withdraws": true + }, + { + "name": "e2 an older completed Record stays", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 8, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [ + 5 + ], + "previous_completed_task": { + "started_event_id": 2, + "attempt": 1, + "started_time": [ + 100, + 0 + ] + } + }, + "record": { + "producer": { + "kind": "workflow", + "id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 2, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + } + } + }, + "withdraws": false + }, + { + "name": "e3 D at a lower attempt of t goes", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 8, + "task_attempt": 3, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [], + "previous_completed_task": { + "started_event_id": 5, + "attempt": 1, + "started_time": [ + 100, + 0 + ] + } + }, + "record": { + "producer": { + "kind": "workflow", + "id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 8, + "task_attempt": 2, + "task_started_time": [ + 100, + 0 + ] + } + } + }, + "withdraws": true + }, + { + "name": "e3 a Record of t itself stays", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 8, + "task_attempt": 3, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [], + "previous_completed_task": { + "started_event_id": 5, + "attempt": 1, + "started_time": [ + 100, + 0 + ] + } + }, + "record": { + "producer": { + "kind": "workflow", + "id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 8, + "task_attempt": 3, + "task_started_time": [ + 100, + 0 + ] + } + } + }, + "withdraws": false + }, + { + "name": "e4 U at an earlier started time goes", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 9, + "task_attempt": 1, + "task_started_time": [ + 200, + 0 + ] + }, + "failed_tasks": [], + "previous_completed_task": { + "started_event_id": 6, + "attempt": 1, + "started_time": [ + 100, + 0 + ] + } + }, + "record": { + "producer": { + "kind": "workflow", + "id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 9, + "task_attempt": 1, + "task_started_time": [ + 150, + 0 + ] + } + } + }, + "withdraws": true + }, + { + "name": "e5 D between c and t goes", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 14, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [], + "previous_completed_task": { + "started_event_id": 5, + "attempt": 1, + "started_time": [ + 100, + 0 + ] + } + }, + "record": { + "producer": { + "kind": "workflow", + "id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 11, + "task_attempt": 2, + "task_started_time": [ + 100, + 0 + ] + } + } + }, + "withdraws": true + }, + { + "name": "e5 a Record at c stays", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 14, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [], + "previous_completed_task": { + "started_event_id": 5, + "attempt": 1, + "started_time": [ + 100, + 0 + ] + } + }, + "record": { + "producer": { + "kind": "workflow", + "id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 5, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + } + } + }, + "withdraws": false + }, + { + "name": "c attempt 3, attempt 2 at c goes", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 14, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [], + "previous_completed_task": { + "started_event_id": 8, + "attempt": 3, + "started_time": [ + 300, + 0 + ] + } + }, + "record": { + "producer": { + "kind": "workflow", + "id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 8, + "task_attempt": 2, + "task_started_time": [ + 200, + 0 + ] + } + } + }, + "withdraws": true + }, + { + "name": "c attempt 3, c itself stays", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 14, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [], + "previous_completed_task": { + "started_event_id": 8, + "attempt": 3, + "started_time": [ + 300, + 0 + ] + } + }, + "record": { + "producer": { + "kind": "workflow", + "id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 8, + "task_attempt": 3, + "task_started_time": [ + 300, + 0 + ] + } + } + }, + "withdraws": false + }, + { + "name": "c with no attempt keeps every Record at c", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 14, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [], + "previous_completed_task": { + "started_event_id": 8 + } + }, + "record": { + "producer": { + "kind": "workflow", + "id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 8, + "task_attempt": 1, + "task_started_time": [ + 200, + 0 + ] + } + } + }, + "withdraws": false + }, + { + "name": "e1 a Local Activity of the failed task goes", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 11, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [ + 5 + ], + "previous_completed_task": { + "started_event_id": 8, + "attempt": 1, + "started_time": [ + 100, + 0 + ] + } + }, + "record": { + "producer": { + "kind": "local_activity", + "id": "la:r1:k", + "run_id": "r1", + "epoch": { + "task_started_event_id": 5, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ], + "attempt": 2 + } + } + }, + "withdraws": true + }, + { + "name": "another run's Record stays", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 11, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [ + 5 + ], + "previous_completed_task": { + "started_event_id": 8, + "attempt": 1, + "started_time": [ + 100, + 0 + ] + } + }, + "record": { + "producer": { + "kind": "workflow", + "id": "wf:r9", + "run_id": "r9", + "epoch": { + "task_started_event_id": 5, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + } + } + }, + "withdraws": false + }, + { + "name": "e6 r1's Record goes", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r2", + "run_id": "r2", + "epoch": { + "task_started_event_id": 3, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [], + "withdraw_run": "r1" + }, + "record": { + "producer": { + "kind": "workflow", + "id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 20, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + } + } + }, + "withdraws": true + }, + { + "name": "e6 r1's Activity Record goes", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r2", + "run_id": "r2", + "epoch": { + "task_started_event_id": 3, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [], + "withdraw_run": "r1" + }, + "record": { + "producer": { + "kind": "activity", + "id": "act:r1:a:s", + "run_id": "r1", + "epoch": { + "attempt_scheduled_time": [ + 100, + 0 + ], + "attempt": 1 + } + } + }, + "withdraws": true + }, + { + "name": "e6 r2's own earlier Record of t stays", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r2", + "run_id": "r2", + "epoch": { + "task_started_event_id": 3, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [], + "withdraw_run": "r1" + }, + "record": { + "producer": { + "kind": "workflow", + "id": "wf:r2", + "run_id": "r2", + "epoch": { + "task_started_event_id": 3, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + } + } + }, + "withdraws": false + }, + { + "name": "an Activity retry withdraws attempt 1", + "supersession": { + "producer_kind": "activity", + "producer_id": "act:r1:a:s", + "run_id": "r1", + "epoch": { + "attempt_scheduled_time": [ + 130, + 0 + ], + "attempt": 2 + } + }, + "record": { + "producer": { + "kind": "activity", + "id": "act:r1:a:s", + "run_id": "r1", + "epoch": { + "attempt_scheduled_time": [ + 100, + 0 + ], + "attempt": 1 + } + } + }, + "withdraws": true + }, + { + "name": "an Activity retry keeps another Activity", + "supersession": { + "producer_kind": "activity", + "producer_id": "act:r1:a:s", + "run_id": "r1", + "epoch": { + "attempt_scheduled_time": [ + 130, + 0 + ], + "attempt": 2 + } + }, + "record": { + "producer": { + "kind": "activity", + "id": "act:r1:b:s", + "run_id": "r1", + "epoch": { + "attempt_scheduled_time": [ + 100, + 0 + ], + "attempt": 1 + } + } + }, + "withdraws": false + }, + { + "name": "a client's newer epoch keeps its earlier Records", + "supersession": { + "producer_kind": "client", + "producer_id": "client:ingest", + "epoch": { + "attempt": 2 + } + }, + "record": { + "producer": { + "kind": "client", + "id": "client:ingest", + "epoch": { + "attempt": 1 + } + } + }, + "withdraws": false + }, + { + "name": "no epoch, r1's Record goes", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r2", + "run_id": "r2", + "withdraw_run": "r1", + "failed_tasks": [ + 5 + ] + }, + "record": { + "producer": { + "kind": "workflow", + "id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 5, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + } + } + }, + "withdraws": true + }, + { + "name": "no epoch, r1's Local Activity Record goes", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r2", + "run_id": "r2", + "withdraw_run": "r1", + "failed_tasks": [ + 5 + ] + }, + "record": { + "producer": { + "kind": "local_activity", + "id": "la:r1:k", + "run_id": "r1", + "epoch": { + "task_started_event_id": 5, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ], + "attempt": 1 + } + } + }, + "withdraws": true + }, + { + "name": "no epoch, r2's Local Activity Record at a failed task stays", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r2", + "run_id": "r2", + "withdraw_run": "r1", + "failed_tasks": [ + 5 + ] + }, + "record": { + "producer": { + "kind": "local_activity", + "id": "la:r2:k", + "run_id": "r2", + "epoch": { + "task_started_event_id": 5, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ], + "attempt": 1 + } + } + }, + "withdraws": false + }, + { + "name": "no epoch, r2's Record stays", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r2", + "run_id": "r2", + "withdraw_run": "r1", + "failed_tasks": [ + 5 + ] + }, + "record": { + "producer": { + "kind": "workflow", + "id": "wf:r2", + "run_id": "r2", + "epoch": { + "task_started_event_id": 5, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + } + } + }, + "withdraws": false + }, + { + "name": "a Local Activity's newer epoch withdraws a lower attempt", + "supersession": { + "producer_kind": "local_activity", + "producer_id": "la:r1:k", + "run_id": "r1", + "epoch": { + "task_started_event_id": 8, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ], + "attempt": 3 + } + }, + "record": { + "producer": { + "kind": "local_activity", + "id": "la:r1:k", + "run_id": "r1", + "epoch": { + "task_started_event_id": 8, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ], + "attempt": 2 + } + } + }, + "withdraws": true + }, + { + "name": "a Local Activity's newer epoch withdraws an earlier task", + "supersession": { + "producer_kind": "local_activity", + "producer_id": "la:r1:k", + "run_id": "r1", + "epoch": { + "task_started_event_id": 8, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ], + "attempt": 3 + } + }, + "record": { + "producer": { + "kind": "local_activity", + "id": "la:r1:k", + "run_id": "r1", + "epoch": { + "task_started_event_id": 5, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ], + "attempt": 4 + } + } + }, + "withdraws": true + }, + { + "name": "a Local Activity's newer epoch keeps its own epoch", + "supersession": { + "producer_kind": "local_activity", + "producer_id": "la:r1:k", + "run_id": "r1", + "epoch": { + "task_started_event_id": 8, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ], + "attempt": 3 + } + }, + "record": { + "producer": { + "kind": "local_activity", + "id": "la:r1:k", + "run_id": "r1", + "epoch": { + "task_started_event_id": 8, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ], + "attempt": 3 + } + } + }, + "withdraws": false + }, + { + "name": "a Local Activity's newer epoch keeps another call", + "supersession": { + "producer_kind": "local_activity", + "producer_id": "la:r1:k", + "run_id": "r1", + "epoch": { + "task_started_event_id": 8, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ], + "attempt": 3 + } + }, + "record": { + "producer": { + "kind": "local_activity", + "id": "la:r1:j", + "run_id": "r1", + "epoch": { + "task_started_event_id": 5, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ], + "attempt": 1 + } + } + }, + "withdraws": false + }, + { + "name": "a Local Activity's item keeps the run's Workflow Record", + "supersession": { + "producer_kind": "local_activity", + "producer_id": "la:r1:k", + "run_id": "r1", + "epoch": { + "task_started_event_id": 8, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ], + "attempt": 3 + } + }, + "record": { + "producer": { + "kind": "workflow", + "id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 5, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ] + } + } + }, + "withdraws": false + }, + { + "name": "a Local Activity before c stays", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 14, + "task_attempt": 2, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [ + 5 + ], + "previous_completed_task": { + "started_event_id": 8, + "attempt": 1, + "started_time": [ + 100, + 0 + ] + } + }, + "record": { + "producer": { + "kind": "local_activity", + "id": "la:r1:k", + "run_id": "r1", + "epoch": { + "task_started_event_id": 3, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ], + "attempt": 1 + } + } + }, + "withdraws": false + }, + { + "name": "a Local Activity at c stays", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 14, + "task_attempt": 2, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [ + 5 + ], + "previous_completed_task": { + "started_event_id": 8, + "attempt": 1, + "started_time": [ + 100, + 0 + ] + } + }, + "record": { + "producer": { + "kind": "local_activity", + "id": "la:r1:k", + "run_id": "r1", + "epoch": { + "task_started_event_id": 8, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ], + "attempt": 1 + } + } + }, + "withdraws": false + }, + { + "name": "a Local Activity between c and t goes", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 14, + "task_attempt": 2, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [ + 5 + ], + "previous_completed_task": { + "started_event_id": 8, + "attempt": 1, + "started_time": [ + 100, + 0 + ] + } + }, + "record": { + "producer": { + "kind": "local_activity", + "id": "la:r1:k", + "run_id": "r1", + "epoch": { + "task_started_event_id": 11, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ], + "attempt": 1 + } + } + }, + "withdraws": true + }, + { + "name": "a Local Activity of a lower attempt of t goes", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 14, + "task_attempt": 2, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [ + 5 + ], + "previous_completed_task": { + "started_event_id": 8, + "attempt": 1, + "started_time": [ + 100, + 0 + ] + } + }, + "record": { + "producer": { + "kind": "local_activity", + "id": "la:r1:k", + "run_id": "r1", + "epoch": { + "task_started_event_id": 14, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ], + "attempt": 1 + } + } + }, + "withdraws": true + }, + { + "name": "a Local Activity of t itself stays", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 14, + "task_attempt": 2, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [ + 5 + ], + "previous_completed_task": { + "started_event_id": 8, + "attempt": 1, + "started_time": [ + 100, + 0 + ] + } + }, + "record": { + "producer": { + "kind": "local_activity", + "id": "la:r1:k", + "run_id": "r1", + "epoch": { + "task_started_event_id": 14, + "task_attempt": 2, + "task_started_time": [ + 100, + 0 + ], + "attempt": 1 + } + } + }, + "withdraws": false + }, + { + "name": "a Local Activity of another run stays", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 14, + "task_attempt": 2, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [ + 5 + ], + "previous_completed_task": { + "started_event_id": 8, + "attempt": 1, + "started_time": [ + 100, + 0 + ] + } + }, + "record": { + "producer": { + "kind": "local_activity", + "id": "la:r9:k", + "run_id": "r9", + "epoch": { + "task_started_event_id": 11, + "task_attempt": 1, + "task_started_time": [ + 100, + 0 + ], + "attempt": 1 + } + } + }, + "withdraws": false + }, + { + "name": "a Local Activity Record with no epoch stays", + "supersession": { + "producer_kind": "workflow", + "producer_id": "wf:r1", + "run_id": "r1", + "epoch": { + "task_started_event_id": 14, + "task_attempt": 2, + "task_started_time": [ + 100, + 0 + ] + }, + "failed_tasks": [ + 5 + ], + "previous_completed_task": { + "started_event_id": 8, + "attempt": 1, + "started_time": [ + 100, + 0 + ] + } + }, + "record": { + "producer": { + "kind": "local_activity", + "id": "la:r1:k", + "run_id": "r1" + } + }, + "withdraws": false + } +] diff --git a/crates/streams/tests/vectors.rs b/crates/streams/tests/vectors.rs new file mode 100644 index 000000000..240fcb8e2 --- /dev/null +++ b/crates/streams/tests/vectors.rs @@ -0,0 +1,248 @@ +//! The shared test vectors, which `testdata/gen_vectors.py` writes from a second implementation. +//! Every SDK that builds ids or reads Records runs the same files. + +use prost_types::Timestamp; +use serde_json::Value; +use temporalio_common::protos::temporal::api::common::v1::{ + Payload, payload::ExternalPayloadDetails, +}; +use temporalio_streams::{ + digest::batch_digest, + epoch::{epoch_key, task_key, withdraws}, + ids::{self, StreamKeys, fields}, + offset::{entry_id_of_offset, offset_of_entry_id}, + proto::{ + AppendRecord, Producer, ProducerEpoch, StreamData, StreamProducerKind, StreamRecord, + Supersession, WorkflowTaskEpoch, stream_record::Item, + }, +}; + +fn load(name: &str) -> Value { + let path = format!("{}/testdata/{name}", env!("CARGO_MANIFEST_DIR")); + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap() +} + +fn text<'a>(value: &'a Value, field: &str) -> &'a str { + value[field].as_str().unwrap() +} + +fn time(value: &Value) -> Option { + let pair = value.as_array()?; + Some(Timestamp { + seconds: pair[0].as_i64().unwrap(), + nanos: pair[1].as_i64().unwrap() as i32, + }) +} + +fn number(value: &Value, field: &str) -> i64 { + value[field].as_i64().unwrap_or(0) +} + +fn epoch(value: &Value) -> ProducerEpoch { + ProducerEpoch { + task_started_event_id: number(value, "task_started_event_id"), + task_attempt: number(value, "task_attempt") as i32, + task_started_time: time(&value["task_started_time"]), + attempt_scheduled_time: time(&value["attempt_scheduled_time"]), + attempt: number(value, "attempt") as i32, + } +} + +fn kind(name: &str) -> StreamProducerKind { + match name { + "workflow" => StreamProducerKind::Workflow, + "local_activity" => StreamProducerKind::LocalActivity, + "activity" => StreamProducerKind::Activity, + "standalone_activity" => StreamProducerKind::StandaloneActivity, + "client" => StreamProducerKind::Client, + other => panic!("unknown kind {other}"), + } +} + +#[test] +fn stream_ids_and_keys_match() { + let vectors = load("ids.json"); + for case in vectors["streams"].as_array().unwrap() { + let (workflow_id, run) = (text(case, "workflow_id"), text(case, "first_run_id")); + let id = match case["name"].as_str() { + Some(name) => ids::scoped_stream_id(workflow_id, run, name), + None => ids::default_stream_id(workflow_id, run), + }; + assert_eq!(id, text(case, "stream_id")); + let keys = StreamKeys::new(text(case, "prefix"), text(case, "namespace"), &id); + assert_eq!(keys.meta(), text(case, "meta")); + assert_eq!(keys.log(), text(case, "log")); + assert_eq!(keys.producers(), text(case, "producers")); + } +} + +#[test] +fn producer_ids_match() { + let producers = &load("ids.json")["producers"]; + let id = |name: &str| text(&producers[name], "id"); + let field = |name: &str, field: &str| text(&producers[name], field); + assert_eq!( + ids::workflow_producer_id(field("workflow", "run_id")), + id("workflow") + ); + assert_eq!( + ids::local_activity_producer_id( + field("local_activity", "run_id"), + field("local_activity", "call_key") + ), + id("local_activity") + ); + assert_eq!( + ids::activity_producer_id( + field("activity", "run_id"), + field("activity", "activity_id"), + field("activity", "schedule_key") + ), + id("activity") + ); + assert_eq!( + ids::standalone_activity_producer_id( + field("standalone_activity", "activity_id"), + field("standalone_activity", "activity_run_id") + ), + id("standalone_activity") + ); + assert_eq!( + ids::client_producer_id(field("client", "identity"), field("client", "process_key")), + id("client") + ); + assert_eq!( + ids::named_client_producer_id(field("named_client", "name")), + id("named_client") + ); +} + +#[test] +fn offsets_match() { + for case in load("offsets.json").as_array().unwrap() { + let offset = number(case, "offset"); + assert_eq!(offset_of_entry_id(text(case, "entry_id")).unwrap(), offset); + assert_eq!(entry_id_of_offset(offset), text(case, "entry_id")); + } +} + +#[test] +fn epoch_keys_match() { + for case in load("epochs.json").as_array().unwrap() { + let epoch = epoch(&case["epoch"]); + assert_eq!(epoch_key(&epoch), text(case, "key")); + let task = WorkflowTaskEpoch { + started_event_id: epoch.task_started_event_id, + attempt: epoch.task_attempt, + started_time: epoch.task_started_time, + }; + assert_eq!(task_key(&task), text(case, "task_key")); + } +} + +#[test] +fn meta_fields_match() { + let case = load("fields.json"); + let (kind, producer, run) = ( + number(&case, "kind") as i32, + text(&case, "producer_id"), + text(&case, "run_id"), + ); + assert_eq!(fields::fence(kind, producer), text(&case, "fence")); + assert_eq!(fields::dedupe(kind, producer), text(&case, "dedupe")); + assert_eq!(fields::holds(kind, producer), text(&case, "holds")); + assert_eq!(fields::run_wrote(run), text(&case, "run_wrote")); + assert_eq!(fields::withdrawn_run(run), text(&case, "withdrawn_run")); +} + +#[test] +fn the_appendix_e_withdrawals_match() { + let cases = load("withdrawals.json"); + assert!(cases.as_array().unwrap().len() >= 35); + for case in cases.as_array().unwrap() { + let s = &case["supersession"]; + let supersession = Supersession { + producer_kind: kind(text(s, "producer_kind")) as i32, + producer_id: text(s, "producer_id").to_string(), + // A create's item has no epoch, and a Reader must not read it as an empty one. + epoch: s.get("epoch").map(epoch), + previous_completed_task: s.get("previous_completed_task").map(|c| WorkflowTaskEpoch { + started_event_id: number(c, "started_event_id"), + attempt: number(c, "attempt") as i32, + started_time: time(&c["started_time"]), + }), + failed_tasks: s["failed_tasks"] + .as_array() + .map(|list| list.iter().map(|v| v.as_i64().unwrap()).collect()) + .unwrap_or_default(), + withdraw_run: s["withdraw_run"].as_str().unwrap_or_default().to_string(), + run_id: s["run_id"].as_str().unwrap_or_default().to_string(), + }; + let p = &case["record"]["producer"]; + let record = StreamRecord { + item: Some(Item::Data(StreamData { + producer: Some(Producer { + kind: kind(text(p, "kind")) as i32, + id: text(p, "id").to_string(), + epoch: p.get("epoch").map(epoch), + run_id: p["run_id"].as_str().unwrap_or_default().to_string(), + }), + ..Default::default() + })), + ..Default::default() + }; + assert_eq!( + withdraws(&supersession, &record), + case["withdraws"].as_bool().unwrap(), + "{}", + text(case, "name") + ); + } +} + +fn hex(text: &str) -> Vec { + (0..text.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&text[i..i + 2], 16).unwrap()) + .collect() +} + +#[test] +fn batch_digests_match() { + for case in load("digests.json").as_array().unwrap() { + let records: Vec<_> = case["records"] + .as_array() + .unwrap() + .iter() + .map(|r| AppendRecord { + // A record with none of these keys has no body. + body: ["metadata", "data", "external_payloads"] + .iter() + .any(|key| r.get(key).is_some()) + .then(|| Payload { + metadata: r["metadata"] + .as_object() + .into_iter() + .flatten() + .map(|(key, value)| (key.clone(), hex(value.as_str().unwrap()))) + .collect(), + data: hex(r["data"].as_str().unwrap_or_default()), + external_payloads: r["external_payloads"] + .as_array() + .into_iter() + .flatten() + .map(|size| ExternalPayloadDetails { + size_bytes: size.as_i64().unwrap(), + }) + .collect(), + }), + }) + .collect(); + assert_eq!( + batch_digest(&records), + text(case, "digest"), + "{}", + text(case, "name") + ); + } +} From 285d4d11a38341b32afad2e8179b5d79f51d005f Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 30 Oct 2026 00:22:48 -0700 Subject: [PATCH 02/12] Removed epoch::timestamp, which nothing calls. --- crates/streams/src/epoch.rs | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/crates/streams/src/epoch.rs b/crates/streams/src/epoch.rs index 795edaf0e..1154d7610 100644 --- a/crates/streams/src/epoch.rs +++ b/crates/streams/src/epoch.rs @@ -9,10 +9,7 @@ use crate::proto::{ stream_record::Item, }; use prost_types::Timestamp; -use std::{ - cmp::Ordering, - time::{SystemTime, UNIX_EPOCH}, -}; +use std::cmp::Ordering; fn hex64(value: i64) -> String { format!("{:016x}", value.max(0)) @@ -70,15 +67,6 @@ fn compare_tasks(a: &WorkflowTaskEpoch, b: &WorkflowTaskEpoch) -> Ordering { task_key(a).cmp(&task_key(b)) } -/// A proto timestamp of a system time. -pub fn timestamp(time: SystemTime) -> Timestamp { - let since = time.duration_since(UNIX_EPOCH).unwrap_or_default(); - Timestamp { - seconds: since.as_secs() as i64, - nanos: since.subsec_nanos() as i32, - } -} - /// Whether a `Superseded` item withdraws an earlier Record. /// /// For an Activity or a Local Activity, it withdraws that Producer's Records with a lower epoch. From fd35fe53c428fe6d6b61f7cfa683ff0d1068c62f Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 30 Oct 2026 00:22:56 -0700 Subject: [PATCH 03/12] Removed dead ids, moved the meta fields to testing, fixed vector docs. Nothing outside the tests built a default Stream id or parsed a Workflow Producer's run. Only the shared Store suite reads the meta field names, so they sit behind test-support. The README says which side reads each vectors file, and the digest docs no longer claim that the server computes the digest. --- crates/streams/Cargo.toml | 4 ++ crates/streams/README.md | 5 ++- crates/streams/src/digest.rs | 4 +- crates/streams/src/ids.rs | 50 ------------------------ crates/streams/src/lib.rs | 2 + crates/streams/src/testing/fields.rs | 53 ++++++++++++++++++++++++++ crates/streams/src/testing/mod.rs | 3 ++ crates/streams/testdata/gen_vectors.py | 10 ++--- crates/streams/testdata/ids.json | 11 ------ crates/streams/tests/vectors.rs | 29 ++++---------- 10 files changed, 77 insertions(+), 94 deletions(-) create mode 100644 crates/streams/src/testing/fields.rs create mode 100644 crates/streams/src/testing/mod.rs diff --git a/crates/streams/Cargo.toml b/crates/streams/Cargo.toml index 277de8918..1c94b6162 100644 --- a/crates/streams/Cargo.toml +++ b/crates/streams/Cargo.toml @@ -11,6 +11,10 @@ repository = "https://github.com/temporalio/sdk-rust" keywords = ["temporal", "workflow", "streams"] categories = ["development-tools"] +[features] +# The test helpers, for the crates of other Stores. +test-support = [] + [dependencies] prost = { workspace = true } prost-types = { workspace = true } diff --git a/crates/streams/README.md b/crates/streams/README.md index b93aea8ac..b9de9896c 100644 --- a/crates/streams/README.md +++ b/crates/streams/README.md @@ -17,5 +17,6 @@ Workflow makes each Stream call from a Local Activity. Core has no Stream code. calls. - `testdata/*.json` are the shared vectors. They cover Stream ids, Producer ids, Store keys and meta fields, offsets, epoch keys, the withdrawal rule and the batch digest of a dedupe entry. - `testdata/gen_vectors.py` writes them from a second implementation. Each SDK, each Store and the - server run the same files. + `testdata/gen_vectors.py` writes them from a second implementation, and the crate's tests read + each file. The Python SDK also reads `ids.json` and `withdrawals.json`, and the server reads + `epochs.json` and `withdrawals.json`. `digests.json` is the golden of the crate's batch digest. diff --git a/crates/streams/src/digest.rs b/crates/streams/src/digest.rs index d2441af72..78e44edc7 100644 --- a/crates/streams/src/digest.rs +++ b/crates/streams/src/digest.rs @@ -1,6 +1,6 @@ //! The content digest of a batch. Each dedupe entry stores it, so that a resend with the same -//! `sequence` and other content fails instead of being dropped without an error. The server -//! computes the same digest, and `testdata/digests.json` has the expected values. +//! `sequence` and other content fails instead of being dropped without an error. The Store compares +//! the digest that this function gives, and `testdata/digests.json` has its expected values. use crate::proto::AppendRecord; use sha2::{Digest, Sha256}; diff --git a/crates/streams/src/ids.rs b/crates/streams/src/ids.rs index 1ef228b22..cbeb157b1 100644 --- a/crates/streams/src/ids.rs +++ b/crates/streams/src/ids.rs @@ -60,11 +60,6 @@ fn join(parts: &[&str]) -> String { .join(":") } -/// The id of a Workflow's default Stream, `:`. -pub fn default_stream_id(workflow_id: &str, first_run_id: &str) -> String { - join(&[workflow_id, first_run_id]) -} - /// The id of a Stream that a Workflow creates with a name, `::`. pub fn scoped_stream_id(workflow_id: &str, first_run_id: &str, name: &str) -> String { join(&[workflow_id, first_run_id, name]) @@ -177,47 +172,6 @@ pub fn check_producer(producer: &Producer) -> StreamResult<()> { Ok(()) } -/// The run id in a Workflow Producer id, or `None` for another id. -pub fn run_of_workflow_producer(id: &str) -> Option { - decode_part(id.strip_prefix("wf:")?) -} - -/// The fields of the `:meta` hash. The field names hold encoded ids only, so a `|` in a field -/// name is always a separator. -pub mod fields { - use super::encode_part; - - /// The format version of the Stream. - pub const VERSION: &str = "v"; - - /// The newest epoch key of a Producer, the fence. - pub fn fence(kind: i32, producer_id: &str) -> String { - format!("e|{kind}|{producer_id}") - } - - /// The last batch of a Producer's newest epoch, as - /// `||||`. These are the dedupe key, the first and - /// last entry ids of the batch, and the content digest that a resend must match. - pub fn dedupe(kind: i32, producer_id: &str) -> String { - format!("d|{kind}|{producer_id}") - } - - /// The newest epoch key of a Producer that still has Records in the Stream. - pub fn holds(kind: i32, producer_id: &str) -> String { - format!("h|{kind}|{producer_id}") - } - - /// Set when a Producer of the run wrote a Record to the Stream. - pub fn run_wrote(run_id: &str) -> String { - format!("run|{}", encode_part(run_id)) - } - - /// Set when a Workflow retry withdrew the run. - pub fn withdrawn_run(run_id: &str) -> String { - format!("wr|{}", encode_part(run_id)) - } -} - /// The keys of one Stream. #[derive(Debug, Clone, PartialEq, Eq)] pub struct StreamKeys { @@ -328,10 +282,6 @@ mod tests { "{producer:?}" ); } - assert_eq!( - run_of_workflow_producer(&workflow_producer_id(run)).as_deref(), - Some(run) - ); } #[test] diff --git a/crates/streams/src/lib.rs b/crates/streams/src/lib.rs index d4879e593..7cd17dec8 100644 --- a/crates/streams/src/lib.rs +++ b/crates/streams/src/lib.rs @@ -12,6 +12,8 @@ mod error; pub mod ids; pub mod offset; pub mod protos; +#[cfg(any(test, feature = "test-support"))] +pub mod testing; pub use error::{Reason, StreamError, StreamResult}; diff --git a/crates/streams/src/testing/fields.rs b/crates/streams/src/testing/fields.rs new file mode 100644 index 000000000..f05c80d6d --- /dev/null +++ b/crates/streams/src/testing/fields.rs @@ -0,0 +1,53 @@ +//! The fields of the `:meta` hash. The field names hold encoded ids only, so a `|` in a field name +//! is always a separator. + +use crate::ids::encode_part; + +/// The format version of the Stream. +pub const VERSION: &str = "v"; + +/// The newest epoch key of a Producer, the fence. +pub fn fence(kind: i32, producer_id: &str) -> String { + format!("e|{kind}|{producer_id}") +} + +/// The last batch of a Producer's newest epoch, as +/// `||||`. These are the dedupe key, the first and last +/// entry ids of the batch, and the content digest that a resend must match. +pub fn dedupe(kind: i32, producer_id: &str) -> String { + format!("d|{kind}|{producer_id}") +} + +/// The newest epoch key of a Producer that still has Records in the Stream. +pub fn holds(kind: i32, producer_id: &str) -> String { + format!("h|{kind}|{producer_id}") +} + +/// Set when a Producer of the run wrote a Record to the Stream. +pub fn run_wrote(run_id: &str) -> String { + format!("run|{}", encode_part(run_id)) +} + +/// Set when a Workflow retry withdrew the run. +pub fn withdrawn_run(run_id: &str) -> String { + format!("wr|{}", encode_part(run_id)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_fields_match_the_shared_vectors() { + let case: serde_json::Value = + serde_json::from_str(include_str!("../../testdata/fields.json")).unwrap(); + let text = |field: &str| case[field].as_str().unwrap(); + let kind = case["kind"].as_i64().unwrap() as i32; + let (producer, run) = (text("producer_id"), text("run_id")); + assert_eq!(fence(kind, producer), text("fence")); + assert_eq!(dedupe(kind, producer), text("dedupe")); + assert_eq!(holds(kind, producer), text("holds")); + assert_eq!(run_wrote(run), text("run_wrote")); + assert_eq!(withdrawn_run(run), text("withdrawn_run")); + } +} diff --git a/crates/streams/src/testing/mod.rs b/crates/streams/src/testing/mod.rs new file mode 100644 index 000000000..f8feafa3b --- /dev/null +++ b/crates/streams/src/testing/mod.rs @@ -0,0 +1,3 @@ +//! Test helpers that the crates of other Stores share. + +pub mod fields; diff --git a/crates/streams/testdata/gen_vectors.py b/crates/streams/testdata/gen_vectors.py index c18d1a6cf..42f3540b6 100644 --- a/crates/streams/testdata/gen_vectors.py +++ b/crates/streams/testdata/gen_vectors.py @@ -1,7 +1,7 @@ """Writes the shared test vectors of the Stream runtime. This is a second implementation of the layout, in Python, so the crate's tests check the Rust -code against it. Each SDK runs the same JSON files. Run it from this directory: +code against it. The crate's README says which side reads each file. Run it from this directory: python3 gen_vectors.py """ @@ -58,14 +58,10 @@ def epoch_key(epoch): def ids(): cases = [] for prefix, namespace, workflow_id, first_run_id, name in [ - ("temporal-streams", "default", "agent-1", "run-1", None), ("temporal-streams", "default", "agent-1", "run-1", "turn-3"), ("p:x", "ns{1}", "a:b/c", "r%1", "é name"), ]: - if name is None: - stream_id = join(workflow_id, first_run_id) - else: - stream_id = join(workflow_id, first_run_id, name) + stream_id = join(workflow_id, first_run_id, name) cases.append( { "prefix": prefix, @@ -180,7 +176,7 @@ def part(data): def digests(): - """Batches as hex bytes, so the server and every SDK compute the same digest.""" + """Batches as hex bytes, with the digest that the crate's `batch_digest` must compute.""" cases = [ {"name": "empty batch", "records": []}, {"name": "one record, no body", "records": [{}]}, diff --git a/crates/streams/testdata/ids.json b/crates/streams/testdata/ids.json index 9275767d7..b53900947 100644 --- a/crates/streams/testdata/ids.json +++ b/crates/streams/testdata/ids.json @@ -1,16 +1,5 @@ { "streams": [ - { - "prefix": "temporal-streams", - "namespace": "default", - "workflow_id": "agent-1", - "first_run_id": "run-1", - "name": null, - "stream_id": "agent-1:run-1", - "meta": "temporal-streams:{default:agent-1%3Arun-1}:meta", - "log": "temporal-streams:{default:agent-1%3Arun-1}:log", - "producers": "temporal-streams:{default:agent-1%3Arun-1}:producers" - }, { "prefix": "temporal-streams", "namespace": "default", diff --git a/crates/streams/tests/vectors.rs b/crates/streams/tests/vectors.rs index 240fcb8e2..68fe97416 100644 --- a/crates/streams/tests/vectors.rs +++ b/crates/streams/tests/vectors.rs @@ -1,5 +1,5 @@ //! The shared test vectors, which `testdata/gen_vectors.py` writes from a second implementation. -//! Every SDK that builds ids or reads Records runs the same files. +//! The crate's README says which other side reads each file. use prost_types::Timestamp; use serde_json::Value; @@ -9,7 +9,7 @@ use temporalio_common::protos::temporal::api::common::v1::{ use temporalio_streams::{ digest::batch_digest, epoch::{epoch_key, task_key, withdraws}, - ids::{self, StreamKeys, fields}, + ids::{self, StreamKeys}, offset::{entry_id_of_offset, offset_of_entry_id}, proto::{ AppendRecord, Producer, ProducerEpoch, StreamData, StreamProducerKind, StreamRecord, @@ -63,11 +63,11 @@ fn kind(name: &str) -> StreamProducerKind { fn stream_ids_and_keys_match() { let vectors = load("ids.json"); for case in vectors["streams"].as_array().unwrap() { - let (workflow_id, run) = (text(case, "workflow_id"), text(case, "first_run_id")); - let id = match case["name"].as_str() { - Some(name) => ids::scoped_stream_id(workflow_id, run, name), - None => ids::default_stream_id(workflow_id, run), - }; + let id = ids::scoped_stream_id( + text(case, "workflow_id"), + text(case, "first_run_id"), + text(case, "name"), + ); assert_eq!(id, text(case, "stream_id")); let keys = StreamKeys::new(text(case, "prefix"), text(case, "namespace"), &id); assert_eq!(keys.meta(), text(case, "meta")); @@ -140,21 +140,6 @@ fn epoch_keys_match() { } } -#[test] -fn meta_fields_match() { - let case = load("fields.json"); - let (kind, producer, run) = ( - number(&case, "kind") as i32, - text(&case, "producer_id"), - text(&case, "run_id"), - ); - assert_eq!(fields::fence(kind, producer), text(&case, "fence")); - assert_eq!(fields::dedupe(kind, producer), text(&case, "dedupe")); - assert_eq!(fields::holds(kind, producer), text(&case, "holds")); - assert_eq!(fields::run_wrote(run), text(&case, "run_wrote")); - assert_eq!(fields::withdrawn_run(run), text(&case, "withdrawn_run")); -} - #[test] fn the_appendix_e_withdrawals_match() { let cases = load("withdrawals.json"); From 0fa1945268d0ea24176d109572a1587e1ca23314 Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Fri, 30 Oct 2026 00:45:43 -0700 Subject: [PATCH 04/12] Dropped the server from the vectors README line. The server keeps its own copies of some vectors, and two of them differ from these files. --- crates/streams/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/streams/README.md b/crates/streams/README.md index b9de9896c..8bab866bd 100644 --- a/crates/streams/README.md +++ b/crates/streams/README.md @@ -18,5 +18,5 @@ Workflow makes each Stream call from a Local Activity. Core has no Stream code. - `testdata/*.json` are the shared vectors. They cover Stream ids, Producer ids, Store keys and meta fields, offsets, epoch keys, the withdrawal rule and the batch digest of a dedupe entry. `testdata/gen_vectors.py` writes them from a second implementation, and the crate's tests read - each file. The Python SDK also reads `ids.json` and `withdrawals.json`, and the server reads - `epochs.json` and `withdrawals.json`. `digests.json` is the golden of the crate's batch digest. + each file. The Python SDK also reads `ids.json` and `withdrawals.json`. `digests.json` is the + golden of the crate's batch digest. From f24d43d6b1025ea2210575b31c3e6dffcc7275ca Mon Sep 17 00:00:00 2001 From: Mohammad Dashti Date: Sat, 31 Oct 2026 00:58:53 -0700 Subject: [PATCH 05/12] Renamed Supersession to Superseded in the rule and the vectors. --- crates/streams/src/epoch.rs | 26 ++++----- crates/streams/testdata/gen_vectors.py | 30 +++++----- crates/streams/testdata/withdrawals.json | 74 ++++++++++++------------ crates/streams/tests/vectors.rs | 8 +-- 4 files changed, 69 insertions(+), 69 deletions(-) diff --git a/crates/streams/src/epoch.rs b/crates/streams/src/epoch.rs index 1154d7610..32dc9da4d 100644 --- a/crates/streams/src/epoch.rs +++ b/crates/streams/src/epoch.rs @@ -5,7 +5,7 @@ //! the epochs field by field. `testdata/epochs.json` has the shared vectors of the keys. use crate::proto::{ - Producer, ProducerEpoch, StreamProducerKind, StreamRecord, Supersession, WorkflowTaskEpoch, + Producer, ProducerEpoch, StreamProducerKind, StreamRecord, StreamSuperseded, WorkflowTaskEpoch, stream_record::Item, }; use prost_types::Timestamp; @@ -75,37 +75,37 @@ fn compare_tasks(a: &WorkflowTaskEpoch, b: &WorkflowTaskEpoch) -> Ordering { /// those at c or at t with a lower epoch. The item of a retry run withdraws every Record of the /// previous run. A client's newer epoch fences the earlier ones but withdraws nothing, since a /// restarted client's earlier Records are real messages, not a failed attempt's. -pub fn withdraws(supersession: &Supersession, record: &StreamRecord) -> bool { +pub fn withdraws(superseded: &StreamSuperseded, record: &StreamRecord) -> bool { let Some(Item::Data(data)) = &record.item else { return false; }; let Some(producer) = data.producer.as_ref() else { return false; }; - if !supersession.withdraw_run.is_empty() && producer.run_id == supersession.withdraw_run { + if !superseded.withdraw_run.is_empty() && producer.run_id == superseded.withdraw_run { return true; } - let kind = StreamProducerKind::try_from(supersession.producer_kind).unwrap_or_default(); + let kind = StreamProducerKind::try_from(superseded.producer_kind).unwrap_or_default(); if kind == StreamProducerKind::Client { return false; } - let (Some(epoch), Some(old)) = (supersession.epoch.as_ref(), producer.epoch.as_ref()) else { + let (Some(epoch), Some(old)) = (superseded.epoch.as_ref(), producer.epoch.as_ref()) else { return false; }; if kind != StreamProducerKind::Workflow { - return producer.kind == supersession.producer_kind - && producer.id == supersession.producer_id + return producer.kind == superseded.producer_kind + && producer.id == superseded.producer_id && compare_epochs(old, epoch) == Ordering::Less; } - if !of_run(producer, supersession) { + if !of_run(producer, superseded) { return false; } let (task, old) = (task_of(epoch), task_of(old)); let started = old.started_event_id; - if supersession.failed_tasks.contains(&started) { + if superseded.failed_tasks.contains(&started) { return true; } - let completed = supersession.previous_completed_task.as_ref(); + let completed = superseded.previous_completed_task.as_ref(); let after = completed.map_or(0, |c| c.started_event_id); if after < started && started < task.started_event_id { return true; @@ -118,11 +118,11 @@ pub fn withdraws(supersession: &Supersession, record: &StreamRecord) -> bool { /// Whether a Producer is the Workflow run of a `Superseded` item, or one of its Local /// Activities. -fn of_run(producer: &Producer, supersession: &Supersession) -> bool { +fn of_run(producer: &Producer, superseded: &StreamSuperseded) -> bool { let kind = StreamProducerKind::try_from(producer.kind).unwrap_or_default(); match kind { - StreamProducerKind::Workflow => producer.id == supersession.producer_id, - StreamProducerKind::LocalActivity => producer.run_id == supersession.run_id, + StreamProducerKind::Workflow => producer.id == superseded.producer_id, + StreamProducerKind::LocalActivity => producer.run_id == superseded.run_id, _ => false, } } diff --git a/crates/streams/testdata/gen_vectors.py b/crates/streams/testdata/gen_vectors.py index 42f3540b6..d4a4102c3 100644 --- a/crates/streams/testdata/gen_vectors.py +++ b/crates/streams/testdata/gen_vectors.py @@ -210,29 +210,29 @@ def digests(): return cases -def withdraws(supersession, record): +def withdraws(superseded, record): """The reader rule of a Superseded item, as the API proposal states it.""" producer = record["producer"] - if supersession.get("withdraw_run") and producer.get("run_id") == supersession["withdraw_run"]: + if superseded.get("withdraw_run") and producer.get("run_id") == superseded["withdraw_run"]: return True # A client's newer epoch only fences. Its earlier Records are real messages. - if supersession["producer_kind"] == "client": + if superseded["producer_kind"] == "client": return False # An item with no epoch, such as a create's, withdraws only a run. - if "epoch" not in supersession or "epoch" not in producer: + if "epoch" not in superseded or "epoch" not in producer: return False - old, new = producer["epoch"], supersession["epoch"] - if supersession["producer_kind"] != "workflow": + old, new = producer["epoch"], superseded["epoch"] + if superseded["producer_kind"] != "workflow": return ( - producer["kind"] == supersession["producer_kind"] - and producer["id"] == supersession["producer_id"] + producer["kind"] == superseded["producer_kind"] + and producer["id"] == superseded["producer_id"] and epoch_key(old) < epoch_key(new) ) if producer["kind"] == "workflow": - if producer["id"] != supersession["producer_id"]: + if producer["id"] != superseded["producer_id"]: return False elif producer["kind"] == "local_activity": - if producer.get("run_id", "") != supersession["run_id"]: + if producer.get("run_id", "") != superseded["run_id"]: return False else: return False @@ -249,9 +249,9 @@ def task(epoch): started, old_key = task(old) t_started, t_key = task(new) - c = supersession.get("previous_completed_task") + c = superseded.get("previous_completed_task") c_started = c["started_event_id"] if c else 0 - if started in supersession.get("failed_tasks", []): + if started in superseded.get("failed_tasks", []): return True if c_started < started < t_started: return True @@ -275,10 +275,10 @@ def withdrawal_examples(): r1 = "r1" cases = [] - def case(name, supersession, producer, expected): + def case(name, superseded, producer, expected): record = {"producer": producer} - assert withdraws(supersession, record) == expected, name - cases.append({"name": name, "supersession": supersession, "record": record, + assert withdraws(superseded, record) == expected, name + cases.append({"name": name, "superseded": superseded, "record": record, "withdraws": expected}) def sup(task, c=None, failed=(), withdraw_run=None, run=r1): diff --git a/crates/streams/testdata/withdrawals.json b/crates/streams/testdata/withdrawals.json index 3913d1015..fdf395bf4 100644 --- a/crates/streams/testdata/withdrawals.json +++ b/crates/streams/testdata/withdrawals.json @@ -1,7 +1,7 @@ [ { "name": "e1 B at the failed task 5 goes", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", @@ -44,7 +44,7 @@ }, { "name": "e1 a Record at the completed task 8 stays", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", @@ -87,7 +87,7 @@ }, { "name": "e1 a Record before c stays", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", @@ -130,7 +130,7 @@ }, { "name": "e2 B goes", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", @@ -173,7 +173,7 @@ }, { "name": "e2 an older completed Record stays", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", @@ -216,7 +216,7 @@ }, { "name": "e3 D at a lower attempt of t goes", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", @@ -257,7 +257,7 @@ }, { "name": "e3 a Record of t itself stays", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", @@ -298,7 +298,7 @@ }, { "name": "e4 U at an earlier started time goes", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", @@ -339,7 +339,7 @@ }, { "name": "e5 D between c and t goes", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", @@ -380,7 +380,7 @@ }, { "name": "e5 a Record at c stays", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", @@ -421,7 +421,7 @@ }, { "name": "c attempt 3, attempt 2 at c goes", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", @@ -462,7 +462,7 @@ }, { "name": "c attempt 3, c itself stays", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", @@ -503,7 +503,7 @@ }, { "name": "c with no attempt keeps every Record at c", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", @@ -539,7 +539,7 @@ }, { "name": "e1 a Local Activity of the failed task goes", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", @@ -583,7 +583,7 @@ }, { "name": "another run's Record stays", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", @@ -626,7 +626,7 @@ }, { "name": "e6 r1's Record goes", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r2", "run_id": "r2", @@ -660,7 +660,7 @@ }, { "name": "e6 r1's Activity Record goes", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r2", "run_id": "r2", @@ -693,7 +693,7 @@ }, { "name": "e6 r2's own earlier Record of t stays", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r2", "run_id": "r2", @@ -727,7 +727,7 @@ }, { "name": "an Activity retry withdraws attempt 1", - "supersession": { + "superseded": { "producer_kind": "activity", "producer_id": "act:r1:a:s", "run_id": "r1", @@ -757,7 +757,7 @@ }, { "name": "an Activity retry keeps another Activity", - "supersession": { + "superseded": { "producer_kind": "activity", "producer_id": "act:r1:a:s", "run_id": "r1", @@ -787,7 +787,7 @@ }, { "name": "a client's newer epoch keeps its earlier Records", - "supersession": { + "superseded": { "producer_kind": "client", "producer_id": "client:ingest", "epoch": { @@ -807,7 +807,7 @@ }, { "name": "no epoch, r1's Record goes", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r2", "run_id": "r2", @@ -835,7 +835,7 @@ }, { "name": "no epoch, r1's Local Activity Record goes", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r2", "run_id": "r2", @@ -864,7 +864,7 @@ }, { "name": "no epoch, r2's Local Activity Record at a failed task stays", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r2", "run_id": "r2", @@ -893,7 +893,7 @@ }, { "name": "no epoch, r2's Record stays", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r2", "run_id": "r2", @@ -921,7 +921,7 @@ }, { "name": "a Local Activity's newer epoch withdraws a lower attempt", - "supersession": { + "superseded": { "producer_kind": "local_activity", "producer_id": "la:r1:k", "run_id": "r1", @@ -955,7 +955,7 @@ }, { "name": "a Local Activity's newer epoch withdraws an earlier task", - "supersession": { + "superseded": { "producer_kind": "local_activity", "producer_id": "la:r1:k", "run_id": "r1", @@ -989,7 +989,7 @@ }, { "name": "a Local Activity's newer epoch keeps its own epoch", - "supersession": { + "superseded": { "producer_kind": "local_activity", "producer_id": "la:r1:k", "run_id": "r1", @@ -1023,7 +1023,7 @@ }, { "name": "a Local Activity's newer epoch keeps another call", - "supersession": { + "superseded": { "producer_kind": "local_activity", "producer_id": "la:r1:k", "run_id": "r1", @@ -1057,7 +1057,7 @@ }, { "name": "a Local Activity's item keeps the run's Workflow Record", - "supersession": { + "superseded": { "producer_kind": "local_activity", "producer_id": "la:r1:k", "run_id": "r1", @@ -1090,7 +1090,7 @@ }, { "name": "a Local Activity before c stays", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", @@ -1134,7 +1134,7 @@ }, { "name": "a Local Activity at c stays", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", @@ -1178,7 +1178,7 @@ }, { "name": "a Local Activity between c and t goes", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", @@ -1222,7 +1222,7 @@ }, { "name": "a Local Activity of a lower attempt of t goes", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", @@ -1266,7 +1266,7 @@ }, { "name": "a Local Activity of t itself stays", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", @@ -1310,7 +1310,7 @@ }, { "name": "a Local Activity of another run stays", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", @@ -1354,7 +1354,7 @@ }, { "name": "a Local Activity Record with no epoch stays", - "supersession": { + "superseded": { "producer_kind": "workflow", "producer_id": "wf:r1", "run_id": "r1", diff --git a/crates/streams/tests/vectors.rs b/crates/streams/tests/vectors.rs index 68fe97416..5860b59b9 100644 --- a/crates/streams/tests/vectors.rs +++ b/crates/streams/tests/vectors.rs @@ -13,7 +13,7 @@ use temporalio_streams::{ offset::{entry_id_of_offset, offset_of_entry_id}, proto::{ AppendRecord, Producer, ProducerEpoch, StreamData, StreamProducerKind, StreamRecord, - Supersession, WorkflowTaskEpoch, stream_record::Item, + StreamSuperseded, WorkflowTaskEpoch, stream_record::Item, }, }; @@ -145,8 +145,8 @@ fn the_appendix_e_withdrawals_match() { let cases = load("withdrawals.json"); assert!(cases.as_array().unwrap().len() >= 35); for case in cases.as_array().unwrap() { - let s = &case["supersession"]; - let supersession = Supersession { + let s = &case["superseded"]; + let superseded = StreamSuperseded { producer_kind: kind(text(s, "producer_kind")) as i32, producer_id: text(s, "producer_id").to_string(), // A create's item has no epoch, and a Reader must not read it as an empty one. @@ -177,7 +177,7 @@ fn the_appendix_e_withdrawals_match() { ..Default::default() }; assert_eq!( - withdraws(&supersession, &record), + withdraws(&superseded, &record), case["withdraws"].as_bool().unwrap(), "{}", text(case, "name") From bf07ca05f010772df995572c4ade590dc3d80475 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Wed, 4 Nov 2026 10:49:32 -0700 Subject: [PATCH 06/12] Made the task and epoch keys and the batch digest binary. A task key is 24 big-endian bytes, an epoch key 40 and a digest the first 16 bytes of the SHA-256, which saves Store bytes. Both Stores compare the keys as bytes, and the vectors give them in hex. --- crates/streams/src/digest.rs | 19 +++---- crates/streams/src/epoch.rs | 69 ++++++++++++++------------ crates/streams/testdata/digests.json | 18 +++---- crates/streams/testdata/epochs.json | 20 ++++---- crates/streams/testdata/gen_vectors.py | 42 +++++++--------- crates/streams/tests/vectors.rs | 8 +-- 6 files changed, 89 insertions(+), 87 deletions(-) diff --git a/crates/streams/src/digest.rs b/crates/streams/src/digest.rs index 78e44edc7..ef448b357 100644 --- a/crates/streams/src/digest.rs +++ b/crates/streams/src/digest.rs @@ -5,10 +5,13 @@ use crate::proto::AppendRecord; use sha2::{Digest, Sha256}; -/// SHA-256 over the Records in order, as 64 lowercase hex characters. Each part has its length -/// before it, so that two different batches never hash the same bytes. The metadata is hashed in -/// key order, since a map has no order on the wire. -pub fn batch_digest(records: &[AppendRecord]) -> String { +/// The length of a [batch_digest]. +pub const DIGEST_LEN: usize = 16; + +/// The first 16 bytes of a SHA-256 over the Records in order. Each part has its length before it, +/// so that two different batches never hash the same bytes. The metadata is hashed in key order, +/// since a map has no order on the wire. +pub fn batch_digest(records: &[AppendRecord]) -> [u8; DIGEST_LEN] { let mut hasher = Sha256::new(); for record in records { // A Record with no body and one with an empty body are two different stored Records. @@ -30,11 +33,9 @@ pub fn batch_digest(records: &[AppendRecord]) -> String { hasher.update(external.size_bytes.to_be_bytes()); } } - hasher - .finalize() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() + let mut digest = [0; DIGEST_LEN]; + digest.copy_from_slice(&hasher.finalize()[..DIGEST_LEN]); + digest } fn part(hasher: &mut Sha256, bytes: &[u8]) { diff --git a/crates/streams/src/epoch.rs b/crates/streams/src/epoch.rs index 32dc9da4d..6a418107d 100644 --- a/crates/streams/src/epoch.rs +++ b/crates/streams/src/epoch.rs @@ -1,8 +1,8 @@ //! Producer epochs, their order, and the withdrawal rule of a `Superseded` item. //! -//! The Lua scripts compare epochs as strings. So an epoch is stored in Redis as a key of -//! fixed-width hex fields, in the order of comparison. A string comparison of two keys then orders -//! the epochs field by field. `testdata/epochs.json` has the shared vectors of the keys. +//! A Store keeps an epoch as a key of fixed-width big-endian fields, in the order of comparison. +//! A byte comparison of two keys then orders the epochs field by field. `testdata/epochs.json` has +//! the shared vectors of the keys, in hex. use crate::proto::{ Producer, ProducerEpoch, StreamProducerKind, StreamRecord, StreamSuperseded, WorkflowTaskEpoch, @@ -11,42 +11,47 @@ use crate::proto::{ use prost_types::Timestamp; use std::cmp::Ordering; -fn hex64(value: i64) -> String { - format!("{:016x}", value.max(0)) +/// The length of a [task_key]. +pub const TASK_KEY_LEN: usize = 24; +/// The length of an [epoch_key]. +pub const EPOCH_KEY_LEN: usize = 40; + +// A negative field is clamped at zero, since its bytes would outrank every real epoch. +fn be64(value: i64) -> [u8; 8] { + value.max(0).to_be_bytes() } -fn hex32(value: i32) -> String { - format!("{:08x}", value.max(0)) +fn be32(value: i32) -> [u8; 4] { + value.max(0).to_be_bytes() } -fn time_fields(time: Option<&Timestamp>) -> [String; 2] { - let (seconds, nanos) = time.map_or((0, 0), |time| (time.seconds, time.nanos)); - [hex64(seconds), hex32(nanos)] +fn time_fields(time: Option<&Timestamp>) -> (i64, i32) { + time.map_or((0, 0), |time| (time.seconds, time.nanos)) } -/// The key of a Workflow Task attempt, from its started event id, attempt and started time. The -/// epoch keys of the run and of the task's Local Activities start with it. -pub fn task_key(task: &WorkflowTaskEpoch) -> String { - let [seconds, nanos] = time_fields(task.started_time.as_ref()); - [ - hex64(task.started_event_id), - hex32(task.attempt), - seconds, - nanos, - ] - .join(".") +/// The key of a Workflow Task attempt. It is the started event id, the attempt, and the started +/// seconds and nanos, in 8, 4, 8 and 4 bytes. The epoch keys of the run and of the task's Local +/// Activities start with it. +pub fn task_key(task: &WorkflowTaskEpoch) -> [u8; TASK_KEY_LEN] { + let (seconds, nanos) = time_fields(task.started_time.as_ref()); + let mut key = [0; TASK_KEY_LEN]; + key[..8].copy_from_slice(&be64(task.started_event_id)); + key[8..12].copy_from_slice(&be32(task.attempt)); + key[12..20].copy_from_slice(&be64(seconds)); + key[20..].copy_from_slice(&be32(nanos)); + key } -/// The key of an epoch. Fields that a kind doesn't use compare as zero. -pub fn epoch_key(epoch: &ProducerEpoch) -> String { - let [scheduled_seconds, scheduled_nanos] = time_fields(epoch.attempt_scheduled_time.as_ref()); - [ - task_key(&task_of(epoch)), - scheduled_seconds, - scheduled_nanos, - hex32(epoch.attempt), - ] - .join(".") +/// The key of an epoch. It is the task key, then the attempt's scheduled seconds and nanos and the +/// attempt, in 8, 4 and 4 bytes. Fields that a kind doesn't use compare as zero. +pub fn epoch_key(epoch: &ProducerEpoch) -> [u8; EPOCH_KEY_LEN] { + let (seconds, nanos) = time_fields(epoch.attempt_scheduled_time.as_ref()); + let mut key = [0; EPOCH_KEY_LEN]; + key[..TASK_KEY_LEN].copy_from_slice(&task_key(&task_of(epoch))); + key[24..32].copy_from_slice(&be64(seconds)); + key[32..36].copy_from_slice(&be32(nanos)); + key[36..].copy_from_slice(&be32(epoch.attempt)); + key } /// The Workflow Task fields of an epoch. @@ -131,7 +136,7 @@ fn of_run(producer: &Producer, superseded: &StreamSuperseded) -> bool { mod tests { use super::*; - // Without the clamp, the hex of a negative field would outrank every real epoch. + // Without the clamp, the bytes of a negative field would outrank every real epoch. #[test] fn a_negative_field_compares_as_zero() { let negative = ProducerEpoch { diff --git a/crates/streams/testdata/digests.json b/crates/streams/testdata/digests.json index b44ec94ad..d870b506a 100644 --- a/crates/streams/testdata/digests.json +++ b/crates/streams/testdata/digests.json @@ -2,14 +2,14 @@ { "name": "empty batch", "records": [], - "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + "digest": "e3b0c44298fc1c149afbf4c8996fb924" }, { "name": "one record, no body", "records": [ {} ], - "digest": "6e340b9cffb37a989ca544e6bb780a2c78901d3fb33738768511a30617afa01d" + "digest": "6e340b9cffb37a989ca544e6bb780a2c" }, { "name": "one record, an empty body", @@ -18,7 +18,7 @@ "data": "" } ], - "digest": "6bb0f5246ac6ee3fa6a6879c512f393f05348f9cbb2144fe34dc8a8a4354f208" + "digest": "6bb0f5246ac6ee3fa6a6879c512f393f" }, { "name": "one record", @@ -27,7 +27,7 @@ "data": "68656c6c6f" } ], - "digest": "00cedb65fe85721791cf031124cc4ba2722ab1ba174277c3d79d9b2ceba637a1" + "digest": "00cedb65fe85721791cf031124cc4ba2" }, { "name": "metadata in key order", @@ -40,7 +40,7 @@ "data": "7b2278223a317d" } ], - "digest": "eea3c073d22bc6bd16ffd5c5a18a290037e9b4df892664c75ed41192a5ce1542" + "digest": "eea3c073d22bc6bd16ffd5c5a18a2900" }, { "name": "two records", @@ -52,7 +52,7 @@ "data": "0203" } ], - "digest": "b75e10295b9753dbbc5774e454c9a7fbbfac2aa5775d9525e2b8cddff8ed9106" + "digest": "b75e10295b9753dbbc5774e454c9a7fb" }, { "name": "external payloads", @@ -65,7 +65,7 @@ ] } ], - "digest": "381530dbc9c2fb0d5ddefbc99e72c6e1f79e6f4127536a74b10f20e568440f64" + "digest": "381530dbc9c2fb0d5ddefbc99e72c6e1" }, { "name": "metadata key and value split one way", @@ -76,7 +76,7 @@ } } ], - "digest": "be5bd276e8b11daeb753e3d3b6d6f93d6ece8a9e53fe665356d7f946739c86aa" + "digest": "be5bd276e8b11daeb753e3d3b6d6f93d" }, { "name": "metadata key and value split another way", @@ -87,6 +87,6 @@ } } ], - "digest": "ddc7d1a81ecebb12548d3adae06622b809df0494090db434f2f98c0a46aa3d32" + "digest": "ddc7d1a81ecebb12548d3adae06622b8" } ] diff --git a/crates/streams/testdata/epochs.json b/crates/streams/testdata/epochs.json index ce1fe0735..fdc62c58c 100644 --- a/crates/streams/testdata/epochs.json +++ b/crates/streams/testdata/epochs.json @@ -9,8 +9,8 @@ 5 ] }, - "key": "0000000000000008.00000002.0000000068e77800.00000005.0000000000000000.00000000.00000000", - "task_key": "0000000000000008.00000002.0000000068e77800.00000005" + "key": "0000000000000008000000020000000068e778000000000500000000000000000000000000000000", + "task_key": "0000000000000008000000020000000068e7780000000005" }, { "kind": "local_activity", @@ -23,8 +23,8 @@ ], "attempt": 3 }, - "key": "0000000000000008.00000001.0000000068e77800.00000005.0000000000000000.00000000.00000003", - "task_key": "0000000000000008.00000001.0000000068e77800.00000005" + "key": "0000000000000008000000010000000068e778000000000500000000000000000000000000000003", + "task_key": "0000000000000008000000010000000068e7780000000005" }, { "kind": "activity", @@ -35,21 +35,21 @@ ], "attempt": 2 }, - "key": "0000000000000000.00000000.0000000000000000.00000000.0000000068e77801.3b9ac9ff.00000002", - "task_key": "0000000000000000.00000000.0000000000000000.00000000" + "key": "0000000000000000000000000000000000000000000000000000000068e778013b9ac9ff00000002", + "task_key": "000000000000000000000000000000000000000000000000" }, { "kind": "client", "epoch": { "attempt": 1 }, - "key": "0000000000000000.00000000.0000000000000000.00000000.0000000000000000.00000000.00000001", - "task_key": "0000000000000000.00000000.0000000000000000.00000000" + "key": "00000000000000000000000000000000000000000000000000000000000000000000000000000001", + "task_key": "000000000000000000000000000000000000000000000000" }, { "kind": "empty", "epoch": {}, - "key": "0000000000000000.00000000.0000000000000000.00000000.0000000000000000.00000000.00000000", - "task_key": "0000000000000000.00000000.0000000000000000.00000000" + "key": "00000000000000000000000000000000000000000000000000000000000000000000000000000000", + "task_key": "000000000000000000000000000000000000000000000000" } ] diff --git a/crates/streams/testdata/gen_vectors.py b/crates/streams/testdata/gen_vectors.py index d4a4102c3..e04bf02e4 100644 --- a/crates/streams/testdata/gen_vectors.py +++ b/crates/streams/testdata/gen_vectors.py @@ -26,33 +26,28 @@ def keys(prefix, namespace, stream_id): return {"meta": f"{base}:meta", "log": f"{base}:log", "producers": f"{base}:producers"} -def hex64(value): - return f"{max(value, 0):016x}" +def be64(value): + return max(value, 0).to_bytes(8, "big") -def hex32(value): - return f"{max(value, 0):08x}" +def be32(value): + return max(value, 0).to_bytes(4, "big") def task_key(started_event_id=0, attempt=0, started_time=(0, 0)): - return ".".join( - [hex64(started_event_id), hex32(attempt), hex64(started_time[0]), hex32(started_time[1])] - ) + """24 bytes, which compare as bytes in the order of the fields.""" + return be64(started_event_id) + be32(attempt) + be64(started_time[0]) + be32(started_time[1]) def epoch_key(epoch): - return ".".join( - [ - task_key( - epoch.get("task_started_event_id", 0), - epoch.get("task_attempt", 0), - tuple(epoch.get("task_started_time", (0, 0))), - ), - hex64(epoch.get("attempt_scheduled_time", (0, 0))[0]), - hex32(epoch.get("attempt_scheduled_time", (0, 0))[1]), - hex32(epoch.get("attempt", 0)), - ] + """40 bytes, the task key and then the scheduled time and the attempt.""" + scheduled = epoch.get("attempt_scheduled_time", (0, 0)) + task = task_key( + epoch.get("task_started_event_id", 0), + epoch.get("task_attempt", 0), + tuple(epoch.get("task_started_time", (0, 0))), ) + return task + be64(scheduled[0]) + be32(scheduled[1]) + be32(epoch.get("attempt", 0)) def ids(): @@ -122,13 +117,13 @@ def epochs(): {"kind": "empty", "epoch": {}}, ] for case in cases: - case["key"] = epoch_key(case["epoch"]) + case["key"] = epoch_key(case["epoch"]).hex() epoch = case["epoch"] case["task_key"] = task_key( epoch.get("task_started_event_id", 0), epoch.get("task_attempt", 0), tuple(epoch.get("task_started_time", (0, 0))), - ) + ).hex() return cases @@ -148,7 +143,8 @@ def fields(): def digest(records): - """The content digest of a batch, which a dedupe entry keeps.""" + """The content digest of a batch, which a dedupe entry keeps, as the first 16 bytes of the + SHA-256.""" out = hashlib.sha256() def part(data): @@ -172,11 +168,11 @@ def part(data): out.update(len(external).to_bytes(8, "big")) for size in external: out.update(size.to_bytes(8, "big", signed=True)) - return out.hexdigest() + return out.digest()[:16].hex() def digests(): - """Batches as hex bytes, with the digest that the crate's `batch_digest` must compute.""" + """Batches as hex bytes, with the hex digest that the crate's `batch_digest` must compute.""" cases = [ {"name": "empty batch", "records": []}, {"name": "one record, no body", "records": [{}]}, diff --git a/crates/streams/tests/vectors.rs b/crates/streams/tests/vectors.rs index 5860b59b9..9cbcfa26d 100644 --- a/crates/streams/tests/vectors.rs +++ b/crates/streams/tests/vectors.rs @@ -130,13 +130,13 @@ fn offsets_match() { fn epoch_keys_match() { for case in load("epochs.json").as_array().unwrap() { let epoch = epoch(&case["epoch"]); - assert_eq!(epoch_key(&epoch), text(case, "key")); + assert_eq!(epoch_key(&epoch).to_vec(), hex(text(case, "key"))); let task = WorkflowTaskEpoch { started_event_id: epoch.task_started_event_id, attempt: epoch.task_attempt, started_time: epoch.task_started_time, }; - assert_eq!(task_key(&task), text(case, "task_key")); + assert_eq!(task_key(&task).to_vec(), hex(text(case, "task_key"))); } } @@ -224,8 +224,8 @@ fn batch_digests_match() { }) .collect(); assert_eq!( - batch_digest(&records), - text(case, "digest"), + batch_digest(&records).to_vec(), + hex(text(case, "digest")), "{}", text(case, "name") ); From 9626b23344e26ac0f83f6b65b53abcd20a3caf4f Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Wed, 4 Nov 2026 17:49:49 -0700 Subject: [PATCH 07/12] Took the Producer's kind from its epoch case. Each kind keeps its number, so the stored names and the epoch keys keep their bytes. The vector loaders build the case from the flat fields of the JSON. --- crates/streams/src/epoch.rs | 113 ++++++++++++++++++++++++-------- crates/streams/src/ids.rs | 99 ++++++++++++++-------------- crates/streams/tests/vectors.rs | 67 ++++++++++--------- 3 files changed, 170 insertions(+), 109 deletions(-) diff --git a/crates/streams/src/epoch.rs b/crates/streams/src/epoch.rs index 6a418107d..551130dd7 100644 --- a/crates/streams/src/epoch.rs +++ b/crates/streams/src/epoch.rs @@ -4,13 +4,62 @@ //! A byte comparison of two keys then orders the epochs field by field. `testdata/epochs.json` has //! the shared vectors of the keys, in hex. -use crate::proto::{ - Producer, ProducerEpoch, StreamProducerKind, StreamRecord, StreamSuperseded, WorkflowTaskEpoch, - stream_record::Item, +use crate::{ + StreamError, StreamResult, + proto::{ + Producer, ProducerEpoch, StreamRecord, StreamSuperseded, WorkflowTaskEpoch, + producer_epoch::Kind, stream_record::Item, + }, }; use prost_types::Timestamp; use std::cmp::Ordering; +/// The kind of a Producer, which is the case of its epoch. The stored field names and keys hold +/// its number, so each kind keeps its number. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ProducerKind { + /// A Workflow run. + Workflow = 1, + /// A Local Activity. + LocalActivity = 2, + /// An Activity of a Workflow. + Activity = 3, + /// A client. + Client = 4, + /// A Standalone Activity. + StandaloneActivity = 5, +} + +impl From<&Kind> for ProducerKind { + fn from(kind: &Kind) -> Self { + match kind { + Kind::Workflow(_) => Self::Workflow, + Kind::LocalActivity(_) => Self::LocalActivity, + Kind::Activity(_) => Self::Activity, + Kind::StandaloneActivity(_) => Self::StandaloneActivity, + Kind::Client(_) => Self::Client, + } + } +} + +/// Refuses a Producer with no epoch, or with an epoch that has no case. +impl TryFrom<&Producer> for ProducerKind { + type Error = StreamError; + + fn try_from(producer: &Producer) -> StreamResult { + let epoch = producer + .epoch + .as_ref() + .ok_or_else(|| StreamError::invalid("The Producer has no epoch"))?; + kind(epoch).ok_or_else(|| StreamError::invalid("The Producer's epoch has no kind")) + } +} + +/// The kind of an epoch's case, `None` when the epoch has no case. +fn kind(epoch: &ProducerEpoch) -> Option { + epoch.kind.as_ref().map(ProducerKind::from) +} + /// The length of a [task_key]. pub const TASK_KEY_LEN: usize = 24; /// The length of an [epoch_key]. @@ -42,24 +91,34 @@ pub fn task_key(task: &WorkflowTaskEpoch) -> [u8; TASK_KEY_LEN] { key } -/// The key of an epoch. It is the task key, then the attempt's scheduled seconds and nanos and the -/// attempt, in 8, 4 and 4 bytes. Fields that a kind doesn't use compare as zero. +/// The key of an epoch. It is the [task_of] key, then the attempt's scheduled seconds and nanos +/// and the attempt, in 8, 4 and 4 bytes. A field that the case doesn't have is zero, and an epoch +/// with no case is all zeros. pub fn epoch_key(epoch: &ProducerEpoch) -> [u8; EPOCH_KEY_LEN] { - let (seconds, nanos) = time_fields(epoch.attempt_scheduled_time.as_ref()); + let (scheduled, attempt) = match &epoch.kind { + Some(Kind::LocalActivity(local)) => (None, local.attempt), + Some(Kind::Activity(activity) | Kind::StandaloneActivity(activity)) => { + (activity.attempt_scheduled_time.as_ref(), activity.attempt) + } + Some(Kind::Client(client)) => (None, client.attempt), + Some(Kind::Workflow(_)) | None => (None, 0), + }; + let (seconds, nanos) = time_fields(scheduled); let mut key = [0; EPOCH_KEY_LEN]; key[..TASK_KEY_LEN].copy_from_slice(&task_key(&task_of(epoch))); key[24..32].copy_from_slice(&be64(seconds)); key[32..36].copy_from_slice(&be32(nanos)); - key[36..].copy_from_slice(&be32(epoch.attempt)); + key[36..].copy_from_slice(&be32(attempt)); key } -/// The Workflow Task fields of an epoch. +/// The Workflow Task of an epoch. It is a Workflow's own task, or the task that dispatched a Local +/// Activity, and zero for the other kinds. pub fn task_of(epoch: &ProducerEpoch) -> WorkflowTaskEpoch { - WorkflowTaskEpoch { - started_event_id: epoch.task_started_event_id, - attempt: epoch.task_attempt, - started_time: epoch.task_started_time, + match &epoch.kind { + Some(Kind::Workflow(task)) => *task, + Some(Kind::LocalActivity(local)) => local.task.unwrap_or_default(), + _ => WorkflowTaskEpoch::default(), } } @@ -72,7 +131,8 @@ fn compare_tasks(a: &WorkflowTaskEpoch, b: &WorkflowTaskEpoch) -> Ordering { task_key(a).cmp(&task_key(b)) } -/// Whether a `Superseded` item withdraws an earlier Record. +/// Whether a `Superseded` item withdraws an earlier Record. The case of the item's epoch picks the +/// rule. /// /// For an Activity or a Local Activity, it withdraws that Producer's Records with a lower epoch. /// For a Workflow task t with the previous completed task c, it withdraws the Records of the run @@ -90,17 +150,17 @@ pub fn withdraws(superseded: &StreamSuperseded, record: &StreamRecord) -> bool { if !superseded.withdraw_run.is_empty() && producer.run_id == superseded.withdraw_run { return true; } - let kind = StreamProducerKind::try_from(superseded.producer_kind).unwrap_or_default(); - if kind == StreamProducerKind::Client { - return false; - } let (Some(epoch), Some(old)) = (superseded.epoch.as_ref(), producer.epoch.as_ref()) else { return false; }; - if kind != StreamProducerKind::Workflow { - return producer.kind == superseded.producer_kind - && producer.id == superseded.producer_id - && compare_epochs(old, epoch) == Ordering::Less; + match kind(epoch) { + None | Some(ProducerKind::Client) => return false, + Some(ProducerKind::Workflow) => {} + same => { + return kind(old) == same + && producer.id == superseded.producer_id + && compare_epochs(old, epoch) == Ordering::Less; + } } if !of_run(producer, superseded) { return false; @@ -124,10 +184,9 @@ pub fn withdraws(superseded: &StreamSuperseded, record: &StreamRecord) -> bool { /// Whether a Producer is the Workflow run of a `Superseded` item, or one of its Local /// Activities. fn of_run(producer: &Producer, superseded: &StreamSuperseded) -> bool { - let kind = StreamProducerKind::try_from(producer.kind).unwrap_or_default(); - match kind { - StreamProducerKind::Workflow => producer.id == superseded.producer_id, - StreamProducerKind::LocalActivity => producer.run_id == superseded.run_id, + match producer.epoch.as_ref().and_then(kind) { + Some(ProducerKind::Workflow) => producer.id == superseded.producer_id, + Some(ProducerKind::LocalActivity) => producer.run_id == superseded.run_id, _ => false, } } @@ -135,13 +194,13 @@ fn of_run(producer: &Producer, superseded: &StreamSuperseded) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::proto::ClientEpoch; // Without the clamp, the bytes of a negative field would outrank every real epoch. #[test] fn a_negative_field_compares_as_zero() { let negative = ProducerEpoch { - attempt: -1, - ..Default::default() + kind: Some(Kind::Client(ClientEpoch { attempt: -1 })), }; assert_eq!( compare_epochs(&negative, &ProducerEpoch::default()), diff --git a/crates/streams/src/ids.rs b/crates/streams/src/ids.rs index cbeb157b1..5ad3376b8 100644 --- a/crates/streams/src/ids.rs +++ b/crates/streams/src/ids.rs @@ -11,10 +11,7 @@ //! script. Each part is percent-encoded, so a `:` or a brace in an id can't make two Streams share //! a key. -use crate::{ - StreamError, StreamResult, - proto::{Producer, StreamProducerKind}, -}; +use crate::{StreamError, StreamResult, epoch::ProducerKind, proto::Producer}; /// The longest Stream id, in bytes. pub const MAX_STREAM_ID_BYTES: usize = 4096; @@ -111,25 +108,22 @@ pub fn named_client_producer_id(name: &str) -> String { } /// The id prefix of a Producer kind. -pub(crate) fn kind_prefix(kind: StreamProducerKind) -> Option<&'static str> { +pub(crate) fn kind_prefix(kind: ProducerKind) -> &'static str { match kind { - StreamProducerKind::Workflow => Some("wf:"), - StreamProducerKind::LocalActivity => Some("la:"), - StreamProducerKind::Activity => Some("act:"), - StreamProducerKind::StandaloneActivity => Some("sa:"), - StreamProducerKind::Client => Some("client:"), - StreamProducerKind::Unspecified => None, + ProducerKind::Workflow => "wf:", + ProducerKind::LocalActivity => "la:", + ProducerKind::Activity => "act:", + ProducerKind::StandaloneActivity => "sa:", + ProducerKind::Client => "client:", } } -/// Checks that a Producer's id has the prefix of its kind, and that the run in the id is its -/// `run_id`. The kind prefix stops a Producer from using the id of another kind. +/// Checks that a Producer's epoch has a case, that its id has the prefix of that kind, and that the +/// run in the id is its `run_id`. The kind prefix stops a Producer from using the id of another +/// kind. pub fn check_producer(producer: &Producer) -> StreamResult<()> { - let kind = - StreamProducerKind::try_from(producer.kind).unwrap_or(StreamProducerKind::Unspecified); - let Some(prefix) = kind_prefix(kind) else { - return Err(StreamError::invalid("The Producer has no kind")); - }; + let kind = ProducerKind::try_from(producer)?; + let prefix = kind_prefix(kind); let Some(rest) = producer.id.strip_prefix(prefix) else { return Err(StreamError::invalid(format!( "The Producer id {:?} doesn't start with {prefix:?}, as its kind requires", @@ -138,13 +132,12 @@ pub fn check_producer(producer: &Producer) -> StreamResult<()> { }; let parts: Vec<_> = rest.split(':').collect(); let expected = match kind { - StreamProducerKind::Workflow => 1, - StreamProducerKind::LocalActivity | StreamProducerKind::StandaloneActivity => 2, + ProducerKind::Workflow => 1, + ProducerKind::LocalActivity | ProducerKind::StandaloneActivity => 2, // A named client id has one part. A process client id has two. - StreamProducerKind::Client if parts.len() == 1 => 1, - StreamProducerKind::Client => 2, - StreamProducerKind::Activity => 3, - StreamProducerKind::Unspecified => unreachable!("refused above"), + ProducerKind::Client if parts.len() == 1 => 1, + ProducerKind::Client => 2, + ProducerKind::Activity => 3, }; // The meta field names use `|` as a separator, so a part must be in the form of `encode_part`. let encoded = |part: &&str| decode_part(part).is_some_and(|text| encode_part(&text) == *part); @@ -156,9 +149,7 @@ pub fn check_producer(producer: &Producer) -> StreamResult<()> { } let runs = matches!( kind, - StreamProducerKind::Workflow - | StreamProducerKind::LocalActivity - | StreamProducerKind::Activity + ProducerKind::Workflow | ProducerKind::LocalActivity | ProducerKind::Activity ); if runs && decode_part(parts[0]).as_deref() != Some(producer.run_id.as_str()) { return Err(StreamError::invalid(format!( @@ -166,7 +157,7 @@ pub fn check_producer(producer: &Producer) -> StreamResult<()> { producer.id, producer.run_id ))); } - if kind == StreamProducerKind::Client && !producer.run_id.is_empty() { + if kind == ProducerKind::Client && !producer.run_id.is_empty() { return Err(StreamError::invalid("A client Producer has no run id")); } Ok(()) @@ -210,12 +201,19 @@ impl StreamKeys { #[cfg(test)] mod tests { use super::*; + use crate::proto::{ProducerEpoch, producer_epoch::Kind}; - fn producer(kind: StreamProducerKind, id: &str, run_id: &str) -> Producer { + fn producer(kind: ProducerKind, id: &str, run_id: &str) -> Producer { + let kind = match kind { + ProducerKind::Workflow => Kind::Workflow(Default::default()), + ProducerKind::LocalActivity => Kind::LocalActivity(Default::default()), + ProducerKind::Activity => Kind::Activity(Default::default()), + ProducerKind::StandaloneActivity => Kind::StandaloneActivity(Default::default()), + ProducerKind::Client => Kind::Client(Default::default()), + }; Producer { - kind: kind as i32, id: id.to_string(), - epoch: None, + epoch: Some(ProducerEpoch { kind: Some(kind) }), run_id: run_id.to_string(), } } @@ -233,28 +231,24 @@ mod tests { fn the_kind_prefix_and_the_run_must_match_the_producer() { let run = "run:1"; let ok = [ + producer(ProducerKind::Workflow, &workflow_producer_id(run), run), producer( - StreamProducerKind::Workflow, - &workflow_producer_id(run), - run, - ), - producer( - StreamProducerKind::LocalActivity, + ProducerKind::LocalActivity, &local_activity_producer_id(run, "k"), run, ), producer( - StreamProducerKind::Activity, + ProducerKind::Activity, &activity_producer_id(run, "a:1", "s"), run, ), producer( - StreamProducerKind::StandaloneActivity, + ProducerKind::StandaloneActivity, &standalone_activity_producer_id("a", "r"), "", ), producer( - StreamProducerKind::Client, + ProducerKind::Client, &client_producer_id("me@host", "p"), "", ), @@ -263,17 +257,20 @@ mod tests { check_producer(producer).unwrap(); } let bad = [ - producer(StreamProducerKind::Client, &workflow_producer_id(run), run), - producer( - StreamProducerKind::Workflow, - &workflow_producer_id(run), - "other", - ), - producer(StreamProducerKind::Activity, "act:run%3A1:a", run), - producer(StreamProducerKind::Unspecified, "wf:x", "x"), - producer(StreamProducerKind::Client, "client:a:b", "run"), - producer(StreamProducerKind::Client, "client:a|b", ""), - producer(StreamProducerKind::Client, "client:run%3a1", ""), + producer(ProducerKind::Client, &workflow_producer_id(run), run), + producer(ProducerKind::Workflow, &workflow_producer_id(run), "other"), + producer(ProducerKind::Activity, "act:run%3A1:a", run), + Producer { + epoch: None, + ..producer(ProducerKind::Workflow, "wf:x", "x") + }, + Producer { + epoch: Some(ProducerEpoch::default()), + ..producer(ProducerKind::Workflow, "wf:x", "x") + }, + producer(ProducerKind::Client, "client:a:b", "run"), + producer(ProducerKind::Client, "client:a|b", ""), + producer(ProducerKind::Client, "client:run%3a1", ""), ]; for producer in &bad { assert_eq!( diff --git a/crates/streams/tests/vectors.rs b/crates/streams/tests/vectors.rs index 9cbcfa26d..60a026802 100644 --- a/crates/streams/tests/vectors.rs +++ b/crates/streams/tests/vectors.rs @@ -8,12 +8,13 @@ use temporalio_common::protos::temporal::api::common::v1::{ }; use temporalio_streams::{ digest::batch_digest, - epoch::{epoch_key, task_key, withdraws}, + epoch::{epoch_key, task_key, task_of, withdraws}, ids::{self, StreamKeys}, offset::{entry_id_of_offset, offset_of_entry_id}, proto::{ - AppendRecord, Producer, ProducerEpoch, StreamData, StreamProducerKind, StreamRecord, - StreamSuperseded, WorkflowTaskEpoch, stream_record::Item, + ActivityEpoch, AppendRecord, ClientEpoch, LocalActivityEpoch, Producer, ProducerEpoch, + StreamData, StreamRecord, StreamSuperseded, WorkflowTaskEpoch, producer_epoch::Kind, + stream_record::Item, }, }; @@ -38,25 +39,31 @@ fn number(value: &Value, field: &str) -> i64 { value[field].as_i64().unwrap_or(0) } -fn epoch(value: &Value) -> ProducerEpoch { - ProducerEpoch { - task_started_event_id: number(value, "task_started_event_id"), - task_attempt: number(value, "task_attempt") as i32, - task_started_time: time(&value["task_started_time"]), +/// The epoch of a kind, from the flat fields of the vectors. The case takes the fields of its kind. +fn epoch(kind: &str, value: &Value) -> ProducerEpoch { + let task = WorkflowTaskEpoch { + started_event_id: number(value, "task_started_event_id"), + attempt: number(value, "task_attempt") as i32, + started_time: time(&value["task_started_time"]), + }; + let attempt = number(value, "attempt") as i32; + let activity = ActivityEpoch { attempt_scheduled_time: time(&value["attempt_scheduled_time"]), - attempt: number(value, "attempt") as i32, - } -} - -fn kind(name: &str) -> StreamProducerKind { - match name { - "workflow" => StreamProducerKind::Workflow, - "local_activity" => StreamProducerKind::LocalActivity, - "activity" => StreamProducerKind::Activity, - "standalone_activity" => StreamProducerKind::StandaloneActivity, - "client" => StreamProducerKind::Client, + attempt, + }; + let kind = match kind { + "workflow" => Some(Kind::Workflow(task)), + "local_activity" => Some(Kind::LocalActivity(LocalActivityEpoch { + task: Some(task), + attempt, + })), + "activity" => Some(Kind::Activity(activity)), + "standalone_activity" => Some(Kind::StandaloneActivity(activity)), + "client" => Some(Kind::Client(ClientEpoch { attempt })), + "empty" => None, other => panic!("unknown kind {other}"), - } + }; + ProducerEpoch { kind } } #[test] @@ -129,14 +136,12 @@ fn offsets_match() { #[test] fn epoch_keys_match() { for case in load("epochs.json").as_array().unwrap() { - let epoch = epoch(&case["epoch"]); + let epoch = epoch(text(case, "kind"), &case["epoch"]); assert_eq!(epoch_key(&epoch).to_vec(), hex(text(case, "key"))); - let task = WorkflowTaskEpoch { - started_event_id: epoch.task_started_event_id, - attempt: epoch.task_attempt, - started_time: epoch.task_started_time, - }; - assert_eq!(task_key(&task).to_vec(), hex(text(case, "task_key"))); + assert_eq!( + task_key(&task_of(&epoch)).to_vec(), + hex(text(case, "task_key")) + ); } } @@ -147,10 +152,11 @@ fn the_appendix_e_withdrawals_match() { for case in cases.as_array().unwrap() { let s = &case["superseded"]; let superseded = StreamSuperseded { - producer_kind: kind(text(s, "producer_kind")) as i32, producer_id: text(s, "producer_id").to_string(), // A create's item has no epoch, and a Reader must not read it as an empty one. - epoch: s.get("epoch").map(epoch), + epoch: s + .get("epoch") + .map(|value| epoch(text(s, "producer_kind"), value)), previous_completed_task: s.get("previous_completed_task").map(|c| WorkflowTaskEpoch { started_event_id: number(c, "started_event_id"), attempt: number(c, "attempt") as i32, @@ -167,9 +173,8 @@ fn the_appendix_e_withdrawals_match() { let record = StreamRecord { item: Some(Item::Data(StreamData { producer: Some(Producer { - kind: kind(text(p, "kind")) as i32, id: text(p, "id").to_string(), - epoch: p.get("epoch").map(epoch), + epoch: p.get("epoch").map(|value| epoch(text(p, "kind"), value)), run_id: p["run_id"].as_str().unwrap_or_default().to_string(), }), ..Default::default() From f65901e37e47926f74a43846dbe5086f69b01ff2 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Wed, 4 Nov 2026 22:34:15 -0700 Subject: [PATCH 08/12] Refused an entry id that isn't valid as corrupt Store data. A retry reads the same entry id, so the error isn't retryable. --- crates/streams/src/offset.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/streams/src/offset.rs b/crates/streams/src/offset.rs index a61ddaed5..607d7bfb9 100644 --- a/crates/streams/src/offset.rs +++ b/crates/streams/src/offset.rs @@ -12,12 +12,12 @@ const MAX_MS: u64 = (i64::MAX >> SEQ_BITS) as u64; /// Packs a Redis entry id. pub fn offset_of_entry_id(id: &str) -> StreamResult { - let bad = || StreamError::storage(format!("The Store gave the entry id {id:?}")); + let bad = || StreamError::storage_corrupt(format!("The Store gave the entry id {id:?}")); let (ms, seq) = id.split_once('-').ok_or_else(bad)?; let ms: u64 = ms.parse().map_err(|_| bad())?; let seq: u64 = seq.parse().map_err(|_| bad())?; if ms > MAX_MS || seq > SEQ_MASK as u64 { - return Err(StreamError::storage(format!( + return Err(StreamError::storage_corrupt(format!( "The entry id {id:?} doesn't fit in an offset" ))); } @@ -36,9 +36,10 @@ mod tests { #[test] fn an_entry_id_that_doesnt_fit_is_refused() { - assert!(offset_of_entry_id("1-1048576").is_err()); - assert!(offset_of_entry_id(&format!("{}-0", MAX_MS + 1)).is_err()); - assert!(offset_of_entry_id("x").is_err()); + for id in ["1-1048576", &format!("{}-0", MAX_MS + 1), "x"] { + let error = offset_of_entry_id(id).unwrap_err(); + assert_eq!(error.reason, crate::Reason::StorageCorrupt, "{id}"); + } assert_eq!( offset_of_entry_id(&format!("{MAX_MS}-{SEQ_MASK}")).unwrap(), i64::MAX From 2fced8e057490dae1166bafe76b7a5879214f0b2 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Wed, 4 Nov 2026 23:00:02 -0700 Subject: [PATCH 09/12] Added the closed key of a Stream. A close writes no log entry, so a Store that waits on its log needs another key that a close wakes. --- crates/streams/src/ids.rs | 6 ++++++ crates/streams/testdata/gen_vectors.py | 7 ++++++- crates/streams/testdata/ids.json | 6 ++++-- crates/streams/tests/vectors.rs | 1 + 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/streams/src/ids.rs b/crates/streams/src/ids.rs index 5ad3376b8..d50cf47f3 100644 --- a/crates/streams/src/ids.rs +++ b/crates/streams/src/ids.rs @@ -196,6 +196,12 @@ impl StreamKeys { pub fn producers(&self) -> String { format!("{}:producers", self.base) } + + /// The key that a close adds one entry to. A close writes no log entry, so a waiting read also + /// waits on this key, and the close wakes it. + pub fn closed(&self) -> String { + format!("{}:closed", self.base) + } } #[cfg(test)] diff --git a/crates/streams/testdata/gen_vectors.py b/crates/streams/testdata/gen_vectors.py index e04bf02e4..5e181a1bb 100644 --- a/crates/streams/testdata/gen_vectors.py +++ b/crates/streams/testdata/gen_vectors.py @@ -23,7 +23,12 @@ def join(*parts): def keys(prefix, namespace, stream_id): base = f"{part(prefix)}:{{{join(namespace, stream_id)}}}" - return {"meta": f"{base}:meta", "log": f"{base}:log", "producers": f"{base}:producers"} + return { + "meta": f"{base}:meta", + "log": f"{base}:log", + "producers": f"{base}:producers", + "closed": f"{base}:closed", + } def be64(value): diff --git a/crates/streams/testdata/ids.json b/crates/streams/testdata/ids.json index b53900947..c872c4989 100644 --- a/crates/streams/testdata/ids.json +++ b/crates/streams/testdata/ids.json @@ -9,7 +9,8 @@ "stream_id": "agent-1:run-1:turn-3", "meta": "temporal-streams:{default:agent-1%3Arun-1%3Aturn-3}:meta", "log": "temporal-streams:{default:agent-1%3Arun-1%3Aturn-3}:log", - "producers": "temporal-streams:{default:agent-1%3Arun-1%3Aturn-3}:producers" + "producers": "temporal-streams:{default:agent-1%3Arun-1%3Aturn-3}:producers", + "closed": "temporal-streams:{default:agent-1%3Arun-1%3Aturn-3}:closed" }, { "prefix": "p:x", @@ -20,7 +21,8 @@ "stream_id": "a%3Ab%2Fc:r%251:%C3%A9%20name", "meta": "p%3Ax:{ns%7B1%7D:a%253Ab%252Fc%3Ar%25251%3A%25C3%25A9%2520name}:meta", "log": "p%3Ax:{ns%7B1%7D:a%253Ab%252Fc%3Ar%25251%3A%25C3%25A9%2520name}:log", - "producers": "p%3Ax:{ns%7B1%7D:a%253Ab%252Fc%3Ar%25251%3A%25C3%25A9%2520name}:producers" + "producers": "p%3Ax:{ns%7B1%7D:a%253Ab%252Fc%3Ar%25251%3A%25C3%25A9%2520name}:producers", + "closed": "p%3Ax:{ns%7B1%7D:a%253Ab%252Fc%3Ar%25251%3A%25C3%25A9%2520name}:closed" } ], "producers": { diff --git a/crates/streams/tests/vectors.rs b/crates/streams/tests/vectors.rs index 60a026802..57f367594 100644 --- a/crates/streams/tests/vectors.rs +++ b/crates/streams/tests/vectors.rs @@ -80,6 +80,7 @@ fn stream_ids_and_keys_match() { assert_eq!(keys.meta(), text(case, "meta")); assert_eq!(keys.log(), text(case, "log")); assert_eq!(keys.producers(), text(case, "producers")); + assert_eq!(keys.closed(), text(case, "closed")); } } From 613e8247e49189c1cea1bc1f89573ea54ccd686d Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Thu, 5 Nov 2026 10:40:09 -0700 Subject: [PATCH 10/12] Read a Superseded item's withdrawal facts from its writer. The facts and the withdrawing Producer moved into StreamWriter. The vector loader builds the writer from the same JSON fields. --- crates/streams/src/epoch.rs | 31 +++++++++++++++++------------ crates/streams/tests/vectors.rs | 35 ++++++++++++++++++++------------- 2 files changed, 39 insertions(+), 27 deletions(-) diff --git a/crates/streams/src/epoch.rs b/crates/streams/src/epoch.rs index 551130dd7..697d66ef6 100644 --- a/crates/streams/src/epoch.rs +++ b/crates/streams/src/epoch.rs @@ -131,8 +131,8 @@ fn compare_tasks(a: &WorkflowTaskEpoch, b: &WorkflowTaskEpoch) -> Ordering { task_key(a).cmp(&task_key(b)) } -/// Whether a `Superseded` item withdraws an earlier Record. The case of the item's epoch picks the -/// rule. +/// Whether a `Superseded` item withdraws an earlier Record. The case of the epoch of the item's +/// writer picks the rule. /// /// For an Activity or a Local Activity, it withdraws that Producer's Records with a lower epoch. /// For a Workflow task t with the previous completed task c, it withdraws the Records of the run @@ -141,16 +141,22 @@ fn compare_tasks(a: &WorkflowTaskEpoch, b: &WorkflowTaskEpoch) -> Ordering { /// previous run. A client's newer epoch fences the earlier ones but withdraws nothing, since a /// restarted client's earlier Records are real messages, not a failed attempt's. pub fn withdraws(superseded: &StreamSuperseded, record: &StreamRecord) -> bool { + let Some(writer) = superseded.writer.as_ref() else { + return false; + }; let Some(Item::Data(data)) = &record.item else { return false; }; let Some(producer) = data.producer.as_ref() else { return false; }; - if !superseded.withdraw_run.is_empty() && producer.run_id == superseded.withdraw_run { + if !writer.withdraw_run.is_empty() && producer.run_id == writer.withdraw_run { return true; } - let (Some(epoch), Some(old)) = (superseded.epoch.as_ref(), producer.epoch.as_ref()) else { + let Some(withdrawing) = writer.producer.as_ref() else { + return false; + }; + let (Some(epoch), Some(old)) = (withdrawing.epoch.as_ref(), producer.epoch.as_ref()) else { return false; }; match kind(epoch) { @@ -158,19 +164,19 @@ pub fn withdraws(superseded: &StreamSuperseded, record: &StreamRecord) -> bool { Some(ProducerKind::Workflow) => {} same => { return kind(old) == same - && producer.id == superseded.producer_id + && producer.id == withdrawing.id && compare_epochs(old, epoch) == Ordering::Less; } } - if !of_run(producer, superseded) { + if !of_run(producer, withdrawing) { return false; } let (task, old) = (task_of(epoch), task_of(old)); let started = old.started_event_id; - if superseded.failed_tasks.contains(&started) { + if writer.failed_tasks.contains(&started) { return true; } - let completed = superseded.previous_completed_task.as_ref(); + let completed = writer.previous_completed_task.as_ref(); let after = completed.map_or(0, |c| c.started_event_id); if after < started && started < task.started_event_id { return true; @@ -181,12 +187,11 @@ pub fn withdraws(superseded: &StreamSuperseded, record: &StreamRecord) -> bool { lower_at(&task) || completed.is_some_and(lower_at) } -/// Whether a Producer is the Workflow run of a `Superseded` item, or one of its Local -/// Activities. -fn of_run(producer: &Producer, superseded: &StreamSuperseded) -> bool { +/// Whether a Producer is the Workflow run that withdraws, or one of its Local Activities. +fn of_run(producer: &Producer, withdrawing: &Producer) -> bool { match producer.epoch.as_ref().and_then(kind) { - Some(ProducerKind::Workflow) => producer.id == superseded.producer_id, - Some(ProducerKind::LocalActivity) => producer.run_id == superseded.run_id, + Some(ProducerKind::Workflow) => producer.id == withdrawing.id, + Some(ProducerKind::LocalActivity) => producer.run_id == withdrawing.run_id, _ => false, } } diff --git a/crates/streams/tests/vectors.rs b/crates/streams/tests/vectors.rs index 57f367594..853a4e96c 100644 --- a/crates/streams/tests/vectors.rs +++ b/crates/streams/tests/vectors.rs @@ -13,8 +13,8 @@ use temporalio_streams::{ offset::{entry_id_of_offset, offset_of_entry_id}, proto::{ ActivityEpoch, AppendRecord, ClientEpoch, LocalActivityEpoch, Producer, ProducerEpoch, - StreamData, StreamRecord, StreamSuperseded, WorkflowTaskEpoch, producer_epoch::Kind, - stream_record::Item, + StreamData, StreamRecord, StreamSuperseded, StreamWriter, WorkflowTaskEpoch, + producer_epoch::Kind, stream_record::Item, }, }; @@ -152,24 +152,31 @@ fn the_appendix_e_withdrawals_match() { assert!(cases.as_array().unwrap().len() >= 35); for case in cases.as_array().unwrap() { let s = &case["superseded"]; - let superseded = StreamSuperseded { - producer_id: text(s, "producer_id").to_string(), + let withdrawing = Producer { + id: text(s, "producer_id").to_string(), // A create's item has no epoch, and a Reader must not read it as an empty one. epoch: s .get("epoch") .map(|value| epoch(text(s, "producer_kind"), value)), - previous_completed_task: s.get("previous_completed_task").map(|c| WorkflowTaskEpoch { - started_event_id: number(c, "started_event_id"), - attempt: number(c, "attempt") as i32, - started_time: time(&c["started_time"]), - }), - failed_tasks: s["failed_tasks"] - .as_array() - .map(|list| list.iter().map(|v| v.as_i64().unwrap()).collect()) - .unwrap_or_default(), - withdraw_run: s["withdraw_run"].as_str().unwrap_or_default().to_string(), run_id: s["run_id"].as_str().unwrap_or_default().to_string(), }; + let superseded = StreamSuperseded { + writer: Some(StreamWriter { + producer: Some(withdrawing), + previous_completed_task: s.get("previous_completed_task").map(|c| { + WorkflowTaskEpoch { + started_event_id: number(c, "started_event_id"), + attempt: number(c, "attempt") as i32, + started_time: time(&c["started_time"]), + } + }), + failed_tasks: s["failed_tasks"] + .as_array() + .map(|list| list.iter().map(|v| v.as_i64().unwrap()).collect()) + .unwrap_or_default(), + withdraw_run: s["withdraw_run"].as_str().unwrap_or_default().to_string(), + }), + }; let p = &case["record"]["producer"]; let record = StreamRecord { item: Some(Item::Data(StreamData { From fd35e82b32be0c485ff72431d9639d563c3a6fb9 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 6 Nov 2026 17:13:44 -0700 Subject: [PATCH 11/12] Put the Producer kind and the writer's withdrawal into variants. The review asked to stop representing invalid states. The Producer kind is now a oneof with run_id only in the run-based variants, so a client can't carry a run. A write's task facts and withdraw_run move into RunWithdrawal, which only a run writer sets. --- .../temporal/api/stream/v1/message.proto | 102 +++++++++++------- .../streamservice/v1/request_response.proto | 2 +- .../temporal/sdk/core/streams/streams.proto | 7 +- 3 files changed, 68 insertions(+), 43 deletions(-) diff --git a/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto b/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto index 63bca5f6c..7385028a1 100644 --- a/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto +++ b/crates/protos/protos/api_upstream/temporal/api/stream/v1/message.proto @@ -29,27 +29,6 @@ message StreamRef { string backend = 3; } -// Experimental. Might change in the future. -// The ordered attempt of a Producer. A higher epoch supersedes the Records of a lower epoch of the -// same Producer. The case is the kind of the Producer. It decides how the Store orders the epochs, -// and which earlier Records a higher epoch withdraws. -message ProducerEpoch { - oneof kind { - // A Workflow run, which writes in its Workflow Task. A higher epoch withdraws the Records - // of the run's Workflow Tasks that never completed, also those of their Local Activities. - WorkflowTaskEpoch workflow = 1; - // A Local Activity. A higher epoch withdraws the Producer's lower epochs. - LocalActivityEpoch local_activity = 2; - // An Activity of a Workflow. A higher epoch withdraws the Producer's lower epochs. - ActivityEpoch activity = 3; - // A Standalone Activity. A higher epoch withdraws the Producer's lower epochs. - ActivityEpoch standalone_activity = 4; - // A client. A higher epoch only fences the lower ones, since a restarted client's earlier - // Records are real messages, not the Records of a failed attempt. - ClientEpoch client = 5; - } -} - // Experimental. Might change in the future. // One Workflow Task attempt of a run. It orders by started_event_id, then attempt, then // started_time. @@ -86,32 +65,78 @@ message ClientEpoch { } // Experimental. Might change in the future. -// The code that wrote a batch. Its id stays the same across attempts, and its epoch orders them. +// A Workflow run Producer. It writes in its Workflow Task. A higher epoch withdraws the Records of +// the run's Workflow Tasks that never completed, with those of their Local Activities. +message WorkflowProducer { + // The first execution run id of the run chain. + string run_id = 1; + WorkflowTaskEpoch epoch = 2; +} + +// Experimental. Might change in the future. +// A Local Activity Producer. A higher epoch withdraws the Producer's lower epochs. +message LocalActivityProducer { + string run_id = 1; + LocalActivityEpoch epoch = 2; +} + +// Experimental. Might change in the future. +// An Activity Producer, for an Activity of a Workflow or a Standalone Activity. A higher epoch +// withdraws the Producer's lower epochs. +message ActivityProducer { + string run_id = 1; + ActivityEpoch epoch = 2; +} + +// Experimental. Might change in the future. +// A client Producer. A higher epoch only fences the lower ones, since a restarted client's earlier +// Records are real messages, not the Records of a failed attempt. A client has no run. +message ClientProducer { + ClientEpoch epoch = 1; +} + +// Experimental. Might change in the future. +// The code that wrote a batch. Its id stays the same across attempts, and the kind's case orders +// its epochs and picks which earlier Records a higher epoch withdraws. A run-based kind carries its +// run, so a client can't. message Producer { // Stable across attempts, such as `wf:` for the Workflow Producer of a run. string id = 1; - // Required. Its case gives the kind of the Producer. - ProducerEpoch epoch = 2; - // The run of the Producer, so nobody parses the id. Empty for a client. - string run_id = 3; + // Required. The case is the kind of the Producer. + oneof kind { + WorkflowProducer workflow = 2; + LocalActivityProducer local_activity = 3; + ActivityProducer activity = 4; + ActivityProducer standalone_activity = 5; + ClientProducer client = 6; + } } // Experimental. Might change in the future. // The Producer of a write, and which earlier Records the write withdraws. An append, a close and // a writer carry the same fields, so the Store orders and withdraws each write the same way. message StreamWriter { - // Required on a write. Its epoch's case picks the withdrawal rule. + // Required on a write. Its Producer's kind picks the withdrawal rule. Producer producer = 1; - // Workflow and Local Activity only. The run's last completed Workflow Task, from a Local - // Activity's start. Its started_event_id is 0 before the first. The write withdraws the run's - // Local Activity Records whose Workflow Task never completed. - WorkflowTaskEpoch previous_completed_task = 2; - // Workflow and Local Activity only. The started event ids of the run's failed or timed out - // Workflow Tasks. The write withdraws the run's Local Activity Records of these tasks. - repeated int64 failed_tasks = 3; - // Retry run only. The previous run, on the retry run's first write. Every Record whose - // Producer carries it is withdrawn. - string withdraw_run = 4; + // Set only by a Workflow or Local Activity write, since only a run withdraws orphaned Local + // Activity Records or a previous run. Unset for an Activity or client write. + RunWithdrawal withdrawal = 2; +} + +// Experimental. Might change in the future. +// What a Workflow or Local Activity write withdraws from its run. One write can carry both the task +// facts and a run to withdraw, so these are fields of one message and not a oneof. +message RunWithdrawal { + // The run's last completed Workflow Task, from a Local Activity's start. Its started_event_id + // is 0 before the first. The write withdraws the run's Local Activity Records whose Workflow + // Task never completed. + WorkflowTaskEpoch previous_completed_task = 1; + // The started event ids of the run's failed or timed out Workflow Tasks. The write withdraws + // the run's Local Activity Records of these tasks. + repeated int64 failed_tasks = 2; + // Retry run only. The previous run, on the retry run's first write. Every Record whose Producer + // carries it is withdrawn. + string withdraw_run = 3; } // Experimental. Might change in the future. @@ -125,8 +150,7 @@ message AppendRecord { // batch of a higher epoch, or for a retry run's first write, and a read builds a Superseded item // from them. A reader drops the earlier Records that the facts match. message StreamSuperseded { - // The writer of the batch that withdraws. Its Producer's epoch is empty when only - // withdraw_run applies. + // The writer of the batch that withdraws, with its withdrawal facts. StreamWriter writer = 1; } diff --git a/crates/protos/protos/api_upstream/temporal/api/streamservice/v1/request_response.proto b/crates/protos/protos/api_upstream/temporal/api/streamservice/v1/request_response.proto index cdd61456e..463f91194 100644 --- a/crates/protos/protos/api_upstream/temporal/api/streamservice/v1/request_response.proto +++ b/crates/protos/protos/api_upstream/temporal/api/streamservice/v1/request_response.proto @@ -75,7 +75,7 @@ message AppendRecordsRequest { int64 sequence = 4; repeated temporal.api.stream.v1.AppendRecord records = 5; // Only read by the server, on a Workflow Producer's batch. The batch withdraws no Workflow - // Task and keeps no task facts, but the writer's withdraw_run still applies. + // Task and keeps no task facts, but the writer's withdrawal withdraw_run still applies. bool defer_withdrawal = 6; // Only read by the server. The server checks the Producer against the Activity's current // attempt, so nobody else writes as that Activity. diff --git a/crates/protos/protos/local/temporal/sdk/core/streams/streams.proto b/crates/protos/protos/local/temporal/sdk/core/streams/streams.proto index 0c9b198b7..050ac393a 100644 --- a/crates/protos/protos/local/temporal/sdk/core/streams/streams.proto +++ b/crates/protos/protos/local/temporal/sdk/core/streams/streams.proto @@ -20,9 +20,10 @@ import "temporal/api/stream/v1/message.proto"; message OpenWriterRequest { string namespace = 1; temporal.api.stream.v1.StreamRef ref = 2; - // Each batch sends it as AppendRecords does. For a Local Activity it has the run's task facts, - // so the writes withdraw the run's orphaned Local Activity Records. Its withdraw_run must be - // empty, since only a retry run's first append withdraws a run, and a writer sends many. + // Each batch sends it as AppendRecords does. For a Local Activity its withdrawal has the run's + // task facts, so the writes withdraw the run's orphaned Local Activity Records. The withdrawal's + // withdraw_run must be empty, since only a retry run's first append withdraws a run, and a + // writer sends many. temporal.api.stream.v1.StreamWriter writer = 3; // A write sends at most this many Records in one batch. Zero is 1000. int32 max_batch_size = 4; From becc5a1c35530307b4e711036f76fc560e8b4a92 Mon Sep 17 00:00:00 2001 From: Moe Dashti Date: Fri, 6 Nov 2026 18:25:16 -0700 Subject: [PATCH 12/12] Read the Producer kind through the oneof. Matches the proto change, so the ids and epoch vectors stay the same. --- crates/streams/src/epoch.rs | 127 ++++++++++++++++++++------------ crates/streams/src/ids.rs | 50 ++++++++----- crates/streams/tests/vectors.rs | 97 ++++++++++++++---------- 3 files changed, 173 insertions(+), 101 deletions(-) diff --git a/crates/streams/src/epoch.rs b/crates/streams/src/epoch.rs index 697d66ef6..15b1ca6a2 100644 --- a/crates/streams/src/epoch.rs +++ b/crates/streams/src/epoch.rs @@ -7,8 +7,8 @@ use crate::{ StreamError, StreamResult, proto::{ - Producer, ProducerEpoch, StreamRecord, StreamSuperseded, WorkflowTaskEpoch, - producer_epoch::Kind, stream_record::Item, + Producer, StreamRecord, StreamSuperseded, StreamWriter, WorkflowTaskEpoch, producer::Kind, + stream_record::Item, }, }; use prost_types::Timestamp; @@ -42,22 +42,52 @@ impl From<&Kind> for ProducerKind { } } -/// Refuses a Producer with no epoch, or with an epoch that has no case. +/// Refuses a Producer with no kind case. impl TryFrom<&Producer> for ProducerKind { type Error = StreamError; fn try_from(producer: &Producer) -> StreamResult { - let epoch = producer - .epoch - .as_ref() - .ok_or_else(|| StreamError::invalid("The Producer has no epoch"))?; - kind(epoch).ok_or_else(|| StreamError::invalid("The Producer's epoch has no kind")) + kind_of(producer).ok_or_else(|| StreamError::invalid("The Producer has no kind")) } } -/// The kind of an epoch's case, `None` when the epoch has no case. -fn kind(epoch: &ProducerEpoch) -> Option { - epoch.kind.as_ref().map(ProducerKind::from) +/// The kind case of a Producer, `None` when it has none. +pub fn kind_of(producer: &Producer) -> Option { + producer.kind.as_ref().map(ProducerKind::from) +} + +/// The run of a Producer. Empty for a client, which has no run. +pub fn producer_run_id(producer: &Producer) -> &str { + match &producer.kind { + Some(Kind::Workflow(w)) => &w.run_id, + Some(Kind::LocalActivity(l)) => &l.run_id, + Some(Kind::Activity(a) | Kind::StandaloneActivity(a)) => &a.run_id, + Some(Kind::Client(_)) | None => "", + } +} + +/// The run that a writer withdraws, empty for none. +pub fn writer_withdraw_run(writer: &StreamWriter) -> &str { + writer + .withdrawal + .as_ref() + .map_or("", |w| w.withdraw_run.as_str()) +} + +/// The started event ids of the run's failed Workflow Tasks that a writer reports. +pub fn writer_failed_tasks(writer: &StreamWriter) -> &[i64] { + writer + .withdrawal + .as_ref() + .map_or(&[], |w| w.failed_tasks.as_slice()) +} + +/// The run's last completed Workflow Task that a writer reports, `None` for none. +pub fn writer_previous_completed_task(writer: &StreamWriter) -> Option<&WorkflowTaskEpoch> { + writer + .withdrawal + .as_ref() + .and_then(|w| w.previous_completed_task.as_ref()) } /// The length of a [task_key]. @@ -92,20 +122,24 @@ pub fn task_key(task: &WorkflowTaskEpoch) -> [u8; TASK_KEY_LEN] { } /// The key of an epoch. It is the [task_of] key, then the attempt's scheduled seconds and nanos -/// and the attempt, in 8, 4 and 4 bytes. A field that the case doesn't have is zero, and an epoch +/// and the attempt, in 8, 4 and 4 bytes. A field that the case doesn't have is zero, and a Producer /// with no case is all zeros. -pub fn epoch_key(epoch: &ProducerEpoch) -> [u8; EPOCH_KEY_LEN] { - let (scheduled, attempt) = match &epoch.kind { - Some(Kind::LocalActivity(local)) => (None, local.attempt), +pub fn epoch_key(kind: Option<&Kind>) -> [u8; EPOCH_KEY_LEN] { + let (scheduled, attempt) = match kind { + Some(Kind::LocalActivity(local)) => (None, local.epoch.map_or(0, |e| e.attempt)), Some(Kind::Activity(activity) | Kind::StandaloneActivity(activity)) => { - (activity.attempt_scheduled_time.as_ref(), activity.attempt) + let epoch = activity.epoch; + ( + epoch.and_then(|e| e.attempt_scheduled_time), + epoch.map_or(0, |e| e.attempt), + ) } - Some(Kind::Client(client)) => (None, client.attempt), + Some(Kind::Client(client)) => (None, client.epoch.map_or(0, |e| e.attempt)), Some(Kind::Workflow(_)) | None => (None, 0), }; - let (seconds, nanos) = time_fields(scheduled); + let (seconds, nanos) = time_fields(scheduled.as_ref()); let mut key = [0; EPOCH_KEY_LEN]; - key[..TASK_KEY_LEN].copy_from_slice(&task_key(&task_of(epoch))); + key[..TASK_KEY_LEN].copy_from_slice(&task_key(&task_of(kind))); key[24..32].copy_from_slice(&be64(seconds)); key[32..36].copy_from_slice(&be32(nanos)); key[36..].copy_from_slice(&be32(attempt)); @@ -114,16 +148,16 @@ pub fn epoch_key(epoch: &ProducerEpoch) -> [u8; EPOCH_KEY_LEN] { /// The Workflow Task of an epoch. It is a Workflow's own task, or the task that dispatched a Local /// Activity, and zero for the other kinds. -pub fn task_of(epoch: &ProducerEpoch) -> WorkflowTaskEpoch { - match &epoch.kind { - Some(Kind::Workflow(task)) => *task, - Some(Kind::LocalActivity(local)) => local.task.unwrap_or_default(), +pub fn task_of(kind: Option<&Kind>) -> WorkflowTaskEpoch { + match kind { + Some(Kind::Workflow(w)) => w.epoch.unwrap_or_default(), + Some(Kind::LocalActivity(local)) => local.epoch.and_then(|e| e.task).unwrap_or_default(), _ => WorkflowTaskEpoch::default(), } } /// Orders two epochs field by field. -pub fn compare_epochs(a: &ProducerEpoch, b: &ProducerEpoch) -> Ordering { +pub fn compare_epochs(a: Option<&Kind>, b: Option<&Kind>) -> Ordering { epoch_key(a).cmp(&epoch_key(b)) } @@ -150,48 +184,52 @@ pub fn withdraws(superseded: &StreamSuperseded, record: &StreamRecord) -> bool { let Some(producer) = data.producer.as_ref() else { return false; }; - if !writer.withdraw_run.is_empty() && producer.run_id == writer.withdraw_run { + let withdraw_run = writer_withdraw_run(writer); + if !withdraw_run.is_empty() && producer_run_id(producer) == withdraw_run { return true; } let Some(withdrawing) = writer.producer.as_ref() else { return false; }; - let (Some(epoch), Some(old)) = (withdrawing.epoch.as_ref(), producer.epoch.as_ref()) else { + let (new, old) = (withdrawing.kind.as_ref(), producer.kind.as_ref()); + let (Some(new_kind), Some(old_kind)) = (new, old) else { return false; }; - match kind(epoch) { - None | Some(ProducerKind::Client) => return false, - Some(ProducerKind::Workflow) => {} + match ProducerKind::from(new_kind) { + ProducerKind::Client => return false, + ProducerKind::Workflow => {} same => { - return kind(old) == same + return ProducerKind::from(old_kind) == same && producer.id == withdrawing.id - && compare_epochs(old, epoch) == Ordering::Less; + && compare_epochs(old, new) == Ordering::Less; } } if !of_run(producer, withdrawing) { return false; } - let (task, old) = (task_of(epoch), task_of(old)); - let started = old.started_event_id; - if writer.failed_tasks.contains(&started) { + let (task, old_task) = (task_of(new), task_of(old)); + let started = old_task.started_event_id; + if writer_failed_tasks(writer).contains(&started) { return true; } - let completed = writer.previous_completed_task.as_ref(); + let completed = writer_previous_completed_task(writer); let after = completed.map_or(0, |c| c.started_event_id); if after < started && started < task.started_event_id { return true; } let lower_at = |at: &WorkflowTaskEpoch| { - started == at.started_event_id && compare_tasks(&old, at) == Ordering::Less + started == at.started_event_id && compare_tasks(&old_task, at) == Ordering::Less }; lower_at(&task) || completed.is_some_and(lower_at) } /// Whether a Producer is the Workflow run that withdraws, or one of its Local Activities. fn of_run(producer: &Producer, withdrawing: &Producer) -> bool { - match producer.epoch.as_ref().and_then(kind) { + match kind_of(producer) { Some(ProducerKind::Workflow) => producer.id == withdrawing.id, - Some(ProducerKind::LocalActivity) => producer.run_id == withdrawing.run_id, + Some(ProducerKind::LocalActivity) => { + producer_run_id(producer) == producer_run_id(withdrawing) + } _ => false, } } @@ -199,17 +237,14 @@ fn of_run(producer: &Producer, withdrawing: &Producer) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::proto::ClientEpoch; + use crate::proto::{ClientEpoch, ClientProducer, producer::Kind}; // Without the clamp, the bytes of a negative field would outrank every real epoch. #[test] fn a_negative_field_compares_as_zero() { - let negative = ProducerEpoch { - kind: Some(Kind::Client(ClientEpoch { attempt: -1 })), - }; - assert_eq!( - compare_epochs(&negative, &ProducerEpoch::default()), - Ordering::Equal - ); + let negative = Kind::Client(ClientProducer { + epoch: Some(ClientEpoch { attempt: -1 }), + }); + assert_eq!(compare_epochs(Some(&negative), None), Ordering::Equal); } } diff --git a/crates/streams/src/ids.rs b/crates/streams/src/ids.rs index d50cf47f3..45eb1e77b 100644 --- a/crates/streams/src/ids.rs +++ b/crates/streams/src/ids.rs @@ -11,7 +11,11 @@ //! script. Each part is percent-encoded, so a `:` or a brace in an id can't make two Streams share //! a key. -use crate::{StreamError, StreamResult, epoch::ProducerKind, proto::Producer}; +use crate::{ + StreamError, StreamResult, + epoch::{ProducerKind, producer_run_id}, + proto::Producer, +}; /// The longest Stream id, in bytes. pub const MAX_STREAM_ID_BYTES: usize = 4096; @@ -151,13 +155,14 @@ pub fn check_producer(producer: &Producer) -> StreamResult<()> { kind, ProducerKind::Workflow | ProducerKind::LocalActivity | ProducerKind::Activity ); - if runs && decode_part(parts[0]).as_deref() != Some(producer.run_id.as_str()) { + if runs && decode_part(parts[0]).as_deref() != Some(producer_run_id(producer)) { return Err(StreamError::invalid(format!( "The Producer id {:?} names another run than its run id {:?}", - producer.id, producer.run_id + producer.id, + producer_run_id(producer) ))); } - if kind == ProducerKind::Client && !producer.run_id.is_empty() { + if kind == ProducerKind::Client && !producer_run_id(producer).is_empty() { return Err(StreamError::invalid("A client Producer has no run id")); } Ok(()) @@ -207,20 +212,34 @@ impl StreamKeys { #[cfg(test)] mod tests { use super::*; - use crate::proto::{ProducerEpoch, producer_epoch::Kind}; + use crate::proto::{ + ActivityProducer, ClientProducer, LocalActivityProducer, WorkflowProducer, producer::Kind, + }; fn producer(kind: ProducerKind, id: &str, run_id: &str) -> Producer { + let run_id = run_id.to_string(); let kind = match kind { - ProducerKind::Workflow => Kind::Workflow(Default::default()), - ProducerKind::LocalActivity => Kind::LocalActivity(Default::default()), - ProducerKind::Activity => Kind::Activity(Default::default()), - ProducerKind::StandaloneActivity => Kind::StandaloneActivity(Default::default()), - ProducerKind::Client => Kind::Client(Default::default()), + ProducerKind::Workflow => Kind::Workflow(WorkflowProducer { + run_id, + ..Default::default() + }), + ProducerKind::LocalActivity => Kind::LocalActivity(LocalActivityProducer { + run_id, + ..Default::default() + }), + ProducerKind::Activity => Kind::Activity(ActivityProducer { + run_id, + ..Default::default() + }), + ProducerKind::StandaloneActivity => Kind::StandaloneActivity(ActivityProducer { + run_id, + ..Default::default() + }), + ProducerKind::Client => Kind::Client(ClientProducer::default()), }; Producer { id: id.to_string(), - epoch: Some(ProducerEpoch { kind: Some(kind) }), - run_id: run_id.to_string(), + kind: Some(kind), } } @@ -267,14 +286,9 @@ mod tests { producer(ProducerKind::Workflow, &workflow_producer_id(run), "other"), producer(ProducerKind::Activity, "act:run%3A1:a", run), Producer { - epoch: None, - ..producer(ProducerKind::Workflow, "wf:x", "x") - }, - Producer { - epoch: Some(ProducerEpoch::default()), + kind: None, ..producer(ProducerKind::Workflow, "wf:x", "x") }, - producer(ProducerKind::Client, "client:a:b", "run"), producer(ProducerKind::Client, "client:a|b", ""), producer(ProducerKind::Client, "client:run%3a1", ""), ]; diff --git a/crates/streams/tests/vectors.rs b/crates/streams/tests/vectors.rs index 853a4e96c..d824561a4 100644 --- a/crates/streams/tests/vectors.rs +++ b/crates/streams/tests/vectors.rs @@ -12,9 +12,10 @@ use temporalio_streams::{ ids::{self, StreamKeys}, offset::{entry_id_of_offset, offset_of_entry_id}, proto::{ - ActivityEpoch, AppendRecord, ClientEpoch, LocalActivityEpoch, Producer, ProducerEpoch, - StreamData, StreamRecord, StreamSuperseded, StreamWriter, WorkflowTaskEpoch, - producer_epoch::Kind, stream_record::Item, + ActivityEpoch, ActivityProducer, AppendRecord, ClientEpoch, ClientProducer, + LocalActivityEpoch, LocalActivityProducer, Producer, RunWithdrawal, StreamData, + StreamRecord, StreamSuperseded, StreamWriter, WorkflowProducer, WorkflowTaskEpoch, + producer::Kind, stream_record::Item, }, }; @@ -39,31 +40,43 @@ fn number(value: &Value, field: &str) -> i64 { value[field].as_i64().unwrap_or(0) } -/// The epoch of a kind, from the flat fields of the vectors. The case takes the fields of its kind. -fn epoch(kind: &str, value: &Value) -> ProducerEpoch { +/// The Producer kind of a vector, from its flat fields and its run. The case takes the fields of +/// its kind, and a run-based kind carries the run. `empty` has no case. +fn kind_of(kind: &str, value: &Value, run_id: &str) -> Option { let task = WorkflowTaskEpoch { started_event_id: number(value, "task_started_event_id"), attempt: number(value, "task_attempt") as i32, started_time: time(&value["task_started_time"]), }; let attempt = number(value, "attempt") as i32; - let activity = ActivityEpoch { - attempt_scheduled_time: time(&value["attempt_scheduled_time"]), - attempt, - }; - let kind = match kind { - "workflow" => Some(Kind::Workflow(task)), - "local_activity" => Some(Kind::LocalActivity(LocalActivityEpoch { - task: Some(task), + let run_id = run_id.to_string(); + let activity = ActivityProducer { + run_id: run_id.clone(), + epoch: Some(ActivityEpoch { + attempt_scheduled_time: time(&value["attempt_scheduled_time"]), attempt, + }), + }; + match kind { + "workflow" => Some(Kind::Workflow(WorkflowProducer { + run_id, + epoch: Some(task), + })), + "local_activity" => Some(Kind::LocalActivity(LocalActivityProducer { + run_id, + epoch: Some(LocalActivityEpoch { + task: Some(task), + attempt, + }), })), "activity" => Some(Kind::Activity(activity)), "standalone_activity" => Some(Kind::StandaloneActivity(activity)), - "client" => Some(Kind::Client(ClientEpoch { attempt })), + "client" => Some(Kind::Client(ClientProducer { + epoch: Some(ClientEpoch { attempt }), + })), "empty" => None, other => panic!("unknown kind {other}"), - }; - ProducerEpoch { kind } + } } #[test] @@ -137,10 +150,10 @@ fn offsets_match() { #[test] fn epoch_keys_match() { for case in load("epochs.json").as_array().unwrap() { - let epoch = epoch(text(case, "kind"), &case["epoch"]); - assert_eq!(epoch_key(&epoch).to_vec(), hex(text(case, "key"))); + let kind = kind_of(text(case, "kind"), &case["epoch"], ""); + assert_eq!(epoch_key(kind.as_ref()).to_vec(), hex(text(case, "key"))); assert_eq!( - task_key(&task_of(&epoch)).to_vec(), + task_key(&task_of(kind.as_ref())).to_vec(), hex(text(case, "task_key")) ); } @@ -152,29 +165,34 @@ fn the_appendix_e_withdrawals_match() { assert!(cases.as_array().unwrap().len() >= 35); for case in cases.as_array().unwrap() { let s = &case["superseded"]; + // A create's item has no epoch, and a Reader must not read it as an empty one. let withdrawing = Producer { id: text(s, "producer_id").to_string(), - // A create's item has no epoch, and a Reader must not read it as an empty one. - epoch: s - .get("epoch") - .map(|value| epoch(text(s, "producer_kind"), value)), - run_id: s["run_id"].as_str().unwrap_or_default().to_string(), + kind: s.get("epoch").and_then(|value| { + kind_of( + text(s, "producer_kind"), + value, + s["run_id"].as_str().unwrap_or_default(), + ) + }), }; let superseded = StreamSuperseded { writer: Some(StreamWriter { producer: Some(withdrawing), - previous_completed_task: s.get("previous_completed_task").map(|c| { - WorkflowTaskEpoch { - started_event_id: number(c, "started_event_id"), - attempt: number(c, "attempt") as i32, - started_time: time(&c["started_time"]), - } + withdrawal: Some(RunWithdrawal { + previous_completed_task: s.get("previous_completed_task").map(|c| { + WorkflowTaskEpoch { + started_event_id: number(c, "started_event_id"), + attempt: number(c, "attempt") as i32, + started_time: time(&c["started_time"]), + } + }), + failed_tasks: s["failed_tasks"] + .as_array() + .map(|list| list.iter().map(|v| v.as_i64().unwrap()).collect()) + .unwrap_or_default(), + withdraw_run: s["withdraw_run"].as_str().unwrap_or_default().to_string(), }), - failed_tasks: s["failed_tasks"] - .as_array() - .map(|list| list.iter().map(|v| v.as_i64().unwrap()).collect()) - .unwrap_or_default(), - withdraw_run: s["withdraw_run"].as_str().unwrap_or_default().to_string(), }), }; let p = &case["record"]["producer"]; @@ -182,8 +200,13 @@ fn the_appendix_e_withdrawals_match() { item: Some(Item::Data(StreamData { producer: Some(Producer { id: text(p, "id").to_string(), - epoch: p.get("epoch").map(|value| epoch(text(p, "kind"), value)), - run_id: p["run_id"].as_str().unwrap_or_default().to_string(), + kind: p.get("epoch").and_then(|value| { + kind_of( + text(p, "kind"), + value, + p["run_id"].as_str().unwrap_or_default(), + ) + }), }), ..Default::default() })),