Skip to content

Type capture label keys and values as Dictionary(Int32,Utf8) - #1927

Draft
preinlein wants to merge 6 commits into
mainfrom
paul.reinlein/capture-labels-dict
Draft

Type capture label keys and values as Dictionary(Int32,Utf8)#1927
preinlein wants to merge 6 commits into
mainfrom
paul.reinlein/capture-labels-dict

Conversation

@preinlein

@preinlein preinlein commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Types the capture parquet labels map key and value columns as Dictionary(Int32, Utf8) instead of Utf8.

  • Adds lading_capture_schema::label_dictionary_type() as the single source of the label logical type, used by both the schema and the parquet writer.
  • Builds label key/value arrays with StringDictionaryBuilder<Int32Type> in the writer, and reads them back as dictionary arrays in validate and the roundtrip property test.

Motivation

  • Labels dominate capture parquet memory (~92% of bytes in some cases), and their key/value strings repeat heavily across rows.
  • Parquet page dictionary encoding already shrank the on-disk size, but readers still materialized every label string per row into resident memory.
  • Typing the columns as Dictionary(Int32, Utf8) lets downstream readers materialize each distinct string once and reference it by index, cutting resident memory rather than only the on-disk size.

Additional Notes

This is a breaking change. The old (Utf8) and new (dictionary) capture schemas are not interchangeable; downstream consumers must read the label columns as dictionary arrays. It is intended to land as a coordinated cutover with the downstream reader change.

Labels dominate capture parquet memory, and their key/value strings repeat
heavily across rows. Typing the labels map key/value as Dictionary(Int32,Utf8)
instead of Utf8 lets readers materialize each distinct string once and
reference it by index, cutting resident memory of downstream capture readers
rather than only the on-disk size that parquet page dictionary encoding
already handled.

- Add lading_capture_schema::label_dictionary_type() as the single source of
  the label logical type; use it in the schema and the parquet writer.
- Build label key/value arrays with StringDictionaryBuilder<Int32Type> in the
  writer, and read them as dictionary arrays in validate and the roundtrip test.

Breaking: the old (Utf8) and new (dictionary) capture schemas are not
interchangeable; consumers must read the label columns as dictionary arrays.
Address review feedback on the label Dictionary(Int32,Utf8) change:

- Extract resolve_label_dictionary helper in formats::parquet, used by both
  the validate path and the roundtrip test, removing the duplicated
  DictionaryArray -> StringArray downcast blocks.
- Add parquet_round_trip_repeated_labels, a deterministic test roundtripping
  many rows that share identical label strings so the dictionary-dedup path is
  exercised under the production repetition pattern.
- Soften the label_dictionary_type doc comment: drop the unsubstantiated 92%
  figure and reader-specific claim.
The `Dictionary(Int32, Utf8)` label typing broke a second in-repo reader that
the first pass missed: `captool analyze` decoded the label `key`/`value` columns
with `downcast_ref::<StringArray>()`, which returns `None` once those columns are
`DictionaryArray<Int32Type>`, so `analyze_metric` failed on every capture written
by the new writer. It compiles (auto-detected bin target), so there was no
compile-time signal, and nothing exercised the path.

- Make `resolve_label_dictionary` `pub` so `captool` (in the `lading` crate) uses
  the same canonical decode path as the `lading-capture` reader/validator instead
  of re-rolling the two-step downcast.
- Route `captool analyze` label decoding through `resolve_label_dictionary`.
- Add a `captool` test that writes a capture with the production `Format` writer
  and runs `list_metrics`/`analyze_metric` over it, so any future divergence
  between the writer's on-disk schema and the analyzer fails in CI.
- Add a `lading-capture` roundtrip test that flushes between chunks, forcing
  shared label strings into independent per-batch dictionaries.
- Add a `validate_parquet` test covering its own dictionary-decode and
  per-series label-reconstruction path.
- Fix doc/import/casing nits from review.
Addresses a second review pass over the dictionary-label changes:

- Rewrite the `formats` multi-batch test. The old
  `parquet_round_trip_multiple_batches` documented that flushing between
  chunks forces separate per-batch dictionaries, but `ArrowWriter`
  coalesces per-`write()` batches into one row group, so three flushes of
  four rows produced a single read batch — the doc claim was false and the
  test never exercised per-batch decode. `parquet_round_trip_multiple_read_batches`
  reads with `with_batch_size(2)` to genuinely emit several batches, decodes
  each batch's `Dictionary(Int32, Utf8)` labels via `resolve_label_dictionary`,
  and asserts `batch_count > 1` so the coverage claim cannot silently lapse.
- Assert `mean` and `is_monotonic` on the staging series in the captool
  `analyze_metric` test, matching the prod series assertions.
- Remove a redundant dictionary-label validate test; `validate.rs` already
  covers the `validate_parquet` decode path across happy and error cases.
The `parquet_round_trip_multiple_read_batches` doc claimed the test defends
against a reader mishandling per-batch dictionaries (stale indices, assuming
one global dictionary). It cannot: the writer emits a single row group with
one dictionary page, so every read batch shares that dictionary — there are no
divergent per-batch dictionaries to reconcile, and the test data
(`prod`/`staging` alternating) yields an identical mapping in every batch
regardless. Reword the doc to state what the test actually proves — that a file
read as more than one `RecordBatch` decodes every label correctly through
`resolve_label_dictionary` — and note the single-dictionary layout explicitly.
Doc comment only; no behavior change.
Trim the verbose doc and inline comments introduced with the dictionary-label
work down to what each actually needs to say. No code changes. Also drop a
stale "independent dictionary" inline comment in the multi-batch test that
contradicted the corrected doc (the file has one shared dictionary page).
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