feat(core): add RLE dictionary encoding support for STRING columns in Parquet - #6909
Conversation
The Parquet reader supported RLE dictionary encoding for VARCHAR columns but not for STRING columns. This adds the missing dispatch arm in decode_byte_array_dispatch that wires BaseVarDictDecoder + RleDictionarySlicer + StringColumnSink together for ColumnTypeTag::String, reusing the same infrastructure already proven for VARCHAR. The default writer encoding for STRING columns changes from DeltaLengthByteArray to RleDictionary, matching the VARCHAR default. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
bluestreak01
left a comment
There was a problem hiding this comment.
PR #6909 Review: feat(core): add RLE dictionary encoding support for STRING columns in Parquet
Critical
1. Multi-partition Parquet export fallback is a no-op for STRING columns
core/rust/qdbr/src/parquet_write/file.rs:499-507:
// non-Symbol dict columns would emit multiple DictPages per column chunk
// (invalid Parquet). Fall back to the type's default encoding.
if num_partitions > 1
&& col_encoding == Encoding::RleDictionary
&& !first_partition_column.data_type.is_symbol()
{
col_encoding = super::schema::encoding_map(first_partition_column.data_type);
}Since encoding_map(String) now returns RleDictionary, this fallback reassigns RleDictionary back to itself — a no-op. The comment explicitly states this produces invalid Parquet (multiple DictPages per column chunk). Multi-partition Parquet exports with STRING columns will now use dictionary encoding without the intended fallback to a non-dictionary encoding.
Nuance: VARCHAR also defaults to RleDictionary and has the same no-op fallback, so this is a pre-existing pattern. However, this PR extends it to STRING columns, increasing the blast radius. The fix should change the fallback to use a specific non-dictionary encoding (e.g., Encoding::DeltaLengthByteArray) rather than encoding_map():
col_encoding = match first_partition_column.data_type.tag() {
ColumnTypeTag::String => Encoding::DeltaLengthByteArray,
ColumnTypeTag::Varchar => Encoding::DeltaLengthByteArray,
_ => Encoding::Plain,
};Moderate
2. File size regression with no justification
The encoding default change from DeltaLengthByteArray to RleDictionary increases Parquet file sizes across the board:
| Test case | Before | After | Increase |
|---|---|---|---|
| basic_parquet | 1,231 B | 1,251 B | +1.6% |
| json_conn_table | 1,683 B | 1,787 B | +6.2% |
| bloom_single_test | 81,711 B | 99,354 B | +21.6% |
| large_export | 927,480 B | 1,026,961 B | +10.7% |
RleDictionary is typically larger than DeltaLengthByteArray for high-cardinality string data (many unique values) because it stores a full dictionary + indices. The PR description only mentions throughput benchmarks (49–73 Melem/s) but does not address the file size tradeoff or explain why the regression is acceptable. The PR should either:
- Justify the tradeoff (e.g., decode speed gain vs. file size cost)
- Keep
DeltaLengthByteArrayas the default and only add reader support for RleDictionary (to read files from other tools)
3. UTF-8 decode errors silently swallowed (pre-existing, not blocking)
core/rust/qdbr/src/parquet_read/column_sink/var.rs:106-110 — StringColumnSink stores UTF-8 errors in a private error field (line 155), but no code ever reads it. The Pushable trait has no result() method. Invalid UTF-8 strings are silently converted to NULLs. This is pre-existing (affects VARCHAR too) and not introduced by this PR, but the PR copies the pattern. Consider filing a follow-up issue.
4. PlainDictionary encoding not tested
The new match arm at decode.rs:1194-1195 handles both Encoding::RleDictionary | Encoding::PlainDictionary, but only RleDictionary is tested. PlainDictionary is a legacy Parquet v1 encoding that external tools may produce. A test should be added for Encoding::PlainDictionary with STRING columns.
Minor
5. PR title focuses on implementation details
Per CLAUDE.md: "PR title descriptions must speak to the end-user about the positive impact, not about internal implementation details." The current title describes the implementation. A better title would focus on the user impact, e.g., feat(core): add Parquet import support for dictionary-encoded STRING columns.
6. Missing edge case tests
The decode test covers version variants, null patterns (none/sparse/dense), and filtered decode. However, several edge cases are not tested:
- Empty strings (all test strings are 7+ characters)
- All-NULL columns (only 0%, 10%, 50% null tested)
- Single-row tables (all tests use COUNT=1000)
- Cardinality=1 (all identical strings)
Downgraded (false positives from initial review)
| Original claim | Why dismissed |
|---|---|
Parameter order mismatch in RleDictionarySlicer::try_new |
row_hi = mode.source_row_count() maps to param row_count (3rd) and row_count = mode.sliced_row_count() maps to param sliced_row_count (4th) — correct order, just confusing variable names. |
Empty buffer panic in RleDictionarySlicer::try_new |
Pre-existing in the VARCHAR path. Parquet RLE spec mandates a bit-width byte; only reachable with corrupt files. |
Integer overflow in StringColumnSink::reserve() |
Requires count > usize::MAX / 8, impossible in practice since row counts are bounded by JNI i32 parameters. |
Summary
The core reader change (adding the StringColumnSink + RleDictionarySlicer match arm) is correct and properly mirrors the existing VARCHAR path. However, the multi-partition fallback no-op (Critical #1) can produce invalid Parquet files for STRING columns in multi-partition exports, and the file size regression (Moderate #2) needs justification.
Review stats: 9 draft findings verified, 3 downgraded as false positives.
…pes in encoding_map
… use DeltaLengthByteArray
decode_byte_array_dispatch now returns a clear "dictionary page required for dictionary encoding" error when a STRING column uses RleDictionary/PlainDictionary encoding but the dict page is missing. This aligns with the pattern used in decode_fixed_len_dispatch for UUID, Long128, and Long256. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
testFuzz() called rnd.nextLong() from all 8 threads concurrently after the start latch. Rnd is not thread-safe — the race can corrupt its state to all-zeros, making every thread pick the same (table, wal) pair on every iteration. This serializes all 1.6M operations through a single monitor, exceeding the 30-second timeout on slow CI machines. Pre-generate per-thread seeds on the main thread so each worker gets a distinct Rnd without touching the shared instance. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
[PR Coverage check]😍 pass : 77 / 102 (75.49%) file detail
|
Summary
BaseVarDictDecoder+RleDictionarySlicer+StringColumnSinktogether indecode_byte_array_dispatchRleDictionarytoDeltaLengthByteArray, aligning it with STRING and Binary defaultsstring_dictbenchmark cases to thedecode_pagebenchmark suiteTest plan
test_string_rle_dictionarydecode test covering all writer versions and null patterns (no nulls, sparse, dense), both full and filtered decodecargo testsuite (680 tests pass)cargo llvm-cov— new match arm fully covered (12 executions across monomorphizations)string_dict_c{10,100,256,1000}_n{0,20}shows 49–73 Melem/s throughput🤖 Generated with Claude Code