Skip to content

Partition-level bloom sentinels for cross-segment skipping (#47) - #35

Draft
SferaDev wants to merge 4 commits into
fix/epoch-encodingfrom
feat/partition-bloom-sentinels
Draft

Partition-level bloom sentinels for cross-segment skipping (#47)#35
SferaDev wants to merge 4 commits into
fix/epoch-encodingfrom
feat/partition-bloom-sentinels

Conversation

@SferaDev

@SferaDev SferaDev commented Jun 12, 2026

Copy link
Copy Markdown
Member

What

Partition-level bloom sentinels (PERF #47): during compression, every bloom-supported (numeric/date/timestamp, non-segment-by) column accumulates a partition-wide bloom filter, stored as a _segment_id = -1 sentinel row in the existing <partition>_blooms companion table. At query time a new Phase 0pre in load_segments_heap probes the sentinel by PK before the colstats probes and the meta scan — a rejecting sentinel skips every segment in the partition for the price of one index descent per probed column.

Stacked PR: based on fix/epoch-encoding (#33), whose bloom_probe_encode fix this feature's probe path builds on. Will retarget to main once #33 merges.

Why

Point lookups (ClickBench Q19-class: WHERE UserID = <const>) previously paid per-partition colstats minmax probes, a meta scan, and per-segment bloom loads even for partitions that cannot contain the value. The sentinel collapses all of that meta-hit work into a single 2-key PK probe per partition.

Numbers

  • Local ClickBench, 1M rows (1 partition, 34 segments): Q19 DeltaX scan 0.96 ms → 0.106 ms (segments=0 segments_bloom_skipped=34, meta hit 142 → 0).
  • Local ClickBench, 10M rows: Q19 10.7 ms → 5.4 ms.
  • EC2 100M: this change ran as part of the perf session's batch that produced the 27.07 s ClickBench total; point-lookup queries skip all segments of non-matching partitions via one sentinel probe each. The isolated per-query EC2 delta was not separately re-measured after extracting this PR.

Design digest

(Full write-up: dev/docs/PERF_IMPROVEMENTS.md §47, shipped in this PR.)

  • Build: fixed 2 MiB power-of-two accumulator per column (PARTITION_BLOOM_BUILD_BYTES, PARTITION_BLOOM_HASHES = 4 — fixed because the accumulator is created before the partition's final ndistinct is known), folded down (OR-halving — positions are hash % 2^n, so folding can only widen the match set, never produce false negatives) to ~10 bits/element using the partition-level HLL ndistinct at flush.
  • Density gate, fail-safe: filters whose folded density exceeds 0.6 are not stored — absence of a sentinel simply means no partition-level skipping. No per-segment cardinality gate (it was tried and withdrawn: sort-clustered columns like UserID show low per-segment ndistinct and got disqualified — exactly the point-lookup targets).
  • Probe: Phase 0pre, gated by GUC pg_deltax.partition_bloom_filters (default on; build side follows pg_deltax.bloom_filters). Probe constants go through bloom_probe_encode (Fix epoch-encoding bugs in segment min/max pruning and bloom probes #33) so the probe hashes the same i64 domain the build side hashed (Unix-epoch µs for timestamps/dates, raw bit patterns for floats).
  • All three build paths write sentinels: SPI compress (compress_partition_streaming), buffered COPY backfill (flush_segment), threaded parquet backfill (workers ship value hashes via CompressedSegment::partition_bloom_hashes; the main thread owns the accumulators).
  • Correctness invariants:
    • Sentinel rows are never loaded as data segments: segments are enumerated from the meta table (ids ≥ 1); the per-segment bloom loop skips ids not in the surviving set.
    • Decompress and retention drop the blooms companion table wholesale — stale sentinels cannot survive a decompress → modify → recompress cycle; recompression rebuilds them from scratch (companion tables are always built fresh; DML on compressed partitions is rejected by trigger).
    • Sentinels are only ever written when a partition compresses from scratch; there is no in-place update path in this PR. If incremental segment-append ever lands, a stored sentinel must be OR-merged (with fold-down, which preserves hash % 2^n membership) or dropped — never overwritten.

Testing

  • make build — clean.
  • make clippy — zero warnings.
  • make test (PG 17) — 568 unit tests passed (includes new fold/no-false-negative bloom tests).
  • make integration-test PG_VERSIONS=17 — full suite green, including the new tests/test_partition_bloom.py (sentinel creation on both compress paths, sentinel-only pruning with per-segment bloom rows deleted, GUC off behavior, IN-list probes, present-value non-pruning, decompress→recompress sentinel rebuild).

Provenance

Extracted from a longer perf session (perf/clickhouse-gap-session, commit fcfb1dc — bloom parts only; the same commit's storage-v2 segment-file code ships separately in #32, and its topn merge rework was superseded and is excluded).

@SferaDev
SferaDev marked this pull request as ready for review June 12, 2026 13:33
SferaDev added a commit that referenced this pull request Jun 12, 2026
…stack

Partition bloom sentinels (#35) and dual-mode segment files (#32) are
separate PRs; the new decompose comments referenced them as if present.
Rephrase as forward-looking interaction notes (matching the existing
'PERF #47, not yet on this branch' convention) or drop the reference.
SferaDev added a commit that referenced this pull request Jun 12, 2026
…stack

Partition bloom sentinels (#35) and dual-mode segment files (#32) are
separate PRs; the new decompose comments referenced them as if present.
Rephrase as forward-looking interaction notes (matching the existing
'PERF #47, not yet on this branch' convention) or drop the reference.
@SferaDev
SferaDev force-pushed the feat/partition-bloom-sentinels branch from 01bde7a to 4b1dbcc Compare June 12, 2026 15:15
SferaDev added a commit that referenced this pull request Jun 12, 2026
…stack

Partition bloom sentinels (#35) and dual-mode segment files (#32) are
separate PRs; the new decompose comments referenced them as if present.
Rephrase as forward-looking interaction notes (matching the existing
'PERF #47, not yet on this branch' convention) or drop the reference.
@SferaDev
SferaDev force-pushed the fix/epoch-encoding branch from 9b59034 to b579349 Compare June 12, 2026 20:11
@SferaDev
SferaDev force-pushed the feat/partition-bloom-sentinels branch from 4b1dbcc to 99e08a9 Compare June 12, 2026 20:45
SferaDev added a commit that referenced this pull request Jun 12, 2026
…stack

Partition bloom sentinels (#35) and dual-mode segment files (#32) are
separate PRs; the new decompose comments referenced them as if present.
Rephrase as forward-looking interaction notes (matching the existing
'PERF #47, not yet on this branch' convention) or drop the reference.
SferaDev added 4 commits June 12, 2026 23:12
During compression every bloom-supported (numeric/date/timestamp,
non-segment-by) column accumulates a partition-wide bloom filter in a
fixed 2 MiB power-of-two build buffer (PARTITION_BLOOM_HASHES = 4),
folded down (OR-halving, no false negatives) to ~10 bits/element from
the partition-level HLL ndistinct and stored as a _segment_id = -1
sentinel row in the existing _blooms companion table. Sentinels whose
folded density exceeds 0.6 are dropped — absence of a sentinel just
means no partition-level skipping (fail-safe).

At query time, Phase 0pre of load_segments_heap probes the sentinel by
PK before the colstats probes and the meta scan: a rejecting sentinel
skips every segment in the partition for the price of one index
descent per probed column. Probe constants go through
bloom_probe_encode so the probe hashes the same i64 domain the build
side hashed (Unix-epoch us for timestamps/dates, raw bit patterns for
floats). Gated by pg_deltax.partition_bloom_filters (default on);
build follows pg_deltax.bloom_filters.

All three build paths write sentinels: SPI compress
(compress_partition_streaming), buffered COPY backfill (flush_segment),
and the threaded parquet backfill (compute_partition_bloom_hashes on
workers, merged into the main thread's accumulators). Decompress and
retention drop the blooms table wholesale, so stale sentinels cannot
survive; BloomFilter::merge_fold (OR-merge with fold-down) is included
for the future incremental-compaction path.

Local ClickBench: Q19 DeltaX scan 0.96 -> 0.106 ms at 1M rows
(segments=0, segments_bloom_skipped=34), 10.7 -> 5.4 ms at 10M.

Extracted from the perf/clickhouse-gap-session branch (commit fcfb1dc,
bloom parts only); stacks on the epoch-encoding fixes branch.
Fresh-eyes pass over the partition-bloom-sentinel diff:

- bloom.rs: remove BloomFilter::merge_fold and its two tests — dead code
  with no production caller (sentinels are write-once per compress; the
  incremental-compaction PR that needs it can reintroduce it). Reword
  the PARTITION_BLOOM_HASHES rationale to the real current constraint.

- compress.rs: extract bloom_value_hashes() as the single source of
  truth for the build-side value->hash mapping; compute_segment_blooms
  and compute_partition_bloom_hashes (parquet worker path) both use it,
  so the two build paths can no longer diverge. Drops the insert
  closure, the duplicated five-arm type matches, and the unreachable!().

- scan/exec/segments.rs: extract bloom_probe_hashes() shared by the
  Phase 0pre sentinel probe and the per-segment bloom checks — same op
  gate, type gate, and bloom_probe_encode mapping on both probe sites
  by construction.

- PERF_IMPROVEMENTS.md: state the future-incremental-update invariant
  without referencing the removed function.

No behavior change; net -135 lines.
The PG18 integration job failed intermittently on test_worker_drains/
retention: the worker serviced one test, then went silent for the rest of
the session. Root cause (reproduced by planting a deltax_deltatable row
with no backing table): any ERROR in a maintenance cycle — most commonly
'relation "<table>_default" does not exist' from the test _cleanup()
race, where pg_reload_conf() wakes the worker via SIGHUP right before the
catalog DELETE + DROP TABLE commit — unwound out of deltax_worker_main and
terminated the process. pgrx registers bgworkers with BGW_NEVER_RESTART by
default, so a single transient error silently disabled all partition
maintenance until server restart.

Two layers of defense:
- wrap each maintenance pass in PgTryBuilder: on error, abort the open
  transaction (autovacuum-style sigsetjmp recovery), log, and retry on the
  next wakeup;
- register the worker with a 5s restart time so even an unexpected death
  isn't permanent.

Adds test_worker_survives_error_in_cycle, which plants the ghost catalog
row, asserts the worker process survives the failing cycle, and verifies
maintenance resumes once the bad entry is removed.
…l_idx

The partition-sentinel probe directly indexed col_names/col_idx_map with
bq.col_idx. On a logical-replication subscriber mid-initial-sync the
partition descriptor can be empty while batch quals are present, so the
probe panicked with 'index out of bounds: the len is 0 but the index is 0'
(seen in CI test_scenario_2_replicate_companion_tables_directly, PG17).
Use checked lookups like the per-segment bloom path (segment_filters)
does; a skipped probe only forfeits pruning, never correctness.
@SferaDev
SferaDev force-pushed the feat/partition-bloom-sentinels branch from 99e08a9 to 0e694f5 Compare June 12, 2026 21:21
SferaDev added a commit that referenced this pull request Jun 12, 2026
…stack

Partition bloom sentinels (#35) and dual-mode segment files (#32) are
separate PRs; the new decompose comments referenced them as if present.
Rephrase as forward-looking interaction notes (matching the existing
'PERF #47, not yet on this branch' convention) or drop the reference.
@SferaDev
SferaDev marked this pull request as draft June 12, 2026 22:41
SferaDev added a commit that referenced this pull request Jun 15, 2026
* fix: valbitmap correctness — fail-safe pruning, retention leak, cross-schema companion lookup

Three pre-existing correctness bugs found during review of #31/#34/#35:

1. Text valbitmap wrong-empty-results on segment overflow. A segment with
   more than VALBITMAP_MAX_DISTINCT (32) distinct text values writes no
   valbitmap row and contributes nothing to the partition-level
   column_valmap. When the union of the remaining segments stayed <= 32,
   querying a value unique to the overflowed segment missed the valmap and
   took the 'prune every segment without reading the bitmap table'
   shortcut — returning zero rows instead of the overflowed segment's
   rows. Pruning now always goes through the per-segment bitmap rows:
   a valmap miss (empty wanted_bits) prunes exactly the segments that
   wrote a bitmap row; segments without one are never prunable by valmap
   logic. The now-redundant ValbitmapCheck::prune_all field is removed.

2. Retention drop leaked _valbitmap companions. auto_drop_partitions
   dropped blobs/blooms/text_lengths/colstats/meta but not valbitmap,
   leaving orphaned tables in _deltax_compressed forever. (decompress and
   the empty-partition cleanup already covered all six.)

3. check_compressed_partition matched companions by partition NAME only.
   Companion tables live in the single shared _deltax_compressed schema
   and embed only the table name, so a same-named partition in another
   schema (or any same-named plain table) was treated as compressed and
   served the other partition's data. The lookup now confirms via
   deltax.deltax_partition that the exact (schema, table) pair is the
   compressed one — at most one partition of a given name can be
   compressed, so is_compressed disambiguates.

Regression tests: tests/test_valbitmap.py (one-segment-overflow shape),
tests/test_worker.py (no orphaned companions after retention drop),
tests/test_compression.py (two-schema same-name partition).

* test: make overflow regression probe robust to minmax/dictionary segment skipping

The plan-shape assertions (vb_skipped=1, segments=1) now ride on the
present-value 'ov07' query — where only the valbitmap can skip the
bitmap-covered segment and the overflowed segment must be scanned. The
absent-value probe keeps only the row-count assertion: an absent constant
can legitimately eliminate the overflowed segment through exact
per-segment evidence (text minmax, dictionary value check) regardless of
the valbitmap.

* rustfmt: format worker.rs launcher fan-out (inherited unformatted from #38 merge)
wezell pushed a commit to dotCMS/deltax that referenced this pull request Jul 14, 2026
* fix: valbitmap correctness — fail-safe pruning, retention leak, cross-schema companion lookup

Three pre-existing correctness bugs found during review of xataio#31/xataio#34/xataio#35:

1. Text valbitmap wrong-empty-results on segment overflow. A segment with
   more than VALBITMAP_MAX_DISTINCT (32) distinct text values writes no
   valbitmap row and contributes nothing to the partition-level
   column_valmap. When the union of the remaining segments stayed <= 32,
   querying a value unique to the overflowed segment missed the valmap and
   took the 'prune every segment without reading the bitmap table'
   shortcut — returning zero rows instead of the overflowed segment's
   rows. Pruning now always goes through the per-segment bitmap rows:
   a valmap miss (empty wanted_bits) prunes exactly the segments that
   wrote a bitmap row; segments without one are never prunable by valmap
   logic. The now-redundant ValbitmapCheck::prune_all field is removed.

2. Retention drop leaked _valbitmap companions. auto_drop_partitions
   dropped blobs/blooms/text_lengths/colstats/meta but not valbitmap,
   leaving orphaned tables in _deltax_compressed forever. (decompress and
   the empty-partition cleanup already covered all six.)

3. check_compressed_partition matched companions by partition NAME only.
   Companion tables live in the single shared _deltax_compressed schema
   and embed only the table name, so a same-named partition in another
   schema (or any same-named plain table) was treated as compressed and
   served the other partition's data. The lookup now confirms via
   deltax.deltax_partition that the exact (schema, table) pair is the
   compressed one — at most one partition of a given name can be
   compressed, so is_compressed disambiguates.

Regression tests: tests/test_valbitmap.py (one-segment-overflow shape),
tests/test_worker.py (no orphaned companions after retention drop),
tests/test_compression.py (two-schema same-name partition).

* test: make overflow regression probe robust to minmax/dictionary segment skipping

The plan-shape assertions (vb_skipped=1, segments=1) now ride on the
present-value 'ov07' query — where only the valbitmap can skip the
bitmap-covered segment and the overflowed segment must be scanned. The
absent-value probe keeps only the row-count assertion: an absent constant
can legitimately eliminate the overflowed segment through exact
per-segment evidence (text minmax, dictionary value check) regardless of
the valbitmap.

* rustfmt: format worker.rs launcher fan-out (inherited unformatted from xataio#38 merge)

(cherry picked from commit 1649c98)
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