dataflow_rs/engine/executor.rs
1//! # Evaluation Primitives
2//!
3//! Free functions and types that support JSONLogic evaluation in the engine.
4//! Built-in function execution lives on each config type (`MapConfig::execute`,
5//! `ValidationConfig::execute`, …) — this module just provides the shared
6//! evaluation machinery they all build on.
7//!
8//! The bump arena is held in a thread-local cell on each Tokio worker. Per
9//! call, the arena is rewound via `Bump::reset` (constant-time, retains chunks)
10//! before the eval. Chunks accumulate to fit the workload's high-water mark
11//! and persist across calls — no per-task malloc/free churn. Profiling
12//! showed per-task `Bump::with_capacity` malloc was the dominant cost when
13//! arena sizing was tuned for realistic workloads; thread-local reuse
14//! amortizes that to zero in steady state.
15//!
16//! `ArenaContext` (below) extends this further for **mutating** tasks (map):
17//! the message context is `to_arena`'d once per task call into a depth‑2
18//! cache, and subsequent writes only re‑arena the dirtied subtree — typically
19//! `data.MT103` while the heavy `data.input` stays cached.
20
21use crate::engine::error::Result;
22use bumpalo::Bump;
23use datalogic_rs::{Engine, Logic};
24use datavalue::{DataValue, OwnedDataValue};
25use log::error;
26use std::cell::RefCell;
27use std::sync::Arc;
28
29/// Initial bump arena capacity per worker thread. Sized to cover a realistic
30/// ISO-20022-shaped payload's `to_arena` deep-clone in one shot, so the first
31/// few calls on each thread don't trigger `Bump::new_chunk`. After that the
32/// chunks persist across calls and the capacity is irrelevant.
33const ARENA_INITIAL_CAPACITY: usize = 128 * 1024;
34
35thread_local! {
36 /// Per-worker-thread bump arena. `Engine` and `Arc<Logic>` are `Send + Sync`
37 /// and shared across threads; `Bump` is `!Send` so it lives here for
38 /// zero-contention scratch space. Chunks accumulate over the thread's
39 /// lifetime and `reset()` rewinds the pointer without freeing chunks back
40 /// to the OS — steady-state allocator pressure is zero.
41 static EVAL_ARENA: RefCell<Bump> = RefCell::new(Bump::with_capacity(ARENA_INITIAL_CAPACITY));
42}
43
44/// Evaluate `compiled` against `context` using the worker thread's bump
45/// arena, returning the result as an owned `OwnedDataValue`. The arena is
46/// rewound before the call so peak memory is bounded by the single largest
47/// evaluation; chunks persist across calls so steady-state allocation is zero.
48///
49/// Use this for one-shot evals where the context isn't reused across
50/// multiple JSONLogic calls (e.g. a single condition check). For batches of
51/// read-only evals against the same context (validation, log) use
52/// [`with_arena`] and convert the context once via
53/// [`datavalue::OwnedDataValue::to_arena`].
54#[inline]
55pub(crate) fn eval_to_owned(
56 engine: &Engine,
57 compiled: &Logic,
58 context: &OwnedDataValue,
59) -> std::result::Result<OwnedDataValue, datalogic_rs::Error> {
60 with_eval_arena(|arena| {
61 let r = engine.evaluate(compiled, context, arena)?;
62 Ok(r.to_owned())
63 })
64}
65
66/// As [`eval_to_owned`] but projects the arena result directly to
67/// `serde_json::Value` — one walk out, with no `OwnedDataValue` intermediate
68/// and no `serde_json::from_value` rebuild.
69#[inline]
70pub(crate) fn eval_to_json(
71 engine: &Engine,
72 compiled: &Logic,
73 context: &OwnedDataValue,
74) -> std::result::Result<serde_json::Value, datalogic_rs::Error> {
75 with_eval_arena(|arena| Ok(engine.evaluate(compiled, context, arena)?.to_serde_value()))
76}
77
78/// As [`eval_to_owned`] but coerced to a *plain* string: a `DataValue::String`
79/// yields its contents, everything else its compact JSON form (`Display` on
80/// `DataValue` is compact JSON).
81///
82/// This deliberately disagrees with datalogic-rs's `String: FromDataValue` — and
83/// therefore with `Session::eval_str` — which keeps the JSON quoting, so a string
84/// result there comes back as `"\"abc\""`. The divergence is pinned by a test.
85#[inline]
86pub(crate) fn eval_to_plain_string(
87 engine: &Engine,
88 compiled: &Logic,
89 context: &OwnedDataValue,
90) -> std::result::Result<String, datalogic_rs::Error> {
91 with_eval_arena(|arena| {
92 Ok(match engine.evaluate(compiled, context, arena)? {
93 DataValue::String(s) => s.to_string(),
94 other => other.to_string(),
95 })
96 })
97}
98
99/// Run `f` against the worker thread's rewound arena, falling back to a fresh
100/// `Bump` if the thread-local is already borrowed.
101///
102/// The fallback exists for re-entrancy. The engine's own paths never nest:
103/// `next_async_boundary` keeps every non-sync-builtin task out of the
104/// `run_sync_stretch` arena block, and the only in-crate `TaskContext::new` call
105/// site sits at an `.await` outside every `with_arena` scope. But
106/// `TaskContext::new` is `pub` precisely so tests and benches can drive a handler
107/// directly, and one of those *can* be written inside a `with_arena` closure.
108/// Paying one allocation there is better than panicking out of
109/// `process_message`.
110///
111/// Note [`with_arena`] deliberately does **not** do this: a nested batch scope
112/// would be an engine bug, and the panic is the right signal for it.
113#[inline]
114fn with_eval_arena<R>(f: impl FnOnce(&Bump) -> R) -> R {
115 EVAL_ARENA.with(|cell| match cell.try_borrow_mut() {
116 Ok(mut arena) => {
117 arena.reset();
118 f(&arena)
119 }
120 Err(_) => {
121 let arena = Bump::new();
122 f(&arena)
123 }
124 })
125}
126
127/// Run `f` with the worker thread's bump arena rewound. The closure receives
128/// the `Bump` and can amortize work across multiple `engine.evaluate` calls
129/// by converting the input context to `DataValue` once and reusing it. Use
130/// this for batches of read-only evals against the same context (validation,
131/// log) — it skips the per-eval `to_arena` deep-clone that dominates
132/// realistic profile.
133#[inline]
134pub(crate) fn with_arena<R>(f: impl FnOnce(&Bump) -> R) -> R {
135 EVAL_ARENA.with(|cell| {
136 let mut arena = cell.borrow_mut();
137 arena.reset();
138 f(&arena)
139 })
140}
141
142/// Depth‑2 arena cache for a `Message.context` (always an
143/// `OwnedDataValue::Object`).
144///
145/// Built once at the top of a mutating task call, then mutated in place as
146/// the task writes back into `message.context`. Writes at path `a.b.X`
147/// invalidate only the `(a, b)` arena slot — `data.input` stays cached
148/// across the entire map task even while `data.MT103.*` is being written.
149///
150/// **Lifetime model.** All arena allocations come out of the borrowed `Bump`.
151/// `top_keys` / `top_values` / `depth2` are owned `Vec`s so we can mutate
152/// them freely; the `DataValue<'a>` slice handed to `engine.evaluate` is a
153/// fresh `arena.alloc_slice_copy` per call, so it stays valid for that eval
154/// regardless of subsequent mutations.
155pub(crate) struct ArenaContext<'a> {
156 arena: &'a Bump,
157 /// Top-level slot keys, arena-allocated `&'a str`.
158 top_keys: Vec<&'a str>,
159 /// Top-level slot values. When a slot's owned value is an `Object`, the
160 /// corresponding `top_values[i]` is `DataValue::Object(&'a [...])` whose
161 /// slice was minted from `depth2[i]` via `alloc_slice_copy`. When not an
162 /// Object, `depth2[i] = None` and `top_values[i]` is the full arena form.
163 top_values: Vec<DataValue<'a>>,
164 /// Depth‑2 cache, parallel to `top_keys`. `None` for non‑Object top slots.
165 depth2: Vec<Option<Depth2Cache<'a>>>,
166}
167
168struct Depth2Cache<'a> {
169 keys: Vec<&'a str>,
170 values: Vec<DataValue<'a>>,
171}
172
173impl<'a> ArenaContext<'a> {
174 /// Build from an `OwnedDataValue` context (which should be the canonical
175 /// `Object { data, metadata, temp_data }` shape). Deep-walks the owned
176 /// tree exactly once; subsequent reads / mutations are O(touched slot).
177 pub fn from_owned(ctx: &OwnedDataValue, arena: &'a Bump) -> Self {
178 let mut top_keys: Vec<&'a str> = Vec::with_capacity(4);
179 let mut top_values: Vec<DataValue<'a>> = Vec::with_capacity(4);
180 let mut depth2: Vec<Option<Depth2Cache<'a>>> = Vec::with_capacity(4);
181
182 if let OwnedDataValue::Object(pairs) = ctx {
183 for (k, v) in pairs {
184 top_keys.push(arena.alloc_str(k));
185 match v {
186 OwnedDataValue::Object(children) => {
187 let mut d2_keys: Vec<&'a str> = Vec::with_capacity(children.len());
188 let mut d2_values: Vec<DataValue<'a>> = Vec::with_capacity(children.len());
189 for (ck, cv) in children {
190 d2_keys.push(arena.alloc_str(ck));
191 d2_values.push(cv.to_arena(arena));
192 }
193 let slice = build_object_slice(arena, &d2_keys, &d2_values);
194 top_values.push(DataValue::Object(slice));
195 depth2.push(Some(Depth2Cache {
196 keys: d2_keys,
197 values: d2_values,
198 }));
199 }
200 _ => {
201 top_values.push(v.to_arena(arena));
202 depth2.push(None);
203 }
204 }
205 }
206 }
207
208 Self {
209 arena,
210 top_keys,
211 top_values,
212 depth2,
213 }
214 }
215
216 /// Build an arena `DataValue::Object` for the current state. The returned
217 /// slice is freshly allocated in the arena and stays valid for the caller
218 /// to pass into `engine.evaluate`; later mutations on `self` allocate a
219 /// new slice on the next call.
220 pub fn as_data_value(&self) -> DataValue<'a> {
221 let slice = build_object_slice(self.arena, &self.top_keys, &self.top_values);
222 DataValue::Object(slice)
223 }
224
225 /// Borrow the underlying arena — needed by callers that want to allocate
226 /// or evaluate into the same `Bump` (e.g. `engine.evaluate(...)`).
227 #[inline]
228 pub fn arena(&self) -> &'a Bump {
229 self.arena
230 }
231
232 /// Apply an owned write at `path` (pre-split into `parts`) to *both* the
233 /// underlying `OwnedDataValue` context (via the supplied closure that
234 /// performs the in-place mutation) and the arena cache, using the
235 /// already-arena-resident eval result `value_av` for the cache side.
236 ///
237 /// The cache update splices `value_av` into the depth-2 slot directly
238 /// (rebuilding only the spine of the written path — shallow pair copies,
239 /// no string data copied, no descent into unchanged siblings). The old
240 /// per-write behaviour — `to_arena` of the *entire* owned depth-2
241 /// subtree — made k mappings into the same subtree O(k²); it remains as
242 /// [`Self::refresh_after_write_parts`], the correctness backstop for any
243 /// shape the splice doesn't cover (array segments, missing depth-2 slot,
244 /// non-object hops, root writes).
245 ///
246 /// The owned-context write stays the source of truth; `value_av` must be
247 /// the arena form of the value the closure writes at `parts`.
248 pub fn apply_mutation_parts_write_through(
249 &mut self,
250 owned_ctx: &mut OwnedDataValue,
251 parts: &[Arc<str>],
252 value_av: DataValue<'a>,
253 apply: impl FnOnce(&mut OwnedDataValue),
254 ) {
255 apply(owned_ctx);
256 if !self.try_splice_write_parts(parts, value_av) {
257 self.refresh_after_write_parts(owned_ctx, parts);
258 }
259 // Differential check (unit-test builds only): after every write-through
260 // the cache must equal a from-scratch rebuild of the owned context.
261 // This gives every unit test that drives a map task free differential
262 // coverage of the splice against the owned source of truth.
263 #[cfg(test)]
264 self.assert_matches_owned(owned_ctx);
265 }
266
267 /// Attempt the plain-case arena splice for a write of `value_av` at
268 /// `parts`. Covered shape: `parts[0]` resolves to a cached top slot with
269 /// a depth-2 cache; for writes deeper than depth 2, `parts[1]` names an
270 /// *existing* depth-2 child and every deeper segment is a plain
271 /// (non-numeric) object key. Returns `false` when the shape needs the
272 /// owned-rebuild fallback instead.
273 fn try_splice_write_parts(&mut self, parts: &[Arc<str>], value_av: DataValue<'a>) -> bool {
274 if parts.len() < 2 {
275 return false;
276 }
277 // A raw segment that parses as usize takes array-index semantics in
278 // `set_nested_value_parts` — fall back rather than mirror that here.
279 // (`#`-escaped keys never parse: `#20` is the object key "20".)
280 if parts[2..].iter().any(|p| p.parse::<usize>().is_ok()) {
281 return false;
282 }
283
284 let top = strip_hash(&parts[0]);
285 let Some(top_idx) = self.top_keys.iter().position(|k| *k == top) else {
286 // Write created a brand-new top slot — rare; let the fallback
287 // build it from owned.
288 return false;
289 };
290 let arena = self.arena;
291 let Some(d2) = self.depth2[top_idx].as_mut() else {
292 // Top slot isn't an Object (or has no depth-2 cache) — the owned
293 // write may have no-op'd or reshaped it; fall back.
294 return false;
295 };
296
297 let d2_key = strip_hash(&parts[1]);
298 let d2_pos = d2.keys.iter().position(|k| *k == d2_key);
299
300 if parts.len() == 2 {
301 // Whole depth-2 slot replace/insert: the eval result IS the new
302 // child value — no owned round-trip needed.
303 match d2_pos {
304 Some(pos) => d2.values[pos] = value_av,
305 None => {
306 d2.keys.push(arena.alloc_str(d2_key));
307 d2.values.push(value_av);
308 }
309 }
310 } else {
311 let Some(pos) = d2_pos else {
312 // First write into a not-yet-cached depth-2 subtree (e.g. the
313 // first mapping into `data.MT103`) — the owned write creates
314 // it; let the fallback arena the fresh subtree once.
315 return false;
316 };
317 let Some(new_subtree) =
318 splice_object_write(arena, d2.values[pos], &parts[2..], value_av)
319 else {
320 // Non-object hop (scalar/array mid-path) — the owned write
321 // no-ops or takes semantics the splice doesn't mirror.
322 return false;
323 };
324 d2.values[pos] = new_subtree;
325 }
326
327 let slice = build_object_slice(arena, &d2.keys, &d2.values);
328 self.top_values[top_idx] = DataValue::Object(slice);
329 true
330 }
331
332 /// Test-only differential check: the live cache must be value- and
333 /// order-identical to a fresh depth-2 rebuild of `owned_ctx`.
334 #[cfg(test)]
335 fn assert_matches_owned(&self, owned_ctx: &OwnedDataValue) {
336 let rebuilt = ArenaContext::from_owned(owned_ctx, self.arena);
337 assert_eq!(
338 self.as_data_value().to_owned(),
339 rebuilt.as_data_value().to_owned(),
340 "arena cache diverged from owned context"
341 );
342 }
343
344 /// Refresh the arena slot(s) for `path` from the current `owned_ctx`,
345 /// without applying any new write. Used when a sync task mutated
346 /// `message.context` directly (e.g. `parse_json` going through legacy
347 /// helpers) and we need the arena to catch up.
348 pub fn refresh_for_path(&mut self, owned_ctx: &OwnedDataValue, path: &str) {
349 self.refresh_after_write(owned_ctx, path);
350 }
351
352 /// Pre-split variant of [`Self::refresh_for_path`] — callers holding
353 /// compiler-populated path parts (parse/publish target paths) skip the
354 /// per-call `str::split`.
355 pub fn refresh_for_path_parts(&mut self, owned_ctx: &OwnedDataValue, parts: &[Arc<str>]) {
356 self.refresh_after_write_parts(owned_ctx, parts);
357 }
358
359 /// Pre-split variant of `refresh_after_write` — same algorithm, no
360 /// per-call `str::split` walk. `parts` retains the original `#` prefix;
361 /// the hash strip is applied here at lookup so the cache key matches
362 /// what `set_nested_value_parts` actually wrote.
363 fn refresh_after_write_parts(&mut self, owned_ctx: &OwnedDataValue, parts: &[Arc<str>]) {
364 let top_raw: &str = match parts.first() {
365 Some(p) if !p.is_empty() => p,
366 _ => {
367 self.rebuild_all_from(owned_ctx);
368 return;
369 }
370 };
371 let top = top_raw.strip_prefix('#').unwrap_or(top_raw);
372 fn strip<'p>(p: &'p Arc<str>) -> &'p str {
373 let s: &'p str = p;
374 s.strip_prefix('#').unwrap_or(s)
375 }
376 let depth2_key: Option<&str> = parts.get(1).map(strip);
377 let depth3_key: Option<&str> = parts.get(2).map(strip);
378 self.refresh_after_write_inner(owned_ctx, top, depth2_key, depth3_key);
379 }
380
381 /// Refresh the arena cache after `owned_ctx` was mutated at `path`.
382 fn refresh_after_write(&mut self, owned_ctx: &OwnedDataValue, path: &str) {
383 let mut parts = path.split('.');
384 let top_raw = match parts.next() {
385 Some(p) if !p.is_empty() => p,
386 _ => {
387 self.rebuild_all_from(owned_ctx);
388 return;
389 }
390 };
391 let top = top_raw.strip_prefix('#').unwrap_or(top_raw);
392 let depth2_key = parts.next().map(|p| p.strip_prefix('#').unwrap_or(p));
393 let depth3_key = parts.next().map(|p| p.strip_prefix('#').unwrap_or(p));
394 self.refresh_after_write_inner(owned_ctx, top, depth2_key, depth3_key);
395 }
396
397 /// Shared body: walk the cache for `top` and optional `depth2_key`,
398 /// rebuilding only the dirtied slot. `depth3_key` is ignored (the
399 /// depth-3 sub-cache was tried but regressed on the realistic workload —
400 /// per-write d3 cache thrashing exceeded the savings).
401 fn refresh_after_write_inner(
402 &mut self,
403 owned_ctx: &OwnedDataValue,
404 top: &str,
405 depth2_key: Option<&str>,
406 _depth3_key: Option<&str>,
407 ) {
408 let OwnedDataValue::Object(owned_pairs) = owned_ctx else {
409 self.rebuild_all_from(owned_ctx);
410 return;
411 };
412
413 let owned_top_val = owned_pairs.iter().find(|(k, _)| k == top).map(|(_, v)| v);
414
415 let top_idx = self.top_keys.iter().position(|k| *k == top);
416
417 match (owned_top_val, top_idx) {
418 (None, Some(idx)) => {
419 // Top slot was removed from owned ctx — remove from cache.
420 self.top_keys.remove(idx);
421 self.top_values.remove(idx);
422 self.depth2.remove(idx);
423 }
424 (Some(new_val), idx_opt) => {
425 let idx = match idx_opt {
426 Some(i) => i,
427 None => {
428 self.top_keys.push(self.arena.alloc_str(top));
429 self.top_values.push(DataValue::Null);
430 self.depth2.push(None);
431 self.top_keys.len() - 1
432 }
433 };
434
435 match (new_val, depth2_key, &mut self.depth2[idx]) {
436 // Depth-2 write into an existing Object top slot that already
437 // has a depth-2 cache → refresh only the child.
438 (OwnedDataValue::Object(new_children), Some(child_key), Some(d2)) => {
439 if let Some(new_child) = new_children
440 .iter()
441 .find(|(k, _)| k == child_key)
442 .map(|(_, v)| v)
443 {
444 // Replace or insert the single child slot.
445 let child_arena = new_child.to_arena(self.arena);
446 if let Some(pos) = d2.keys.iter().position(|k| *k == child_key) {
447 d2.values[pos] = child_arena;
448 } else {
449 d2.keys.push(self.arena.alloc_str(child_key));
450 d2.values.push(child_arena);
451 }
452 // Also reflect deletions of *other* depth-2 keys
453 // (rare but possible if the write replaced the
454 // whole top object). Cheap O(n) scan.
455 if d2.keys.len() != new_children.len() {
456 // Owned children diverged from our cache —
457 // rebuild the depth-2 cache from owned.
458 self.rebuild_top_slot(owned_top_val.unwrap(), idx);
459 return;
460 }
461 } else {
462 // child_key not found in new owned object — child
463 // was removed. Drop from cache.
464 if let Some(pos) = d2.keys.iter().position(|k| *k == child_key) {
465 d2.keys.remove(pos);
466 d2.values.remove(pos);
467 }
468 }
469 let slice = build_object_slice(self.arena, &d2.keys, &d2.values);
470 self.top_values[idx] = DataValue::Object(slice);
471 }
472 // Top-level (depth-1) write or shape change → rebuild
473 // the whole top slot (cheap relative to a full ctx walk).
474 _ => {
475 self.rebuild_top_slot(new_val, idx);
476 }
477 }
478 }
479 (None, None) => { /* no-op */ }
480 }
481 }
482
483 fn rebuild_top_slot(&mut self, owned: &OwnedDataValue, idx: usize) {
484 match owned {
485 OwnedDataValue::Object(children) => {
486 let mut d2_keys: Vec<&'a str> = Vec::with_capacity(children.len());
487 let mut d2_values: Vec<DataValue<'a>> = Vec::with_capacity(children.len());
488 for (ck, cv) in children {
489 d2_keys.push(self.arena.alloc_str(ck));
490 d2_values.push(cv.to_arena(self.arena));
491 }
492 let slice = build_object_slice(self.arena, &d2_keys, &d2_values);
493 self.top_values[idx] = DataValue::Object(slice);
494 self.depth2[idx] = Some(Depth2Cache {
495 keys: d2_keys,
496 values: d2_values,
497 });
498 }
499 _ => {
500 self.top_values[idx] = owned.to_arena(self.arena);
501 self.depth2[idx] = None;
502 }
503 }
504 }
505
506 /// Last-resort: ditch all cached state and rebuild from scratch. Should be
507 /// rare on normal flows — only triggered if the context shape changes in
508 /// a way the depth-2 cache can't track.
509 fn rebuild_all_from(&mut self, ctx: &OwnedDataValue) {
510 let rebuilt = ArenaContext::from_owned(ctx, self.arena);
511 self.top_keys = rebuilt.top_keys;
512 self.top_values = rebuilt.top_values;
513 self.depth2 = rebuilt.depth2;
514 }
515}
516
517/// Allocate a fresh `(key, value)` slice in the arena. Each
518/// `engine.evaluate` call gets its own slice; subsequent mutations to the
519/// underlying Vecs are independent.
520fn build_object_slice<'a>(
521 arena: &'a Bump,
522 keys: &[&'a str],
523 values: &[DataValue<'a>],
524) -> &'a [(&'a str, DataValue<'a>)] {
525 debug_assert_eq!(keys.len(), values.len());
526 arena.alloc_slice_fill_iter(keys.iter().zip(values.iter()).map(|(k, v)| (*k, *v)))
527}
528
529/// Strip exactly one leading `#` from an object-key path segment — same
530/// semantics as `utils::strip_hash_prefix` (`"#20"` → `"20"`, `"##"` → `"#"`).
531#[inline]
532fn strip_hash(part: &str) -> &str {
533 part.strip_prefix('#').unwrap_or(part)
534}
535
536/// Rebuild only the spine of `current` for a write of `value` at `parts`
537/// (all plain object keys — the caller has excluded numeric segments).
538/// Each level allocates a fresh `(key, value)` slice with the one descended
539/// child replaced: shallow pair copies, no string data copied, no descent
540/// into unchanged siblings. A missing key builds the remaining chain as
541/// nested single-pair Objects, mirroring `set_nested_value_parts`' create
542/// semantics for non-numeric segments. Returns `None` when `current` is not
543/// an Object — those shapes (array hop, scalar overwrite mid-path) take the
544/// owned-rebuild fallback, which mirrors whatever the owned write did.
545fn splice_object_write<'a>(
546 arena: &'a Bump,
547 current: DataValue<'a>,
548 parts: &[Arc<str>],
549 value: DataValue<'a>,
550) -> Option<DataValue<'a>> {
551 let DataValue::Object(pairs) = current else {
552 return None;
553 };
554 let key = strip_hash(&parts[0]);
555 let pos = pairs.iter().position(|(k, _)| *k == key);
556
557 let new_slice = if parts.len() == 1 {
558 // Terminal level: replace the slot, or append a new pair.
559 match pos {
560 Some(p) => alloc_pairs_with_replaced(arena, pairs, p, value),
561 None => alloc_pairs_with_appended(arena, pairs, arena.alloc_str(key), value),
562 }
563 } else {
564 match pos {
565 Some(p) => {
566 let child = splice_object_write(arena, pairs[p].1, &parts[1..], value)?;
567 alloc_pairs_with_replaced(arena, pairs, p, child)
568 }
569 None => {
570 let chain = build_object_chain(arena, &parts[1..], value);
571 alloc_pairs_with_appended(arena, pairs, arena.alloc_str(key), chain)
572 }
573 }
574 };
575 Some(DataValue::Object(new_slice))
576}
577
578/// Wrap `value` in nested single-pair Objects, innermost-last: for parts
579/// `[a, b]` produces `{a: {b: value}}`. Mirrors the container-creation path
580/// of `set_nested_value_parts` when every segment is a non-numeric key.
581fn build_object_chain<'a>(
582 arena: &'a Bump,
583 parts: &[Arc<str>],
584 value: DataValue<'a>,
585) -> DataValue<'a> {
586 let mut acc = value;
587 for part in parts.iter().rev() {
588 let key: &'a str = arena.alloc_str(strip_hash(part));
589 let slice = arena.alloc_slice_fill_iter(std::iter::once((key, acc)));
590 acc = DataValue::Object(slice);
591 }
592 acc
593}
594
595/// Fresh slice with `pairs[replace_idx]`'s value swapped for `new_value`.
596fn alloc_pairs_with_replaced<'a>(
597 arena: &'a Bump,
598 pairs: &'a [(&'a str, DataValue<'a>)],
599 replace_idx: usize,
600 new_value: DataValue<'a>,
601) -> &'a [(&'a str, DataValue<'a>)] {
602 arena.alloc_slice_fill_with(pairs.len(), |i| {
603 if i == replace_idx {
604 (pairs[i].0, new_value)
605 } else {
606 pairs[i]
607 }
608 })
609}
610
611/// Fresh slice of `pairs` plus one appended `(key, value)` pair.
612fn alloc_pairs_with_appended<'a>(
613 arena: &'a Bump,
614 pairs: &'a [(&'a str, DataValue<'a>)],
615 key: &'a str,
616 value: DataValue<'a>,
617) -> &'a [(&'a str, DataValue<'a>)] {
618 arena.alloc_slice_fill_with(pairs.len() + 1, |i| {
619 if i < pairs.len() {
620 pairs[i]
621 } else {
622 (key, value)
623 }
624 })
625}
626
627/// Evaluate a workflow or task condition using a cached compiled logic
628/// expression. Returns `true` when `condition_index` is `None` (no condition
629/// is treated as "always run"). Evaluation errors are logged and downgraded
630/// to `false` — a condition that fails to evaluate skips its task/workflow
631/// rather than aborting the whole message.
632pub fn evaluate_condition(
633 engine: &Engine,
634 compiled_condition: Option<&Arc<Logic>>,
635 context: &OwnedDataValue,
636) -> Result<bool> {
637 match compiled_condition {
638 Some(compiled) => match eval_to_owned(engine, compiled, context) {
639 Ok(value) => Ok(matches!(value, OwnedDataValue::Bool(true))),
640 Err(e) => {
641 error!("Failed to evaluate condition: {:?}", e);
642 Ok(false)
643 }
644 },
645 None => Ok(true),
646 }
647}
648
649/// Same as `evaluate_condition` but evaluates against an arena-resident
650/// `DataValue` and an existing `Bump`. Used inside a `with_arena` block
651/// (the workflow sync-stretch path) to avoid re-entering the thread-local
652/// arena `RefCell::borrow_mut`.
653pub fn evaluate_condition_in_arena(
654 engine: &Engine,
655 compiled_condition: Option<&Arc<Logic>>,
656 ctx: DataValue<'_>,
657 arena: &Bump,
658) -> Result<bool> {
659 match compiled_condition {
660 Some(compiled) => match engine.evaluate(compiled, ctx, arena) {
661 Ok(value) => Ok(matches!(value, DataValue::Bool(true))),
662 Err(e) => {
663 error!("Failed to evaluate condition: {:?}", e);
664 Ok(false)
665 }
666 },
667 None => Ok(true),
668 }
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674 use crate::engine::utils::{set_nested_value, set_nested_value_parts};
675 use serde_json::json;
676
677 fn dv(v: serde_json::Value) -> OwnedDataValue {
678 OwnedDataValue::from(&v)
679 }
680
681 fn parts_of(path: &str) -> Vec<Arc<str>> {
682 path.split('.').map(Arc::from).collect()
683 }
684
685 /// Drive a corpus of writes through the write-through path. Every call
686 /// re-validates the cache against a from-scratch rebuild via the
687 /// `#[cfg(test)]` assertion inside `apply_mutation_parts_write_through`,
688 /// so this test fails on any splice/fallback divergence — splice hits
689 /// (deep object paths, `#`-escaped keys, chain creation, depth-2
690 /// replace/insert) and fallback shapes (array segments, scalar mid-path
691 /// no-ops, new top slots) alike.
692 #[test]
693 fn write_through_differential_corpus() {
694 let mut owned = dv(json!({
695 "data": {
696 "input": {"big": {"nested": [1, 2, 3]}, "flag": true},
697 "MT103": {"20": "REF", "72": "existing"},
698 "items": [{"x": 1}, {"x": 2}],
699 "scalar": "leaf"
700 },
701 "metadata": {"processed_at": "t0"},
702 "temp_data": {}
703 }));
704
705 with_arena(|arena| {
706 let mut ctx = ArenaContext::from_owned(&owned, arena);
707
708 let corpus: &[(&str, serde_json::Value)] = &[
709 // Depth-3 replace of an existing key inside a cached subtree.
710 ("data.MT103.20", json!("NEWREF")),
711 // Depth-3 append of a new key.
712 ("data.MT103.23B", json!("CRED")),
713 // Depth-4 with a missing intermediate — chain creation.
714 ("data.MT103.32A.amount", json!(1500.25)),
715 // Depth-4 into the now-existing intermediate.
716 ("data.MT103.32A.currency", json!("USD")),
717 // Deeper chain creation (three missing levels).
718 ("data.MT103.a.b.c", json!({"deep": [1, {"k": "v"}]})),
719 // Overwrite an entire existing sub-object.
720 ("data.MT103.32A", json!({"replaced": true})),
721 // Depth-2 whole-slot replace with an object.
722 ("data.MT103", json!({"fresh": {"start": 1}})),
723 // Depth-2 whole-slot replace with a scalar (drops d2 cache on
724 // next rebuild; subsequent write through it must no-op).
725 ("data.scalar2", json!("plain")),
726 // New depth-2 key inserted into an existing top slot.
727 ("data.MT202", json!({"20": "REF2"})),
728 // Write into a not-yet-cached depth-2 subtree (fallback).
729 ("data.MT205.20", json!("REF5")),
730 // `#`-escaped keys: object keys "20" and "#".
731 ("data.MT103.#20", json!("HASH20")),
732 ("data.MT103.##", json!("HASHKEY")),
733 // Array index segments — fallback shapes.
734 ("data.items.0.x", json!(10)),
735 ("data.items.3", json!({"x": 4})),
736 // Scalar mid-path — owned write no-ops; cache must stay in sync.
737 ("data.scalar.sub.key", json!("ignored")),
738 // Metadata / temp_data writes.
739 ("metadata.progress.workflow_id", json!("wf1")),
740 ("temp_data.uetr", json!("uuid-here")),
741 // Brand-new top-level slot (fallback creates it).
742 ("other_top.x.y", json!(42)),
743 ];
744
745 for (path, val) in corpus {
746 let parts = parts_of(path);
747 let owned_val = dv(val.clone());
748 let value_av = owned_val.to_arena(arena);
749 ctx.apply_mutation_parts_write_through(&mut owned, &parts, value_av, |c| {
750 set_nested_value_parts(c, &parts, owned_val.clone());
751 });
752 }
753
754 // Spot-check a few end states against the owned source of truth.
755 assert_eq!(owned["data"]["MT103"]["20"], dv(json!("HASH20")));
756 assert_eq!(owned["data"]["MT103"]["fresh"]["start"], dv(json!(1)));
757 assert_eq!(owned["data"]["items"][3]["x"], dv(json!(4)));
758 assert_eq!(owned["data"]["scalar"], dv(json!("leaf")));
759 assert_eq!(
760 owned["metadata"]["progress"]["workflow_id"],
761 dv(json!("wf1"))
762 );
763 });
764 }
765
766 /// Interleave reads (as_data_value) with write-throughs to make sure a
767 /// previously handed-out slice never aliases a mutated spine.
768 #[test]
769 fn write_through_reads_see_latest_state() {
770 let mut owned = dv(json!({
771 "data": {"doc": {"a": 1}},
772 "metadata": {},
773 "temp_data": {}
774 }));
775
776 with_arena(|arena| {
777 let mut ctx = ArenaContext::from_owned(&owned, arena);
778
779 for i in 0..10u64 {
780 let key = format!("data.doc.f{i}");
781 let parts = parts_of(&key);
782 let owned_val = OwnedDataValue::from(i);
783 let value_av = owned_val.to_arena(arena);
784 ctx.apply_mutation_parts_write_through(&mut owned, &parts, value_av, |c| {
785 set_nested_value_parts(c, &parts, owned_val.clone());
786 });
787 // A fresh projection after every write must equal owned.
788 assert_eq!(ctx.as_data_value().to_owned(), owned);
789 }
790 });
791 }
792
793 /// The refresh-only path (used by parse_json and the per-task progress
794 /// refresh) must remain consistent when mixed with write-throughs.
795 #[test]
796 fn refresh_and_write_through_interleave() {
797 let mut owned = dv(json!({
798 "data": {},
799 "metadata": {},
800 "temp_data": {}
801 }));
802
803 with_arena(|arena| {
804 let mut ctx = ArenaContext::from_owned(&owned, arena);
805
806 // Simulate parse_json: direct owned write + refresh_for_path.
807 set_nested_value(&mut owned, "data.input", dv(json!({"payload": {"v": 7}})));
808 ctx.refresh_for_path(&owned, "data.input");
809 assert_eq!(ctx.as_data_value().to_owned(), owned);
810
811 // Then map write-throughs landing next to it.
812 let parts = parts_of("data.out.v");
813 let owned_val = dv(json!(7));
814 let value_av = owned_val.to_arena(arena);
815 ctx.apply_mutation_parts_write_through(&mut owned, &parts, value_av, |c| {
816 set_nested_value_parts(c, &parts, owned_val.clone());
817 });
818
819 // Simulate the progress write + narrow refresh.
820 set_nested_value(
821 &mut owned,
822 "metadata.progress",
823 dv(json!({"task_id": "t1"})),
824 );
825 ctx.refresh_for_path(&owned, "metadata.progress");
826 assert_eq!(ctx.as_data_value().to_owned(), owned);
827 });
828 }
829}