perf(virtual): R2RML/Iceberg virtual-dataset performance program — corpus, pruning, fused aggregation, budget forwarding (consolidates #1475–#1507) - #1507
Conversation
…(WP1) Verified strategy inventory (11 strategies + SPARQL routing) with trigger/ bypass conditions and file:line anchors at a5528e8; corrects 7 claims from the exploration pass (kill-switch name, != pruning nuance, ScanValue shape, catalog-session pin API) and completes the env-switch master table. Hypothesis map H1-H8 links each suspected pathology to the inventory and to the benchmark evidence that will confirm or refute it.
…pport (WP3) Fills the reserved stub: allowlisted span capture with creation-time and late-recorded fields (on_record merging for iceberg.scan_plan counters), span-lifetime timing, in-memory take() sink, per-layer span_name_filter so composed subscribers are unaffected, and the FLUREE_BENCH_TRACING=file: JSONL mode with FLUREE_BENCH_SPAN_ALLOWLIST.
Attempted to falsify H1/H2/H5 and the §0 corrections rather than re-confirm: - C2 reclassified: '!= never prunes' stands — no NotEq scan filter is ever produced on the R2RML path; the engine's degenerate-!= prune is unreachable. - H2 refined: ORDER BY..LIMIT is a bounded streaming top-k (not a full sort buffer) but forwards no budget, so the scan still fully drains; adds the fixable-vs-scan-bound split of the four budget-absorbing modifiers. - H1/H5 survived stronger: zero with_row_filter call sites in the iceberg crate; R2rmlTableProvider exposes no count entrypoint.
…P5 spec) BI-grounded corpus spec with feature tags, hypothesis linkage (H1-H8 each >=3 queries w/ dims-only controls), expected-row oracles from generator invariants, order-sensitivity + canonicalization flags. Key A/B pairs: decimal-vs-date/int pruning on the GL table (H4, the model's only decimal columns), pure-LIMIT vs ORDER-BY-LIMIT (H2), VALUES-vs-FILTER (H8), SCD-2 current-vs-history correctness oracle. SCD oracle live-verified on enterprise-sf01: 300,000 current / 390,000 total.
In-process runner over the graph query builders comparing native ledgers vs R2RML/Iceberg virtual graph sources: corpus manifest w/ closed feature-tag enum + validation, per-query wall timing (priming + median-of-reps, adaptive), BenchSpanLayer pathway counters (scan_table/load_table/ parquet_read/scan_plan/oauth_token) w/ spans_missing honesty flags, canonical result hashing (sorted multiset, float quantization), pacing + deadline w/ dnf-as-data, JSONL run records w/ env fingerprint, side-by-side ratio report. Subcommands: setup --verify | run | exec-one | report. Seed corpus: 5 dims-only smoke queries. Validated end-to-end on enterprise-sf01 (all ok).
… + oracle caveats H2 membership now requires an actual LIMIT above the absorbing modifier (6 queries, entries and matrix reconciled; Q29 gained its claimed LIMIT). Predicate audit vs enterprise.ttl clean (63 tokens, 0 misspellings). Cardinality bounds corrected (Q02/Q42/Q10). New bless-from-native section: native ledger is the correctness oracle; expected-error queries Q43/Q44 must not be blessed from native (no R2RML router there to reject them).
Replaces the 5 seed queries with the corpus-design set: 26 BI questions -> 54 queries (q001-q054), real lineage headers, full feature-tag enum incl. filter_iri, 16-query smoke subset covering every tag, per-target expected_status for the error-boundary pair (q043/q044: native ok-empty, virtual GRAPH-scope error), property-path probes as ok-but-slow H8 perf probes (live-settled: 18.75s dims-only vs ~ms native). Validated: 13 unit tests green + full native run, 54/54 ok within expected bounds (max 1.73s). Adds the DW_SF01 mapping + activates the virtual-sf01 target (registered against ENTERPRISE_DEMO.DW_SF01, loaded 2026-07-10, exact generator row counts).
7 findings, each with rows/hash/wall/span evidence + mechanism anchors: - F1/F2 SILENT correctness bugs: transitive property paths and subqueries return empty with status=ok — sub-scopes escape the triple-scoped unconverted-pattern error guard (graph.rs:245-253); one fix closes both. (Sequence paths decompose to triples and work — slow, H8.) - F3 confirmed: bound-subject wildcard omits the rr:class rdf:type triple (q042 24 vs 21 rows; UI subject inspector loses @type on virtual). - F5 perf-DNFs incl. the H3 anchor: dims-only OPTIONAL at 377 correlated scan_table calls, native 95ms vs virtual DNF@120s. - F6 positives: fused-agg 1.52x native; date pushdown pruned 98.8% of files. - F7 harness: iceberg.scan_plan fires only on the pushdown branch. Corrects q034's manifest note (transitive = silent-empty, not slow-but-ok).
…col (WP6) - vbench baseline --expected/--perf: blesses per-query oracles from the native target (52 expected files; expected-virtual-error queries get none by design) + perf medians per target. - vbench compare --gate: expected-hash + budget-ratio gating (budgets.json, query overrides > class defaults; cold advisory-only) with one in-process auto-rerun before declaring a perf violation red. - Cold protocol: run --cache-state cold spawns exec-one --cold children; a home-scoped iceberg cache (<home>/.vbench-iceberg-cache via ledger cache config) replaces the machine-global $TMPDIR/fluree_binary_cache for vbench targets, so cold clears are hermetic and guarded (path assertion refuses non-cache dirs; unit-tested). - Bonus: dashboard subcommand (self-contained HTML) + survey caps. Validated: 21 tests green; compare --gate self-check exit 0 (ratios ~1.0); cold q001 virtual: 20.8s cold vs 2.3s warm, forced oauth/loadTable/ parquet_read/scan_plan spans, and result hash MATCHES the native blessed oracle — end-to-end parity proof across both pipelines.
…less oracles Every ORDER BY + LIMIT query gains a unique tiebreaker (subject IRI / unique var) so hash gating stays exact under ties; pure-LIMIT queries where any k rows are valid gate on row count + invariants via new manifest hash_gate: rows_only (default full). Register updated: F4 reclassified corpus-defect / engine exonerated (tie + selection nondeterminism, head-diff verified); F8 added (single-class q001 loads 6 tables — WP7 over-scan lead). All expected oracles re-blessed from a fresh 54/54-ok native run (native-full-v2) since amended queries invalidate their previous hashes.
rows_only queries gate on row count + invariants instead of exact hash (nondeterministic-selection queries per the corpus determinism policy).
… SF20 survey - perf/virtual-sf01.json: all 54 hot records (4 merged run files; 24 ok / 30 dnf) + 6-query cold subset (q001 cold 19.4s vs 2.2s hot; the 5 already-DNF queries stay DNF cold). - Error-boundary expectations corrected from observation: q043 lang-tag object is ok/0-rows on virtual (GRAPH-error expectation was wrong; oracle now blessed), q044 custom-datatype burns to DNF instead of erroring. - SF20 survey (16 smoke queries, informational): completing queries barely degrade at 200x fact scale (fused-agg 1.6s, date-pruned scan 427K rows in 4.2s) while the same four pathologies DNF identically — the wall is structural, not scale-induced. Survey runs are bless-refused by design. - Final dashboard over all four run sets at results/dashboard.html (gitignored).
…k, q044 dnf — neither errors)
…ter ranking Master bottleneck: decode is FILE-COUNT-bound (~200ms fixed per file x 7,670 files per fact table / 8-way concurrency cap ≈ 197s → DNF; measured ~39 files/s across three independent DNF shapes). Most fact queries never reach their downstream operators — H6/H3 are gated behind H1. COUNT(*) prints the exact answer (manifest estimated_row_count) then decodes everything anyway. F8 root-caused: shared base predicate (edw:name) fans the star to 6 dims; class fusion correctly refuses. Toggle proofs: LIMIT_PUSHDOWN=0 reproduces the ORDER-BY DNF exactly; FUSED_AGG=0 = 1.48x. No wrong-turn redesigns — architecture sound, under-optimized for tiny-file reality + shared-predicate stars.
…orpus-gated PR-0 correctness (silent-empty loud-refuse guard + bound-subject rdf:type) → PR-1 COUNT(*) manifest shortcut → PR-2 H1 decode-wall levers (per-file cost decomposition first, concurrency cap, manifest fast paths; compaction as data-side guidance only) → PR-3 star over-scan → PR-4 parent-lookup memoization → PR-5 scan-side top-k (design sketch) → PR-6 fused-agg-over- join (gated behind PR-2) → PR-7 decimal pushdown + counters → PR-8 cold/ floor caching program. Every PR carries corpus-grounded impact (which queries flip), risk/blast-radius, kill-switch semantics, and the shared DoD: targets at goal + no corpus regression + parity + W3C + BSBM/native budgets + switch-off = old behavior. The corpus is the contract.
A PropertyPath (transitive +/*) or Subquery inside a GRAPH scope over an R2RML source is not lowered to R2RML leaves and evaluates against the source's empty native index, silently returning zero rows as success (corpus q034: 0 vs native 4,514; q051: 0 vs 247). The whole-scope guard counted only unconverted top-level triples, so sub-scopes escaped it. Track non-lowered sub-scope kinds (property path, shortest path, subquery) through the rewrite recursion and fail the GRAPH scope with InvalidQuery, matching the unconverted-triple guard. Patterns that only transform bound solutions (FILTER/BIND/VALUES/UNWIND) and search patterns that route by their own graph_source_id are deliberately not flagged. No lowering is attempted here; this converts silent-wrong into loud-refuse only. Corpus: q034/q051 expected_status flips to virtual=error.
<iri> ?p ?o over a virtual dataset returned the POM data predicates but omitted the rr:class-derived rdf:type triple (corpus q042: 21 vs native 24 rows; the UI subject inspector showed no @type for virtual subjects). The subject-var wildcard path already binds the class; the bound-subject prefix-prune branch skipped class emission. Emit ?p=rdf:type / ?o=<class> per matching TriplesMap class in the bound-subject true-wildcard materialization, gated to a variable predicate with no predicate filter and no fused type-var, and only for a matched subject row (dangling IRIs still produce nothing).
…until provider impl) R2rmlTableProvider gains table_row_count (default Ok(None) — opt-in), and the fused aggregate answers a bare COUNT (one CountRows fold, no GROUP BY, no FILTER) from it when the provider can prove manifest count == full scan. The non-null proof columns are parsed from the subject template STRING (template_columns is loader-only; a with_subject_template mapping would otherwise pass the null guard vacuously). With no provider impl yet every call falls back to today's scan — corpus and suites unchanged.
…R-1 provider side) FlureeR2rmlProvider implements table_row_count: resolve the SAME per-query pinned table context the scan uses (new load_table_context helper, shared verbatim loadTable resolution incl. the IcebergCatalogSession snapshot pin), read only the manifest-list/manifest Avro (never a data file), and return the record_count sum when sound_manifest_row_count proves it equals a full scan: no delete manifests, and every subject/object non-null column provably zero-null (absent/partial stats are unknown, never zero; unknown column or would-be-negative total declines). Constant-subject mappings need no null proof. Declines fall back to the always-correct scan. Scan-only concerns (scan-start log, Parquet disk cache) stay in scan_table_inner — a COUNT emits no phantom scan log and builds no cache. Live SF01: fact COUNTs q036/q037/q039 DNF@180s -> 2.8-4.4s with exact counts hash-equal to the native oracle; the residual is entirely the cold REST floor (oauth+loadTable). Fused-scan sentinel and GROUP-BY negative control unaffected; FLUREE_FUSED_R2RML_AGG=0 restores the scan path. Native corpus 54/54, 0 hash mismatches across three runs.
…the footer read (PR-2 phase 1) Measured (q011 91 files + q046 7,670 files, cold vs warm): the fixed per-file cost is the Parquet footer read — 2 sequential S3 range GETs (~190ms) that never warm because (1) ParquetFooterCache caps at 64 entries vs 7,670 files/table, and (2) read_metadata range-reads the footer from SOURCE S3 even when the whole file is in the local disk cache. Decode is 0.13ms; spawn/channel 0.17ms; whole-file fetch warms to 0.23ms. Concurrency clamp verified min(cpus,8); sweep 8->32 = 2.1x with per-file wall flat. Reconciles the DNF wall exactly: 7,670 x 195ms / 8 = 187s. Adds 4 additive debug sub-spans (footer/fetch/decode/plan) to the parquet read path + bench allowlist — the ongoing counters that will verify the PR-2 levers.
…oncurrency (PR-2) The fact-scan DNF wall was one cost: ~200ms/file of Parquet footer reads — 2 sequential S3 range GETs that never warmed (footer cache capped at 64 entries vs 7,670 files/table; footers range-read from source S3 even with the whole file in the local disk cache). Decode itself is ~0.1ms. Lever A (FLUREE_ICEBERG_FOOTER_FROM_CACHE, default on): for files in the whole-file-fetch tiers, parse the footer from the bytes we fetch anyway — disk-cache hit serves footer+data locally (A2); the cheap <=32MB tier collapses 3 S3 round-trips to 1 (A1). Sparse-range/large files keep the existing footer-first path unchanged. Switch off restores prior behavior exactly (verified: footer_read 208ms/file, prior wall reproduced). Lever B: decode concurrency default min(cpus,8) -> min(cpus,32) — pointwise never below the old default; FLUREE_ICEBERG_SCAN_CONCURRENCY still wins; 32 = measured contention ceiling (8->32 = 2.1x on a 91-file scan). Live SF01: warm per-file 191ms -> 0.67ms (footer 190,627us -> 21us, n=91); q011 3.27s -> 620ms, q001 2.19s -> 1.06s, q022 675 -> 440ms, hash-verified. q046 (ORDER BY over 7,670 files) FLIPS DNF@180s -> 45.9s fully cold (footer 20us/file cold; residual is pure data-fetch S3 GETs). Native corpus 0 hash mismatches. grp_misc multilang flake explained: pre-existing thread-local-subscriber order dependence (test doc says 'only test in this binary'; bundled with 241 siblings), perturbed by new callsites — 237/237 single-threaded; separate test-infra follow-up.
… + disjoint-class hint (PR-3) A same-subject star resolved its TriplesMaps by BASE predicate alone, so a typed dim star sharing a common predicate (edw:name) fanned out to every name-bearing table (q001: 6 loadTables/scans for a 500-row single-table query). Two provably-safe resolution prunes behind FLUREE_R2RML_STAR_TM_PRUNE (default on): (a) All-members intersection: only TMs supplying EVERY star predicate can produce a complete star row; resolution filters to that intersection. The all-members-required assumption is pinned by test (optional members never fuse into stars). (b') Disjoint-class hint: when the star's co-located class was NOT fused (class in a different TM) but wildcard_class_fusion_is_safe proves every non-class TM subject-template-disjoint from the class-declaring TMs, prune resolution to the class TMs — the standalone class scan joined on subject would drop the pruned rows anyway. Resolution-only; rdf:type emission and materialization unchanged. The ROADMAP's original fix (b) (prune to class-declaring TMs whenever fusion is refused) was proven UNSOUND under vertical partitioning (counterexample in ROADMAP annotation; regression test class_prune_hint_refused_under_vertical_partition). Crawl-side fan-out needed no work: already class-constrained (81b0ec6). Live SF01 (16-core host): q001 cold 14.18s -> 2.94s (4.8x), load_table n=6 -> 1; switch-off faithfully reproduces the fan-out. Native corpus 55 queries, 0 hash mismatches; q022/q054 sentinels hash-OK. q002/q042 mismatch proven PRE-EXISTING (identical both switch states): native/virtual IRI-serialization divergence, filed as F9 (product decision escalated). Suites: query 1200/1200 (6 new tests), api all bins green.
… crossed-write duplicate)
… scan operator (PR-4) The correlated R2RML scan rebuilt every RefObjectMap parent lookup HashMap per 1000-row child batch (build_progress), so a fact⋈dim query re-scanned its dimension parents ~once per batch: q008 (fact⋈dim GROUP BY revenue rollup) burned 123+ table scans and DNF'd. Cache ParentLookups across batches on the operator (Arc-shared, keyed by LookupCacheKey), window- capped: a lookup larger than one materialize window is used for its batch but NOT retained — a fact-as-parent (q015) falls through to today's per-batch build, so the cache cannot OOM. Kill switch FLUREE_R2RML_PARENT_MEMO (default on), read per-operator at construction so both regimes are deterministically testable. Live SF01: q008 memo ON = ok, 8 scans (DNF -> completing); OFF = DNF@75s, 123+ scans — the on/off scan split is load-immune and self-attributing. q008's hash mismatch is pre-existing (identical both switch states; F9-class). q015 fact-parent: no OOM (RSS ~538MB), falls through as designed. Native corpus 55/55, 0 hash mismatches. CI-enforced counting test: 5 child batches against a mock provider = exactly 1 parent scan memo-on, 5 memo-off. Query suite 1201/1201. EXPLICITLY OUT OF SCOPE: q050's correlated-OPTIONAL per-row operator rebuild, which resets any operator-scoped cache — PR-4b will widen inner_pattern_is_hash_join_safe (optional.rs:1418) so R2RML OPTIONAL inners take the batched hash-left-join instead of per-row rebuild.
…ched OPTIONAL hash-join Why widening inner_pattern_is_hash_join_safe for Pattern::R2rml is likely sound (same seeded construction as the per-row path; referenced_vars has an R2rml arm), the shape partition (scalar POM + single-valued ref = safe Triple-analogs = exactly q050's inner; star/type-var sound-but-test-first; object-only-correlation sound-unoptimized), and the one soundness-critical prerequisite: R2rmlPattern::referenced_vars completeness (P1). Plus the q008 false-mismatch harness note (JSONL meta-line jq artifact).
…; q008 confirmed deterministically correct
bplatz
left a comment
There was a problem hiding this comment.
Approving with two definite issues flagged inline — I'd want both resolved before this lands, but neither is structural, so I don't think they need another review round.
Verified locally at this head: cargo test -p fluree-db-query → 1261 passed, 0 failed.
What I checked and can confirm
I concentrated on the engine-wide surface, since that's the part that reaches native queries, and it holds up:
- The forward/absorb classification is correct where it matters.
Offsetforwardsbudget.saturating_add(self.offset)— the off-by-offset that would have turned aLIMIT k OFFSET minto a short result isn't there.Joinabsorbs, with an accurate comment about why a join's output cardinality isn't its left input's.Graphdeliberately doesn't forward toself.child. BindOperatorforwarding is sound. BIND is genuinely 1:1 and order-preserving — an expression error leaves the variable unbound but keeps the row — so the reclassification from absorb to forward is right.- Float pruning is NaN-safe, which is the classic way this goes wrong.
value_codec.rs:50-59makes a NaN operand yieldNonerather thanSome(false), andbounds_can_contain'sunwrap_or(true)turns that into keep. Thebounds_can_contain_keeps_on_nan_boundtest pins it. - The top-k
karithmetic is right (LIMIT + OFFSET), andOffsetOperatordeliberately does not implementset_topk, so the directive can't cross an OFFSET under-counted. Declining is the safe direction. - The COUNT(*) manifest shortcut declines on delete manifests independently of #1520's guard, so those two don't conflict.
The two definite issues
insert_dim_gkeystreats an equal-value duplicate dim join-key as harmless. It's harmless for group assignment but not for multiplicity — the fused fold under-counts relative to the generic pipeline. Detail inline.PersistedScanFilescarries no delete flag (unlikePersistedCountStats, which does). Once #1520 is onmain, that lets the MoR override leak past a process restart. Detail inline.
Three worth a look items inline as well — none blocking.
Two notes on the program, not this diff
- The stated merge order is load-bearing, not just convenient. Issue 2 is contained only because #1520 lands first and #1528's
CACHE_FORMAT_VERSIONbump lands after. Worth stating that as a constraint on the merge rather than leaving it implicit in the reading order — if #1507 ever landed ahead of #1520, disk entries written by a guard-less build would be served by a guard-ful one. - On the five flagged perf violations (F21): the attribution to catalog-GET latency convinced me —
fetch_bytes ≈ 0and none of the five contains a UNION or BIND, so F17 is provably inert for them. Agree with not re-blessing on a catalog-slow day.
The consolidation itself reads well, and the #1503 scope-narrowing note in the body — walking back the "credential-free first-ask" claim to intra-window once F21 attribution showed the TTL bound — is the kind of correction that's easy to leave out. Good that it's there.
| key: Vec<String>, | ||
| gkeys: Vec<GKey>, | ||
| ) -> bool { | ||
| match map.get(&key) { |
There was a problem hiding this comment.
Definite issue — the equal-value duplicate arm under-counts.
The conflicting-duplicate decline is right, and the reasoning above it is right. But Some(_) => true keeps a single map entry for an equal-value duplicate, and GroupKeyResolver.map is probed once per fact row, contributing once. The reference pipeline doesn't do that: a fact row joining to two dim rows produces two output rows.
Concretely — join on a non-PK column, group by an attribute:
DIM: (id=1, region_code='X', region_name='West')
(id=2, region_code='X', region_name='West')
FACT: joins on region_code, GROUP BY region_name
Both dim rows generate distinct subject IRIs (the subject template keys on id, not region_code), so there's no RDF triple-dedup to collapse them. The generic path emits two rows for each matching fact row, both landing in group West; the fused fold emits one. COUNT(*) comes back at half, and SUM likewise.
The prev != gkeys test is the wrong discriminator: it asks whether the duplicates disagree about which group, but the cardinality bug is present precisely when they agree. What makes an equal-value duplicate safe isn't equal group-keys — it's the join key functionally determining the dim subject, which is what actually collapses the two rows to one.
The conditions are the ones the doc comment already names as reachable (name-based FK inference, hand-written mappings, unverified subject-keys), and SF01's unique PKs are exactly why the corpus can't catch it.
Simplest sound fix is to decline on any duplicate join key, equal-valued or not, unless the join key is known to determine the dim subject. dim_dup_join_key_conflict_declines_equal_dup_kept would need its second assertion inverted — it currently pins the behavior in question.
There was a problem hiding this comment.
Confirmed — this is precisely the latent defect the audit line had already identified and fixed (decline on ANY duplicate parent key), and your functional-determination framing is the right articulation of why equal group-keys was the wrong discriminator. Forward-ported here in 0b4269a per your ask: the equal-dup arm now declines, the pinning test is inverted and renamed (dim_dup_join_key_always_declines), and the stale outer doc sentence ("harmless and kept") is corrected — it was wrong at both rungs. The function body, in-body comment, and test are byte-identical with the audit line's version, so the eventual merges auto-resolve with no divergence.
| /// off, so `Arc` can't derive `Serialize`; the loader re-wraps in `Arc`). | ||
| #[derive(Serialize, Deserialize)] | ||
| struct PersistedScanFiles { | ||
| data_files: Vec<DataFile>, |
There was a problem hiding this comment.
Definite issue — this struct needs the delete flag its sibling has, once #1520 is on main.
PersistedCountStats (just below) persists has_delete_manifests. PersistedScanFiles doesn't, and the scan-files hit arm in r2rml.rs rebuilds FileScanTasks from the cached data_files without re-entering plan_scan — which is where #1520's fail-closed guard lives.
For the in-memory cache that's harmless: entries are only written from the guarded miss arm, the key is an immutable metadata_location, and the cache dies with the process. Persisting it breaks the last of those three:
- Operator sets
FLUREE_ICEBERG_ALLOW_MOR_DELETES=1to read a delete-bearing table. - The scan proceeds under the override and writes the file list to disk.
- Operator unsets the env and restarts, believing the guard is back on.
- The next query hits the persisted entry, rebuilds tasks around
plan_scan, and silently returns deleted rows — with the guard nominally active.
So the override stops being a per-process escape hatch and becomes a durable one, which I don't think is the intent.
I see #1528 closes this (delete flag on the entry, re-check on both hit arms, CACHE_FORMAT_VERSION 2→3). Since #1507 lands first, either pull that flag forward here, or don't persist scan-files entries written under the override. The CACHE_FORMAT_VERSION bump alone doesn't fix it — a fresh post-bump entry written under the override has the same problem.
There was a problem hiding this comment.
Confirmed as a defect, with one correction to the remediation path: the audit-line fix cannot be pulled forward as-is, because this rung has no MoR guard at all — mor_guard.rs, the override env, and ensure_no_delete_manifests all arrive with #1520, which branched from much newer main, and the cache-arm commit calls those symbols directly. What landed instead (0247c79) is guard-independent and closes the same hole standalone: PersistedScanFiles gains has_delete_manifests (field and serde shape byte-identical to the audit line's, so the schemas converge), stamped from a manifest-list-only detection mirroring what the COUNT path already does at this rung, and BOTH hit arms treat a flagged entry as a miss — a delete-bearing table simply never serves a cached scan plan here, it re-plans. CACHE_FORMAT_VERSION 2→3 rides along, so pre-flag v2 entries are invalidated wholesale rather than deserialized with a default. Net effect on your sequencing concern: no intermediate state of main carries the override-persistence leak in any order — once #1520 and the audit line land, the override-aware guard_cached_scan_files supersedes this conservative arm at the already-anticipated reconciliation point.
| // honors it ONLY if the key resolves to a single scalar scan column, | ||
| // else no-op. Pure optimization — this `SortOperator` remains the | ||
| // authority for the exact (compound) order + LIMIT. | ||
| if can_topk { |
There was a problem hiding this comment.
Worth a look — the can_project_distinct_before_sort branch above computes k and can_topk identically but never offers set_topk.
I believe the omission is correct: DISTINCT sits below the sort on that path, so k rows out of the scan can dedup to fewer than k and the pushdown would genuinely lose rows. But nothing says so — both branches now look like near-copies where one has the offer and the other doesn't, which reads as an oversight rather than a decision.
Given this comment block does a good job explaining why ASC declines, it'd be worth a matching line on the other branch explaining why DISTINCT-before-sort declines. Otherwise the obvious "clean up the asymmetry" edit is a silent correctness regression, and no test would catch it — topk_directive_carries_limit_plus_offset only exercises this branch.
There was a problem hiding this comment.
Comment added in 067e792 stating the decline rationale (DISTINCT below the sort means k scan rows can dedup to fewer than k, so offering set_topk would under-produce — deliberate omission, mirror of the ASC note), so the "clean up the asymmetry" edit now has a written reason not to happen.
| // count (1) here, not the forwarding site. That classification is the | ||
| // load-bearing contract; it lives in the trait doc, so this lever | ||
| // stays sound only as long as the doc's absorb list does. | ||
| if self |
There was a problem hiding this comment.
Worth a look — the COUPLING note is the right instinct, but it under-describes the surface it's protecting.
It points at the forward/absorb classification in the Operator::set_row_budget doc, which reads as "an operator either forwards to its child or absorbs." GraphOperator is neither: it absorbs w.r.t. self.child but threads the budget into the per-parent-batch correlated inner subplan (graph.rs:357 and :536). So a union inside a GRAPH scope can receive a budget by a route the absorb-list framing doesn't describe.
I worked through it and I believe it's still sound — the inner subplan's rows flow to the same LIMIT, so budget buffered rows remain a valid k-subset, and each parent batch rebuilds the inner tree with a fresh budget. But that's a second, subtler argument than the one written here, and it's the one a future editor is most likely to miss. Worth a sentence naming the correlated-inner route explicitly, since that's the path where "row/order-preserving all the way up to the LIMIT" isn't literally true.
There was a problem hiding this comment.
Extended in 8aaa1eb: the note now names the correlated-inner route explicitly (absorbs w.r.t. self.child, threads the budget into per-parent-batch inner subplans) and the second soundness argument — fresh budget per batch, inner rows flowing to the same LIMIT — that a future editor would otherwise have to rediscover.
| /// File path for `metadata_location`'s `suffix` entry. The location is an | ||
| /// `s3://…` path; hash it to a filesystem-safe, fixed-length stem. | ||
| fn path(&self, metadata_location: &str, suffix: &str) -> PathBuf { | ||
| let mut h = std::collections::hash_map::DefaultHasher::new(); |
There was a problem hiding this comment.
Worth a look — DefaultHasher's output isn't stable across Rust releases.
std::collections::hash_map::DefaultHasher is explicitly documented as not guaranteeing a stable algorithm between versions. The full-key collision guard means this is never a correctness problem — a changed hash just misses. But it means a toolchain bump silently orphans the entire on-disk cache: every entry becomes unreachable and un-reclaimable, and the first-ask cold-floor work this PR is built around quietly stops paying off until the cache refills.
Given the cache is persistent and the whole point is surviving across processes, a fixed-algorithm hash would make the file names reproducible. Not blocking — mostly worth knowing so it isn't misdiagnosed as a cache-eviction regression later, which is exactly the story F21 had to rule out.
There was a problem hiding this comment.
Fixed in 0247c79 by porting the audit line's stable hasher back to this rung (xxh64 seed 0 via the already-in-tree xxhash_rust, plus the golden pin test that locks the file-name derivation), riding the same v3 invalidation as the delete-flag change so the one-time re-key costs nothing extra. File names are now reproducible across toolchains, which also removes the future misdiagnosis-as-eviction scenario you flagged.
Superseded — switching to request-changes on the two definite issues below.
bplatz
left a comment
There was a problem hiding this comment.
Two blockers, both detailed inline above:
fused_aggregate.rs—insert_dim_gkeysunder-counts on an equal-value duplicate dim join-key. Equal group-keys is the wrong discriminator; the cardinality bug is present precisely when the duplicates agree.COUNT(*)returns half.disk_catalog_cache.rs—PersistedScanFilesneeds the delete flagPersistedCountStatsalready has. Once #1520 is onmain, the MoR override survives a restart via the disk cache and the scan-files hit arm skips the guard.
Three non-blocking worth a look comments inline as well.
The rest verified clean: cargo test -p fluree-db-query → 1261 passed. Offset forwards budget + offset correctly, Join/Graph absorb correctly, BIND's absorb→forward reclassification is sound, float pruning is NaN-safe, and the COUNT(*) manifest shortcut declines on deletes independently of #1520.
Also worth making explicit: the #1520 → #1507 → #1528 merge order is load-bearing, not just a reading order. Blocker 2 is contained only because of it.
An equal-value duplicate parent join key was kept as a single GroupKeyResolver map entry, so the fused fold emitted one row where the generic pipeline emits the fan-out of a materialized RefObjectMap join — halving COUNT(*)/SUM when the duplicates agree. Collapse insert_dim_gkeys to decline on ANY duplicate (function body + comment ported verbatim from the #1528 rung so the eventual merges auto-resolve identical), and invert+rename the pinning test to dim_dup_join_key_always_declines. Also correct the outer doc comment's stale "harmless and kept" claim, which is wrong at both rungs.
…ty + stable disk-cache hasher (B2 Option A, W3) B2 (Option A, guard-independent): PersistedScanFiles/CachedScanFiles gain has_delete_manifests and CACHE_FORMAT_VERSION bumps 2->3 (struct + version + conversions + tests ported verbatim from the #1528 rung). The scan-files miss arm stamps the flag from a manifest-list-only delete probe (send_snapshot_has_delete_manifests, mirroring the COUNT(*) path's detection without re-reading data manifests); both cache-hit arms route the fetched entry through servable_scan_files, treating a delete-bearing entry as a MISS (re-plan). No merge-on-read guard exists at this rung, so this is a plain fall-through, never an override re-check — the planner is left untouched to avoid colliding with #1520 when it lands on main first. The version bump alone invalidates every v2 scanfiles entry. W3: replace std DefaultHasher (unstable across toolchains) with a fixed xxh64 stable_key_hash (seed 0) + CACHE_SCOPE filename segment + a golden pin test, so a toolchain bump no longer silently orphans the on-disk cache. Ported verbatim from the #1528 rung; disk_catalog_cache.rs is byte-identical to it.
…(W1) The can_project_distinct_before_sort branch computes k/can_topk identically to its sibling but deliberately offers no scan-side set_topk: DISTINCT sits below the sort, so k scan rows can dedup to fewer than k and the push-down would under-produce. Add a rationale comment mirroring the ASC-declines note so the asymmetry reads as a decision, not an oversight.
…LING note (W2) Extend the COUPLING note to name GraphOperator, which is neither pure-forward nor pure-absorb in the set_row_budget taxonomy: it absorbs w.r.t. self.child but threads the budget into the per-parent-batch correlated inner subplan. Soundness holds because each batch rebuilds the inner tree with a fresh budget whose rows flow to the same LIMIT, but that is the subtler argument a future editor is most likely to miss.
|
Both blockers and all three worth-a-look items are addressed at head 8aaa1eb (four commits: 0b4269a B1, 0247c79 B2+W3, 067e792 W1, 8aaa1eb W2), and the merge-order note is taken with one improvement: rather than stating the constraint, the fixes remove most of it — B1 and B2 are now correct at this rung standalone (B2 via a guard-independent conservative arm, since the guard itself only exists from #1520 onward; the audit line's override-aware version supersedes it at merge). The dim-duplicate and cache regions here are byte-identical with the audit line, so the stack's eventual merges auto-resolve. CI is green at this head. |
bplatz
left a comment
There was a problem hiding this comment.
All five items resolved at 8aaa1eb, verified locally:
- B1:
insert_dim_gkeysnow declines any duplicate parent join key, the pinning test is inverted (dim_dup_join_key_always_declines), and the stale "harmless and kept" doc sentence is gone. Function body is byte-identical with the #1528 rung, so the merge auto-resolves. - B2: the guard-independent route is the right call given this rung has none of #1520's symbols — flag stamped from a manifest-list-only probe, both hit arms decline through
servable_scan_files, v3 bump invalidates pre-flag entries. Stricter than the override-aware arm, correct in every merge order, superseded cleanly when the audit line lands. This resolves my merge-order note by dissolving the constraint, which is better than documenting it. - W1/W2/W3: decline rationale, correlated-inner route note, and the stable xxh64 hasher + golden pin all landed as discussed.
disk_catalog_cache.rsdiffs empty against the audit line.
Re-verified: cargo test -p fluree-db-query → 1261 passed; fluree-db-api --features iceberg --lib → 771 passed including the new cache/servable tests. CI green at head.
Reconcile graph-source secret redaction + CLI /info display refactor: - ledger_info.rs: union redact_json_secrets — keep lambda's ConfigValue::SecretRef preservation (is_secret_ref_object, checked first) AND main's #1534 fail-closed container handling (redact_secret_subtree / SECRET_REF_SAFE_KEYS). - cli iceberg.rs & info.rs: adopt main's unified graph_source_display module; drop the now-orphaned inline print/redact helpers and their tests (coverage moved to graph_source_display.rs).
perf(virtual): R2RML/Iceberg virtual-dataset performance program — corpus, pruning, fused aggregation, budget forwarding (consolidates #1475–#1507)
This is one of five surviving pull requests in the consolidated virtual-dataset review program, which collapses the #1475–#1529 lineage into five reviewable units at the maintainers' request. #1507 is the base of the readable stack (base =
main) and carries the entire R2RML/Iceberg virtual-dataset performance program: the benchmark corpus and harness, the correctness-first fixes, the decode-wall and cold-floor levers, star/parent-lookup pruning, fused aggregation over FK joins, numeric pruning, scan-side top-k, and the F17 UNION/BIND budget forwarding at the tip. It is the one place besides #1520 that receives real CI once re-based tomain. Reading order across the program: #1520 (standalone MoR correctness fix) → #1507 (this PR) → #1514 (serverless Lambda-usability + BYO-IAM) → #1528 (big-Iceberg-audit implementation) → #1529 (native-twin materialize builder). The outward-facing audit record is on thedocs/virtual-dataset-audit-2026-07branch — start with 00-MASTER-AUDIT.md §0, then the empirical results in C2-bench-wave1.md.Original PR body
Scope expanded by consolidation; the tip PR's own change (F17 UNION/BIND budget forwarding) is the narrative below. The 19 pull requests folded in are rolled up under "Consolidated scope."
What
q029 (
{ WebEvent, eventType="purchase" } UNION { WebEvent, eventType="add_to_cart" } LIMIT 100) re-scanned bothFACT_WEB_EVENTbranches ~253× — 1.94 M file decodes for a 100-row answer — because theLIMIT 100budget never reached the branch scans. It died at two absorb points:UnionOperatorandBindOperator(q029's branch root isBIND(const AS ?et)), both inheriting the trait's no-opset_row_budget; and once branch-1 filled the budget the union still drained branch-2.Two changes, both gated by
FLUREE_R2RML_UNION_BUDGET(default on):UnionOperator(the fullkto each branch — a single branch may supply allk) andBindOperator(to its child; BIND is categorically 1:1/order-preserving, reclassified from absorb to forward). The R2RML scan then caps its materialize window atk.pending_output_rows >= row_budget. Sound on two counts — a budget reaches the union only under row/order-preserving operators up to the LIMIT (Sort/Distinct absorb ⇒ arbitrary-k-subset /rows_only), and the check fires only when the budget is actually MET (an under-filled branch-1 still runs branch-2).Result
q029: 125 s (pre-slate) → 2.61 s in-protocol (3-rep per-head baseline, F17 + the #1503/artifact cache stack) — under the ≤3 s bar; burndown row closes. Deterministic, network-invariant evidence (live gate):
scan_table253 → 2, parquet decodes 1,940,510 → 7,703 (2 sweeps → 1), OFF byte-preserved, oracle-parity 0-mismatch. Full-corpus baseline: 59 records, 0 hash mismatches.Cold isolated first-ask is ~57 s-class and is reported, not gated: the residual is one full
FACT_WEB_EVENTsweep, S3-fetch-bound (decode trivial). Wall model:~7 s fixed (loadTable+oauth+scan_plan) + N_sweeps × one-sweep-fetch; the union is sequential, so the lever removes one whole sweep. This is the shared PR-2a floor (q016/q031/q056) — fetch-rate-sensitive, not decode.Honest notes
eventTypeis a post-decode residual and the scan window is geometric-growth, so the budget materializes a different valid first-100 →rows_only(same class as q055/q057). Oracle parity holds.Perf violations at this head — documented, not blessed away
The 3-rep baseline flagged 5 pre-existing perf violations (q002/q004/q024/q030/q043, 2.5–8× over blessed). Attributed and NOT caused by F17:
fetch_bytes≈ 0 for every one (files disk-served; artifact cache 90 MB, 5.7× under the 512 MiB cap) — disproves any cache-eviction / regression story.edw:namename-dim fan-out (F20-family, pre-existing).Tests
fluree-db-query, 13 union tests): BIND forwards; Sort/Distinct absorb (decline proofs); union stores; Limit→Union correctness; 3 build-count lever tests (branch-1 fills k ⇒ branch-2 not built; under-fills ⇒ built; no budget ⇒ both run).-p fluree-db-querysuite: 1256 passed, 0 failed.row_budgetand union branches are pattern-built, so there is no hermetic mock-scan seam (a#[cfg(test)]injection hook was considered and declined to keep the binary scaffolding-free).Harness
The live-gate script's OFF arm ceiling was raised 180 s → 300 s (its ~167 s baseline is fragile at 180 — same headroom ratio as q056 168/180). Manifest
timeout_sstays 180 (shipped F17-ON q029 is ~2.6 s in-protocol, far under it).Stacked on #1506 (
test/exploration-corpus-family). Commits: sketch → forwarding → doc-correction → lever → baseline-verdict/F21-docs.Consolidated scope
The lineage below is one linear history; each row's commits are present on this branch's tip and each superseded PR closes as consolidated-at-maintainer-request, its pre-consolidation tip preserved under
archive/pre-refactor-2026-07-21/<branch>. Rows are in base→tip order.vbench), and native-blessed baselines for the whole series — the evidence base. No engine-pathway change beyond implementing a reserved bench span layer.GRAPHscope over an R2RML source previously "succeeded" with 0 rows; it now errors). Breaking, virtual-only.COUNT(*)over one class answers from the Iceberg manifestrecord_countsum instead of decoding every Parquet file (three fact COUNTs DNF→sub-5s), behind a soundness contract that declines on deletes / unproven-nullable / absent stats.TriplesMapfan-out (all-members intersection + disjoint-class hint) so a single-table dimension star stops fanning out to every name-bearing table.Arc-shared) so a fact⋈dim query stops rebuilding its parent map per 1,000-row batch.ORDER BY DESC(<scan col>) LIMIT kover a single-table scan — read ~k files instead of the whole table.sparql_jsonso virtual output matches native (q002/q042 become byte-identical to the native oracle), gated and kill-switched.mainunreviewed and so the stacked merge has no orphan.Verification of record
CI does not fire on the individual superseded PRs (their bases were stacked, not
main); the verification of record for the program is the corpus benchmark, which runs native and virtual over the identical SF01 source and hash-compares.Parity is clean: the wave-1 corpus run at the program tip reports 65
ok+ 3 expected-error (the lang-tag / custom-datatype error-boundary queries) and 0 unexpected failures, with 0 hash mismatches — native and virtual produce byte-identical results on every deterministic query. 57 of the 65okqueries land at or under 3 s. The full record, the per-query table, and the run JSONLs are in C2-bench-wave1.md and the deliverable data (native-full-10e073fe9.jsonl,virtual-full-10e073fe9.jsonl) under data/. The wave-2 probe battery reproduced every predicted coverage gap live and proved SPARQL and JSON-LD produce identical results on the same query — recorded in C2b-bench-wave2-probes.md.The one honest caveat carried in the harness: the committed perf baseline was blessed from an old tip and is gate-blind on 62% of the corpus (28 members were pinned at their DNF timeout wall and 14 were missing), so the perf arm's numbers are re-baselined at the current head rather than trusted from the committed budgets — this is why the tip's five flagged perf violations (q002/q004/q024/q030/q043) are attributed to loadTable-GET catalog latency (tracked as F21) rather than to any engine regression; the correctness/hash arm is unaffected. The stale-baseline analysis is in C2-bench-wave1.md §1 and the red-team recomputation of the headline numbers in RT2-redteam-empirics.md.
Native-impact appendix
The program is overwhelmingly on the R2RML/Iceberg virtual path, with one engine-wide item and one breaking-behavior call-out. Every switch below defaults on and gated-off is byte-identical unless stated.
set_row_budgetforwarding (this PR's tip) — the one engine-wide edit. The forwarding lands on the genericUnionOperatorandBindOperator, which native queries also traverse (BIND is reclassified from absorb to forward). SwitchFLUREE_R2RML_UNION_BUDGET(default on; gates BIND too); gated-off is byte-preserved. When it fires it is not byte-identical: aLIMITwithoutORDER BYover a UNION/BIND returns a different valid first-k — same count, bounded multiset (therows_onlyclass), never a wrong count. The native impact is bounded and disclosed: the native corpus legs are byte-identical at every head (0 hash mismatches), and any nativeLIMIT-over-UNION that hits the reorder is switch-revertible. Deterministic evidence from the live gate:scan_table253→2, OFF byte-preserved, oracle-parity 0-mismatch, blast radius q029 only — the full mechanism and these scan-count numbers are recorded per the original perf(virtual): R2RML/Iceberg virtual-dataset performance program — corpus, pruning, fused aggregation, budget forwarding (consolidates #1475–#1507) #1507 PR record (perf(virtual): R2RML/Iceberg virtual-dataset performance program — corpus, pruning, fused aggregation, budget forwarding (consolidates #1475–#1507) #1507), in its What / Result / Honest-notes sections, and the program-wide parity backing is the corpus benchmark's 0-hash-mismatch result in C2-bench-wave1.md.Operator::set_row_budgettrait default is engine-wide but inert unless a budget is set. It is the shared mechanism the program's budget-forwarding levers ride; on a native query with no row budget it is a no-op, and the native corpus legs are byte-identical as evidence.GRAPHscope over an R2RML source previously returned 0 rows as success and now raise a typedInvalidQuery. This is scoped to virtual sub-scopes; it is not a native path. It is the one breaking change in the wave and is labeled for the release notes.Binding::Irivalues through the samecompact_id_iricompaction native already uses, and it is gated three ways (from_graph_source && FLUREE_R2RML_CURIE_ALIGN && format == SparqlJson). Native results arrive asBinding::Sidand are untouched; only virtual graph-source IRIs insparql_jsonare affected.Confirmed negatives across the whole span (git-checked, name-only diff clean): no
arrow/datafusion/parquetdependency bump, and no.github/workflowsorrust-toolchainedits anywhere in the lineage. The CI gates and the shared native-dependency surface are untouched.Merge to main
Re-basing this branch onto
mainrenders the PR conflicting:mainadvanced 122 commits past the lineage fork and eight files overlap the program (Cargo.lock,Cargo.toml,fluree-db-api/src/query/helpers.rs,fluree-db-api/src/view/query.rs,fluree-db-query/src/context.rs,fluree-db-query/src/execute/operator_tree.rs,fluree-db-query/src/optional.rs,fluree-db-query/src/r2rml/fused_aggregate.rs). The resolution is a single merge-of-current-maincommit on the branch — not a rewrite — resolving the hard conflict inoptional.rs(the batched-OPTIONAL hash-join-safe predicate arms) and lettingCargo.lockauto-merge, so mergeability goes green and CI runs meaningfully against the change. The merge is authored and adversarially reviewed before it is pushed.One residual to disclose with that merge:
optional.rsalso carries the PR-4d (#1501) seed-coalescing path, which interacts with the batched-OPTIONAL predicate the merge resolves. The genuinely new eligibility after this merge is a single-sourceDefaultGraphSourceinner (main's Cypher edge-annotation shape), which now satisfies the hash-join-safe predicate and can therefore seed-coalesce — a path neither parent had; no corpus member covers that shape today, coalescing there is double-guarded (multi-ledger checked at the coalesce decision and again inbuild_batch, with a per-row fallback), andFLUREE_R2RML_OPTIONAL_SEED_COALESCEis the kill switch. As regression assurance for the pre-existing path, re-run the batched-OPTIONAL corpus members (q016 and q050) and confirm coalescing still fires (onebuild_batchper driving side, not per window) with both hashes matching their native oracle. The eventual merge tomainis a maintainer merge-time item, planned around those eight overlapping files, not a blocker for reviewing this PR as a unit.