Skip to content

fix: Deep-merge nested data documents in Engine::add_data#760

Open
kusha wants to merge 5 commits into
microsoft:mainfrom
kusha:markbirger-microsoft-data-merge-bug-analysis
Open

fix: Deep-merge nested data documents in Engine::add_data#760
kusha wants to merge 5 commits into
microsoft:mainfrom
kusha:markbirger-microsoft-data-merge-bug-analysis

Conversation

@kusha

@kusha kusha commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Engine::add_data performed a shallow merge. Adding a nested object under a key that already existed would either replace the existing subtree wholesale or raise a spurious generated multiple times conflict, 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::merge now recurses into nested objects, so keys from both sides are preserved — matching OPA's data-document merge semantics (internal/merge/merge.go).
  • Nested sets are unioned on merge. This is a regorus extension (OPA data documents are JSON and cannot contain sets), but it keeps set behavior consistent between top-level and nested keys.
  • Genuine leaf conflicts (the same path holding two different scalar values) still error. Equal values remain a no-op, which the shared rule-evaluation path (Interpreter::merge_rule_value) relies on, since a rule may legally produce the same value more than once.
  • Engine::add_data doctest and CHANGELOG.md updated to reflect the deep-merge behavior.

The with data.x as ... modifier is unaffected: it replaces the targeted subtree directly and does not go through Value::merge.

Tests

Added to src/tests/interpreter/mod.rs:

  • Object deep-merge (single- and multi-level), leaf-conflict errors, object-vs-scalar conflict errors, equal-leaf no-op.
  • Set union (top-level and nested), equal-set no-op.
  • with data.x modifier replace semantics (nested replace preserves siblings; whole-subtree replace).
  • Rules reading deep-merged base data, and rule values coexisting with merged base data.

Validation

  • cargo test --lib: all passing.
  • cargo test --test opa: pass/fail counts identical to main — no conformance regression.
  • cargo xtask pre-commit (rustfmt + clippy -Dwarnings): clean.

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>
@kusha kusha force-pushed the markbirger-microsoft-data-merge-bug-analysis branch from 749d710 to efff5c4 Compare July 8, 2026 10:40
@anakrish anakrish requested a review from Copilot July 8, 2026 10:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::merge to 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_data docs/doctest and CHANGELOG.md to 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.

Comment thread src/value/mod.rs
Comment on lines +1397 to +1404
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 {

@kusha kusha Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread src/value/mod.rs Outdated
Mark Birger and others added 2 commits July 9, 2026 11:39
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>
@anakrish

anakrish commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

🐛 add_data is not atomic — a failed nested merge partially mutates engine data

Now that Value::merge recurses, a conflict in a later nested key is reported only after earlier keys from the same document have already been written into the engine's data. Because add_data merges directly into the live init_data (engine.rs:468get_init_data_mut().merge(data)), a rejected add_data call can leave the engine in a partially-mutated state.

Repro (fails today):

engine.add_data(Value::from_json_str(r#"{ "a": { "z": 1 } }"#)?)?;
// `m` sorts before `z`, so `a.m` is inserted, then `a.z` (1 vs 3) conflicts and bails.
assert!(engine.add_data(Value::from_json_str(r#"{ "a": { "m": 2, "z": 3 } }"#)?).is_err());
// BUG: engine data is now { "a": { "m": 2, "z": 1 } } — the rejected `m` leaked in.

The merge doc says a conflict "is an error", but callers reasonably expect an errored add_data to be a no-op (all-or-nothing).

Suggested fix — merge into a candidate copy and commit only on success. Value is Rc-backed with copy-on-write, so this only deep-copies the paths actually touched by the merge (the untouched subtrees stay shared):

pub fn add_data(&mut self, data: Value) -> Result<()> {
    if data.as_object().is_err() {
        bail!("data must be object");
    }
    // Merge into a candidate copy so a mid-merge conflict cannot leave the
    // data document partially mutated: add_data is all-or-nothing.
    let mut candidate = self.interpreter.get_init_data().clone();
    candidate.merge(data)?;
    *self.interpreter.get_init_data_mut() = candidate;
    self.prepared = false;
    Ok(())
}

(Cost is O(number of top-level keys) copy-on-write per call; acceptable for correctness. A more surgical rollback is possible but far more complex.)

Suggested regression test (add to src/tests/interpreter/mod.rs):

#[test]
fn test_add_data_failed_merge_is_atomic() -> Result<()> {
    let mut engine = Engine::new();

    engine.add_data(Value::from_json_str(r#"{ "a" : { "z" : 1 } }"#)?)?;

    // Mixes a new key `m` with a conflicting leaf `z` (1 vs 3). The whole call
    // must fail AND leave existing data untouched — `m` must not leak in.
    assert!(engine
        .add_data(Value::from_json_str(r#"{ "a" : { "m" : 2, "z" : 3 } }"#)?)
        .is_err());

    assert_eq!(
        engine.get_data(),
        Value::from_json_str(r#"{ "a" : { "z" : 1 } }"#)?
    );

    Ok(())
}

Note this pre-dates the PR for top-level keys, but the new recursion widens the window to nested paths, so it's worth closing here.

🤖 Found during automated review (deep-review skill), verified manually.

Mark Birger and others added 2 commits July 10, 2026 12:32
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>
@kusha

kusha commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @anakrish , good catch. Fixed.

I went with validate-then-merge instead of candidate-copy. Candidate-copy costs even on success: after eval, init_data is shared (cloned into with_document["data"] and self.data), so clone() + merge forces an Rc::make_mut COW of the touched spine on every add, and discards the in-place fast path when init_data is uniquely owned. Accumulating under one subtree becomes O(N²).

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. check_mergeable mirrors merge's conflict rule exactly, so they can't drift. Added your repro plus set and array variants.

@anakrish

Copy link
Copy Markdown
Collaborator

🔁 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

check_mergeable mirrors merge's semantic conflict rules, but merge can also fail at enforce_limit_anyhow() after it has already mutated:

map.insert(k.clone(), v.clone());
enforce_limit_anyhow()?;   // <-- fails AFTER the insert

So on an allocator-memory-limits build, an add_data that passes check_mergeable (e.g. a pure insertion of a large nested doc) can insert some keys, then return Err at the limit — leaving the data document partially merged. This directly contradicts the newly-added doc guarantee:

The merge is atomic: if any conflict is detected … the existing data document is left unchanged.

Suggested fix — merge into a candidate copy and commit only on success. Value is Rc-backed with copy-on-write, so this only deep-copies the paths the merge actually touches, and it makes every error mode (conflict and limit) transactional — which also lets check_mergeable be removed:

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 allocator-memory-limits config): set a memory limit just above current usage, add_data a large nested doc that trips the limit mid-merge, assert the call errors and engine.get_data() is unchanged.


2. 🟠 MEDIUM — Zero-arg function conflicts now silently deep-merge instead of erroring

Value::merge is shared with rule/function materialization via Interpreter::merge_rule_value. Complete rules are safe (their conflict is caught independently in update_rule_value, interpreter.rs:1697), but zero-arg functions materialize through update_data → merge_rule_value → Value::merge (interpreter.rs:3785, 3673, 3404), so they inherit the new deep-merge. Verified empirically on this branch:

Policy before this PR this PR
f() := {"a":{"x":1}} / f() := {"a":{"y":2}} Err (conflict) {"a":{"x":1,"y":2}} — silently merged ⚠️
p := {"a":{"x":1}} / p := {"a":{"y":2}} (complete rule) Err Err (unaffected ✅)

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 add_data its own deep_merge and keep Value::merge as the strict equality-or-conflict primitive for merge_rule_value, or gate the recursive arm behind a flag/param that only add_data sets.

Regression test (add to src/tests/interpreter/mod.rs):

#[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 Rc::make_mut(map) clones large shared objects with no limit check

The object arm now clones the map up front, before it knows whether any mutation is needed:

(Value::Object(map), Value::Object(new)) => {
    let map = Rc::make_mut(map);   // clones the whole map if shared
    for (k, v) in new.iter() { ... }

If a caller holds a snapshot (shared Rc, e.g. via get_data()) and the incoming doc only overlaps equal values, this deep-clones a potentially large object while performing no insertion — so the object-arm enforce_limit_anyhow() (which lives only in the None insert branch) never runs, and memory can exceed the configured limit undetected. The old code called Rc::make_mut only on the insert path.

Suggested fix — inspect with map.get(k) first and only Rc::make_mut(map) when an insert or a mutating recursion is actually required; or run enforce_limit_anyhow() immediately after any Rc::make_mut that may clone.


4. 🟡 MEDIUM/LOW — Recursive merge/precheck has no depth guard (now doubled)

Both merge and check_mergeable recurse one stack frame per nesting level, and check_mergeable runs first. A deeply nested, programmatically-constructed Value (JSON is capped ~128 by serde, but the Value API is not) added via add_data can overflow the stack → process abort instead of a recoverable Result.

Suggested fix — thread a depth counter with a conservative cap (returning an error past it), or rewrite the object traversal iteratively with an explicit stack. A regression test after adding the cap: assert an over-deep document is rejected rather than crashing.


5. 🔵 LOW — Doc wording + test coverage


🤖 Automated second-round review (code-review + deep-review skills); findings #1 and #2 verified manually by building and running targeted tests on this branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants