1use std::collections::BTreeMap;
4use std::rc::Rc;
5
6use crate::ctx::Ctx;
7use crate::graph::{Graph, GraphNodeOpts};
8use crate::identity::{canonical_tuple_key, compound_tuple_key};
9use crate::messaging::{DataIssue, MessageBusAvailablePage, MessageBusCommand, MessageEnvelope};
10use crate::node::Node;
11use crate::process::{ProcessCommand, ProcessEvent, ProcessStatus, ProcessStatusState};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct MessageBusDelivery {
16 pub topic: String,
18 pub seq: u64,
20 pub subscription_id: String,
22 pub command_id: String,
24}
25
26#[derive(Debug, Clone, PartialEq)]
27pub struct ProcessDeliveredCommand<TCommand> {
29 pub command: ProcessCommand<TCommand>,
31 pub delivery: MessageBusDelivery,
33}
34
35pub type ProcessMessageCommandFn<TPayload, TCommand> =
37 Rc<dyn Fn(&MessageEnvelope<TPayload>, &MessageBusDelivery) -> Option<ProcessCommand<TCommand>>>;
38
39#[derive(Clone)]
40pub struct ProcessMessagingPolicy<TPayload, TCommand> {
42 pub command: ProcessMessageCommandFn<TPayload, TCommand>,
44 pub ack_rejected: bool,
46 pub outbox_topic: Option<String>,
48}
49
50impl<TPayload, TCommand> ProcessMessagingPolicy<TPayload, TCommand> {
51 pub fn new(
53 command: impl Fn(&MessageEnvelope<TPayload>, &MessageBusDelivery) -> Option<ProcessCommand<TCommand>>
54 + 'static,
55 ) -> Self {
56 Self {
57 command: Rc::new(command),
58 ack_rejected: true,
59 outbox_topic: None,
60 }
61 }
62
63 pub fn ack_rejected(mut self, ack_rejected: bool) -> Self {
65 self.ack_rejected = ack_rejected;
66 self
67 }
68
69 pub fn with_outbox_topic(mut self, topic: impl Into<String>) -> Self {
71 self.outbox_topic = Some(topic.into());
72 self
73 }
74}
75
76#[derive(Clone)]
77pub struct ProcessMessagingRecipeOptions<TPayload, TCommand, TEvent> {
79 pub name: String,
81 pub deliveries: Node<MessageBusAvailablePage<TPayload>>,
83 pub status: Node<ProcessStatus>,
85 pub events: Option<Node<ProcessEvent<TEvent>>>,
87 pub policy: ProcessMessagingPolicy<TPayload, TCommand>,
89}
90
91impl<TPayload, TCommand, TEvent> ProcessMessagingRecipeOptions<TPayload, TCommand, TEvent> {
92 pub fn new(
94 deliveries: Node<MessageBusAvailablePage<TPayload>>,
95 status: Node<ProcessStatus>,
96 policy: ProcessMessagingPolicy<TPayload, TCommand>,
97 ) -> Self {
98 Self {
99 name: "processMessaging".to_owned(),
100 deliveries,
101 status,
102 events: None,
103 policy,
104 }
105 }
106
107 pub fn named(mut self, name: impl Into<String>) -> Self {
109 self.name = name.into();
110 self
111 }
112
113 pub fn with_events(mut self, events: Node<ProcessEvent<TEvent>>) -> Self {
115 self.events = Some(events);
116 self
117 }
118}
119
120#[derive(Clone)]
121pub struct ProcessMessagingRecipeBundle<TCommand, TEvent> {
123 pub delivered_commands: Node<ProcessDeliveredCommand<TCommand>>,
125 pub commands: Node<ProcessCommand<TCommand>>,
127 pub ack_commands: Node<MessageBusCommand<TCommand>>,
129 pub outbox_commands: Option<Node<MessageBusCommand<ProcessEvent<TEvent>>>>,
131 pub issues: Node<DataIssue>,
133}
134
135#[derive(Clone)]
136enum ProcessMessagingFact<TCommand> {
137 Command(ProcessDeliveredCommand<TCommand>),
138 Issue(DataIssue),
139}
140
141#[derive(Clone, Default)]
142struct AckState {
143 deliveries: BTreeMap<String, Vec<MessageBusDelivery>>,
144}
145
146pub fn process_messaging_recipe<
148 TPayload: Clone + 'static,
149 TCommand: Clone + 'static,
150 TEvent: Clone + 'static,
151>(
152 graph: &Graph,
153 opts: ProcessMessagingRecipeOptions<TPayload, TCommand, TEvent>,
154) -> ProcessMessagingRecipeBundle<TCommand, TEvent> {
155 let name = opts.name.clone();
156 let policy = opts.policy.clone();
157 let runtime = graph.node_opts::<ProcessMessagingFact<TCommand>, _>(
158 vec![opts.deliveries.erased()],
159 move |ctx| {
160 for page in ctx.batch::<MessageBusAvailablePage<TPayload>>(0) {
161 for message in &page.messages {
162 let delivery = message_delivery(page.as_ref(), message);
163 if let Some(command) = (policy.command)(message, &delivery) {
164 ctx.emit(ProcessMessagingFact::Command(ProcessDeliveredCommand {
165 command,
166 delivery,
167 }));
168 } else {
169 ctx.emit(ProcessMessagingFact::<TCommand>::Issue(message_issue(
170 &delivery,
171 )));
172 }
173 }
174 }
175 },
176 GraphNodeOpts::named(format!("{name}/runtime")),
177 );
178 let delivered_commands = project(
179 graph,
180 &runtime,
181 format!("{name}/deliveredCommands"),
182 |fact| match fact {
183 ProcessMessagingFact::Command(delivered) => Some(delivered.clone()),
184 ProcessMessagingFact::Issue(_) => None,
185 },
186 );
187 let commands = project(
188 graph,
189 &runtime,
190 format!("{name}/commands"),
191 |fact| match fact {
192 ProcessMessagingFact::Command(delivered) => Some(delivered.command.clone()),
193 ProcessMessagingFact::Issue(_) => None,
194 },
195 );
196 let issues = project(
197 graph,
198 &runtime,
199 format!("{name}/issues"),
200 |fact| match fact {
201 ProcessMessagingFact::Issue(issue) => Some(issue.clone()),
202 ProcessMessagingFact::Command(_) => None,
203 },
204 );
205 let ack_commands = process_message_ack_commands(
206 graph,
207 ProcessMessageAckOptions {
208 name: format!("{name}/ackCommands"),
209 delivered_commands: delivered_commands.clone(),
210 status: opts.status,
211 issues: Some(issues.clone()),
212 ack_rejected: opts.policy.ack_rejected,
213 },
214 );
215 let outbox_commands = opts.events.and_then(|events| {
216 opts.policy.outbox_topic.map(|topic| {
217 process_event_outbox_commands(graph, events, topic, format!("{name}/outboxCommands"))
218 })
219 });
220 ProcessMessagingRecipeBundle {
221 delivered_commands,
222 commands,
223 ack_commands,
224 outbox_commands,
225 issues,
226 }
227}
228
229pub struct ProcessMessageAckOptions<TCommand> {
231 pub name: String,
233 pub delivered_commands: Node<ProcessDeliveredCommand<TCommand>>,
235 pub status: Node<ProcessStatus>,
237 pub issues: Option<Node<DataIssue>>,
239 pub ack_rejected: bool,
241}
242
243pub fn process_message_ack_commands<TCommand: Clone + 'static>(
245 graph: &Graph,
246 opts: ProcessMessageAckOptions<TCommand>,
247) -> Node<MessageBusCommand<TCommand>> {
248 let mut deps = vec![opts.delivered_commands.erased(), opts.status.erased()];
249 if let Some(issues) = &opts.issues {
250 deps.push(issues.erased());
251 }
252 graph.node_opts::<MessageBusCommand<TCommand>, _>(
253 deps,
254 move |ctx| {
255 let mut state = ctx
256 .state_get::<AckState>()
257 .map(|state| (*state).clone())
258 .unwrap_or_default();
259 for delivered in ctx.batch::<ProcessDeliveredCommand<TCommand>>(0) {
260 state
261 .deliveries
262 .entry(delivered.command.id.clone())
263 .or_default()
264 .push(delivered.delivery.clone());
265 }
266 for status in ctx.batch::<ProcessStatus>(1) {
267 if status.state == ProcessStatusState::Rejected && !opts.ack_rejected {
268 continue;
269 }
270 let Some(command_id) = &status.command_id else {
271 continue;
272 };
273 if let Some(delivery) = shift_delivery(&mut state.deliveries, command_id) {
274 ctx.emit(ack_command::<TCommand>(
275 &delivery,
276 process_ack_id(&delivery, "status-ack"),
277 ));
278 }
279 }
280 if opts.issues.is_some() {
281 for issue in ctx.batch::<DataIssue>(2) {
282 if let Some(delivery) = issue_delivery(&issue) {
283 ctx.emit(ack_command::<TCommand>(
284 &delivery,
285 process_ack_id(&delivery, "issue-ack"),
286 ));
287 }
288 }
289 }
290 ctx.state_set(state);
291 ctx.state_persist(true);
292 },
293 GraphNodeOpts::named(opts.name),
294 )
295}
296
297fn process_ack_id(delivery: &MessageBusDelivery, reason: &str) -> String {
298 compound_tuple_key(
299 "process-message-ack",
300 &[
301 &delivery.topic,
302 &delivery.subscription_id,
303 &delivery.seq.to_string(),
304 reason,
305 ],
306 )
307}
308
309pub fn process_event_outbox_commands<TEvent: Clone + 'static>(
311 graph: &Graph,
312 events: Node<ProcessEvent<TEvent>>,
313 topic: impl Into<String>,
314 name: impl Into<String>,
315) -> Node<MessageBusCommand<ProcessEvent<TEvent>>> {
316 let topic = topic.into();
317 graph.node_opts::<MessageBusCommand<ProcessEvent<TEvent>>, _>(
318 vec![events.erased()],
319 move |ctx| {
320 for event in ctx.batch::<ProcessEvent<TEvent>>(0) {
321 ctx.emit(MessageBusCommand::Publish {
322 topic: topic.clone(),
323 payload: (*event).clone(),
324 key: event.process_id.clone(),
325 command_id: Some(compound_tuple_key("process-outbox", &[&event.id])),
326 idempotency_key: Some(event.id.clone()),
327 });
328 }
329 },
330 GraphNodeOpts::named(name.into()),
331 )
332}
333
334fn project<TIn: Clone + 'static, TOut: 'static>(
335 graph: &Graph,
336 source: &Node<TIn>,
337 name: String,
338 pick: impl Fn(&TIn) -> Option<TOut> + 'static,
339) -> Node<TOut> {
340 graph.node_opts::<TOut, _>(
341 vec![source.erased()],
342 move |ctx: &Ctx| {
343 for fact in ctx.batch::<TIn>(0) {
344 if let Some(value) = pick(&fact) {
345 ctx.emit(value);
346 }
347 }
348 },
349 GraphNodeOpts::named(name),
350 )
351}
352
353fn message_delivery<T>(
354 page: &MessageBusAvailablePage<T>,
355 message: &MessageEnvelope<T>,
356) -> MessageBusDelivery {
357 MessageBusDelivery {
358 topic: page.topic.clone(),
359 seq: message.seq,
360 subscription_id: page.subscription_id.clone(),
361 command_id: message
362 .command_id
363 .clone()
364 .unwrap_or_else(|| canonical_tuple_key(&[&page.topic, &message.seq.to_string()])),
365 }
366}
367
368fn message_issue(delivery: &MessageBusDelivery) -> DataIssue {
369 DataIssue {
370 kind: "issue".to_owned(),
371 code: "process-message-lowering-rejected".to_owned(),
372 message: "Process messaging recipe could not lower retained message to a command fact"
373 .to_owned(),
374 severity: "error".to_owned(),
375 source: "process.messaging".to_owned(),
376 topic: Some(delivery.topic.clone()),
377 details: Some(delivery_details(delivery)),
378 }
379}
380
381fn issue_delivery(issue: &DataIssue) -> Option<MessageBusDelivery> {
382 issue.details.as_deref().and_then(parse_delivery_details)
383}
384
385fn ack_command<T>(delivery: &MessageBusDelivery, command_id: String) -> MessageBusCommand<T> {
386 MessageBusCommand::Ack {
387 topic: delivery.topic.clone(),
388 subscription_id: delivery.subscription_id.clone(),
389 seq: delivery.seq,
390 command_id: Some(command_id),
391 }
392}
393
394fn shift_delivery(
395 deliveries: &mut BTreeMap<String, Vec<MessageBusDelivery>>,
396 command_id: &str,
397) -> Option<MessageBusDelivery> {
398 let queue = deliveries.get_mut(command_id)?;
399 if queue.is_empty() {
400 return None;
401 }
402 let first = queue.remove(0);
403 if queue.is_empty() {
404 deliveries.remove(command_id);
405 }
406 Some(first)
407}
408
409fn delivery_details(delivery: &MessageBusDelivery) -> String {
410 format!(
411 "messageBus:topic={};subscription_id={};seq={};command_id={}",
412 delivery.topic, delivery.subscription_id, delivery.seq, delivery.command_id
413 )
414}
415
416fn parse_delivery_details(details: &str) -> Option<MessageBusDelivery> {
417 let rest = details.strip_prefix("messageBus:")?;
418 let mut topic = None;
419 let mut subscription_id = None;
420 let mut seq = None;
421 let mut command_id = None;
422 for part in rest.split(';') {
423 let (key, value) = part.split_once('=')?;
424 match key {
425 "topic" => topic = Some(value.to_owned()),
426 "subscription_id" => subscription_id = Some(value.to_owned()),
427 "seq" => seq = value.parse::<u64>().ok(),
428 "command_id" => command_id = Some(value.to_owned()),
429 _ => {}
430 }
431 }
432 Some(MessageBusDelivery {
433 topic: topic?,
434 subscription_id: subscription_id?,
435 seq: seq?,
436 command_id: command_id?,
437 })
438}