Skip to content

feat(writer): cardinality-bounded global-dict buffering (ADR 0021)#285

Merged
dfa1 merged 1 commit into
mainfrom
feat/cardinality-bounded-global-dict
Jul 19, 2026
Merged

feat(writer): cardinality-bounded global-dict buffering (ADR 0021)#285
dfa1 merged 1 commit into
mainfrom
feat/cardinality-bounded-global-dict

Conversation

@dfa1

@dfa1 dfa1 commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Summary

Implements ADR 0021. Replaces VortexWriter's raw-value buffering for global-dictionary candidates (which scaled memory with file size × column count, causing OOM on wide categorical files — see #278/#279) with cardinality-bounded buffering:

  • Each candidate keeps a deduplicated value → code map (capped at GLOBAL_DICT_MAX_CARDINALITY = 2048), a per-code occurrence count, and cheap in-memory short[] code arrays per chunk, instead of raw duplicated values.
  • The cardinality cap check moves from close()-only to continuous — the moment a chunk's fresh distinct values would exceed the cap, the column demotes immediately (atomic two-pass ingest: a rejected chunk never partially mutates the dictionary state).
  • Demotion (cardinality- or byte-budget-triggered) is lossless: buffered chunks are reconstructed exactly from their codes + an inverse map and replayed through the normal cascade path.
  • Primitive columns keep their frequency-descending code assignment (dominant value → code 0, for SparseEncodingEncoder fill=0 compression, matching Rust's FloatDictScheme) via a close()-time remap; Utf8 columns need no remap (already first-seen order).
  • WriteOptions#globalDictMaxRetainedBytes default raised 256MB → 1GB now that it guards ~2B/row code arrays instead of raw strings (~35-45× smaller retention per candidate).

Test plan

  • Full writer suite: ./mvnw verify -pl writer -am -DskipITs — 1713 tests, 0 failures, checkstyle clean
  • New regression tests: mid-file cardinality-triggered demotion (primitive + nullable-primitive variants — the nullable reconstruction path is the one ADR 0021 itself flags as a risk to manage), primitive frequency-remap invariant, Utf8 U16-code-width coverage
  • Reviewed by vortex-reviewer against the 5 highest correctness risks (round-trip fidelity, frequency remap consistency, two-pass ingest atomicity, per-chunk stats parity) — APPROVE, all gaps closed before this push
  • Prior integration ground truth (JavaWritesRustReadsIntegrationTest, FileSizeComparisonIntegrationTest) confirmed green during implementation — wire output stays Rust-readable, dict+sparse size win preserved

🤖 Generated with Claude Code

VortexWriter previously buffered every global-dict-candidate column's raw
per-row values from its first chunk until close(), so it could build one shared
dictionary spanning the whole file. Retained memory scaled with file size x
column count, not with any bounded quantity: a candidate whose distinct set only
grew past GLOBAL_DICT_MAX_CARDINALITY (2048) after millions of later rows had
already accumulated raw duplicates for the whole file, OOM-ing on wide
categorical files (NYC 311: 18.5M rows x 38 string columns). A prior stopgap
added an aggregate raw-byte budget, but its eviction signal was wrong — a huge
genuinely-low-cardinality column (exactly where a shared dict helps most) was
demoted first for being the biggest byte contributor.

Per ADR 0021, replace raw-value buffering with cardinality-bounded buffering:

- Each candidate keeps a deduplicated value->code map (first-seen order, capped
  at GLOBAL_DICT_MAX_CARDINALITY), a per-code occurrence count, and one cheap
  in-memory short[] code array per chunk. Per-chunk row/null counts and
  min/max/sum stats are captured at ingest, before the raw array is discarded.
- The cap check moves from close()-only to continuous: the moment a chunk's
  fresh distinct values would exceed the cap, the column demotes immediately.
  Ingest is atomic — a two-pass check ensures a rejected chunk never partially
  mutates the map.
- Demotion (cardinality- or byte-budget-triggered) is lossless: buffered chunks
  are reconstructed exactly from their codes + the inverse map and replayed
  through the normal cascade path, yielding a Chunked-of-Flats layout.
- Primitive close()-time build ranks distinct values by occurrence descending
  (dominant -> code 0, for SparseEncoding fill=0, matching Rust FloatDictScheme)
  via a first-seen -> frequency-rank remap over the buffered code arrays. Utf8
  keeps first-seen order (its incremental map already matches); no remap.

The globalDictMaxRetainedBytes budget stays as a secondary safety net but now
tracks ~2 B/row code arrays (~35-45x smaller than raw strings); its default is
raised from 256 MB to 1 GB so normal wide low-cardinality files keep all their
dictionaries while pathological many-wide-column cases stay bounded.

Adds regression tests for mid-file cardinality-triggered demotion (output is a
Chunked-of-Flats layout, not a shared Dict) and the primitive frequency remap
(dominant value gets code 0 regardless of first-seen order). Existing byte-
budget and default-value tests updated for the smaller code-array quantity and
the new 1 GB default. Full writer suite (1711) green; Java-writes-Rust-reads
(217) and file-size comparison (8) integration tests confirm the wire output
stays Rust-readable and size-competitive.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@dfa1
dfa1 merged commit 62bb851 into main Jul 19, 2026
6 checks passed
@dfa1
dfa1 deleted the feat/cardinality-bounded-global-dict branch July 20, 2026 20:59
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