1use std::cell::RefCell;
9use std::collections::BTreeMap;
10use std::collections::HashSet;
11use std::error::Error;
12use std::fmt;
13use std::panic::{catch_unwind, resume_unwind, AssertUnwindSafe};
14use std::rc::Rc;
15
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19use crate::ctx::Ctx;
20use crate::graph::{Graph, GraphNodeOpts};
21use crate::identity::canonical_tuple_key;
22use crate::json::JsonValue;
23use crate::node::{Core, Node, NodeOpts};
24use crate::protocol::{AnyValue, LockId};
25
26pub const PROMPTS_TOPIC: &str = "prompts";
28pub const RESPONSES_TOPIC: &str = "responses";
30pub const INJECTIONS_TOPIC: &str = "injections";
32pub const DEFERRED_TOPIC: &str = "deferred";
34pub const SPAWNS_TOPIC: &str = "spawns";
36pub const CONTEXT_TOPIC: &str = "context";
38pub const TODOS_TOPIC: &str = "todos";
40
41pub const STANDARD_TOPICS: [&str; 7] = [
43 PROMPTS_TOPIC,
44 RESPONSES_TOPIC,
45 INJECTIONS_TOPIC,
46 DEFERRED_TOPIC,
47 SPAWNS_TOPIC,
48 CONTEXT_TOPIC,
49 TODOS_TOPIC,
50];
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum JsonSchemaType {
56 String,
58 Number,
60 Integer,
62 Boolean,
64 Object,
66 Array,
68 Null,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(untagged)]
74pub enum JsonSchemaTypeSpec {
76 Single(JsonSchemaType),
78 AnyOf(Vec<JsonSchemaType>),
80}
81
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83#[serde(untagged)]
84pub enum JsonSchemaAdditionalProperties {
86 Bool(bool),
88 Schema(Box<JsonSchema>),
90}
91
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93#[serde(untagged)]
94pub enum JsonSchemaItems {
96 Schema(Box<JsonSchema>),
98 Tuple(Vec<JsonSchema>),
100}
101
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
104#[serde(deny_unknown_fields)]
105pub struct JsonSchema {
106 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
107 pub schema_type: Option<JsonSchemaTypeSpec>,
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub properties: Option<BTreeMap<String, JsonSchema>>,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub required: Option<Vec<String>>,
115 #[serde(
116 rename = "additionalProperties",
117 skip_serializing_if = "Option::is_none"
118 )]
119 pub additional_properties: Option<JsonSchemaAdditionalProperties>,
121 #[serde(skip_serializing_if = "Option::is_none")]
122 pub items: Option<JsonSchemaItems>,
124 #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
125 pub enum_values: Option<Vec<JsonValue>>,
127 #[serde(rename = "const", skip_serializing_if = "Option::is_none")]
128 pub const_value: Option<JsonValue>,
130 #[serde(rename = "$ref", skip_serializing_if = "Option::is_none")]
131 pub ref_path: Option<String>,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub definitions: Option<BTreeMap<String, JsonSchema>>,
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub description: Option<String>,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub title: Option<String>,
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146pub struct TopicMessage<T> {
147 pub id: String,
149 #[serde(skip_serializing_if = "Option::is_none")]
150 pub schema: Option<JsonSchema>,
152 #[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
153 pub expires_at: Option<String>,
155 #[serde(rename = "correlationId", skip_serializing_if = "Option::is_none")]
156 pub correlation_id: Option<String>,
158 pub payload: T,
160}
161
162#[derive(Clone, Debug, PartialEq)]
164pub struct EventMessage<T> {
165 pub id: String,
167 pub type_: String,
169 pub payload: T,
171 pub key: Option<String>,
173 pub subject_id: Option<String>,
175 pub correlation_id: Option<String>,
177 pub causation_id: Option<String>,
179 pub occurred_at_ms: Option<u64>,
181 pub actor: Option<String>,
183 pub evidence_refs: Vec<String>,
185 pub schema: Option<JsonSchema>,
187 pub metadata: Option<BTreeMap<String, JsonValue>>,
189}
190
191#[derive(Clone, Debug, Default, PartialEq)]
192pub struct EventMessageOptions {
194 pub id: String,
196 pub key: Option<String>,
198 pub subject_id: Option<String>,
200 pub correlation_id: Option<String>,
202 pub causation_id: Option<String>,
204 pub occurred_at_ms: Option<u64>,
206 pub actor: Option<String>,
208 pub evidence_refs: Vec<String>,
210 pub schema: Option<JsonSchema>,
212 pub metadata: Option<BTreeMap<String, JsonValue>>,
214}
215
216pub fn event_message<T>(
218 type_: impl Into<String>,
219 payload: T,
220 opts: EventMessageOptions,
221) -> EventMessage<T> {
222 let type_ = type_.into();
223 assert_non_empty(&type_, "eventMessage.type");
224 assert_non_empty(&opts.id, "eventMessage.id");
225 EventMessage {
226 id: opts.id,
227 type_,
228 payload,
229 key: opts.key,
230 subject_id: opts.subject_id,
231 correlation_id: opts.correlation_id,
232 causation_id: opts.causation_id,
233 occurred_at_ms: opts.occurred_at_ms,
234 actor: opts.actor,
235 evidence_refs: opts.evidence_refs,
236 schema: opts.schema,
237 metadata: opts.metadata,
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct JsonSchemaValidationError {
244 pub path: String,
246 pub message: String,
248}
249
250impl JsonSchemaValidationError {
251 fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
252 Self {
253 path: path.into(),
254 message: message.into(),
255 }
256 }
257}
258
259impl fmt::Display for JsonSchemaValidationError {
260 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261 write!(f, "{}: {}", self.path, self.message)
262 }
263}
264
265impl Error for JsonSchemaValidationError {}
266
267pub type JsonSchemaValidationResult<T> = Result<T, JsonSchemaValidationError>;
269
270pub fn validate_json_schema(
272 schema: &JsonSchema,
273 value: &JsonValue,
274) -> JsonSchemaValidationResult<()> {
275 validate_json_schema_inner(schema, schema, value, "$", &mut Vec::new())
276}
277
278pub fn is_json_schema_valid(schema: &JsonSchema, value: &JsonValue) -> bool {
280 validate_json_schema(schema, value).is_ok()
281}
282
283pub fn validate_topic_message_payload(
285 message: &TopicMessage<JsonValue>,
286) -> JsonSchemaValidationResult<()> {
287 if let Some(schema) = &message.schema {
288 validate_json_schema(schema, &message.payload)?;
289 }
290 Ok(())
291}
292
293fn validate_json_schema_inner(
294 schema: &JsonSchema,
295 root: &JsonSchema,
296 value: &JsonValue,
297 path: &str,
298 refs_seen: &mut Vec<(String, String)>,
299) -> JsonSchemaValidationResult<()> {
300 if let Some(ref_path) = &schema.ref_path {
301 validate_json_schema_ref(root, ref_path, value, path, refs_seen)?;
302 }
303 if let Some(const_value) = &schema.const_value {
304 if value != const_value {
305 return Err(JsonSchemaValidationError::new(
306 path,
307 "value does not match const",
308 ));
309 }
310 }
311 if let Some(enum_values) = &schema.enum_values {
312 if !enum_values.iter().any(|candidate| candidate == value) {
313 return Err(JsonSchemaValidationError::new(
314 path,
315 "value is not one of the allowed enum values",
316 ));
317 }
318 }
319 if let Some(schema_type) = &schema.schema_type {
320 validate_json_schema_type(schema_type, value, path)?;
321 }
322 if let Value::Object(object) = value {
323 validate_json_schema_object(schema, root, object, path, refs_seen)?;
324 }
325 if let Value::Array(items) = value {
326 validate_json_schema_array(schema, root, items, path, refs_seen)?;
327 }
328 Ok(())
329}
330
331fn validate_json_schema_ref(
332 root: &JsonSchema,
333 ref_path: &str,
334 value: &JsonValue,
335 path: &str,
336 refs_seen: &mut Vec<(String, String)>,
337) -> JsonSchemaValidationResult<()> {
338 let name = ref_path.strip_prefix("#/definitions/").ok_or_else(|| {
339 JsonSchemaValidationError::new(path, "only local #/definitions refs are supported")
340 })?;
341 if refs_seen
342 .iter()
343 .any(|(seen_ref, seen_path)| seen_ref == ref_path && seen_path == path)
344 {
345 return Err(JsonSchemaValidationError::new(
346 path,
347 format!("cyclic JSON schema ref '{ref_path}'"),
348 ));
349 }
350 let definitions = root.definitions.as_ref().ok_or_else(|| {
351 JsonSchemaValidationError::new(path, format!("unknown JSON schema ref '{ref_path}'"))
352 })?;
353 let referenced = definitions.get(name).ok_or_else(|| {
354 JsonSchemaValidationError::new(path, format!("unknown JSON schema ref '{ref_path}'"))
355 })?;
356 refs_seen.push((ref_path.to_owned(), path.to_owned()));
357 let result = validate_json_schema_inner(referenced, root, value, path, refs_seen);
358 refs_seen.pop();
359 result
360}
361
362fn validate_json_schema_type(
363 schema_type: &JsonSchemaTypeSpec,
364 value: &JsonValue,
365 path: &str,
366) -> JsonSchemaValidationResult<()> {
367 let ok = match schema_type {
368 JsonSchemaTypeSpec::Single(expected) => json_value_matches_type(value, *expected),
369 JsonSchemaTypeSpec::AnyOf(expected) => expected
370 .iter()
371 .any(|expected| json_value_matches_type(value, *expected)),
372 };
373 if ok {
374 Ok(())
375 } else {
376 Err(JsonSchemaValidationError::new(
377 path,
378 format!(
379 "expected {}, got {}",
380 json_schema_type_label(schema_type),
381 json_value_type_label(value)
382 ),
383 ))
384 }
385}
386
387fn json_value_matches_type(value: &JsonValue, expected: JsonSchemaType) -> bool {
388 match expected {
389 JsonSchemaType::String => value.is_string(),
390 JsonSchemaType::Number => value.is_number(),
391 JsonSchemaType::Integer => {
392 value.as_i64().is_some()
393 || value.as_u64().is_some()
394 || value
395 .as_f64()
396 .is_some_and(|number| number.is_finite() && number.fract() == 0.0)
397 }
398 JsonSchemaType::Boolean => value.is_boolean(),
399 JsonSchemaType::Object => value.is_object(),
400 JsonSchemaType::Array => value.is_array(),
401 JsonSchemaType::Null => value.is_null(),
402 }
403}
404
405fn json_schema_type_label(schema_type: &JsonSchemaTypeSpec) -> String {
406 match schema_type {
407 JsonSchemaTypeSpec::Single(expected) => format!("{expected:?}").to_lowercase(),
408 JsonSchemaTypeSpec::AnyOf(expected) => expected
409 .iter()
410 .map(|expected| format!("{expected:?}").to_lowercase())
411 .collect::<Vec<_>>()
412 .join("|"),
413 }
414}
415
416fn json_value_type_label(value: &JsonValue) -> &'static str {
417 match value {
418 Value::Null => "null",
419 Value::Bool(_) => "boolean",
420 Value::Number(_) => "number",
421 Value::String(_) => "string",
422 Value::Array(_) => "array",
423 Value::Object(_) => "object",
424 }
425}
426
427fn validate_json_schema_object(
428 schema: &JsonSchema,
429 root: &JsonSchema,
430 object: &serde_json::Map<String, Value>,
431 path: &str,
432 refs_seen: &mut Vec<(String, String)>,
433) -> JsonSchemaValidationResult<()> {
434 if let Some(required) = &schema.required {
435 for key in required {
436 if !object.contains_key(key) {
437 return Err(JsonSchemaValidationError::new(
438 path,
439 format!("missing required property '{key}'"),
440 ));
441 }
442 }
443 }
444 let empty_properties = BTreeMap::new();
445 let properties = schema.properties.as_ref().unwrap_or(&empty_properties);
446 for (key, property_schema) in properties {
447 if let Some(property_value) = object.get(key) {
448 validate_json_schema_inner(
449 property_schema,
450 root,
451 property_value,
452 &format!("{path}.{key}"),
453 refs_seen,
454 )?;
455 }
456 }
457 validate_additional_properties(schema, root, object, properties, path, refs_seen)?;
458 Ok(())
459}
460
461fn validate_additional_properties(
462 schema: &JsonSchema,
463 root: &JsonSchema,
464 object: &serde_json::Map<String, Value>,
465 properties: &BTreeMap<String, JsonSchema>,
466 path: &str,
467 refs_seen: &mut Vec<(String, String)>,
468) -> JsonSchemaValidationResult<()> {
469 let Some(additional_properties) = &schema.additional_properties else {
470 return Ok(());
471 };
472 for (key, value) in object {
473 if properties.contains_key(key) {
474 continue;
475 }
476 match additional_properties {
477 JsonSchemaAdditionalProperties::Bool(true) => {}
478 JsonSchemaAdditionalProperties::Bool(false) => {
479 return Err(JsonSchemaValidationError::new(
480 path,
481 format!("unexpected additional property '{key}'"),
482 ));
483 }
484 JsonSchemaAdditionalProperties::Schema(additional_schema) => {
485 validate_json_schema_inner(
486 additional_schema,
487 root,
488 value,
489 &format!("{path}.{key}"),
490 refs_seen,
491 )?;
492 }
493 }
494 }
495 Ok(())
496}
497
498fn validate_json_schema_array(
499 schema: &JsonSchema,
500 root: &JsonSchema,
501 values: &[Value],
502 path: &str,
503 refs_seen: &mut Vec<(String, String)>,
504) -> JsonSchemaValidationResult<()> {
505 let Some(items) = &schema.items else {
506 return Ok(());
507 };
508 match items {
509 JsonSchemaItems::Schema(item_schema) => {
510 for (index, value) in values.iter().enumerate() {
511 validate_json_schema_inner(
512 item_schema,
513 root,
514 value,
515 &format!("{path}[{index}]"),
516 refs_seen,
517 )?;
518 }
519 }
520 JsonSchemaItems::Tuple(item_schemas) => {
521 for (index, item_schema) in item_schemas.iter().enumerate() {
522 if let Some(value) = values.get(index) {
523 validate_json_schema_inner(
524 item_schema,
525 root,
526 value,
527 &format!("{path}[{index}]"),
528 refs_seen,
529 )?;
530 }
531 }
532 }
533 }
534 Ok(())
535}
536
537#[derive(Debug, Clone, PartialEq)]
538pub struct DataIssue {
540 pub kind: String,
542 pub code: String,
544 pub message: String,
546 pub severity: String,
548 pub source: String,
550 pub topic: Option<String>,
552 pub details: Option<String>,
554}
555
556#[derive(Debug, Clone, PartialEq)]
557pub struct MessageEnvelope<T = AnyValue> {
559 pub topic: String,
561 pub seq: u64,
563 pub payload: T,
565 pub key: Option<String>,
567 pub timestamp_ms: u64,
569 pub command_id: Option<String>,
571 pub idempotency_key: Option<String>,
573}
574
575impl MessageEnvelope<AnyValue> {
576 pub fn payload_as<T: 'static>(&self) -> Option<Rc<T>> {
578 self.payload.clone().downcast::<T>().ok()
579 }
580}
581
582#[derive(Debug, Clone, Copy, PartialEq, Eq)]
583pub enum MessageBusTopicPolicy {
585 Strict,
587 CreateAsFact,
589}
590
591#[derive(Debug, Clone, PartialEq, Eq, Default)]
592pub struct MessageBusRetentionPolicy {
594 pub max_messages: Option<usize>,
596 pub max_age_ms: Option<u64>,
598}
599
600#[derive(Debug, Clone, Copy, PartialEq, Eq)]
601pub enum MessageBusDedupeAction {
603 Status,
605 Issue,
607}
608
609#[derive(Debug, Clone, PartialEq, Eq)]
610pub struct MessageBusDedupePolicy {
612 pub command_id: MessageBusDedupeAction,
614}
615
616impl Default for MessageBusDedupePolicy {
617 fn default() -> Self {
618 Self {
619 command_id: MessageBusDedupeAction::Status,
620 }
621 }
622}
623
624#[derive(Clone)]
625pub struct MessageBusOptions {
627 pub name: String,
629 pub topics: Vec<String>,
631 pub topic_policy: MessageBusTopicPolicy,
633 pub retention: MessageBusRetentionPolicy,
635 pub dedupe: MessageBusDedupePolicy,
637 pub now: Rc<dyn Fn() -> u64>,
639}
640
641impl Default for MessageBusOptions {
642 fn default() -> Self {
643 Self {
644 name: "messageBus".to_owned(),
645 topics: Vec::new(),
646 topic_policy: MessageBusTopicPolicy::Strict,
647 retention: MessageBusRetentionPolicy::default(),
648 dedupe: MessageBusDedupePolicy::default(),
649 now: Rc::new(|| 0),
650 }
651 }
652}
653
654impl MessageBusOptions {
655 pub fn named(name: impl Into<String>) -> Self {
657 Self {
658 name: name.into(),
659 ..Self::default()
660 }
661 }
662
663 pub fn with_topics(mut self, topics: impl IntoIterator<Item = impl Into<String>>) -> Self {
665 self.topics = topics.into_iter().map(Into::into).collect();
666 self
667 }
668
669 pub fn with_topic_policy(mut self, policy: MessageBusTopicPolicy) -> Self {
671 self.topic_policy = policy;
672 self
673 }
674
675 pub fn with_retention(mut self, retention: MessageBusRetentionPolicy) -> Self {
677 self.retention = retention;
678 self
679 }
680
681 pub fn with_dedupe(mut self, dedupe: MessageBusDedupePolicy) -> Self {
683 self.dedupe = dedupe;
684 self
685 }
686
687 pub fn with_now(mut self, now: impl Fn() -> u64 + 'static) -> Self {
689 self.now = Rc::new(now);
690 self
691 }
692}
693
694#[derive(Debug, Clone, PartialEq)]
695pub enum MessageBusCommand<T = AnyValue> {
697 EnsureTopic {
699 topic: String,
701 command_id: Option<String>,
703 },
704 CloseTopic {
706 topic: String,
708 command_id: Option<String>,
710 },
711 Publish {
713 topic: String,
715 payload: T,
717 key: Option<String>,
719 command_id: Option<String>,
721 idempotency_key: Option<String>,
723 },
724 TopicPolicy {
726 topic_policy: MessageBusTopicPolicy,
728 command_id: Option<String>,
730 },
731 Ack {
733 topic: String,
735 subscription_id: String,
737 seq: u64,
739 command_id: Option<String>,
741 },
742 Seek {
744 topic: String,
746 subscription_id: String,
748 next_seq: u64,
750 command_id: Option<String>,
752 },
753 CloseSubscription {
755 topic: String,
757 subscription_id: String,
759 command_id: Option<String>,
761 },
762}
763
764impl<T> MessageBusCommand<T> {
765 fn topic(&self) -> Option<&str> {
766 match self {
767 Self::EnsureTopic { topic, .. }
768 | Self::CloseTopic { topic, .. }
769 | Self::Publish { topic, .. }
770 | Self::Ack { topic, .. }
771 | Self::Seek { topic, .. }
772 | Self::CloseSubscription { topic, .. } => Some(topic),
773 Self::TopicPolicy { .. } => None,
774 }
775 }
776
777 fn command_id(&self) -> Option<&str> {
778 match self {
779 Self::EnsureTopic { command_id, .. }
780 | Self::CloseTopic { command_id, .. }
781 | Self::Publish { command_id, .. }
782 | Self::TopicPolicy { command_id, .. }
783 | Self::Ack { command_id, .. }
784 | Self::Seek { command_id, .. }
785 | Self::CloseSubscription { command_id, .. } => command_id.as_deref(),
786 }
787 }
788}
789
790#[derive(Debug, Clone, Copy, PartialEq, Eq)]
791pub enum MessageBusStatusKind {
793 TopicCreated,
795 TopicClosed,
797 MessagePublished,
799 RetentionTrimmed,
801 DuplicateCommand,
803 SubscriptionOpened,
805 SubscriptionAcked,
807 SubscriptionSought,
809 SubscriptionClosed,
811}
812
813#[derive(Debug, Clone, PartialEq)]
814pub struct MessageBusStatus {
816 pub kind: MessageBusStatusKind,
818 pub topic: Option<String>,
820 pub seq: Option<u64>,
822 pub head_seq: Option<u64>,
824 pub subscription_id: Option<String>,
826 pub next_seq: Option<u64>,
828 pub command_id: Option<String>,
830 pub issue_code: Option<String>,
832 pub timestamp_ms: u64,
834 pub details: Option<String>,
836}
837
838#[derive(Debug, Clone, PartialEq, Eq)]
839pub struct MessageBusCatalogEntry {
841 pub topic: String,
843 pub closed: bool,
845 pub head_seq: u64,
847 pub next_seq: u64,
849 pub message_count: usize,
851}
852
853#[derive(Debug, Clone, PartialEq, Eq)]
854pub struct MessageBusCatalogPage {
856 pub topics: Vec<MessageBusCatalogEntry>,
858 pub next_after_topic: Option<String>,
860 pub has_more: bool,
862}
863
864#[derive(Debug, Clone, PartialEq)]
865pub struct MessageBusDeadLetterEntry<T = AnyValue> {
867 pub entry_seq: u64,
869 pub topic: Option<String>,
871 pub command: Option<MessageBusCommand<T>>,
873 pub message: Option<MessageEnvelope<T>>,
875 pub issue: DataIssue,
877 pub timestamp_ms: u64,
879}
880
881#[derive(Debug, Clone, PartialEq)]
882pub struct MessageBusDeadLetterPage<T = AnyValue> {
884 pub entries: Vec<MessageBusDeadLetterEntry<T>>,
886 pub next_after_entry_seq: Option<u64>,
888 pub has_more: bool,
890}
891
892#[derive(Debug, Clone, PartialEq)]
893pub struct MessageBusTopicPage<T = AnyValue> {
895 pub topic: String,
897 pub messages: Vec<MessageEnvelope<T>>,
899 pub from_seq: u64,
901 pub through_seq: Option<u64>,
903 pub next_after_seq: Option<u64>,
905 pub has_more: bool,
907}
908
909#[derive(Debug, Clone, PartialEq, Eq)]
910pub struct MessageBusCursor {
912 pub topic: String,
914 pub subscription_id: String,
916 pub next_seq: u64,
918 pub closed: bool,
920 pub retention_gap: bool,
922 pub head_seq: u64,
924}
925
926#[derive(Debug, Clone, PartialEq)]
927pub struct MessageBusAvailablePage<T = AnyValue> {
929 pub topic: String,
931 pub subscription_id: String,
933 pub cursor: MessageBusCursor,
935 pub messages: Vec<MessageEnvelope<T>>,
937 pub from_seq: u64,
939 pub through_seq: Option<u64>,
941 pub next_after_seq: Option<u64>,
943 pub has_more: bool,
945}
946
947#[derive(Debug, Clone, PartialEq, Eq)]
948pub struct MessageBusCatalogParams {
950 pub limit: Option<usize>,
952 pub after_topic: Option<String>,
954 pub include_closed: bool,
956}
957
958#[derive(Debug, Clone, PartialEq, Eq)]
959pub struct MessageBusDeadLetterParams {
961 pub limit: Option<usize>,
963 pub after_entry_seq: Option<u64>,
965 pub topic: Option<String>,
967 pub code: Option<String>,
969}
970
971#[derive(Debug, Clone, PartialEq, Eq)]
972pub struct MessageBusTopicParams {
974 pub limit: Option<usize>,
976 pub after_seq: Option<u64>,
978}
979
980#[derive(Debug, Clone, PartialEq, Eq)]
981pub struct MessageBusAvailableParams {
983 pub limit: Option<usize>,
985 pub after_seq: Option<u64>,
987}
988
989#[derive(Clone)]
990pub struct MessageBusPullProjection<TPage> {
992 pub snapshot: Node<TPage>,
994 pub snapshot_pull_id: LockId,
996 pub status: Node<MessageBusStatus>,
998 pub issues: Node<DataIssue>,
1000}
1001
1002pub type MessageBusTopicProjection<T = AnyValue> = MessageBusPullProjection<MessageBusTopicPage<T>>;
1004
1005#[derive(Clone)]
1006pub struct MessageBusSubscription<T = AnyValue> {
1008 pub available: Node<MessageBusAvailablePage<T>>,
1010 pub available_pull_id: LockId,
1012 pub cursor: Node<MessageBusCursor>,
1014 pub status: Node<MessageBusStatus>,
1016 pub issues: Node<DataIssue>,
1018 commands: Node<MessageBusCommand<T>>,
1019 topic: String,
1020 subscription_id: String,
1021}
1022
1023#[derive(Clone)]
1024pub struct MessageBus<T = AnyValue> {
1026 graph: Graph,
1027 name: Rc<String>,
1028 pub commands: Node<MessageBusCommand<T>>,
1030 pub messages: Node<MessageEnvelope<T>>,
1032 pub status: Node<MessageBusStatus>,
1034 pub issues: Node<DataIssue>,
1036 state: Rc<RefCell<MessageBusState<T>>>,
1037 command_sources: Rc<RefCell<Vec<Core>>>,
1038 _runtime_retain: Rc<RetainNode>,
1039}
1040
1041#[derive(Clone)]
1042pub struct ToTopicBundle<T> {
1044 pub commands: Node<MessageBusCommand<T>>,
1046}
1047
1048struct RetainNode {
1049 release: RefCell<Option<Box<dyn FnOnce()>>>,
1050}
1051
1052impl RetainNode {
1053 fn new(release: Box<dyn FnOnce()>) -> Self {
1054 Self {
1055 release: RefCell::new(Some(release)),
1056 }
1057 }
1058}
1059
1060impl Drop for RetainNode {
1061 fn drop(&mut self) {
1062 if let Some(release) = self.release.borrow_mut().take() {
1063 release();
1064 }
1065 }
1066}
1067
1068#[derive(Clone)]
1069struct TopicState<T> {
1070 closed: bool,
1071 head_seq: u64,
1072 next_seq: u64,
1073 messages: Vec<MessageEnvelope<T>>,
1074}
1075
1076#[derive(Debug, Clone, PartialEq, Eq)]
1077struct SubscriptionState {
1078 topic: String,
1079 subscription_id: String,
1080 next_seq: u64,
1081 closed: bool,
1082 retention_gap: bool,
1083}
1084
1085struct MessageBusState<T = AnyValue> {
1086 now: Rc<dyn Fn() -> u64>,
1087 topic_policy: MessageBusTopicPolicy,
1088 retention: MessageBusRetentionPolicy,
1089 dedupe: MessageBusDedupePolicy,
1090 topics: BTreeMap<String, TopicState<T>>,
1091 subscriptions: BTreeMap<String, SubscriptionState>,
1092 seen_command_ids: HashSet<String>,
1093 seen_idempotency_keys: HashSet<String>,
1094 dead_letters: Vec<MessageBusDeadLetterEntry<T>>,
1095 dead_letter_seq: u64,
1096}
1097
1098#[derive(Clone)]
1099enum RuntimeEvent<T = AnyValue> {
1100 Message(MessageEnvelope<T>),
1101 Status(MessageBusStatus),
1102 Issue(DataIssue),
1103}
1104
1105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1106pub enum MessageBusSubscriptionFrom {
1108 Earliest,
1110 Latest,
1112 Seq(u64),
1114}
1115
1116#[derive(Clone)]
1117pub struct MessageBusSubscriptionOptions {
1119 pub topic: String,
1121 pub subscription_id: String,
1123 pub from: MessageBusSubscriptionFrom,
1125 pub name: Option<String>,
1127}
1128
1129impl MessageBusSubscriptionOptions {
1130 pub fn new(topic: impl Into<String>, subscription_id: impl Into<String>) -> Self {
1132 Self {
1133 topic: topic.into(),
1134 subscription_id: subscription_id.into(),
1135 from: MessageBusSubscriptionFrom::Earliest,
1136 name: None,
1137 }
1138 }
1139
1140 pub fn from(mut self, from: MessageBusSubscriptionFrom) -> Self {
1142 self.from = from;
1143 self
1144 }
1145
1146 pub fn named(mut self, name: impl Into<String>) -> Self {
1148 self.name = Some(name.into());
1149 self
1150 }
1151}
1152
1153impl<T: Clone + 'static> MessageBus<T> {
1154 pub fn new(graph: &Graph, topics: impl IntoIterator<Item = impl Into<String>>) -> Self {
1156 Self::with_options(graph, MessageBusOptions::default().with_topics(topics))
1157 }
1158
1159 pub fn with_options(graph: &Graph, opts: MessageBusOptions) -> Self {
1161 let name = Rc::new(opts.name);
1162 let command_sources = Rc::new(RefCell::new(Vec::new()));
1163 let commands = graph.node_opts::<MessageBusCommand<T>, _>(
1164 Vec::new(),
1165 message_bus_command_body::<T>(0),
1166 node_opts(format!("{name}/commands"), "messageBusCommands"),
1167 );
1168 let initial_topics = unique_topics(opts.topics);
1169 let state = Rc::new(RefCell::new(MessageBusState {
1170 now: opts.now,
1171 topic_policy: opts.topic_policy,
1172 retention: opts.retention,
1173 dedupe: opts.dedupe,
1174 topics: initial_topics
1175 .into_iter()
1176 .map(|topic| (topic, make_topic_state()))
1177 .collect(),
1178 subscriptions: BTreeMap::new(),
1179 seen_command_ids: HashSet::new(),
1180 seen_idempotency_keys: HashSet::new(),
1181 dead_letters: Vec::new(),
1182 dead_letter_seq: 0,
1183 }));
1184 let runtime_state = state.clone();
1185 let runtime = graph.node_opts::<RuntimeEvent<T>, _>(
1186 vec![commands.erased()],
1187 move |ctx| {
1188 for command in ctx.batch::<MessageBusCommand<T>>(0) {
1189 let events = {
1190 let mut state = runtime_state.borrow_mut();
1191 reduce_message_bus_command(&mut state, (*command).clone())
1192 };
1193 for event in events {
1194 ctx.emit(event);
1195 }
1196 }
1197 },
1198 node_opts(format!("{name}/runtime"), "messageBusRuntime"),
1199 );
1200 let runtime_retain = Rc::new(RetainNode::new(
1201 graph.retain(&runtime, &format!("{name}.messageBus.runtime")),
1202 ));
1203 let messages = graph.node_opts::<MessageEnvelope<T>, _>(
1204 vec![runtime.erased()],
1205 move |ctx| {
1206 for event in ctx.batch::<RuntimeEvent<T>>(0) {
1207 if let RuntimeEvent::Message(message) = event.as_ref() {
1208 ctx.emit(message.clone());
1209 }
1210 }
1211 },
1212 node_opts(format!("{name}/messages"), "messageBusMessages"),
1213 );
1214 let status = graph.node_opts::<MessageBusStatus, _>(
1215 vec![runtime.erased()],
1216 move |ctx| {
1217 for event in ctx.batch::<RuntimeEvent<T>>(0) {
1218 if let RuntimeEvent::Status(status) = event.as_ref() {
1219 ctx.emit(status.clone());
1220 }
1221 }
1222 },
1223 node_opts(format!("{name}/status"), "messageBusStatus"),
1224 );
1225 let issues = graph.node_opts::<DataIssue, _>(
1226 vec![runtime.erased()],
1227 move |ctx| {
1228 for event in ctx.batch::<RuntimeEvent<T>>(0) {
1229 if let RuntimeEvent::Issue(issue) = event.as_ref() {
1230 ctx.emit(issue.clone());
1231 }
1232 }
1233 },
1234 node_opts(format!("{name}/issues"), "messageBusIssues"),
1235 );
1236 Self {
1237 graph: graph.clone(),
1238 name,
1239 commands,
1240 messages,
1241 status,
1242 issues,
1243 state,
1244 command_sources,
1245 _runtime_retain: runtime_retain,
1246 }
1247 }
1248
1249 pub fn ensure_topic(
1251 &self,
1252 topic: impl Into<String>,
1253 command_id: Option<String>,
1254 ) -> MessageBusCommand<T> {
1255 self.publish_command(MessageBusCommand::EnsureTopic {
1256 topic: topic.into(),
1257 command_id,
1258 })
1259 }
1260
1261 pub fn close_topic(
1263 &self,
1264 topic: impl Into<String>,
1265 command_id: Option<String>,
1266 ) -> MessageBusCommand<T> {
1267 self.publish_command(MessageBusCommand::CloseTopic {
1268 topic: topic.into(),
1269 command_id,
1270 })
1271 }
1272
1273 pub fn publish(
1275 &self,
1276 topic: impl Into<String>,
1277 payload: T,
1278 key: Option<String>,
1279 command_id: Option<String>,
1280 idempotency_key: Option<String>,
1281 ) -> MessageBusCommand<T> {
1282 self.publish_command(MessageBusCommand::Publish {
1283 topic: topic.into(),
1284 payload,
1285 key,
1286 command_id,
1287 idempotency_key,
1288 })
1289 }
1290
1291 pub fn set_topic_policy(
1293 &self,
1294 topic_policy: MessageBusTopicPolicy,
1295 command_id: Option<String>,
1296 ) -> MessageBusCommand<T> {
1297 self.publish_command(MessageBusCommand::TopicPolicy {
1298 topic_policy,
1299 command_id,
1300 })
1301 }
1302
1303 pub fn topic(&self, topic: impl Into<String>) -> MessageBusTopicProjection<T> {
1305 self.topic_named(topic, None::<String>)
1306 }
1307
1308 pub fn topic_named(
1310 &self,
1311 topic: impl Into<String>,
1312 name: Option<impl Into<String>>,
1313 ) -> MessageBusTopicProjection<T> {
1314 let topic = topic.into();
1315 assert_topic_key(&topic, "messageBus.topic");
1316 let snapshot_pull_id = LockId::new(format!("{}/{topic}/topicSnapshot", self.name));
1317 let state = self.state.clone();
1318 let topic_for_fn = topic.clone();
1319 let snapshot = self.graph.node_opts::<MessageBusTopicPage<T>, _>(
1320 vec![self.messages.erased()],
1321 move |ctx| {
1322 let params =
1323 pull_params::<MessageBusTopicParams>(ctx).unwrap_or(MessageBusTopicParams {
1324 limit: None,
1325 after_seq: None,
1326 });
1327 ctx.emit(topic_page(&state.borrow(), &topic_for_fn, ¶ms));
1328 },
1329 pull_node_opts(
1330 name.map(Into::into)
1331 .unwrap_or_else(|| format!("{}/{topic}/topic", self.name)),
1332 "messageBusTopicProjection",
1333 snapshot_pull_id.clone(),
1334 ),
1335 );
1336 MessageBusPullProjection {
1337 snapshot,
1338 snapshot_pull_id,
1339 status: self.status.clone(),
1340 issues: self.issues.clone(),
1341 }
1342 }
1343
1344 pub fn catalog(&self) -> MessageBusPullProjection<MessageBusCatalogPage> {
1346 self.catalog_named(None::<String>)
1347 }
1348
1349 pub fn catalog_named(
1351 &self,
1352 name: Option<impl Into<String>>,
1353 ) -> MessageBusPullProjection<MessageBusCatalogPage> {
1354 let snapshot_pull_id = LockId::new(format!("{}/catalogSnapshot", self.name));
1355 let state = self.state.clone();
1356 let snapshot = self.graph.node_opts::<MessageBusCatalogPage, _>(
1357 vec![self.status.erased()],
1358 move |ctx| {
1359 let params = pull_params::<MessageBusCatalogParams>(ctx).unwrap_or(
1360 MessageBusCatalogParams {
1361 limit: None,
1362 after_topic: None,
1363 include_closed: false,
1364 },
1365 );
1366 ctx.emit(catalog_page(&state.borrow(), ¶ms));
1367 },
1368 pull_node_opts(
1369 name.map(Into::into)
1370 .unwrap_or_else(|| format!("{}/catalog", self.name)),
1371 "messageBusCatalog",
1372 snapshot_pull_id.clone(),
1373 ),
1374 );
1375 MessageBusPullProjection {
1376 snapshot,
1377 snapshot_pull_id,
1378 status: self.status.clone(),
1379 issues: self.issues.clone(),
1380 }
1381 }
1382
1383 pub fn dead_letter(&self) -> MessageBusPullProjection<MessageBusDeadLetterPage<T>> {
1385 self.dead_letter_named(None::<String>)
1386 }
1387
1388 pub fn dead_letter_named(
1390 &self,
1391 name: Option<impl Into<String>>,
1392 ) -> MessageBusPullProjection<MessageBusDeadLetterPage<T>> {
1393 let snapshot_pull_id = LockId::new(format!("{}/deadLetterSnapshot", self.name));
1394 let state = self.state.clone();
1395 let snapshot = self.graph.node_opts::<MessageBusDeadLetterPage<T>, _>(
1396 vec![self.issues.erased(), self.status.erased()],
1397 move |ctx| {
1398 let params = pull_params::<MessageBusDeadLetterParams>(ctx).unwrap_or(
1399 MessageBusDeadLetterParams {
1400 limit: None,
1401 after_entry_seq: None,
1402 topic: None,
1403 code: None,
1404 },
1405 );
1406 ctx.emit(dead_letter_page(&state.borrow(), ¶ms));
1407 },
1408 pull_node_opts(
1409 name.map(Into::into)
1410 .unwrap_or_else(|| format!("{}/deadLetter", self.name)),
1411 "messageBusDeadLetter",
1412 snapshot_pull_id.clone(),
1413 ),
1414 );
1415 MessageBusPullProjection {
1416 snapshot,
1417 snapshot_pull_id,
1418 status: self.status.clone(),
1419 issues: self.issues.clone(),
1420 }
1421 }
1422
1423 pub fn subscription(&self, opts: MessageBusSubscriptionOptions) -> MessageBusSubscription<T> {
1425 assert_topic_key(&opts.topic, "messageBus.subscription");
1426 assert_non_empty(&opts.subscription_id, "messageBus.subscriptionId");
1427 let (sub, opened, issue) = ensure_subscription(&mut self.state.borrow_mut(), &opts);
1428 let available_pull_id = LockId::new(format!(
1429 "{}/{}/{}/available",
1430 self.name, opts.topic, opts.subscription_id
1431 ));
1432 let projection_name = opts
1433 .name
1434 .clone()
1435 .unwrap_or_else(|| format!("{}/{}/{}", self.name, opts.topic, opts.subscription_id));
1436 let available_state = self.state.clone();
1437 let available_sub = sub.clone();
1438 let available = self.graph.node_opts::<MessageBusAvailablePage<T>, _>(
1439 vec![self.messages.erased(), self.status.erased()],
1440 move |ctx| {
1441 let params = pull_params::<MessageBusAvailableParams>(ctx).unwrap_or(
1442 MessageBusAvailableParams {
1443 limit: None,
1444 after_seq: None,
1445 },
1446 );
1447 ctx.emit(available_page(
1448 &available_state.borrow(),
1449 &available_sub,
1450 ¶ms,
1451 ));
1452 },
1453 pull_node_opts(
1454 format!("{projection_name}/available"),
1455 "messageBusSubscriptionAvailable",
1456 available_pull_id.clone(),
1457 ),
1458 );
1459 let cursor_state = self.state.clone();
1460 let cursor_sub = sub.clone();
1461 let cursor = self.graph.node_opts::<MessageBusCursor, _>(
1462 vec![self.status.erased()],
1463 move |ctx| {
1464 for status in ctx.batch::<MessageBusStatus>(0) {
1465 let subscription_moved = status.topic.as_deref()
1466 == Some(cursor_sub.topic.as_str())
1467 && status.subscription_id.as_deref()
1468 == Some(cursor_sub.subscription_id.as_str())
1469 && matches!(
1470 status.kind,
1471 MessageBusStatusKind::SubscriptionOpened
1472 | MessageBusStatusKind::SubscriptionAcked
1473 | MessageBusStatusKind::SubscriptionSought
1474 | MessageBusStatusKind::SubscriptionClosed
1475 );
1476 let retention_changed = status.topic.as_deref()
1477 == Some(cursor_sub.topic.as_str())
1478 && status.kind == MessageBusStatusKind::RetentionTrimmed;
1479 if subscription_moved || retention_changed {
1480 ctx.emit(cursor_snapshot(&cursor_state.borrow(), &cursor_sub));
1481 }
1482 }
1483 },
1484 node_opts(
1485 format!("{projection_name}/cursor"),
1486 "messageBusSubscriptionCursor",
1487 ),
1488 );
1489 if opened {
1490 let opened_status = status_fact(
1491 &self.state.borrow(),
1492 MessageBusStatusKind::SubscriptionOpened,
1493 StatusFields {
1494 topic: Some(sub.topic.clone()),
1495 subscription_id: Some(sub.subscription_id.clone()),
1496 next_seq: Some(sub.next_seq),
1497 details: Some(format!("from={:?}", opts.from)),
1498 ..StatusFields::default()
1499 },
1500 );
1501 self.status.set(opened_status);
1502 }
1503 if let Some(issue) = issue {
1504 self.issues.set(issue);
1505 }
1506 MessageBusSubscription {
1507 available,
1508 available_pull_id,
1509 cursor,
1510 status: self.status.clone(),
1511 issues: self.issues.clone(),
1512 commands: self.commands.clone(),
1513 topic: sub.topic,
1514 subscription_id: sub.subscription_id,
1515 }
1516 }
1517
1518 fn publish_command(&self, command: MessageBusCommand<T>) -> MessageBusCommand<T> {
1519 self.commands.set(command.clone());
1520 command
1521 }
1522}
1523
1524impl<T: Clone + 'static> MessageBusSubscription<T> {
1525 pub fn ack(&self, seq: u64, command_id: Option<String>) -> MessageBusCommand<T> {
1527 let command = MessageBusCommand::Ack {
1528 topic: self.topic.clone(),
1529 subscription_id: self.subscription_id.clone(),
1530 seq,
1531 command_id,
1532 };
1533 self.commands.set(command.clone());
1534 command
1535 }
1536
1537 pub fn seek(&self, next_seq: u64, command_id: Option<String>) -> MessageBusCommand<T> {
1539 let command = MessageBusCommand::Seek {
1540 topic: self.topic.clone(),
1541 subscription_id: self.subscription_id.clone(),
1542 next_seq,
1543 command_id,
1544 };
1545 self.commands.set(command.clone());
1546 command
1547 }
1548
1549 pub fn close(&self, command_id: Option<String>) -> MessageBusCommand<T> {
1551 let command = MessageBusCommand::CloseSubscription {
1552 topic: self.topic.clone(),
1553 subscription_id: self.subscription_id.clone(),
1554 command_id,
1555 };
1556 self.commands.set(command.clone());
1557 command
1558 }
1559}
1560
1561pub fn message_bus<T: Clone + 'static>(graph: &Graph, opts: MessageBusOptions) -> MessageBus<T> {
1563 MessageBus::with_options(graph, opts)
1564}
1565
1566pub fn to_topic<T: Clone + 'static>(
1568 graph: &Graph,
1569 source: &Node<T>,
1570 bus: &MessageBus<T>,
1571 topic: impl Into<String>,
1572 name: impl Into<String>,
1573) -> ToTopicBundle<T> {
1574 let topic = topic.into();
1575 assert_topic_key(&topic, "toTopic");
1576 assert!(
1577 source.erased().same_graph(&bus.commands.erased()),
1578 "toTopic: bus and source graph must match"
1579 );
1580 assert!(
1581 !is_reachable_upstream(&source.erased(), &bus.commands.erased()),
1582 "toTopic: source already depends on bus command path"
1583 );
1584 let topic_for_fn = topic.clone();
1585 let commands = graph.node_opts::<MessageBusCommand<T>, _>(
1586 vec![source.erased()],
1587 move |ctx| {
1588 for value in ctx.batch::<T>(0) {
1589 ctx.emit(MessageBusCommand::Publish {
1590 topic: topic_for_fn.clone(),
1591 payload: (*value).clone(),
1592 key: None,
1593 command_id: None,
1594 idempotency_key: None,
1595 });
1596 }
1597 },
1598 node_opts(name.into(), "messageBusToTopic"),
1599 );
1600 let command_source = commands.erased();
1601 bus.command_sources
1602 .borrow_mut()
1603 .push(command_source.clone());
1604 let command_sources = bus.command_sources.borrow().clone();
1605 let command_source_count = command_sources.len();
1606 let rewire = catch_unwind(AssertUnwindSafe(|| {
1607 bus.commands.replace_deps(
1608 command_sources,
1609 message_bus_command_body::<T>(command_source_count),
1610 );
1611 }));
1612 if let Err(panic) = rewire {
1613 bus.command_sources
1614 .borrow_mut()
1615 .retain(|source| !source.ptr_eq(&command_source));
1616 graph.release_nodes(&[commands.erased()], "toTopic failed command wiring");
1617 resume_unwind(panic);
1618 }
1619 ToTopicBundle { commands }
1620}
1621
1622pub(crate) fn attach_message_bus_deferred_command_sink<T: Clone + 'static>(
1623 graph: &Graph,
1624 bus: &MessageBus<T>,
1625 commands: &Node<MessageBusCommand<T>>,
1626) -> Box<dyn FnOnce()> {
1627 assert!(
1628 commands.erased().same_graph(&bus.commands.erased()),
1629 "messageBus: command sink graph must match"
1630 );
1631 let _ = graph;
1632 let bus_commands = bus.commands.erased();
1633 commands.subscribe(move |msg| {
1634 if let crate::protocol::Message::Data(value) = msg {
1635 if let Ok(command) = value.clone().downcast::<MessageBusCommand<T>>() {
1636 bus_commands.request_down_next(vec![crate::protocol::Message::Data(Rc::new(
1637 (*command).clone(),
1638 ))]);
1639 }
1640 }
1641 })
1642}
1643
1644fn message_bus_command_body<T: Clone + 'static>(
1645 command_source_count: usize,
1646) -> impl Fn(&Ctx) + 'static {
1647 move |ctx| {
1648 for index in 0..command_source_count {
1649 for command in ctx.batch::<MessageBusCommand<T>>(index) {
1650 ctx.emit((*command).clone());
1651 }
1652 }
1653 }
1654}
1655
1656fn reduce_message_bus_command<T: Clone + 'static>(
1657 state: &mut MessageBusState<T>,
1658 command: MessageBusCommand<T>,
1659) -> Vec<RuntimeEvent<T>> {
1660 if let Some(topic) = command.topic() {
1661 if let Some(error) = validate_topic_key(topic, "messageBus") {
1662 return reject_command(state, command, error);
1663 }
1664 }
1665 if let Some(subscription_id) = command_subscription_id(&command) {
1666 if subscription_id.is_empty() {
1667 return reject_command(
1668 state,
1669 command,
1670 "subscriptionId must be a non-empty string".to_owned(),
1671 );
1672 }
1673 }
1674 if let Some(command_id) = command.command_id() {
1675 if state.seen_command_ids.contains(command_id) {
1676 return duplicate_command_events(state, command, "duplicate commandId");
1677 }
1678 state.seen_command_ids.insert(command_id.to_owned());
1679 }
1680 if let MessageBusCommand::Publish {
1681 topic,
1682 idempotency_key: Some(idempotency_key),
1683 ..
1684 } = &command
1685 {
1686 if state
1687 .seen_idempotency_keys
1688 .contains(&idempotency_key_for(topic, idempotency_key))
1689 {
1690 return duplicate_command_events(state, command, "duplicate idempotencyKey");
1691 }
1692 }
1693 match command {
1694 MessageBusCommand::EnsureTopic { topic, command_id } => {
1695 ensure_topic(state, topic, command_id)
1696 }
1697 MessageBusCommand::CloseTopic { topic, command_id } => {
1698 close_topic(state, topic, command_id)
1699 }
1700 MessageBusCommand::TopicPolicy {
1701 topic_policy,
1702 command_id,
1703 } => {
1704 state.topic_policy = topic_policy;
1705 let _ = command_id;
1706 Vec::new()
1707 }
1708 MessageBusCommand::Publish { .. } => publish_message(state, command),
1709 MessageBusCommand::Ack { .. } => ack_subscription(state, command),
1710 MessageBusCommand::Seek { .. } => seek_subscription(state, command),
1711 MessageBusCommand::CloseSubscription { .. } => close_subscription(state, command),
1712 }
1713}
1714
1715fn publish_message<T: Clone + 'static>(
1716 state: &mut MessageBusState<T>,
1717 command: MessageBusCommand<T>,
1718) -> Vec<RuntimeEvent<T>> {
1719 let MessageBusCommand::Publish {
1720 topic,
1721 payload,
1722 key,
1723 command_id,
1724 idempotency_key,
1725 } = command
1726 else {
1727 return Vec::new();
1728 };
1729 let mut events = Vec::new();
1730 if !state.topics.contains_key(&topic) {
1731 if state.topic_policy != MessageBusTopicPolicy::CreateAsFact {
1732 return issue_events(
1733 state,
1734 Some(MessageBusCommand::Publish {
1735 topic,
1736 payload,
1737 key,
1738 command_id,
1739 idempotency_key,
1740 }),
1741 None,
1742 "unknown-topic",
1743 "unknown topic",
1744 );
1745 }
1746 events.extend(ensure_topic(state, topic.clone(), command_id.clone()));
1747 }
1748 let Some(topic_state) = state.topics.get(&topic) else {
1749 return issue_events::<T>(state, None, None, "unknown-topic", "unknown topic");
1750 };
1751 if topic_state.closed {
1752 return issue_events(
1753 state,
1754 Some(MessageBusCommand::Publish {
1755 topic,
1756 payload,
1757 key,
1758 command_id,
1759 idempotency_key,
1760 }),
1761 None,
1762 "closed-topic",
1763 "closed topic",
1764 );
1765 }
1766 let timestamp_ms = timestamp_or_zero(state);
1767 let topic_state = state
1768 .topics
1769 .get_mut(&topic)
1770 .expect("topic checked immediately above");
1771 let message = MessageEnvelope {
1772 topic: topic.clone(),
1773 seq: topic_state.next_seq,
1774 payload,
1775 key,
1776 timestamp_ms,
1777 command_id: command_id.clone(),
1778 idempotency_key: idempotency_key.clone(),
1779 };
1780 topic_state.next_seq += 1;
1781 if let Some(idempotency_key) = idempotency_key {
1782 state
1783 .seen_idempotency_keys
1784 .insert(idempotency_key_for(&topic, &idempotency_key));
1785 }
1786 topic_state.messages.push(message.clone());
1787 events.extend([
1788 RuntimeEvent::Message(message.clone()),
1789 RuntimeEvent::Status(status_fact(
1790 state,
1791 MessageBusStatusKind::MessagePublished,
1792 StatusFields {
1793 topic: Some(topic.clone()),
1794 seq: Some(message.seq),
1795 command_id,
1796 ..StatusFields::default()
1797 },
1798 )),
1799 ]);
1800 events.extend(trim_retention(state, &topic));
1801 events
1802}
1803
1804fn ensure_topic<T: Clone + 'static>(
1805 state: &mut MessageBusState<T>,
1806 topic: String,
1807 command_id: Option<String>,
1808) -> Vec<RuntimeEvent<T>> {
1809 state
1810 .topics
1811 .entry(topic.clone())
1812 .or_insert_with(make_topic_state);
1813 vec![RuntimeEvent::Status(status_fact(
1814 state,
1815 MessageBusStatusKind::TopicCreated,
1816 StatusFields {
1817 topic: Some(topic),
1818 command_id,
1819 ..StatusFields::default()
1820 },
1821 ))]
1822}
1823
1824fn close_topic<T: Clone + 'static>(
1825 state: &mut MessageBusState<T>,
1826 topic: String,
1827 command_id: Option<String>,
1828) -> Vec<RuntimeEvent<T>> {
1829 let Some(topic_state) = state.topics.get_mut(&topic) else {
1830 return issue_events(
1831 state,
1832 Some(MessageBusCommand::CloseTopic { topic, command_id }),
1833 None,
1834 "unknown-topic",
1835 "unknown topic",
1836 );
1837 };
1838 topic_state.closed = true;
1839 vec![RuntimeEvent::Status(status_fact(
1840 state,
1841 MessageBusStatusKind::TopicClosed,
1842 StatusFields {
1843 topic: Some(topic),
1844 command_id,
1845 ..StatusFields::default()
1846 },
1847 ))]
1848}
1849
1850fn trim_retention<T: Clone + 'static>(
1851 state: &mut MessageBusState<T>,
1852 topic_name: &str,
1853) -> Vec<RuntimeEvent<T>> {
1854 let now = timestamp_or_zero(state);
1855 let Some(topic) = state.topics.get_mut(topic_name) else {
1856 return Vec::new();
1857 };
1858 let before_head = topic.head_seq;
1859 if let Some(max_age_ms) = state.retention.max_age_ms {
1860 topic
1861 .messages
1862 .retain(|message| now.saturating_sub(message.timestamp_ms) <= max_age_ms);
1863 }
1864 if let Some(max_messages) = state.retention.max_messages {
1865 if max_messages == 0 {
1866 return issue_events::<T>(
1867 state,
1868 None,
1869 None,
1870 "policy-rejected",
1871 "retention.maxMessages must be positive",
1872 );
1873 }
1874 if topic.messages.len() > max_messages {
1875 let trim_count = topic.messages.len() - max_messages;
1876 topic.messages.drain(0..trim_count);
1877 }
1878 }
1879 topic.head_seq = topic
1880 .messages
1881 .first()
1882 .map_or(topic.next_seq, |message| message.seq);
1883 if topic.head_seq == before_head {
1884 return Vec::new();
1885 }
1886 let head_seq = topic.head_seq;
1887 let trim_count = head_seq.saturating_sub(before_head);
1888 let mut events = vec![RuntimeEvent::Status(status_fact(
1889 state,
1890 MessageBusStatusKind::RetentionTrimmed,
1891 StatusFields {
1892 topic: Some(topic_name.to_owned()),
1893 head_seq: Some(head_seq),
1894 details: Some(format!("trimCount={trim_count}")),
1895 ..StatusFields::default()
1896 },
1897 ))];
1898 let affected = state
1899 .subscriptions
1900 .values_mut()
1901 .filter(|sub| {
1902 !sub.closed && sub.topic == topic_name && sub.next_seq < head_seq && !sub.retention_gap
1903 })
1904 .map(|sub| {
1905 sub.retention_gap = true;
1906 sub.subscription_id.clone()
1907 })
1908 .collect::<Vec<_>>();
1909 for subscription_id in affected {
1910 events.extend(issue_events::<T>(
1911 state,
1912 None,
1913 None,
1914 "retention-gap",
1915 format!("subscription '{subscription_id}' is before retained headSeq"),
1916 ));
1917 }
1918 events
1919}
1920
1921fn ack_subscription<T: Clone + 'static>(
1922 state: &mut MessageBusState<T>,
1923 command: MessageBusCommand<T>,
1924) -> Vec<RuntimeEvent<T>> {
1925 let MessageBusCommand::Ack {
1926 topic,
1927 subscription_id,
1928 seq,
1929 command_id,
1930 } = command
1931 else {
1932 return Vec::new();
1933 };
1934 let Some(topic_state) = state.topics.get(&topic) else {
1935 return issue_events::<T>(state, None, None, "unknown-topic", "unknown topic");
1936 };
1937 let key = subscription_key(&topic, &subscription_id);
1938 let Some(sub) = state.subscriptions.get_mut(&key) else {
1939 return issue_events::<T>(
1940 state,
1941 None,
1942 None,
1943 "unknown-subscription",
1944 "unknown subscription",
1945 );
1946 };
1947 if sub.closed {
1948 return issue_events::<T>(
1949 state,
1950 None,
1951 None,
1952 "subscription-closed",
1953 "subscription is closed",
1954 );
1955 }
1956 if sub.retention_gap {
1957 return issue_events::<T>(
1958 state,
1959 None,
1960 None,
1961 "retention-gap",
1962 "subscription must seek before ack",
1963 );
1964 }
1965 if seq < sub.next_seq {
1966 return issue_events::<T>(
1967 state,
1968 None,
1969 None,
1970 "source-cursor-stale",
1971 "ack is behind subscription cursor",
1972 );
1973 }
1974 if seq >= topic_state.next_seq {
1975 return issue_events::<T>(
1976 state,
1977 None,
1978 None,
1979 "cursor-out-of-range",
1980 "ack is beyond topic tail",
1981 );
1982 }
1983 sub.next_seq = seq + 1;
1984 vec![RuntimeEvent::Status(status_fact(
1985 state,
1986 MessageBusStatusKind::SubscriptionAcked,
1987 StatusFields {
1988 topic: Some(topic),
1989 subscription_id: Some(subscription_id),
1990 next_seq: Some(seq + 1),
1991 command_id,
1992 ..StatusFields::default()
1993 },
1994 ))]
1995}
1996
1997fn seek_subscription<T: Clone + 'static>(
1998 state: &mut MessageBusState<T>,
1999 command: MessageBusCommand<T>,
2000) -> Vec<RuntimeEvent<T>> {
2001 let MessageBusCommand::Seek {
2002 topic,
2003 subscription_id,
2004 next_seq,
2005 command_id,
2006 } = command
2007 else {
2008 return Vec::new();
2009 };
2010 let Some(topic_state) = state.topics.get(&topic) else {
2011 return issue_events::<T>(state, None, None, "unknown-topic", "unknown topic");
2012 };
2013 let key = subscription_key(&topic, &subscription_id);
2014 let Some(sub) = state.subscriptions.get_mut(&key) else {
2015 return issue_events::<T>(
2016 state,
2017 None,
2018 None,
2019 "unknown-subscription",
2020 "unknown subscription",
2021 );
2022 };
2023 if sub.closed {
2024 return issue_events::<T>(
2025 state,
2026 None,
2027 None,
2028 "subscription-closed",
2029 "subscription is closed",
2030 );
2031 }
2032 if next_seq < topic_state.head_seq {
2033 return issue_events::<T>(
2034 state,
2035 None,
2036 None,
2037 "retention-gap",
2038 "seek is before retained headSeq",
2039 );
2040 }
2041 if next_seq > topic_state.next_seq {
2042 return issue_events::<T>(
2043 state,
2044 None,
2045 None,
2046 "cursor-out-of-range",
2047 "seek is beyond topic tail",
2048 );
2049 }
2050 sub.next_seq = next_seq;
2051 sub.retention_gap = false;
2052 vec![RuntimeEvent::Status(status_fact(
2053 state,
2054 MessageBusStatusKind::SubscriptionSought,
2055 StatusFields {
2056 topic: Some(topic),
2057 subscription_id: Some(subscription_id),
2058 next_seq: Some(next_seq),
2059 command_id,
2060 ..StatusFields::default()
2061 },
2062 ))]
2063}
2064
2065fn close_subscription<T: Clone + 'static>(
2066 state: &mut MessageBusState<T>,
2067 command: MessageBusCommand<T>,
2068) -> Vec<RuntimeEvent<T>> {
2069 let MessageBusCommand::CloseSubscription {
2070 topic,
2071 subscription_id,
2072 command_id,
2073 } = command
2074 else {
2075 return Vec::new();
2076 };
2077 if !state.topics.contains_key(&topic) {
2078 return issue_events::<T>(state, None, None, "unknown-topic", "unknown topic");
2079 }
2080 let key = subscription_key(&topic, &subscription_id);
2081 let Some(sub) = state.subscriptions.get_mut(&key) else {
2082 return issue_events::<T>(
2083 state,
2084 None,
2085 None,
2086 "unknown-subscription",
2087 "unknown subscription",
2088 );
2089 };
2090 sub.closed = true;
2091 let next_seq = sub.next_seq;
2092 vec![RuntimeEvent::Status(status_fact(
2093 state,
2094 MessageBusStatusKind::SubscriptionClosed,
2095 StatusFields {
2096 topic: Some(topic),
2097 subscription_id: Some(subscription_id),
2098 next_seq: Some(next_seq),
2099 command_id,
2100 ..StatusFields::default()
2101 },
2102 ))]
2103}
2104
2105fn issue_events<T: Clone + 'static>(
2106 state: &mut MessageBusState<T>,
2107 command: Option<MessageBusCommand<T>>,
2108 message: Option<MessageEnvelope<T>>,
2109 code: &str,
2110 issue_message: impl Into<String>,
2111) -> Vec<RuntimeEvent<T>> {
2112 let topic = command
2113 .as_ref()
2114 .and_then(MessageBusCommand::topic)
2115 .map(str::to_owned)
2116 .or_else(|| message.as_ref().map(|message| message.topic.clone()));
2117 let issue = DataIssue {
2118 kind: "issue".to_owned(),
2119 code: code.to_owned(),
2120 message: issue_message.into(),
2121 severity: "error".to_owned(),
2122 source: "messageBus".to_owned(),
2123 topic: topic.clone(),
2124 details: None,
2125 };
2126 let entry = MessageBusDeadLetterEntry {
2127 entry_seq: state.dead_letter_seq + 1,
2128 topic: topic.clone(),
2129 command,
2130 message,
2131 issue: issue.clone(),
2132 timestamp_ms: timestamp_or_zero(state),
2133 };
2134 state.dead_letter_seq = entry.entry_seq;
2135 state.dead_letters.push(entry);
2136 vec![RuntimeEvent::Issue(issue)]
2137}
2138
2139fn reject_command<T: Clone + 'static>(
2140 state: &mut MessageBusState<T>,
2141 command: MessageBusCommand<T>,
2142 reason: String,
2143) -> Vec<RuntimeEvent<T>> {
2144 issue_events(state, Some(command), None, "malformed-command", reason)
2145}
2146
2147fn duplicate_command_events<T: Clone + 'static>(
2148 state: &mut MessageBusState<T>,
2149 command: MessageBusCommand<T>,
2150 message: &str,
2151) -> Vec<RuntimeEvent<T>> {
2152 let status = RuntimeEvent::Status(status_fact(
2153 state,
2154 MessageBusStatusKind::DuplicateCommand,
2155 StatusFields {
2156 topic: command.topic().map(str::to_owned),
2157 command_id: command.command_id().map(str::to_owned),
2158 ..StatusFields::default()
2159 },
2160 ));
2161 if state.dedupe.command_id == MessageBusDedupeAction::Issue {
2162 return issue_events(state, Some(command), None, "duplicate-command", message);
2163 }
2164 vec![status]
2165}
2166
2167#[derive(Default)]
2168struct StatusFields {
2169 topic: Option<String>,
2170 seq: Option<u64>,
2171 head_seq: Option<u64>,
2172 subscription_id: Option<String>,
2173 next_seq: Option<u64>,
2174 command_id: Option<String>,
2175 details: Option<String>,
2176}
2177
2178fn status_fact<T>(
2179 state: &MessageBusState<T>,
2180 kind: MessageBusStatusKind,
2181 fields: StatusFields,
2182) -> MessageBusStatus {
2183 MessageBusStatus {
2184 kind,
2185 topic: fields.topic,
2186 seq: fields.seq,
2187 head_seq: fields.head_seq,
2188 subscription_id: fields.subscription_id,
2189 next_seq: fields.next_seq,
2190 command_id: fields.command_id,
2191 issue_code: None,
2192 timestamp_ms: timestamp_or_zero(state),
2193 details: fields.details,
2194 }
2195}
2196
2197fn catalog_page<T>(
2198 state: &MessageBusState<T>,
2199 params: &MessageBusCatalogParams,
2200) -> MessageBusCatalogPage {
2201 let limit = positive_limit(params.limit);
2202 let topics = state
2203 .topics
2204 .iter()
2205 .filter(|(topic, value)| {
2206 (params.include_closed || !value.closed)
2207 && params
2208 .after_topic
2209 .as_ref()
2210 .is_none_or(|after| *topic > after)
2211 })
2212 .collect::<Vec<_>>();
2213 let has_more = topics.len() > limit;
2214 let page = topics
2215 .into_iter()
2216 .take(limit)
2217 .map(|(topic, value)| MessageBusCatalogEntry {
2218 topic: topic.clone(),
2219 closed: value.closed,
2220 head_seq: value.head_seq,
2221 next_seq: value.next_seq,
2222 message_count: value.messages.len(),
2223 })
2224 .collect::<Vec<_>>();
2225 let next_after_topic = if has_more {
2226 page.last().map(|entry| entry.topic.clone())
2227 } else {
2228 None
2229 };
2230 MessageBusCatalogPage {
2231 topics: page,
2232 next_after_topic,
2233 has_more,
2234 }
2235}
2236
2237fn topic_page<T: Clone>(
2238 state: &MessageBusState<T>,
2239 topic_name: &str,
2240 params: &MessageBusTopicParams,
2241) -> MessageBusTopicPage<T> {
2242 let start = params.after_seq.map_or_else(
2243 || {
2244 state
2245 .topics
2246 .get(topic_name)
2247 .map_or(1, |topic| topic.head_seq)
2248 },
2249 |seq| seq + 1,
2250 );
2251 let limit = positive_limit(params.limit);
2252 let all = state
2253 .topics
2254 .get(topic_name)
2255 .map(|topic| {
2256 topic
2257 .messages
2258 .iter()
2259 .filter(|message| message.seq >= start)
2260 .cloned()
2261 .collect::<Vec<_>>()
2262 })
2263 .unwrap_or_default();
2264 let has_more = all.len() > limit;
2265 let messages = all.into_iter().take(limit).collect::<Vec<_>>();
2266 let through_seq = messages.last().map(|message| message.seq);
2267 MessageBusTopicPage {
2268 topic: topic_name.to_owned(),
2269 messages,
2270 from_seq: start,
2271 through_seq,
2272 next_after_seq: if has_more { through_seq } else { None },
2273 has_more,
2274 }
2275}
2276
2277fn available_page<T: Clone>(
2278 state: &MessageBusState<T>,
2279 sub_key: &SubscriptionState,
2280 params: &MessageBusAvailableParams,
2281) -> MessageBusAvailablePage<T> {
2282 let key = subscription_key(&sub_key.topic, &sub_key.subscription_id);
2283 let sub = state.subscriptions.get(&key).unwrap_or(sub_key);
2284 let cursor = cursor_snapshot(state, sub);
2285 let start = params.after_seq.map_or(sub.next_seq, |seq| seq + 1);
2286 let all = if sub.retention_gap {
2287 Vec::new()
2288 } else {
2289 state
2290 .topics
2291 .get(&sub.topic)
2292 .map(|topic| {
2293 topic
2294 .messages
2295 .iter()
2296 .filter(|message| message.seq >= start)
2297 .cloned()
2298 .collect::<Vec<_>>()
2299 })
2300 .unwrap_or_default()
2301 };
2302 let limit = positive_limit(params.limit);
2303 let has_more = all.len() > limit;
2304 let messages = all.into_iter().take(limit).collect::<Vec<_>>();
2305 let through_seq = messages.last().map(|message| message.seq);
2306 MessageBusAvailablePage {
2307 topic: sub.topic.clone(),
2308 subscription_id: sub.subscription_id.clone(),
2309 cursor,
2310 messages,
2311 from_seq: start,
2312 through_seq,
2313 next_after_seq: if has_more { through_seq } else { None },
2314 has_more,
2315 }
2316}
2317
2318fn dead_letter_page<T: Clone>(
2319 state: &MessageBusState<T>,
2320 params: &MessageBusDeadLetterParams,
2321) -> MessageBusDeadLetterPage<T> {
2322 let limit = positive_limit(params.limit);
2323 let entries = state
2324 .dead_letters
2325 .iter()
2326 .filter(|entry| {
2327 params
2328 .after_entry_seq
2329 .is_none_or(|after| entry.entry_seq > after)
2330 && params
2331 .topic
2332 .as_ref()
2333 .is_none_or(|topic| entry.topic.as_ref() == Some(topic))
2334 && params
2335 .code
2336 .as_ref()
2337 .is_none_or(|code| &entry.issue.code == code)
2338 })
2339 .cloned()
2340 .collect::<Vec<_>>();
2341 let has_more = entries.len() > limit;
2342 let page = entries.into_iter().take(limit).collect::<Vec<_>>();
2343 let next_after_entry_seq = if has_more {
2344 page.last().map(|entry| entry.entry_seq)
2345 } else {
2346 None
2347 };
2348 MessageBusDeadLetterPage {
2349 entries: page,
2350 next_after_entry_seq,
2351 has_more,
2352 }
2353}
2354
2355fn cursor_snapshot<T>(state: &MessageBusState<T>, sub: &SubscriptionState) -> MessageBusCursor {
2356 let key = subscription_key(&sub.topic, &sub.subscription_id);
2357 let sub = state.subscriptions.get(&key).unwrap_or(sub);
2358 MessageBusCursor {
2359 topic: sub.topic.clone(),
2360 subscription_id: sub.subscription_id.clone(),
2361 next_seq: sub.next_seq,
2362 closed: sub.closed,
2363 retention_gap: sub.retention_gap,
2364 head_seq: state
2365 .topics
2366 .get(&sub.topic)
2367 .map_or(1, |topic| topic.head_seq),
2368 }
2369}
2370
2371fn ensure_subscription<T>(
2372 state: &mut MessageBusState<T>,
2373 opts: &MessageBusSubscriptionOptions,
2374) -> (SubscriptionState, bool, Option<DataIssue>) {
2375 let key = subscription_key(&opts.topic, &opts.subscription_id);
2376 if let Some(existing) = state.subscriptions.get(&key) {
2377 return (existing.clone(), false, None);
2378 }
2379 let topic_range = state
2380 .topics
2381 .get(&opts.topic)
2382 .map(|topic| (topic.head_seq, topic.next_seq));
2383 let next_seq = match opts.from {
2384 MessageBusSubscriptionFrom::Earliest => topic_range.map_or(1, |(head_seq, _)| head_seq),
2385 MessageBusSubscriptionFrom::Latest => topic_range.map_or(1, |(_, next_seq)| next_seq),
2386 MessageBusSubscriptionFrom::Seq(seq) => seq,
2387 };
2388 let issue = topic_range.and_then(|(head_seq, tail_seq)| {
2389 if next_seq < head_seq || next_seq > tail_seq {
2390 Some(DataIssue {
2391 kind: "issue".to_owned(),
2392 code: "cursor-out-of-range".to_owned(),
2393 message: format!(
2394 "subscription start seq {next_seq} is outside retained range {}..={}",
2395 head_seq, tail_seq
2396 ),
2397 severity: "error".to_owned(),
2398 source: "messageBus".to_owned(),
2399 topic: Some(opts.topic.clone()),
2400 details: Some(format!("subscriptionId={}", opts.subscription_id)),
2401 })
2402 } else {
2403 None
2404 }
2405 });
2406 let next_seq = if issue.is_some() {
2407 topic_range.map_or(1, |(head_seq, _)| head_seq)
2408 } else {
2409 next_seq
2410 };
2411 if let Some(issue) = issue.clone() {
2412 let entry = MessageBusDeadLetterEntry {
2413 entry_seq: state.dead_letter_seq + 1,
2414 topic: Some(opts.topic.clone()),
2415 command: None,
2416 message: None,
2417 issue,
2418 timestamp_ms: timestamp_or_zero(state),
2419 };
2420 state.dead_letter_seq = entry.entry_seq;
2421 state.dead_letters.push(entry);
2422 }
2423 let sub = SubscriptionState {
2424 topic: opts.topic.clone(),
2425 subscription_id: opts.subscription_id.clone(),
2426 next_seq,
2427 closed: false,
2428 retention_gap: topic_range.is_some_and(|(head_seq, _)| next_seq < head_seq),
2429 };
2430 state.subscriptions.insert(key, sub.clone());
2431 (sub, true, issue)
2432}
2433
2434fn pull_params<T: Clone + 'static>(ctx: &Ctx) -> Option<T> {
2435 ctx.pull()
2436 .and_then(|pull| pull.params::<T>())
2437 .map(|params| (*params).clone())
2438}
2439
2440fn make_topic_state<T>() -> TopicState<T> {
2441 TopicState {
2442 closed: false,
2443 head_seq: 1,
2444 next_seq: 1,
2445 messages: Vec::new(),
2446 }
2447}
2448
2449fn unique_topics(topics: Vec<String>) -> Vec<String> {
2450 let mut seen = HashSet::new();
2451 let mut unique = Vec::with_capacity(topics.len());
2452 for topic in topics {
2453 assert_topic_key(&topic, "messageBus");
2454 assert!(seen.insert(topic.clone()), "messageBus: duplicate topic");
2455 unique.push(topic);
2456 }
2457 unique.sort();
2458 unique
2459}
2460
2461fn assert_topic_key(topic: &str, owner: &str) {
2462 if let Some(error) = validate_topic_key(topic, owner) {
2463 panic!("{error}");
2464 }
2465}
2466
2467fn validate_topic_key(topic: &str, owner: &str) -> Option<String> {
2468 if topic.is_empty() {
2469 return Some(format!("{owner}: topic must be a non-empty string"));
2470 }
2471 None
2472}
2473
2474fn assert_non_empty(value: &str, owner: &str) {
2475 assert!(!value.is_empty(), "{owner}: must be a non-empty string");
2476}
2477
2478fn positive_limit(limit: Option<usize>) -> usize {
2479 let limit = limit.unwrap_or(100);
2480 assert!(limit > 0, "messageBus: limit must be positive");
2481 limit
2482}
2483
2484fn command_subscription_id<T>(command: &MessageBusCommand<T>) -> Option<&str> {
2485 match command {
2486 MessageBusCommand::Ack {
2487 subscription_id, ..
2488 }
2489 | MessageBusCommand::Seek {
2490 subscription_id, ..
2491 }
2492 | MessageBusCommand::CloseSubscription {
2493 subscription_id, ..
2494 } => Some(subscription_id),
2495 _ => None,
2496 }
2497}
2498
2499fn subscription_key(topic: &str, subscription_id: &str) -> String {
2500 canonical_tuple_key(&[topic, subscription_id])
2501}
2502
2503fn idempotency_key_for(topic: &str, idempotency_key: &str) -> String {
2504 canonical_tuple_key(&[topic, idempotency_key])
2505}
2506
2507fn timestamp_or_zero<T>(state: &MessageBusState<T>) -> u64 {
2508 catch_unwind(AssertUnwindSafe(|| (state.now)())).unwrap_or(0)
2509}
2510
2511fn node_opts(name: impl Into<String>, factory: impl Into<String>) -> GraphNodeOpts {
2512 let mut opts = GraphNodeOpts::named(name);
2513 opts.node = NodeOpts {
2514 factory: Some(factory.into()),
2515 complete_when_deps_complete: false,
2516 error_when_deps_error: false,
2517 ..opts.node
2518 };
2519 opts
2520}
2521
2522fn pull_node_opts(
2523 name: impl Into<String>,
2524 factory: impl Into<String>,
2525 pull_id: LockId,
2526) -> GraphNodeOpts {
2527 let mut opts = node_opts(name, factory);
2528 opts.node.pull_id = Some(pull_id);
2529 opts.node.partial = true;
2530 opts
2531}
2532
2533fn is_reachable_upstream(from: &Core, target: &Core) -> bool {
2534 let mut seen = HashSet::new();
2535 let mut stack = vec![from.clone()];
2536 while let Some(node) = stack.pop() {
2537 if node.ptr_eq(target) {
2538 return true;
2539 }
2540 if !seen.insert(node.identity_key()) {
2541 continue;
2542 }
2543 stack.extend(node.deps());
2544 }
2545 false
2546}
2547
2548#[cfg(test)]
2549mod clean_slate_tests {
2550 use super::*;
2551 use crate::graph::graph;
2552 use crate::protocol::{Message, PullDemand};
2553
2554 #[test]
2555 fn message_bus_core_exposes_clean_slate_nodes() {
2556 let g = graph();
2557 let _bus = message_bus::<String>(
2558 &g,
2559 MessageBusOptions::named("bus")
2560 .with_topics(["orders"])
2561 .with_now(|| 10),
2562 );
2563 let snap = g.describe();
2564 for id in [
2565 "bus/commands",
2566 "bus/runtime",
2567 "bus/messages",
2568 "bus/status",
2569 "bus/issues",
2570 ] {
2571 assert!(snap.nodes.iter().any(|node| node.id == id), "{id}");
2572 }
2573 assert!(!snap.nodes.iter().any(|node| node.id.contains("dynamicHub")));
2574 }
2575
2576 #[test]
2577 fn unknown_topic_is_strict_issue_without_retained_message() {
2578 let g = graph();
2579 let bus = message_bus::<String>(&g, MessageBusOptions::named("bus").with_now(|| 20));
2580 let _messages = bus.messages.subscribe(|_| {});
2581 let _issues = bus.issues.subscribe(|_| {});
2582 let _status = bus.status.subscribe(|_| {});
2583
2584 bus.publish(
2585 "missing",
2586 "payload".to_owned(),
2587 None,
2588 Some("c1".to_owned()),
2589 None,
2590 );
2591
2592 assert!(bus.messages.cache().is_none());
2593 assert_eq!(bus.issues.cache().unwrap().code, "unknown-topic");
2594 assert!(bus.status.cache().is_none());
2595 }
2596
2597 #[test]
2598 fn catalog_topic_and_dead_letter_are_pull_read_only_projections() {
2599 let g = graph();
2600 let bus = message_bus::<String>(
2601 &g,
2602 MessageBusOptions::named("bus")
2603 .with_topics(["orders"])
2604 .with_now(|| 30),
2605 );
2606 let catalog = bus.catalog();
2607 let topic = bus.topic("orders");
2608 let dead = bus.dead_letter();
2609 let _catalog = catalog.snapshot.subscribe(|_| {});
2610 let _topic = topic.snapshot.subscribe(|_| {});
2611 let _dead = dead.snapshot.subscribe(|_| {});
2612
2613 bus.publish("orders", "o1".to_owned(), None, None, None);
2614 bus.publish("missing", "x".to_owned(), None, None, None);
2615 catalog.snapshot.up(vec![Message::Pull(PullDemand::new(
2616 catalog.snapshot_pull_id.clone(),
2617 ))]);
2618 topic
2619 .snapshot
2620 .up(vec![Message::Pull(PullDemand::with_params(
2621 topic.snapshot_pull_id.clone(),
2622 MessageBusTopicParams {
2623 limit: Some(1),
2624 after_seq: None,
2625 },
2626 ))]);
2627 dead.snapshot.up(vec![Message::Pull(PullDemand::with_params(
2628 dead.snapshot_pull_id.clone(),
2629 MessageBusDeadLetterParams {
2630 limit: None,
2631 after_entry_seq: None,
2632 topic: None,
2633 code: Some("unknown-topic".to_owned()),
2634 },
2635 ))]);
2636
2637 assert_eq!(catalog.snapshot.cache().unwrap().topics[0].topic, "orders");
2638 assert_eq!(topic.snapshot.cache().unwrap().messages[0].payload, "o1");
2639 assert_eq!(
2640 dead.snapshot.cache().unwrap().entries[0].issue.code,
2641 "unknown-topic"
2642 );
2643 }
2644
2645 #[test]
2646 fn available_pull_does_not_move_cursor_ack_seek_close_do() {
2647 let g = graph();
2648 let bus = message_bus::<String>(
2649 &g,
2650 MessageBusOptions::named("bus")
2651 .with_topics(["orders"])
2652 .with_now(|| 40),
2653 );
2654 let sub = bus.subscription(MessageBusSubscriptionOptions::new("orders", "s1"));
2655 let _available = sub.available.subscribe(|_| {});
2656 let _cursor = sub.cursor.subscribe(|_| {});
2657
2658 bus.publish("orders", "o1".to_owned(), None, None, None);
2659 bus.publish("orders", "o2".to_owned(), None, None, None);
2660 sub.available.up(vec![Message::Pull(PullDemand::with_params(
2661 sub.available_pull_id.clone(),
2662 MessageBusAvailableParams {
2663 limit: Some(1),
2664 after_seq: Some(1),
2665 },
2666 ))]);
2667
2668 let page = sub.available.cache().unwrap();
2669 assert_eq!(page.messages[0].seq, 2);
2670 assert_eq!(page.cursor.next_seq, 1);
2671 let opened_cursor = sub.cursor.cache().unwrap();
2672 assert_eq!(opened_cursor.next_seq, 1);
2673 assert!(!opened_cursor.retention_gap);
2674
2675 sub.ack(1, None);
2676 assert_eq!(sub.cursor.cache().unwrap().next_seq, 2);
2677 sub.seek(1, None);
2678 assert_eq!(sub.cursor.cache().unwrap().next_seq, 1);
2679 sub.close(None);
2680 assert!(sub.cursor.cache().unwrap().closed);
2681 }
2682
2683 #[test]
2684 fn retention_count_advances_head_and_marks_gap_until_seek() {
2685 let g = graph();
2686 let bus = message_bus::<String>(
2687 &g,
2688 MessageBusOptions::named("bus")
2689 .with_topics(["orders"])
2690 .with_retention(MessageBusRetentionPolicy {
2691 max_messages: Some(1),
2692 max_age_ms: None,
2693 }),
2694 );
2695 let sub = bus.subscription(MessageBusSubscriptionOptions::new("orders", "s1"));
2696 let _cursor = sub.cursor.subscribe(|_| {});
2697 let _issues = bus.issues.subscribe(|_| {});
2698
2699 bus.publish("orders", "o1".to_owned(), None, None, None);
2700 bus.publish("orders", "o2".to_owned(), None, None, None);
2701
2702 assert_eq!(bus.issues.cache().unwrap().code, "retention-gap");
2703 assert!(sub.cursor.cache().unwrap().retention_gap);
2704 sub.seek(2, None);
2705 let cursor = sub.cursor.cache().unwrap();
2706 assert_eq!(cursor.next_seq, 2);
2707 assert!(!cursor.retention_gap);
2708 }
2709
2710 #[test]
2711 fn invalid_subscription_start_seq_is_visible_issue_not_impossible_cursor() {
2712 let g = graph();
2713 let bus = message_bus::<String>(
2714 &g,
2715 MessageBusOptions::named("bus")
2716 .with_topics(["orders"])
2717 .with_retention(MessageBusRetentionPolicy {
2718 max_messages: Some(1),
2719 max_age_ms: None,
2720 }),
2721 );
2722 let _issues = bus.issues.subscribe(|_| {});
2723 let dead = bus.dead_letter();
2724 let _dead = dead.snapshot.subscribe(|_| {});
2725
2726 bus.publish("orders", "o1".to_owned(), None, None, None);
2727 bus.publish("orders", "o2".to_owned(), None, None, None);
2728 let sub = bus.subscription(
2729 MessageBusSubscriptionOptions::new("orders", "late")
2730 .from(MessageBusSubscriptionFrom::Seq(1)),
2731 );
2732 let _cursor = sub.cursor.subscribe(|_| {});
2733 dead.snapshot.up(vec![Message::Pull(PullDemand::with_params(
2734 dead.snapshot_pull_id.clone(),
2735 MessageBusDeadLetterParams {
2736 limit: None,
2737 after_entry_seq: None,
2738 topic: Some("orders".to_owned()),
2739 code: Some("cursor-out-of-range".to_owned()),
2740 },
2741 ))]);
2742
2743 assert_eq!(bus.issues.cache().unwrap().code, "cursor-out-of-range");
2744 assert_eq!(sub.cursor.cache().unwrap().next_seq, 2);
2745 assert_eq!(
2746 dead.snapshot.cache().unwrap().entries[0].issue.code,
2747 "cursor-out-of-range"
2748 );
2749 }
2750}