Skip to main content

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}