Skip to main content

es_entity/operation/batch/
mod.rs

1//! Running a batch of items in one transaction while isolating each failure.
2//!
3//! Two shapes, both built on [`SavepointOperation::with_savepoint`] and both
4//! available on every [`AtomicOperation`](super::AtomicOperation):
5//!
6//! - [`run_isolated`](BatchIsolation::run_isolated) — one savepoint per item.
7//!   Use it when each item needs its own logic and its own error isolation.
8//! - [`run_bisected`](BatchIsolation::run_bisected) — one probe over the whole
9//!   slice, splitting only on failure. Use it when the closure handles a whole
10//!   slice in set-based statements (`create_all` / `update_all` and friends),
11//!   so the happy path costs one probe.
12//!
13//! # The closure
14//!
15//! `AsyncFnOnce + Clone + Sync`, cloned once per probe so that each probe calls
16//! its own clone exactly once.
17//!
18//! Each such call has a single opaque future type, which auto-trait inference
19//! resolves at the call site. A closure that borrows `&self` therefore composes
20//! inside an `#[async_trait]` runner or a `tokio::spawn`.
21//!
22//! Cloning the closure clones its captures, so state shared *across* probes
23//! belongs behind something whose clone is the same underlying value: `Arc<…>`,
24//! or a borrowed `&Mutex<…>`. `Sync` holds callers to that — `Cell`, `RefCell`
25//! and `Rc` are not `Sync`, so the bound rejects them.
26//!
27//! # What `f` must tolerate
28//!
29//! A bisect calls `f` repeatedly over **arbitrary contiguous sub-slices, in
30//! non-positional order**, and may re-probe the same range after a transient
31//! failure. `f` must therefore be a function of the set it is handed, and of
32//! the database state at that moment: only `*_in_op` work against the probe's
33//! operation, so that a rollback undoes all of it.
34//!
35//! # Hooks
36//!
37//! Commit hooks registered inside a probe are staged on its savepoint: folded
38//! outward on success, dropped on failure. No hook callback runs at a savepoint
39//! boundary, so a rolled-back probe contributes exactly zero hook state to
40//! match its zero database state. See [`SavepointOp`].
41//!
42//! # Observability
43//!
44//! Tracing is left to the caller, since es-entity's `tracing` dependency is
45//! optional. [`BisectOutcomes`] carries `probes_used` and `transient_retries`
46//! for reporting under the caller's own target and field names.
47
48mod search;
49mod transient;
50
51use std::future::Future;
52
53use super::{SavepointOp, SavepointOperation};
54
55pub use search::*;
56pub use transient::*;
57
58/// Batch isolation for every [`AtomicOperation`](super::AtomicOperation).
59///
60/// Blanket-implemented, like [`SavepointOperation`], so `DbOp`, `SavepointOp`
61/// (nesting), `HookOperation`, and operation types defined outside this crate
62/// all get it without naming a concrete type — and so a function generic over
63/// `impl AtomicOperation` can use it.
64pub trait BatchIsolation: SavepointOperation {
65    /// Runs `f` once per item, each inside its own `SAVEPOINT`, in item order.
66    ///
67    /// A failing item unwinds only its own writes and staged hooks; the
68    /// transaction stays usable and the loop continues, so its healthy
69    /// batch-mates still commit. Outcomes are returned positionally: one entry
70    /// per input, `f`'s own `Ok`/`Err` preserved.
71    ///
72    /// The outer `Err(sqlx::Error)` means the savepoint machinery itself
73    /// failed, leaving the enclosing transaction in an indeterminate state:
74    /// abandon it.
75    fn run_isolated<'a, T, V, E, F>(
76        &'a mut self,
77        items: &'a [T],
78        f: F,
79    ) -> impl Future<Output = Result<Vec<Result<V, E>>, sqlx::Error>> + 'a
80    where
81        T: 'a,
82        V: 'a,
83        E: 'a,
84        F: AsyncFnOnce(&mut SavepointOp<'_>, &T) -> Result<V, E> + Clone + Sync + 'a,
85    {
86        async move {
87            let mut outcomes = Vec::with_capacity(items.len());
88            for item in items {
89                let f = f.clone();
90                outcomes.push(self.with_savepoint(async |sp| f(sp, item).await).await?);
91            }
92            Ok(outcomes)
93        }
94    }
95
96    /// Probes the whole slice at once, bisecting only on failure.
97    ///
98    /// The happy path costs **one** probe. On failure the slice splits and
99    /// pending ranges are probed largest-first (earliest start breaking ties)
100    /// until `budget` is spent, so clean siblings are salvaged and a culprit
101    /// resolves from its own single-item probe, where its error is
102    /// attributable to it.
103    ///
104    /// Deadlock victims and serialization failures re-probe the same range
105    /// unsplit and are refunded to the budget — see [`TransientPolicy`]. Use
106    /// [`run_bisected_with`](Self::run_bisected_with) to widen that class.
107    fn run_bisected<'a, T, E, F>(
108        &'a mut self,
109        items: &'a [T],
110        budget: BisectBudget,
111        f: F,
112    ) -> impl Future<Output = Result<BisectOutcomes<E>, sqlx::Error>> + 'a
113    where
114        T: 'a,
115        E: std::error::Error + 'static,
116        F: AsyncFnOnce(&mut SavepointOp<'_>, &[T]) -> Result<(), E> + Clone + Sync + 'a,
117    {
118        self.run_bisected_with(
119            items,
120            budget,
121            TransientPolicy::new(sqlstate_is_transient::<E> as fn(&E) -> bool),
122            f,
123        )
124    }
125
126    /// [`run_bisected`](Self::run_bisected) with a caller-supplied notion of
127    /// which failures are transient.
128    ///
129    /// Classification being the caller's, the error bound here is just
130    /// [`Display`](std::fmt::Display).
131    fn run_bisected_with<'a, T, E, F, P>(
132        &'a mut self,
133        items: &'a [T],
134        budget: BisectBudget,
135        policy: TransientPolicy<P>,
136        f: F,
137    ) -> impl Future<Output = Result<BisectOutcomes<E>, sqlx::Error>> + 'a
138    where
139        T: 'a,
140        E: std::fmt::Display + 'a,
141        P: Fn(&E) -> bool + 'a,
142        F: AsyncFnOnce(&mut SavepointOp<'_>, &[T]) -> Result<(), E> + Clone + Sync + 'a,
143    {
144        async move {
145            let mut search = BisectSearch::new(items.len(), budget)
146                .with_max_transient_retries(policy.max_retries);
147
148            while let Some(range) = search.next_range() {
149                let f = f.clone();
150                let slice = &items[range.clone()];
151
152                let verdict = match self.with_savepoint(async |sp| f(sp, slice).await).await? {
153                    Ok(()) => ProbeVerdict::Clean,
154                    Err(error) if (policy.is_transient)(&error) => ProbeVerdict::Transient(error),
155                    Err(error) => ProbeVerdict::Failed(error),
156                };
157
158                if let Err(limit) = search.report(range, verdict) {
159                    // The search learned nothing about the items, so there are
160                    // no per-item verdicts to return — the caller re-runs the
161                    // whole batch.
162                    return Err(sqlx::Error::Protocol(match search.last_error() {
163                        Some(error) => format!("{limit}; last error: {error}"),
164                        None => limit.to_string(),
165                    }));
166                }
167            }
168
169            Ok(search.into_outcomes())
170        }
171    }
172}
173
174impl<T: SavepointOperation + ?Sized> BatchIsolation for T {}