fix: Deep-merge nested data documents in Engine::add_data#760
Conversation
add_data previously performed a shallow merge: adding a nested object under a key that already existed either replaced the whole subtree or errored on a spurious conflict, instead of merging the trees. This makes Engine::add_data (and the shared Value::merge) recurse into nested objects so keys from both sides are preserved, matching OPA's data-document merge semantics. Nested sets are unioned as a regorus extension (OPA data is JSON and has no sets). Genuine leaf conflicts (same path, two different scalar values) still error; equal values remain a no-op, which the shared rule-evaluation path relies on. Adds tests for object deep-merge, set union, leaf/type conflicts, and interaction with the 'with data.x' modifier. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
749d710 to
efff5c4
Compare
There was a problem hiding this comment.
Pull request overview
This PR fixes Engine::add_data to follow OPA-style deep-merge semantics for nested objects by making Value::merge recursive for object values (and unioning nested sets as a regorus extension). This aligns repeated add_data calls with expected data-document composition behavior without spurious conflicts or subtree replacement.
Changes:
- Update
Value::mergeto recursively merge nested objects and union nested sets, while still erroring on genuine leaf conflicts (and tolerating equal-values as a no-op). - Add interpreter tests covering deep-merge behavior, conflicts, set union, and
with data.* as ...replacement semantics. - Update
Engine::add_datadocs/doctest andCHANGELOG.mdto document the new merge behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/value/mod.rs | Implements recursive object merge and nested set union in Value::merge, updating merge semantics used by Engine::add_data. |
| src/tests/interpreter/mod.rs | Adds tests to validate deep-merge behavior, conflicts, set union, and with data.* replacement behavior. |
| src/engine.rs | Updates Engine::add_data documentation/doctest to reflect deep-merge behavior and conflict rules. |
| CHANGELOG.md | Notes the deep-merge behavior change in the Unreleased section. |
| let both_mergeable = matches!( | ||
| (&*existing, v), | ||
| (Value::Object(_), Value::Object(_)) | ||
| | (Value::Set(_), Value::Set(_)) | ||
| ); | ||
| if both_mergeable { | ||
| existing.merge(v.clone())?; | ||
| } else if *existing != *v { |
There was a problem hiding this comment.
Optimized. The (Set, Set) arm no longer calls Rc::make_mut(new). It now mem::takes the RHS and Rc::try_unwraps it: elements are moved out when the set is uniquely owned, and only the per-element Rc handles are cloned when it's shared (the v.clone() path).
Copilot review on microsoft#760 noted the doc comment called non-mergeable variants 'non-container values', which is misleading since arrays are containers yet still conflict unless equal. Reword to describe a conflict as any differing pair that is not both objects or both sets (e.g. unequal scalars or arrays). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
When unioning sets in Value::merge, the RHS set is often shared: the object arm recurses via existing.merge(v.clone()), which bumps the incoming set's Rc refcount. The old Rc::make_mut(new) then structurally deep-cloned the entire RHS BTreeSet just to drain it via append and immediately discard the copy. Move the elements out when the RHS set is uniquely owned, and otherwise clone only the per-element Rc handles into the destination. The union result is identical (BTreeSet dedups), but no throwaway set is allocated on the nested-merge path exercised by add_data deep-merge. Addresses a Copilot review comment on microsoft#760. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
🐛
|
Now that Value::merge recurses, a conflict in a later nested key was reported only after earlier keys of the same document had already been written into the live init_data, leaving the engine partially mutated on a rejected add_data. Add a read-only Value::check_mergeable that mirrors merge's conflict rule (objects deep-merge, sets union, equal values no-op, anything else conflicts) and run it in add_data before merging. On conflict nothing is mutated, so add_data is all-or-nothing. The check allocates nothing and never copies the data spine, preserving merge's in-place uniquely-owned fast path (no candidate copy of the data document). Adds regression tests for a partial object-leaf conflict and a partial set-union conflict. Reported by a maintainer on microsoft#760. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Arrays are atomic leaves, so a differing array at a shared path is a conflict. The new key sorts before the conflicting array key, so a naive in-place merge would leak the new key before hitting the conflict. This test locks in that add_data rejects the whole call and leaves data untouched. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Thanks @anakrish , good catch. Fixed. I went with validate-then-merge instead of candidate-copy. Candidate-copy costs even on success: after eval, self.interpreter.get_init_data().check_mergeable(&data)?;
self.prepared = false;
self.interpreter.get_init_data_mut().merge(data)The read-only check is O(overlap), allocates nothing, and short-circuits at absent keys (new-key adds never walk the payload), so the happy path stays zero-copy and in-place. |
🔁 Second-round review (post-update)Thanks for the atomicity fix — the conflict path is now correctly all-or-nothing. Re-reviewing the update surfaced two new issues plus some hardening items. Findings below are ordered by severity; the two most important are #1 and #2. 1. 🔴 HIGH — Atomicity is still broken for the allocator-limit failure mode
map.insert(k.clone(), v.clone());
enforce_limit_anyhow()?; // <-- fails AFTER the insertSo on an
Suggested fix — merge into a candidate copy and commit only on success. pub fn add_data(&mut self, data: Value) -> Result<()> {
if data.as_object().is_err() {
bail!("data must be object");
}
// All-or-nothing: merge into a candidate, commit only on success. Covers both
// semantic conflicts AND allocator-limit failures. Rc copy-on-write means only
// the touched subtrees are cloned.
let mut candidate = self.interpreter.get_init_data().clone();
candidate.merge(data)?;
*self.interpreter.get_init_data_mut() = candidate;
self.prepared = false;
Ok(())
}Regression test (needs the 2. 🟠 MEDIUM — Zero-arg function conflicts now silently deep-merge instead of erroring
An authoring mistake that used to be a hard error now silently produces a merged value. It's narrow (zero-arg functions + a shared key whose values are both objects/sets), but it's a silent evaluation-semantics change. (Note: disjoint object keys already merged pre-PR, so that specific case is not a regression.) Suggested fix — don't let the data-document deep-merge leak into rule materialization. Either give Regression test (add to #[test]
fn test_zero_arg_function_object_conflict_errors() -> Result<()> {
let mut engine = Engine::new();
engine.add_policy(
"p.rego".to_string(),
r#"
package test
f() := {"a": {"x": 1}}
f() := {"a": {"y": 2}}
"#
.to_string(),
)?;
// Two zero-arg definitions producing objects that share a key must conflict,
// NOT silently deep-merge.
assert!(engine
.eval_query("data.test.f".to_string(), false)
.is_err());
Ok(())
}3. 🟡 MEDIUM — Unconditional
|
On �llocator-memory-limits builds, Value::merge runs the limit check *after* inserting each key, so an add_data whose merge trips the limit mid-way left the data document partially mutated. check_mergeable only models semantic conflicts, not limit failures, so the validate-then-merge precheck couldn't cover this failure mode. Use a build-split strategy in �dd_data: - default builds: keep the zero-copy validate-then-merge fast path (a conflict is the only way the merge can fail). - allocator-memory-limits builds: merge into a candidate copy and commit only on success, making both conflict and limit failures transactional. Value is Rc/copy-on-write, so only touched subtrees are cloned. check_mergeable is now cfg-gated to the default build to avoid dead code. Tests (allocator-memory-limits build): add a partial-merge atomicity test (limit trips mid-merge, data must be untouched) and a candidate-copy conflict-atomicity test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
microsoft#760 made Value::merge recursive so Engine::add_data deep-merges nested data documents. But that same method also backs rule materialization, where recursion is wrong: two rule definitions producing different outputs for one path must conflict (OPA complete-rule semantics), not silently combine. Split the two behaviors: - Value::merge is strict and shallow again (as pre-microsoft#760): a key on both sides must be equal or it conflicts; used for rule outputs. - Value::deep_merge is the recursive data-document merge behind add_data; check_mergeable validates it up front without allocating, so the default build merges in place instead of cloning a candidate. Also fix zero-arg functions (f() := ...): route their materialization through strict equality via a new RuleValueMerge selector, so disjoint outputs ({a:1} vs {b:2}) conflict as OPA does while prefix scaffolding (a.foo + a.bar) still combines. Add a 14-case interpreter conformance matrix (multiple_outputs.yaml) covering functions, static/dynamic partial objects, and ref-heads, matched against OPA v1.2.0. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
deep_merge's object arm called Rc::make_mut on the target map up front, cloning a shared map's spine even when the merge changed nothing (a no-op subset re-add) or conflicted before any mutation. Decide each incoming key from a read-only probe (skip / insert / recurse / conflict) and take Rc::make_mut only when a key actually mutates, so no-op and conflict merges leave shared maps untouched. Behavior is unchanged: the equality short-circuit that previously ran inside the recursive call now runs in the probe, and conflicts bail with the same message. Add value tests asserting Rc::ptr_eq is preserved across no-op subset, equal-nested-object, and first-key-conflict merges. OPA conformance unchanged (3021 pass / 651 fail, byte-identical). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ow DoS deep_merge and check_mergeable recursed unbounded on object/set nesting. A Value built without serde_json's parse-time recursion limit (the Python and Ruby native bindings, or programmatic construction) could therefore drive add_data into a stack overflow -- an uncatchable abort that poisons every engine in an FFI process. Thread a depth counter through both functions and bail past MAX_MERGE_DEPTH (128, matching serde_json's default) so over-deep data fails with a clean Err. In the default build check_mergeable trips first, keeping add_data atomic; the guard in deep_merge covers the allocator-memory-limits build and any disjoint-then-overlapping merge. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…depth limit Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
@anakrish I've addressed all five findings. Two of them (#1 and #2) I approached slightly differently than suggested; flagging those so you can tell me if you'd prefer the narrower/simpler version. 1. 🔴 Allocator-limit atomicityFixed, but with a build-split rather than unconditionally switching to the candidate copy:
So Tests ( 2. 🟠 Zero-arg function conflictsFixed by separating the two merges, as you suggested: One scope note: I took zero-arg functions all the way to OPA v1.2.0 complete-rule semantics — so any two differing outputs conflict, including disjoint top-level keys ( Coverage lives in a conformance matrix ( 3. 🟡 Unconditional
|
Summary
Engine::add_dataperformed a shallow merge. Adding a nested object under a key that already existed would either replace the existing subtree wholesale or raise a spuriousgenerated multiple timesconflict, instead of deep-merging the two data trees.
rust engine.add_data(Value::from_json_str(r#"{ "y": { "a": 10 } }"#)?)?; engine.add_data(Value::from_json_str(r#"{ "y": { "b": 20 } }"#)?)?; // previously errored / dropped "a" // data["y"] is now { "a": 10, "b": 20 } Changes
Value::mergenow recurses into nested objects, so keys from both sides are preserved — matching OPA's data-document merge semantics (internal/merge/merge.go).Interpreter::merge_rule_value) relies on, since a rule may legally produce the same value more than once.Engine::add_datadoctest andCHANGELOG.mdupdated to reflect the deep-merge behavior.The
with data.x as ...modifier is unaffected: it replaces the targeted subtree directly and does not go throughValue::merge.Tests
Added to
src/tests/interpreter/mod.rs:with data.xmodifier replace semantics (nested replace preserves siblings; whole-subtree replace).Validation
cargo test --lib: all passing.cargo test --test opa: pass/fail counts identical tomain— no conformance regression.cargo xtask pre-commit(rustfmt + clippy-Dwarnings): clean.