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 {}