Skip to main content

obix/out/
ctx.rs

1//! Handler-controlled transaction scoping for outbox event-handler jobs.
2//!
3//! Every persistent event delivered to an
4//! [`OutboxEventHandler`](super::OutboxEventHandler) comes with an
5//! [`EventCtx`] that the handler must resolve into a [`Handled`] token by
6//! choosing exactly one of four entry verbs — a monotone cost ladder:
7//!
8//! | verb | meaning | cost |
9//! |------|---------|------|
10//! | [`EventCtx::skip`] | not my event | zero — no transaction is opened |
11//! | [`EventCtx::collect_with`] (and the [`collect`](EventCtx::collect) sugar) | contribute an item to the pending batch's accumulator | zero at collect time — one [`flush`](super::OutboxEventHandler::flush) call per batch applies all items |
12//! | [`EventCtx::consume_in_batch`] | do work joined to the pending batch op | shares one transaction (and one checkpoint) with neighboring events |
13//! | [`EventCtx::consume_isolated`] | land the pending batch first, then do my work in a fresh op | my event is its own atomic unit, fenced from history |
14//!
15//! and — when an op was taken — one of two exit verbs:
16//!
17//! | verb | meaning |
18//! |------|---------|
19//! | [`BatchOp::commit`] / [`IsolatedOp::commit`] | land the op (work + checkpoint, atomically) when the invocation returns |
20//! | [`BatchOp::defer`] | leave the op open so subsequent events can coalesce into it |
21//!
22//! A pending batch — an open op, collected items, or both — only ever exists
23//! while there is ready persistent backlog: the runner never awaits the
24//! stream while a batch is pending, so a pending stream is itself a flush
25//! trigger. Batching therefore rides bursts that already happened and adds
26//! no latency at low traffic. Ephemeral events travel on their own stream
27//! and are handled between batches — they never interrupt a batch, a
28//! transaction never spans the foreign `handle_ephemeral` await, and between
29//! batches the two streams race fairly so neither can starve the other.
30//! Handlers that only consume one stream should declare it via
31//! [`SUBSCRIPTION`](super::OutboxEventHandler::SUBSCRIPTION) — the other
32//! stream is then never subscribed at all.
33//!
34//! Every flush — whichever of the triggers fires — first hands all collected
35//! items to the handler's [`flush`](super::OutboxEventHandler::flush) inside
36//! the batch transaction, then persists the checkpoint at the last *fully
37//! handled* sequence (skips included), then commits: items, work and pointer
38//! are inseparable. A failed flush rolls everything back and replays the
39//! whole batch (items are re-collected), so collected work must tolerate
40//! wholesale replay — the same contract as [`defer`](BatchOp::defer).
41
42use serde::{Deserialize, Serialize};
43
44use std::marker::PhantomData;
45
46use job::CurrentJob;
47
48use crate::sequence::EventSequence;
49
50/// Error type shared with the handler trait methods.
51pub(crate) type HandlerError = Box<dyn std::error::Error + Send + Sync>;
52
53/// Persisted execution state of an outbox event-handler job: the sequence of
54/// the last fully handled persistent event.
55#[derive(Default, Clone, Copy, Serialize, Deserialize)]
56pub(crate) struct OutboxEventJobState {
57    pub(crate) sequence: EventSequence,
58}
59
60/// Book-keeping the runner shares with [`EventCtx`].
61pub(crate) struct BatchTracker {
62    /// Number of events contributing to the pending batch (consumed into the
63    /// op or collected into the accumulator) — drives `max_batch_size`.
64    pub(crate) events_in_op: usize,
65    /// Number of events that collected items since the last flush. Nonzero
66    /// means the accumulator is dirty: the batch is open even without an op,
67    /// and no checkpoint may be persisted before the items are flushed.
68    pub(crate) collected: usize,
69    /// Highest sequence whose checkpoint has been persisted to the database.
70    pub(crate) persisted_seq: EventSequence,
71    /// When the checkpoint was last persisted (any flush or standalone write).
72    pub(crate) last_persist: tokio::time::Instant,
73}
74
75pub(crate) struct CtxParts<'inv> {
76    pub(crate) op_slot: &'inv mut Option<es_entity::DbOp<'static>>,
77    pub(crate) current_job: &'inv mut CurrentJob,
78    pub(crate) state: &'inv OutboxEventJobState,
79    pub(crate) tracker: &'inv mut BatchTracker,
80}
81
82/// Proof that a persistent event was resolved in one of the legal ways.
83///
84/// Only obtainable from [`EventCtx::skip`], [`EventCtx::collect_with`] (or
85/// its [`collect`](EventCtx::collect) sugar), [`BatchOp::commit`],
86/// [`BatchOp::defer`] or [`IsolatedOp::commit`] — the type system forces
87/// every [`handle_persistent`](super::OutboxEventHandler::handle_persistent)
88/// invocation to decide the transactional fate of its event.
89///
90/// The token is branded with the invocation's lifetime, so it cannot leave
91/// the invocation that minted it: handlers are `'static`, so stashing a
92/// token for a later invocation does not compile —
93///
94/// ```compile_fail
95/// use obix::{EventCtx, Handled};
96///
97/// struct Evil {
98///     stash: std::sync::Mutex<Option<Handled<'static>>>,
99/// }
100///
101/// fn stash_it(ctx: EventCtx<'_>, evil: &Evil) {
102///     // error[E0521]: borrowed data escapes outside of function
103///     *evil.stash.lock().unwrap() = Some(ctx.skip());
104/// }
105/// ```
106///
107/// Combined with the entry verbs consuming the [`EventCtx`] (each invocation
108/// can mint exactly one token), the returned token is always *the* token of
109/// the current invocation, of the kind that actually happened.
110#[must_use = "return the Handled token from handle_persistent"]
111pub struct Handled<'inv> {
112    pub(crate) outcome: Outcome,
113    pub(crate) _invocation: PhantomData<&'inv ()>,
114}
115
116#[derive(Clone, Copy, Debug, PartialEq, Eq)]
117pub(crate) enum Outcome {
118    Skip,
119    Collect,
120    Commit,
121    Defer,
122}
123
124/// Per-event decision point handed to
125/// [`handle_persistent`](super::OutboxEventHandler::handle_persistent).
126///
127/// Generic over the handler's
128/// [`Batch`](super::OutboxEventHandler::Batch) accumulator `B` (defaulting
129/// to `()` for handlers that never collect). See the [module docs](self)
130/// for the semantics of the four entry verbs.
131#[must_use = "resolve the EventCtx via skip / collect / consume_in_batch / consume_isolated"]
132pub struct EventCtx<'inv, B = ()> {
133    pub(crate) parts: CtxParts<'inv>,
134    pub(crate) batch: &'inv mut B,
135    pub(crate) flusher: &'inv dyn ItemFlush<B>,
136}
137
138impl<'inv, B> EventCtx<'inv, B> {
139    /// This event is not for me — no transaction is opened, an open batch op
140    /// is left untouched, and the checkpoint advances lazily (piggybacked on
141    /// the next flush, or persisted on the configured checkpoint interval).
142    pub fn skip(self) -> Handled<'inv> {
143        Handled {
144            outcome: Outcome::Skip,
145            _invocation: PhantomData,
146        }
147    }
148
149    /// Contribute to the pending batch's accumulator — a pure memory write:
150    /// no transaction is opened and no statement is executed now. The runner
151    /// hands the accumulated batch to the handler's
152    /// [`flush`](super::OutboxEventHandler::flush) exactly once per batch
153    /// landing, inside the transaction that commits the checkpoint.
154    ///
155    /// Like [`defer`](BatchOp::defer), collected work shares fate with its
156    /// neighbors and must tolerate whole-batch replay: on a failed flush the
157    /// events replay and their items are re-collected.
158    ///
159    /// For `Vec` and `HashMap` accumulators the [`collect`](Self::collect)
160    /// sugar is usually more convenient.
161    pub fn collect_with(self, f: impl FnOnce(&mut B)) -> Handled<'inv> {
162        f(self.batch);
163        self.parts.tracker.events_in_op += 1;
164        self.parts.tracker.collected += 1;
165        Handled {
166            outcome: Outcome::Collect,
167            _invocation: PhantomData,
168        }
169    }
170
171    /// Do transactional work joined to the pending batch op (opening a fresh
172    /// one if none is pending).
173    ///
174    /// Work in a batch shares fate with its neighbors: a failure anywhere in
175    /// the batch rolls back and replays the whole batch from the last
176    /// committed checkpoint. Only choose this when the handler's work is
177    /// idempotent under such replay (pure in-op DB writes and in-op job
178    /// spawns are — they roll back with the op).
179    pub async fn consume_in_batch(self) -> Result<BatchOp<'inv>, HandlerError> {
180        let parts = self.parts;
181        if parts.op_slot.is_none() {
182            *parts.op_slot = Some(
183                es_entity::DbOp::init_with_clock(
184                    parts.current_job.pool(),
185                    parts.current_job.clock(),
186                )
187                .await?,
188            );
189        }
190        parts.tracker.events_in_op += 1;
191        Ok(BatchOp { parts })
192    }
193
194    /// Land the pending batch first (its collected items, its work and its
195    /// checkpoint, at the last fully handled sequence), then hand back a
196    /// fresh op: this event is its own atomic unit, sharing no fate with
197    /// history — and none with the future either, since [`IsolatedOp`] only
198    /// offers [`commit`](IsolatedOp::commit).
199    ///
200    /// This is the failure-isolation fence: if this event's work fails, only
201    /// this event replays; and it is the exact legacy per-event semantics
202    /// when no batch is pending.
203    pub async fn consume_isolated(self) -> Result<IsolatedOp<'inv>, HandlerError>
204    where
205        B: Default,
206    {
207        let EventCtx {
208            mut parts,
209            batch,
210            flusher,
211        } = self;
212        flush_batch(&mut parts, batch, flusher, "isolated_entry").await?;
213        *parts.op_slot = Some(
214            es_entity::DbOp::init_with_clock(parts.current_job.pool(), parts.current_job.clock())
215                .await?,
216        );
217        parts.tracker.events_in_op = 1;
218        Ok(IsolatedOp { parts })
219    }
220}
221
222impl<'inv, T> EventCtx<'inv, Vec<T>> {
223    /// [`collect_with`](Self::collect_with) sugar for `Vec` accumulators:
224    /// append one item to the pending batch.
225    pub fn collect(self, item: T) -> Handled<'inv> {
226        self.collect_with(|batch| batch.push(item))
227    }
228}
229
230impl<'inv, K, V, S> EventCtx<'inv, std::collections::HashMap<K, V, S>>
231where
232    K: std::hash::Hash + Eq,
233    S: std::hash::BuildHasher,
234{
235    /// [`collect_with`](Self::collect_with) sugar for `HashMap` accumulators:
236    /// keyed last-write-wins insert. Persistent events arrive in ascending
237    /// sequence, so within a batch this naturally keeps the newest item per
238    /// key — the coalescing fold (N updates per key → 1 flushed entry).
239    pub fn collect(self, key: K, value: V) -> Handled<'inv> {
240        self.collect_with(|batch| {
241            batch.insert(key, value);
242        })
243    }
244}
245
246/// An op joined to the pending batch. Implements
247/// [`AtomicOperation`](es_entity::AtomicOperation) — use it exactly like any
248/// atomic operation, then exit with [`commit`](Self::commit) or
249/// [`defer`](Self::defer).
250///
251/// There is no mutable access to the raw [`es_entity::DbOp`] (only a shared
252/// [`Deref`](std::ops::Deref) view): committing, rolling back, or swapping
253/// out the underlying op is unrepresentable, so work and checkpoint can only
254/// land together, through the runner.
255#[must_use = "exit with .commit() or .defer() to produce the Handled token"]
256pub struct BatchOp<'inv> {
257    parts: CtxParts<'inv>,
258}
259
260impl<'inv> BatchOp<'inv> {
261    fn op_mut(&mut self) -> &mut es_entity::DbOp<'static> {
262        self.parts
263            .op_slot
264            .as_mut()
265            .expect("BatchOp always holds a materialized op")
266    }
267
268    /// Land the whole batch (my work included) when the invocation returns:
269    /// checkpoint at my sequence → commit.
270    pub fn commit(self) -> Handled<'inv> {
271        Handled {
272            outcome: Outcome::Commit,
273            _invocation: PhantomData,
274        }
275    }
276
277    /// Leave the op open so subsequent events can coalesce into it. The
278    /// runner lands it when the ready persistent backlog is drained, when
279    /// the configured max batch size is reached, when a later event commits
280    /// or isolates, or on shutdown.
281    pub fn defer(self) -> Handled<'inv> {
282        Handled {
283            outcome: Outcome::Defer,
284            _invocation: PhantomData,
285        }
286    }
287}
288
289impl std::ops::Deref for BatchOp<'_> {
290    type Target = es_entity::DbOp<'static>;
291
292    fn deref(&self) -> &Self::Target {
293        self.parts
294            .op_slot
295            .as_ref()
296            .expect("BatchOp always holds a materialized op")
297    }
298}
299
300/// Full delegation to the inner [`es_entity::DbOp`] — including the provided
301/// methods, which [`es_entity::DbOp`] overrides (`supports_hooks` is `true`,
302/// `commit_hook` returns registered hooks, `maybe_now`/`clock` carry the op's
303/// cached time and clock). Inheriting the trait defaults instead would
304/// silently report `supports_hooks() == false` and lose the op time.
305///
306/// This lets handlers pass `&mut op` directly to any
307/// `fn(&mut impl AtomicOperation)` API (service `*_in_op` methods,
308/// `spawn_in_op`, `publish_persisted_in_op`, …) — and it is the *only*
309/// mutable surface: with no `DerefMut`, a `&mut es_entity::DbOp` can never be
310/// obtained from the guard (which would allow `std::mem::swap`-ing in a decoy
311/// op and committing the real one without its checkpoint).
312impl es_entity::AtomicOperation for BatchOp<'_> {
313    fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
314        (**self).maybe_now()
315    }
316
317    fn clock(&self) -> &es_entity::clock::ClockHandle {
318        es_entity::AtomicOperation::clock(&**self)
319    }
320
321    fn connection(&mut self) -> &mut es_entity::db::Connection {
322        self.op_mut().connection()
323    }
324
325    fn as_executor(&mut self) -> es_entity::OneTimeExecutor<'_, &mut es_entity::db::Connection> {
326        self.op_mut().as_executor()
327    }
328
329    fn add_commit_hook<H: es_entity::hooks::CommitHook>(&mut self, hook: H) -> Result<(), H> {
330        self.op_mut().add_commit_hook(hook)
331    }
332
333    fn commit_hook<H: es_entity::hooks::CommitHook>(&self) -> Option<&H> {
334        (**self).commit_hook::<H>()
335    }
336
337    fn supports_hooks(&self) -> bool {
338        (**self).supports_hooks()
339    }
340}
341
342/// An op holding exactly this event's work, fenced from the batch history.
343/// Implements [`AtomicOperation`](es_entity::AtomicOperation). The only exit
344/// is [`commit`](Self::commit) — isolation from future events is guaranteed
345/// by construction, and (as with [`BatchOp`]) no mutable access to the raw
346/// [`es_entity::DbOp`] exists, so the op can only land through the runner.
347#[must_use = "exit with .commit() to produce the Handled token"]
348pub struct IsolatedOp<'inv> {
349    parts: CtxParts<'inv>,
350}
351
352impl<'inv> IsolatedOp<'inv> {
353    fn op_mut(&mut self) -> &mut es_entity::DbOp<'static> {
354        self.parts
355            .op_slot
356            .as_mut()
357            .expect("IsolatedOp always holds a materialized op")
358    }
359
360    /// Land my work and my checkpoint, atomically, when the invocation
361    /// returns.
362    pub fn commit(self) -> Handled<'inv> {
363        Handled {
364            outcome: Outcome::Commit,
365            _invocation: PhantomData,
366        }
367    }
368}
369
370impl std::ops::Deref for IsolatedOp<'_> {
371    type Target = es_entity::DbOp<'static>;
372
373    fn deref(&self) -> &Self::Target {
374        self.parts
375            .op_slot
376            .as_ref()
377            .expect("IsolatedOp always holds a materialized op")
378    }
379}
380
381/// Full delegation to the inner [`es_entity::DbOp`] — see the notes on
382/// [`BatchOp`]'s impl for why every provided method is delegated too, and why
383/// this is deliberately the only mutable surface (no `DerefMut`).
384impl es_entity::AtomicOperation for IsolatedOp<'_> {
385    fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
386        (**self).maybe_now()
387    }
388
389    fn clock(&self) -> &es_entity::clock::ClockHandle {
390        es_entity::AtomicOperation::clock(&**self)
391    }
392
393    fn connection(&mut self) -> &mut es_entity::db::Connection {
394        self.op_mut().connection()
395    }
396
397    fn as_executor(&mut self) -> es_entity::OneTimeExecutor<'_, &mut es_entity::db::Connection> {
398        self.op_mut().as_executor()
399    }
400
401    fn add_commit_hook<H: es_entity::hooks::CommitHook>(&mut self, hook: H) -> Result<(), H> {
402        self.op_mut().add_commit_hook(hook)
403    }
404
405    fn commit_hook<H: es_entity::hooks::CommitHook>(&self) -> Option<&H> {
406        (**self).commit_hook::<H>()
407    }
408
409    fn supports_hooks(&self) -> bool {
410        (**self).supports_hooks()
411    }
412}
413
414pub(crate) type BoxFuture<'a, T> =
415    std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
416
417/// Object-safe bridge from the runner (and [`EventCtx::consume_isolated`]'s
418/// entry fence) to the handler's typed
419/// [`flush`](super::OutboxEventHandler::flush) — erases the handler type so
420/// [`EventCtx`] only needs to know the accumulator `B`.
421pub(crate) trait ItemFlush<B>: Send + Sync {
422    fn flush_items<'a>(
423        &'a self,
424        op: &'a mut es_entity::DbOp<'static>,
425        items: B,
426    ) -> BoxFuture<'a, Result<(), HandlerError>>;
427}
428
429/// Restricted view of the batch op handed to
430/// [`flush`](super::OutboxEventHandler::flush) — everything an
431/// [`AtomicOperation`](es_entity::AtomicOperation) can do, and nothing else.
432///
433/// Committing belongs to the runner: after `flush` returns `Ok`, the
434/// checkpoint is written and the transaction commits — items, work and
435/// pointer land atomically. There is no access to the raw
436/// [`es_entity::DbOp`], mirroring [`BatchOp`]/[`IsolatedOp`]'s sealing.
437pub struct FlushOp<'a>(&'a mut es_entity::DbOp<'static>);
438
439impl<'a> FlushOp<'a> {
440    pub(crate) fn new(op: &'a mut es_entity::DbOp<'static>) -> Self {
441        Self(op)
442    }
443}
444
445/// Full delegation to the inner [`es_entity::DbOp`] — see the notes on
446/// [`BatchOp`]'s impl for why every provided method is delegated too.
447/// `add_commit_hook` delegation is what lets a flush body publish onto the
448/// batch op (e.g. `publish_all_persisted`) with the events buffered on the
449/// runner's commit.
450impl es_entity::AtomicOperation for FlushOp<'_> {
451    fn maybe_now(&self) -> Option<chrono::DateTime<chrono::Utc>> {
452        self.0.maybe_now()
453    }
454
455    fn clock(&self) -> &es_entity::clock::ClockHandle {
456        es_entity::AtomicOperation::clock(self.0)
457    }
458
459    fn connection(&mut self) -> &mut es_entity::db::Connection {
460        self.0.connection()
461    }
462
463    fn as_executor(&mut self) -> es_entity::OneTimeExecutor<'_, &mut es_entity::db::Connection> {
464        self.0.as_executor()
465    }
466
467    fn add_commit_hook<H: es_entity::hooks::CommitHook>(&mut self, hook: H) -> Result<(), H> {
468        self.0.add_commit_hook(hook)
469    }
470
471    fn commit_hook<H: es_entity::hooks::CommitHook>(&self) -> Option<&H> {
472        self.0.commit_hook::<H>()
473    }
474
475    fn supports_hooks(&self) -> bool {
476        self.0.supports_hooks()
477    }
478}
479
480/// A batch flush failed. Carries the sequence range actually at fault, so
481/// the failure is not misattributed to the (innocent) event whose verb
482/// happened to trigger the landing — e.g. a later event entering
483/// [`consume_isolated`](EventCtx::consume_isolated).
484///
485/// Propagates through `handle_persistent` as a boxed error; downcast to
486/// re-attribute in logs or traces.
487#[derive(Debug)]
488pub struct FlushError {
489    /// Which trigger landed the batch (`"backlog_drained"`, `"batch_full"`,
490    /// `"commit"`, `"isolated_entry"`, `"shutdown"`, `"stream_closed"`).
491    pub reason: &'static str,
492    /// The batch covers sequences strictly after this (the last durable
493    /// checkpoint)…
494    pub after: EventSequence,
495    /// …through this (the last fully handled event).
496    pub through: EventSequence,
497    pub source: HandlerError,
498}
499
500impl std::fmt::Display for FlushError {
501    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
502        write!(
503            f,
504            "flush of batch ({}, {}] failed (reason={}): {}",
505            self.after, self.through, self.reason, self.source
506        )
507    }
508}
509
510impl std::error::Error for FlushError {
511    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
512        Some(self.source.as_ref())
513    }
514}
515
516/// Land the pending batch, if any: hand collected items to the handler's
517/// flush (inside the batch transaction, opening one if only items are
518/// pending) → checkpoint at the last fully handled sequence → commit. No-op
519/// when nothing is pending.
520#[tracing::instrument(
521    name = "outbox.flush_batch",
522    skip_all,
523    fields(
524        reason = reason,
525        batch_size = parts.tracker.events_in_op,
526        collected = parts.tracker.collected,
527        checkpoint_seq = u64::from(parts.state.sequence),
528    ),
529    err
530)]
531pub(crate) async fn flush_batch<B: Default>(
532    parts: &mut CtxParts<'_>,
533    batch: &mut B,
534    flusher: &dyn ItemFlush<B>,
535    reason: &'static str,
536) -> Result<(), HandlerError> {
537    if parts.op_slot.is_none() && parts.tracker.collected == 0 {
538        return Ok(());
539    }
540    if parts.tracker.collected > 0 {
541        if parts.op_slot.is_none() {
542            *parts.op_slot = Some(
543                es_entity::DbOp::init_with_clock(
544                    parts.current_job.pool(),
545                    parts.current_job.clock(),
546                )
547                .await?,
548            );
549        }
550        // Drain before the call: on error the items are dropped with the op,
551        // and the replayed events re-collect them — the accumulator never
552        // leaks stale state into a retry.
553        let items = std::mem::take(batch);
554        parts.tracker.collected = 0;
555        let op = parts.op_slot.as_mut().expect("op was materialized above");
556        if let Err(source) = flusher.flush_items(op, items).await {
557            return Err(Box::new(FlushError {
558                reason,
559                after: parts.tracker.persisted_seq,
560                through: parts.state.sequence,
561                source,
562            }));
563        }
564    }
565    let mut op = parts
566        .op_slot
567        .take()
568        .expect("a pending batch always has an op by now");
569    parts
570        .current_job
571        .update_execution_state_in_op(&mut op, parts.state)
572        .await?;
573    op.commit().await?;
574    parts.tracker.persisted_seq = parts.state.sequence;
575    parts.tracker.last_persist = tokio::time::Instant::now();
576    parts.tracker.events_in_op = 0;
577    Ok(())
578}
579
580/// Persist the checkpoint on its own — used for skip-only stretches where no
581/// work op ever materialized, bounded by the configured checkpoint interval.
582#[tracing::instrument(
583    name = "outbox.persist_checkpoint",
584    skip_all,
585    fields(checkpoint_seq = u64::from(state.sequence)),
586    err
587)]
588pub(crate) async fn persist_checkpoint(
589    current_job: &mut CurrentJob,
590    state: &OutboxEventJobState,
591) -> Result<(), HandlerError> {
592    let mut op = es_entity::DbOp::init_with_clock(current_job.pool(), current_job.clock()).await?;
593    current_job
594        .update_execution_state_in_op(&mut op, state)
595        .await?;
596    op.commit().await?;
597    Ok(())
598}