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}