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}