Skip to main content

dataflow_rs/run/
error_context.rs

1//! The error context: where the engine mirrors the records each call added
2//! into the message's context, so a later condition or mapping can branch on
3//! why a task failed. v3's `with_error_context_path` and
4//! `with_error_context_limit`, with v3's record, trim and replacement rules.
5//!
6//! The recorder mirrors after the call's error record, for every recorded
7//! call that is not a `Skip` and added a record; the engine removes the
8//! path's key at run start, after the stamps and before the trace's
9//! `initial`.
10
11use super::engine::slots;
12use crate::base::{Change, Error, ErrorKind, ErrorRecord, Result};
13use crate::expr::{Path, Root};
14use crate::state::RunContext;
15use datavalue::OwnedDataValue;
16use std::borrow::Cow;
17
18/// Where the engine mirrors failure records in the context, and how many it
19/// keeps.
20///
21/// After every recorded call that is not a `Skip`, each error record the call
22/// added (the handler's own and the engine's) is appended at the path as
23/// `{"workflow_id", "task_id", "code", "status"}`, `status` being the call's
24/// (500 for an `Err`), and the array is trimmed to the newest
25/// [`limit`](Self::limit()). A call that adds no record writes nothing, so a
26/// message whose calls all succeed has no key at the path, not `[]`. As in
27/// v3, every segment below the root names an object key: a non-array at the
28/// path, or anything but an object above it, is replaced.
29///
30/// At run start the engine removes the path's key: a message never brings
31/// records in.
32///
33/// ```
34/// use dataflow_rs::run::{EngineConfig, ErrorContext};
35/// use dataflow_rs::ErrorKind;
36///
37/// let ec = ErrorContext::new("metadata.errors".parse()?, ErrorContext::DEFAULT_LIMIT)?;
38/// assert_eq!(ec.limit(), 32);
39/// let config = EngineConfig::default().error_context(ec);
40/// assert!(config.error_context.is_some());
41///
42/// // The engine owns `metadata.progress`, and every slot below it.
43/// let refused = ErrorContext::new("metadata.progress.errors".parse()?, 32).unwrap_err();
44/// assert_eq!(*refused.kind(), ErrorKind::Config);
45/// # Ok::<(), dataflow_rs::Error>(())
46/// ```
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct ErrorContext {
49    path: Path,
50    limit: usize,
51}
52
53impl ErrorContext {
54    /// The records kept when a host names no limit: v3's default.
55    pub const DEFAULT_LIMIT: usize = 32;
56
57    /// The error context at `path`, keeping the newest `limit` records.
58    ///
59    /// # Errors
60    ///
61    /// [`ErrorKind::Config`] for a root (`data`, `metadata` or `temp_data`
62    /// itself); for a path at or below a `metadata` slot the engine owns
63    /// `metadata.progress`, which it writes after every call for
64    /// cross-workflow chaining, and the run's stamps `metadata.processed_at`,
65    /// `metadata.engine_version` and `metadata.channel`; and for a `limit` of
66    /// 0.
67    pub fn new(path: Path, limit: usize) -> Result<Self> {
68        if path.is_root() {
69            return Err(Error::new(
70                ErrorKind::Config,
71                format!(
72                    "error context path must name a slot inside `{}`, got `{path}`",
73                    path.root().as_str()
74                ),
75            ));
76        }
77        if path.root() == Root::Metadata
78            && let Some(slot) = path.segs().get(1)
79            && slots::ALL.contains(&&*slot.key)
80        {
81            return Err(Error::new(
82                ErrorKind::Config,
83                format!(
84                    "error context path may not be at or below `metadata.{}` — the engine owns \
85                     that slot",
86                    slot.key
87                ),
88            ));
89        }
90        if limit == 0 {
91            return Err(Error::new(
92                ErrorKind::Config,
93                "error context limit must be at least 1",
94            ));
95        }
96        Ok(Self { path, limit })
97    }
98
99    /// Where the records are mirrored.
100    pub fn path(&self) -> &Path {
101        &self.path
102    }
103
104    /// How many records are kept: the newest.
105    pub fn limit(&self) -> usize {
106        self.limit
107    }
108
109    /// Removes the path's key from `context`, the owned context at run start:
110    /// what the write would find there, by the same reading of the segments
111    /// as keys. Anything but an object on the way leaves `context` as it is.
112    pub(crate) fn reset(&self, context: &mut OwnedDataValue) {
113        let Some((last, parents)) = self.path.segs().split_last() else {
114            return;
115        };
116        let mut node = context;
117        for seg in parents {
118            node = match node {
119                OwnedDataValue::Object(pairs) => {
120                    match pairs.iter_mut().find(|(k, _)| **k == *seg.key) {
121                        Some((_, child)) => child,
122                        None => return,
123                    }
124                }
125                _ => return,
126            };
127        }
128        if let OwnedDataValue::Object(pairs) = node
129            && let Some(at) = pairs.iter().position(|(k, _)| **k == *last.key)
130        {
131            pairs.remove(at);
132        }
133    }
134
135    /// Mirrors `records`, the ones a call of `workflow`'s `task` added, at
136    /// the path, with the call's `status`: v3's `append_error_records`. The
137    /// write's `Change`, when `traced`: at the path, from what was there (or
138    /// `null`) to the array, or, when a hop above the path was replaced, at
139    /// that hop, so a replay rebuilds the context.
140    pub(crate) fn mirror(
141        &self,
142        rc: &mut RunContext<'_, '_>,
143        records: &[ErrorRecord],
144        workflow: &str,
145        task: &str,
146        status: u16,
147        traced: bool,
148    ) -> Option<Change> {
149        if records.is_empty() {
150            return None;
151        }
152        let fresh = || {
153            records
154                .iter()
155                .map(|r| record(workflow, task, &r.code, status))
156        };
157        let segs = self.path.segs();
158        // With every segment a key, what the path reads is under objects
159        // alone, and a write there creates only objects: the path is the
160        // slot, unless a hop above holds a scalar, `null` or an array, when
161        // the write does nothing and the walk below finds that hop. Nothing
162        // is converted on the way.
163        if segs[1..].iter().all(|seg| seg.index.is_none()) {
164            let old = rc.get(&self.path).map(Cow::into_owned);
165            let (value, change) = self.extend(&self.path, 0, old, fresh(), traced);
166            if rc.set(&self.path, value, None) {
167                return change;
168            }
169        }
170        let (at, old) = self.walk(rc);
171        let below = segs.len() - at.segs().len();
172        let (value, change) = self.extend(&at, below, old, fresh(), traced);
173        rc.set(&at, value, None);
174        change
175    }
176
177    /// Where v3 writes, and what is there now: the path itself when every
178    /// hop above it holds an object, else the first hop that does not,
179    /// missing or replaced. Each hop is read by key, so an array is a hop to
180    /// replace, whatever the segment.
181    fn walk(&self, rc: &RunContext<'_, '_>) -> (Cow<'_, Path>, Option<OwnedDataValue>) {
182        let segs = self.path.segs();
183        for len in 1..segs.len() {
184            if !rc.is_object_at(&segs[..len]) {
185                let hop = self.path.prefix(len);
186                let found = rc.get(&hop).map(Cow::into_owned);
187                return (Cow::Owned(hop), found);
188            }
189        }
190        let old = rc.get(&self.path).map(Cow::into_owned);
191        (Cow::Borrowed(&self.path), old)
192    }
193
194    /// The value written at `at`, `below` segments above the path, over
195    /// `old`, what is there: the array there, or a new one, extended with
196    /// `fresh` and trimmed to the newest `limit`, under an object for each
197    /// segment `at` stops short of. Its `Change` when `traced`.
198    fn extend(
199        &self,
200        at: &Path,
201        below: usize,
202        old: Option<OwnedDataValue>,
203        fresh: impl Iterator<Item = OwnedDataValue>,
204        traced: bool,
205    ) -> (OwnedDataValue, Option<Change>) {
206        let old_value = traced.then(|| old.clone().unwrap_or(OwnedDataValue::Null));
207        let mut items = match old {
208            Some(OwnedDataValue::Array(items)) if below == 0 => items,
209            Some(_) if below == 0 => {
210                ::log::warn!(
211                    "error context path `{}` held a non-array — replacing it",
212                    self.path
213                );
214                Vec::new()
215            }
216            Some(_) => {
217                ::log::warn!(
218                    "error context path `{}` runs through a non-object at `{at}` — replacing it",
219                    self.path
220                );
221                Vec::new()
222            }
223            None => Vec::new(),
224        };
225        items.extend(fresh);
226        // Keep-newest: a loop with a failing body would otherwise grow the
227        // array once per sweep.
228        if items.len() > self.limit {
229            items.drain(..items.len() - self.limit);
230        }
231        let segs = self.path.segs();
232        let value = segs[segs.len() - below..]
233            .iter()
234            .rev()
235            .fold(OwnedDataValue::Array(items), |inner, seg| {
236                OwnedDataValue::Object(vec![(String::from(&*seg.key), inner)])
237            });
238        // A write at a root replaces it, and its `Change` says so.
239        let change = old_value.map(|old| Change::assign(at, old, value.clone()));
240        (value, change)
241    }
242}
243
244/// One mirrored record: v3's `new_error_record`, its keys in its order.
245fn record(workflow: &str, task: &str, code: &str, status: u16) -> OwnedDataValue {
246    OwnedDataValue::Object(vec![
247        (
248            "workflow_id".to_owned(),
249            OwnedDataValue::String(workflow.to_owned()),
250        ),
251        (
252            "task_id".to_owned(),
253            OwnedDataValue::String(task.to_owned()),
254        ),
255        ("code".to_owned(), OwnedDataValue::String(code.to_owned())),
256        ("status".to_owned(), OwnedDataValue::from(status)),
257    ])
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::state::Tuning;
264
265    /// v3's `append_error_records`, ported over owned values: the reference
266    /// the mirror is held to. Every segment is a key, with one `#` stripped.
267    fn v3_append(context: &mut OwnedDataValue, path: &str, limit: usize, new: &[OwnedDataValue]) {
268        fn object(node: &mut OwnedDataValue) -> &mut Vec<(String, OwnedDataValue)> {
269            if !matches!(node, OwnedDataValue::Object(_)) {
270                *node = OwnedDataValue::Object(Vec::new());
271            }
272            match node {
273                OwnedDataValue::Object(fields) => fields,
274                _ => unreachable!(),
275            }
276        }
277        fn child<'v>(node: &'v mut OwnedDataValue, key: &str) -> &'v mut OwnedDataValue {
278            let fields = object(node);
279            let at = match fields.iter().position(|(k, _)| k == key) {
280                Some(at) => at,
281                None => {
282                    fields.push((key.to_owned(), OwnedDataValue::Null));
283                    fields.len() - 1
284                }
285            };
286            &mut fields[at].1
287        }
288        let mut node = context;
289        for raw in path.split('.') {
290            node = child(node, raw.strip_prefix('#').unwrap_or(raw));
291        }
292        if !matches!(node, OwnedDataValue::Array(_)) {
293            *node = OwnedDataValue::Array(Vec::new());
294        }
295        let OwnedDataValue::Array(items) = node else {
296            unreachable!()
297        };
298        items.extend(new.iter().cloned());
299        if items.len() > limit {
300            items.drain(..items.len() - limit);
301        }
302    }
303
304    fn parse(text: &str) -> OwnedDataValue {
305        serde_json::from_str(text).unwrap()
306    }
307
308    fn text(v: &OwnedDataValue) -> String {
309        serde_json::to_string(v).unwrap()
310    }
311
312    #[test]
313    fn with_and_without_a_view_the_mirror_leaves_what_v3_leaves() {
314        let seeds = [
315            r#"{"data":{},"metadata":{},"temp_data":{}}"#,
316            r#"{"data":{"items":[1,2],"n":5,"z":null,"o":{"k":1}},"metadata":{"errors":"s"},"temp_data":{}}"#,
317            r#"{"data":[1],"metadata":{"errors":[{"old":1}]},"temp_data":{}}"#,
318        ];
319        let paths = [
320            "metadata.errors",
321            "data.a.b.c",
322            "data.#x",
323            "data.items.0",
324            "data.items.k",
325            "data.items.0.deep",
326            "data.n.x",
327            "data.z.x",
328            "data.o.list",
329            "temp_data.e",
330        ];
331        let calls: [&[&str]; 3] = [&["A"], &["B", "C"], &["D"]];
332        for seed in seeds {
333            for path in paths {
334                let ec = ErrorContext::new(path.parse().unwrap(), 3).unwrap();
335                let mut expected = parse(seed);
336                for codes in calls {
337                    let new: Vec<_> = codes.iter().map(|c| record("w", "t", c, 500)).collect();
338                    v3_append(&mut expected, path, 3, &new);
339                }
340                for viewed in [false, true] {
341                    let mut ctx = parse(seed);
342                    let mut rc = RunContext::new(&mut ctx, Tuning::default());
343                    for codes in calls {
344                        if viewed {
345                            rc.read(|_, _| ());
346                        }
347                        let records: Vec<_> =
348                            codes.iter().map(|c| ErrorRecord::new(*c, "m")).collect();
349                        ec.mirror(&mut rc, &records, "w", "t", 500, false);
350                    }
351                    assert_eq!(rc.has_view(), viewed);
352                    rc.finish();
353                    assert_eq!(
354                        text(&ctx),
355                        text(&expected),
356                        "{path} over {seed}, viewed {viewed}"
357                    );
358                }
359            }
360        }
361    }
362
363    #[test]
364    fn a_write_at_a_root_says_it_replaced_it() {
365        // A root that holds no object is
366        // replaced, and the trace's change says so; a hop below a root is
367        // assigned, and its change says nothing.
368        for (path, seed, at, replaced) in [
369            (
370                "data.errors",
371                r#"{"data":[1],"metadata":{},"temp_data":{}}"#,
372                "data",
373                true,
374            ),
375            (
376                "data.n.list",
377                r#"{"data":{"n":5},"metadata":{},"temp_data":{}}"#,
378                "data.n",
379                false,
380            ),
381        ] {
382            let ec = ErrorContext::new(path.parse().unwrap(), 3).unwrap();
383            let mut ctx = parse(seed);
384            let mut rc = RunContext::new(&mut ctx, Tuning::default());
385            let records = [ErrorRecord::new("A", "m")];
386            let change = ec
387                .mirror(&mut rc, &records, "w", "t", 500, true)
388                .expect("a traced mirror records its change");
389            rc.finish();
390            assert_eq!((&*change.path, change.replaced), (at, replaced), "{path}");
391            let written: Path = at.parse().unwrap();
392            assert_eq!(
393                text(written.get(&ctx).unwrap()),
394                text(&change.new_value),
395                "{path}"
396            );
397        }
398    }
399
400    #[test]
401    fn the_reset_removes_only_what_the_write_would_find() {
402        let seed = r#"{"data":{"items":[1,2],"o":{"k":1,"j":2}},"metadata":{"errors":[1]},"temp_data":{}}"#;
403        for (path, expected) in [
404            (
405                "metadata.errors",
406                r#"{"data":{"items":[1,2],"o":{"k":1,"j":2}},"metadata":{},"temp_data":{}}"#,
407            ),
408            (
409                "data.o.k",
410                r#"{"data":{"items":[1,2],"o":{"j":2}},"metadata":{"errors":[1]},"temp_data":{}}"#,
411            ),
412            // The write reads `0` as a key, so an array element is not its.
413            ("data.items.0", seed),
414            ("data.missing.x", seed),
415        ] {
416            let mut ctx = parse(seed);
417            ErrorContext::new(path.parse().unwrap(), 1)
418                .unwrap()
419                .reset(&mut ctx);
420            assert_eq!(text(&ctx), expected, "{path}");
421        }
422    }
423}