Skip to main content

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