1use 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#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct ChangelogRecord {
41 pub topic: String,
43 pub key: Bytes,
45 pub value: Option<Bytes>,
47 pub timestamp: Option<i64>,
51}
52
53pub struct EmbeddedTask {
92 graph: Graph,
93 reuse_source_topics: HashSet<String>,
98}
99
100impl EmbeddedTask {
101 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 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 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 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 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 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 pub fn take_output(&mut self) -> Vec<OutputRecord> {
232 self.graph.take_output()
233 }
234
235 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 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 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 pub fn set_logging(&mut self, on: bool) {
277 self.graph.set_logging(on);
278 }
279
280 #[must_use]
283 pub fn stream_time(&self) -> i64 {
284 self.graph.stream_time
285 }
286
287 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 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 pub fn flush_caches(&mut self) -> Result<(), ProcessorError> {
330 pollster::block_on(self.graph.flush_caches())
331 }
332
333 pub fn clear_stores(&mut self) {
335 pollster::block_on(self.graph.clear_stores());
336 }
337
338 pub fn snapshot_stores(&mut self) -> TaskSnapshot {
344 pollster::block_on(self.graph.snapshot_stores())
345 }
346
347 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 pub fn close(mut self) {
365 pollster::block_on(self.graph.close_processors());
366 }
367
368 #[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 #[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 #[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 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 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 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 pipe_word(&mut task, 1, "");
538 check!(task.take_output().is_empty());
539 check!(task.drain_changelogs().is_empty());
540
541 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 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 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 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 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 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 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 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 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 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 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}