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, Aggregate, AggregateEvent, EventFilter, Executor};
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, Debug, serde::Serialize, serde::Deserialize)]
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::subscription]
68/// async fn my_handler<E: Executor>(
69///     context: &Context<'_, E>,
70///     event: Event<MyEventData>,
71/// ) -> anyhow::Result<()> {
72///     // Access shared data
73///     let config: Data<AppConfig> = context.extract();
74///
75///     // Use executor for queries
76///     let events = context.executor.read(...).await?;
77///     Ok(())
78/// }
79/// ```
80#[derive(Clone)]
81pub struct Context<'a, E: Executor> {
82    context: context::RwContext,
83    /// Reference to the executor for database operations
84    pub executor: &'a E,
85}
86
87impl<'a, E: Executor> Deref for Context<'a, E> {
88    type Target = context::RwContext;
89
90    fn deref(&self) -> &Self::Target {
91        &self.context
92    }
93}
94
95/// Trait for event handlers.
96///
97/// Handlers process events in two modes:
98/// - `handle`: For subscriptions that perform side effects (send emails, update read models)
99/// - `apply`: For loading aggregate state by replaying events
100///
101/// This trait is typically implemented via the `#[evento::handler]` macro.
102pub trait Handler<E: Executor>: Sync + Send {
103    /// Handles an event during subscription processing.
104    ///
105    /// This is called when processing events in a subscription context,
106    /// where side effects like database updates or API calls are appropriate.
107    fn handle<'a>(
108        &'a self,
109        context: &'a Context<'a, E>,
110        event: &'a crate::Event,
111    ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>>;
112
113    /// Returns the aggregate type this handler processes.
114    fn aggregate_type(&self) -> &'static str;
115    /// Returns the event name this handler processes.
116    fn event_name(&self) -> &'static str;
117}
118
119/// Builder for creating event subscriptions.
120///
121/// Created via [`Projection::subscription`](crate::projection::Projection::subscription), this builder configures
122/// a continuous event processing subscription with retry logic,
123/// routing key filtering, and graceful shutdown support.
124///
125/// # Example
126///
127/// ```rust,ignore
128/// let subscription = projection
129///     .subscription()
130///     .routing_key("accounts")
131///     .chunk_size(100)
132///     .retry(5)
133///     .delay(Duration::from_secs(10))
134///     .start(&executor)
135///     .await?;
136///
137/// // Later, gracefully shutdown
138/// subscription.shutdown().await?;
139/// ```
140pub struct SubscriptionBuilder<E: Executor> {
141    key: String,
142    handlers: HashMap<String, Box<dyn Handler<E>>>,
143    context: context::RwContext,
144    routing_key: Option<RoutingKey>,
145    prefix_key: Option<String>,
146    delay: Option<Duration>,
147    poll_interval: Duration,
148    chunk_size: u16,
149    continue_on_error: 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            poll_interval: Duration::from_millis(250),
168            retry: Some(30),
169            chunk_size: 300,
170            continue_on_error: false,
171            routing_key: None,
172            prefix_key: None,
173            aggregators: Default::default(),
174            shutdown_rx: None,
175        }
176    }
177
178    /// Enables safety checks for unhandled events.
179    ///
180    /// When enabled, processing fails if an event is encountered without a handler.
181    pub fn strict(mut self) -> Self {
182        self.safety_disabled = false;
183
184        self
185    }
186
187    /// Registers an event handler with this subscription.
188    ///
189    /// # Panics
190    ///
191    /// Panics if a handler for the same event type is already registered.
192    pub fn handler<H: Handler<E> + 'static>(mut self, h: H) -> Self {
193        let key = format!("{}_{}", h.aggregate_type(), h.event_name());
194        if self.handlers.insert(key.to_owned(), Box::new(h)).is_some() {
195            panic!("Cannot register event handler: key {} already exists", key);
196        }
197        self
198    }
199
200    /// Registers a skip handler for an event type.
201    ///
202    /// Events of this type will be acknowledged but not processed.
203    ///
204    /// # Panics
205    ///
206    /// Panics if a handler for the same event type is already registered.
207    pub fn skip<EV: AggregateEvent + Send + Sync + 'static>(self) -> Self {
208        self.handler(SkipHandler::<EV>(PhantomData))
209    }
210
211    /// Adds shared data to the subscription context.
212    ///
213    /// Data added here is accessible in handlers via the context.
214    pub fn data<D: Send + Sync + 'static>(self, v: D) -> Self {
215        self.context.insert(v);
216
217        self
218    }
219
220    /// Allows the subscription to continue after handler failures.
221    ///
222    /// By default, subscriptions stop on the first error. With this flag,
223    /// errors are logged but processing continues.
224    pub fn continue_on_error(mut self) -> Self {
225        self.continue_on_error = true;
226
227        self
228    }
229
230    /// Sets the number of events to process per batch.
231    ///
232    /// Default is 300.
233    pub fn chunk_size(mut self, v: u16) -> Self {
234        self.chunk_size = v;
235
236        self
237    }
238
239    /// Sets a delay before starting the subscription.
240    ///
241    /// Useful for staggering subscription starts in multi-node deployments.
242    pub fn delay(mut self, v: Duration) -> Self {
243        self.delay = Some(v);
244
245        self
246    }
247
248    /// Sets the polling interval used as a fallback when no write signal is
249    /// available.
250    ///
251    /// When the executor supports [`write_watch`](crate::Executor::write_watch)
252    /// (the default backends do), an in-process write wakes the subscription
253    /// immediately and this interval only bounds latency for writes the signal
254    /// cannot observe — cross-process writers or custom executors. Backlogs are
255    /// drained at full speed regardless of this value. Default is 250ms.
256    pub fn poll_interval(mut self, v: Duration) -> Self {
257        self.poll_interval = v;
258
259        self
260    }
261
262    /// Filters events by routing key.
263    ///
264    /// Only events with the matching routing key will be processed.
265    /// Overrides any executor-level default.
266    pub fn routing_key(mut self, v: impl Into<String>) -> Self {
267        self.routing_key = Some(RoutingKey::Value(Some(v.into())));
268
269        self
270    }
271
272    /// Sets the maximum number of retries on failure.
273    ///
274    /// Uses exponential backoff. Default is 30.
275    pub fn retry(mut self, v: u8) -> Self {
276        self.retry = Some(v);
277
278        self
279    }
280
281    /// Processes all events regardless of routing key.
282    ///
283    /// Overrides any executor-level default.
284    pub fn all(mut self) -> Self {
285        self.routing_key = Some(RoutingKey::All);
286
287        self
288    }
289
290    /// Adds a related aggregate to process events from.
291    pub fn aggregate<A: Aggregate>(mut self, id: impl Into<String>) -> Self {
292        self.aggregators
293            .insert(A::aggregate_type().to_owned(), id.into());
294
295        self
296    }
297
298    fn read_aggregators(&self) -> Vec<EventFilter> {
299        self.handlers
300            .values()
301            .map(|h| {
302                // `#[subscription_all]` handlers report the sentinel name "all" and
303                // match every event of the type, so they must read by type rather
304                // than by a literal event name (which would match nothing).
305                let by_name = self.safety_disabled && h.event_name() != "all";
306                match self.aggregators.get(h.aggregate_type()) {
307                    Some(id) => EventFilter {
308                        aggregate_type: h.aggregate_type().to_owned(),
309                        aggregate_id: Some(id.to_owned()),
310                        name: by_name.then(|| h.event_name().to_owned()),
311                    },
312                    _ => {
313                        if by_name {
314                            EventFilter::by_event(h.aggregate_type(), h.event_name())
315                        } else {
316                            EventFilter::by_type(h.aggregate_type())
317                        }
318                    }
319                }
320            })
321            .collect()
322    }
323
324    fn key(&self) -> String {
325        let prefix = match &self.routing_key {
326            Some(RoutingKey::Value(Some(k))) => Some(k.as_str()),
327            Some(RoutingKey::All) => self.prefix_key.as_deref(),
328            _ => None,
329        };
330        match prefix {
331            Some(p) => format!("{p}.{}", self.key),
332            None => self.key.to_owned(),
333        }
334    }
335
336    /// Resolves an unset routing key from the executor's default and captures
337    /// the default as a storage-key prefix.
338    ///
339    /// Called once at the top of `start()` / `run_once()` before any other
340    /// method reads `self.routing_key`. After this, `routing_key` is always
341    /// `Some(_)`.
342    ///
343    /// `prefix_key` is captured from `executor.default_routing_key()` even
344    /// when the user has already called `.all()`, so the storage key remains
345    /// scoped to the executor's tenant — otherwise two executors with
346    /// different defaults would share one row in the subscriber table.
347    fn resolve_routing_key(&mut self, executor: &E) {
348        if self.prefix_key.is_none() {
349            self.prefix_key = executor.default_routing_key().map(|s| s.to_owned());
350        }
351        if self.routing_key.is_some() {
352            return;
353        }
354        self.routing_key = Some(match executor.default_routing_key() {
355            Some(k) => RoutingKey::Value(Some(k.to_owned())),
356            None => RoutingKey::Value(None),
357        });
358    }
359
360    fn effective_routing_key(&self) -> RoutingKey {
361        self.routing_key.clone().unwrap_or(RoutingKey::Value(None))
362    }
363
364    #[tracing::instrument(
365        skip_all,
366        fields(
367            subscription = Empty,
368            aggregate_type = Empty,
369            aggregate_id = Empty,
370            event = Empty,
371        )
372    )]
373    async fn process(
374        &self,
375        executor: &E,
376        id: &Ulid,
377        aggregators: &[EventFilter],
378    ) -> anyhow::Result<bool> {
379        // Drains all currently-available events back-to-back and returns as soon
380        // as it catches up. The caller (`start`'s loop) owns all waiting — poll
381        // interval, write signal, and shutdown — so there is no pacing here: this
382        // keeps both fresh-event latency and backlog drain at full speed.
383        tracing::Span::current().record("subscription", self.key());
384
385        loop {
386            if !executor.is_subscriber_running(self.key(), *id).await? {
387                return Ok(false);
388            }
389
390            let cursor = executor.get_subscriber_cursor(self.key()).await?;
391
392            let res = executor
393                .read(
394                    Some(aggregators.to_vec()),
395                    Some(self.effective_routing_key()),
396                    Args::forward(self.chunk_size, cursor),
397                )
398                .await?;
399
400            if res.edges.is_empty() {
401                return Ok(false);
402            }
403
404            // A partial chunk means everything currently available has been read;
405            // a full chunk likely has more pending, so loop and read the next one
406            // immediately.
407            let drained = res.edges.len() < self.chunk_size as usize;
408
409            // Stability watermark (microseconds since epoch): on a replicated
410            // backend that can apply events out of cursor order, the subscription
411            // must not advance past it, or a late lower-cursor event would be
412            // skipped. `None` (single-store backends) means no gating.
413            let stable = executor.stable_timestamp();
414
415            let timestamp = executor
416                .latest_timestamp(
417                    Some(aggregators.to_vec()),
418                    Some(self.effective_routing_key()),
419                )
420                .await?;
421
422            let context = Context {
423                context: self.context.clone(),
424                executor,
425            };
426
427            for event in res.edges {
428                // Edges arrive in ascending cursor order, so the first event at or
429                // above the watermark (and every event after it) is held back: we
430                // stop without advancing the cursor and retry on the next tick,
431                // once the watermark has moved forward.
432                if let Some(w) = stable {
433                    let event_micros = (event.node.timestamp)
434                        .saturating_mul(1_000_000)
435                        .saturating_add(event.node.timestamp_subsec as u64 * 1_000);
436                    if event_micros >= w {
437                        return Ok(false);
438                    }
439                }
440
441                if let Some(ref rx) = self.shutdown_rx {
442                    let mut rx = rx.lock().await;
443                    if rx.try_recv().is_ok() {
444                        tracing::info!(
445                            key = self.key(),
446                            "Subscription received shutdown signal, stopping gracefully"
447                        );
448
449                        return Ok(true);
450                    }
451                    drop(rx);
452                }
453
454                tracing::Span::current().record("aggregate_type", &event.node.aggregate_type);
455                tracing::Span::current().record("aggregate_id", &event.node.aggregate_id);
456                tracing::Span::current().record("event", &event.node.name);
457
458                let all_key = format!("{}_all", event.node.aggregate_type);
459                let key = format!("{}_{}", event.node.aggregate_type, event.node.name);
460                let Some(handler) = self.handlers.get(&all_key).or(self.handlers.get(&key)) else {
461                    if !self.safety_disabled {
462                        anyhow::bail!("no handler s={} k={key}", self.key());
463                    }
464
465                    continue;
466                };
467
468                if let Err(err) = handler.handle(&context, &event.node).await {
469                    tracing::error!("failed");
470
471                    return Err(err);
472                }
473
474                tracing::debug!("completed");
475
476                executor
477                    .acknowledge(
478                        self.key(),
479                        event.cursor.to_owned(),
480                        timestamp.saturating_sub(event.node.timestamp),
481                    )
482                    .await?;
483            }
484
485            if drained {
486                return Ok(false);
487            }
488        }
489    }
490
491    /// Disables retry-on-failure for this subscription.
492    ///
493    /// By default failed batches are retried with exponential backoff (see
494    /// [`retry`](Self::retry)). Combine this with [`start`](Self::start) or
495    /// [`run_once`](Self::run_once) to process without retries.
496    pub fn no_retry(mut self) -> Self {
497        self.retry = None;
498
499        self
500    }
501
502    /// Starts a continuous background subscription.
503    ///
504    /// Returns a [`Subscription`] handle that can be used for graceful shutdown.
505    /// The subscription runs in a spawned tokio task and polls for new events.
506    #[tracing::instrument(skip_all, fields(
507        subscription = self.key(),
508        aggregate_type = tracing::field::Empty,
509        aggregate_id = tracing::field::Empty,
510        event = tracing::field::Empty,
511    ))]
512    pub async fn start(mut self, executor: &E) -> anyhow::Result<Subscription>
513    where
514        E: Clone,
515    {
516        self.resolve_routing_key(executor);
517        let executor = executor.clone();
518        let id = Ulid::new();
519        let subscription_id = id;
520        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
521        self.shutdown_rx = Some(Mutex::new(shutdown_rx));
522
523        executor
524            .upsert_subscriber(self.key(), id.to_owned())
525            .await?;
526
527        let mut write_watch = executor.write_watch();
528
529        let task_handle = tokio::spawn(async move {
530            let read_aggregators = self.read_aggregators();
531            let start = self
532                .delay
533                .map(|d| Instant::now() + d)
534                .unwrap_or_else(Instant::now);
535
536            // First tick fires at `start` (immediately when no delay is set), so
537            // an existing backlog is processed right away. Thereafter this paces
538            // the fallback re-poll; the write signal (when available) wakes the
539            // loop sooner.
540            let mut interval = interval_at(start, self.poll_interval);
541
542            loop {
543                // Wake on whichever comes first: an in-process write (when the
544                // executor exposes a signal), the fallback poll tick, or a
545                // shutdown signal. Selecting on shutdown here keeps shutdown
546                // immediate even with a long poll interval. `changed()` only
547                // resolves for write generations newer than the last seen, so it
548                // never busy-loops.
549                let mut shutdown = false;
550                {
551                    let mut shutdown_guard = match self.shutdown_rx {
552                        Some(ref rx) => Some(rx.lock().await),
553                        None => None,
554                    };
555                    match (write_watch.as_mut(), shutdown_guard.as_mut()) {
556                        (Some(rx), Some(srx)) => tokio::select! {
557                            _ = interval.tick() => {}
558                            res = rx.changed() => {
559                                if res.is_ok() {
560                                    rx.borrow_and_update();
561                                }
562                            }
563                            _ = &mut **srx => { shutdown = true; }
564                        },
565                        (Some(rx), None) => tokio::select! {
566                            _ = interval.tick() => {}
567                            res = rx.changed() => {
568                                if res.is_ok() {
569                                    rx.borrow_and_update();
570                                }
571                            }
572                        },
573                        (None, Some(srx)) => tokio::select! {
574                            _ = interval.tick() => {}
575                            _ = &mut **srx => { shutdown = true; }
576                        },
577                        (None, None) => {
578                            interval.tick().await;
579                        }
580                    }
581                }
582
583                if shutdown {
584                    tracing::info!(
585                        key = self.key(),
586                        "Subscription received shutdown signal, stopping gracefully"
587                    );
588
589                    break;
590                }
591
592                let result = match self.retry {
593                    Some(retry) => {
594                        (|| async { self.process(&executor, &id, &read_aggregators).await })
595                            .retry(ExponentialBuilder::default().with_max_times(retry.into()))
596                            .sleep(tokio::time::sleep)
597                            .notify(|err, dur| {
598                                tracing::error!(
599                                    error = %err,
600                                    duration = ?dur,
601                                    "Failed to process event"
602                                );
603                            })
604                            .await
605                    }
606                    _ => self.process(&executor, &id, &read_aggregators).await,
607                };
608
609                match result {
610                    Ok(shutdown) => {
611                        if shutdown {
612                            break;
613                        }
614                    }
615                    Err(err) => {
616                        tracing::error!(error = %err, "Failed to process event");
617
618                        if !self.continue_on_error {
619                            break;
620                        }
621                    }
622                };
623            }
624        });
625
626        Ok(Subscription {
627            id: subscription_id,
628            task_handle,
629            shutdown_tx,
630        })
631    }
632
633    /// Processes all currently pending events once, then returns.
634    ///
635    /// Unlike [`start`](Self::start), this does not run continuously or spawn a
636    /// background task — it drains the events available now and returns. Pair with
637    /// [`no_retry`](Self::no_retry) to run a single pass without retries.
638    #[tracing::instrument(skip_all, fields(
639        subscription = self.key(),
640        aggregate_type = tracing::field::Empty,
641        aggregate_id = tracing::field::Empty,
642        event = tracing::field::Empty,
643    ))]
644    pub async fn run_once(&mut self, executor: &E) -> anyhow::Result<()> {
645        self.resolve_routing_key(executor);
646        let id = Ulid::new();
647
648        executor
649            .upsert_subscriber(self.key(), id.to_owned())
650            .await?;
651
652        let read_aggregators = self.read_aggregators();
653
654        match self.retry {
655            Some(retry) => {
656                (|| async { self.process(executor, &id, &read_aggregators).await })
657                    .retry(ExponentialBuilder::default().with_max_times(retry.into()))
658                    .sleep(tokio::time::sleep)
659                    .notify(|err, dur| {
660                        tracing::error!(
661                            error = %err,
662                            duration = ?dur,
663                            "Failed to process event"
664                        );
665                    })
666                    .await
667            }
668            _ => self.process(executor, &id, &read_aggregators).await,
669        }?;
670
671        Ok(())
672    }
673}
674
675/// Handle to a running event subscription.
676///
677/// Returned by [`SubscriptionBuilder::start`], this handle provides
678/// the subscription ID and a method for graceful shutdown.
679///
680/// # Example
681///
682/// ```rust,ignore
683/// let subscription = projection
684///     .subscription()
685///     .start(&executor)
686///     .await?;
687///
688/// println!("Started subscription: {}", subscription.id);
689///
690/// // On application shutdown
691/// subscription.shutdown().await?;
692/// ```
693#[derive(Debug)]
694pub struct Subscription {
695    /// Unique ID for this subscription instance
696    pub id: Ulid,
697    task_handle: tokio::task::JoinHandle<()>,
698    shutdown_tx: tokio::sync::oneshot::Sender<()>,
699}
700
701impl Subscription {
702    /// Gracefully shuts down the subscription.
703    ///
704    /// Signals the subscription to stop and waits for it to finish
705    /// processing the current event before returning.
706    pub async fn shutdown(self) -> Result<(), tokio::task::JoinError> {
707        let _ = self.shutdown_tx.send(());
708
709        self.task_handle.await
710    }
711}
712
713struct SkipHandler<E: AggregateEvent>(PhantomData<E>);
714
715impl<E: Executor, EV: AggregateEvent + Send + Sync> Handler<E> for SkipHandler<EV> {
716    fn handle<'a>(
717        &'a self,
718        _context: &'a Context<'a, E>,
719        _event: &'a crate::Event,
720    ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>> {
721        Box::pin(async { Ok(()) })
722    }
723
724    fn aggregate_type(&self) -> &'static str {
725        EV::aggregate_type()
726    }
727
728    fn event_name(&self) -> &'static str {
729        EV::event_name()
730    }
731}