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}