Skip to main content

dataflow_rs/engine/
task_context.rs

1//! # Task context
2//!
3//! Wraps the per-call state passed to every `AsyncFunctionHandler::execute`
4//! call: the message under processing, a handle to the shared datalogic
5//! engine, and an audit-trail accumulator. Exposes typed helpers so handlers
6//! don't have to reach into `crate::engine::utils::{get,set}_nested_value`
7//! or hand-build `Change` entries.
8//!
9//! Custom handlers should treat `TaskContext` as their *only* mutation surface
10//! for `message.context`: the `set` family records a `Change` automatically
11//! when `message.capture_changes` is true, keeping the audit trail in sync
12//! with the data without per-handler boilerplate.
13
14use crate::engine::error::{DataflowError, ErrorInfo, Result};
15use crate::engine::message::{Change, Message};
16use crate::engine::utils::{get_nested_value, set_nested_value};
17use datalogic_rs::{Engine as DatalogicEngine, Logic};
18use datavalue::OwnedDataValue;
19use serde_json::Value as JsonValue;
20use std::sync::Arc;
21
22/// Per-call execution context handed to `AsyncFunctionHandler::execute`.
23///
24/// Borrows the message and datalogic engine for the duration of the handler
25/// call; collects `Change` entries that the workflow executor folds into the
26/// audit trail when the handler returns. Drop semantics are trivial — there
27/// is nothing to flush; the executor extracts the buffered changes via
28/// `into_changes()`.
29pub struct TaskContext<'a> {
30    message: &'a mut Message,
31    datalogic: &'a Arc<DatalogicEngine>,
32    /// Changes accumulated through the `set*` family. Only populated when
33    /// `message.capture_changes` is true; otherwise pushes are no-ops to
34    /// keep the bulk-pipeline fast path allocation-free.
35    changes: Vec<Change>,
36    /// Who is executing, when the engine built this context.
37    ///
38    /// Borrowed rather than `Arc`-cloned: the ids live on the `Workflow` and
39    /// `Task`, both of which outlive the dispatch call, and the accessors hand
40    /// back `&str` either way — so a refcount bump per task would buy nothing.
41    identity: Option<TaskIdentity<'a>>,
42    /// Sweep index of the enclosing looping workflow, if any.
43    loop_counter: Option<i64>,
44}
45
46/// Which task, in which workflow, the engine is currently running.
47///
48/// All-or-nothing by construction: the engine executing a task inside a
49/// workflow knows both ids, and every other path knows neither. Two separate
50/// `Option<&str>` fields would additionally allow "workflow known, task
51/// unknown" — a state that never occurs — and would be silently swappable at
52/// the call site, being adjacent and identically typed.
53#[derive(Debug, Clone, Copy)]
54pub(crate) struct TaskIdentity<'a> {
55    pub workflow_id: &'a str,
56    pub task_id: &'a str,
57}
58
59impl<'a> TaskContext<'a> {
60    /// Construct a new context. Mostly engine-internal — handlers receive a
61    /// pre-built `&mut TaskContext` from the executor — but exposed `pub` so
62    /// tests and benchmarks can drive `AsyncFunctionHandler::execute`
63    /// directly without going through `Engine::process_message`.
64    /// A context built this way reports `None` from [`Self::workflow_id`],
65    /// [`Self::task_id`] and [`Self::loop_counter`] — there is no workflow run
66    /// to describe, and inventing ids would be worse than admitting their
67    /// absence.
68    pub fn new(message: &'a mut Message, datalogic: &'a Arc<DatalogicEngine>) -> Self {
69        Self {
70            message,
71            datalogic,
72            changes: Vec::new(),
73            identity: None,
74            loop_counter: None,
75        }
76    }
77
78    /// As [`Self::new`], with the identity of the executing task.
79    ///
80    /// Used by the task executor on the dispatch path. A separate constructor
81    /// rather than setters, so there is no window in which a context exists
82    /// with half its identity filled in.
83    pub(crate) fn with_identity(
84        message: &'a mut Message,
85        datalogic: &'a Arc<DatalogicEngine>,
86        identity: Option<TaskIdentity<'a>>,
87        loop_counter: Option<i64>,
88    ) -> Self {
89        Self {
90            message,
91            datalogic,
92            changes: Vec::new(),
93            identity,
94            loop_counter,
95        }
96    }
97
98    /// Id of the workflow being executed, when the engine built this context.
99    ///
100    /// `None` for a context built with [`Self::new`] — a test or benchmark
101    /// driving a handler directly is not inside a workflow run.
102    ///
103    /// ```
104    /// # use dataflow_rs::{TaskContext, engine::message::Message};
105    /// # use serde_json::json;
106    /// # let datalogic = std::sync::Arc::new(datalogic_rs::Engine::new());
107    /// # let mut message = Message::from_value(&json!({}));
108    /// let ctx = TaskContext::new(&mut message, &datalogic);
109    /// assert_eq!(ctx.workflow_id(), None);
110    /// ```
111    #[inline]
112    pub fn workflow_id(&self) -> Option<&str> {
113        self.identity.map(|i| i.workflow_id)
114    }
115
116    /// Id of the task being executed, when the engine built this context.
117    ///
118    /// Always a **leaf** task's id. Handlers run only on leaf tasks — a task
119    /// group is span bookkeeping recorded on the task that opens it, never a
120    /// dispatch target — so a group id can never appear here.
121    ///
122    /// `None` for a context built with [`Self::new`].
123    #[inline]
124    pub fn task_id(&self) -> Option<&str> {
125        self.identity.map(|i| i.task_id)
126    }
127
128    /// Sweep index of the enclosing looping workflow, or `None` when the
129    /// workflow does not carry a `loop`.
130    ///
131    /// This is a different fact from identity being unknown: a handler in a
132    /// non-looping workflow has both ids and no counter.
133    ///
134    /// Worth preferring over reading the counter out of `temp_data`, which
135    /// only works when the host gave `LoopConfig` a `counter` name and the
136    /// handler hardcodes that path. A loop with no named counter writes to no
137    /// path at all, and its sweep index is reachable no other way.
138    #[inline]
139    pub fn loop_counter(&self) -> Option<i64> {
140        self.loop_counter
141    }
142
143    /// Borrow the message under processing. Use this when you need to inspect
144    /// the message id, payload, or audit trail; for reading and mutating the
145    /// `data` / `metadata` / `temp_data` context, prefer the typed helpers on
146    /// `TaskContext` itself.
147    #[inline]
148    pub fn message(&self) -> &Message {
149        self.message
150    }
151
152    /// Mutable access to the message. Prefer the typed helpers (`set`,
153    /// `add_error`) over poking at `message.context` directly — direct
154    /// mutations bypass the audit trail.
155    #[inline]
156    pub fn message_mut(&mut self) -> &mut Message {
157        self.message
158    }
159
160    /// Shared datalogic engine, in case the handler needs to evaluate ad-hoc
161    /// JSONLogic. Most handlers can ignore this argument.
162    #[inline]
163    pub fn datalogic(&self) -> &Arc<DatalogicEngine> {
164        self.datalogic
165    }
166
167    /// Read-only view of `data`. Returns `&OwnedDataValue::Null` if missing
168    /// (mirrors the `Index` fallback semantics of `serde_json::Value`).
169    #[inline]
170    pub fn data(&self) -> &OwnedDataValue {
171        self.message.data()
172    }
173
174    /// Read-only view of `metadata`.
175    #[inline]
176    pub fn metadata(&self) -> &OwnedDataValue {
177        self.message.metadata()
178    }
179
180    /// Read-only view of `temp_data`.
181    #[inline]
182    pub fn temp_data(&self) -> &OwnedDataValue {
183        self.message.temp_data()
184    }
185
186    /// The full `{data, metadata, temp_data}` tree — the root every workflow
187    /// JSONLogic expression is written against.
188    ///
189    /// [`Self::data`] / [`Self::metadata`] / [`Self::temp_data`] expose the three
190    /// slots individually; this is the whole-context accessor, so handlers do not
191    /// have to reach through `ctx.message().context`.
192    ///
193    /// Note `payload` is **not** part of this tree, and therefore not part of the
194    /// JSONLogic evaluation context — `{"var": "payload.foo"}` resolves to
195    /// nothing. Parse the payload into `data` first.
196    #[inline]
197    pub fn context(&self) -> &OwnedDataValue {
198        &self.message.context
199    }
200
201    /// Evaluate a pre-compiled expression against the message context, on the
202    /// worker thread's pooled arena.
203    ///
204    /// The same path [`crate::engine::executor::evaluate_condition`] takes, but
205    /// returning the value instead of collapsing it to a bool, and surfacing
206    /// evaluation failures as `Err` instead of `false`. That difference is
207    /// deliberate: a condition that fails to evaluate should not run its task,
208    /// whereas a handler reading a config value needs to know the read failed.
209    ///
210    /// # Errors
211    ///
212    /// [`DataflowError::LogicEvaluation`] if the expression fails to evaluate.
213    pub fn eval(&self, logic: &Logic) -> Result<OwnedDataValue> {
214        crate::engine::executor::eval_to_owned(self.datalogic, logic, &self.message.context)
215            .map_err(|e| DataflowError::LogicEvaluation(e.to_string()))
216    }
217
218    /// As [`Self::eval`], projected straight from the arena to
219    /// `serde_json::Value` in one walk — no `OwnedDataValue` intermediate and no
220    /// `serde_json::from_value` rebuild.
221    pub fn eval_json(&self, logic: &Logic) -> Result<JsonValue> {
222        crate::engine::executor::eval_to_json(self.datalogic, logic, &self.message.context)
223            .map_err(|e| DataflowError::LogicEvaluation(e.to_string()))
224    }
225
226    /// As [`Self::eval`], coerced to a *plain* string: a JSON string result
227    /// yields its contents, anything else its compact JSON form.
228    ///
229    /// # This disagrees with datalogic-rs on purpose
230    ///
231    /// datalogic-rs's `String: FromDataValue` — and therefore
232    /// `Session::eval_str` — keeps the JSON quoting, so a string result comes
233    /// back from it as `"\"abc\""`. This method returns `abc`.
234    ///
235    /// The name says `plain_string` rather than `to_string` precisely so the
236    /// difference is visible at the call site: two string semantics in one
237    /// ecosystem is a footgun, and these values end up in URL paths and message
238    /// keys. A test pins both sides, so it fails if either changes.
239    pub fn eval_to_plain_string(&self, logic: &Logic) -> Result<String> {
240        crate::engine::executor::eval_to_plain_string(self.datalogic, logic, &self.message.context)
241            .map_err(|e| DataflowError::LogicEvaluation(e.to_string()))
242    }
243
244    /// Look up a value by dot-path against the full context tree (rooted at
245    /// the unified `{data, metadata, temp_data}` object). Returns `None` if
246    /// the path doesn't resolve.
247    ///
248    /// Use the same path syntax as JSONLogic: `"data.user.name"`,
249    /// `"temp_data.items.0"`, `"metadata.progress.status_code"`.
250    #[inline]
251    pub fn get(&self, path: &str) -> Option<&OwnedDataValue> {
252        get_nested_value(&self.message.context, path)
253    }
254
255    /// Set a value at a dot-path on the context. Records a `Change` on the
256    /// audit trail when `message.capture_changes` is true; otherwise the
257    /// write happens but no audit entry is buffered.
258    ///
259    /// Intermediate objects/arrays are created on demand; see
260    /// [`crate::engine::utils::set_nested_value`] for the exact semantics
261    /// (numeric segments → arrays, `#` prefix → escaped object key, etc.).
262    pub fn set(&mut self, path: &str, value: OwnedDataValue) {
263        if self.message.capture_changes {
264            let old_value = get_nested_value(&self.message.context, path)
265                .cloned()
266                .unwrap_or(OwnedDataValue::Null);
267            let new_value = value.clone();
268            self.changes.push(Change {
269                path: Arc::from(path),
270                old_value,
271                new_value,
272            });
273        }
274        set_nested_value(&mut self.message.context, path, value);
275    }
276
277    /// Same as [`Self::set`] but accepts a `serde_json::Value` (bridges
278    /// through `OwnedDataValue::from`). Convenience for handlers that
279    /// already speak `serde_json::Value`.
280    #[inline]
281    pub fn set_json(&mut self, path: &str, value: &JsonValue) {
282        self.set(path, OwnedDataValue::from(value));
283    }
284
285    /// Append an error to `message.errors`. Convenience for
286    /// `ctx.message_mut().add_error(...)`.
287    #[inline]
288    pub fn add_error(&mut self, error: ErrorInfo) {
289        self.message.add_error(error);
290    }
291
292    /// Drain the accumulated changes. The workflow executor calls this after
293    /// the handler returns to fold them into the audit trail; tests and
294    /// benchmarks driving the trait directly can use it to inspect what the
295    /// handler buffered.
296    #[inline]
297    pub fn into_changes(self) -> Vec<Change> {
298        self.changes
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use crate::engine::executor::with_arena;
306    use crate::engine::utils::set_nested_value;
307    use serde_json::json;
308
309    fn dv(v: serde_json::Value) -> OwnedDataValue {
310        OwnedDataValue::from(&v)
311    }
312
313    fn engine() -> Arc<DatalogicEngine> {
314        Arc::new(DatalogicEngine::builder().with_templating(true).build())
315    }
316
317    /// A message with one key in each of the three context slots.
318    fn populated() -> Message {
319        let mut m = Message::from_value(&json!({"payload_key": "payload_value"}));
320        set_nested_value(&mut m.context, "data.x", dv(json!("dx")));
321        set_nested_value(&mut m.context, "metadata.x", dv(json!("mx")));
322        set_nested_value(&mut m.context, "temp_data.x", dv(json!("tx")));
323        m
324    }
325
326    #[test]
327    fn context_matches_the_three_slot_accessors() {
328        let mut m = populated();
329        let dl = engine();
330        let ctx = TaskContext::new(&mut m, &dl);
331
332        let whole = ctx.context();
333        assert_eq!(&whole["data"], ctx.data());
334        assert_eq!(&whole["metadata"], ctx.metadata());
335        assert_eq!(&whole["temp_data"], ctx.temp_data());
336        assert_eq!(whole, &ctx.message().context);
337    }
338
339    #[test]
340    fn eval_roots_at_the_unified_context_not_data_alone() {
341        let mut m = populated();
342        let dl = engine();
343        let ctx = TaskContext::new(&mut m, &dl);
344
345        for (path, expected) in [
346            ("data.x", "dx"),
347            ("metadata.x", "mx"),
348            ("temp_data.x", "tx"),
349        ] {
350            let logic = dl.compile_arc(&json!({"var": path})).unwrap();
351            assert_eq!(ctx.eval(&logic).unwrap(), dv(json!(expected)));
352            assert_eq!(ctx.eval_json(&logic).unwrap(), json!(expected));
353            assert_eq!(ctx.eval_to_plain_string(&logic).unwrap(), expected);
354        }
355    }
356
357    #[test]
358    fn payload_is_not_in_the_eval_context() {
359        // Stays true through the new surface: `payload` is a separate field on
360        // Message and never part of the JSONLogic root.
361        let mut m = populated();
362        let dl = engine();
363        let ctx = TaskContext::new(&mut m, &dl);
364
365        let logic = dl
366            .compile_arc(&json!({"var": "payload.payload_key"}))
367            .unwrap();
368        assert_eq!(ctx.eval(&logic).unwrap(), OwnedDataValue::Null);
369        assert_eq!(ctx.eval_json(&logic).unwrap(), serde_json::Value::Null);
370        assert_eq!(ctx.eval_to_plain_string(&logic).unwrap(), "null");
371    }
372
373    #[test]
374    fn eval_json_covers_every_result_kind() {
375        let mut m = Message::from_value(&json!({}));
376        let dl = engine();
377        let ctx = TaskContext::new(&mut m, &dl);
378
379        for expected in [
380            json!(null),
381            json!(true),
382            json!(42),
383            json!(1.5),
384            json!("abc"),
385            json!([1, 2]),
386            json!({"a": 1}),
387            json!({"a": [1, {"b": "c"}], "d": {"e": [true, null]}}),
388        ] {
389            let logic = dl.compile_arc(&expected).unwrap();
390            assert_eq!(
391                ctx.eval_json(&logic).unwrap(),
392                expected,
393                "round-trip for {expected}"
394            );
395            // The owned and JSON projections agree on the same result.
396            assert_eq!(
397                serde_json::Value::from(&ctx.eval(&logic).unwrap()),
398                expected
399            );
400        }
401    }
402
403    #[test]
404    fn eval_to_plain_string_unquotes_strings_and_compacts_the_rest() {
405        let mut m = Message::from_value(&json!({}));
406        let dl = engine();
407        let ctx = TaskContext::new(&mut m, &dl);
408
409        let cases = [
410            (json!("abc"), "abc"),
411            (json!(""), ""),
412            (json!(null), "null"),
413            (json!(true), "true"),
414            (json!(42), "42"),
415            (json!({"a": 1}), "{\"a\":1}"),
416            (json!([1, 2]), "[1,2]"),
417        ];
418        for (input, expected) in cases {
419            let logic = dl.compile_arc(&input).unwrap();
420            assert_eq!(
421                ctx.eval_to_plain_string(&logic).unwrap(),
422                expected,
423                "for {input}"
424            );
425        }
426    }
427
428    #[test]
429    fn eval_to_plain_string_diverges_from_datalogics_own_string_projection() {
430        // This test IS the documentation of the divergence — it must fail if
431        // either side changes. datalogic-rs's `String: FromDataValue` keeps the
432        // JSON quoting; ours does not.
433        let mut m = Message::from_value(&json!({}));
434        let dl = engine();
435        let ctx = TaskContext::new(&mut m, &dl);
436
437        // Non-ASCII plus an embedded quote, to cover escaping too.
438        let raw = "héllo \"world\" 世界";
439        let logic = dl.compile_arc(&json!(raw)).unwrap();
440
441        // Ours: contents, byte-identical.
442        assert_eq!(ctx.eval_to_plain_string(&logic).unwrap(), raw);
443
444        // datalogic's: JSON-quoted and escaped.
445        let via_session = dl.session().eval_str(&logic, &m.context).unwrap();
446        assert_ne!(
447            via_session, raw,
448            "if these agree, the divergence this method exists for is gone"
449        );
450        assert!(
451            via_session.starts_with('"') && via_session.contains("\\\""),
452            "datalogic keeps the quoting and escaping, got: {via_session}"
453        );
454    }
455
456    #[test]
457    fn eval_surfaces_an_error_where_evaluate_condition_returns_false() {
458        // Asserted together so the difference is intentional and visible: a
459        // condition that fails should not run its task; a handler reading a
460        // config value needs to know the read failed.
461        let mut m = Message::from_value(&json!({}));
462        let dl = engine();
463
464        // `+` over a non-numeric operand fails to evaluate.
465        let bad = dl.compile_arc(&json!({"+": ["abc", 1]})).unwrap();
466
467        let condition_result =
468            crate::engine::executor::evaluate_condition(&dl, Some(&bad), &m.context);
469
470        let ctx = TaskContext::new(&mut m, &dl);
471        let eval_result = ctx.eval(&bad);
472
473        match (&condition_result, &eval_result) {
474            (Ok(false), Err(DataflowError::LogicEvaluation(msg))) => {
475                assert!(!msg.is_empty(), "the error message must not be empty");
476            }
477            other => panic!(
478                "expected evaluate_condition Ok(false) alongside eval Err(LogicEvaluation), got {other:?}"
479            ),
480        }
481    }
482
483    #[test]
484    fn consecutive_evals_and_interleaved_sets_both_work() {
485        // The arena is rewound between calls, not corrupted; and an eval between
486        // two `set`s leaves the buffered Changes intact.
487        let mut m = populated();
488        let dl = engine();
489        let first = dl.compile_arc(&json!({"var": "data.x"})).unwrap();
490        let second = dl.compile_arc(&json!({"var": "metadata.x"})).unwrap();
491
492        let mut ctx = TaskContext::new(&mut m, &dl);
493
494        assert_eq!(ctx.eval_json(&first).unwrap(), json!("dx"));
495        assert_eq!(ctx.eval_json(&second).unwrap(), json!("mx"));
496        assert_eq!(ctx.eval_json(&first).unwrap(), json!("dx"));
497
498        ctx.set("data.written", dv(json!(1)));
499        assert_eq!(ctx.eval_json(&first).unwrap(), json!("dx"));
500        ctx.set("data.written2", dv(json!(2)));
501
502        let changes = ctx.into_changes();
503        let paths: Vec<&str> = changes.iter().map(|c| &*c.path).collect();
504        assert_eq!(paths, vec!["data.written", "data.written2"]);
505    }
506
507    #[test]
508    fn eval_inside_a_with_arena_scope_falls_back_instead_of_panicking() {
509        // `TaskContext::new` is pub so a test or bench can construct one inside a
510        // `with_arena` closure. The `try_borrow_mut` fallback makes that return
511        // `Ok` on a fresh Bump rather than panicking out of the arena scope.
512        let mut m = populated();
513        let dl = engine();
514        let logic = dl.compile_arc(&json!({"var": "data.x"})).unwrap();
515
516        let got = with_arena(|_| {
517            let ctx = TaskContext::new(&mut m, &dl);
518            ctx.eval_json(&logic)
519        });
520
521        assert_eq!(got.unwrap(), json!("dx"));
522    }
523}