agentplane/batch/mod.rs
1//! Batch runs — one plan, many items, per-item durability.
2//!
3//! # Why a batch is not a run, and not N unrelated runs
4//!
5//! A Jahresabrechnung over 10⁵ `Marktlokationen`, or a `MaBiS` `Clearingliste` across
6//! a Bilanzierungsgebiet, is a single business act made of many independent
7//! ones. Modelling it as *one run* means one journal, one budget, and one
8//! failure: item 60,000 fails and the run fails, so the 59,999 settlements that
9//! worked are trapped inside a failed audit record. Modelling it as *N unrelated
10//! runs* loses the act — nobody can answer "did the Jahresabrechnung finish", or
11//! "what did it cost", because there is no it.
12//!
13//! So a batch is a first-class object that owns N runs sharing one frozen plan.
14//! Each item gets its own journal, its own budget, and its own outcome. The
15//! batch gets a cursor, a census of what happened, and a terminal state.
16//!
17//! # Partial failure is a terminal state, not a degraded success
18//!
19//! [`BatchStatus`] has no `Succeeded`. A finished batch is
20//! [`BatchStatus::Completed`] carrying counts, so a caller cannot write
21//! `if status.is_ok()` and skip the 43 items that did not settle. "Mostly
22//! worked" is the single most dangerous thing a batch can report, because it is
23//! reported as success everywhere it is not explicitly handled — and the items
24//! that failed are precisely the ones a human needed to hear about.
25//!
26//! Reading the outcome forces a decision:
27//!
28//! ```text
29//! match report.status {
30//! BatchStatus::Completed { failed: 0, quarantined: 0, succeeded } => ok(succeeded),
31//! BatchStatus::Completed { failed, quarantined, .. } => escalate(failed, quarantined),
32//! BatchStatus::Running => still_going(),
33//! }
34//! ```
35//!
36//! # Resume is the effect protocol, one level up
37//!
38//! An item is processed exactly the way an effect is performed: **announce, act,
39//! record.** Before an item runs, its run id is written to the batch store
40//! ([`BatchStore::reserve`]); then the run happens; then the outcome is recorded.
41//!
42//! That ordering is what makes item-granular resume work, and it is worth being
43//! precise about why. A crash between reserve and record leaves an item marked
44//! started with a known run id. Resume finds it, and **replays that run** rather
45//! than starting a new one — so the item's effects are read back from its
46//! journal instead of performed again. Exactly-once for a batch item is not new
47//! machinery; it is the run-level guarantee, addressed by a stored id.
48//!
49//! The cursor is therefore an *optimisation*, not a correctness mechanism. If it
50//! were lost entirely, re-processing every item would be safe — slow, but not
51//! wrong. That is the property to preserve when changing anything here.
52//!
53//! # Input is streamed, never materialised
54//!
55//! [`ItemSource`] is a cursor, not a `Vec`. A batch over 10⁵ meters must not
56//! require 10⁵ items in memory, and — more subtly — must not require the caller
57//! to have *produced* them all before the first one runs. A source that reads
58//! from a database, a file, or a paged API is the normal case.
59
60use std::fmt::Debug;
61
62use async_trait::async_trait;
63use serde::{Deserialize, Serialize};
64use serde_json::Value;
65
66use crate::core::{BatchId, Label, RunId, Spend, StoreError};
67
68/// One unit of work in a batch.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct BatchItem {
71 /// Stable, unique within the batch, and **ordered**.
72 ///
73 /// Ordering is what lets the cursor be a single value rather than a set of
74 /// seen keys, which for 10⁵ items is the difference between a resume that
75 /// reads one row and one that reads the whole batch. A meter number, a
76 /// document id, or a zero-padded sequence all work; a random uuid does not,
77 /// because "everything after this key" then means nothing.
78 pub key: String,
79 /// What the plan receives as run input for this item.
80 pub input: Value,
81 /// The input's label, when the source vouches for one.
82 ///
83 /// `None` — the default — admits the item **untrusted**, from the source
84 /// `batch:<batch id>`. A source reading a paged API, a file or somebody
85 /// else's database is the normal case, and an item it produced is data
86 /// from outside the plane: trusted input would reach every mutating sink
87 /// and every protected field with no release on the record.
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub label: Option<Label>,
90}
91
92impl BatchItem {
93 /// An item admitted untrusted, from the source `batch:<batch id>`.
94 pub fn new(key: impl Into<String>, input: Value) -> Self {
95 Self {
96 key: key.into(),
97 input,
98 label: None,
99 }
100 }
101
102 /// Admit this item under `label` instead.
103 ///
104 /// The operator's statement about where the source's data came from,
105 /// journaled as the run's input label. `Label::trusted()` is the one to
106 /// think about: it says the plane's own operator produced this input, and
107 /// every taint gate downstream takes that at its word.
108 #[must_use]
109 pub fn labelled(mut self, label: Label) -> Self {
110 self.label = Some(label);
111 self
112 }
113
114 /// The label this item's run is admitted under, in batch `batch`.
115 #[must_use]
116 pub fn admission_label(&self, batch: BatchId) -> Label {
117 self.label.clone().unwrap_or_else(|| {
118 Label::untrusted(crate::core::SourceId::new(format!("batch:{batch}")))
119 })
120 }
121}
122
123/// A source could not produce items.
124///
125/// Distinct from `StoreError` because an [`ItemSource`] is the embedder's code
126/// reading the embedder's system — a meter register, a paged API, a file. Making
127/// it a store error would say the *plane's* storage failed, sending an operator
128/// to look at the wrong thing.
129#[derive(Debug, Clone, thiserror::Error)]
130#[error("batch source: {0}")]
131pub struct SourceError(String);
132
133impl SourceError {
134 pub fn new(detail: impl Into<String>) -> Self {
135 Self(detail.into())
136 }
137}
138
139/// Where a batch's work comes from.
140///
141/// A cursor rather than a collection — see the module docs on why input is never
142/// materialised.
143#[async_trait]
144pub trait ItemSource: Send + Sync + Debug {
145 /// The next page of items strictly after `after`, in strictly increasing
146 /// byte order of [`BatchItem::key`].
147 ///
148 /// Byte order, not a collation or a numeric sort: the resume cursor is the
149 /// greatest finished key compared as bytes, so a page in any other order
150 /// resumes past items that never ran. The driver refuses a page whose keys
151 /// do not strictly increase past `after`. Zero-pad numeric keys.
152 ///
153 /// Returning fewer than `limit` items does **not** mean the source is
154 /// exhausted; returning zero does. The distinction matters for sources that
155 /// page unevenly, and conflating them truncates a batch silently — which is
156 /// the failure mode this whole crate exists to make loud.
157 async fn next(&self, after: Option<&str>, limit: usize) -> Result<Vec<BatchItem>, SourceError>;
158}
159
160/// How one item ended.
161///
162/// Mirrors `RunStatus` but deliberately coarser: a batch's census is about what
163/// a human must do next, and "failed" and "exhausted" both mean *this item did
164/// not settle*. The item's own run journal holds the detail.
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum ItemOutcome {
168 Succeeded,
169 /// Ran and did not settle — a failure, or a limit.
170 Failed(String),
171 /// Set aside for a human. Distinct from failed because the *response* is
172 /// different: a failed item may be re-run, a quarantined one must not be
173 /// touched until someone has looked at it.
174 Quarantined(String),
175 /// Waiting on something — an event, a person, a timer.
176 ///
177 /// Not terminal. A batch that suspends items is not finished, and counting a
178 /// suspension as a failure would send someone to investigate a run that is
179 /// working exactly as designed.
180 Suspended(String),
181 /// Paused at a ceiling. Not terminal, and deliberately not `Failed`: the
182 /// item's run is intact and stays open — an operator's two honest moves
183 /// are raise the ceiling and resume, or cancel — and a pause reported as
184 /// a fault teaches the reader to re-run work that is standing. The string
185 /// names the ceiling that was hit.
186 Exhausted(String),
187 /// Paused because an authority the item acts under was withdrawn. Not
188 /// terminal, and not `Exhausted`: the work stands as it does at a ceiling,
189 /// but the move is lifting the halt on that subject rather than raising a
190 /// budget. The string names the subject and why it was withdrawn.
191 Withheld(String),
192}
193
194impl ItemOutcome {
195 #[must_use]
196 pub const fn as_str(&self) -> &'static str {
197 match self {
198 Self::Succeeded => "succeeded",
199 Self::Failed(_) => "failed",
200 Self::Quarantined(_) => "quarantined",
201 Self::Suspended(_) => "suspended",
202 Self::Exhausted(_) => "exhausted",
203 Self::Withheld(_) => "withheld",
204 }
205 }
206
207 /// Every outcome, each carrying `detail`.
208 ///
209 /// The list [`parse`](Self::parse) is written over, and the list a store
210 /// derives its terminal set from — so a query asking *what is still open*
211 /// answers from the type rather than from a list of strings beside it.
212 #[must_use]
213 pub fn all(detail: String) -> [Self; 6] {
214 [
215 Self::Succeeded,
216 Self::Failed(detail.clone()),
217 Self::Quarantined(detail.clone()),
218 Self::Suspended(detail.clone()),
219 Self::Exhausted(detail.clone()),
220 Self::Withheld(detail),
221 ]
222 }
223
224 /// The inverse of [`as_str`](Self::as_str), written over
225 /// [`all`](Self::all) for the reason [`CaseStatus::parse`] is.
226 ///
227 /// `None` is damage rather than absence: an item whose outcome cannot be
228 /// read has not *failed to run*, and a census filing it as in-flight keeps
229 /// the batch `Running` forever over a row nobody can explain.
230 ///
231 /// [`CaseStatus::parse`]: crate::core::CaseStatus::parse
232 #[must_use]
233 pub fn parse(tag: &str, detail: String) -> Option<Self> {
234 Self::all(detail).into_iter().find(|o| o.as_str() == tag)
235 }
236
237 #[must_use]
238 pub const fn is_terminal(&self) -> bool {
239 !matches!(
240 self,
241 Self::Suspended(_) | Self::Exhausted(_) | Self::Withheld(_)
242 )
243 }
244
245 /// The stored spellings of every outcome that ends an item.
246 ///
247 /// What a store asks for when it wants the complement: an item is open
248 /// while it has no outcome or an outcome outside this set, so a
249 /// non-terminal variant added later is open by construction rather than by
250 /// somebody remembering to widen a list of strings.
251 #[must_use]
252 pub fn terminal_tags() -> Vec<&'static str> {
253 Self::all(String::new())
254 .iter()
255 .filter(|o| o.is_terminal())
256 .map(Self::as_str)
257 .collect()
258 }
259
260 /// Whether this item is finished *and* needs nothing from anybody.
261 ///
262 /// Terminal and settled are different questions, and the difference is the
263 /// backlog: `Failed` and `Quarantined` are terminal and are exactly what a
264 /// person has to see. Written as a match rather than `== Succeeded` so a
265 /// variant added later is unsettled by construction — the safe direction.
266 #[must_use]
267 pub const fn is_settled(&self) -> bool {
268 match self {
269 Self::Succeeded => true,
270 Self::Failed(_)
271 | Self::Quarantined(_)
272 | Self::Suspended(_)
273 | Self::Exhausted(_)
274 | Self::Withheld(_) => false,
275 }
276 }
277
278 /// The stored spellings of every outcome nobody has to act on.
279 ///
280 /// The set a store negates to build [`items_needing_attention`]. Derived
281 /// from [`all`](Self::all) for the reason
282 /// [`terminal_tags`](Self::terminal_tags) is: a hand-kept list of strings
283 /// stops naming a class of trouble the day somebody adds one.
284 ///
285 /// [`items_needing_attention`]: BatchStore::items_needing_attention
286 #[must_use]
287 pub fn settled_tags() -> Vec<&'static str> {
288 Self::all(String::new())
289 .iter()
290 .filter(|o| o.is_settled())
291 .map(Self::as_str)
292 .collect()
293 }
294}
295
296/// What a batch has done, and whether it is done.
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
298#[serde(rename_all = "snake_case")]
299pub enum BatchStatus {
300 /// Items remain, or some are still suspended.
301 Running,
302 /// Every item reached a terminal outcome.
303 ///
304 /// Note the absence of a `Succeeded` variant: see the module docs. A caller
305 /// must read the counts to know what happened, because there is no way to
306 /// spell "it worked" that skips them.
307 Completed {
308 succeeded: u64,
309 failed: u64,
310 quarantined: u64,
311 },
312}
313
314impl BatchStatus {
315 /// Whether every item settled.
316 ///
317 /// Named for what it asserts rather than `is_ok`: the question a caller
318 /// usually means is "is there anything to do", and a batch with 43 failures
319 /// answers yes.
320 #[must_use]
321 pub const fn everything_settled(&self) -> bool {
322 matches!(
323 self,
324 Self::Completed {
325 failed: 0,
326 quarantined: 0,
327 ..
328 }
329 )
330 }
331}
332
333/// One item's record within a batch.
334#[derive(Debug, Clone, PartialEq, Eq)]
335pub struct ItemRecord {
336 pub key: String,
337 /// The run that processed it. Stable across resume — replaying this id is
338 /// what makes re-processing safe.
339 pub run: RunId,
340 /// `None` while the item is reserved but not yet finished.
341 pub outcome: Option<ItemOutcome>,
342 /// What this item consumed, so "what did the settlement run cost" is a sum
343 /// rather than an estimate.
344 pub spend: Spend,
345}
346
347/// What a batch cost and how it ended.
348#[derive(Debug, Clone, PartialEq, Eq)]
349pub struct BatchReport {
350 pub id: BatchId,
351 pub status: BatchStatus,
352 /// Items reserved but not yet terminal — suspended, or interrupted.
353 pub in_flight: u64,
354 /// Items paused at a ceiling, resumable once somebody raises it.
355 ///
356 /// Separate from [`in_flight`](Self::in_flight) because the move differs: an
357 /// interrupted item resumes when a sweep reaches it, this one waits for a
358 /// decision. Its own field because both leave the batch
359 /// [`Running`](BatchStatus::Running), and so does a deliberate `max_items`
360 /// window — only the counts tell those apart.
361 pub exhausted: u64,
362 /// Items paused because an authority they act under was withdrawn —
363 /// resumable once the halt on that subject is lifted.
364 pub withheld: u64,
365 /// The whole batch's consumption, summed from its items.
366 pub spend: Spend,
367 /// The key processing stopped after, for a resume.
368 pub cursor: Option<String>,
369}
370
371impl BatchReport {
372 /// Whether anything here needs a person — the alerting predicate.
373 ///
374 /// Deliberately **not** *is this batch unfinished*. Every windowed pass
375 /// returns [`Running`](BatchStatus::Running), so a status-keyed predicate
376 /// would be true on every correct use of [`BatchSpec::max_items`], and a
377 /// predicate that is always true is one people stop reading. It reads the
378 /// facts somebody acts on instead: `in_flight`, `exhausted`, and the failed
379 /// or quarantined counts.
380 ///
381 /// [`BatchStore::items_needing_attention`] is the listing behind the answer;
382 /// a predicate that says *yes* and cannot say *which* is a counter with no
383 /// alert.
384 ///
385 /// [`BatchSpec::max_items`]: crate::runtime::BatchSpec::max_items
386 #[must_use]
387 pub const fn needs_attention(&self) -> bool {
388 self.in_flight > 0
389 || self.exhausted > 0
390 || self.withheld > 0
391 || self.failed_or_quarantined() > 0
392 }
393
394 /// The terminal items that did not settle.
395 #[must_use]
396 pub const fn failed_or_quarantined(&self) -> u64 {
397 match self.status {
398 BatchStatus::Running => 0,
399 BatchStatus::Completed {
400 failed,
401 quarantined,
402 succeeded: _,
403 } => failed + quarantined,
404 }
405 }
406}
407
408/// Durable batch state.
409///
410/// Separate from the journal because a batch is not a run: it has no hash chain
411/// of its own, and its integrity comes from the per-item runs it points at. What
412/// it needs is a cursor, a reservation, and a census — three queries, not a log.
413#[async_trait]
414pub trait BatchStore: Send + Sync + Debug {
415 /// Which tenant this handle's batches and item reservations belong to.
416 fn tenant(&self) -> &str;
417
418 /// Register a batch. Idempotent on `id`, so a retried submission does not
419 /// fork one act into two.
420 ///
421 /// Idempotent on the id **and held to the digest**: one batch runs one
422 /// frozen plan, and that sentence is this method's to enforce, because the
423 /// runner cannot — by the time it executes an item, the store's row is the
424 /// only witness to what the batch was opened with.
425 ///
426 /// # Errors
427 ///
428 /// [`StoreError::BatchPlanChanged`] when the batch exists under a
429 /// different `plan_digest`. A resume offering an edited plan must be
430 /// refused here, in the store, or items settle under a plan the batch's
431 /// record does not name.
432 async fn open(&self, id: BatchId, plan_digest: &str) -> Result<(), StoreError>;
433
434 /// The plan digest this batch was opened with, or `None` for no such batch.
435 ///
436 /// The existence question, answered from the batch's own row. A census
437 /// cannot answer it — a batch with no items yet and a batch that does not
438 /// exist both count zero rows — and the difference matters to an operator:
439 /// one is work not started, the other is a mistyped id.
440 async fn plan_digest(&self, id: BatchId) -> Result<Option<String>, StoreError>;
441
442 /// Record that the source produced its last item.
443 ///
444 /// Without this a batch cannot tell "every item I have stored is terminal"
445 /// from "I am finished" — and those differ every time processing stops
446 /// early. A batch halted after 10,000 of 100,000 items has no unfinished
447 /// item anywhere in its store, so a census alone would report it complete
448 /// with 90,000 meters unsettled.
449 ///
450 /// Durable rather than in-memory because the distinction has to survive the
451 /// process: a resumed batch must know whether it ever reached the end.
452 ///
453 /// # Errors
454 ///
455 /// [`StoreError::NotFound`] when no such batch exists, for the reason
456 /// [`record`](Self::record) refuses an unreserved item: this mark is the
457 /// one bit that lets a census read as *finished*, and reporting it written
458 /// when nothing was is the quietest possible way to lose it.
459 async fn mark_exhausted(&self, id: BatchId) -> Result<(), StoreError>;
460
461 /// Whether the source has been read to the end.
462 async fn is_exhausted(&self, id: BatchId) -> Result<bool, StoreError>;
463
464 /// Claim an item and bind it to a run id, **before** the run starts.
465 ///
466 /// Returns the existing record if the item was already reserved — which is
467 /// what makes a crashed batch resumable: the second attempt gets the first
468 /// attempt's run id back and replays it rather than starting fresh.
469 async fn reserve(
470 &self,
471 batch: BatchId,
472 key: &str,
473 run: RunId,
474 ) -> Result<ItemRecord, StoreError>;
475
476 /// Record how an item ended, and what it consumed.
477 ///
478 /// # Errors
479 ///
480 /// [`StoreError::NotFound`] when the item was never reserved — a refusal,
481 /// never a silent no-op: `Ok` over a write that matched nothing tells the
482 /// caller *recorded* about an outcome that vanished, the same lie a
483 /// release that freed nothing tells. The row count the write already
484 /// produces is what catches it.
485 async fn record(
486 &self,
487 batch: BatchId,
488 key: &str,
489 outcome: &ItemOutcome,
490 spend: Spend,
491 ) -> Result<(), StoreError>;
492
493 /// The highest key whose item reached a terminal outcome, with no
494 /// unfinished item before it.
495 ///
496 /// The contiguous prefix, not the maximum: an item suspended at key 400 must
497 /// hold the cursor at 399 even if 401 through 500 have finished, or a resume
498 /// would step over it and the batch would report complete with an item still
499 /// waiting.
500 async fn cursor(&self, batch: BatchId) -> Result<Option<String>, StoreError>;
501
502 /// Counts by outcome, plus reserved-but-unfinished.
503 async fn census(&self, batch: BatchId) -> Result<BatchCensus, StoreError>;
504
505 /// Every item record, oldest key first. For operators and for tests; the
506 /// driver uses `cursor` and `census`.
507 async fn items(&self, batch: BatchId, limit: usize) -> Result<Vec<ItemRecord>, StoreError>;
508
509 /// The items that are not settled, oldest key first.
510 ///
511 /// [`census`](Self::census) answers *43 failed*; the question an operator
512 /// has is **which 43**, and over the size a batch exists for the only other
513 /// route is paging [`items`](Self::items) through a hundred thousand rows
514 /// that are almost all successes — the finding indexed and reaching nobody.
515 ///
516 /// Unsettled, not un-terminal: a failed item and a suspended one are both
517 /// here, because *finished* and *finished with* are different questions.
518 /// Each record's [`ItemOutcome`] says whether the next move is a re-run, a
519 /// raised ceiling, or a look.
520 ///
521 /// Oldest-first is legitimate because entries **leave**: resolving an item
522 /// drops it. An ascending page over a listing nothing empties would have a
523 /// permanent head and an unreachable tail.
524 ///
525 /// # Errors
526 ///
527 /// Backend failures. An unknown batch is an empty listing, not an error:
528 /// [`plan_digest`](Self::plan_digest) is the existence question.
529 async fn items_needing_attention(
530 &self,
531 batch: BatchId,
532 limit: usize,
533 ) -> Result<Vec<ItemRecord>, StoreError>;
534}
535
536/// A batch's tally.
537#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
538pub struct BatchCensus {
539 pub succeeded: u64,
540 pub failed: u64,
541 pub quarantined: u64,
542 pub suspended: u64,
543 /// Paused at a ceiling — resumable once somebody raises it.
544 pub exhausted: u64,
545 /// Paused under a withdrawn authority — resumable once the halt lifts.
546 pub withheld: u64,
547 /// Reserved with no outcome recorded — an item interrupted mid-flight.
548 pub in_flight: u64,
549 pub spend: Spend,
550}
551
552impl BatchCensus {
553 /// Items that will not change without intervention.
554 #[must_use]
555 pub const fn terminal(&self) -> u64 {
556 self.succeeded + self.failed + self.quarantined
557 }
558}