fix: avoid LZ4 overflow when compressing large dictionary values - #8359
fix: avoid LZ4 overflow when compressing large dictionary values#8359beinan wants to merge 2 commits into
Conversation
Dictionary values are compressed with a single LZ4 call, but the default compression for that buffer was hardcoded to lz4 with no size check. Once the dictionary exceeds LZ4_MAX_INPUT_SIZE (0x7E000000) the write fails with a bare "Compression input too long" from the lz4 crate. This is reachable from ordinary writes: a column whose values are duplicate-heavy enough to be dictionary encoded builds a dictionary bounded only by 0.8 * data_size, so a page over ~2.6 GiB can produce an oversized dictionary buffer. Reproduced with a list<large_binary> column of low-entropy values, which fails on a 2.25 GB dictionary. Fall back to zstd for the default when the dictionary values buffer is too large for LZ4. Zstd has no comparable input limit and compresses these buffers better in practice. An explicitly requested scheme is still honored so a user's choice is never silently changed. Also validate the input length in Lz4BufferCompressor::compress. The lz4 crate's error names neither the actual size nor the limit, which makes the failure hard to diagnose; the error now reports both and points at zstd. Note LZ4 buffers here prepend the uncompressed length as a u32, so this format cannot represent larger inputs regardless of compress_bound. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
The default fallback is the right format-compatible direction, but codec selection must use the exact serialized block length and choose only a codec compiled into the current build. A complete revision can preserve explicit choices, use LZ4 only when its actual input fits, then use zstd—or uncompressed encoding when zstd is unavailable—for the implicit fallback.
| let num_dictionary_items = dictionary_data.num_values(); | ||
| let dict_values_field = Self::build_dict_values_compressor_field(field)?; | ||
| let dict_values_field = | ||
| Self::build_dict_values_compressor_field(field, dictionary_data.data_size())?; |
There was a problem hiding this comment.
dictionary_data.data_size() is smaller than the buffer that this compressor actually passes to LZ4 for variable-width dictionaries. VariableEncoder adds 8 bytes for 32-bit offsets or 16 bytes for 64-bit offsets, so a dictionary with data_size() == LZ4_MAX_INPUT_SIZE selects LZ4 here but reaches check_input_size at MAX + 8 / MAX + 16 and still fails the write. Select from the exact serialized block length (or serialize once before selection), and test the fixed/u32/u64 boundaries.
Bounded reproducer
The full writer case needs a multi-GiB allocation, so I ran this no-allocation boundary reproduction using the current head's selector and framing formulas:
python3 -B -c 'lz4_max=0x7E000000
dict_data_size=lz4_max
selected="zstd" if dict_data_size > lz4_max else "lz4"
for offset_bits, framing in ((32, 8), (64, 16)):
serialized_size=dict_data_size+framing
print(offset_bits, selected, serialized_size, serialized_size > lz4_max)
assert selected == "lz4" and serialized_size > lz4_max'Observed:
32 lz4 2113929224 True
64 lz4 2113929232 True
| /// of buffer better anyway, so fall back to it rather than failing the write. | ||
| fn default_dict_values_compression(dict_values_size: u64) -> &'static str { | ||
| if dict_values_size > lz4_max_input_size() { | ||
| DEFAULT_LARGE_DICT_VALUES_COMPRESSION |
There was a problem hiding this comment.
This selects zstd even when the zstd feature is not compiled. The supported lz4,bitpacking feature configuration builds successfully, but an oversized implicit dictionary then asks GeneralBufferCompressor for zstd and gets package was not built with zstd support, so the original write failure remains. Make the implicit fallback feature-aware (zstd when available, otherwise none); explicit codec requests should continue to fail rather than being silently changed.
Reproducer
I ran a disposable crate with this dependency:
lance-encoding = { path = "/home/repo/rust/lance-encoding", default-features = false, features = ["lz4", "bitpacking"] }use lance_encoding::encodings::physical::block::{
CompressionConfig, CompressionScheme, GeneralBufferCompressor,
};
fn main() {
let error = GeneralBufferCompressor::get_compressor(CompressionConfig::new(
CompressionScheme::Zstd, None,
))
.unwrap_err();
println!("{error}");
assert!(error.to_string().contains("not built with zstd support"));
}Command: CARGO_TARGET_DIR=/home/agent/tmp/gate8359-feature-repro-target cargo run --quiet
Observed: Invalid user input: package was not built with zstd support
Address review feedback on the dictionary values compression fallback. The size check compared LZ4's input limit against DataBlock::data_size(), but CompressedBufferEncoder compresses the serialized form, which is larger by the header VariableEncoder prepends (16 bytes for 64-bit offsets). A dictionary just under the limit would still overflow once serialized. Add variable_encoded_size() next to the encoder it mirrors and check against that instead. The fallback also named zstd unconditionally, which fails with "package was not built with zstd support" on builds without that feature. Fall back to uncompressed storage in that case so the write still succeeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed 8d8dafd addressing both review points, plus real-world numbers from the reporter. Review feedbackExact serialized length. Confirmed — the check compared LZ4's limit against Codec availability. Also confirmed — falling back to Note the library builds clean with Real-world numbersThe reporter verified the workaround on production data and measured it — single row, 2.431 GiB payload, 2600 chunks:
This is a better picture than my synthetic benchmark: 35% smaller on disk for ~1.5× slower write and read. Worth noting that tradeoff only applies where both codecs actually work — the fallback here triggers only above the LZ4 limit, where the alternative is a failed write, so nothing that succeeds today changes behavior. Verification
|
There was a problem hiding this comment.
✅ Gate recommendation: approve.
The revision now selects from the exact serialized dictionary-buffer length, preserves explicit codec choices, and uses an available existing encoding when LZ4 cannot accept the buffer. The focused boundary, precedence, codec-guard, feature-build, and dictionary round-trip checks found no blocker.
Xuanwo
left a comment
There was a problem hiding this comment.
I don’t fully understand this issue. Are we talking about a dictionary larger than 2 GiB? If so, it feels like we should approach such data with a different strategy.
How large those large_binary? Should they be stored as blob?
|
@Xuanwo Good questions, and I think you're right on the design point. Let me answer directly. Yes, this is a dictionary larger than 2 GiB, and nothing currently prevents that. The budget is purely proportional with no absolute term ( let max_encoded_size = (data_size as f64 * threshold_ratio) as u64; // 0.8 * data_sizeIt is enforced during encoding ( How large are the values? ~0.96 MB each (2.431 GiB / 2600 chunks). Which leads to the more interesting problem: There is no per-value size gate on dictionary encoding at all. The gates are block type, a minimum of 100 values, and sample uniqueness < 0.98. Meanwhile So I agree the deeper issue is that dictionary encoding is running on data it was never meant for. On blob: yes, that's likely the right answer for this user, and it does work on a list child — the marker goes on the What I'd suggest for this PR: I think it's still worth landing as-is, but on narrower grounds than I originally framed it. Independent of whether the dictionary should be this large, For the root cause I see two options, and I'd rather you pick than guess:
Happy to do either in this PR or a follow-up. If you'd prefer I fold one in here and drop the codec fallback entirely, that also works — though I'd keep the |
|
@Xuanwo here is the reproduction, so you can judge the "is this real" part independently of the design discussion. ReproducerDrop this in #[test_log::test(tokio::test)]
#[ignore = "allocates ~7 GiB"]
async fn repro_compression_input_too_long() {
const VALUE_SIZE: usize = 45_000;
const NUM_DISTINCT: usize = 50_000;
const NUM_VALUES: usize = 150_000;
// Distinct values, but low byte entropy, and repeated in runs so the
// 4096-element uniqueness sample sees duplicates and dictionary encoding wins.
let distinct: Vec<Vec<u8>> = (0..NUM_DISTINCT).map(|i| {
let mut v: Vec<u8> = (0..VALUE_SIZE).map(|j| (j % 16) as u8).collect();
v[..8].copy_from_slice(&(i as u64).to_le_bytes());
v
}).collect();
let values: Vec<&[u8]> = (0..NUM_VALUES)
.map(|i| distinct[(i / 3) % NUM_DISTINCT].as_slice()).collect();
let arr = LargeBinaryArray::from_iter_values(values);
let item_field = Arc::new(Field::new("item", DataType::LargeBinary, true));
let offsets = OffsetBuffer::new(vec![0i32, arr.len() as i32].into());
let list = ListArray::new(item_field.clone(), offsets, Arc::new(arr), None);
let schema = Arc::new(Schema::new(vec![
Field::new("data", DataType::List(item_field), false)]));
let batch = RecordBatch::try_new(schema.clone(), vec![Arc::new(list)]).unwrap();
let fs = FsFixture::default();
let reader = RecordBatchIterator::new(vec![Ok(batch)], schema);
write_lance_file(reader, &fs, ConcreteFileVersion::V2_2,
FileWriterOptions::default()).await;
}On With instrumentation showing the chain: The dictionary is 2.25 GB against a 5.4 GB budget — well within it. Nothing rejects it. The reporter hit this on production data with no unusual settings; So: real bug, reachable from an ordinary write, and the error names neither the size nor the limit. On blob — I think it only partly fitsYou are right that blob is the better home for ~1 MB values. But talking to the reporter, their column is not uniformly large: most values are small, a few are very large, and the distribution is skewed. Declaring the whole column a blob column to accommodate a minority of big values is a heavier change than the data warrants, and it changes the read API for every consumer of that column. Blob v2 does handle skew better than I first credited — the tiering is per value, not per column ( const INLINE_MAX: usize = 64 * 1024; // 64KB inline cutoff
const DEDICATED_THRESHOLD: usize = 4 * 1024 * 1024; // 4MB dedicated cutoffSo small values stay inline and only the heavy ones go to sidecars. That is a genuinely reasonable fit, and I will pass it along as a recommendation. (Minor: But I do not think it resolves the bug. Blob is opt-in per field, and the failure happens for users who have not opted in and have no signal that they should — the write simply fails with an error that does not suggest a remedy. Whatever we recommend for this schema, an ordinary That is why I would still land the codec-selection fix, and treat the "should a >2 GiB dictionary exist" question as the separate follow-up. I am happy to take that follow-up too — I just would not want the crash to stay in |
|
Thank you @beinan, I will take a think around this and maybe answer you on Monday. |
No rush, happy to chat any time |
There was a problem hiding this comment.
🟡 Gate recommendation: maintainer decision required.
The codec fix is correct for the demonstrated LZ4 boundary, but it also decides whether the default writer should admit a >2 GiB dictionary that readers must fetch, decode, and cache as one unit. The format defines that eager dictionary contract but no acceptable maximum, so the resource policy cannot be resolved from existing invariants.
Please choose between:
- preserving dictionary compression and using zstd or uncompressed storage when implicit LZ4 cannot encode the buffer, accepting multi-GiB dictionary cache state;
- imposing an absolute size or codec-capability cutoff for auto-generated dictionaries and falling back to the existing structural encoding, with raw storage or a descriptive error for source dictionary arrays.
Blob remains an opt-in schema choice with different read semantics, not a transparent fallback. The deciding criterion is whether compression ratio and successful generic inline writes outweigh bounded initialization and cache memory. A max-value-width gate is not equivalent: miniblocks contain dictionary indices, not those original values.
Dictionary values are compressed with a single LZ4 call, but the default compression for that buffer is hardcoded to
lz4with no size check (primitive.rs:108). Once the dictionary exceedsLZ4_MAX_INPUT_SIZE(0x7E000000), the write fails with a bareCompression input too longfrom the lz4 crate.Why this is reachable
This is not a synthetic edge case. A column whose values are duplicate-heavy enough to be dictionary encoded builds a dictionary bounded only by
0.8 * data_size, so a sufficiently large page can produce an oversized dictionary buffer.The path is:
should_dictionary_encodeaccepts the block →build_dict_values_compressor_fieldsynthesizes a field carryinglance-encoding:compression = "lz4"→ that hits the user-requested branch oftry_general_compression, bypassing theMIN_BLOCK_SIZE_FOR_GENERAL_COMPRESSIONsize test entirely →CompressedBufferEncoder::compressflattens the dictionary and makes one LZ4 call.Worth noting the automatic branch of
try_general_compressionis not implicated: it selectsCompressedBufferEncoder::default(), which is zstd whenever thezstdfeature is on.I reproduced this with a
list<large_binary>column of low-entropy values, which fails on a 2.25 GB dictionary buffer.The fix
Fall back to zstd for the default when the dictionary values buffer is too large for LZ4. Zstd has no comparable input limit. An explicitly requested scheme (field metadata or env var) is still honored, so a user's choice is never silently changed — they get the error instead.
Also validate the input length in
Lz4BufferCompressor::compress. The lz4 crate's error names neither the actual size nor the limit, which makes this hard to diagnose; the error now reports both and points at zstd.Note the LZ4 buffers here prepend the uncompressed length as a
u32, so this format cannot represent larger inputs regardless ofcompress_bound.On the ratio tradeoff
Zstd is not a regression for this buffer. Measured at 256 MB with the crate versions in-tree, zstd level 0 (the default used elsewhere in
lance-encoding):Zstd does not lose on ratio anywhere and wins substantially on the buffer in question. Since the fallback only triggers above 2 GiB — where LZ4 cannot run at all — the alternative is a failed write.
Tests
test_lz4_input_size_limit— the size guard, written against a pure function so it does not allocate multiple GiB.test_resolve_dict_values_compression_metadata_large_falls_back_to_zstd— oversized falls back; exactly at the limit still uses LZ4.test_resolve_dict_values_compression_metadata_large_respects_explicit_request— an explicitlz4request is preserved.Verified end-to-end that the original 6.29 GiB
list<large_binary>repro now writes successfully.cargo test -p lance-encoding(557 passed) andcargo test -p lance-file(155 passed) are green;cargo fmt --allandcargo clippy --tests -- -D warningsare clean.The end-to-end repro itself is not included as a test since it allocates ~7 GiB, which is not appropriate for CI.
🤖 Generated with Claude Code