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}
37
38impl<'a> TaskContext<'a> {
39    /// Construct a new context. Mostly engine-internal — handlers receive a
40    /// pre-built `&mut TaskContext` from the executor — but exposed `pub` so
41    /// tests and benchmarks can drive `AsyncFunctionHandler::execute`
42    /// directly without going through `Engine::process_message`.
43    pub fn new(message: &'a mut Message, datalogic: &'a Arc<DatalogicEngine>) -> Self {
44        Self {
45            message,
46            datalogic,
47            changes: Vec::new(),
48        }
49    }
50
51    /// Borrow the message under processing. Use this when you need to inspect
52    /// the message id, payload, or audit trail; for reading and mutating the
53    /// `data` / `metadata` / `temp_data` context, prefer the typed helpers on
54    /// `TaskContext` itself.
55    #[inline]
56    pub fn message(&self) -> &Message {
57        self.message
58    }
59
60    /// Mutable access to the message. Prefer the typed helpers (`set`,
61    /// `add_error`) over poking at `message.context` directly — direct
62    /// mutations bypass the audit trail.
63    #[inline]
64    pub fn message_mut(&mut self) -> &mut Message {
65        self.message
66    }
67
68    /// Shared datalogic engine, in case the handler needs to evaluate ad-hoc
69    /// JSONLogic. Most handlers can ignore this argument.
70    #[inline]
71    pub fn datalogic(&self) -> &Arc<DatalogicEngine> {
72        self.datalogic
73    }
74
75    /// Read-only view of `data`. Returns `&OwnedDataValue::Null` if missing
76    /// (mirrors the `Index` fallback semantics of `serde_json::Value`).
77    #[inline]
78    pub fn data(&self) -> &OwnedDataValue {
79        self.message.data()
80    }
81
82    /// Read-only view of `metadata`.
83    #[inline]
84    pub fn metadata(&self) -> &OwnedDataValue {
85        self.message.metadata()
86    }
87
88    /// Read-only view of `temp_data`.
89    #[inline]
90    pub fn temp_data(&self) -> &OwnedDataValue {
91        self.message.temp_data()
92    }
93
94    /// The full `{data, metadata, temp_data}` tree — the root every workflow
95    /// JSONLogic expression is written against.
96    ///
97    /// [`Self::data`] / [`Self::metadata`] / [`Self::temp_data`] expose the three
98    /// slots individually; this is the whole-context accessor, so handlers do not
99    /// have to reach through `ctx.message().context`.
100    ///
101    /// Note `payload` is **not** part of this tree, and therefore not part of the
102    /// JSONLogic evaluation context — `{"var": "payload.foo"}` resolves to
103    /// nothing. Parse the payload into `data` first.
104    #[inline]
105    pub fn context(&self) -> &OwnedDataValue {
106        &self.message.context
107    }
108
109    /// Evaluate a pre-compiled expression against the message context, on the
110    /// worker thread's pooled arena.
111    ///
112    /// The same path [`crate::engine::executor::evaluate_condition`] takes, but
113    /// returning the value instead of collapsing it to a bool, and surfacing
114    /// evaluation failures as `Err` instead of `false`. That difference is
115    /// deliberate: a condition that fails to evaluate should not run its task,
116    /// whereas a handler reading a config value needs to know the read failed.
117    ///
118    /// # Errors
119    ///
120    /// [`DataflowError::LogicEvaluation`] if the expression fails to evaluate.
121    pub fn eval(&self, logic: &Logic) -> Result<OwnedDataValue> {
122        crate::engine::executor::eval_to_owned(self.datalogic, logic, &self.message.context)
123            .map_err(|e| DataflowError::LogicEvaluation(e.to_string()))
124    }
125
126    /// As [`Self::eval`], projected straight from the arena to
127    /// `serde_json::Value` in one walk — no `OwnedDataValue` intermediate and no
128    /// `serde_json::from_value` rebuild.
129    pub fn eval_json(&self, logic: &Logic) -> Result<JsonValue> {
130        crate::engine::executor::eval_to_json(self.datalogic, logic, &self.message.context)
131            .map_err(|e| DataflowError::LogicEvaluation(e.to_string()))
132    }
133
134    /// As [`Self::eval`], coerced to a *plain* string: a JSON string result
135    /// yields its contents, anything else its compact JSON form.
136    ///
137    /// # This disagrees with datalogic-rs on purpose
138    ///
139    /// datalogic-rs's `String: FromDataValue` — and therefore
140    /// `Session::eval_str` — keeps the JSON quoting, so a string result comes
141    /// back from it as `"\"abc\""`. This method returns `abc`.
142    ///
143    /// The name says `plain_string` rather than `to_string` precisely so the
144    /// difference is visible at the call site: two string semantics in one
145    /// ecosystem is a footgun, and these values end up in URL paths and message
146    /// keys. A test pins both sides, so it fails if either changes.
147    pub fn eval_to_plain_string(&self, logic: &Logic) -> Result<String> {
148        crate::engine::executor::eval_to_plain_string(self.datalogic, logic, &self.message.context)
149            .map_err(|e| DataflowError::LogicEvaluation(e.to_string()))
150    }
151
152    /// Look up a value by dot-path against the full context tree (rooted at
153    /// the unified `{data, metadata, temp_data}` object). Returns `None` if
154    /// the path doesn't resolve.
155    ///
156    /// Use the same path syntax as JSONLogic: `"data.user.name"`,
157    /// `"temp_data.items.0"`, `"metadata.progress.status_code"`.
158    #[inline]
159    pub fn get(&self, path: &str) -> Option<&OwnedDataValue> {
160        get_nested_value(&self.message.context, path)
161    }
162
163    /// Set a value at a dot-path on the context. Records a `Change` on the
164    /// audit trail when `message.capture_changes` is true; otherwise the
165    /// write happens but no audit entry is buffered.
166    ///
167    /// Intermediate objects/arrays are created on demand; see
168    /// [`crate::engine::utils::set_nested_value`] for the exact semantics
169    /// (numeric segments → arrays, `#` prefix → escaped object key, etc.).
170    pub fn set(&mut self, path: &str, value: OwnedDataValue) {
171        if self.message.capture_changes {
172            let old_value = get_nested_value(&self.message.context, path)
173                .cloned()
174                .unwrap_or(OwnedDataValue::Null);
175            let new_value = value.clone();
176            self.changes.push(Change {
177                path: Arc::from(path),
178                old_value,
179                new_value,
180            });
181        }
182        set_nested_value(&mut self.message.context, path, value);
183    }
184
185    /// Same as [`Self::set`] but accepts a `serde_json::Value` (bridges
186    /// through `OwnedDataValue::from`). Convenience for handlers that
187    /// already speak `serde_json::Value`.
188    #[inline]
189    pub fn set_json(&mut self, path: &str, value: &JsonValue) {
190        self.set(path, OwnedDataValue::from(value));
191    }
192
193    /// Append an error to `message.errors`. Convenience for
194    /// `ctx.message_mut().add_error(...)`.
195    #[inline]
196    pub fn add_error(&mut self, error: ErrorInfo) {
197        self.message.add_error(error);
198    }
199
200    /// Drain the accumulated changes. The workflow executor calls this after
201    /// the handler returns to fold them into the audit trail; tests and
202    /// benchmarks driving the trait directly can use it to inspect what the
203    /// handler buffered.
204    #[inline]
205    pub fn into_changes(self) -> Vec<Change> {
206        self.changes
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::engine::executor::with_arena;
214    use crate::engine::utils::set_nested_value;
215    use serde_json::json;
216
217    fn dv(v: serde_json::Value) -> OwnedDataValue {
218        OwnedDataValue::from(&v)
219    }
220
221    fn engine() -> Arc<DatalogicEngine> {
222        Arc::new(DatalogicEngine::builder().with_templating(true).build())
223    }
224
225    /// A message with one key in each of the three context slots.
226    fn populated() -> Message {
227        let mut m = Message::from_value(&json!({"payload_key": "payload_value"}));
228        set_nested_value(&mut m.context, "data.x", dv(json!("dx")));
229        set_nested_value(&mut m.context, "metadata.x", dv(json!("mx")));
230        set_nested_value(&mut m.context, "temp_data.x", dv(json!("tx")));
231        m
232    }
233
234    #[test]
235    fn context_matches_the_three_slot_accessors() {
236        let mut m = populated();
237        let dl = engine();
238        let ctx = TaskContext::new(&mut m, &dl);
239
240        let whole = ctx.context();
241        assert_eq!(&whole["data"], ctx.data());
242        assert_eq!(&whole["metadata"], ctx.metadata());
243        assert_eq!(&whole["temp_data"], ctx.temp_data());
244        assert_eq!(whole, &ctx.message().context);
245    }
246
247    #[test]
248    fn eval_roots_at_the_unified_context_not_data_alone() {
249        let mut m = populated();
250        let dl = engine();
251        let ctx = TaskContext::new(&mut m, &dl);
252
253        for (path, expected) in [
254            ("data.x", "dx"),
255            ("metadata.x", "mx"),
256            ("temp_data.x", "tx"),
257        ] {
258            let logic = dl.compile_arc(&json!({"var": path})).unwrap();
259            assert_eq!(ctx.eval(&logic).unwrap(), dv(json!(expected)));
260            assert_eq!(ctx.eval_json(&logic).unwrap(), json!(expected));
261            assert_eq!(ctx.eval_to_plain_string(&logic).unwrap(), expected);
262        }
263    }
264
265    #[test]
266    fn payload_is_not_in_the_eval_context() {
267        // Stays true through the new surface: `payload` is a separate field on
268        // Message and never part of the JSONLogic root.
269        let mut m = populated();
270        let dl = engine();
271        let ctx = TaskContext::new(&mut m, &dl);
272
273        let logic = dl
274            .compile_arc(&json!({"var": "payload.payload_key"}))
275            .unwrap();
276        assert_eq!(ctx.eval(&logic).unwrap(), OwnedDataValue::Null);
277        assert_eq!(ctx.eval_json(&logic).unwrap(), serde_json::Value::Null);
278        assert_eq!(ctx.eval_to_plain_string(&logic).unwrap(), "null");
279    }
280
281    #[test]
282    fn eval_json_covers_every_result_kind() {
283        let mut m = Message::from_value(&json!({}));
284        let dl = engine();
285        let ctx = TaskContext::new(&mut m, &dl);
286
287        for expected in [
288            json!(null),
289            json!(true),
290            json!(42),
291            json!(1.5),
292            json!("abc"),
293            json!([1, 2]),
294            json!({"a": 1}),
295            json!({"a": [1, {"b": "c"}], "d": {"e": [true, null]}}),
296        ] {
297            let logic = dl.compile_arc(&expected).unwrap();
298            assert_eq!(
299                ctx.eval_json(&logic).unwrap(),
300                expected,
301                "round-trip for {expected}"
302            );
303            // The owned and JSON projections agree on the same result.
304            assert_eq!(
305                serde_json::Value::from(&ctx.eval(&logic).unwrap()),
306                expected
307            );
308        }
309    }
310
311    #[test]
312    fn eval_to_plain_string_unquotes_strings_and_compacts_the_rest() {
313        let mut m = Message::from_value(&json!({}));
314        let dl = engine();
315        let ctx = TaskContext::new(&mut m, &dl);
316
317        let cases = [
318            (json!("abc"), "abc"),
319            (json!(""), ""),
320            (json!(null), "null"),
321            (json!(true), "true"),
322            (json!(42), "42"),
323            (json!({"a": 1}), "{\"a\":1}"),
324            (json!([1, 2]), "[1,2]"),
325        ];
326        for (input, expected) in cases {
327            let logic = dl.compile_arc(&input).unwrap();
328            assert_eq!(
329                ctx.eval_to_plain_string(&logic).unwrap(),
330                expected,
331                "for {input}"
332            );
333        }
334    }
335
336    #[test]
337    fn eval_to_plain_string_diverges_from_datalogics_own_string_projection() {
338        // This test IS the documentation of the divergence — it must fail if
339        // either side changes. datalogic-rs's `String: FromDataValue` keeps the
340        // JSON quoting; ours does not.
341        let mut m = Message::from_value(&json!({}));
342        let dl = engine();
343        let ctx = TaskContext::new(&mut m, &dl);
344
345        // Non-ASCII plus an embedded quote, to cover escaping too.
346        let raw = "héllo \"world\" 世界";
347        let logic = dl.compile_arc(&json!(raw)).unwrap();
348
349        // Ours: contents, byte-identical.
350        assert_eq!(ctx.eval_to_plain_string(&logic).unwrap(), raw);
351
352        // datalogic's: JSON-quoted and escaped.
353        let via_session = dl.session().eval_str(&logic, &m.context).unwrap();
354        assert_ne!(
355            via_session, raw,
356            "if these agree, the divergence this method exists for is gone"
357        );
358        assert!(
359            via_session.starts_with('"') && via_session.contains("\\\""),
360            "datalogic keeps the quoting and escaping, got: {via_session}"
361        );
362    }
363
364    #[test]
365    fn eval_surfaces_an_error_where_evaluate_condition_returns_false() {
366        // Asserted together so the difference is intentional and visible: a
367        // condition that fails should not run its task; a handler reading a
368        // config value needs to know the read failed.
369        let mut m = Message::from_value(&json!({}));
370        let dl = engine();
371
372        // `+` over a non-numeric operand fails to evaluate.
373        let bad = dl.compile_arc(&json!({"+": ["abc", 1]})).unwrap();
374
375        let condition_result =
376            crate::engine::executor::evaluate_condition(&dl, Some(&bad), &m.context);
377
378        let ctx = TaskContext::new(&mut m, &dl);
379        let eval_result = ctx.eval(&bad);
380
381        match (&condition_result, &eval_result) {
382            (Ok(false), Err(DataflowError::LogicEvaluation(msg))) => {
383                assert!(!msg.is_empty(), "the error message must not be empty");
384            }
385            other => panic!(
386                "expected evaluate_condition Ok(false) alongside eval Err(LogicEvaluation), got {other:?}"
387            ),
388        }
389    }
390
391    #[test]
392    fn consecutive_evals_and_interleaved_sets_both_work() {
393        // The arena is rewound between calls, not corrupted; and an eval between
394        // two `set`s leaves the buffered Changes intact.
395        let mut m = populated();
396        let dl = engine();
397        let first = dl.compile_arc(&json!({"var": "data.x"})).unwrap();
398        let second = dl.compile_arc(&json!({"var": "metadata.x"})).unwrap();
399
400        let mut ctx = TaskContext::new(&mut m, &dl);
401
402        assert_eq!(ctx.eval_json(&first).unwrap(), json!("dx"));
403        assert_eq!(ctx.eval_json(&second).unwrap(), json!("mx"));
404        assert_eq!(ctx.eval_json(&first).unwrap(), json!("dx"));
405
406        ctx.set("data.written", dv(json!(1)));
407        assert_eq!(ctx.eval_json(&first).unwrap(), json!("dx"));
408        ctx.set("data.written2", dv(json!(2)));
409
410        let changes = ctx.into_changes();
411        let paths: Vec<&str> = changes.iter().map(|c| &*c.path).collect();
412        assert_eq!(paths, vec!["data.written", "data.written2"]);
413    }
414
415    #[test]
416    fn eval_inside_a_with_arena_scope_falls_back_instead_of_panicking() {
417        // `TaskContext::new` is pub so a test or bench can construct one inside a
418        // `with_arena` closure. The `try_borrow_mut` fallback makes that return
419        // `Ok` on a fresh Bump rather than panicking out of the arena scope.
420        let mut m = populated();
421        let dl = engine();
422        let logic = dl.compile_arc(&json!({"var": "data.x"})).unwrap();
423
424        let got = with_arena(|_| {
425            let ctx = TaskContext::new(&mut m, &dl);
426            ctx.eval_json(&logic)
427        });
428
429        assert_eq!(got.unwrap(), json!("dx"));
430    }
431}