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