Skip to content

Phase B: per-column dictionary / delta / zigzag-varint encodings - #13

Merged
pbudzik merged 1 commit into
mainfrom
feat/per-column-encodings
May 16, 2026
Merged

Phase B: per-column dictionary / delta / zigzag-varint encodings#13
pbudzik merged 1 commit into
mainfrom
feat/per-column-encodings

Conversation

@pbudzik

@pbudzik pbudzik commented May 16, 2026

Copy link
Copy Markdown
Owner

Summary

The columnar segment format reserved Encoding::{Dictionary, Delta, Zigzag} discriminants when it shipped — this PR implements them. Pure writer/reader work; no format version bump.

Per-column choices

Column Encoding Why
event_id Plain Near-unique per row; dictionary would expand
kind Plain 3 values; Plain+zstd good enough (RLE is a future tweak)
correction_ref Plain Sparse + structured
account_id / product_id / meter_id / source / unit Dictionary Heavy repetition — main win
subscription_id / model_id Dictionary Same, nullable variant
timestamp_ms / ingested_at_ms Delta Near-monotonic; deltas are small
quantity Zigzag-varint Small ± values pack to 1–2 bytes (vs 16 for raw i128)
dimensions Plain BTreeMap per row; complex enough that bincode+zstd is fine

Payload formats

  • Dictionary: bincode (Vec<String>, Vec<u32>) (or Vec<Option<u32>> for nullable). Unique values plus a per-row index.
  • Delta: bincode Vec<i64> of running differences; reader does prefix-sum.
  • Zigzag-varint: u32 LE count + concatenated zigzag→varint bytes. The count is needed because varints are variable-width.

Compatibility

The reader is permissive: for each column type, it accepts Plain OR the appropriate alternative. This means segments written before this PR still load unchanged (everything was Plain), and a future encoder change for any column (e.g., adding RLE for kind) doesn't break old segments.

Impact

The existing compression_reduces_size_on_repetitive_data test (10k events with one constant account) still passes its <500 KB bar. The new dictionary_encoding_shrinks_repetitive_id_columns test tightens that bar to <250 KB on the same payload. Real workloads with mostly-static IDs should see comparable or larger wins.

Tests

Five new tests in tests/encodings.rs:

  • dictionary_encoding_shrinks_repetitive_id_columns — tightened size bound
  • dictionary_round_trip_with_many_distinct_values — includes Option<String> nulls
  • delta_encoding_handles_out_of_order_timestamps — out-of-order + negatives + i64::MAX/2
  • zigzag_varint_round_trips_edge_cases0, ±1, ±128, i128::MAX, i128::MIN
  • zigzag_varint_packs_small_quantities_tight — 1000 small-quantity events under 8 KB

Test plan

  • cargo build --all-targets clean with -D warnings
  • cargo test --all-targets — 82 tests pass (was 77; +5)
  • CI green

🤖 Generated with Claude Code

The columnar segment format already reserved the Encoding discriminants
(Dictionary, Delta, Zigzag) when it shipped, so this is pure writer/
reader work — no format version bump, segments written before this
change still load unchanged via the Plain path.

Per-column choice:

  event_id          Plain        (high cardinality, ~unique per row)
  kind              Plain        (3 values; Plain+zstd good enough)
  correction_ref    Plain        (sparse + structured)
  account_id        Dictionary   (heavy repeat)
  subscription_id   Dictionary   (heavy repeat, nullable)
  product_id        Dictionary
  meter_id          Dictionary
  model_id          Dictionary   (nullable)
  source            Dictionary
  unit              Dictionary
  timestamp_ms      Delta        (near-monotonic; tiny deltas)
  quantity          Zigzag       (small +/- values pack to 1-2 bytes)
  dimensions        Plain        (BTreeMap; bincode + zstd handles it)
  ingested_at_ms    Delta

Dictionary payload: `(Vec<String>, Vec<u32>)` — unique values + per-row
index. For nullable strings, `Vec<Option<u32>>` so None is preserved
without a sentinel.

Delta payload: `Vec<i64>` of running differences; reader does prefix-
sum to reconstruct.

Zigzag-varint payload: `u32 LE count` + concatenated zigzag→varint
bytes. The count is needed because varints are variable-width. Zigzag
maps signed → unsigned (`-1 → 1`, `1 → 2`, …) so small absolute values
stay small unsigned.

Reader is permissive: it accepts Plain OR the appropriate alternative
for each column type. This means a future writer change (e.g., adding
RLE for `kind`) doesn't break compatibility — segments with mixed
encoding history coexist.

Impact: the existing `compression_reduces_size_on_repetitive_data`
test still passes its <500KB bar, but the new
`dictionary_encoding_shrinks_repetitive_id_columns` test tightens the
bar to <250KB. Real workloads with mostly-static account/product/
meter columns should see comparable or larger wins.

Five new tests in tests/encodings.rs:
  - dictionary_encoding_shrinks_repetitive_id_columns
  - dictionary_round_trip_with_many_distinct_values (incl. Option nulls)
  - delta_encoding_handles_out_of_order_timestamps (incl. negatives + MAX)
  - zigzag_varint_round_trips_edge_cases (incl. i128::MIN/MAX)
  - zigzag_varint_packs_small_quantities_tight

Total tests: 82 (was 77; +5). Clean under RUSTFLAGS=-D warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@pbudzik
pbudzik merged commit 4dc7b03 into main May 16, 2026
1 check passed
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