Skip to main content

evento_core/
subscription.rs

1//! Continuous event subscriptions.
2//!
3//! This module provides infrastructure for processing events continuously
4//! in the background with retry logic, routing key filtering, and graceful
5//! shutdown support.
6//!
7//! # Key Types
8//!
9//! - [`SubscriptionBuilder`] - Builds and configures event subscriptions
10//! - [`Subscription`] - Handle to a running subscription
11//! - [`Handler`] - Trait for event handlers
12//! - [`Context`] - Handler context with executor access
13//! - [`RoutingKey`] - Filter for event routing
14//!
15//! # Example
16//!
17//! ```rust,ignore
18//! use evento::subscription::SubscriptionBuilder;
19//!
20//! // Build a subscription with handlers
21//! let subscription = SubscriptionBuilder::new("my-subscription")
22//!     .handler(account_opened_handler)
23//!     .handler(money_deposited_handler)
24//!     .routing_key("accounts")
25//!     .chunk_size(100)
26//!     .retry(5)
27//!     .start(&executor)
28//!     .await?;
29//!
30//! // Later, gracefully shutdown
31//! subscription.shutdown().await?;
32//! ```
33
34use backon::{ExponentialBuilder, Retryable};
35use std::{
36    collections::HashMap, future::Future, marker::PhantomData, ops::Deref, pin::Pin, time::Duration,
37};
38use tokio::{
39    sync::{oneshot::Receiver, Mutex},
40    time::{interval_at, Instant},
41};
42use tracing::field::Empty;
43use ulid::Ulid;
44
45use crate::{context, cursor::Args, Aggregator, AggregatorEvent, Executor, ReadAggregator};
46
47/// Filter for events by routing key.
48///
49/// Routing keys allow partitioning events for parallel processing
50/// or filtering subscriptions to specific event streams.
51#[derive(Clone)]
52pub enum RoutingKey {
53    /// Match all events regardless of routing key
54    All,
55    /// Match events with a specific routing key (or no key if `None`)
56    Value(Option<String>),
57}
58
59/// Handler context providing access to executor and shared data.
60///
61/// `Context` wraps an [`RwContext`](crate::context::RwContext) for type-safe
62/// data storage and provides access to the executor for database operations.
63///
64/// # Example
65///
66/// ```rust,ignore
67/// #[evento::handler]
68/// async fn my_handler<E: Executor>(
69///     event: Event<MyEventData>,
70///     action: Action<'_, MyView, E>,
71/// ) -> anyhow::Result<()> {
72///     if let Action::Handle(ctx) = action {
73///         // Access shared data
74///         let config: Data<AppConfig> = ctx.extract();
75///
76///         // Use executor for queries
77///         let events = ctx.executor.read(...).await?;
78///     }
79///     Ok(())
80/// }
81/// ```
82#[derive(Clone)]
83pub struct Context<'a, E: Executor> {
84    context: context::RwContext,
85    /// Reference to the executor for database operations
86    pub executor: &'a E,
87}
88
89impl<'a, E: Executor> Deref for Context<'a, E> {
90    type Target = context::RwContext;
91
92    fn deref(&self) -> &Self::Target {
93        &self.context
94    }
95}
96
97/// Trait for event handlers.
98///
99/// Handlers process events in two modes:
100/// - `handle`: For subscriptions that perform side effects (send emails, update read models)
101/// - `apply`: For loading aggregate state by replaying events
102///
103/// This trait is typically implemented via the `#[evento::handler]` macro.
104pub trait Handler<E: Executor>: Sync + Send {
105    /// Handles an event during subscription processing.
106    ///
107    /// This is called when processing events in a subscription context,
108    /// where side effects like database updates or API calls are appropriate.
109    fn handle<'a>(
110        &'a self,
111        context: &'a Context<'a, E>,
112        event: &'a crate::Event,
113    ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>>;
114
115    /// Returns the aggregator type this handler processes.
116    fn aggregator_type(&self) -> &'static str;
117    /// Returns the event name this handler processes.
118    fn event_name(&self) -> &'static str;
119}
120
121/// Builder for creating event subscriptions.
122///
123/// Created via [`Projection::subscription`], this builder configures
124/// a continuous event processing subscription with retry logic,
125/// routing key filtering, and graceful shutdown support.
126///
127/// # Example
128///
129/// ```rust,ignore
130/// let subscription = projection
131///     .subscription()
132///     .routing_key("accounts")
133///     .chunk_size(100)
134///     .retry(5)
135///     .delay(Duration::from_secs(10))
136///     .start(&executor)
137///     .await?;
138///
139/// // Later, gracefully shutdown
140/// subscription.shutdown().await?;
141/// ```
142pub struct SubscriptionBuilder<E: Executor> {
143    key: String,
144    handlers: HashMap<String, Box<dyn Handler<E>>>,
145    context: context::RwContext,
146    routing_key: Option<RoutingKey>,
147    delay: Option<Duration>,
148    chunk_size: u16,
149    is_accept_failure: bool,
150    retry: Option<u8>,
151    aggregators: HashMap<String, String>,
152    safety_disabled: bool,
153    shutdown_rx: Option<Mutex<Receiver<()>>>,
154}
155
156impl<E: Executor + 'static> SubscriptionBuilder<E> {
157    /// Creates a new projection with the given key.
158    ///
159    /// The key is used as the subscription identifier for cursor tracking.
160    pub fn new(key: impl Into<String>) -> Self {
161        Self {
162            key: key.into(),
163            handlers: HashMap::new(),
164            safety_disabled: true,
165            context: Default::default(),
166            delay: None,
167            retry: Some(30),
168            chunk_size: 300,
169            is_accept_failure: false,
170            routing_key: None,
171            aggregators: Default::default(),
172            shutdown_rx: None,
173        }
174    }
175
176    /// Enables safety checks for unhandled events.
177    ///
178    /// When enabled, processing fails if an event is encountered without a handler.
179    pub fn safety_check(mut self) -> Self {
180        self.safety_disabled = false;
181
182        self
183    }
184
185    /// Registers an event handler with this subscription.
186    ///
187    /// # Panics
188    ///
189    /// Panics if a handler for the same event type is already registered.
190    pub fn handler<H: Handler<E> + 'static>(mut self, h: H) -> Self {
191        let key = format!("{}_{}", h.aggregator_type(), h.event_name());
192        if self.handlers.insert(key.to_owned(), Box::new(h)).is_some() {
193            panic!("Cannot register event handler: key {} already exists", key);
194        }
195        self
196    }
197
198    /// Registers a skip handler for an event type.
199    ///
200    /// Events of this type will be acknowledged but not processed.
201    ///
202    /// # Panics
203    ///
204    /// Panics if a handler for the same event type is already registered.
205    pub fn skip<EV: AggregatorEvent + Send + Sync + 'static>(self) -> Self {
206        self.handler(SkipHandler::<EV>(PhantomData))
207    }
208
209    /// Adds shared data to the subscription context.
210    ///
211    /// Data added here is accessible in handlers via the context.
212    pub fn data<D: Send + Sync + 'static>(self, v: D) -> Self {
213        self.context.insert(v);
214
215        self
216    }
217
218    /// Allows the subscription to continue after handler failures.
219    ///
220    /// By default, subscriptions stop on the first error. With this flag,
221    /// errors are logged but processing continues.
222    pub fn accept_failure(mut self) -> Self {
223        self.is_accept_failure = true;
224
225        self
226    }
227
228    /// Sets the number of events to process per batch.
229    ///
230    /// Default is 300.
231    pub fn chunk_size(mut self, v: u16) -> Self {
232        self.chunk_size = v;
233
234        self
235    }
236
237    /// Sets a delay before starting the subscription.
238    ///
239    /// Useful for staggering subscription starts in multi-node deployments.
240    pub fn delay(mut self, v: Duration) -> Self {
241        self.delay = Some(v);
242
243        self
244    }
245
246    /// Filters events by routing key.
247    ///
248    /// Only events with the matching routing key will be processed.
249    /// Overrides any executor-level default.
250    pub fn routing_key(mut self, v: impl Into<String>) -> Self {
251        self.routing_key = Some(RoutingKey::Value(Some(v.into())));
252
253        self
254    }
255
256    /// Sets the maximum number of retries on failure.
257    ///
258    /// Uses exponential backoff. Default is 30.
259    pub fn retry(mut self, v: u8) -> Self {
260        self.retry = Some(v);
261
262        self
263    }
264
265    /// Processes all events regardless of routing key.
266    ///
267    /// Overrides any executor-level default.
268    pub fn all(mut self) -> Self {
269        self.routing_key = Some(RoutingKey::All);
270
271        self
272    }
273
274    /// Adds a related aggregate to process events from.
275    pub fn aggregator<A: Aggregator>(mut self, id: impl Into<String>) -> Self {
276        self.aggregators
277            .insert(A::aggregator_type().to_owned(), id.into());
278
279        self
280    }
281
282    fn read_aggregators(&self) -> Vec<ReadAggregator> {
283        self.handlers
284            .values()
285            .map(|h| match self.aggregators.get(h.aggregator_type()) {
286                Some(id) => ReadAggregator {
287                    aggregator_type: h.aggregator_type().to_owned(),
288                    aggregator_id: Some(id.to_owned()),
289                    name: if self.safety_disabled {
290                        Some(h.event_name().to_owned())
291                    } else {
292                        None
293                    },
294                },
295                _ => {
296                    if self.safety_disabled {
297                        ReadAggregator::event(h.aggregator_type(), h.event_name())
298                    } else {
299                        ReadAggregator::aggregator(h.aggregator_type())
300                    }
301                }
302            })
303            .collect()
304    }
305
306    fn key(&self) -> String {
307        if let Some(RoutingKey::Value(Some(ref key))) = self.routing_key {
308            return format!("{key}.{}", self.key);
309        }
310
311        self.key.to_owned()
312    }
313
314    /// Resolves an unset routing key from the executor's default.
315    ///
316    /// Called once at the top of `start()` / `execute()` before any other
317    /// method reads `self.routing_key`. After this, `routing_key` is always
318    /// `Some(_)`.
319    fn resolve_routing_key(&mut self, executor: &E) {
320        if self.routing_key.is_some() {
321            return;
322        }
323        self.routing_key = Some(match executor.default_routing_key() {
324            Some(k) => RoutingKey::Value(Some(k.to_owned())),
325            None => RoutingKey::Value(None),
326        });
327    }
328
329    fn effective_routing_key(&self) -> RoutingKey {
330        self.routing_key.clone().unwrap_or(RoutingKey::Value(None))
331    }
332
333    #[tracing::instrument(
334        skip_all,
335        fields(
336            subscription = Empty,
337            aggregator_type = Empty,
338            aggregator_id = Empty,
339            event = Empty,
340        )
341    )]
342    async fn process(
343        &self,
344        executor: &E,
345        id: &Ulid,
346        aggregators: &[ReadAggregator],
347    ) -> anyhow::Result<bool> {
348        let mut interval = interval_at(
349            Instant::now() - Duration::from_millis(400),
350            Duration::from_millis(300),
351        );
352
353        tracing::Span::current().record("subscription", self.key());
354
355        loop {
356            interval.tick().await;
357
358            if !executor.is_subscriber_running(self.key(), *id).await? {
359                return Ok(false);
360            }
361
362            let cursor = executor.get_subscriber_cursor(self.key()).await?;
363
364            let res = executor
365                .read(
366                    Some(aggregators.to_vec()),
367                    Some(self.effective_routing_key()),
368                    Args::forward(self.chunk_size, cursor),
369                )
370                .await?;
371
372            if res.edges.is_empty() {
373                return Ok(false);
374            }
375
376            let timestamp = executor
377                .latest_timestamp(
378                    Some(aggregators.to_vec()),
379                    Some(self.effective_routing_key()),
380                )
381                .await?;
382
383            let context = Context {
384                context: self.context.clone(),
385                executor,
386            };
387
388            for event in res.edges {
389                if let Some(ref rx) = self.shutdown_rx {
390                    let mut rx = rx.lock().await;
391                    if rx.try_recv().is_ok() {
392                        tracing::info!(
393                            key = self.key(),
394                            "Subscription received shutdown signal, stopping gracefull"
395                        );
396
397                        return Ok(true);
398                    }
399                    drop(rx);
400                }
401
402                tracing::Span::current().record("aggregator_type", &event.node.aggregator_type);
403                tracing::Span::current().record("aggregator_id", &event.node.aggregator_id);
404                tracing::Span::current().record("event", &event.node.name);
405
406                let all_key = format!("{}_all", event.node.aggregator_type);
407                let key = format!("{}_{}", event.node.aggregator_type, event.node.name);
408                let Some(handler) = self.handlers.get(&all_key).or(self.handlers.get(&key)) else {
409                    if !self.safety_disabled {
410                        anyhow::bail!("no handler s={} k={key}", self.key());
411                    }
412
413                    continue;
414                };
415
416                if let Err(err) = handler.handle(&context, &event.node).await {
417                    tracing::error!("failed");
418
419                    return Err(err);
420                }
421
422                tracing::debug!("completed");
423
424                executor
425                    .acknowledge(
426                        self.key(),
427                        event.cursor.to_owned(),
428                        timestamp.saturating_sub(event.node.timestamp),
429                    )
430                    .await?;
431            }
432        }
433    }
434
435    /// Starts the subscription without retry logic.
436    ///
437    /// Equivalent to calling `start()` with retries disabled.
438    pub async fn unretry_start(mut self, executor: &E) -> anyhow::Result<Subscription>
439    where
440        E: Clone,
441    {
442        self.retry = None;
443        self.start(executor).await
444    }
445
446    /// Starts a continuous background subscription.
447    ///
448    /// Returns a [`Subscription`] handle that can be used for graceful shutdown.
449    /// The subscription runs in a spawned tokio task and polls for new events.
450    #[tracing::instrument(skip_all, fields(
451        subscription = self.key(),
452        aggregator_type = tracing::field::Empty,
453        aggregator_id = tracing::field::Empty,
454        event = tracing::field::Empty,
455    ))]
456    pub async fn start(mut self, executor: &E) -> anyhow::Result<Subscription>
457    where
458        E: Clone,
459    {
460        self.resolve_routing_key(executor);
461        let executor = executor.clone();
462        let id = Ulid::new();
463        let subscription_id = id;
464        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
465        self.shutdown_rx = Some(Mutex::new(shutdown_rx));
466
467        executor
468            .upsert_subscriber(self.key(), id.to_owned())
469            .await?;
470
471        let task_handle = tokio::spawn(async move {
472            let read_aggregators = self.read_aggregators();
473            let start = self
474                .delay
475                .map(|d| Instant::now() + d)
476                .unwrap_or_else(Instant::now);
477
478            let mut interval = interval_at(
479                start - Duration::from_millis(1200),
480                Duration::from_millis(1000),
481            );
482
483            loop {
484                interval.tick().await;
485
486                if let Some(ref rx) = self.shutdown_rx {
487                    let mut rx = rx.lock().await;
488                    if rx.try_recv().is_ok() {
489                        tracing::info!(
490                            key = self.key(),
491                            "Subscription received shutdown signal, stopping gracefull"
492                        );
493
494                        break;
495                    }
496                    drop(rx);
497                }
498
499                let result = match self.retry {
500                    Some(retry) => {
501                        (|| async { self.process(&executor, &id, &read_aggregators).await })
502                            .retry(ExponentialBuilder::default().with_max_times(retry.into()))
503                            .sleep(tokio::time::sleep)
504                            .notify(|err, dur| {
505                                tracing::error!(
506                                    error = %err,
507                                    duration = ?dur,
508                                    "Failed to process event"
509                                );
510                            })
511                            .await
512                    }
513                    _ => self.process(&executor, &id, &read_aggregators).await,
514                };
515
516                match result {
517                    Ok(shutdown) => {
518                        if shutdown {
519                            break;
520                        }
521                    }
522                    Err(err) => {
523                        tracing::error!(error = %err, "Failed to process event");
524
525                        if !self.is_accept_failure {
526                            break;
527                        }
528                    }
529                };
530            }
531        });
532
533        Ok(Subscription {
534            id: subscription_id,
535            task_handle,
536            shutdown_tx,
537        })
538    }
539
540    /// Executes the subscription once without retry logic.
541    ///
542    /// Processes all pending events and returns. Does not poll for new events.
543    pub async fn unretry_execute(mut self, executor: &E) -> anyhow::Result<()> {
544        self.retry = None;
545        self.execute(executor).await
546    }
547
548    /// Executes the subscription once, processing all pending events.
549    ///
550    /// Unlike `start()`, this does not run continuously. It processes
551    /// all currently pending events and returns.
552    #[tracing::instrument(skip_all, fields(
553        subscription = self.key(),
554        aggregator_type = tracing::field::Empty,
555        aggregator_id = tracing::field::Empty,
556        event = tracing::field::Empty,
557    ))]
558    pub async fn execute(&mut self, executor: &E) -> anyhow::Result<()> {
559        self.resolve_routing_key(executor);
560        let id = Ulid::new();
561
562        executor
563            .upsert_subscriber(self.key(), id.to_owned())
564            .await?;
565
566        let read_aggregators = self.read_aggregators();
567
568        match self.retry {
569            Some(retry) => {
570                (|| async { self.process(executor, &id, &read_aggregators).await })
571                    .retry(ExponentialBuilder::default().with_max_times(retry.into()))
572                    .sleep(tokio::time::sleep)
573                    .notify(|err, dur| {
574                        tracing::error!(
575                            error = %err,
576                            duration = ?dur,
577                            "Failed to process event"
578                        );
579                    })
580                    .await
581            }
582            _ => self.process(executor, &id, &read_aggregators).await,
583        }?;
584
585        Ok(())
586    }
587}
588
589/// Handle to a running event subscription.
590///
591/// Returned by [`SubscriptionBuilder::start`], this handle provides
592/// the subscription ID and a method for graceful shutdown.
593///
594/// # Example
595///
596/// ```rust,ignore
597/// let subscription = projection
598///     .subscription()
599///     .start(&executor)
600///     .await?;
601///
602/// println!("Started subscription: {}", subscription.id);
603///
604/// // On application shutdown
605/// subscription.shutdown().await?;
606/// ```
607#[derive(Debug)]
608pub struct Subscription {
609    /// Unique ID for this subscription instance
610    pub id: Ulid,
611    task_handle: tokio::task::JoinHandle<()>,
612    shutdown_tx: tokio::sync::oneshot::Sender<()>,
613}
614
615impl Subscription {
616    /// Gracefully shuts down the subscription.
617    ///
618    /// Signals the subscription to stop and waits for it to finish
619    /// processing the current event before returning.
620    pub async fn shutdown(self) -> Result<(), tokio::task::JoinError> {
621        let _ = self.shutdown_tx.send(());
622
623        self.task_handle.await
624    }
625}
626
627struct SkipHandler<E: AggregatorEvent>(PhantomData<E>);
628
629impl<E: Executor, EV: AggregatorEvent + Send + Sync> Handler<E> for SkipHandler<EV> {
630    fn handle<'a>(
631        &'a self,
632        _context: &'a Context<'a, E>,
633        _event: &'a crate::Event,
634    ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>> {
635        Box::pin(async { Ok(()) })
636    }
637
638    fn aggregator_type(&self) -> &'static str {
639        EV::aggregator_type()
640    }
641
642    fn event_name(&self) -> &'static str {
643        EV::event_name()
644    }
645}