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