Appearance
Cookbook
Practical, copy-paste recipes. Most code blocks are imported directly from compiled, asserting Rust and Python tests under crates/*/tests/; all projection snippets use that mechanism. The remaining inline persistence recipes mirror the shipped integration-test API and keep their shared serializer helper visible on the page.
Recipes
- Rate-limit a stream
- Keep-latest under backpressure
- Recover from upstream failure with a fallback source
- Frame a byte stream
- Resilient source with exponential-backoff restart
- Fan out and recombine with GraphDSL
- Group-by aggregation
- Bounded async parallelism
- Ask an actor per element
- Cancel a running stream
- Dynamic fan-in from many producers
- Drive and assert a pipeline with TestKit probes
- Consume Kafka with the native connector
- Run a native Kafka consumer group
- Run SQL over Kafka with native ingest
- Respawn remembered entities after rebalance
- Build and run a Python pipeline
- Run a vectorized Arrow UDF from Python
- Run a Python UDF remotely over zstd
- Event-sourced counter with replies
- Tune snapshots and retention
- Project events by tag into a read model
- Durable-state CAS upsert with conflict handling
- Sharded event-sourced entity
Rate-limit a stream
Problem: cap outbound calls to at most N requests per unit of time.
Use throttle(max_elements, per, max_burst, ThrottleMode) to pace the stream. ThrottleMode::Shaping introduces delay; ThrottleMode::Enforcing fails excess elements with StreamError::Failed.
rust
use datum::{Sink, Source, ThrottleMode};
use std::time::{Duration, Instant};
// Problem: cap outbound API calls to at most 1 request every 20 ms.
// throttle(max_elements, per, max_burst, mode) paces the stream.
// ThrottleMode::Shaping delays excess elements; Enforcing fails them instead.
let t0 = Instant::now();
let items: Vec<u64> = Source::from_iter(1_u64..=5)
.throttle(1, Duration::from_millis(20), 1, ThrottleMode::Shaping)
.run_with(Sink::collect())
.unwrap()
.wait()
.unwrap();
let elapsed = t0.elapsed();Watch:
max_burstcontrols the initial token bucket — set it equal tomax_elementsfor a strict no-burst policy.
Keep-latest under backpressure
Problem: a slow consumer receives sensor bursts; merge them rather than buffering forever.
conflate(f) coalesces buffered elements when the consumer is slower than the producer. |_acc, latest| latest discards older values and retains the most-recent arrival.
rust
use datum::{Sink, Source};
// Problem: a slow consumer receives sensor bursts; keep only the most-recent value.
// conflate(f) coalesces buffered elements when the consumer is slower than the
// producer. "Keep-latest" discards the accumulated value, retaining the newest.
let items: Vec<u64> = Source::from_iter(1_u64..=5)
.conflate(|_accumulated, latest| latest)
.run_with(Sink::collect())
.unwrap()
.wait()
.unwrap();Watch: in a synchronous fused chain the source outruns the consumer before the first pull, so all values collapse into one. Use
conflate(|acc, x| acc + x)when you need to preserve a summary (e.g. a running total) across the burst.
Recover from upstream failure with a fallback source
Problem: a primary data source may fail transiently; continue with a static default.
recover_with(f) intercepts the first error and replaces it with a Source<T> continuation. Elements from the fallback are emitted normally and the stream completes cleanly.
rust
use datum::{Sink, Source, StreamError};
// Problem: a primary data source may fail; fall back to a static default dataset
// so downstream processing can continue.
// recover_with(f) replaces the first error with a Source<T> continuation.
let items: Vec<u64> = Source::from_iter(1_u64..=2)
.concat(Source::failed(StreamError::Failed(
"upstream unavailable".into(),
)))
.recover_with(|_err| Some(Source::from_iter([99_u64, 100])))
.run_with(Sink::collect())
.unwrap()
.wait()
.unwrap();Watch:
recover_with_retries(n, f)re-attempts the fallback source up tontimes — useful when the fallback itself may fail.recover(f)is the simpler variant that emits at most one fallback element.
Frame a byte stream
Problem: a TCP connection delivers log lines in arbitrary-sized chunks.
Framing::delimiter(delimiter, max_frame_len, allow_truncation) reassembles frames across chunk boundaries and strips the delimiter. allow_truncation = true emits a partial final frame when the stream ends without a trailing delimiter.
rust
use datum::{Framing, Sink, Source};
// Problem: a TCP connection delivers log lines in arbitrary-sized byte chunks.
// Framing::delimiter(delimiter, max_frame_len, allow_truncation) reassembles
// frames across chunk boundaries and strips the delimiter.
// allow_truncation=true emits a partial final frame with no trailing delimiter.
let chunks: Vec<Vec<u8>> = vec![
b"event:login\nevent:".to_vec(), // chunk boundary splits a frame
b"logout\n".to_vec(),
b"event:timeout".to_vec(), // no trailing newline
];
let frames: Vec<Vec<u8>> = Source::from_iter(chunks)
.via(Framing::delimiter(b"\n".to_vec(), 256, true))
.run_with(Sink::collect())
.unwrap()
.wait()
.unwrap();Watch:
max_frame_lenprotects against unbounded buffering on a malformed stream — exceeding it fails withStreamError::Failed.Framing::json(max_len)is available for concatenated JSON objects.
Resilient source with exponential-backoff restart
Problem: a remote source fails intermittently; reconnect automatically with backoff.
RestartSource::on_failures_with_backoff(settings, factory) rematerializes the factory when the inner source fails. RestartSettings::with_max_restarts(n, within) bounds the total restart count inside a rolling window.
rust
use datum::{RestartSettings, RestartSource, Sink, Source, StreamError};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use std::time::Duration;
let attempts = Arc::new(AtomicUsize::new(0));
let attempts_c = Arc::clone(&attempts);
let settings = RestartSettings::new(
Duration::from_millis(5), // min backoff
Duration::from_millis(100), // max backoff
0.0, // no jitter — deterministic in tests
)
.with_max_restarts(3, Duration::from_secs(10));
// on_failures_with_backoff rematerializes the factory when the inner source fails;
// a clean completion passes through unchanged. Useful for reconnecting to external
// services that experience transient errors.
let items: Vec<u64> = RestartSource::on_failures_with_backoff(settings, move || {
let n = attempts_c.fetch_add(1, Ordering::SeqCst);
if n == 0 {
// First attempt: simulate a transient upstream error.
Source::failed(StreamError::Failed("transient error".into()))
} else {
// Second attempt succeeds.
Source::from_iter(1_u64..=3)
}
})
.take(3)
.run_with(Sink::collect())
.unwrap()
.wait()
.unwrap();Watch:
on_failures_with_backoffonly restarts on failure — a clean completion flows through unchanged. Usewith_backoffif you want to restart on both failure and completion (e.g. for infinite pagination).
Fan out and recombine with GraphDSL
Problem: apply two independent transformations to each element, then merge results.
The GraphDSL builder wires typed ports together. Broadcast fans one inlet to N outlets; Merge collects N inlets into one outlet.
rust
use datum::{Broadcast, GraphDsl, GraphFlowShape, Merge};
// Problem: apply two independent processing paths to each element, then merge
// the results. GraphDSL wires arbitrary graph topologies via typed ports.
// Here Broadcast fans every element to two outlets; Merge collects both.
let graph = GraphDsl::try_create(|builder| {
let bcast = builder.add(Broadcast::<u64>::new(2));
let merge = builder.add(Merge::<u64>::new(2));
builder.connect(bcast.outlet(0)?, merge.inlet(0)?)?;
builder.connect(bcast.outlet(1)?, merge.inlet(1)?)?;
Ok(GraphFlowShape::new(bcast.inlet(), merge.outlet()))
})
.unwrap();
// Each input element passes through both outlets → every value appears twice.
let mut items: Vec<u64> = graph.run_with_input(1_u64..=3).unwrap();
items.sort_unstable();Watch:
Mergeis non-deterministic when inlets deliver concurrently — sort before asserting equality. For a round-robin fan-in useBalance; for an ordered fan-in useMergeSequence.
Group-by aggregation
Problem: compute per-category totals from a mixed event stream.
group_by(max_keys, key_fn, allow_recreation) partitions elements into per-key sub-sources. flat_map_merge(breadth, f) runs up to breadth sub-source folds concurrently.
rust
use datum::{Sink, Source};
// Problem: compute per-category totals from a mixed event stream.
// group_by(max_keys, key_fn, allow_recreation) partitions elements by key.
// Each sub-source is folded independently; flat_map_merge runs the folds
// concurrently up to max_keys at a time.
let events: Vec<(u32, u64)> = vec![(1, 10), (2, 20), (1, 30), (2, 40), (1, 50)];
let mut totals: Vec<u64> = Source::from_iter(events)
.group_by(2, |e| e.0, false)
.flat_map_merge(2, |sub| sub.fold(0_u64, |acc, e| acc + e.1))
.run_with(Sink::collect())
.unwrap()
.wait()
.unwrap();
totals.sort_unstable();Watch:
max_keyscaps the number of simultaneously open sub-sources; elements with an unknown key after the cap is reached will fail the stream.
Bounded async parallelism
Problem: enrich records with async lookups without overwhelming the backend.
map_async(parallelism, f) runs up to parallelism futures concurrently and preserves input order in the output. Use map_async_unordered when order does not matter and you want lower latency.
rust
use datum::{Sink, Source};
// Problem: enrich each record with an async lookup (e.g. a DB or HTTP call),
// running at most 4 concurrent requests to avoid saturating the backend.
// map_async(parallelism, f) caps concurrent futures and preserves input order.
let enriched: Vec<String> = Source::from_iter(1_u64..=6)
.map_async(4, |id| async move { Ok(format!("user-{id}")) })
.run_with(Sink::collect())
.unwrap()
.wait()
.unwrap();Watch: the future closure must return
StreamResult<Next>(i.e.Result<Next, StreamError>). A returnedErrfails the whole stream; wrap recoverable errors explicitly if you need per-element fallback logic.
Ask an actor per element
Problem: delegate per-element computation to a typed Ractor actor.
ActorFlow::ask(actor_ref, parallelism, timeout, make_msg) routes each stream element as a request/reply pair. Up to parallelism requests may be in-flight simultaneously; output order is preserved.
Happy path:
rust
use datum::actor::{Actor, ActorProcessingErr, ActorRef};
use datum::{ActorFlow, ReplyPort, Sink, Source};
use std::time::Duration;
enum SquareMsg {
Square {
value: u64,
reply_to: ReplyPort<u64>,
},
}
struct SquareActor;
impl Actor for SquareActor {
type Msg = SquareMsg;
type State = ();
type Arguments = ();
async fn pre_start(
&self,
_: ActorRef<Self::Msg>,
_: Self::Arguments,
) -> Result<Self::State, ActorProcessingErr> {
Ok(())
}
async fn handle(
&self,
_: ActorRef<Self::Msg>,
message: Self::Msg,
_: &mut Self::State,
) -> Result<(), ActorProcessingErr> {
let SquareMsg::Square { value, reply_to } = message;
let _ = reply_to.send(value * value);
Ok(())
}
}
// Problem: delegate per-element computation to a typed actor.
// ActorFlow::ask(actor_ref, parallelism, timeout, make_msg) routes each element
// as a request/reply pair and preserves output order.
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let (actor_ref, _handle) = rt.block_on(async {
Actor::spawn(None, SquareActor, ())
.await
.expect("actor spawns")
});
let results: Vec<u64> = Source::from_iter(1_u64..=5)
.via(ActorFlow::ask(
actor_ref.clone(),
2, // at most 2 in-flight requests
Duration::from_secs(1),
|v, reply_to| SquareMsg::Square { value: v, reply_to },
))
.run_with(Sink::collect())
.unwrap()
.wait()
.unwrap();
actor_ref.stop(None);Timeout / failure path — when the actor never replies within the deadline the stream fails with StreamError::ActorAskTimeout:
rust
use datum::actor::{Actor, ActorProcessingErr, ActorRef};
use datum::{ActorFlow, ReplyPort, Sink, Source, StreamError};
use std::time::Duration;
// Problem: the backend actor is overloaded and never replies within the deadline.
// ActorFlow::ask propagates the missed deadline as StreamError::ActorAskTimeout,
// so callers can distinguish a timeout from an actor crash.
enum HangMsg {
Hang { _reply_to: ReplyPort<u64> },
}
struct HangActor;
impl Actor for HangActor {
type Msg = HangMsg;
type State = Vec<ReplyPort<u64>>;
type Arguments = ();
async fn pre_start(
&self,
_: ActorRef<Self::Msg>,
_: Self::Arguments,
) -> Result<Self::State, ActorProcessingErr> {
Ok(Vec::new())
}
async fn handle(
&self,
_: ActorRef<Self::Msg>,
message: Self::Msg,
state: &mut Self::State,
) -> Result<(), ActorProcessingErr> {
let HangMsg::Hang { _reply_to } = message;
state.push(_reply_to); // hold port open — never reply
Ok(())
}
}
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let (actor_ref, _handle) = rt.block_on(async {
Actor::spawn(None, HangActor, ())
.await
.expect("actor spawns")
});
let timeout = Duration::from_millis(50);
let result = Source::single(1_u64)
.via(ActorFlow::ask(
actor_ref.clone(),
1,
timeout,
|_v, reply_to| HangMsg::Hang {
_reply_to: reply_to,
},
))
.run_with(Sink::collect())
.unwrap()
.wait();
actor_ref.stop(None);Watch: actor-ask throughput carries ~2× CPU vs wall-clock on the
ordered_sumbenchmark path — the ask protocol does real work. See Actors Interop for the full performance picture.
Cancel a running stream
Problem: shut down a long-running stream from an external handle.
KillSwitches::single() materializes a UniqueKillSwitch alongside the stream. switch.shutdown() completes downstream and cancels upstream at the next pull. switch.abort(err) fails the stream instead.
rust
use datum::{
Keep, KillSwitches, Materializer,
testkit::{TestSink, TestSource},
};
// Problem: shut down a long-running stream from an external handle without
// stopping the whole runtime.
// KillSwitches::single() materializes a UniqueKillSwitch alongside the stream.
// switch.shutdown() completes downstream and cancels upstream on the next pull.
let materializer = Materializer::new();
let ((source, switch), sink) = TestSource::probe::<u64>()
.via_mat(KillSwitches::single(), Keep::both)
.to_mat(TestSink::probe(), Keep::both)
.run_with_materializer(&materializer)
.expect("materializes");
// Deliver one element normally before triggering shutdown.
sink.request(1);
assert_eq!(source.expect_request(), 1);
source.send_next(42);
sink.assert_next(42);
// Shutdown: the next pull observes completion; upstream receives cancellation.
switch.shutdown();
sink.request(1);
sink.expect_complete();
source.expect_cancellation();Watch:
KillSwitches::shared(name)produces aSharedKillSwitchwhoseswitch.flow::<T>()can be wired into any number of independent streams — oneshutdown()call terminates all of them.
Dynamic fan-in from many producers
Problem: aggregate input from an unknown number of concurrent producers that attach at different times.
MergeHub::source_with_draining::<T>(buffer) materializes a Sink<T, NotUsed> that any producer can clone and attach to independently. control.drain_and_complete() signals that no new producers will attach.
rust
use datum::{Keep, MergeHub, Sink, Source};
// Problem: aggregate input from an unknown number of concurrent producers
// that attach at different times.
// MergeHub::source_with_draining materializes a Sink<T, NotUsed> that any
// producer can clone and attach to independently.
let ((hub_sink, control), collected) = MergeHub::source_with_draining::<u32>(4)
.to_mat(Sink::collect(), Keep::both)
.run()
.unwrap();
// Each producer runs its own source into a clone of the hub sink.
hub_sink
.clone()
.run_with(Source::from_iter([1_u32, 2, 3]))
.unwrap();
hub_sink
.clone()
.run_with(Source::from_iter([10_u32, 20, 30]))
.unwrap();
// Signal that no new producers will attach; hub completes when all finish.
control.drain_and_complete();
let mut items: Vec<u32> = collected.wait().unwrap();
items.sort_unstable();Watch:
per_producer_buffer_sizeis per-producer, not global. Keep it small to bound memory; producers that fill their buffer are backpressured.
Drive and assert a pipeline with TestKit probes
Problem: verify a processing pipeline's element-by-element behavior in isolation.
TestSource::probe::<T>() and TestSink::probe::<T>() give manual control over every upstream element and downstream credit signal. The protocol is explicit: credit must be issued before elements can arrive.
rust
use datum::{
Flow, Keep, Materializer,
testkit::{TestSink, TestSource},
};
// Problem: verify a processing pipeline's element-by-element behavior in
// isolation before wiring it into production.
// TestSource and TestSink probes give manual control over upstream elements
// and downstream credit, making the full backpressure protocol visible.
let materializer = Materializer::new();
let (source, sink) = TestSource::probe::<u64>()
.via(Flow::identity().map(|x| format!("id-{x}")))
.to_mat(TestSink::probe(), Keep::both)
.run_with_materializer(&materializer)
.expect("materializes");
// One credit → one pull propagates upstream → one element travels end-to-end.
sink.request(1);
assert_eq!(source.expect_request(), 1);
source.send_next(42);
sink.assert_next("id-42".to_string());
sink.request(1);
assert_eq!(source.expect_request(), 1);
source.send_next(99);
sink.assert_next("id-99".to_string());
// Complete the publisher; subscriber observes completion on the next pull.
sink.request(1);
assert_eq!(source.expect_request(), 1);
source.send_complete();
sink.expect_complete();Watch: terminal signals (
expect_complete,expect_error) require outstanding credit — always callsink.request(n)before asserting a completion or error, or usedrain_until_complete()to consume all elements in one call.
Consume Kafka with the native connector
Problem: consume a Kafka topic with Datum-managed at-least-once commits, using the native Kafka connector.
The snippet keeps KafkaConsumerBackend::Native explicit, uses CommitPolicy::Manual, and commits only after your side effect or checkpoint succeeds. It is broker-gated: set MQ_BOOTSTRAP_SERVERS and DATUM_DOCS_ORDERS_TOPIC to run it.
rust
use datum::{Sink, StreamError};
use datum_mq::{
CommitPolicy, ConsumerRecord, KafkaConsumerBackend, KafkaConsumerSettings, KafkaOffset,
KafkaSource, Subscription,
};
// Broker-gated: set these when running the recipe against a local Kafka topic
// that already contains at least three order records.
let Some(topic) = std::env::var("DATUM_DOCS_ORDERS_TOPIC").ok() else {
return;
};
let bootstrap =
std::env::var("MQ_BOOTSTRAP_SERVERS").unwrap_or_else(|_| "127.0.0.1:9092".to_owned());
let group = format!("datum-docs-native-{}", std::process::id());
let settings = KafkaConsumerSettings::new(bootstrap, group)
.with_consumer_backend(KafkaConsumerBackend::Native)
.with_commit_policy(CommitPolicy::Manual)
.with("auto.offset.reset", "earliest")
.with("partition.assignment.strategy", "cooperative-sticky")
.with_backpressure(512, 1_024)
.with_poll_batch_size(64);
let processed = KafkaSource::committable(settings, Subscription::topics([topic]))
.as_source()
.take(3)
.run_with(Sink::fold_result(
0_u64,
|count, (record, offset): (ConsumerRecord, KafkaOffset)| {
// Do durable, idempotent work first. Use topic/partition/offset
// from the record or offset as your de-duplication key.
let _dedupe_key = (&record.topic, record.partition, record.offset);
// Commit only after the side effect/checkpoint succeeds.
offset.commit().map_err(StreamError::from)?;
Ok(count + 1)
},
))
.expect("native Kafka consumer materializes")
.wait()
.expect("native Kafka consumer completes");
println!("processed {processed} orders");
assert_eq!(processed, 3);Expected output:
text
processed 3 ordersWatch: Kafka is native-only in Datum 0.11: there is no
rdkafkafeature or dependency, and no C/CMake build. The native producer and consumer support plaintext, ring-backed TLS, PLAIN/SCRAM SASL, and none/gzip/Snappy/LZ4/Zstd record batches. Transactions, consume-transform-produce EOS, GSSAPI, and OAUTHBEARER are not claimed.
Run a native Kafka consumer group
Problem: run multiple native consumers in one group, use cooperative-sticky assignment, stop cleanly, and avoid committing offsets after an assignment is lost.
The native backend uses classic consumer groups. Cooperative-sticky is the default, but setting partition.assignment.strategy=cooperative-sticky makes the rollout obvious. The snippet handles AssignmentLost as a terminal commit error and uses KafkaControl::drain_and_shutdown for deploy shutdown. Set MQ_BOOTSTRAP_SERVERS and DATUM_DOCS_GROUP_TOPIC to run it.
rust
use std::time::Duration;
use datum::{Keep, Sink, StreamCompletion, StreamError};
use datum_mq::{
KafkaConsumerBackend, KafkaConsumerSettings, KafkaControl, KafkaPayloadBatch, KafkaSource,
MqError, Subscription,
};
// Broker-gated: the topic should exist and receive records while this
// process is running.
let Some(topic) = std::env::var("DATUM_DOCS_GROUP_TOPIC").ok() else {
return;
};
let bootstrap =
std::env::var("MQ_BOOTSTRAP_SERVERS").unwrap_or_else(|_| "127.0.0.1:9092".to_owned());
let settings = KafkaConsumerSettings::new(bootstrap, "orders-workers")
.with_consumer_backend(KafkaConsumerBackend::Native)
.with("auto.offset.reset", "earliest")
// The native backend accepts one classic assignor. Cooperative-sticky is
// the default; set it explicitly when you want rollout parity.
.with("partition.assignment.strategy", "cooperative-sticky")
.with_backpressure(256, 512)
.with_commit_batch_size(128)
.with_commit_interval(Duration::from_millis(100));
let (control, completion): (KafkaControl, StreamCompletion<datum::NotUsed>) =
KafkaSource::committable_payload_batches(settings, Subscription::topics([topic]))
.to_mat(
Sink::foreach_result(|batch: KafkaPayloadBatch| {
for record in batch.records() {
let _payload = batch.payload(record);
// Process each payload here.
}
match batch.commit() {
Ok(()) => Ok(()),
Err(error @ MqError::AssignmentLost { .. }) => {
// The group no longer owns at least one partition in
// this batch. Do not retry this commit; fail the
// stream and let the group replay from the last
// committed offset.
Err(StreamError::from(error))
}
Err(error) => Err(StreamError::from(error)),
}
}),
Keep::both,
)
.run()
.expect("native Kafka group consumer materializes");
// On SIGTERM or deploy shutdown, stop taking new records and wait for
// already emitted batches to commit.
control
.drain_and_shutdown(Duration::from_secs(30))
.expect("consumer drains outstanding commits");
completion.wait().expect("consumer completes after drain");
let metrics = control.metrics().snapshot();
println!(
"drained; committed={} rebalances={} lost={}",
metrics.committed_offsets, metrics.rebalances, metrics.lost_partitions
);Expected output:
text
drained; committed=<offset-count> rebalances=<count> lost=0Watch:
AssignmentLostmeans the group no longer owns that partition. Do not retry that commit; fail or stop the stream and let Kafka replay from the last committed offset on the new owner.
Run SQL over Kafka with native ingest
Problem: expose a Kafka JSON topic as a SQL table and execute a streaming SQL query through datum-sql, while consuming Kafka through the native connector.
register_mq_topic builds a committable SQL table over datum-mq payload batches. Kafka ingest uses Datum's native connector; the snippet keeps with_consumer_backend(KafkaConsumerBackend::Native) explicit alongside the consumer settings. Set MQ_BOOTSTRAP_SERVERS and DATUM_DOCS_SQL_TOPIC to run the snippet against JSON rows shaped like {"id":201,"name":"q0-a"}.
rust
use std::sync::{Arc, Mutex};
use arrow::array::Int64Array;
use datum_mq::{KafkaConsumerBackend, KafkaConsumerSettings};
use datum_sql::{DatumSqlContext, JsonRowFormat};
// Broker-gated: point this at a Kafka topic containing JSON rows shaped like
// {"id":201,"name":"q0-a"}.
let Some(topic) = std::env::var("DATUM_DOCS_SQL_TOPIC").ok() else {
return;
};
let bootstrap =
std::env::var("MQ_BOOTSTRAP_SERVERS").unwrap_or_else(|_| "127.0.0.1:9092".to_owned());
let schema = test_schema();
let format = JsonRowFormat::new(Arc::clone(&schema)).with_schema_revision(18);
let settings = KafkaConsumerSettings::new(
bootstrap,
format!("datum-docs-sql-native-{}", unique_suffix()),
)
.with_consumer_backend(KafkaConsumerBackend::Native)
.with("auto.offset.reset", "earliest")
.with_poll_batch_size(128);
let context = DatumSqlContext::new();
context
.register_mq_topic("orders", settings, topic, format)
.expect("Kafka topic registers as a SQL table");
let ids = Arc::new(Mutex::new(Vec::<i64>::new()));
let ids_for_sink = Arc::clone(&ids);
context
.register_append_sink("selected_orders", move |batch| {
let id_column = batch
.column(0)
.as_any()
.downcast_ref::<Int64Array>()
.expect("id is Int64");
ids_for_sink
.lock()
.expect("ids lock")
.extend((0..id_column.len()).map(|row| id_column.value(row)));
Ok(())
})
.expect("append sink registers");
let handle = context
.execute_streaming(
"INSERT INTO selected_orders \
SELECT id FROM orders WHERE id >= 200",
)
.await
.expect("streaming SQL query materializes");
let deadline = Instant::now() + Duration::from_secs(30);
while ids.lock().expect("ids lock").len() < 3 && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
}
handle.cancel();
let mut ids = ids.lock().expect("ids lock").clone();
ids.sort_unstable();
println!("selected order ids: {ids:?}");Expected output:
text
selected order ids: [201, 202, 203]Watch:
execute_streamingkeeps running until the source completes, fails, or is cancelled. Keep the returned handle and callcancel()or wire it into your service shutdown path.
Respawn remembered entities after rebalance
Problem: keep entity ids alive across shard movement so a rebalanced shard starts its remembered entities on the new owner without waiting for a triggering message.
Enable RememberEntitiesConfig with a store, register the type through register_remembered_entity_type, and call flush_remember_entities() when you need a shutdown/restart-visible barrier for queued start/stop records. The v0.10.3 behavior guarantee is that allocation replication is retried during shard movement, so remembered entity re-spawn follows the final owner after rebalance.
rust
use std::{
collections::BTreeMap,
net::SocketAddr,
sync::{Arc, Mutex},
};
use datum_agent::{
ClusterAgent, ClusterAgentConfig, ClusterAgentHandle, NodeSessionConfig,
dcp::{DcpJobFactories, DcpServerConfig, DcpTcpServerConfig},
};
use datum_cluster::{ClusterConfig, MemberState};
use datum_cluster_sharding::{
EntityContext, InMemoryStore, RememberEntitiesConfig, ReplyPort, Sharding, ShardingConfig,
ShardingHandle, ShardingResult,
};
#[derive(Serialize, Deserialize)]
enum CartMsg {
Touch(ReplyPort<String>),
}
fn agent_config(node_id: &str, seed_nodes: Vec<SocketAddr>) -> ClusterAgentConfig {
ClusterAgentConfig {
cluster: ClusterConfig {
node_id: node_id.to_owned(),
seed_nodes,
bind_addr: "127.0.0.1:0".parse().expect("bind addr"),
advertise_addr: "127.0.0.1:0".parse().expect("advertise addr"),
gossip_interval: Duration::from_millis(60),
probe_timeout: Duration::from_millis(15),
downing_timeout: Duration::from_millis(150),
..ClusterConfig::default()
},
dcp: DcpServerConfig {
node_id: node_id.to_owned(),
tcp: Some(DcpTcpServerConfig {
addr: "127.0.0.1:0".parse().expect("dcp addr"),
}),
..DcpServerConfig::default()
},
sessions: NodeSessionConfig {
request_timeout: Duration::from_secs(2),
command_buffer: 256,
..NodeSessionConfig::default()
},
..ClusterAgentConfig::default()
}
}
async fn wait_all_up(agents: &[&ClusterAgentHandle], expected: usize) {
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
while tokio::time::Instant::now() < deadline {
if agents.iter().all(|agent| {
agent
.cluster()
.current_state()
.members
.values()
.filter(|member| member.state == MemberState::Up && !member.unreachable)
.count()
== expected
}) {
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!("cluster did not converge to {expected} Up members");
}
async fn register_cart(
sharding: &ShardingHandle,
starts: Arc<Mutex<BTreeMap<String, Vec<String>>>>,
) {
let node_id = sharding.node_id().to_owned();
sharding
.register_remembered_entity_type("cart", move |context: EntityContext| {
starts
.lock()
.expect("starts lock")
.entry(context.entity_id.clone())
.or_default()
.push(node_id.clone());
let reply_node = node_id.clone();
move |_context: &EntityContext, message: CartMsg| -> ShardingResult<()> {
let CartMsg::Touch(reply) = message;
let _ = reply.send(reply_node.clone());
Ok(())
}
})
.await
.expect("remembered entity type registers");
}
let starts = Arc::new(Mutex::new(BTreeMap::<String, Vec<String>>::new()));
let remember_store = Arc::new(InMemoryStore::new());
let sharding_config = ShardingConfig {
num_shards: 16,
rebalance_per_round: 16,
coordinator_tick: Duration::from_millis(25),
remember_entities: RememberEntitiesConfig::with_store(remember_store),
..ShardingConfig::default()
};
let first_agent =
ClusterAgent::start(agent_config("node-a", Vec::new()), DcpJobFactories::new())
.await
.expect("first node starts");
let first = Sharding::init(&first_agent, sharding_config.clone()).expect("first sharding");
register_cart(&first, Arc::clone(&starts)).await;
// Start enough entity ids to cover many shards, then flush the write-behind
// remember store before inducing a rebalance.
let mut entity_shards = BTreeMap::new();
for index in 0..64 {
let entity_id = format!("cart-{index}");
let entity_ref = first.entity_ref::<CartMsg>("cart", entity_id.clone());
entity_shards.insert(entity_id, entity_ref.shard_id().to_owned());
let _owner = entity_ref
.ask(Duration::from_secs(5), CartMsg::Touch)
.await
.expect("initial cart touch");
}
first
.flush_remember_entities()
.await
.expect("remembered starts flush");
let initial_table = first
.allocation_table("cart")
.await
.expect("initial allocation table");
let initial_owner_by_shard = initial_table
.entries
.iter()
.map(|entry| (entry.shard_id.clone(), entry.node_id.clone()))
.collect::<BTreeMap<_, _>>();
// A joining node triggers graceful shard rebalance. Remembered ids for any
// moved shard are re-spawned on the new owner without a new user message.
let second_agent = ClusterAgent::start(
agent_config("node-b", vec![first_agent.cluster().advertise_addr()]),
DcpJobFactories::new(),
)
.await
.expect("second node starts");
wait_all_up(&[&first_agent, &second_agent], 2).await;
let second = Sharding::init(&second_agent, sharding_config).expect("second sharding");
register_cart(&second, Arc::clone(&starts)).await;
let moved_entity = {
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
let table = first
.allocation_table("cart")
.await
.expect("current allocation table");
let final_owner_by_shard = table
.entries
.iter()
.map(|entry| (entry.shard_id.clone(), entry.node_id.clone()))
.collect::<BTreeMap<_, _>>();
if let Some(entity_id) = entity_shards.iter().find_map(|(entity_id, shard_id)| {
let before = initial_owner_by_shard.get(shard_id)?;
let after = final_owner_by_shard.get(shard_id)?;
(before != after).then(|| entity_id.clone())
}) {
break entity_id;
}
if tokio::time::Instant::now() >= deadline {
panic!("no remembered cart moved during rebalance");
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
};
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
while tokio::time::Instant::now() < deadline {
if starts
.lock()
.expect("starts lock")
.get(&moved_entity)
.is_some_and(|nodes| nodes.len() >= 2)
{
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
let started_on = starts
.lock()
.expect("starts lock")
.get(&moved_entity)
.cloned()
.expect("moved entity was started");
println!("{moved_entity} started on {started_on:?}");
assert!(started_on.len() >= 2);
first.shutdown().await;
second.shutdown().await;
first_agent.shutdown().await.expect("first shuts down");
second_agent.shutdown().await.expect("second shuts down");Expected output:
text
cart-17 started on ["node-a", "node-b"]Watch: remember-entities remembers ids, not in-memory state. A moved or restarted entity is constructed from its factory again; use your own persistence for durable counters, carts, sessions, or aggregates.
Build and run a Python pipeline
Problem: assemble and run a Datum stream from Python using plain Python callables.
map(lambda ...) and filter(lambda ...) are convenience operators on integer streams. Datum batches elements into single-column Arrow RecordBatch values and executes the generated Arrow UDF once per batch; the row loop runs inside Python. The graph remains a blueprint until run(runtime).
python
import threading
import time
import pytest
import datum
import datum._native as native
def test_fold_sum_over_range_matches_rust_result():
with datum.Runtime() as runtime:
graph = (
datum.Source.range(0, 1_000)
.map_multiply(3)
.filter_greater_than(1_000)
.to_mat(datum.Sink.fold())
)
assert graph.run(runtime).wait() == native._rust_pipeline_sum(1_000)
def test_named_fold_terminals_preserve_sum_and_product_defaults():
with datum.Runtime() as runtime:
source = datum.Source.from_iter([2, 3, 4])
assert source.to_mat(datum.Sink.fold()).run(runtime).wait() == 9
assert source.to_mat(datum.Sink.fold_sum(10)).run(runtime).wait() == 19
assert source.to_mat(datum.Sink.fold_product()).run(runtime).wait() == 24
assert source.to_mat(datum.Sink.fold_product(2)).run(runtime).wait() == 48
@pytest.mark.parametrize(
("op", "replacement"),
[
("sum", "fold_sum"),
("add", "fold_sum"),
("+", "fold_sum"),
("product", "fold_product"),
("mul", "fold_product"),
("multiply", "fold_product"),
("*", "fold_product"),
],
)
def test_removed_fold_op_names_exact_replacement(op, replacement):
with pytest.raises(datum.BuildError) as error:
datum.Sink.fold(7, op=op)
assert str(error.value) == (
f"fold() no longer accepts op=; use {replacement}(7)"
)
def test_removed_fold_op_unknown_value_names_both_typed_replacements():
with pytest.raises(datum.BuildError) as error:
datum.Sink.fold(7, "custom")
assert str(error.value) == (
"fold() no longer accepts op=; use fold_sum(initial) or "
"fold_product(initial)"
)
def test_blueprint_can_be_materialized_twice():
runtime = datum.Runtime()
graph = (
datum.Source.from_iter([1, 2, 3, 4])
.map_add(10)
.drop(1)
.take(2)
.to_mat(datum.Sink.collect())
)
try:
assert graph.run(runtime).wait() == [12, 13]
assert graph.run(runtime).wait() == [12, 13]
finally:
runtime.shutdown()
def test_stream_error_preserves_rust_text():
with datum.Runtime() as runtime:
graph = datum.Source.single(1).map_add(2**63 - 1).to_mat(datum.Sink.collect())
with pytest.raises(datum.StreamError, match="integer overflow in map_add"):
graph.run(runtime).wait()
def test_named_map_and_filter_kernels_build_integer_pipelines():
with datum.Runtime() as runtime:
graph = (
datum.Source.from_iter([1, 2, 3])
.map_add(10)
.filter_greater_than(11)
.to_mat(datum.Sink.collect())
)
assert graph.run(runtime).wait() == [12, 13]
flow = datum.Flow.map_multiply(2).via(datum.Flow.filter_less_or_equal(4))
flow_graph = datum.Source.from_iter([1, 2, 3]).via(flow).to_mat(datum.Sink.collect())
assert flow_graph.run(runtime).wait() == [2, 4]
@pytest.mark.parametrize(
("op", "replacement"),
[
("add", "map_add"),
("+", "map_add"),
("sub", "map_subtract"),
("subtract", "map_subtract"),
("-", "map_subtract"),
("mul", "map_multiply"),
("multiply", "map_multiply"),
("*", "map_multiply"),
],
)
def test_removed_source_map_op_names_exact_replacement(op, replacement):
with pytest.raises(datum.BuildError) as error:
datum.Source.single(1).map_op(op, 3)
assert str(error.value) == (
f"map_op() was removed; use {replacement}(3), map(lambda) for arbitrary "
"Python, or col()-expressions on batch streams"
)
@pytest.mark.parametrize(
("op", "replacement"),
[
("eq", "filter_equal"),
("equal", "filter_equal"),
("==", "filter_equal"),
("ne", "filter_not_equal"),
("not_equal", "filter_not_equal"),
("!=", "filter_not_equal"),
("lt", "filter_less_than"),
("less_than", "filter_less_than"),
("<", "filter_less_than"),
("le", "filter_less_or_equal"),
("less_or_equal", "filter_less_or_equal"),
("<=", "filter_less_or_equal"),
("gt", "filter_greater_than"),
("greater_than", "filter_greater_than"),
(">", "filter_greater_than"),
("ge", "filter_greater_or_equal"),
("greater_or_equal", "filter_greater_or_equal"),
(">=", "filter_greater_or_equal"),
],
)
def test_removed_source_filter_op_names_exact_replacement(op, replacement):
with pytest.raises(datum.BuildError) as error:
datum.Source.single(1).filter_op(op, 3)
assert str(error.value) == (
f"filter_op() was removed; use {replacement}(3), filter(lambda) for arbitrary "
"Python, or col()-expressions on batch streams"
)
def test_removed_flow_string_ops_and_unknown_ops_raise_generic_messages():
with pytest.raises(datum.BuildError) as map_error:
datum.Flow.map_op("python_udf", 1)
assert str(map_error.value) == (
"map_op() was removed; use map_add(), map_subtract(), or map_multiply(), "
"map(lambda) for arbitrary Python, or col()-expressions on batch streams"
)
with pytest.raises(datum.BuildError) as filter_error:
datum.Flow.filter_op("python_udf", 1)
assert str(filter_error.value) == (
"filter_op() was removed; use filter_equal(), filter_not_equal(), "
"filter_less_than(), filter_less_or_equal(), filter_greater_than(), or "
"filter_greater_or_equal(), filter(lambda) for arbitrary Python, or "
"col()-expressions on batch streams"
)
def test_map_filter_and_flat_map_accept_python_callables():
with datum.Runtime() as runtime:
graph = (
datum.Source.from_iter([1, 2, 3, 4, 5])
.map(lambda item: item + 1, batch_size=2)
.filter(lambda item: item > 3, batch_size=2)
.flat_map(lambda item: [item, item * 10], batch_size=2)
.to_mat(datum.Sink.collect())
)
assert graph.run(runtime).wait() == [4, 40, 5, 50, 6, 60]
flow = datum.Flow.map(lambda item: item * 2, batch_size=3).via(
datum.Flow.filter(lambda item: item >= 4, batch_size=3)
)
flow = flow.via(datum.Flow.flat_map(lambda item: (item, item + 1), batch_size=3))
assert (
datum.Source.from_iter([1, 2, 3])
.via(flow)
.to_mat(datum.Sink.collect())
.run(runtime)
.wait()
) == [4, 5, 6, 7]
source = datum.Source.single(1)
assert not hasattr(source, "map_arithmetic")
assert not hasattr(source, "filter_compare")
assert not hasattr(datum.Flow, "map_arithmetic")
assert not hasattr(datum.Flow, "filter_compare")
def test_callable_type_contract_failures_preserve_traceback_text():
with datum.Runtime() as runtime:
with pytest.raises(datum.StreamError) as map_error:
(
datum.Source.single(1)
.map(lambda item: "not an int")
.to_mat(datum.Sink.collect())
.run(runtime)
.wait()
)
assert "Traceback" in str(map_error.value)
assert "map callable result must be int" in str(map_error.value)
with pytest.raises(datum.StreamError) as filter_error:
(
datum.Source.single(1)
.filter(lambda item: 1)
.to_mat(datum.Sink.collect())
.run(runtime)
.wait()
)
assert "filter callable must return bool" in str(filter_error.value)
with pytest.raises(datum.StreamError) as flat_map_error:
(
datum.Source.single(1)
.flat_map(lambda item: 1)
.to_mat(datum.Sink.collect())
.run(runtime)
.wait()
)
assert "flat_map callable must return an iterable of int" in str(
flat_map_error.value
)
def test_build_errors_use_datum_exception_hierarchy():
assert issubclass(datum.BuildError, datum.DatumError)
assert issubclass(datum.StreamError, datum.DatumError)
def test_generic_runtime_aliases_are_subscriptable():
assert datum.Source[int] is datum.Source
assert datum.Flow[int, int] is datum.Flow
assert datum.Sink[int, list[int]] is datum.Sink
assert datum.RunnableGraph[int] is datum.RunnableGraph
assert datum.StreamCompletion[int] is datum.StreamCompletion
assert datum.Inlet[int] is datum.Inlet
assert datum.Outlet[int] is datum.Outlet
def test_stream_completion_wait_is_one_shot():
with datum.Runtime() as runtime:
completion = datum.Source.single(1).to_mat(datum.Sink.collect()).run(runtime)
assert completion.wait() == [1]
with pytest.raises(datum.BuildError) as error:
completion.wait()
assert "already consumed" in str(error.value)
assert "cached result reuse is intentionally unsupported" in str(error.value)
def test_runtime_context_manager_shuts_down():
runtime = datum.Runtime()
with runtime as entered:
assert entered is runtime
assert not runtime.is_shutdown()
assert runtime.is_shutdown()
def test_wait_releases_gil_while_rust_runs():
progress = 0
running = True
def spin():
nonlocal progress
while running:
progress += 1
time.sleep(0)
worker = threading.Thread(target=spin)
worker.start()
with datum.Runtime() as runtime:
graph = (
datum.Source.range(0, 20_000_000)
.map_multiply(3)
.filter_greater_than(20_000_000)
.to_mat(datum.Sink.fold())
)
completion = graph.run(runtime)
before = progress
assert completion.wait() == native._rust_pipeline_sum(20_000_000)
after = progress
running = False
worker.join(timeout=5)
assert after > before
def test_for_each_count_and_flow_via():
with datum.Runtime() as runtime:
flow = datum.Flow.map_add(1).via(datum.Flow.filter_greater_than(2))
graph = datum.Source.from_iter([1, 2, 3]).via(flow).to_mat(datum.Sink.for_each_count())
assert graph.run(runtime).wait() == 2
def test_cookbook_python_pipeline_recipe():
# #region cookbook-python-pipeline
import datum
with datum.Runtime() as runtime:
graph = (
datum.Source.from_iter([1, 2, 3, 4])
.map(lambda item: item + 10)
.filter(lambda item: item > 12)
.to_mat(datum.Sink.collect())
)
result = graph.run(runtime).wait()
print(result)
assert result == [13, 14]
# #endregion cookbook-python-pipelineExpected output:
text
[13, 14]Watch: the callable tier is ergonomic, not the fastest path. Use named Rust kernels such as
map_add/filter_greater_thanfor hot integer arithmetic and comparisons, ormap_batcheswith PyArrow kernels for vectorized Python work. UseSink.fold_sum()orSink.fold_product()for named arithmetic terminals, andPartitionStrategy.MODULOwhen a GraphDSL partition strategy is explicit.
Run a vectorized Arrow UDF from Python
Problem: transform Arrow batches with a vectorized Python function and surface Python failures as clean Datum stream errors.
Source.from_arrow(...).map_batches(udf, output_schema=...) passes whole pyarrow.RecordBatch values across the boundary. Declare the output schema up front; Datum validates the first returned batch and preserves Python traceback text when a UDF raises.
python
import threading
import time
import pytest
import datum
import datum._native as native
pa = pytest.importorskip("pyarrow")
pc = pytest.importorskip("pyarrow.compute")
def batch(values=(1, 2, 3, 4)):
ids = pa.array(values, type=pa.int64())
offsets = pa.array([value * 10 for value in values], type=pa.int64())
return pa.record_batch([ids, offsets], names=["id", "offset"])
def one_column_schema(name="id"):
return pa.schema([(name, pa.int64())])
def empty_batch(schema=None):
schema = schema or one_column_schema()
arrays = [pa.array([], type=field.type) for field in schema]
return pa.record_batch(arrays, schema=schema)
def buffer_address(record_batch, column=0, buffer=1):
return record_batch.column(column).buffers()[buffer].address
def test_source_from_table_reader_and_iterator_count_rows():
first = batch((1, 2))
second = batch((3, 4, 5))
with datum.Runtime() as runtime:
table = pa.Table.from_batches([first, second])
assert datum.Source.from_arrow(table).count_rows(runtime) == 5
reader = pa.RecordBatchReader.from_batches(first.schema, [first, second])
assert datum.Source.from_arrow(reader).count_rows(runtime) == 5
assert datum.Source.from_arrow(iter([first, second])).count_rows(runtime) == 5
def test_source_from_arrow_empty_input_requires_declared_schema():
schema = one_column_schema()
with pytest.raises(datum.BuildError, match="schema is required for an empty Arrow batch iterator"):
datum.Source.from_arrow([])
with pytest.raises(datum.BuildError, match="schema is required for empty Arrow input"):
datum.Source.from_arrow(empty_batch(schema))
with pytest.raises(datum.BuildError, match="schema is required for empty Arrow input"):
datum.Source.from_arrow(pa.Table.from_batches([empty_batch(schema)]))
source = datum.Source.from_arrow([], schema=schema)
assert source.schema() == schema
with datum.Runtime() as runtime:
assert source.count_rows(runtime) == 0
def test_source_from_arrow_declared_schema_validates_at_build_time():
source_batch = batch()
declared = one_column_schema()
with pytest.raises(datum.BuildError, match="input Arrow object schema mismatch"):
datum.Source.from_arrow(source_batch, schema=declared)
mismatched = pa.record_batch(
[pa.array([1, 2], type=pa.int64())],
names=["other"],
)
with pytest.raises(datum.BuildError, match="input Arrow batch schema mismatch"):
datum.Source.from_arrow([batch((1, 2)), mismatched])
def test_identity_udf_round_trips_zero_copy():
source_batch = batch()
with datum.Runtime() as runtime:
result = (
datum.Source.from_arrow(source_batch)
.map_batches(lambda incoming: incoming, output_schema=source_batch.schema)
.run_collect(runtime)
)
result_batch = result.to_batches()[0]
assert result_batch.to_pydict() == source_batch.to_pydict()
assert buffer_address(result_batch) == buffer_address(source_batch)
input_buffers, output_buffers = native._debug_arrow_udf_return_buffer_addresses(
source_batch,
lambda incoming: incoming,
source_batch.schema,
identity_fast_path=False,
)
assert output_buffers == input_buffers
def test_projection_udf_declares_and_validates_output_schema():
source_batch = batch()
output_schema = one_column_schema()
def project_id(incoming):
return pa.record_batch([incoming.column(0)], schema=output_schema)
with datum.Runtime() as runtime:
result = (
datum.Source.from_arrow(source_batch)
.map_batches(project_id, output_schema=output_schema)
.run_collect(runtime)
)
assert result.schema == output_schema
assert result.to_pydict() == {"id": [1, 2, 3, 4]}
assert buffer_address(result.to_batches()[0]) == buffer_address(source_batch)
input_buffers, output_buffers = native._debug_arrow_udf_return_buffer_addresses(
source_batch,
project_id,
output_schema,
)
assert output_buffers == input_buffers[:1]
def test_batch_schema_propagates_through_sources_and_flows():
source_batch = batch()
projected_schema = one_column_schema()
doubled_schema = pa.schema([("doubled", pa.int64())])
source = datum.Source.from_arrow(source_batch)
assert source.schema() == source_batch.schema
flow = datum.Flow.map_batches(
lambda incoming: pa.record_batch([incoming.column(0)], schema=projected_schema),
output_schema=projected_schema,
)
assert flow.schema() == projected_schema
chained_flow = flow.then_map_batches(
lambda incoming: pa.record_batch(
[pc.multiply(incoming.column(0), pa.scalar(2, type=pa.int64()))],
schema=doubled_schema,
),
output_schema=doubled_schema,
)
assert chained_flow.schema() == doubled_schema
assert source.via(chained_flow).schema() == doubled_schema
def test_map_batches_requires_declared_output_schema_at_build_time():
source_batch = batch()
source = datum.Source.from_arrow(source_batch)
with pytest.raises(datum.BuildError, match="requires declared output_schema"):
source.map_batches(lambda incoming: incoming)
with pytest.raises(datum.BuildError, match="requires declared output_schema"):
datum.Flow.map_batches(lambda incoming: incoming)
flow = datum.Flow.map_batches(
lambda incoming: incoming,
output_schema=source_batch.schema,
)
with pytest.raises(datum.BuildError, match="requires declared output_schema"):
flow.then_map_batches(lambda incoming: incoming)
def test_compute_kernel_udf_uses_arrow_batches():
source_batch = batch()
output_schema = one_column_schema("total")
def add_columns(incoming):
total = pc.add(incoming.column(0), incoming.column(1))
return pa.record_batch([total], schema=output_schema)
with datum.Runtime() as runtime:
result = (
datum.Source.from_arrow(source_batch)
.map_batches(add_columns, output_schema=output_schema)
.run_collect(runtime)
)
assert result.to_pydict() == {"total": [11, 22, 33, 44]}
def test_udf_return_schema_mismatch_fails_stream_at_first_batch():
source_batch = batch()
output_schema = one_column_schema()
stream = datum.Source.from_arrow(source_batch).map_batches(
lambda incoming: incoming,
output_schema=output_schema,
)
with datum.Runtime() as runtime:
with pytest.raises(datum.StreamError, match="schema mismatch"):
stream.count_rows(runtime)
def test_batch_source_via_rejects_non_batch_flow_at_build_call():
source = datum.Source.from_arrow(batch())
with pytest.raises(TypeError):
source.via(datum.Flow.map_add(1))
def test_udf_exception_preserves_python_traceback_text():
first = batch((1,))
second = batch((2,))
calls = 0
def fail_on_second(incoming):
nonlocal calls
calls += 1
if calls == 2:
raise ValueError("mid-stream marker")
return incoming
stream = datum.Source.from_arrow([first, second]).map_batches(
fail_on_second,
output_schema=first.schema,
)
assert calls == 0
with datum.Runtime() as runtime:
with pytest.raises(datum.StreamError) as raised:
stream.count_rows(runtime)
message = str(raised.value)
assert "Traceback" in message
assert "ValueError: mid-stream marker" in message
def test_batch_udf_blueprint_can_be_materialized_twice():
source_batch = batch()
stream = datum.Source.from_arrow(source_batch).map_batches(
lambda incoming: incoming,
output_schema=source_batch.schema,
)
with datum.Runtime() as runtime:
assert stream.count_rows(runtime) == 4
assert stream.count_rows(runtime) == 4
def test_flow_map_batches_factory_and_batch_sink_terminals():
source_batch = batch()
output_schema = one_column_schema()
flow = datum.Flow.map_batches(
lambda incoming: pa.record_batch([incoming.column(0)], schema=output_schema),
output_schema=output_schema,
)
source = datum.Source.from_arrow(source_batch).via(flow)
with datum.Runtime() as runtime:
assert source.to_mat(datum.BatchSink.count_batches()).run(runtime).wait() == 1
assert source.to_mat(datum.Sink.arrow_count_rows()).run(runtime).wait() == 4
table = source.to_mat(datum.Sink.arrow_collect()).run(runtime).wait()
assert table.to_pydict() == {"id": [1, 2, 3, 4]}
def test_wait_releases_gil_around_udf_stream_execution():
batches = [batch((idx, idx + 1, idx + 2, idx + 3)) for idx in range(256)]
stream = datum.Source.from_arrow(batches).map_batches(
lambda incoming: incoming,
output_schema=batches[0].schema,
)
progress = 0
running = True
def spin():
nonlocal progress
while running:
progress += 1
time.sleep(0)
worker = threading.Thread(target=spin)
worker.start()
try:
with datum.Runtime() as runtime:
completion = stream.to_mat(datum.BatchSink.count_rows()).run(runtime)
before = progress
assert completion.wait() == 1024
after = progress
finally:
running = False
worker.join(timeout=5)
assert after > before
def test_native_rust_arrow_benchmark_helpers():
result, wall_ns, cpu_ns, rss_kb = native._bench_arrow_rust_operator(
batch(), "identity", 1
)
assert result == 4
assert wall_ns > 0
assert cpu_ns >= 0
assert rss_kb > 0
compute_result, compute_wall_ns, compute_cpu_ns, compute_rss_kb = (
native._bench_arrow_rust_operator(batch(), "compute", 1)
)
assert compute_result == 4
assert compute_wall_ns > 0
assert compute_cpu_ns >= 0
assert compute_rss_kb > 0
def test_native_python_arrow_benchmark_buckets():
result = native._bench_arrow_python_udf(
batch(),
lambda incoming: incoming,
batch().schema,
1,
)
rows, wall_ns, _cpu_ns, rss_kb, batches, gil_ns, export_ns, call_ns, import_ns, validate_ns = result
assert rows == 4
assert wall_ns > 0
assert rss_kb > 0
assert batches == 1
assert gil_ns > 0
assert export_ns > 0
assert call_ns > 0
assert import_ns >= 0
assert validate_ns > 0
def test_polars_arrow_capsule_interop_if_available():
pl = pytest.importorskip("polars")
frame = pl.DataFrame({"id": [1, 2], "offset": [10, 20]})
with datum.Runtime() as runtime:
assert datum.Source.from_arrow(frame).count_rows(runtime) == 2
def test_duckdb_arrow_capsule_interop_if_available():
duckdb = pytest.importorskip("duckdb")
reader = duckdb.sql("select 1::bigint as id, 10::bigint as offset").arrow()
with datum.Runtime() as runtime:
assert datum.Source.from_arrow(reader).count_rows(runtime) == 1
def test_cookbook_arrow_udf_recipe():
# #region cookbook-python-arrow-udf
import pytest
import pyarrow as pa
import pyarrow.compute as pc
import datum
source_batch = pa.record_batch(
[
pa.array([1, 2, 3, 4], type=pa.int64()),
pa.array([10, 20, 30, 40], type=pa.int64()),
],
names=["id", "offset"],
)
output_schema = pa.schema([("total", pa.int64())])
def add_columns(incoming):
total = pc.add(incoming.column(0), incoming.column(1))
return pa.record_batch([total], schema=output_schema)
with datum.Runtime() as runtime:
result = (
datum.Source.from_arrow(source_batch)
.map_batches(add_columns, output_schema=output_schema)
.run_collect(runtime)
)
print(result.to_pydict())
assert result.to_pydict() == {"total": [11, 22, 33, 44]}
def fail_cleanly(_incoming):
raise ValueError("bad batch marker")
failing = datum.Source.from_arrow(source_batch).map_batches(
fail_cleanly,
output_schema=source_batch.schema,
)
with datum.Runtime() as runtime:
with pytest.raises(datum.StreamError, match="ValueError: bad batch marker"):
failing.count_rows(runtime)
# #endregion cookbook-python-arrow-udfExpected output:
text
{'total': [11, 22, 33, 44]}Watch:
map_batchesis the vectorized tier. Scalar Python callables are accepted bymap/filter/flat_map, but they still run as Arrow UDF batches and keep the per-element loop in Python.
Run a Python UDF remotely over zstd
Problem: run a Python Arrow UDF on a remote Datum worker pool, authenticate with a token, and choose a wire format for a constrained link.
Start a connect server with udf_workers, connect with the server token, and run the same map_batches graph remotely. For slow links, prefer arrow-ipc-zstd: the PY-3c wire-format A/B records it as the remote/constrained default, with the best rows/s on both 100 Mbit/s rows (benchmark record).
python
import os
import time
import pytest
import datum
import datum._native as native
pa = pytest.importorskip("pyarrow")
pc = pytest.importorskip("pyarrow.compute")
# The default published wheel is TCP-only; QUIC requires a source build with the
# `quic` feature. Skip QUIC round-trip tests cleanly when it is not compiled in.
quic_only = pytest.mark.skipif(
not native._connect_quic_supported(),
reason="datum-stream built without QUIC support (TCP-only wheel)",
)
def batch(values=(1, 2, 3, 4)):
left = pa.array(values, type=pa.int64())
right = pa.array([value * 10 for value in values], type=pa.int64())
return pa.record_batch([left, right], names=["left", "right"])
def large_batch(rows=65_536):
left = pa.array(range(rows), type=pa.int64())
right = pa.array(range(rows, rows * 2), type=pa.int64())
return pa.record_batch([left, right], names=["left", "right"])
def wait_until(predicate, timeout=5.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return
time.sleep(0.01)
assert predicate()
def connect_pair(udf_workers=1):
server = datum.connect.serve(udf_workers=udf_workers)
session = datum.connect.connect(server.addr, token=server.token)
return server, session
def require_udf_shm_root(tmp_path, server, session):
roots = list(tmp_path.glob("datum-connect-udf-*"))
if not roots:
session.close()
server.close()
pytest.skip("datum-py was built without the default-off udf-shm feature")
assert len(roots) == 1
return roots[0]
WIRE_FORMATS = ["arrow-ipc", "arrow-ipc-lz4", "arrow-ipc-zstd", "parquet"]
def balance_merge_graph():
def build(builder):
balance = builder.add(datum.Balance(2))
merge = builder.add(datum.Merge(2))
builder.connect(balance.outlet(0), merge.inlet(0))
builder.connect(balance.outlet(1), merge.inlet(1))
return datum.FlowShape(balance.inlet(), merge.outlet())
return datum.GraphDsl.create(build)
def broadcast_zip_graph():
def build(builder):
broadcast = builder.add(datum.Broadcast(2))
zip_stage = builder.add(datum.Zip())
builder.connect(broadcast.outlet(0), zip_stage.in0())
builder.connect(broadcast.outlet(1), zip_stage.in1())
return datum.FlowShape(broadcast.inlet(), zip_stage.outlet())
return datum.GraphDsl.create(build)
def typed_partition_merge_graph():
def build(builder):
partition = builder.add(
datum.Partition(2, strategy=datum.PartitionStrategy.MODULO)
)
merge = builder.add(datum.Merge(2))
builder.connect(partition.outlet(0), merge.inlet(0))
builder.connect(partition.outlet(1), merge.inlet(1))
return datum.FlowShape(partition.inlet(), merge.outlet())
return datum.GraphDsl.create(build)
def test_named_kernel_connect_plan_round_trips_and_matches_typed_enum_encoding():
assert native._debug_named_kernel_connect_plans_match()
server, session = connect_pair()
try:
graph = (
datum.Source.range(0, 20)
.via(datum.Flow.map_multiply(3))
.filter_greater_or_equal(21)
.take(5)
.to_mat(datum.Sink.collect())
)
with datum.Runtime() as runtime:
expected = graph.run(runtime).wait()
assert session.run(graph) == expected
assert session.run(graph) == expected
finally:
session.close()
server.close()
def test_typed_partition_and_fold_product_round_trip_over_connect():
graph = (
datum.Source.range(1, 6)
.via_graph(typed_partition_merge_graph())
.to_mat(datum.Sink.fold_product())
)
with datum.Runtime() as runtime:
expected = graph.run(runtime).wait()
assert expected == 120
server, session = connect_pair()
try:
assert session.run(graph) == expected
finally:
session.close()
server.close()
def test_connect_linear_callable_lambdas_match_in_process_collect():
server, session = connect_pair(udf_workers=2)
try:
graph = (
datum.Source.range(0, 20)
.map(lambda item: item + 1, batch_size=4)
.filter(lambda item: item % 3 == 0, batch_size=4)
.flat_map(lambda item: [item, -item], batch_size=4)
.take(8)
.to_mat(datum.Sink.collect())
)
with datum.Runtime() as runtime:
expected = graph.run(runtime).wait()
assert session.run(graph) == expected
finally:
session.close()
server.close()
def test_connect_junction_graph_matches_in_process_result():
server, session = connect_pair()
try:
graph = (
datum.Source.range(0, 12)
.via_graph(balance_merge_graph())
.to_mat(datum.Sink.collect())
)
with datum.Runtime() as runtime:
expected = graph.run(runtime).wait()
assert session.run(graph) == expected
finally:
session.close()
server.close()
def test_connect_broadcast_zip_tuple_flow_matches_in_process():
server, session = connect_pair()
try:
graph = broadcast_zip_graph()
values = [1, 2, 3, 4]
expected = graph.run_with_input(values)
assert session.run_with_input(graph, values) == expected
finally:
session.close()
server.close()
def test_connect_zip_shape_uneven_inputs_matches_in_process():
server, session = connect_pair()
try:
graph = datum.GraphDsl.create(lambda builder: builder.add(datum.Zip()))
left = [1, 2, 3]
right = [10]
expected = graph.run_zip(left, right)
assert session.run_zip(graph, left, right) == expected
finally:
session.close()
server.close()
def test_connect_concat_ordering_matches_in_process():
server, session = connect_pair()
try:
graph = datum.GraphDsl.create(lambda builder: builder.add(datum.Concat(3)))
inputs = [[1, 2], [], [3, 4]]
expected = graph.run_concat(inputs)
assert session.run_concat(graph, inputs) == expected
finally:
session.close()
server.close()
def test_connect_interleave_segment_ordering_matches_in_process():
server, session = connect_pair()
try:
graph = datum.GraphDsl.create(
lambda builder: builder.add(datum.Interleave(3, segment_size=2))
)
inputs = [[1, 2, 3], [10, 11, 12], [20]]
expected = graph.run_interleave(inputs)
assert session.run_interleave(graph, inputs) == expected
finally:
session.close()
server.close()
def test_connect_arrow_batch_pipeline_streams_arrow_results():
server, session = connect_pair()
try:
first = batch((1, 2))
second = batch((3, 4, 5))
graph = datum.Source.from_arrow([first, second]).to_mat(datum.BatchSink.collect())
with datum.Runtime() as runtime:
expected = graph.run(runtime).wait().to_pydict()
assert session.run(graph).to_pydict() == expected
finally:
session.close()
server.close()
def test_connect_arrow_batch_udfs_match_in_process_results():
server, session = connect_pair(udf_workers=2)
try:
source_batch = batch()
projection_schema = pa.schema([("left", pa.int64())])
compute_schema = pa.schema([("total", pa.int64())])
def identity(incoming):
return incoming
def project_left(incoming):
return incoming.select([0])
def add_columns(incoming):
total = pc.add(incoming.column(0), incoming.column(1))
return pa.record_batch([total], schema=compute_schema)
scenarios = [
(identity, source_batch.schema),
(project_left, projection_schema),
(add_columns, compute_schema),
]
with datum.Runtime() as runtime:
for udf, output_schema in scenarios:
graph = (
datum.Source.from_arrow(source_batch)
.map_batches(udf, output_schema=output_schema)
.to_mat(datum.BatchSink.collect())
)
expected = graph.run(runtime).wait().to_pydict()
assert session.run(graph).to_pydict() == expected
finally:
session.close()
server.close()
def test_connect_python_udf_shared_memory_mmap_round_trip_and_cleanup(
monkeypatch, tmp_path
):
source_batch = large_batch()
graph = (
datum.Source.from_arrow(source_batch)
.map_batches(lambda incoming: incoming, output_schema=source_batch.schema)
.to_mat(datum.BatchSink.collect())
)
monkeypatch.setenv("DATUM_CONNECT_UDF_SHM", "off")
pipe_server, pipe_session = connect_pair()
try:
pipe_result = pipe_session.run(graph).to_pydict()
finally:
pipe_session.close()
pipe_server.close()
monkeypatch.setenv("DATUM_CONNECT_UDF_SHM", "on")
monkeypatch.setenv("DATUM_CONNECT_UDF_SHM_DIR", str(tmp_path))
monkeypatch.setenv("DATUM_CONNECT_UDF_SHM_THRESHOLD_BYTES", "1")
server, session = connect_pair()
root = require_udf_shm_root(tmp_path, server, session)
try:
assert session.run(graph).to_pydict() == pipe_result
assert list(root.rglob("*.arrow")) == []
finally:
session.close()
server.close()
assert list(tmp_path.glob("datum-connect-udf-*")) == []
def test_connect_python_udf_shared_memory_unavailable_falls_back_to_pipe(
monkeypatch, tmp_path
):
unavailable = tmp_path / "not-a-directory"
unavailable.write_text("blocks directory creation", encoding="utf-8")
monkeypatch.setenv("DATUM_CONNECT_UDF_SHM", "on")
monkeypatch.setenv("DATUM_CONNECT_UDF_SHM_DIR", str(unavailable))
monkeypatch.setenv("DATUM_CONNECT_UDF_SHM_THRESHOLD_BYTES", "1")
source_batch = large_batch()
server, session = connect_pair()
try:
graph = (
datum.Source.from_arrow(source_batch)
.map_batches(lambda incoming: incoming, output_schema=source_batch.schema)
.to_mat(datum.BatchSink.count_rows())
)
assert session.run(graph) == source_batch.num_rows
finally:
session.close()
server.close()
def test_connect_python_udf_shared_output_overflow_retries_inline(
monkeypatch, tmp_path
):
monkeypatch.setenv("DATUM_CONNECT_UDF_SHM", "on")
monkeypatch.setenv("DATUM_CONNECT_UDF_SHM_DIR", str(tmp_path))
monkeypatch.setenv("DATUM_CONNECT_UDF_SHM_THRESHOLD_BYTES", "1")
source_batch = large_batch()
output_schema = pa.schema(
[(f"value_{index}", pa.int64()) for index in range(6)]
)
def expand(incoming):
columns = [incoming.column(index % 2) for index in range(6)]
return pa.record_batch(columns, schema=output_schema)
server, session = connect_pair()
root = require_udf_shm_root(tmp_path, server, session)
try:
graph = (
datum.Source.from_arrow(source_batch)
.map_batches(expand, output_schema=output_schema)
.to_mat(datum.BatchSink.count_rows())
)
assert session.run(graph) == source_batch.num_rows
assert list(root.rglob("*.arrow")) == []
finally:
session.close()
server.close()
def test_connect_python_udf_worker_crash_reclaims_shared_segments(
monkeypatch, tmp_path
):
monkeypatch.setenv("DATUM_CONNECT_UDF_SHM", "on")
monkeypatch.setenv("DATUM_CONNECT_UDF_SHM_DIR", str(tmp_path))
monkeypatch.setenv("DATUM_CONNECT_UDF_SHM_THRESHOLD_BYTES", "1")
orphan = tmp_path / "datum-connect-udf-4294967294-1-orphan"
orphan.mkdir()
(orphan / "output-1.arrow").write_bytes(b"orphan")
source_batch = large_batch()
server, session = connect_pair()
roots = list(tmp_path.glob("datum-connect-udf-*"))
if len(roots) == 1 and orphan.exists():
session.close()
server.close()
pytest.skip("datum-py was built without the default-off udf-shm feature")
assert len(roots) == 1
root = roots[0]
assert not orphan.exists()
try:
def crash(incoming):
import os
os._exit(77)
crash_graph = (
datum.Source.from_arrow(source_batch)
.map_batches(crash, output_schema=source_batch.schema)
.to_mat(datum.BatchSink.count_rows())
)
with pytest.raises(datum.StreamError, match="died while executing batch"):
session.run(crash_graph)
assert list(root.rglob("*.arrow")) == []
recovery_graph = (
datum.Source.from_arrow(source_batch)
.map_batches(lambda incoming: incoming, output_schema=source_batch.schema)
.to_mat(datum.BatchSink.count_rows())
)
assert session.run(recovery_graph) == source_batch.num_rows
finally:
session.close()
server.close()
@pytest.mark.parametrize("wire_format", WIRE_FORMATS)
def test_connect_wire_formats_match_arrow_ipc_for_identity_and_compute(wire_format):
server, session = connect_pair(udf_workers=2)
try:
source_batch = batch()
compute_schema = pa.schema([("total", pa.int64())])
def identity(incoming):
return incoming
def add_columns(incoming):
total = pc.add(incoming.column(0), incoming.column(1))
return pa.record_batch([total], schema=compute_schema)
scenarios = [
(identity, source_batch.schema),
(add_columns, compute_schema),
]
with datum.Runtime() as runtime:
for udf, output_schema in scenarios:
graph = (
datum.Source.from_arrow(source_batch)
.map_batches(udf, output_schema=output_schema)
.to_mat(datum.BatchSink.collect())
)
expected = graph.run(runtime).wait().to_pydict()
assert session.run(graph, wire_format=wire_format).to_pydict() == expected
finally:
session.close()
server.close()
def test_connect_unavailable_wire_format_rejects_cleanly():
server = datum.connect.serve(udf_workers=1, wire_formats=["arrow-ipc"])
try:
with pytest.raises(datum.StreamError, match="no mutually supported wire format"):
datum.connect.connect(
server.addr,
token=server.token,
wire_formats=["parquet"],
)
finally:
server.close()
def test_connect_wire_format_fallback_and_unnegotiated_override():
server = datum.connect.serve(udf_workers=1, wire_formats=["arrow-ipc"])
session = None
try:
session = datum.connect.connect(
server.addr,
token=server.token,
wire_formats=["parquet", "arrow-ipc"],
)
graph = datum.Source.from_arrow(batch()).to_mat(datum.BatchSink.count_rows())
assert session.default_wire_format == "arrow-ipc"
assert session.negotiated_wire_formats() == ["arrow-ipc"]
assert session.run(graph) == 4
with pytest.raises(datum.StreamError, match="not negotiated"):
session.run(graph, wire_format="parquet")
finally:
try:
if session is not None:
session.close()
except Exception:
pass
server.close()
def test_connect_python_udf_exception_preserves_traceback_text():
server, session = connect_pair()
try:
source_batch = batch()
def fail(incoming):
raise ValueError("remote traceback marker")
graph = (
datum.Source.from_arrow(source_batch)
.map_batches(fail, output_schema=source_batch.schema)
.to_mat(datum.BatchSink.count_rows())
)
with pytest.raises(datum.StreamError) as raised:
session.run(graph)
message = str(raised.value)
assert "Traceback" in message
assert "ValueError: remote traceback marker" in message
finally:
session.close()
server.close()
def test_connect_python_udf_worker_crash_is_contained_and_pool_recovers():
server, session = connect_pair()
try:
source_batch = batch()
def crash(incoming):
import os
os._exit(77)
crash_graph = (
datum.Source.from_arrow(source_batch)
.map_batches(crash, output_schema=source_batch.schema)
.to_mat(datum.BatchSink.count_rows())
)
with pytest.raises(datum.StreamError) as raised:
session.run(crash_graph)
message = str(raised.value)
assert "Python UDF worker" in message
assert "died while executing batch" in message
recovery_graph = (
datum.Source.from_arrow(source_batch)
.map_batches(lambda incoming: incoming, output_schema=source_batch.schema)
.to_mat(datum.BatchSink.count_rows())
)
assert session.run(recovery_graph) == source_batch.num_rows
finally:
session.close()
server.close()
def test_connect_python_udf_is_reused_across_executions(tmp_path):
server, session = connect_pair()
try:
marker = tmp_path / "unpickle-count.txt"
source_batch = batch()
class ReuseProbe:
def __init__(self, path):
self.path = str(path)
def __setstate__(self, state):
self.__dict__.update(state)
with open(self.path, "a", encoding="utf-8") as handle:
handle.write("load\n")
def __call__(self, incoming):
return incoming
graph = (
datum.Source.from_arrow(source_batch)
.map_batches(ReuseProbe(marker), output_schema=source_batch.schema)
.to_mat(datum.BatchSink.count_rows())
)
assert session.run(graph) == source_batch.num_rows
assert session.run(graph) == source_batch.num_rows
assert marker.read_text(encoding="utf-8").splitlines() == ["load"]
finally:
session.close()
server.close()
def test_connect_python_udf_cpu_bound_work_scales_with_worker_pool():
if usable_cpu_count() < 4:
pytest.skip("parallelism proof needs at least four schedulable CPUs")
batches = [batch((idx, idx + 1)) for idx in range(0, 16, 2)]
def cpu_bound(incoming):
acc = 0x9E3779B9
for idx in range(1_200_000):
acc = ((acc ^ idx) * 16_777_619) & 0xFFFFFFFF
if acc == -1:
raise AssertionError("unreachable")
return incoming
one_worker = timed_remote_count(1, batches, cpu_bound)
four_workers = timed_remote_count(4, batches, cpu_bound)
assert one_worker / four_workers > 2.0
def usable_cpu_count():
if hasattr(os, "sched_getaffinity"):
return len(os.sched_getaffinity(0))
return os.cpu_count() or 1
def timed_remote_count(udf_workers, batches, udf):
server, session = connect_pair(udf_workers=udf_workers)
try:
graph = (
datum.Source.from_arrow(batches)
.map_batches(udf, output_schema=batches[0].schema)
.to_mat(datum.BatchSink.count_rows())
)
started = time.perf_counter()
assert session.run(graph) == sum(batch.num_rows for batch in batches)
return time.perf_counter() - started
finally:
session.close()
server.close()
def test_connect_bad_token_rejected_before_execute():
server = datum.connect.serve(udf_workers=1)
try:
with pytest.raises(datum.StreamError, match="bad token"):
datum.connect.connect(server.addr, token="wrong")
finally:
server.close()
def test_connect_cancel_mid_stream_cleans_execution():
server, session = connect_pair()
try:
batches = [batch((idx, idx + 1)) for idx in range(0, 20, 2)]
graph = datum.Source.from_arrow(batches).to_mat(datum.BatchSink.collect())
execution = session.start(graph, initial_demand=1)
first = execution.next_batch()
assert first.num_rows == 2
assert server.active_executions() >= 1
execution.cancel()
wait_until(lambda: server.active_executions() == 0)
finally:
session.close()
server.close()
def test_connect_client_disconnect_cleans_server_execution():
server, session = connect_pair()
try:
batches = [batch((idx, idx + 1)) for idx in range(0, 20, 2)]
graph = datum.Source.from_arrow(batches).to_mat(datum.BatchSink.collect())
session.start(graph, initial_demand=0)
wait_until(lambda: server.active_executions() >= 1)
session.close()
wait_until(lambda: server.active_executions() == 0)
finally:
server.close()
def test_connect_plan_version_mismatch_is_clean_error():
server, session = connect_pair()
try:
graph = datum.Source.from_iter([1, 2, 3]).to_mat(datum.Sink.collect())
with pytest.raises(datum.StreamError, match="unsupported Datum plan version"):
session._debug_run_with_plan_version(graph, 999)
finally:
session.close()
server.close()
def test_cookbook_remote_udf_zstd_recipe():
# #region cookbook-python-remote-zstd
import pyarrow as pa
import pyarrow.compute as pc
import datum
server = datum.connect.serve(
udf_workers=4,
wire_formats=["arrow-ipc-zstd", "arrow-ipc"],
)
session = datum.connect.connect(
server.addr,
token=server.token,
wire_formats=["arrow-ipc-zstd", "arrow-ipc"],
)
try:
batch = pa.record_batch(
[
pa.array([1, 2, 3, 4], type=pa.int64()),
pa.array([10, 20, 30, 40], type=pa.int64()),
],
names=["left", "right"],
)
output_schema = pa.schema([("total", pa.int64())])
def add_columns(incoming):
total = pc.add(incoming.column(0), incoming.column(1))
return pa.record_batch([total], schema=output_schema)
graph = (
datum.Source.from_arrow(batch)
.map_batches(add_columns, output_schema=output_schema)
.to_mat(datum.BatchSink.collect())
)
result = session.run(graph, wire_format="arrow-ipc-zstd")
print(result.to_pydict())
assert session.default_wire_format == "arrow-ipc-zstd"
assert result.to_pydict() == {"total": [11, 22, 33, 44]}
finally:
session.close()
server.close()
# #endregion cookbook-python-remote-zstd
def test_connect_new_junction_graph_old_plan_version_rejects_cleanly():
server, session = connect_pair()
try:
graph = broadcast_zip_graph()
with pytest.raises(datum.StreamError, match="unsupported Datum plan version"):
session._debug_run_with_input_plan_version(graph, [1, 2, 3], 1)
finally:
session.close()
server.close()
def test_connect_orderly_close_keeps_stderr_clean(capfd):
server, session = connect_pair()
try:
graph = datum.Source.range(0, 4).to_mat(datum.Sink.collect())
assert session.run(graph) == [0, 1, 2, 3]
capfd.readouterr()
session.close()
server.close()
captured = capfd.readouterr()
assert captured.err == ""
finally:
try:
session.close()
except Exception:
pass
server.close()
def quic_connect_pair(udf_workers=1):
"""A serve/connect pair over the encrypted QUIC carrier.
The server uses its self-signed localhost dev certificate and the client
skips certificate verification, the localhost dev default.
"""
server = datum.connect.serve(udf_workers=udf_workers, transport="quic")
session = datum.connect.connect(
server.addr, token=server.token, transport="quic"
)
return server, session
@quic_only
def test_connect_quic_round_trip_matches_in_process():
server, session = quic_connect_pair()
try:
graph = (
datum.Source.range(0, 20)
.via(datum.Flow.map_multiply(3))
.filter_greater_or_equal(21)
.take(5)
.to_mat(datum.Sink.collect())
)
with datum.Runtime() as runtime:
expected = graph.run(runtime).wait()
assert session.run(graph) == expected
# A second run reuses the same QUIC session/stream.
assert session.run(graph) == expected
finally:
session.close()
server.close()
@quic_only
def test_connect_quic_arrow_batch_pipeline_streams_arrow_results():
server, session = quic_connect_pair()
try:
first = batch((1, 2))
second = batch((3, 4, 5))
graph = datum.Source.from_arrow([first, second]).to_mat(datum.BatchSink.collect())
with datum.Runtime() as runtime:
expected = graph.run(runtime).wait().to_pydict()
assert session.run(graph).to_pydict() == expected
finally:
session.close()
server.close()
@quic_only
def test_connect_quic_start_demand_cancel_terminal_semantics():
server, session = quic_connect_pair()
try:
graph = datum.Source.range(0, 100).to_mat(datum.Sink.collect())
# Start without draining, then cancel: the Cancel/Ack round-trip must
# complete cleanly over QUIC exactly as it does over TCP.
execution = session.start(graph, initial_demand=0)
execution.cancel()
finally:
session.close()
server.close()
@quic_only
def test_connect_quic_orderly_close_keeps_stderr_clean(capfd):
server, session = quic_connect_pair()
try:
graph = datum.Source.range(0, 4).to_mat(datum.Sink.collect())
assert session.run(graph) == [0, 1, 2, 3]
capfd.readouterr()
session.close()
server.close()
captured = capfd.readouterr()
assert captured.err == ""
finally:
try:
session.close()
except Exception:
pass
server.close()
@quic_only
def test_connect_quic_bad_token_rejected():
server = datum.connect.serve(udf_workers=1, transport="quic")
try:
with pytest.raises(datum.StreamError):
datum.connect.connect(server.addr, token="wrong", transport="quic")
finally:
server.close()
def test_connect_unknown_transport_rejected():
with pytest.raises(datum.BuildError, match="unknown transport"):
datum.connect.serve(transport="udp")
def test_connect_tcp_rejects_quic_only_options():
with pytest.raises(datum.BuildError):
datum.connect.serve(cert="not-with-tcp")
with pytest.raises(datum.BuildError):
datum.connect.connect("127.0.0.1:1", insecure_skip_verify=True)Expected output:
text
{'total': [11, 22, 33, 44]}Watch: token auth is the trust boundary, not a sandbox. Remote Python UDFs execute client-supplied code in worker subprocesses for isolation and parallelism; only accept trusted clients.
Event-sourced counter with replies
Problem: persist counter changes and reply only after each journal write is confirmed.
Use EventSourcedBehavior::with_enforced_replies so every command branch must return a ReplyEffect. then_reply sees the confirmed post-event state. The serializer here is intentionally small and is reused by the next persistence recipes.
rust
use std::{sync::Arc, time::Duration};
use datum::{ActorFlow, ReplyPort, Sink, Source};
use datum_persistence::{
Effect, EventSourcedBehavior, EventSourcedMessage, EventSourcedRuntime, IdentityAdapter,
MemoryJournal, PersistenceError, PersistenceId, PersistenceResult, PersistenceSerializer,
SerializerCatalog, SerializerId,
};
#[derive(Clone, Copy)]
struct I64Serializer;
impl PersistenceSerializer<i64> for I64Serializer {
fn serializer_id(&self) -> SerializerId {
SerializerId::new(41)
}
fn manifest(&self, _value: &i64) -> String {
"counter-i64-v1".into()
}
fn serialize(&self, value: &i64) -> PersistenceResult<Vec<u8>> {
Ok(value.to_be_bytes().to_vec())
}
fn deserialize(&self, manifest: &str, payload: &[u8]) -> PersistenceResult<i64> {
if manifest != "counter-i64-v1" || payload.len() != 8 {
return Err(PersistenceError::Serialization("invalid counter frame".into()));
}
let mut bytes = [0_u8; 8];
bytes.copy_from_slice(payload);
Ok(i64::from_be_bytes(bytes))
}
}
fn counter_catalog() -> SerializerCatalog<i64> {
SerializerCatalog::new(I64Serializer)
}
enum CounterCommand {
Add { amount: i64, reply_to: ReplyPort<i64> },
}
let behavior = EventSourcedBehavior::with_enforced_replies(
PersistenceId::new("counter", "counter-1").unwrap(),
0_i64,
|_, command| {
let CounterCommand::Add { amount, reply_to } = command;
Effect::persist(amount).then_reply(reply_to, |state| *state)
},
|state: &i64, event: &i64| state + event,
)
.with_event_adapter(IdentityAdapter, counter_catalog());
let handle = behavior
.spawn(EventSourcedRuntime::new(Arc::new(MemoryJournal::new())))
.await
.unwrap();
let completion = Source::from_iter([2_i64, 3])
.via(ActorFlow::ask(
handle.actor_ref().clone(),
1,
Duration::from_secs(2),
|amount, reply_to| {
EventSourcedMessage::command(CounterCommand::Add { amount, reply_to })
},
))
.run_with(Sink::collect())
.unwrap();
let replies = tokio::task::spawn_blocking(move || completion.wait())
.await
.unwrap()
.unwrap();
assert_eq!(replies, vec![2, 5]);
handle.stop();Watch: use
datum::ReplyPortwithActorFlow::ask. Cluster sharding has its own serializableReplyPort; the sharded recipe below sends that port fromthen_runafter persistence.
Tune snapshots and retention
Problem: bound replay time while retaining enough history and snapshots for recovery.
Provide a state adapter/catalog and a snapshot store, then configure count-based retention. This example snapshots every 1,000 events, keeps three snapshot windows, and deletes events older than the retained window only after a snapshot succeeds.
rust
use std::sync::Arc;
use datum_persistence::{
Effect, EventSourcedBehavior, EventSourcedRuntime, IdentityAdapter, MemoryJournal,
MemorySnapshotStore, PersistenceId, RetentionCriteria,
};
let retention = RetentionCriteria::snapshot_every(1_000, 3)
.unwrap()
.with_delete_events_on_snapshot();
let behavior = EventSourcedBehavior::new(
PersistenceId::new("counter", "snapshot-counter").unwrap(),
0_i64,
|_, delta: i64| Effect::persist(delta),
|state: &i64, event: &i64| state + event,
)
.with_event_adapter(IdentityAdapter, counter_catalog())
.with_state_adapter(IdentityAdapter, counter_catalog())
.with_retention(retention);
let journal = Arc::new(MemoryJournal::new());
let snapshots = Arc::new(MemorySnapshotStore::new());
let handle = behavior
.spawn(
EventSourcedRuntime::new(journal)
.with_snapshot_store(snapshots),
)
.await
.unwrap();Watch:
snapshot_everyrejects zero frequency or retention count. Snapshot cleanup is asynchronous; observeDeleteEvents*andDeleteSnapshots*signals if shutdown must wait for a retention cycle. Omittingwith_delete_events_on_snapshotkeeps journal history while still pruning old snapshots.
Project events by tag into a read model
Problem: build a bounded read model from all current events carrying a tag.
The managed projection runtime now reopens the query from its durable cursor, batches ALO cursor saves, filters resume-boundary duplicates, applies handler recovery and restart backoff, exposes pause/offset management, and performs an acknowledged final flush. The application supplies a restartable source provider and an idempotent handler.
Memory and RocksDB tag queries use strict Offset::Sequence positions. This small provider turns that query capability into the fresh-query factory required by the runtime. Keep query_identity stable and scoped to the backend/database; changing its manifest is an explicit cursor migration.
rust
use std::{cmp::Ordering, sync::Arc};
use datum_persistence::{
Tag,
projection::{
CursorPosition, ProjectionCursor, ProjectionError, ProjectionResult, ProjectionSource,
ProviderManifest, SourceProvider,
},
query::{EventEnvelope, EventsByTagQuery, Offset, QueryMode},
};
use futures_util::StreamExt;
/// Source provider for `events_by_tag` implementations that use strict
/// `Offset::Sequence` resume positions, such as memory and RocksDB.
#[derive(Clone)]
struct SequenceEventsByTagProvider {
query: Arc<dyn EventsByTagQuery>,
tag: Tag,
manifest: ProviderManifest,
}
impl SequenceEventsByTagProvider {
fn new(
query: Arc<dyn EventsByTagQuery>,
query_identity: &str,
tag: Tag,
) -> ProjectionResult<Self> {
let manifest = ProviderManifest::new(format!(
"sequence-events-by-tag-v1|query={query_identity}|tag={tag}"
))?;
Ok(Self {
query,
tag,
manifest,
})
}
}
#[async_trait::async_trait]
impl SourceProvider<EventEnvelope> for SequenceEventsByTagProvider {
fn manifest(&self) -> &ProviderManifest {
&self.manifest
}
async fn source_from(
&self,
cursor: Option<&ProjectionCursor>,
mode: QueryMode,
) -> ProjectionResult<ProjectionSource<EventEnvelope>> {
let offset = match cursor {
Some(cursor) => {
cursor.ensure_manifest(&self.manifest)?;
Offset::Sequence(decode_sequence(cursor.position())?)
}
None => Offset::NoOffset,
};
let stream = self
.query
.events_by_tag(&self.tag, offset, mode)
.await
.map_err(|error| ProjectionError::Source(error.to_string()))?;
Ok(Box::pin(stream.map(|item| {
item.map_err(|error| ProjectionError::Source(error.to_string()))
})))
}
fn extract_cursor(&self, envelope: &EventEnvelope) -> ProjectionResult<ProjectionCursor> {
let Offset::Sequence(sequence) = envelope.offset else {
return Err(ProjectionError::Source(
"sequence tag provider received a non-sequence offset".into(),
));
};
Ok(ProjectionCursor::from_position(
self.manifest.clone(),
format!("sequence-v1|{sequence}"),
))
}
fn compare_positions(
&self,
left: &CursorPosition,
right: &CursorPosition,
) -> ProjectionResult<Ordering> {
Ok(decode_sequence(left)?.cmp(&decode_sequence(right)?))
}
}
fn decode_sequence(position: &CursorPosition) -> ProjectionResult<u64> {
position
.as_str()
.strip_prefix("sequence-v1|")
.and_then(|value| value.parse().ok())
.ok_or_else(|| {
ProjectionError::CursorCodec(format!("invalid sequence cursor position {position:?}"))
})
}The recipe tags the write-side events as before, but the read side is now a managed ALO projection. The read model keys contributions by (persistence_id, sequence_nr), so replaying an unsaved ALO tail is an idempotent upsert instead of a double increment. It uses the counter_catalog() helper defined in the event-sourced counter recipe above.
rust
use std::{
collections::BTreeMap,
sync::{Arc, Mutex},
};
use datum_persistence::{
Effect, EventSourcedBehavior, EventSourcedRuntime, IdentityAdapter, MemoryJournal,
PersistenceId, SerializerCatalog, Tag,
projection::{
Handler, MemoryProjectionOffsetStore, Projection, ProjectionError, ProjectionId,
ProjectionOffsetStore, ProjectionResult, ProjectionRuntime, ProjectionStatus,
},
query::{EventEnvelope, EventsByTagQuery, QueryMode},
};
#[derive(Default)]
struct CounterReadModel {
// The event identity makes an at-least-once retry an idempotent upsert.
deltas: BTreeMap<(String, u64), i64>,
}
struct CounterProjectionHandler {
model: Arc<Mutex<CounterReadModel>>,
catalog: SerializerCatalog<i64>,
}
#[async_trait::async_trait]
impl Handler<EventEnvelope> for CounterProjectionHandler {
async fn process(&mut self, envelope: &EventEnvelope) -> ProjectionResult<()> {
let delta = self
.catalog
.decode(&envelope.payload)
.map_err(|error| ProjectionError::Handler(error.to_string()))?;
let event_id = (
envelope.persistence_id.id().to_owned(),
envelope.sequence_nr.as_u64(),
);
self.model
.lock()
.map_err(|_| ProjectionError::Handler("read-model lock poisoned".into()))?
.deltas
.entry(event_id)
.or_insert(delta);
Ok(())
}
}
let journal = Arc::new(MemoryJournal::new());
let behavior = EventSourcedBehavior::new(
PersistenceId::new("counter", "projected-counter").unwrap(),
0_i64,
|_, delta: i64| Effect::persist(delta),
|state: &i64, event: &i64| state + event,
)
.with_event_adapter(IdentityAdapter, counter_catalog())
.with_tagger(|_event| vec![Tag::new("counter")]);
let behavior_handle = behavior
.spawn(EventSourcedRuntime::new(journal.clone()))
.await
.unwrap();
behavior_handle.tell(4).unwrap();
behavior_handle.tell(5).unwrap();
assert_eq!(behavior_handle.state().await.unwrap().state, 9);
let provider = SequenceEventsByTagProvider::new(
Arc::clone(&journal) as Arc<dyn EventsByTagQuery>,
"memory:counter-docs",
Tag::new("counter"),
)
.unwrap();
let read_model = Arc::new(Mutex::new(CounterReadModel::default()));
let handler_model = Arc::clone(&read_model);
let projection_id = ProjectionId::new("counter-totals", "counter");
let projection = Projection::at_least_once(projection_id.clone(), provider, move || {
CounterProjectionHandler {
model: Arc::clone(&handler_model),
catalog: counter_catalog(),
}
})
.with_query_mode(QueryMode::Current);
let offset_store = Arc::new(MemoryProjectionOffsetStore::new());
let projection_handle = projection
.spawn(ProjectionRuntime::new(offset_store.clone()))
.await
.unwrap();
assert_eq!(
projection_handle.wait().await.unwrap(),
ProjectionStatus::Completed
);
assert!(offset_store.load(&projection_id).await.unwrap().is_some());
assert_eq!(read_model.lock().unwrap().deltas.values().sum::<i64>(), 9);
behavior_handle.stop();Watch: this provider intentionally accepts only sequence offsets; PostgreSQL and Cassandra tag queries need providers for their own cursor shapes. For PostgreSQL scale-out, use the shipped
EventsBySlicesSourceProvider.QueryMode::Currentflushes and completes successfully;QueryMode::Livestays active until stopped. Delivery guarantees cover emitted envelopes only; see the source-completeness caveats.
Durable-state CAS upsert with conflict handling
Problem: update the durable-state SPI directly and recover cleanly when another writer wins the revision race.
Every mutation must use stored_revision + 1. On conflict, read the latest VersionedState, merge or re-evaluate the application update, and retry with the returned expected revision.
rust
use datum_persistence::{
DurableStateStore, MemoryDurableStateStore, PersistenceError, PersistenceId, Revision,
SerializedPayload, SerializerId,
};
fn frame(value: u8) -> SerializedPayload {
SerializedPayload::new(SerializerId::new(7), "counter-state-v1", vec![value])
}
let store = MemoryDurableStateStore::new();
let persistence_id = PersistenceId::new("durable-counter", "counter-1").unwrap();
store
.upsert(&persistence_id, Revision::new(1), frame(10), None)
.await
.unwrap();
let conflict = store
.upsert(&persistence_id, Revision::new(1), frame(11), None)
.await;
let expected = match conflict {
Err(PersistenceError::RevisionConflict {
expected, actual, ..
}) => {
assert_eq!(actual, Revision::new(1));
expected
}
other => panic!("expected revision conflict, got {other:?}"),
};
let latest = store.get(&persistence_id).await.unwrap();
assert_eq!(latest.revision, Revision::new(1));
store
.upsert(&persistence_id, expected, frame(11), None)
.await
.unwrap();
assert_eq!(store.get(&persistence_id).await.unwrap().revision, Revision::new(2));Watch: do not blindly retry the same revision. A conflict is distinct from
PersistenceError::Failure: the conflict proves the attempted CAS did not commit, while a generic failure may have an uncertain outcome.
Sharded event-sourced entity
Problem: derive a stable persistence id from each sharded entity identity and recover after passivation or handoff.
Enable the integration feature and add the sharding crate used by the command protocol:
toml
[dependencies]
datum-cluster-sharding = "0.11.3"
datum-persistence = { version = "0.11.3", features = ["sharding"] }EventSourcedEntity::register replaces the blueprint's placeholder id with type_name|entity_id. Sharding commands use datum_cluster_sharding::ReplyPort, so send the reply from then_run after the event is confirmed.
rust
use std::{sync::Arc, time::Duration};
use datum_cluster_sharding::{
EntityContext, ReplyPort, ShardingHandle,
serde::{Deserialize, Serialize},
};
use datum_persistence::{
Effect, EventSourcedBehavior, EventSourcedRuntime, IdentityAdapter, Journal, PersistenceId,
sharding::EventSourcedEntity,
};
#[derive(Serialize, Deserialize)]
#[serde(crate = "datum_cluster_sharding::serde")]
enum ShardedCounterCommand {
Add { amount: i64, reply_to: ReplyPort<i64> },
}
async fn register_counter(
sharding: &ShardingHandle,
journal: Arc<dyn Journal>,
) {
EventSourcedEntity::register(
sharding,
"persistent-counter",
EventSourcedRuntime::new(journal),
|_context: EntityContext| {
EventSourcedBehavior::new(
PersistenceId::from_unique("replaced-by-sharding").unwrap(),
0_i64,
|_, command| {
let ShardedCounterCommand::Add { amount, reply_to } = command;
Effect::persist(amount).then_run(move |state| {
let _sent = reply_to.send(*state);
})
},
|state: &i64, event: &i64| state + event,
)
.with_event_adapter(IdentityAdapter, counter_catalog())
},
)
.await
.unwrap();
}
async fn add(sharding: &ShardingHandle, amount: i64) -> i64 {
let entity = sharding.entity_ref::<ShardedCounterCommand>(
"persistent-counter",
"counter-1",
);
entity
.ask(Duration::from_secs(2), |reply_to| ShardedCounterCommand::Add {
amount,
reply_to,
})
.await
.unwrap()
}Watch: passivation or a behavior stop ends the current actor. The next sharded delivery creates a new incarnation and runs journal recovery before handling that command. A journal failure does not hot-recreate the entity on the failing message; the next delivery is the respawn trigger.
Next steps
- Testing Streams — full TestKit API reference
- Dynamic Streams — MergeHub, BroadcastHub, KillSwitches
- Error Handling — RestartSource, recover, supervision
- Actors Interop — ActorFlow::ask, ActorSource, ActorSink
- Working with Graphs — GraphDSL topology reference
- Persistence — event sourcing, durable state, backends, queries, projections, and schemas