feat(value): Frozen object storage (boxed-slice) for compact parsed objects - #787
Conversation
7f6adff to
8b987af
Compare
There was a problem hiding this comment.
Pull request overview
Introduces compact frozen object storage to reduce memory usage for read-mostly policy data.
Changes:
- Adds Empty, Inline, Frozen, and BTree object representations.
- Freezes objects at parsing, evaluation, and engine boundaries.
- Adds storage lifecycle and SARIF memory tests.
Reviewed changes
Copilot reviewed 16 out of 22 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
Cargo.toml |
Adds SmallVec dependency. |
Cargo.lock |
Updates root dependencies. |
bindings/ffi/Cargo.lock |
Updates FFI lockfile. |
bindings/java/Cargo.lock |
Updates Java lockfile. |
bindings/python/Cargo.lock |
Updates Python lockfile. |
bindings/wasm/Cargo.lock |
Updates WASM lockfile. |
src/value/object/mod.rs |
Implements tiered object storage. |
src/value/object/iter.rs |
Supports iterating each storage tier. |
src/value/object/serde.rs |
Freezes deserialized objects. |
src/value/mod.rs |
Adds recursive freezing. |
src/value/tests.rs |
Tests storage lifecycle behavior. |
src/engine.rs |
Freezes input and merged data. |
src/interpreter.rs |
Freezes interpreter results. |
src/rvm/vm/comprehension.rs |
Freezes RVM comprehension results. |
src/rvm/program/serialization/value.rs |
Freezes deserialized RVM objects. |
src/builtins/objects.rs |
Freezes object builtin results. |
src/builtins/azure_policy/template_functions_misc.rs |
Uses frozen object construction. |
src/builtins/azure_policy/template_functions_collection.rs |
Uses frozen object construction. |
tests/sarif_memory.rs |
Adds memory-residency measurement. |
tests/data/sarif_memory/input.json |
Provides SARIF workload fixture. |
tests/data/sarif_memory/policy.rego |
Provides workload policy. |
tests/opa.rs |
Cleans stale OPA checkouts. |
Suppressed comments (1)
src/value/object/mod.rs:177
- Every recursive freeze calls
Object::iter_mut, so this unconditional thaw converts each already-Frozen object into Inline/BTree storage, allocates BTree nodes for larger objects, and then immediately converts it back to a boxed slice. Thusset_inputon a parsed document incurs a full-document thaw/refreeze cycle and a large transient peak. Add a Frozen mutable-slice iterator so value-only traversal can preserve compact storage.
self.thaw();
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
ddabf21 to
59eeeb6
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 26 changed files in this pull request and generated 1 comment.
Suppressed comments (5)
tests/sarif_memory.rs:217
- This bound deliberately allows the historical unoptimized
BTreeMapbaseline (~30×) to pass, so reverting the PR's core memory improvement would not fail this claimed regression test. Add a platform-calibrated bound below that baseline or a deterministic allocation/storage assertion that protects the new compact behavior.
// Coarse sanity bound: even an unoptimized BTreeMap-backed Object should
// not balloon to more than ~50× the JSON size for the un-amplified case
// (MULT=1). The historical baseline (MULT=50, BTreeMap) was ~30× live
// blow-up; we leave plenty of headroom for the unamplified case so this
// assert only catches genuinely catastrophic regressions.
src/value/mod.rs:393
Objectkeys may be anyValue(including arrays/objects), but this branch recursively visits only values. Consequently, objects nested in keys remain mutable-tier storage at every advertised freeze boundary. Rebuild/transform entries while recursively freezing both keys and values, then refreeze the outer object.
if let Some(object) = Rc::get_mut(object) {
object.for_each_value_mut_preserving_storage(|value| {
value.freeze_recursive_impl(next_depth);
});
object.refreeze_in_place();
src/value/object/mod.rs:185
iter_mutexposes immutable keys and mutable values only, so thawing here is unnecessary;for_each_value_mut_preserving_storagealready mutates the same Frozen value slots safely. This converts parsed objects toBTreeduring callers such as RVM template updates (src/rvm/vm/dispatch.rs:516), defeating compact storage for a value-only mutation. Add a Frozen arm toIterMutInnerand iterate the boxed slice directly.
pub fn iter_mut(&mut self) -> IterMut<'_> {
// Frozen storage is read-only, so thaw it in place before handing out
// mutable element references. After this, repr is never Frozen.
self.thaw();
src/value/interning.rs:42
- The table retains two copies of every distinct key for the whole parse: the
Rc<str>allocation and a separate ownedStringused as the map key. High-cardinality JSON objects therefore add key bytes instead of saving them. Key the table by the ref-counted string itself so lookup still borrows&strbut only one string allocation is retained.
use alloc::string::{String, ToString as _};
use core::cell::RefCell;
use std::collections::HashMap;
/// A `String -> Rc<str>` interning table scoped to a single deserialization call.
src/value/tests.rs:1045
- This setup cannot verify the depth guard:
nestconstructs every node throughValue::from(BTreeMap), which now freezes each object immediately, and the assertion inspects only the already-Frozen root. The test still passes if recursive traversal stops immediately or ignores the limit without overflowing. Build an explicitly unfrozen chain and inspect representations immediately before and afterMAX_MERGE_DEPTH.
let mut value = nest(
super::MAX_MERGE_DEPTH + 512,
Value::from_json_str("{}").unwrap(),
);
c8bbb18 to
0895579
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 26 changed files in this pull request and generated no new comments.
Suppressed comments (8)
src/value/tests.rs:1045
- This depth-limit test cannot observe the limit:
nestconstructs every layer throughValue::from(BTreeMap), which now freezes each object immediately, and the only assertion checks that already-Frozen top layer. Construct an unfrozen chain directly and verify that a layer within the cap becomes Frozen while a layer beyond the cap remains Inline; otherwise an unbounded or no-op traversal both pass this test.
let mut value = nest(
super::MAX_MERGE_DEPTH + 512,
Value::from_json_str("{}").unwrap(),
);
src/value/object/mod.rs:185
iter_mutonly exposes mutable values, so it cannot change key ordering and does not need to thaw the object. Thawing every frozen object here turns compact objects with more than two entries into aBTreeMapallocation and permanently loses the compact representation; this is especially costly for RVM object templates, which calliter_muton cloned frozen literals. Add a Frozen-backed mutable iterator, asget_mutandfor_each_value_mut_preserving_storagealready demonstrate is safe.
// Frozen storage is read-only, so thaw it in place before handing out
// mutable element references. After this, repr is never Frozen.
self.thaw();
src/engine.rs:403
- With
allocator-memory-limitsenabled, this newly allocates compact slices (and can rebuild sets) without any way to report a limit violation, soset_inputcan exceed the configured ceiling and still succeed. The previous implementation performed no allocation here. Avoid compaction while a limit is active, or add a fallible input-setting path that checks the limit before committing.
pub fn set_input(&mut self, mut input: Value) {
// Keep this public API infallible: recursive freeze may allocate compact storage, but
// there is no `Result` path here to report allocator-limit failures without an API break.
input.freeze_recursive();
tests/sarif_memory.rs:195
PEAKwas last reset before policy/input parsing, so this value is the peak of the whole measured run, not the peak during evaluation as reported below. Reset the peak afterset_inputand use that snapshot as the evaluation baseline.
let results = engine
.eval_query(
"data.staticAnalysisResult.Verification.compliant".to_string(),
false,
)
src/builtins/azure_policy/template_functions_misc.rs:92
- The exact memory check runs only after the entire output has been accumulated. Each
entry.into_value()now allocates frozen storage, so a large input object can overshoot the configured ceiling for the whole loop before the error is observed. Add the throttled check inside the accumulation loop and retain the final exact check.
result.push(entry.into_value());
}
crate::utils::limits::enforce_memory_limit().map_err(anyhow::Error::new)?;
src/value/object/mod.rs:47
- The representation now has four variants, not three:
Empty,Inline,Frozen, andBTree. The publicObjectdocumentation should describe all four tiers consistently with the module-level documentation.
/// Backed by a three-variant representation: mutable small objects build in a
/// boxed inline `SmallVec` (kept sorted by `Value::Ord`), mutable larger ones
/// promote to `BTreeMap`, and read-mostly objects freeze to an exact-size boxed
/// slice. The representation is private so it can change without touching call
/// sites.
src/value/mod.rs:383
- Every uniquely owned set is torn down and rebuilt even when it contains only scalar values, where freezing has nothing to do. This needlessly reallocates and reinserts the entire set at every recursive freeze boundary (including RVM set-comprehension completion). Avoid rebuilding sets that contain no descendable containers, or use a traversal strategy that only reconstructs when an element actually needs compaction.
let old = core::mem::take(values);
*values = old
.into_iter()
.map(|mut value| {
value.freeze_recursive_impl(next_depth);
value
})
.collect();
src/value/tests.rs:1018
- This does not exercise recursive freezing:
Object::into_value()now freezesinnerbefore it is inserted into the set, and the array/root use the same freezing conversion. Consequently, every object is already Frozen beforefreeze_recursive()runs, so the assertion would pass if recursion were removed. Build the nestedValue::Objectnodes directly fromRc::new(Object)in this test and assert an Inline precondition before callingfreeze_recursive.
This issue also appears on line 1042 of the same file.
let mut set = alloc::collections::BTreeSet::new();
set.insert(inner.clone().into_value());
let mut outer = Object::new();
outer.insert(
4c0529f to
a619dbf
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 19 changed files in this pull request and generated 2 comments.
Suppressed comments (3)
tests/sarif_memory.rs:220
- This bound does not regress the Frozen optimization: the comment records the old BTreeMap baseline at roughly 30×, while the assertion allows 50×. Reverting the compact tier would therefore still pass. Add a stable representation-focused assertion for recursively parsed objects or a platform-calibrated bound that fails on the documented baseline.
let max_blowup = 50.0;
assert!(
value_blowup < max_blowup,
src/value/object/mod.rs:164
iter_mutcannot structurally modify keys, but it currently performs an O(n log n) thaw and leaves the object in BTree storage for value-only updates. This also affects the RVM object-template update path insrc/rvm/vm/dispatch.rs:516. Add a Frozen variant backed bycore::slice::IterMutso mutable value iteration preserves compact storage, consistent withget_mut.
// Frozen storage is read-only, so thaw it in place before handing out
// mutable element references. After this, repr is never Frozen.
self.thaw();
src/value/object/mutate.rs:126
- This arm thaws every frozen object even when
keyalready exists. In that case the API only exposes a value mutation, so commonget_or_insert_withcall sites unnecessarily rebuild the compact boxed slice as aBTreeMapand permanently lose the Frozen tier. Probe the slice first and thaw only when the key is absent.
Repr::Frozen(_) => {
let repr = core::mem::take(self);
*self = match repr {
Repr::Frozen(v) => Object::thawed_repr(v),
other => other,
};
self.get_or_insert_with(key, default)
Introduce a compact storage representation for Object values to cut per-object memory overhead. `Object` now uses a private `Repr` enum: - Empty no allocation - Inline(Box<SmallVec<[..; 2]>>) small maps in one compact allocation - Frozen(Box<[(k, v)]>) read-mostly objects packed into a boxed slice - BTree(BTreeMap) large, mutable ordered maps Objects deserialized/constructed as data are frozen into boxed slices; the first structural mutation thaws back to an inline SmallVec (<= INLINE_CAP) or BTreeMap. Value-overwrite and get_mut of an existing key stay frozen (value-only mutation preserves storage). Call sites that mutate objects (builtins, engine, interpreter, rvm, azure_policy templates) are adapted to the freeze/thaw boundary. This is an internal representation change with no public API changes. Freezing is depth-bounded (MAX_MERGE_DEPTH) to prevent a stack overflow on adversarially nested data that reaches freezing via programmatic construction. Iteration uses a self-owned, key-based ObjectCursor that resumes correctly across storage transitions. The object module is split into cohesive sub-300-line files (mod/mutate/freeze/cursor/iter/serde). Adds freeze/thaw unit tests (src/value/tests.rs) and SARIF/corpus memory-residency integration tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ac07e53-88fc-4050-bd56-c0d9761b911b
A stale/partial OPA checkout (left by a cancelled run or restored from a build cache) can exist under target/opa/branch/<ver> without a `.git` directory. `git clone` refuses to clone into a non-empty directory, so the harness failed with "destination path already exists and is not an empty directory", breaking the debug/release/musl CI test jobs. Remove any such stale directory before cloning. Cherry-picked from microsoft#789. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ac07e53-88fc-4050-bd56-c0d9761b911b
Deduplicate object *keys* while parsing JSON in `Value::from_json_str` (and thus `from_json_file`). A homogeneous array of N objects that share K keys now allocates K key strings instead of N * K; every repeated key becomes a reference-count clone of a single `Rc<str>`. Interning is scoped to a single parse via a thread-local table installed by an RAII `InternGuard`, so there is no thread-lifetime cache: the table is torn down when the parse completes (including on the error path). Only keys are interned -- string *values* are left untouched since their cardinality is unbounded. The new `interning` module is std-only; under `no_std` the hooks compile to no-ops. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ac07e53-88fc-4050-bd56-c0d9761b911b
a619dbf to
2430075
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 19 changed files in this pull request and generated no new comments.
Suppressed comments (3)
tests/sarif_memory.rs:218
- The asserted limit is higher than the documented ~30× unoptimized
BTreeMapbaseline, so this test still passes if parsed objects stop using Frozen storage and the principal optimization fully regresses. Add deterministic coverage that nested objects produced byfrom_json_strare Frozen, or calibrate a platform-tolerant bound that actually distinguishes the optimized representation.
// Coarse sanity bound: even an unoptimized BTreeMap-backed Object should
// not balloon to more than ~50× the JSON size for the un-amplified case
// (MULT=1). The historical baseline (MULT=50, BTreeMap) was ~30× live
// blow-up; we leave plenty of headroom for the unamplified case so this
// assert only catches genuinely catastrophic regressions.
let max_blowup = 50.0;
src/value/object/mod.rs:164
iter_mutonly exposes mutable values, so it cannot structurally change the object, yet this eagerly expands every Frozen object into aBTreeMap. That contradicts the freeze/thaw lifecycle used byget_mutand can discard the memory savings for parsed objects during bulk value updates. Add a FrozenIterMutInnerbacked byslice::IterMutand map each pair to(&key, &mut value)so value-only iteration preserves compact storage.
pub fn iter_mut(&mut self) -> IterMut<'_> {
// Frozen storage is read-only, so thaw it in place before handing out
// mutable element references. After this, repr is never Frozen.
self.thaw();
src/value/interning.rs:10
- This overstates the allocation reduction: each key has already been allocated as an
Rc<str>by serde beforeintern_keyreceives it, and duplicate candidates are then dropped. Interning reduces retained key-string allocations, not the number of key-string allocations performed during parsing; document that distinction so allocation-count and residency results are interpreted correctly.
//! keys parsed while a table is installed are deduplicated: a homogeneous array
//! of `N` objects sharing `K` keys allocates `K` key strings instead of
//! `N * K`, and every later occurrence becomes a reference-count clone. Only
a7a1534
into
microsoft:main
Summary
This PR introduces a Frozen storage tier for
Object(the abstraction backingValue::Object, already merged in #736), reducing steady-state memory for parsed JSON/Rego documents where objects are built once and read many times.Storage tiering
Object's internalReprnow has three variants:Empty— no entries, zero heap allocation.Frozen(Box<[(Value, Value)]>)— a single, tightly-packed, sorted boxed slice. This is the compact tier used for objects that are read-only at the time of freezing (e.g. right after JSON/Rego parsing). NoBTreeMapnode/pointer/allocator overhead per entry.BTree(BTreeMap<Value, Value>)— the general-purpose mutable tier, used while an object is actively being built or mutated (inserts/removes) before it is frozen.This is purely an internal change behind the existing opaque
Objectnewtype — no public API changes.Object/Valueequality, ordering, iteration, and serialization behavior are unchanged; only the physical storage representation differs.Freeze / thaw lifecycle
Objectis compacted to theFrozentier when it becomes an immutableValue(viaObject::into_value/Object::freeze). During JSON/Rego deserialization this happens bottom-up — each nested object is frozen as it is parsed and then inserted into its (already frozen) parent, so a fully parsed document (Value::from_json_str, engine input/data) ends up entirelyFrozenwith no separate top-down pass. Because freezing is a byproduct of construction, transient values computed during evaluation are left in their natural mutable tier rather than being eagerly re-frozen.FrozentoBTree) automatically on first structural mutation (insert of a new key, or remove). Overwriting the value of an existing key (including viaget_mut) does not thaw — it mutates in place and the object staysFrozen.Object::freeze) after a batch of mutations re-compacts back down to theFrozentier.Memory motivation
On a SARIF-shaped memory-residency measurement (amplified workload,
MULT=50, seetests/sarif_memory.rs), the Frozen storage tier showed roughly a 4.86x reduction in live heap residency for the parsed document versus the previousBTreeMap-only baseline.tests/sarif_memory.rsis included as ongoing regression coverage: it loads the committed fixturetests/data/sarif_memory/{input.json, policy.rego}(~0.37 MiB), runs atMULT=1by default, and can be amplified in memory via theMULTenv var.Object key interning
While parsing JSON (
Value::from_json_str/from_json_file), object keys are now deduplicated through a per-parse, thread-local interning table (installed via an RAII guard for the duration of the parse only — no thread-lifetime cache). A homogeneous array ofNobjects sharingKkeys allocatesKkey strings instead ofN * K, with each repeated key becoming a reference-count clone of a singleRc<str>. Only keys are interned; string values are left untouched (their cardinality is unbounded). The module isstd-only and compiles to no-ops underno_std. On the SARIF fixture this further reduces key-string residency for the repeated schema keys.