fix(query): close the memory-budget blind spot on the non-aggregate scan path - #1521
Closed
aaj3f wants to merge 3 commits into
Closed
fix(query): close the memory-budget blind spot on the non-aggregate scan path#1521aaj3f wants to merge 3 commits into
aaj3f wants to merge 3 commits into
Conversation
aaj3f
force-pushed
the
perf/audit-mem-guards
branch
from
July 18, 2026 22:18
60d407a to
1441e00
Compare
…imate + per-query division F-AUD-3 sites D and C (audit-2026-07/V2-membudget-verification.md §6, §3). D: BINDING_EST_BYTES was a hand-picked 64 — a 27% under-count of the true 88-byte size_of::<Binding>() (binding.rs:14-17), so every accounted operator checkpointed late. Derive it from the type and add a compile-time `const _` guard that refuses any future re-pin below the stack size. It is still a floor (ignores the Arc<str> IRI heap a wide crawl carries, ~2.2x) — documented on the constant. C: set_memory_limit had no production caller, so N concurrent queries each compared their own counter against the FULL process budget (two 5 GB queries both read "under 8 GB" while the node sits at 10 GB). Pin a per-query ceiling of budget / FLUREE_QUERY_BUDGET_SHARE_DIV at the runner attach point. Default div=1 pins nothing — byte-for-byte today's behavior; an embedder's explicit ceiling is never clobbered. The sound dynamic form (divide by ACTUAL live top-level concurrency) needs the server request boundary (query_control.rs) to avoid miscounting nested policy/reasoning/sub-queries, and is deferred there. Tests: binding_est_bytes_is_at_least_binding_stack_size, per_query_ceiling_divides_and_floors, shared_ceiling_trips_each_query_at_its_divided_budget.
F-AUD-3 sites A1 and A2 (audit-2026-07/V2-membudget-verification.md §1, §4). The non-aggregate scan path had zero record_alloc / checkpoint — six check_cancelled only — so a wide crawl was invisible to the R3-B memory budget and OOM'd instead of aborting typed. Specimen 071cd59f (a point-lookup crawl that hard-OOM'd at 10237 MB) lived exactly here. A1: record each materialized window (produced_rows * cols * BINDING_EST) in advance_one_window and upgrade the pull-loop poll from check_cancelled() to checkpoint(), so cumulative window bytes trip a typed MemoryBudgetExceeded (507) before the loop pulls another window. One window is bounded (~materialize_window_rows) so it cannot itself OOM. A2: the fact-as-parent build (build_parent_lookup) transiently materializes a full parent-sized map (tens of millions of entries) unbounded by the memo cap — the cap only refuses to RETAIN it after it is fully built. Thread ctx in, account each batch, and checkpoint inside the build loop so it aborts typed BEFORE the whole map is resident. Both gated by FLUREE_SCAN_MEM_ACCOUNTING (default on; off is a clean revert — the scan records nothing, so checkpoint degrades to a pure cancellation poll). The counter is query-lifetime cumulative (no decrement), conservative for a streaming scan, matching the existing fold/join accounting. Per-file buffer accounting (V2 site B) is excluded — it needs a decrement primitive the monotonic counter lacks. Hermetics: r3b_scan_window_budget_aborts_typed (the 071cd59f regression), r3b_parent_build_budget_aborts_typed.
aaj3f
force-pushed
the
perf/audit-mem-guards
branch
from
July 18, 2026 22:44
1441e00 to
d9c1f4c
Compare
…e-abort)
The live re-bless caught a regression from the A1 scan-window accounting: q038 (a
36M-row un-fused COUNT on the per-row materialize path) false-aborted typed at 38s
("Query memory budget exceeded: ~8.61 GB > 8.59 GB") while completing fine at 52.5s
with FLUREE_SCAN_MEM_ACCOUNTING=off and bounded resident memory. Root cause is the
documented cumulative-no-decrement edge (V2): ~70 sequentially-FREED 512K-row scan
windows SUM past the budget even though only one window is ever resident — a
false-positive typed abort on exactly the long-scan class the accounting protects.
Fix — window-scoped release:
- add QueryCancellation::release (fluree-db-core) + the ExecutionContext::release
wrapper: a saturating decrement of the budget counter, valid ONLY for allocations
with a provable drop point. Documented caller invariant: never release a
persistent allocation (the guard would then under-count live memory).
- pair each A1 window charge in advance_one_window with a release once the window
is emitted/handed off (`produced` drops), so a streaming scan accounts only its
resident window, not the all-time sum. Charge + checkpoint still happen BEFORE
emit, so an oversized single window — or this window atop a retained A2 build or
an upstream fold — still aborts typed.
- A2 fact-parent build charge stays cumulative (that map genuinely persists);
fold/join/fused accounting untouched (their buffers persist too).
Regression test r3b_scan_windows_release_no_false_abort: 64 one-row windows under an
8000-byte ceiling COMPLETE (verified to fail pre-fix at window ~16 with
MemoryBudgetExceeded 8448 > 8000). The single-window abort (071cd59f) and
parent-build abort tests still pass. New core tests cover release saturating-sub +
disabled-handle no-op.
This was referenced Jul 19, 2026
Contributor
Author
|
Consolidating at maintainer request. #1521 (the memory-budget guard on the non-aggregate scan path) is carried forward in #1528 (the big-Iceberg-audit implementation), which brings the audit program forward as a single reviewable unit. Its verification of record is recorded under R-1521 in pr-reviews-impl.md (verdict SHIP; every residual bounded, documented, and over-count-safe), alongside the whole-stack live gate of 77 records / 0 hash mismatches / 0 perf violations. The pre-consolidation branch tip is preserved at tag |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Forest map — where this PR sits
This is PR #2 of 4 in the Tier-0/1/2 implementation of the 2026-07 big-iceberg audit (
audit-2026-07/), the bundle composition decided indecisions/DEC-001-pr-bundling.md. Base = the integration branchperf/audit-tier012(perf tip10e073fe9+ a clean merge oforigin/main≥7581f0a, the #1508 resurrection fix). Siblings: PR-SAFE-MOR (onmain), PR-COVERAGE, PR-HARNESS (the terminal re-bless leaf).00-MASTER-AUDIT.md§2 F-AUD-3;V2-membudget-verification.md§1–§7Four V2 fix sites, all here except V2 site B (per-file buffer accounting — excluded; see Residuals):
BINDING_EST_BYTES64 →size_of::<Binding>()(=88) + compile-time floor guardfluree-db-query/src/context.rsfluree-db-query/src/execute/runner.rsrecord_alloc+checkpointon the materialized scan window, released on hand-off (see below)fluree-db-query/src/r2rml/operator.rs(advance_one_window, pull-loop poll)record_alloc+checkpointinside the fact-as-parent build loop (stays cumulative — persistent map)fluree-db-query/src/r2rml/operator.rs(build_parent_lookup)releasedecrement primitive on the budget counter (pairs the A1 window charge)fluree-db-core/src/cancellation.rs+context.rswrapperA1 window-scoped release (q038 fix). The first cut of A1 charged each window cumulatively with no decrement — the documented V2 conservative edge. The live re-bless proved it bites a mainstream shape: q038 (a 36M-row un-fused COUNT on the per-row materialize path) false-aborted typed at 38 s (
~8.61 GB > 8.59 GB) while completing fine at 52.5 s with the accounting off, because ~70 sequentially-freed 512K-row windows summed past the budget though only one is ever resident. Fix: charge +checkpoint()before emit (an oversized single window, or this window atop a retained A2 build / upstream fold, still aborts typed), thenrelease()the charge once the window is handed off (produceddrops). A streaming scan now accounts only its resident window. A2 and the fold/join/fused builds are not released — they genuinely persist, so their cumulative charge is correct.Why — the V2 worst-case arithmetic
The R3-B memory budget instruments the aggregating/joining operators (
record_allocat 7 sites inhash_join.rs/group_aggregate.rs/fused_aggregate.rs). The non-aggregateR2rmlScanOperatorpath had zero — sixcheck_cancelled()and norecord_alloc/checkpoint. So a single wide crawl (default switches) carries, entirely unaccounted:self.pending≈ 4.4 GB unaccounted for a modest wide crawl, ~11 GB with one fact-as-parent lookup — past both the 8 GiB budget and the 10 GiB Lambda ceiling, with
record_allocseeing zero of it. With no per-query division (site C), N×4.4 GB OOMs a 10 GiB node at N≈2. This is not hypothetical: forensic specimen071cd59f— the point-lookup crawl?ol ex:orderLineKey "1" . ?ol ?p ?o— hard-OOM'd at 10237 MB on exactly this path (R2rmlScanOperatorwildcard crawl +build_parent_lookupover a 36M-row fact). It is now the regression testr3b_scan_window_budget_aborts_typed.After this PR the scan path trips a typed
QueryError::MemoryBudgetExceeded(507, distinct from a 408 timeout) instead of OOMing — A1 catches accumulated bounded windows at the pull-loop checkpoint; A2 aborts the unbounded parent build before the whole map is resident.Design note — the per-query division (site C)
V2 §3:
set_memory_limithad no production caller, socheckpoint()always compared each query's own counter against the full process budget — N concurrent queries each read "under 8 GB" while the node sat at 10 GB.I shipped the sound minimal static form: pin
budget / FLUREE_QUERY_BUDGET_SHARE_DIVat the runner attach (execute_prepared_into, thewith_cancellationseam). Defaultdiv=1pins nothing — checkpoint falls back to the full process budget, byte-for-byte today's behavior; an embedder's explicit ceiling is never clobbered. Operators setdivto their deployment's max query concurrency (e.g. a Lambda's reserved concurrency) to opt into sharing.I deliberately did not ship the dynamic divisor (divide by live concurrency so a lone query keeps the full budget), even though the audit recommends default-on sharing: a correct live count must count only top-level queries, and the runner attach point is also re-entered by nested policy
f:query/ reasoning / sub-queries — an in-engine counter there would over-divide and abort legitimate queries. V2 §7(C) reaches the same conclusion ("the authoritative source is the server that spawns the watchdog and holds the handle,fluree-db-server/src/query_control.rs"). So the dynamic count is deferred to the server request boundary; the static form is present, neutral by default, and sound. A non-1 default is intentionally NOT set here — it would shrink every query's budget and risk aborting today-passing queries in a safety PR.Residuals (deferred, documented)
r2rml.rsfan-out, ≤32 files × up to 256 MB) is excluded: releasing on batch flush needs a decrement primitive the current monotonicAtomicUsizecounter lacks. Adding one is a larger change tofluree-db-core::cancellation; deferred. The file buffers remain invisible to the budget (bounded O(32) bybuffer_unorderedbackpressure, so not unbounded — but GB-scale).BINDING_EST_BYTESis now the true 88-byte stack size but still omits theArc<str>IRI heap (~50–70 B) an Iceberg row carries, so a wide IRI-crawl is counted at ~1/2.2 of true resident bytes. Deliberate floor (over-count only ever aborts a query already near OOM); a heap-aware estimate is follow-up.GROUP_EST_BYTESis likewise a flat estimate.self.pending, ≤ one window, drained before the next pull) is left untracked (minimal). Per-file buffers (site B) still need their own release pairing (deferred).Kill-switch ledger (house rule: no unswitched mechanism)
FLUREE_SCAN_MEM_ACCOUNTINGrecord_alloc+checkpoint=off→ scan path records nothing;checkpoint()degrades to a pure cancellation poll (prior behavior)FLUREE_QUERY_BUDGET_SHARE_DIV=1(default) → full budget per query, exactly todayBoth read via the established idioms (
env_switch_enabledOnceLock for the on/off; parsed env for the divisor).FLUREE_QUERY_MEMORY_BUDGET_BYTES=0still disables the whole budget guard, unchanged.Local gate record — verification of record
CI does not fire on this PR:
ci.ymlgates only whenbase == main, and this PR's base isperf/audit-tier012(verified in DEC-001 Adjudication B.3). The local record below is the verification of record. Reproduced in a worktree at branch headcfd773d75(base = integration tip9c6739c3b, the PR-SAFE-MOR guard + its cache-hit re-apply arm, all influree-db-iceberg/*+fluree-db-api/iceberg_catalog.rs, zero overlap with this PR's four files). This PR touches four files —fluree-db-core/src/cancellation.rs(thereleaseprimitive) plusfluree-db-query/src/{context.rs, execute/runner.rs, r2rml/operator.rs}:cargo fmt --check— the four files this PR changes are clean (0 diffs each). The workspace check reports 2 pre-existing drift spots influree-db-query/src/hash_join.rs:1070,1120that this PR does not touch — present at the base (git show origin/perf/audit-tier012:fluree-db-query/src/hash_join.rs | rustfmt --checkshows them), part of the perf line's known fmt debt, left for the hygiene pass (db-verify-gotchas: don't fix pre-existing debt in a scoped PR).cargo clippy -p fluree-db-core --all-targets --no-deps— clean re: this PR (1 pre-existingunused importincommit.rs:943, untouched by this PR).cargo clippy -p fluree-db-query --all-targets --no-deps— clean, 0 warnings (clippy 1.97.0).cargo test -p fluree-db-core— 704 lib + bins pass, 0 failed (incl. the 2 newreleasetests).cargo test -p fluree-db-query— 1296 lib + all integration bins pass, 0 failed (incl. the 6 new hermetics).cargo test -p fluree-db-api(grp_* bins, full package) — all bins pass, 0 failed (99 ignored = live-credential Snowflake/Iceberg suites).No
fluree-db-serverchanges (site C lives in the query runner, not the server), so that gate is not applicable. Thereleaseprimitive lands influree-db-core(the budget counter's home) — minimal (a single saturating decrement), per the design guardrail not to introduce a general decrement surface beyond the window pairing.Hermetic tests (extend the
r3b_*_budget_aborts_typedpattern)r3b_scan_window_budget_aborts_typed— specimen 071cd59f regression: a?s ?p ?owildcard crawl with a 1-byte ceiling aborts typed (A1; still aborts after the release fix — the checkpoint fires while the window is charged, before it is released).r3b_scan_windows_release_no_false_abort— q038 regression: 64 one-row windows under an 8000-byte ceiling COMPLETE (verified to fail pre-fix at window ~16 withMemoryBudgetExceeded 8448 > 8000) — the released per-window charge never accumulates.release_subtracts_and_saturates_at_zero+disabled_handle_release_is_a_noop(fluree-db-core) — thereleaseprimitive decrements, is shared across clones, and saturates.r3b_parent_build_budget_aborts_typed— a fact-as-parentbuild_parent_lookupwith a 1-byte ceiling aborts typed on the first batch (A2, still cumulative).shared_ceiling_trips_each_query_at_its_divided_budget+per_query_ceiling_divides_and_floors— two queries under a divided ceiling each trip at budget/N, not the full budget (C).binding_est_bytes_is_at_least_binding_stack_size— the 88-byte canary; the>= size_ofinvariant is a compile-timeconst _next to the constant (D).