agentplane/runtime/batch.rs
1//! Driving a batch: reserve, run, record, repeat.
2//!
3//! The loop is deliberately boring, because the interesting decisions are all in
4//! [`crate::batch`]'s types. What this file has to get right is the *ordering* —
5//! an item's run id becomes durable before the run performs anything — and the
6//! refusal to stop at the first failure.
7
8use std::sync::Arc;
9
10use crate::batch::{BatchCensus, BatchItem, BatchReport, BatchStatus, ItemOutcome, ItemSource};
11use crate::core::{BatchId, PlanIR, RunId, RuntimeError, Spend};
12
13use super::executor::{RunOutcome, RunStatus, Runtime};
14use super::{Mode, metrics};
15
16/// How a batch is to be run.
17#[derive(Debug)]
18pub struct BatchSpec {
19 /// The plan every item runs. Frozen once, for the whole batch: a batch whose
20 /// plan could change between items would be several acts wearing one name,
21 /// and its audit record would say nothing about what actually happened to
22 /// item 60,000.
23 pub plan: PlanIR,
24 pub source: Arc<dyn ItemSource>,
25 /// Items fetched per call to the source.
26 ///
27 /// A paging hint, not a limit on the batch. See [`BatchSpec::page`].
28 pub page: usize,
29 /// Stop after this many items reach a terminal outcome, if set.
30 ///
31 /// For operating a very large batch in windows — "settle ten thousand and
32 /// let me look". The batch stays `Running`; the next call resumes.
33 pub max_items: Option<u64>,
34}
35
36impl BatchSpec {
37 pub fn new(plan: PlanIR, source: Arc<dyn ItemSource>) -> Self {
38 Self {
39 plan,
40 source,
41 page: 256,
42 max_items: None,
43 }
44 }
45
46 /// How many items to fetch from the source at a time.
47 #[must_use]
48 pub const fn page(mut self, n: usize) -> Self {
49 self.page = n;
50 self
51 }
52
53 /// Process at most this many items before returning.
54 #[must_use]
55 pub const fn max_items(mut self, n: u64) -> Self {
56 self.max_items = Some(n);
57 self
58 }
59}
60
61impl Runtime {
62 /// Start a batch, or continue one.
63 ///
64 /// Passing an existing [`BatchId`] resumes it: processing picks up after the
65 /// stored cursor, and any item that was reserved but never finished is
66 /// replayed under its original run id rather than started again.
67 ///
68 /// Returns when the source is exhausted or `max_items` is reached. **A
69 /// failing item does not stop the batch** — that is the failure isolation a
70 /// batch exists for, and a caller who wants the opposite should be running
71 /// one run, not many.
72 ///
73 /// What does stop it is an admission that never happened: a halt, a
74 /// tenant ceiling, a store outage. Those hold for every item behind the
75 /// one they hit, and they are returned as the error rather than written
76 /// over the item, so the item is still pending when the next pass runs.
77 pub async fn run_batch(
78 &self,
79 id: BatchId,
80 spec: &BatchSpec,
81 ) -> Result<BatchReport, RuntimeError> {
82 let store = self.batches().ok_or_else(|| {
83 RuntimeError::PlanContract(
84 "batches need a batch store — build the runtime with `.batches(store)`".into(),
85 )
86 })?;
87
88 crate::plan::validate(&spec.plan, &self.contract())
89 .map_err(|e| RuntimeError::PlanContract(e.to_string()))?;
90 store
91 .open(id, &spec.plan.digest().to_hex())
92 .await
93 .map_err(RuntimeError::from_store)?;
94
95 // Resume from where a previous pass got to. If this is lost the batch is
96 // slower, not wrong: every item's reservation makes re-processing a
97 // replay. See the module docs in `crate::batch`.
98 let mut after = store.cursor(id).await.map_err(RuntimeError::from_store)?;
99 let mut processed = 0_u64;
100
101 loop {
102 if spec.max_items.is_some_and(|m| processed >= m) {
103 break;
104 }
105 let page = store_page(spec, after.as_deref()).await?;
106 if page.is_empty() {
107 // The source is done. Recorded durably: it is the difference
108 // between a finished batch and one that stopped early with
109 // every stored item terminal.
110 store
111 .mark_exhausted(id)
112 .await
113 .map_err(RuntimeError::from_store)?;
114 break;
115 }
116
117 for item in page {
118 after = Some(item.key.clone());
119 let outcome = self.run_item(id, spec, &item).await?;
120 if outcome.is_terminal() {
121 processed += 1;
122 }
123 if spec.max_items.is_some_and(|m| processed >= m) {
124 break;
125 }
126 }
127 }
128
129 self.batch_report(id).await
130 }
131
132 /// Reserve, run, record — one item.
133 ///
134 /// The order is the effect protocol: the run id is durable before anything
135 /// is performed, so an interrupted item is findable and replayable rather
136 /// than a question nobody can answer.
137 async fn run_item(
138 &self,
139 id: BatchId,
140 spec: &BatchSpec,
141 item: &BatchItem,
142 ) -> Result<ItemOutcome, RuntimeError> {
143 let store = self
144 .batches()
145 .ok_or_else(|| RuntimeError::PlanContract("no batch store".into()))?;
146
147 let reserved = store
148 .reserve(id, &item.key, RunId::generate())
149 .await
150 .map_err(RuntimeError::from_store)?;
151
152 // Already terminal from an earlier pass: nothing to do, and re-running
153 // would be the duplicate work the reservation exists to prevent.
154 if let Some(done) = reserved.outcome.as_ref().filter(|o| o.is_terminal()) {
155 return Ok(done.clone());
156 }
157
158 // A reservation with no terminal outcome is either a fresh item or one
159 // interrupted mid-flight. Both are addressed by the same call: `replay`
160 // resumes an existing journal, and a run that never wrote one is
161 // admitted here for the first time.
162 let outcome = if reserved.outcome.is_some() || self.has_journal(reserved.run).await? {
163 self.replay(reserved.run, Mode::Resume).await
164 } else {
165 self.admit_plan_as(
166 reserved.run,
167 spec.plan.clone(),
168 // Untrusted from `batch:<id>` unless the item says otherwise:
169 // a source is somebody else's data far more often than it is
170 // the operator's own.
171 crate::core::Tainted::with_label(item.input.clone(), item.admission_label(id)),
172 // A batch item's at-most-once identity is its reservation, not
173 // an admission key: the run id is written to the batch store
174 // before the run starts, so a retry replays that journal rather
175 // than admitting again. Two mechanisms for one invariant would
176 // be two places to disagree.
177 super::executor::RunTerms::default(),
178 // A batch pass is started from outside this process, so its
179 // items stop when the plane stops admitting. The refusal is
180 // not recorded, the reservation keeps no outcome, and the next
181 // instance's pass re-admits exactly these items.
182 super::executor::Entry::Outside,
183 )
184 .await
185 };
186
187 // Only a run that reached an outcome is an item outcome. An error
188 // here is the plane refusing to *admit* — a halt, a ceiling, an
189 // instance shutting down, a store that could not be reached — and it
190 // holds for the next item as much
191 // as this one, so the pass stops with it. Recording it would write a
192 // terminal `Failed` over an item nothing ever ran: the halt lifts, the
193 // ceiling frees, and the item stays failed forever. The reservation
194 // is left without an outcome, which is exactly the state the next
195 // pass reads as *reserved, never finished* and admits again.
196 let out = outcome?;
197 let (result, spend) = classify_item(out);
198 store
199 .record(id, &item.key, &result, spend)
200 .await
201 .map_err(RuntimeError::from_store)?;
202
203 self.meter().count(metrics::BATCH_ITEMS, result.as_str());
204 Ok(result)
205 }
206
207 /// Whether a run has already written anything.
208 async fn has_journal(&self, run: RunId) -> Result<bool, RuntimeError> {
209 match self.journal().head(run).await {
210 Ok(head) => Ok(head.seq > 0),
211 Err(e) => Err(RuntimeError::from_store(e)),
212 }
213 }
214
215 /// The batch's current tally, without processing anything.
216 pub async fn batch_report(&self, id: BatchId) -> Result<BatchReport, RuntimeError> {
217 let store = self
218 .batches()
219 .ok_or_else(|| RuntimeError::PlanContract("batches need a batch store".into()))?;
220 // Existence first, from the batch's own row. A census cannot answer
221 // it — no such batch and a batch with no items yet both count zero
222 // rows — and answering a mistyped id with an empty `Running` report
223 // sends an operator watching a batch that will never exist.
224 if store
225 .plan_digest(id)
226 .await
227 .map_err(RuntimeError::from_store)?
228 .is_none()
229 {
230 return Err(RuntimeError::from_store(crate::core::StoreError::NotFound(
231 format!("batch {id}"),
232 )));
233 }
234 let census = store.census(id).await.map_err(RuntimeError::from_store)?;
235 let cursor = store.cursor(id).await.map_err(RuntimeError::from_store)?;
236 let exhausted = store
237 .is_exhausted(id)
238 .await
239 .map_err(RuntimeError::from_store)?;
240 Ok(BatchReport {
241 id,
242 status: status_of(&census, exhausted),
243 in_flight: census.in_flight + census.suspended,
244 exhausted: census.exhausted,
245 withheld: census.withheld,
246 spend: census.spend,
247 cursor,
248 })
249 }
250}
251
252/// A batch is complete only when nothing is reserved and nothing is waiting.
253///
254/// An exhausted item is waiting too — paused at a ceiling, resumable the
255/// moment somebody raises it — and it holds the cursor for exactly that
256/// reason. Reporting `Completed` over one would let `everything_settled()`
257/// answer yes while an item's meters are unsettled, which is the "mostly
258/// worked read as worked" this type exists to prevent.
259///
260/// Note there is no path to a status that says "succeeded": see `crate::batch`.
261fn status_of(c: &BatchCensus, source_exhausted: bool) -> BatchStatus {
262 if !source_exhausted || c.in_flight > 0 || c.suspended > 0 || c.exhausted > 0 || c.withheld > 0
263 {
264 return BatchStatus::Running;
265 }
266 BatchStatus::Completed {
267 succeeded: c.succeeded,
268 failed: c.failed,
269 quarantined: c.quarantined,
270 }
271}
272
273/// Turn a run's ending into an item's.
274///
275/// A store or contract error is *the item's* failure, not the batch's: one
276/// malformed item must not take down a settlement run for the other 99,999. The
277/// detail goes on the item record, and the item's own journal holds the rest.
278fn classify_item(out: RunOutcome) -> (ItemOutcome, Spend) {
279 let spend = out.spend();
280 let item = match out.status {
281 RunStatus::Succeeded => ItemOutcome::Succeeded,
282 RunStatus::Suspended(r) => ItemOutcome::Suspended(r.to_string()),
283 RunStatus::Quarantined(why) => ItemOutcome::Quarantined(why),
284 // Also `Quarantined`, because this enum is deliberately coarse about
285 // *what a human must do next* and the answer here is the same one:
286 // hands off. An abandoned run may hold a mutation nobody could account
287 // for, so filing it under `Failed` — the outcome this batch's readers
288 // re-run — is the one mapping that could take money twice. The reason
289 // names who gave up and why, which is what tells the two apart.
290 RunStatus::Abandoned { actor, reason } => {
291 ItemOutcome::Quarantined(format!("abandoned by '{actor}': {reason}"))
292 }
293 // `Replanning` is never observed by a caller — the executor resolves
294 // it internally — but if one ever escaped it is an item that did not
295 // settle, which is what `Failed` means here.
296 RunStatus::Failed(why) | RunStatus::Replanning(why) => ItemOutcome::Failed(why),
297 // A pause, not a fault — mirrored from the run's own semantics: the
298 // work stands, and raising the ceiling resumes it. Filing it under
299 // `Failed` taught operators to re-run items whose work was intact.
300 RunStatus::Exhausted(limit) => ItemOutcome::Exhausted(limit.to_string()),
301 // The same shape, from the other cause: the item's work stands and the
302 // remedy is lifting the halt on the subject, not raising a budget — so
303 // it is its own outcome rather than a reason string under `Exhausted`.
304 RunStatus::Withheld { subject, reason } => {
305 ItemOutcome::Withheld(format!("authority '{subject}' withdrawn: {reason}"))
306 }
307 // Unreachable by construction — a batch item is admitted as an ordinary
308 // run and none of these is ever admitted — so reaching it means the
309 // reservation points at a run this batch did not start. `Quarantined`
310 // for the same reason abandonment is: the answer to *what does a person
311 // do next* is hands off, and filing it under `Failed` would hand it to
312 // the readers who re-run.
313 RunStatus::Swept
314 | RunStatus::BrokeGlass { .. }
315 | RunStatus::HaltLifted { .. }
316 | RunStatus::HoldReleased { .. }
317 | RunStatus::Observed => ItemOutcome::Quarantined(
318 "this item's run id resolves to one of the plane's own records, or to \
319 a session it only observed, rather than to the item's run — the \
320 reservation and the journal disagree"
321 .to_owned(),
322 ),
323 // An item somebody stopped did not settle, and the batch must not
324 // report otherwise — but the reason names the person, so a partial
325 // batch can be told apart from one that hit a wall.
326 RunStatus::Cancelled { actor, reason } => {
327 ItemOutcome::Failed(format!("cancelled by '{actor}': {reason}"))
328 }
329 };
330 (item, spend)
331}
332
333/// Fetch the next page, mapping a source failure onto the runtime's errors.
334async fn store_page(spec: &BatchSpec, after: Option<&str>) -> Result<Vec<BatchItem>, RuntimeError> {
335 let page = spec
336 .source
337 .next(after, spec.page)
338 .await
339 // Surfaced, never swallowed into an empty page: a source failure that
340 // read as "no more items" would truncate a settlement run silently,
341 // which is the exact failure this crate exists to make loud.
342 .map_err(|e| RuntimeError::PlanContract(e.to_string()))?;
343 in_cursor_order(after, &page)?;
344 Ok(page)
345}
346
347/// Refuse a page whose keys do not strictly increase in byte order past
348/// `after`.
349///
350/// The resume cursor is the greatest key of the finished prefix, compared as
351/// bytes. A source that pages in any other order — a locale collation, a
352/// numeric sort of `"9"` before `"10"`, a repeated key — resumes from a cursor
353/// that skips items it never ran or re-offers ones it did, and the batch
354/// reports complete either way.
355fn in_cursor_order(after: Option<&str>, page: &[BatchItem]) -> Result<(), RuntimeError> {
356 let mut previous = after;
357 for item in page {
358 if let Some(p) = previous
359 && item.key.as_bytes() <= p.as_bytes()
360 {
361 return Err(RuntimeError::PlanContract(format!(
362 "batch source returned key '{}' after '{p}': keys must strictly \
363 increase in byte order, which is the order the resume cursor \
364 compares — a source paging in any other order skips or repeats \
365 items on resume",
366 item.key
367 )));
368 }
369 previous = Some(item.key.as_str());
370 }
371 Ok(())
372}