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}