Skip to main content

krabka_client_streams/
embedded.rs

1//! Drives one task's processor graph for an embedder that owns the I/O.
2//!
3//! A simulator, a browser playground, or another host that talks to brokers it
4//! controls fetches the source records itself and produces what the graph
5//! emits. [`EmbeddedTask`] gives it the same graph that the native runtime's
6//! stream task runs, instantiated from the same [`BuiltTopology`], so a
7//! topology behaves the same in either. The embedder plays the runtime's part:
8//! it pipes each fetched record, produces the sink [`OutputRecord`]s and the
9//! [`ChangelogRecord`]s, replays a changelog to restore a store, and drives the
10//! punctuation clocks.
11//!
12//! Every method is synchronous. The graph's futures are async because a store
13//! backend may be, but the in-memory backend this module opens never waits, so
14//! each future completes on its first poll and `pollster::block_on` returns
15//! without parking a thread. That is what lets the type run on
16//! `wasm32-unknown-unknown`, where no thread can block. A task is
17//! single-threaded: nothing runs in the background, and each method borrows the
18//! task mutably for as long as it drives the graph.
19
20use std::collections::HashSet;
21
22use bytes::Bytes;
23use krabka_units::prelude::*;
24
25pub use crate::processor::erased::OutputRecord;
26use crate::{
27    error::StreamsClientError,
28    processor::{erased::ProcessorError, graph::Graph},
29    runtime::global::GlobalStateManager,
30    store::{backend::StoreBackend, snapshot::TaskSnapshot},
31    topology::BuiltTopology,
32};
33
34/// One record a state store logged, for the embedder to produce to the
35/// changelog topic.
36///
37/// The native runtime pins every changelog record to the task's partition, so
38/// a restore reads it back from the same partition the task processes.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct ChangelogRecord {
41    /// The changelog topic of the store.
42    pub topic: String,
43    /// The serialized store key.
44    pub key: Bytes,
45    /// The serialized store value, or `None` for a tombstone.
46    pub value: Option<Bytes>,
47    /// The record timestamp to produce with, or `None` for the producer
48    /// default, which is the send time. A versioned store sets the version
49    /// timestamp (KIP-889).
50    pub timestamp: Option<i64>,
51}
52
53/// One task's processor graph, driven by an embedder instead of the runtime.
54///
55/// The graph runs with in-memory stores and with the record cache disabled,
56/// which is the JVM `TopologyTestDriver` default, so a store emits on every
57/// update. Changelog topics derive from the built topology's application id,
58/// exactly as [`BuiltTopology::changelog_topics`] lists them.
59///
60/// A KIP-1071 task is one `(subtopology, partition)` of the group's
61/// assignment, and [`for_subtopology`](Self::for_subtopology) instantiates
62/// exactly that subtopology's graph. [`new`](Self::new) instantiates every
63/// subtopology, which is the task itself when the topology has one.
64///
65/// # Examples
66///
67/// ```
68/// use assert2::assert;
69/// use krabka_client_streams::{EmbeddedTask, NodeHandle, Record, Topology, impl_processor};
70///
71/// struct Upper;
72/// impl_processor! {
73///     impl Upper: (String, String) -> (String, String) {
74///         async fn process(&mut self, ctx, r) {
75///             ctx.forward(Record::new(r.key, r.value.to_uppercase(), r.timestamp));
76///         }
77///     }
78/// }
79///
80/// let mut topo = Topology::new();
81/// let src: NodeHandle<String, String> = topo.add_source("src", ["in"]);
82/// let up = topo.add_processor("up", || Upper, [&src]);
83/// topo.add_sink("out", "out", [&up]);
84/// let built = topo.build("app").unwrap();
85///
86/// let mut task = EmbeddedTask::new(&built).unwrap();
87/// task.pipe("in", 0, 0, Some(b"k"), b"hello", 0).unwrap();
88/// let out = task.take_output();
89/// assert!(out[0].value.as_deref() == Some(&b"HELLO"[..]));
90/// ```
91pub struct EmbeddedTask {
92    graph: Graph,
93    /// The topology's source topics. A store whose changelog topic is one of
94    /// these is a `REUSE_KTABLE_SOURCE_TOPICS` reuse-source store, and the task
95    /// never hands its changelog back for production, because the source topic
96    /// already holds those records.
97    reuse_source_topics: HashSet<String>,
98}
99
100impl EmbeddedTask {
101    /// Instantiates the whole topology's graph, every subtopology included,
102    /// with in-memory stores, and calls `init` on every processor.
103    ///
104    /// Use it for a topology with one subtopology, where the task is the
105    /// topology. A KIP-1071 task of a topology with several subtopologies
106    /// must use [`for_subtopology`](Self::for_subtopology): a graph that
107    /// holds every subtopology runs every branch that reads a piped topic, so
108    /// two tasks of different subtopologies over the same source topic would
109    /// each emit the other's sink output and store updates, and each would
110    /// hold the other's stores and fire its punctuators.
111    ///
112    /// # Errors
113    ///
114    /// Returns the [`ProcessorError`] that instantiation or a processor's
115    /// `init` raises. A topology that [`Topology::build`](crate::Topology::build)
116    /// accepted raises none in practice.
117    pub fn new(built: &BuiltTopology) -> Result<Self, ProcessorError> {
118        let reuse_source_topics = built.list_source_topics().into_iter().collect();
119        Self::instantiate(built, built.application_id(), reuse_source_topics, None)
120    }
121
122    /// Instantiates the graph of one subtopology, with in-memory stores, and
123    /// calls `init` on its processors.
124    ///
125    /// The graph holds that subtopology's sources, processors and sinks, and
126    /// the stores its processors connect to, and nothing of any other
127    /// subtopology. This is the graph of a KIP-1071 task `(subtopology_id,
128    /// partition)`; [`BuiltTopology::subtopology_ids`] lists the ids and
129    /// [`BuiltTopology::source_topics_for`] the topics to pipe.
130    ///
131    /// # Errors
132    ///
133    /// Returns [`ProcessorError::UnknownSubtopology`] when the topology has no
134    /// subtopology `subtopology_id`, else what [`new`](Self::new) returns.
135    pub fn for_subtopology(
136        built: &BuiltTopology,
137        subtopology_id: &str,
138    ) -> Result<Self, ProcessorError> {
139        let reuse_source_topics = built.list_source_topics().into_iter().collect();
140        Self::instantiate(
141            built,
142            built.application_id(),
143            reuse_source_topics,
144            Some(subtopology_id),
145        )
146    }
147
148    /// Instantiates the graph of `subtopology`, or of every subtopology when it
149    /// is `None`, under `application_id`, which sets the changelog topic names,
150    /// and with the given reuse-source suppression set.
151    ///
152    /// [`TopologyTestDriver`](crate::TopologyTestDriver) uses a fixed
153    /// application id and an empty set, so a test sees every changelog record.
154    pub(crate) fn instantiate(
155        built: &BuiltTopology,
156        application_id: &str,
157        reuse_source_topics: HashSet<String>,
158        subtopology: Option<&str>,
159    ) -> Result<Self, ProcessorError> {
160        let backend = StoreBackend::InMemory;
161        // A zero cache budget disables the record cache, so stores emit on every
162        // update and the outputs are deterministic. This is the JVM
163        // `TopologyTestDriver` default for `statestore.cache.max.bytes`.
164        let mut graph = match subtopology {
165            Some(id) => pollster::block_on(built.instantiate_subtopology(
166                &backend,
167                application_id,
168                ByteSize::ZERO,
169                id,
170            )),
171            None => pollster::block_on(built.instantiate(&backend, application_id, ByteSize::ZERO)),
172        }?;
173        // The fully-replicated global stores are shared across tasks in the
174        // runtime. Each embedded task owns its own copy, and the embedder fills
175        // it through `apply_global`.
176        graph.globals = pollster::block_on(GlobalStateManager::build(
177            built.global_store_factories(),
178            built.global_store_topics(),
179            &backend,
180            application_id,
181        ));
182        pollster::block_on(graph.init_processors())?;
183        Ok(Self {
184            graph,
185            reuse_source_topics,
186        })
187    }
188
189    pub(crate) fn graph(&self) -> &Graph {
190        &self.graph
191    }
192
193    pub(crate) fn graph_mut(&mut self) -> &mut Graph {
194        &mut self.graph
195    }
196
197    /// Runs one source record through the graph.
198    ///
199    /// `partition` and `offset` are the record's provenance, and a processor
200    /// reads them through its record context. The record raises the task's
201    /// stream-time to its `timestamp` when it is later. A record on a topic no
202    /// source reads is ignored. The sink outputs wait in
203    /// [`take_output`](Self::take_output) and the store changelog records in
204    /// [`drain_changelogs`](Self::drain_changelogs).
205    ///
206    /// # Errors
207    ///
208    /// Returns a [`ProcessorError`] when a source serde cannot deserialize the
209    /// record, or a sink serde cannot serialize an output.
210    pub fn pipe(
211        &mut self,
212        topic: &str,
213        partition: i32,
214        offset: i64,
215        key: Option<&[u8]>,
216        value: &[u8],
217        timestamp: i64,
218    ) -> Result<(), ProcessorError> {
219        pollster::block_on(
220            self.graph
221                .pipe(topic, partition, offset, key, value, timestamp),
222        )
223    }
224
225    /// Takes the sink records the graph emitted since the last call, in
226    /// emission order.
227    ///
228    /// A record for a repartition topic is in the list too. The embedder
229    /// produces it like any other output, and the subtopology that reads the
230    /// repartition topic gets it back through [`pipe`](Self::pipe).
231    pub fn take_output(&mut self) -> Vec<OutputRecord> {
232        self.graph.take_output()
233    }
234
235    /// Takes the changelog records the stores logged since the last call.
236    ///
237    /// A reuse-source store's records are dropped, not returned, because its
238    /// changelog topic is the source topic itself. Restore writes log nothing
239    /// while logging is off.
240    pub fn drain_changelogs(&mut self) -> Vec<ChangelogRecord> {
241        self.graph
242            .drain_changelogs(&self.reuse_source_topics)
243            .into_iter()
244            .map(|(topic, key, value, timestamp)| ChangelogRecord {
245                topic,
246                key,
247                value,
248                timestamp,
249            })
250            .collect()
251    }
252
253    /// Applies one changelog record to the named store, as a restore does.
254    ///
255    /// A `value` of `None` is a tombstone. `timestamp` is the changelog record's
256    /// timestamp; a versioned store inserts the version at it. Turn logging off
257    /// with [`set_logging`](Self::set_logging) for the whole replay, so the
258    /// restored writes are not logged again, and turn it back on before the
259    /// first [`pipe`](Self::pipe). A store the graph does not hold is ignored.
260    pub fn restore_apply(&mut self, store: &str, key: Bytes, value: Option<Bytes>, timestamp: i64) {
261        pollster::block_on(self.graph.restore_apply(store, key, value, timestamp));
262    }
263
264    /// Applies one record of a global source topic to the named global store.
265    ///
266    /// A `GlobalKTable` store is fully replicated and has no changelog, so the
267    /// embedder replays every partition of the source topic here, before the
268    /// first record that joins against it. A `value` of `None` deletes the
269    /// entry. [`BuiltTopology::global_store_topics`] maps each store to its
270    /// topic.
271    pub fn apply_global(&mut self, store: &str, key: Bytes, value: Option<Bytes>) {
272        pollster::block_on(self.graph.globals.apply(store, key, value));
273    }
274
275    /// Turns changelog logging on or off for every store.
276    pub fn set_logging(&mut self, on: bool) {
277        self.graph.set_logging(on);
278    }
279
280    /// The task's stream-time: the highest record timestamp piped so far, or
281    /// `i64::MIN` before the first record.
282    #[must_use]
283    pub fn stream_time(&self) -> i64 {
284        self.graph.stream_time
285    }
286
287    /// Raises stream-time to `stream_time` when it is higher, then fires every
288    /// due stream-time punctuator once at the current stream-time.
289    ///
290    /// The runtime fires stream-time punctuators after each fetched batch.
291    /// Passing [`stream_time`](Self::stream_time) fires what is due without
292    /// moving the clock. What a punctuator forwards waits in
293    /// [`take_output`](Self::take_output) and
294    /// [`drain_changelogs`](Self::drain_changelogs).
295    ///
296    /// # Errors
297    ///
298    /// Returns a [`ProcessorError`] when a sink serde cannot serialize a record
299    /// a punctuator forwarded.
300    pub fn punctuate_stream_time(&mut self, stream_time: i64) -> Result<(), ProcessorError> {
301        pollster::block_on(self.graph.punctuate_stream_time(stream_time))
302    }
303
304    /// Sets the wall clock to `now_ms` and fires every due wall-clock
305    /// punctuator once at that time.
306    ///
307    /// The runtime ticks the wall clock between polls; an embedder ticks it
308    /// from its own clock. A schedule registered during the call stamps its
309    /// first fire from `now_ms`.
310    ///
311    /// # Errors
312    ///
313    /// Returns a [`ProcessorError`] when a sink serde cannot serialize a record
314    /// a punctuator forwarded.
315    pub fn punctuate_wall_clock(&mut self, now_ms: i64) -> Result<(), ProcessorError> {
316        pollster::block_on(self.graph.punctuate_wall_clock(now_ms))
317    }
318
319    /// Flushes every cached store and forwards its deduplicated changes.
320    ///
321    /// The runtime flushes before each commit. The embedded graph runs with the
322    /// record cache disabled, so no store is cached and the call forwards
323    /// nothing; it exists so an embedder can follow the runtime's commit
324    /// sequence unchanged.
325    ///
326    /// # Errors
327    ///
328    /// Returns a [`ProcessorError`] when a node rejects a forwarded change.
329    pub fn flush_caches(&mut self) -> Result<(), ProcessorError> {
330        pollster::block_on(self.graph.flush_caches())
331    }
332
333    /// Wipes every store, for a clean-slate replay of the changelogs.
334    pub fn clear_stores(&mut self) {
335        pollster::block_on(self.graph.clear_stores());
336    }
337
338    /// Snapshots every store, keyed by store name, for a barrier cut.
339    ///
340    /// The payloads are the ones [`SnapshotStore`](crate::SnapshotStore) keeps,
341    /// and [`restore_store_snapshots`](Self::restore_store_snapshots) takes the
342    /// task back to them.
343    pub fn snapshot_stores(&mut self) -> TaskSnapshot {
344        pollster::block_on(self.graph.snapshot_stores())
345    }
346
347    /// Replaces every store with the snapshot taken at a cut.
348    ///
349    /// A store the snapshot does not name is wiped, so the task holds the state
350    /// of the cut and nothing after it.
351    ///
352    /// # Errors
353    ///
354    /// Returns [`StreamsClientError::Snapshot`] when a payload does not match
355    /// the format its store writes.
356    pub fn restore_store_snapshots(
357        &mut self,
358        snapshot: &TaskSnapshot,
359    ) -> Result<(), StreamsClientError> {
360        pollster::block_on(self.graph.restore_store_snapshots(snapshot))
361    }
362
363    /// Calls `close` on every processor, in graph order, and drops the task.
364    pub fn close(mut self) {
365        pollster::block_on(self.graph.close_processors());
366    }
367
368    /// The names of the task's state stores, in ascending order.
369    ///
370    /// Global stores are not in the list; they belong to the application, not
371    /// to a task.
372    #[must_use]
373    pub fn store_names(&self) -> Vec<String> {
374        let mut names = self.graph.stores.names();
375        names.sort();
376        names
377    }
378
379    /// The changelog topic of a store, or `None` when the task has no such
380    /// store or the store has logging off.
381    #[must_use]
382    pub fn store_changelog_topic(&self, store: &str) -> Option<String> {
383        let topic = self.graph.stores.get(store)?.changelog_topic();
384        (!topic.is_empty()).then(|| topic.to_string())
385    }
386
387    /// The first `limit` entries of a key-value store, as raw key and value
388    /// bytes in ascending key order.
389    ///
390    /// The dump is for an inspector, and it scans the whole store before it
391    /// cuts the list. A window, session or versioned store dumps nothing here,
392    /// and neither does a store the task does not hold.
393    #[must_use]
394    pub fn dump_store(&self, store: &str, limit: usize) -> Vec<(Bytes, Bytes)> {
395        let Some(query) = self.graph.stores.iq_get(store) else {
396            return Vec::new();
397        };
398        let mut entries = pollster::block_on(query.iq_kv_all());
399        entries.truncate(limit);
400        entries
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use std::time::Duration;
407
408    use assert2::{assert, check};
409    use async_trait::async_trait;
410    use krabka_protocol::owned::{
411        common::streams_group_heartbeat_request::{key_value::KeyValue, topic_info::TopicInfo},
412        streams_group_heartbeat_request::{Subtopology, Topology as WireTopology},
413    };
414
415    use super::*;
416    use crate::{
417        dsl::StreamsBuilder,
418        processor::{
419            api::{Processor, ProcessorContext},
420            punctuation::{PunctuationType, Punctuator},
421            record::Record,
422            serde::{I64Serde, StringSerde},
423        },
424        topology::{NodeHandle, Topology},
425    };
426
427    struct DropEmpty;
428    #[async_trait]
429    impl Processor<String, String, String, String> for DropEmpty {
430        async fn process(
431            &mut self,
432            ctx: &mut ProcessorContext<'_, '_, String, String>,
433            r: Record<String, String>,
434        ) {
435            if !r.value.is_empty() {
436                ctx.forward(r);
437            }
438        }
439    }
440
441    struct Upper;
442    #[async_trait]
443    impl Processor<String, String, String, String> for Upper {
444        async fn process(
445            &mut self,
446            ctx: &mut ProcessorContext<'_, '_, String, String>,
447            r: Record<String, String>,
448        ) {
449            ctx.forward(Record::new(r.key, r.value.to_uppercase(), r.timestamp));
450        }
451    }
452
453    /// Counts each value in the named store.
454    struct Count(&'static str);
455    #[async_trait]
456    impl Processor<String, String, String, i64> for Count {
457        async fn process(
458            &mut self,
459            ctx: &mut ProcessorContext<'_, '_, String, i64>,
460            r: Record<String, String>,
461        ) {
462            let n = {
463                let store = ctx.get_state_store::<String, i64>(self.0).unwrap();
464                let n = store.get(&r.value).await.unwrap_or(0) + 1;
465                store.put(r.value.clone(), n).await;
466                n
467            };
468            ctx.forward(Record::new(Some(r.value), n, r.timestamp));
469        }
470    }
471
472    /// `in` → drop empty values → uppercase → count per value → `out`, with the
473    /// count in the logged store `counts`.
474    fn filter_map_count() -> BuiltTopology {
475        let mut t = Topology::new();
476        let src: NodeHandle<String, String> = t.add_source("src", ["in"]);
477        let filter = t.add_processor("filter", || DropEmpty, [&src]);
478        let map = t.add_processor("map", || Upper, [&filter]);
479        let count = t.add_processor("count", || Count("counts"), [&map]);
480        t.add_state_store("counts", StringSerde, I64Serde, [count.name()]);
481        t.add_sink("out", "out", [&count]);
482        t.build("app").unwrap()
483    }
484
485    /// Two subtopologies that share no node and both read `in`: `0` uppercases
486    /// and counts in `counts-a` into `out-a`; `1` counts as is in `counts-b`
487    /// into `out-b`.
488    fn two_readers_of_one_topic() -> BuiltTopology {
489        let mut t = Topology::new();
490        let a: NodeHandle<String, String> = t.add_source("src-a", ["in"]);
491        let upper = t.add_processor("upper", || Upper, [&a]);
492        let count_a = t.add_processor("count-a", || Count("counts-a"), [&upper]);
493        t.add_state_store("counts-a", StringSerde, I64Serde, [count_a.name()]);
494        t.add_sink("out-a", "out-a", [&count_a]);
495        let b: NodeHandle<String, String> = t.add_source("src-b", ["in"]);
496        let count_b = t.add_processor("count-b", || Count("counts-b"), [&b]);
497        t.add_state_store("counts-b", StringSerde, I64Serde, [count_b.name()]);
498        t.add_sink("out-b", "out-b", [&count_b]);
499        t.build("app").unwrap()
500    }
501
502    fn i64_bytes(n: i64) -> Bytes {
503        Bytes::copy_from_slice(&n.to_be_bytes())
504    }
505
506    fn pipe_word(task: &mut EmbeddedTask, offset: i64, word: &str) {
507        task.pipe("in", 0, offset, Some(b"k"), word.as_bytes(), offset)
508            .unwrap();
509    }
510
511    #[test]
512    fn pipes_a_record_and_reports_the_output_and_changelog_bytes() {
513        let built = filter_map_count();
514        let mut task = EmbeddedTask::new(&built).unwrap();
515
516        pipe_word(&mut task, 0, "hello");
517        check!(
518            task.take_output()
519                == vec![OutputRecord {
520                    topic: "out".into(),
521                    key: Some(Bytes::from_static(b"HELLO")),
522                    value: Some(i64_bytes(1)),
523                    timestamp: 0,
524                }]
525        );
526        check!(
527            task.drain_changelogs()
528                == vec![ChangelogRecord {
529                    topic: "app-counts-changelog".into(),
530                    key: Bytes::from_static(b"HELLO"),
531                    value: Some(i64_bytes(1)),
532                    timestamp: None,
533                }]
534        );
535
536        // The filter drops an empty value before it reaches the store.
537        pipe_word(&mut task, 1, "");
538        check!(task.take_output().is_empty());
539        check!(task.drain_changelogs().is_empty());
540
541        // A repeated word bumps the count, and each drain starts empty.
542        pipe_word(&mut task, 2, "hello");
543        check!(task.take_output()[0].value == Some(i64_bytes(2)));
544        check!(task.drain_changelogs().len() == 1);
545        check!(task.stream_time() == 2);
546        task.close();
547    }
548
549    #[test]
550    fn unknown_topics_are_ignored() {
551        let built = filter_map_count();
552        let mut task = EmbeddedTask::new(&built).unwrap();
553        task.pipe("elsewhere", 0, 0, None, b"x", 5).unwrap();
554        check!(task.take_output().is_empty());
555        check!(task.stream_time() == 5);
556    }
557
558    #[test]
559    fn restore_from_changelog_records_continues_the_count() {
560        let built = filter_map_count();
561        let mut task = EmbeddedTask::new(&built).unwrap();
562
563        task.set_logging(false);
564        task.restore_apply(
565            "counts",
566            Bytes::from_static(b"HELLO"),
567            Some(i64_bytes(2)),
568            7,
569        );
570        task.restore_apply("counts", Bytes::from_static(b"GONE"), Some(i64_bytes(9)), 8);
571        task.restore_apply("counts", Bytes::from_static(b"GONE"), None, 9);
572        task.set_logging(true);
573        // A restore logs nothing back.
574        check!(task.drain_changelogs().is_empty());
575        check!(task.dump_store("counts", 10) == vec![(Bytes::from_static(b"HELLO"), i64_bytes(2))]);
576
577        pipe_word(&mut task, 0, "hello");
578        check!(task.take_output()[0].value == Some(i64_bytes(3)));
579        check!(
580            task.drain_changelogs()
581                == vec![ChangelogRecord {
582                    topic: "app-counts-changelog".into(),
583                    key: Bytes::from_static(b"HELLO"),
584                    value: Some(i64_bytes(3)),
585                    timestamp: None,
586                }]
587        );
588    }
589
590    #[test]
591    fn dump_store_lists_the_first_entries_in_key_order() {
592        let built = filter_map_count();
593        let mut task = EmbeddedTask::new(&built).unwrap();
594        for (offset, word) in ["b", "a", "c", "a"].into_iter().enumerate() {
595            pipe_word(&mut task, i64::try_from(offset).unwrap(), word);
596        }
597        check!(
598            task.dump_store("counts", 2)
599                == vec![
600                    (Bytes::from_static(b"A"), i64_bytes(2)),
601                    (Bytes::from_static(b"B"), i64_bytes(1)),
602                ]
603        );
604        check!(task.dump_store("counts", 10).len() == 3);
605        check!(task.dump_store("missing", 10).is_empty());
606        check!(task.store_names() == vec!["counts".to_string()]);
607        check!(task.store_changelog_topic("counts") == Some("app-counts-changelog".into()));
608        check!(task.store_changelog_topic("missing") == None);
609    }
610
611    #[test]
612    fn snapshots_rewind_the_stores_and_clear_wipes_them() {
613        let built = filter_map_count();
614        let mut task = EmbeddedTask::new(&built).unwrap();
615        pipe_word(&mut task, 0, "a");
616        pipe_word(&mut task, 1, "a");
617        let at_cut = task.snapshot_stores();
618        check!(at_cut.keys().cloned().collect::<Vec<_>>() == vec!["counts".to_string()]);
619
620        pipe_word(&mut task, 2, "a");
621        check!(task.dump_store("counts", 10) == vec![(Bytes::from_static(b"A"), i64_bytes(3))]);
622        task.restore_store_snapshots(&at_cut).unwrap();
623        check!(task.dump_store("counts", 10) == vec![(Bytes::from_static(b"A"), i64_bytes(2))]);
624
625        task.clear_stores();
626        check!(task.dump_store("counts", 10).is_empty());
627
628        let mut malformed = TaskSnapshot::new();
629        malformed.insert("counts".into(), Bytes::from_static(b"\x00"));
630        assert!(let Err(StreamsClientError::Snapshot(_)) = task.restore_store_snapshots(&malformed));
631    }
632
633    #[test]
634    fn a_reuse_source_changelog_is_not_handed_back() {
635        // The optimized build reads `users` straight into a store whose
636        // changelog is the source topic itself, so its records must never be
637        // produced again.
638        let b = StreamsBuilder::new();
639        b.table::<String, String>("users", "users-store")
640            .to_stream()
641            .to("out");
642        let built = b.build_optimized("app").unwrap();
643        check!(built.changelog_topics() == [("users-store".to_string(), "users".to_string())]);
644
645        let mut task = EmbeddedTask::new(&built).unwrap();
646        task.pipe("users", 0, 0, Some(b"u1"), b"alice", 0).unwrap();
647        check!(task.take_output().len() == 1);
648        check!(task.drain_changelogs().is_empty());
649        check!(
650            task.dump_store("users-store", 10)
651                == vec![(Bytes::from_static(b"u1"), Bytes::from_static(b"alice"))]
652        );
653    }
654
655    #[test]
656    fn apply_global_feeds_a_global_table_join() {
657        let b = StreamsBuilder::new();
658        let customers = b.global_table::<String, String>("customers", "customers-by-id");
659        b.stream::<String, String>(["orders"])
660            .left_join_global(
661                &customers,
662                |_order_id, customer_id| customer_id.clone(),
663                |customer_id, customer| format!("{customer_id}|{}", customer.map_or("?", |c| c)),
664            )
665            .to("enriched");
666        drop(customers);
667        let built = b.build("app").unwrap();
668        check!(
669            built.global_store_topics()
670                == [("customers-by-id".to_string(), "customers".to_string())]
671                    .into_iter()
672                    .collect()
673        );
674
675        let mut task = EmbeddedTask::new(&built).unwrap();
676        task.apply_global(
677            "customers-by-id",
678            Bytes::from_static(b"c1"),
679            Some(Bytes::from_static(b"Alice")),
680        );
681        task.pipe("orders", 0, 0, Some(b"o1"), b"c1", 0).unwrap();
682        task.pipe("orders", 0, 1, Some(b"o2"), b"c2", 1).unwrap();
683        let values: Vec<Option<Bytes>> = task.take_output().into_iter().map(|r| r.value).collect();
684        check!(
685            values
686                == vec![
687                    Some(Bytes::from_static(b"c1|Alice")),
688                    Some(Bytes::from_static(b"c2|?")),
689                ]
690        );
691
692        task.apply_global("customers-by-id", Bytes::from_static(b"c1"), None);
693        task.pipe("orders", 0, 2, Some(b"o3"), b"c1", 2).unwrap();
694        check!(task.take_output()[0].value == Some(Bytes::from_static(b"c1|?")));
695    }
696
697    struct EmitTs;
698    #[async_trait]
699    impl Punctuator<String, i64> for EmitTs {
700        async fn punctuate(&mut self, ctx: &mut ProcessorContext<'_, '_, String, i64>, ts: i64) {
701            ctx.forward(Record::new(None, ts, ts));
702        }
703    }
704
705    /// Schedules one stream-time and one wall-clock punctuator, 10 ms each,
706    /// and drops every record.
707    struct Scheduler;
708    #[async_trait]
709    impl Processor<String, String, String, i64> for Scheduler {
710        async fn init(&mut self, ctx: &mut ProcessorContext<'_, '_, String, i64>) {
711            ctx.schedule(
712                Duration::from_millis(10),
713                PunctuationType::StreamTime,
714                EmitTs,
715            );
716            ctx.schedule(
717                Duration::from_millis(10),
718                PunctuationType::WallClockTime,
719                EmitTs,
720            );
721        }
722        async fn process(
723            &mut self,
724            _ctx: &mut ProcessorContext<'_, '_, String, i64>,
725            _r: Record<String, String>,
726        ) {
727        }
728    }
729
730    #[test]
731    fn punctuators_fire_on_both_clocks() {
732        let mut t = Topology::new();
733        let src: NodeHandle<String, String> = t.add_source("src", ["in"]);
734        let p = t.add_processor("p", || Scheduler, [&src]);
735        t.add_sink("out", "out", [&p]);
736        let built = t.build("app").unwrap();
737        let mut task = EmbeddedTask::new(&built).unwrap();
738
739        // A stream-time schedule first fires on the first record; firing at the
740        // current stream-time moves nothing and emits once.
741        task.pipe("in", 0, 0, None, b"v", 5).unwrap();
742        task.punctuate_stream_time(task.stream_time()).unwrap();
743        check!(
744            task.take_output()
745                == vec![OutputRecord {
746                    topic: "out".into(),
747                    key: None,
748                    value: Some(i64_bytes(5)),
749                    timestamp: 5,
750                }]
751        );
752        task.punctuate_stream_time(task.stream_time()).unwrap();
753        check!(task.take_output().is_empty());
754        // Raising stream-time past the next boundary fires again, at the new
755        // time.
756        task.punctuate_stream_time(20).unwrap();
757        check!(task.take_output()[0].value == Some(i64_bytes(20)));
758        check!(task.stream_time() == 20);
759
760        // A wall-clock schedule first fires one interval after registration,
761        // which was at wall clock 0.
762        task.punctuate_wall_clock(9).unwrap();
763        check!(task.take_output().is_empty());
764        task.punctuate_wall_clock(10).unwrap();
765        check!(task.take_output()[0].value == Some(i64_bytes(10)));
766    }
767
768    #[test]
769    fn for_subtopology_runs_only_that_subtopology() {
770        let built = two_readers_of_one_topic();
771        check!(built.subtopology_ids() == vec!["0".to_string(), "1".to_string()]);
772        check!(built.source_topics_for("0") == ["in".to_string()]);
773        check!(built.source_topics_for("1") == ["in".to_string()]);
774
775        for (id, out_topic, out_key, store, changelog) in [
776            ("0", "out-a", "HELLO", "counts-a", "app-counts-a-changelog"),
777            ("1", "out-b", "hello", "counts-b", "app-counts-b-changelog"),
778        ] {
779            let mut task = EmbeddedTask::for_subtopology(&built, id).unwrap();
780            check!(task.store_names() == vec![store.to_string()]);
781            pipe_word(&mut task, 0, "hello");
782            check!(
783                task.take_output()
784                    == vec![OutputRecord {
785                        topic: out_topic.into(),
786                        key: Some(Bytes::copy_from_slice(out_key.as_bytes())),
787                        value: Some(i64_bytes(1)),
788                        timestamp: 0,
789                    }]
790            );
791            check!(
792                task.drain_changelogs()
793                    == vec![ChangelogRecord {
794                        topic: changelog.into(),
795                        key: Bytes::copy_from_slice(out_key.as_bytes()),
796                        value: Some(i64_bytes(1)),
797                        timestamp: None,
798                    }]
799            );
800        }
801
802        assert!(
803            let Err(ProcessorError::UnknownSubtopology { .. }) =
804                EmbeddedTask::for_subtopology(&built, "2")
805        );
806    }
807
808    #[test]
809    fn new_runs_every_subtopology() {
810        let built = two_readers_of_one_topic();
811        let mut task = EmbeddedTask::new(&built).unwrap();
812        check!(task.store_names() == vec!["counts-a".to_string(), "counts-b".to_string()]);
813        pipe_word(&mut task, 0, "hello");
814        let mut outputs: Vec<(String, Option<Bytes>)> = task
815            .take_output()
816            .into_iter()
817            .map(|r| (r.topic, r.key))
818            .collect();
819        outputs.sort();
820        check!(
821            outputs
822                == vec![
823                    ("out-a".to_string(), Some(Bytes::from_static(b"HELLO"))),
824                    ("out-b".to_string(), Some(Bytes::from_static(b"hello"))),
825                ]
826        );
827        check!(task.drain_changelogs().len() == 2);
828    }
829
830    #[test]
831    fn for_subtopology_scopes_a_repartition_chain() {
832        let built = two_subtopologies();
833
834        // Subtopology 0 reads `in` and writes the repartition topic; it has no
835        // store.
836        let mut up = EmbeddedTask::for_subtopology(&built, "0").unwrap();
837        check!(up.store_names().is_empty());
838        up.pipe("in", 0, 0, Some(b"k"), b"word", 0).unwrap();
839        let out = up.take_output();
840        check!(out.len() == 1);
841        check!(out[0].topic == "app-counts-repartition");
842        check!(up.drain_changelogs().is_empty());
843
844        // Subtopology 1 ignores `in`, reads the repartition topic and counts.
845        let mut down = EmbeddedTask::for_subtopology(&built, "1").unwrap();
846        check!(down.store_names() == vec!["counts".to_string()]);
847        down.pipe("in", 0, 0, Some(b"k"), b"word", 0).unwrap();
848        check!(down.take_output().is_empty());
849        down.pipe(
850            &out[0].topic,
851            0,
852            0,
853            out[0].key.as_deref(),
854            out[0].value.as_deref().unwrap_or_default(),
855            out[0].timestamp,
856        )
857        .unwrap();
858        check!(down.take_output()[0].topic == "out");
859        check!(down.drain_changelogs()[0].topic == "app-counts-changelog");
860    }
861
862    #[test]
863    fn pipe_reports_a_record_the_source_cannot_deserialize() {
864        let mut t = Topology::new();
865        let src: NodeHandle<String, i64> = t.add_source("src", ["in"]);
866        t.add_sink("out", "out", [&src]);
867        let built = t.build("app").unwrap();
868        let mut task = EmbeddedTask::new(&built).unwrap();
869        assert!(let Err(ProcessorError::Serde { .. }) = task.pipe("in", 0, 0, None, b"x", 0));
870        check!(task.take_output().is_empty());
871    }
872
873    #[test]
874    fn flush_caches_forwards_nothing_without_a_record_cache() {
875        let built = filter_map_count();
876        let mut task = EmbeddedTask::new(&built).unwrap();
877        pipe_word(&mut task, 0, "hello");
878        task.take_output();
879        task.drain_changelogs();
880        task.flush_caches().unwrap();
881        check!(task.take_output().is_empty());
882        check!(task.drain_changelogs().is_empty());
883    }
884
885    #[test]
886    fn restore_apply_ignores_a_store_the_task_does_not_hold() {
887        let built = filter_map_count();
888        let mut task = EmbeddedTask::new(&built).unwrap();
889        task.restore_apply("missing", Bytes::from_static(b"k"), Some(i64_bytes(1)), 0);
890        check!(task.dump_store("missing", 10).is_empty());
891        check!(task.drain_changelogs().is_empty());
892        check!(task.store_names() == vec!["counts".to_string()]);
893    }
894
895    /// `in` → group by key → count → `out`, with a repartition in between, so
896    /// the topology has two subtopologies, one repartition topic and one
897    /// changelog.
898    fn two_subtopologies() -> BuiltTopology {
899        let b = StreamsBuilder::new();
900        b.stream::<String, String>(["in"])
901            .map(|k: &String, v: &String| (v.clone(), k.clone()))
902            .group_by_key()
903            .count("counts")
904            .to_stream()
905            .to("out");
906        b.build("app").unwrap()
907    }
908
909    #[test]
910    fn built_topology_lists_its_subtopologies_and_internal_topics() {
911        let built = two_subtopologies();
912        check!(built.subtopology_ids() == vec!["0".to_string(), "1".to_string()]);
913        check!(built.repartition_topics() == vec!["app-counts-repartition".to_string()]);
914        check!(
915            built.changelog_topics()
916                == [("counts".to_string(), "app-counts-changelog".to_string())]
917        );
918        check!(built.source_topics_for("1") == ["app-counts-repartition".to_string()]);
919
920        let mut task = EmbeddedTask::new(&built).unwrap();
921        // Subtopology 0 writes the repartition topic; the embedder produces it
922        // and feeds it back to subtopology 1, whose count logs to the changelog.
923        task.pipe("in", 0, 0, Some(b"k"), b"word", 0).unwrap();
924        let out = task.take_output();
925        check!(out.len() == 1);
926        check!(out[0].topic == "app-counts-repartition");
927        check!(task.drain_changelogs().is_empty());
928        task.pipe(
929            &out[0].topic,
930            0,
931            0,
932            out[0].key.as_deref(),
933            out[0].value.as_deref().unwrap_or_default(),
934            out[0].timestamp,
935        )
936        .unwrap();
937        check!(task.take_output()[0].topic == "out");
938        check!(task.drain_changelogs()[0].topic == "app-counts-changelog");
939    }
940
941    #[test]
942    fn wire_request_is_the_byte_exact_join_topology() {
943        let built = filter_map_count();
944        let expected = WireTopology {
945            epoch: 0,
946            subtopologies: vec![Subtopology {
947                subtopology_id: "0".into(),
948                source_topics: vec!["in".into()],
949                source_topic_regex: Vec::new(),
950                state_changelog_topics: vec![TopicInfo {
951                    name: "app-counts-changelog".into(),
952                    partitions: 0,
953                    replication_factor: -1,
954                    topic_configs: vec![
955                        KeyValue {
956                            key: "cleanup.policy".into(),
957                            value: "compact".into(),
958                            ..Default::default()
959                        },
960                        KeyValue {
961                            key: "message.timestamp.type".into(),
962                            value: "CreateTime".into(),
963                            ..Default::default()
964                        },
965                    ],
966                    ..Default::default()
967                }],
968                repartition_sink_topics: Vec::new(),
969                repartition_source_topics: Vec::new(),
970                copartition_groups: Vec::new(),
971                ..Default::default()
972            }],
973            ..Default::default()
974        };
975        check!(built.to_wire_request() == expected);
976    }
977
978    #[cfg(not(target_family = "wasm"))]
979    #[test]
980    fn wire_request_is_what_the_membership_client_sends() {
981        let built = two_subtopologies();
982        let heartbeat = crate::membership::coordinator::join_heartbeat(
983            "app",
984            "member",
985            "process",
986            None,
987            Time::from_millis(30_000),
988            &built,
989        );
990        check!(heartbeat.topology == Some(built.to_wire_request()));
991    }
992}