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}