Skip to content

Offset and column index cache#66

Open
bharath-techie wants to merge 18 commits into
mainfrom
new-cache-unified-impl
Open

Offset and column index cache#66
bharath-techie wants to merge 18 commits into
mainfrom
new-cache-unified-impl

Conversation

@bharath-techie

Copy link
Copy Markdown
Owner

Description

[Describe what this change achieves]

Related Issues

Resolves #[Issue number to be closed when this PR is merged]

Check List

  • Functionality includes testing.
  • API changes companion pull request created, if applicable.
  • Public documentation issue/PR created, if applicable.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

Add indexed_table::page_index_loader — a process-global, byte-bounded LRU
that loads the parquet page index scoped to a query's predicate columns and
is shared by both scan paths (DataFusion ListingTable + custom indexed
executor). No scan-path wiring yet; this lands the cache + its unit tests in
isolation.

Design:
- ColumnIndex scoped to predicate columns (NONE placeholders elsewhere);
  OffsetIndex kept for all columns / all row groups (read at scan time for
  every projected column — placeholders there break reads).
- Cache stores the decoded (ColumnIndex, OffsetIndex) PAIR + a structural
  size estimate, NOT a full ParquetMetaData, and grafts the pair onto the
  caller's resident footer at lookup. No footer duplication / over-allocation.
- Key is (path, predicate parquet-cols) ONLY — both paths build the identical
  all-RG artifact, so entries are shared across paths. Row-group scoping is a
  future axis (gated Step 2/3) and is deliberately not in the key yet.
- Cache hit returns a fresh Arc (graft), so hits are asserted via the stats
  counter, not Arc::ptr_eq.

Uses the deprecated read_columns_indexes/read_offset_indexes — the only public
subset-decode API today; arrow-rs#8643/opensearch-project#8797/opensearch-project#8714 track the first-class
replacement. 9 unit tests pass (cargo test --lib page_index_loader
--test-threads=1).
…tting

Make the scoped cache (Step 1a) observable for A/B testing, and let its byte
budget be configured.

Rust:
- stats.rs: CacheStatsRepr grows a third CacheGroupRepr (scoped_page_index_cache),
  packed from page_index_loader::scoped_cache_stats(). DfStatsBuffer 75->80 longs
  (600->640 bytes); asserts + tests updated. New test asserts the packed group
  reflects the live global counters/limit.
- ffm.rs: df_set_scoped_page_index_cache_limit(i64) — process-global, no manager
  pointer; negative rejected, zero ignored.

Java:
- StatsLayout: cache_stats group now nests 3 sub-caches (metadata, statistics,
  scoped_page_index); layout 80 longs/640 bytes; 5 new VarHandles; readCacheStats
  builds the 3rd group.
- CacheStats: third CacheGroupStats threaded through ctor/StreamInput/writeTo/
  XContent ("scoped_page_index_cache")/equals/hashCode + getter.
- NativeBridge.setScopedPageIndexCacheLimit -> df_set_scoped_page_index_cache_limit.
- CacheSettings datafusion.scoped_page_index.cache.size.limit (default 64mb);
  CacheUtils pushes it to native at startup (NodeScope, not yet dynamic).
- Updated StatsLayout(Property)Tests offsets/FIELD_COUNT (75->80) + CacheStats(*)
  tests for the 3-arg shape.

Tests: 19 Rust stats + 9 page_index_loader pass; 7 Java stats suites (57 tests)
pass under -PrustDebug -Dsandbox.enabled=true. No scan-path behavior change yet.
Populate the unified scoped cache (1a) on the DataFusion ListingTable / vanilla
parquet scan path so it is exercised + observable via node-stats (1b).

- shard_scoped_reader.rs: ScopedPageIndexReaderFactory — a ParquetFileReaderFactory
  whose get_metadata loads footer-only metadata, resolves predicate column names
  to per-file parquet leaf indices, and grafts an all-RG, column-scoped page index
  via load_scoped_page_index (falls back to footer-only on no-predicate / failure).
  All row groups (not RG-scoped): DataFusion owns RG selection on this path, so a
  placeholder on an RG it scans would panic.
- scoped_page_index_optimizer.rs: ScopedPageIndexOptimizer — a PhysicalOptimizerRule
  that finds each parquet DataSourceExec, reads the pushed predicate, derives
  predicate column names, and REPLACES the reader factory (DataFusion always
  pre-installs its own full-index CachedParquetFileReaderFactory — that is what we
  swap). Provider-agnostic; no-op without a predicate.
- session_context.rs: register the rule after ProjectRowIdOptimizer so it sees the
  final DataSourceExec, using this shard's store + the shared metadata cache.

Tests (15 pass, --test-threads=1): the optimizer end_to_end_listing_scan_fills_
scoped_cache drives a real SessionContext + stock ListingTable and asserts the
scoped cache fills with a miss; reader tests assert predicate-scoped ColumnIndex +
all-column OffsetIndex and the no-predicate no-scope path. No metadata-cache strip
yet (1e) — scoped-cache population does not depend on it.
ScopedPageIndexCacheIT provisions app_logs (parquet/composite) and exercises the
listing-table scan path (numeric  predicate — native, not Lucene-
delegated), asserting via GET /_plugins/_analytics_backend_datafusion/stats
(cache_stats.scoped_page_index_cache):

- the scoped group is always exposed with a positive size_limit_bytes (64mb);
- a listing query populates the cache (entry + memory_bytes > 0) at QUERY time;
- re-running the IDENTICAL query is a pure cache hit: hits increase while misses,
  entries, and memory_bytes stay flat (no duplication / no over-allocation);
- 10 repeated runs do not grow entries or bytes (bounded, predictable).

Assertions are same-method twice-run deltas, not cold-cache baselines: the scoped
cache is a process-global singleton with no reset endpoint and IT methods run in
randomized order on a preserved cluster, so an entry may already exist from a
prior method. Verified the scoped cache is populated ONLY at query time — its sole
writer (load_scoped_page_index) is reachable only from the query-time scoped
reader, never the refresh/warm path (custom_cache_manager only warms the level-1
metadata cache). 3/3 pass under -Dsandbox.enabled=true -PrustDebug.
…r-only)

Make the level-1 metadata cache footer-only (row-group + file stats), so the
heavy decoded page index is never retained there. This is the flip that makes
the scoped page-index cache (1a/1c) load-bearing and kills the wide-schema heap
blowup.

- cache.rs: strip_page_index(entry) downcasts CachedParquetMetaData and rebuilds
  it with set_column_index(None)/set_offset_index(None); no-op (same Arc) if
  already footer-only. Called in MutexFileMetadataCache::put — the single
  chokepoint every put flows through (DataFusion scan opener, infer_schema,
  fetch_statistics, and the warm path all force-decode the full index before
  put; we refuse to retain it).
- custom_cache_manager.rs: corrected the warm-path comment — warming now lands a
  footer-only entry through the same stripping put.

Correctness: page pruning is an optimization; PagePruner::prune_rg early-returns
(scans the whole RG) when no page index is present, never a wrong result. Both
scan paths rebuild a predicate-scoped page index per query via the shared scoped
cache, at query time only — ColumnIndex is never warmed at refresh.

Tests: 2 new strip tests (put_strips_page_index_and_get_returns_footer_only,
strip_is_noop_for_footer_only_entry); 295 indexed_table::tests_e2e pass with the
strip in place (correctness preserved); cache/page_index_loader/shard_scoped_
reader/optimizer/stats all green.
…age index

The decisive fix: do not merely strip the page index after the fact — never
decode it for the level-1 cache in the first place.

DataFusion 54 hardcodes the page-index policy in fetch_metadata: cache present =>
PageIndexPolicy::Optional (force-decodes the FULL ColumnIndex+OffsetIndex before
caching), no cache => Skip. So passing our cache made it decode the whole page
index every footer load, only for put() to throw it away. On wide schemas that
decode is the dominant cost the whole effort targets.

- parquet_bridge::load_parquet_metadata now manages the cache itself: cache get +
  is_valid_for => return cached footer (zero I/O, zero decode); miss => fetch
  WITHOUT a cache (PageIndexPolicy::Skip => page index never decoded), then put a
  footer-only entry. This is the loader both scoped-reader paths and the warm
  path use.
- custom_cache_manager: warm path now delegates to load_parquet_metadata
  (footer-only, no decode) instead of DFParquetMetadata::fetch_metadata-with-cache.
- cache.rs MutexFileMetadataCache::put keeps strip_page_index as a defensive
  backstop for the scan paths DataFusion drives directly (its own opener /
  infer_schema), which still construct fetch_metadata-with-cache out of our reach.

Correctness preserved: PagePruner no-ops without a page index; scoped page index
is rebuilt per query for predicate columns only. 49 unit + 295 indexed e2e pass.
Broaden ScopedPageIndexCacheIT from 3 to 8 tests so the page-index changes
(scoped cache + footer-only level-1 cache) are verified to break nothing, all via
GET /_plugins/_analytics_backend_datafusion/stats:

Correctness (exact counts vs known app_logs cardinalities — 200 docs):
- total=200; status>=400=103 (numeric listing predicate); log_level=ERROR=115
  (keyword); answers stable across cold vs cached re-runs.

Level-1 metadata cache still works after the strip:
- holds footer entries and registers hits across repeated queries.

Scoped cache (hits / misses / size):
- same query re-run = pure hit (misses/entries/bytes flat); 10 repeats stay
  bounded; occupancy never exceeds the 64mb budget.

No-breakage query sweep:
- plain projection, grouped aggregation, multi-column native filter, full-text
  match() (Lucene-delegated), and mixed native+match predicate all execute.

8/8 pass under -Dsandbox.enabled=true -PrustDebug; zero panics/errors in node
logs; caches configured metadata=250mb stats=100mb scoped=64mb.
After Step 1e the level-1 metadata cache is footer-only, so the indexed
PagePruner (which reads column_index()/offset_index() straight off
segment.metadata) would see no page index and conservatively no-op. Restore
page pruning by re-augmenting each segment metadata with a predicate-scoped page
index, built through the SAME shared scoped cache as the listing path.

- indexed_executor.rs: after predicate columns are known, collect predicate
  column NAMES and, per segment, resolve_predicate_parquet_columns +
  load_scoped_page_index, replacing segment.metadata with the augmented copy.
- Resolving NAMES -> per-file parquet leaf indices via the same
  resolve_predicate_parquet_columns the listing reader uses means both paths
  compute the identical scoped-cache key for the same (file, predicate columns),
  so entries are SHARED across paths (constraint #1). All row groups (no RG
  scoping in Step 1); on any failure the segment keeps footer-only metadata and
  pruning no-ops — never a wrong result.

Tests: 295 indexed_table::tests_e2e pass with re-augmentation; page_index_loader
/ shard_scoped_reader / cache modules green.
Add testCrossPathSharingListingThenIndexed: a listing query filters `status`,
then an indexed query (forced onto the indexed path by match(message,...)) also
filters `status`. Asserts via the stats API that the indexed query HITS the
scoped entry the listing query built — hits increase, entries and memory_bytes
stay flat (no second, path-specific entry). This is constraint #1 (one unified
cache shared by both paths), the central failure of the prior iteration, now
verified on a real cluster.

9/9 ScopedPageIndexCacheIT pass under -Dsandbox.enabled=true -PrustDebug.
Reset the scoped cache and re-measure via stats without a cluster restart.

- page_index_loader::clear_scoped_cache() — drop entries + reset counters, keep
  the byte budget (the next query repopulates lazily).
- ffm.rs df_clear_scoped_page_index_cache() FFI.
- NativeBridge.clearScopedPageIndexCache().
- RestClearScopedPageIndexCacheAction: POST
  /_plugins/_analytics_backend_datafusion/cache/scoped_page_index/_clear
  (local node; for the single-node benchmark cluster). Registered in
  DataFusionPlugin.getRestHandlers.

Verified live on the 100M-doc clickbench index: populate -> 20 entries/8.1MB;
clear -> {"acknowledged":true} -> 0/0/0; repopulate -> identical 20/8.1MB.
Initial, test-backed draft of row-group scoping for the scoped page index,
grounded in a close read of DF54 + parquet58 source (HANDOFF_step2_rg_scoping.md
has the full analysis + citations). NOT wired into the scan paths yet — the live
paths still call the unchanged all-RG load_scoped_page_index.

Design (safe per the source analysis):
- ColumnIndex (the heap hog: per-page string min/max) is RG-scopable — decode it
  only for row groups that pass footer RG-stats pruning; NONE elsewhere. NONE is
  handled gracefully by DF (statistics.rs get_data_page_statistics! _ arm) and the
  RGs we placeholder are exactly those DF also prunes.
- OffsetIndex MUST stay all-RG: an empty OffsetIndex on any RG DataFusion scans
  panics (statistics.rs:1835 page_locations.last().unwrap()) or breaks reads
  (failed to skip rows). DF picks the scanned set itself, after + independent of
  our index load, so we cannot safely omit any RG OffsetIndex.
- Survivor set = footer-stats prune (superset of DF scanned set, since DF further
  applies bloom/range/limit) — deterministic in (file, predicate), so both scan
  paths compute the identical set → identical key → cross-path sharing preserved.

Code:
- ScopedKey gains surviving_rgs (empty = Step-1 all-RG; non-empty = Step-2).
- surviving_row_groups(footer, schema, predicate): PruningPredicate over a
  footer-stats PruningStatistics (mirrors DF RowGroupAccessPlanFilter; no page
  index); conservative all-RG fallback.
- load_scoped_page_index_rgs + build_scoped_page_index refactor: ColumnIndex
  gated per-RG, OffsetIndex always all-RG; size estimate reflects the RG scope.
- 4 unit tests (survivor calc, RG-scoped CI / all-RG OI, byte reduction, key
  identity). load_scoped_page_index unchanged → Step 1 fully backward compatible.

Tests: 13 page_index_loader + 295 indexed e2e + cache/reader/optimizer all pass.
Step 3 (RG-scope OffsetIndex) deferred — needs an adversarial repro; see handoff §4.
…+ col 0

Answer to "do we need the OffsetIndex for ALL columns?": no — only
predicate ∪ projection ∪ {0}. Verified against DF54/parquet58 source: the
OffsetIndex is dereferenced only by (a) reads, for PROJECTED columns
(in_memory_row_group.rs:80), (b) page pruning, for the PREDICATE column
(page_filter.rs:512), and (c) the page-skip METRIC, for column 0
(page_filter.rs:255 — affects only the counter, not the RowSelection). Including
col 0 keeps the metric identical to stock DataFusion.

DRAFT (not wired into scan paths):
- ScopedKey gains offset_cols (empty = all columns / Step 1).
- load_scoped_page_index_cols(.., offset_cols): defensively unions predicate cols
  + col 0, clamps/sorts/dedups; collapses to the all-columns key when the union
  covers every column (shares the Step-1 entry).
- build_scoped_page_index decodes the OffsetIndex only for off_cols, scatters to
  absolute column positions, empty placeholders elsewhere; size estimate follows.
- 4 unit tests: offset real only for the scoped set; projected non-predicate read
  succeeds; cached bytes reduced; full-coverage collapse to all-columns entry.

This is orthogonal to RG-scoping (CI by row group) and is the bigger win on wide
schemas (402 cols, project a few). Wiring TODO: listing path has the projection in
the optimizer; indexed path must thread read_projection to the augmentation site
(both paths must pass the same offset_cols to keep cross-path sharing). See
HANDOFF_step2_rg_scoping.md §3b. load_scoped_page_index unchanged → Step 1 + the
RG-scoping draft remain backward compatible.

Tests: 17 page_index_loader + 295 indexed e2e + cache/reader/optimizer all pass.
Signed-off-by: G <bharath78910@gmail.com>
Signed-off-by: G <bharath78910@gmail.com>
Signed-off-by: G <bharath78910@gmail.com>
…r fixes

Signed-off-by: G <bharath78910@gmail.com>
Signed-off-by: G <bharath78910@gmail.com>
Signed-off-by: G <bharath78910@gmail.com>
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.

1 participant