What happened?
The dict layout writer never finishes when a string value is longer than max_bytes - 16, the most an empty dictionary can hold. It spins on one CPU and memory grows until the process is killed.
BytesDictBuilder::encode_value correctly returns None for such a value:
if self.dict_bytes() + additional_bytes > self.max_dict_bytes {
return None;
}
The caller does not handle that case. start_encoding / encode_chunk encode 0 rows, remainder returns the whole chunk, and DictStreamState::encode resets the encoder and tries the same chunk again. The loop never makes progress, and every pass pushes an empty codes chunk and an empty values chunk into res, so res grows forever.
With the default DictLayoutConstraints (1 MiB), any string or binary column that DictStrategy puts on the dict path hangs on a value over 1,048,560 bytes. A value of exactly 1 MiB is enough. We hit this in production on real string data.
Expected: DictStreamState::encode returns. Actual: it never returns.
Steps to reproduce
Add to the tests module in vortex-layout/src/layouts/dict/writer.rs. With a 64-byte dictionary, a 48-byte value passes and a 49-byte value hangs.
/// Encodes one string of `value_len` bytes under a 64-byte dictionary limit and reports
/// whether encoding finished within the timeout.
fn dict_encode_finishes(value_len: usize) -> bool {
use std::sync::mpsc;
use std::time::Duration;
use super::DictChunkLabeler;
use super::DictStreamState;
let (done_tx, done_rx) = mpsc::channel();
std::thread::spawn(move || {
let mut state = DictStreamState {
encoder: None,
constraints: DictConstraints {
max_bytes: 64,
max_len: 100,
},
};
let value = "x".repeat(value_len);
let chunk = VarBinArray::from(vec![value.as_str()]).into_array();
let mut labeler = DictChunkLabeler::new(SequenceId::root().advance());
let encoded = state.encode(&mut labeler, chunk, &mut SESSION.create_execution_ctx());
done_tx.send(encoded.is_ok()).ok();
});
done_rx.recv_timeout(Duration::from_secs(2)) == Ok(true)
}
/// A dictionary entry costs its bytes plus one 16-byte `BinaryView`, so 48 bytes is the
/// longest value that fits in a 64-byte dictionary.
#[test]
fn test_encode_value_that_fills_an_empty_dictionary() {
assert!(dict_encode_finishes(48));
}
#[test]
fn test_encode_value_longer_than_an_empty_dictionary() {
assert!(
dict_encode_finishes(49),
"DictStreamState::encode keeps retrying a chunk it encoded 0 rows of"
);
}
$ cargo test -p vortex-layout --lib test_encode_value
test layouts::dict::writer::tests::test_encode_value_longer_than_an_empty_dictionary ... FAILED
test layouts::dict::writer::tests::test_encode_value_that_fills_an_empty_dictionary ... ok
The thread only exists for the timeout. In those 2 s the test process grew to about 230 MB RSS (debug build).
Environment
- Vortex version:
develop @ e0c00d540 (2026-10-08)
- Rust 1.98.0 (repo toolchain)
- OS: macOS 26, arm64
- Python/Java: n/a
Additional context
What happened?
The dict layout writer never finishes when a string value is longer than
max_bytes - 16, the most an empty dictionary can hold. It spins on one CPU and memory grows until the process is killed.BytesDictBuilder::encode_valuecorrectly returnsNonefor such a value:The caller does not handle that case.
start_encoding/encode_chunkencode 0 rows,remainderreturns the whole chunk, andDictStreamState::encoderesets the encoder and tries the same chunk again. The loop never makes progress, and every pass pushes an empty codes chunk and an empty values chunk intores, soresgrows forever.With the default
DictLayoutConstraints(1 MiB), any string or binary column thatDictStrategyputs on the dict path hangs on a value over 1,048,560 bytes. A value of exactly 1 MiB is enough. We hit this in production on real string data.Expected:
DictStreamState::encodereturns. Actual: it never returns.Steps to reproduce
Add to the
testsmodule invortex-layout/src/layouts/dict/writer.rs. With a 64-byte dictionary, a 48-byte value passes and a 49-byte value hangs.The thread only exists for the timeout. In those 2 s the test process grew to about 230 MB RSS (debug build).
Environment
develop@e0c00d540(2026-10-08)Additional context
max_dict_bytescheck inencode_value, but panicked before reaching it. Here the check works and the caller loops on itsNone.