Skip to main content

dataflow_rs/engine/
for_each.rs

1//! Task-level fan-out: run one task's handler once per element of an array.
2//!
3//! The config lives here; the driver is `WorkflowExecutor::run_for_each`.
4
5use crate::engine::functions::FunctionConfig;
6use crate::engine::utils::compute_path_parts;
7use crate::engine::workflow::{is_slot_path, over_literal_problem, slots_overlap};
8use datalogic_rs::Logic;
9use serde::Deserialize;
10use serde_json::Value;
11use std::sync::Arc;
12
13/// Run a task's function once per element of an array.
14///
15/// ```json
16/// { "id": "infer", "name": "One move per participant",
17///   "for_each": { "over": { "var": "data.participants" }, "as": "p",
18///                 "max_concurrency": 8,
19///                 "collect": "temp_data.move", "into": "temp_data.moves" },
20///   "continue_on_error": true,
21///   "function": { "name": "model_infer", "input": {
22///     "model": { "var": "temp_data.p.model" },
23///     "output": "temp_data.move" } } }
24/// ```
25///
26/// The task's `condition` is evaluated once. [`over`](Self::over) is then
27/// evaluated once, and the function runs once per element with the element at
28/// `temp_data.<as>` and its index at `temp_data.<as>_index`.
29///
30/// # Every call is isolated
31///
32/// Each call runs against **its own copy** of the message, taken before the
33/// first call, so no call sees another's writes — not even when they run one
34/// at a time. When the calls finish, each is folded back into the message in
35/// element order: the errors it recorded, then its writes, then its audit
36/// entry. That is what makes [`max_concurrency`](Self::max_concurrency) a
37/// timing knob only: the message comes out the same at every setting.
38///
39/// The fold replays the writes a handler made through `TaskContext::set`.
40/// A write made by reaching into `ctx.message_mut()` directly is not carried
41/// back. The bindings themselves live only in each call's copy, so neither
42/// `temp_data.<as>` nor `temp_data.<as>_index` is left behind — unless a
43/// handler writes under one of them itself, which is replayed like any other
44/// write. [`collect`](Self::collect) may not name a path under either.
45///
46/// # Results
47///
48/// [`collect`](Self::collect) names the slot each call writes its result to,
49/// and [`into`](Self::into) the array the results land in, in element order.
50/// An element that failed (a returned `Err` or a status of `400` or more), or
51/// never ran, or wrote nothing at `collect`, leaves `null` at its index.
52///
53/// # Failures
54///
55/// The task's `continue_on_error` applies per element. A call that fails the
56/// task — an `Err` or a `5xx` without `continue_on_error` — or returns
57/// `TaskOutcome::Halt` stops new calls from starting; calls already running
58/// finish. Folding stops at the first element whose record fails the task or
59/// halts, so the elements after it contribute nothing — however many had
60/// already finished. `terminal` and `halt_on` apply once, after the whole
61/// fan-out.
62///
63/// Only handler-backed functions may fan out (`http_call`, `enrich`,
64/// `publish_kafka`, custom handlers): built-ins run inline, and JSONLogic's
65/// `map`, `filter` and `reduce` already cover per-element transforms.
66///
67/// `#[non_exhaustive]`: the compiled fields are engine internals. Parse a
68/// workflow from JSON to get one.
69#[derive(Clone, Debug, Deserialize)]
70#[non_exhaustive]
71pub struct ForEach {
72    /// JSONLogic evaluated once, after the task's condition. It must yield an
73    /// array; anything else, `null` included, fails the task. An empty array
74    /// runs no call.
75    pub over: Value,
76
77    /// `temp_data` field holding the element in each call's view. The JSON
78    /// key is `as`. The element's index is at `<as>_index` beside it.
79    #[serde(rename = "as")]
80    pub item: String,
81
82    /// Calls in flight at once. `1`, the default, runs them one at a time.
83    #[serde(default = "one")]
84    pub max_concurrency: usize,
85
86    /// Context path each call writes its result to, such as
87    /// `temp_data.move`. Requires [`into`](Self::into). Must not overlap
88    /// `temp_data.<as>` or `temp_data.<as>_index`.
89    #[serde(default)]
90    pub collect: Option<String>,
91
92    /// Context path that receives the results as an array in element order.
93    /// Requires [`collect`](Self::collect).
94    #[serde(default)]
95    pub into: Option<String>,
96
97    /// Engine-internal: compiled `over`. Not part of the stable API.
98    #[doc(hidden)]
99    #[serde(skip)]
100    pub compiled_over: Option<Arc<Logic>>,
101
102    /// Engine-internal: pre-split `temp_data.<as>`.
103    #[doc(hidden)]
104    #[serde(skip)]
105    pub item_parts: Arc<[Arc<str>]>,
106
107    /// Engine-internal: pre-split `temp_data.<as>_index`.
108    #[doc(hidden)]
109    #[serde(skip)]
110    pub index_parts: Arc<[Arc<str>]>,
111
112    /// Engine-internal: pre-split `collect`.
113    #[doc(hidden)]
114    #[serde(skip)]
115    pub collect_parts: Arc<[Arc<str>]>,
116
117    /// Engine-internal: pre-split `into`.
118    #[doc(hidden)]
119    #[serde(skip)]
120    pub into_parts: Arc<[Arc<str>]>,
121}
122
123fn one() -> usize {
124    1
125}
126
127fn no_parts() -> Arc<[Arc<str>]> {
128    Arc::from([] as [Arc<str>; 0])
129}
130
131/// The three roots every context path starts at.
132const ROOTS: [&str; 3] = ["data", "metadata", "temp_data"];
133
134/// Whether `path` names a key below a context root, such as `temp_data.m`.
135fn is_context_path(path: &str) -> bool {
136    let Some((root, rest)) = path.split_once('.') else {
137        return false;
138    };
139    ROOTS.contains(&root) && is_slot_path(rest)
140}
141
142/// A rule an authored `for_each` breaks, and the key it is reported at.
143///
144/// `field` is the key under `for_each` (`"max_concurrency"`), or empty when
145/// the problem is the `for_each` as a whole.
146pub(crate) struct ForEachProblem {
147    pub field: &'static str,
148    pub message: String,
149}
150
151impl ForEach {
152    /// `<as>_index`: the sibling slot holding the element's index.
153    pub fn index_slot(&self) -> String {
154        format!("{}_index", self.item)
155    }
156
157    /// The one statement of the `for_each` rules.
158    ///
159    /// `Workflow::validate` refuses a workflow on it, and
160    /// `authoring::check_steps` reports it at the offending key, so the two
161    /// cannot disagree about what loads.
162    pub(crate) fn problem(&self, function: &FunctionConfig) -> Option<ForEachProblem> {
163        let at = |field, message: String| Some(ForEachProblem { field, message });
164
165        if function.is_sync_builtin() {
166            return at(
167                "",
168                format!(
169                    "for_each runs a handler-backed function once per element, and '{}' is \
170                     a built-in that runs inline — use JSONLogic's map, filter or reduce over \
171                     the array instead",
172                    function.function_name()
173                ),
174            );
175        }
176        if let Some(problem) = over_literal_problem(&self.over) {
177            return at("over", problem.replacen("loop over", "for_each over", 1));
178        }
179        if !is_slot_path(&self.item) {
180            return at(
181                "as",
182                format!(
183                    "for_each as must be a non-empty temp_data field path, got {:?}",
184                    self.item
185                ),
186            );
187        }
188        if self.max_concurrency == 0 {
189            return at(
190                "max_concurrency",
191                "for_each max_concurrency must be at least 1".to_string(),
192            );
193        }
194
195        let (collect, into) = match (&self.collect, &self.into) {
196            (None, None) => return None,
197            (Some(collect), Some(into)) => (collect, into),
198            (collect, _) => {
199                return at(
200                    if collect.is_some() { "collect" } else { "into" },
201                    "for_each collect and into go together: collect names what each call \
202                     writes, into names the array the results land in"
203                        .to_string(),
204                );
205            }
206        };
207        for (field, path) in [("collect", collect), ("into", into)] {
208            if !is_context_path(path) {
209                return at(
210                    field,
211                    format!(
212                        "for_each {field} must be a context path below data, metadata or \
213                         temp_data, got {path:?}"
214                    ),
215                );
216            }
217        }
218        let item = format!("temp_data.{}", self.item);
219        let index = format!("temp_data.{}", self.index_slot());
220        for binding in [&item, &index] {
221            if slots_overlap(collect, binding) {
222                return at(
223                    "collect",
224                    format!(
225                        "for_each collect ({collect:?}) and {binding:?} overlap — the bindings \
226                         live only in each call's copy, and a result written under one would be \
227                         replayed into the message and left behind"
228                    ),
229                );
230            }
231        }
232        for other in [collect.as_str(), &item, &index] {
233            if slots_overlap(into, other) {
234                return at(
235                    "into",
236                    format!(
237                        "for_each into ({into:?}) and {other:?} overlap — the results array \
238                         would overwrite, or be overwritten by, a slot written per call"
239                    ),
240                );
241            }
242        }
243        None
244    }
245
246    /// Pre-split every path the driver writes or reads, once, at build time.
247    /// Idempotent, so a hot reload can run it again.
248    #[doc(hidden)]
249    pub fn precompute_paths(&mut self) {
250        fn context(path: &Option<String>) -> Arc<[Arc<str>]> {
251            match path.as_deref().and_then(|p| p.split_once('.')) {
252                Some((root, rest)) => compute_path_parts(root, rest),
253                None => no_parts(),
254            }
255        }
256        self.item_parts = compute_path_parts("temp_data", &self.item);
257        self.index_parts = compute_path_parts("temp_data", &self.index_slot());
258        self.collect_parts = context(&self.collect);
259        self.into_parts = context(&self.into);
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use crate::engine::workflow::Workflow;
266    use std::sync::Arc;
267
268    fn task_with(for_each: &str, function: &str) -> Result<Workflow, String> {
269        let wf = Workflow::from_json(&format!(
270            r#"{{ "id": "w", "name": "w", "tasks": [
271                 {{ "id": "t", "name": "t", "for_each": {for_each}, "function": {function} }}] }}"#
272        ))
273        .map_err(|e| e.to_string())?;
274        wf.validate().map_err(|e| e.to_string())?;
275        Ok(wf)
276    }
277
278    const CUSTOM: &str = r#"{"name": "infer", "input": {}}"#;
279
280    #[test]
281    fn a_for_each_parses_with_its_defaults() {
282        let wf = task_with(r#"{"over": {"var": "data.ps"}, "as": "p"}"#, CUSTOM).unwrap();
283        let fe = wf.tasks[0].for_each.as_ref().unwrap();
284        assert_eq!(fe.item, "p");
285        assert_eq!(fe.max_concurrency, 1, "sequential by default");
286        assert!(fe.collect.is_none() && fe.into.is_none());
287        assert_eq!(fe.index_slot(), "p_index");
288        assert!(fe.compiled_over.is_none(), "compiled at build");
289    }
290
291    #[test]
292    fn a_task_without_for_each_has_none() {
293        let wf = Workflow::from_json(
294            r#"{"id": "w", "name": "w", "tasks": [
295                {"id": "t", "name": "t", "function": {"name": "infer", "input": {}}}]}"#,
296        )
297        .unwrap();
298        assert!(wf.tasks[0].for_each.is_none());
299    }
300
301    #[test]
302    fn every_rule_refuses_its_shape() {
303        for (for_each, needle) in [
304            (
305                r#"{"over": "data.ps", "as": "p"}"#,
306                "can never evaluate to an array",
307            ),
308            (
309                r#"{"over": 3, "as": "p"}"#,
310                "can never evaluate to an array",
311            ),
312            (r#"{"over": [], "as": "a..b"}"#, "for_each as"),
313            (r#"{"over": [], "as": ""}"#, "for_each as"),
314            (
315                r#"{"over": [], "as": "p", "max_concurrency": 0}"#,
316                "max_concurrency",
317            ),
318            (
319                r#"{"over": [], "as": "p", "collect": "temp_data.m"}"#,
320                "collect and into",
321            ),
322            (
323                r#"{"over": [], "as": "p", "into": "temp_data.ms"}"#,
324                "collect and into",
325            ),
326            (
327                r#"{"over": [], "as": "p", "collect": "m", "into": "temp_data.ms"}"#,
328                "context path",
329            ),
330            (
331                r#"{"over": [], "as": "p", "collect": "temp_data.m", "into": "temp_data"}"#,
332                "context path",
333            ),
334            (
335                r#"{"over": [], "as": "p", "collect": "temp_data.m", "into": "payload.ms"}"#,
336                "context path",
337            ),
338            (
339                r#"{"over": [], "as": "p", "collect": "temp_data.m", "into": "temp_data.m.x"}"#,
340                "overlap",
341            ),
342            (
343                r#"{"over": [], "as": "p", "collect": "temp_data.m", "into": "temp_data.p_index"}"#,
344                "overlap",
345            ),
346            (
347                r#"{"over": [], "as": "p", "collect": "temp_data.m", "into": "temp_data.p"}"#,
348                "overlap",
349            ),
350            // A result under the binding would be replayed into the message
351            // and leave the binding behind. The needle is unique to the
352            // `collect` message; the `into` one starts `for_each into (`.
353            (
354                r#"{"over": [], "as": "p", "collect": "temp_data.p.out", "into": "temp_data.outs"}"#,
355                "for_each collect (",
356            ),
357            (
358                r#"{"over": [], "as": "p", "collect": "temp_data.p", "into": "temp_data.outs"}"#,
359                "for_each collect (",
360            ),
361            (
362                r#"{"over": [], "as": "p", "collect": "temp_data.p_index", "into": "temp_data.outs"}"#,
363                "for_each collect (",
364            ),
365        ] {
366            let err = task_with(for_each, CUSTOM).expect_err(for_each);
367            assert!(err.contains(needle), "{for_each}: {err}");
368        }
369        assert!(
370            task_with(r#"{"as": "p"}"#, CUSTOM).is_err(),
371            "over is required"
372        );
373        assert!(
374            task_with(r#"{"over": []}"#, CUSTOM).is_err(),
375            "as is required"
376        );
377    }
378
379    #[test]
380    fn an_explicit_null_over_is_refused() {
381        // `over: Value` is required, so an explicit null deserializes to
382        // `Value::Null` and is refused by the literal rule, not read as absent.
383        let err = task_with(r#"{"over": null, "as": "p"}"#, CUSTOM).expect_err("null");
384        assert!(err.contains("null"), "{err}");
385    }
386
387    #[test]
388    fn valid_shapes_are_accepted() {
389        for for_each in [
390            r#"{"over": [1, 2], "as": "p"}"#,
391            r#"{"over": {"var": "data.ps"}, "as": "cur.p", "max_concurrency": 8}"#,
392            r#"{"over": [], "as": "p", "collect": "temp_data.m", "into": "data.ms"}"#,
393            // `_out` is not a segment boundary, so this is beside the binding,
394            // not under it.
395            r#"{"over": [], "as": "p", "collect": "temp_data.p_out", "into": "temp_data.outs"}"#,
396        ] {
397            assert!(task_with(for_each, CUSTOM).is_ok(), "{for_each}");
398        }
399    }
400
401    #[test]
402    fn a_for_each_on_a_sync_builtin_is_refused() {
403        let err = task_with(
404            r#"{"over": [], "as": "p"}"#,
405            r#"{"name": "map", "input": {"mappings": []}}"#,
406        )
407        .expect_err("map is a sync built-in");
408        assert!(err.contains("handler-backed"), "{err}");
409        for (name, input) in [
410            ("http_call", r#"{"connector": "c"}"#),
411            ("enrich", r#"{"connector": "c", "merge_path": "data.x"}"#),
412            ("publish_kafka", r#"{"connector": "c", "topic": "t"}"#),
413        ] {
414            let function = format!(r#"{{"name": "{name}", "input": {input}}}"#);
415            assert!(
416                task_with(r#"{"over": [], "as": "p"}"#, &function).is_ok(),
417                "{name} is handler-backed"
418            );
419        }
420    }
421
422    #[test]
423    fn a_group_carrying_for_each_is_refused_at_parse() {
424        let err = Workflow::from_json(
425            r#"{ "id": "w", "name": "w", "tasks": [
426                 {"id": "g", "for_each": {"over": [], "as": "p"}, "tasks": [
427                   {"id": "t", "name": "t", "function": {"name": "infer", "input": {}}}]}] }"#,
428        )
429        .expect_err("groups cannot fan out");
430        assert!(err.to_string().contains("for_each"), "{err}");
431    }
432
433    #[test]
434    fn a_setup_step_is_validated_too() {
435        let wf = Workflow::from_json(
436            r#"{ "id": "w", "name": "w",
437                 "loop": {"max": 2, "setup": [
438                   {"id": "s", "name": "s",
439                    "for_each": {"over": [], "as": "p", "max_concurrency": 0},
440                    "function": {"name": "infer", "input": {}}}]},
441                 "tasks": [{"id": "t", "name": "t", "function": {"name": "infer", "input": {}}}] }"#,
442        )
443        .unwrap();
444        assert!(wf.validate().is_err());
445    }
446
447    #[test]
448    fn precompute_paths_splits_every_slot() {
449        let mut fe = task_with(
450            r#"{"over": [], "as": "p", "collect": "temp_data.m", "into": "data.ms"}"#,
451            CUSTOM,
452        )
453        .unwrap()
454        .tasks[0]
455            .for_each
456            .clone()
457            .unwrap();
458        fe.precompute_paths();
459        let s = |p: &Arc<[Arc<str>]>| p.iter().map(|x| x.to_string()).collect::<Vec<_>>();
460        assert_eq!(s(&fe.item_parts), ["temp_data", "p"]);
461        assert_eq!(s(&fe.index_parts), ["temp_data", "p_index"]);
462        assert_eq!(s(&fe.collect_parts), ["temp_data", "m"]);
463        assert_eq!(s(&fe.into_parts), ["data", "ms"]);
464
465        let mut bare = task_with(r#"{"over": [], "as": "p"}"#, CUSTOM)
466            .unwrap()
467            .tasks[0]
468            .for_each
469            .clone()
470            .unwrap();
471        bare.precompute_paths();
472        assert!(bare.collect_parts.is_empty() && bare.into_parts.is_empty());
473    }
474}