Skip to main content

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}