Skip to content

Commit fb4b6be

Browse files
dfa1claude
andcommitted
fix: bound VortexWriter's global-dict retained memory to avoid OOM on huge files
VortexWriter buffers a global-dict candidate column's raw chunk data in memory from its first chunk until close() -- a shared dictionary can only be built once every chunk has been seen. On a huge, wide file (e.g. an 18.5M-row / 38-string-column real-world Parquet import) any column that looks low-cardinality on an early chunk but never gets demoted keeps its entire column pinned in the heap; with dozens of such columns the total reaches several GB and the import throws OutOfMemoryError, independent of how much heap is available (memory scales with file size x column count, not with a bounded chunk size). Add an aggregate retained-bytes budget (256 MB, GLOBAL_DICT_MAX_RETAINED_BYTES) across all buffering dict-candidate columns. Each chunk appended to a candidate updates a per-column and running-total estimate (estimateRetainedBytes); crossing the budget demotes the largest-retained columns -- flushing their already-buffered chunks as ordinary per-chunk segments and dropping them from future global-dict candidacy -- until back under budget. This bounds writer memory by the budget rather than by total file size, while still giving most columns their shared dictionary in the common case. Regression test (GlobalDictUtf8Test#retainedBytesBudgetExceeded_utf8_demotesToPerChunkChunkedLayout) reproduces the bug shape at small scale via a test-only budget seam (setDictRetainedBudgetForTest): a column with genuinely low, constant cardinality (so the existing cardinality-ratio fallback never fires) whose raw bytes alone cross a lowered budget, forcing mid-file demotion. Asserts the demoted column lands as a plain per-chunk Chunked layout (not Dict) and that every value still round-trips correctly across the demotion boundary. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 7e711c6 commit fb4b6be

3 files changed

Lines changed: 175 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12+
- `VortexWriter` no longer buffers a global-dictionary candidate column's raw data for the whole file, so importing a huge Parquet file (e.g. an 18.5M-row dataset) no longer exhausts the heap; a column whose retained bytes exceed a fixed budget is demoted to per-chunk encoding. ([a3b921b5](https://github.com/dfa1/vortex-java/commit/a3b921b5))
1213
- A chunked `List` column spanning several flat chunks now decodes into a single stitched array instead of throwing a raw `ClassCastException`; any other unhandled dtype now fails with `VortexException`. ([#268](https://github.com/dfa1/vortex-java/issues/268))
1314
- A chunked `Utf8`/`Binary` column with an entirely-null chunk (`NullArray`) no longer throws `chunk is not a VarBinArray`; the chunk materializes as an all-null run. ([#269](https://github.com/dfa1/vortex-java/issues/269))
1415
- A chunked `List` column with an entirely-null chunk (`NullArray`) no longer throws `chunk is not a ListArray`; the chunk's rows become zero-length lists with out-of-band nulls. ([#269](https://github.com/dfa1/vortex-java/issues/269))

writer/src/main/java/io/github/dfa1/vortex/writer/VortexWriter.java

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,18 @@ public final class VortexWriter implements Closeable {
9999
// Kept low: global dict hurts high-cardinality F64 columns (ALP codes beat U16 dict codes).
100100
static final int GLOBAL_DICT_MAX_CARDINALITY = 2_048;
101101

102+
// Aggregate memory budget (bytes) for the raw data all columns may retain while buffering for a
103+
// shared global dictionary. A global dict must see every chunk before it can be built, so a
104+
// dict-candidate column's raw arrays are held from the first chunk until close(). On a huge,
105+
// wide file (e.g. the 18.5M-row / 38-string-column NYC-311 Parquet import) any column
106+
// mis-detected as low-cardinality on its first chunk — one whose distinct count grows only after
107+
// millions of later rows — would otherwise pin its entire column in the heap; with dozens of such
108+
// columns the total is several GB and the import OOMs. This budget bounds the SUM across all
109+
// buffering columns: once the total is exceeded, the largest-retained columns are demoted (their
110+
// buffered chunks flushed per-chunk, per-chunk encoding thereafter) until back under budget,
111+
// keeping writer memory bounded by the budget rather than by total file size × column count.
112+
static final long GLOBAL_DICT_MAX_RETAINED_BYTES = 256L * 1024 * 1024;
113+
102114
private static final List<EncodingEncoder> DEFAULT_CODECS = List.of(
103115
new AlpEncodingEncoder(), new PrimitiveEncodingEncoder(), new BoolEncodingEncoder(),
104116
new DictEncodingEncoder(), new VarBinEncodingEncoder(), new ExtEncodingEncoder(),
@@ -124,6 +136,14 @@ public final class VortexWriter implements Closeable {
124136
private final Set<ColumnName> dictCandidates = new LinkedHashSet<>();
125137
private final Map<ColumnName, List<Object>> dictBuffers = new LinkedHashMap<>();
126138
private final Map<ColumnName, DictColRef> dictColRefs = new LinkedHashMap<>();
139+
// Raw bytes retained per global-dict-candidate column, and their running sum; together they
140+
// guard the aggregate memory budget (dictRetainedBudget) so no set of mis-detected columns can
141+
// pin the heap. When the sum crosses the budget, the largest columns are demoted until under it.
142+
private final Map<ColumnName, Long> dictRetainedBytes = new LinkedHashMap<>();
143+
private long dictRetainedTotal = 0;
144+
// Effective aggregate global-dict retention budget; the constant by default, lowered by tests
145+
// to exercise the demotion path without allocating the full budget.
146+
private long dictRetainedBudget = GLOBAL_DICT_MAX_RETAINED_BYTES;
127147
private boolean firstChunkSeen = false;
128148

129149
// Per-column zone-maps, populated by flushZoneMaps() in close() when enableZoneMaps is set.
@@ -162,6 +182,12 @@ private VortexWriter(
162182
}
163183
}
164184

185+
// Test seam: lower the aggregate global-dict retention budget so the demotion path (see
186+
// writeChunk) can be exercised without allocating GLOBAL_DICT_MAX_RETAINED_BYTES of column data.
187+
void setDictRetainedBudgetForTest(long budgetBytes) {
188+
this.dictRetainedBudget = budgetBytes;
189+
}
190+
165191
/// Builds a [WriteRegistry] from the given encoder list plus all built-in extension encoders.
166192
private static WriteRegistry buildRegistry(List<EncodingEncoder> encoders) {
167193
WriteRegistry.Builder b = WriteRegistry.builder();
@@ -496,7 +522,18 @@ public void writeChunk(Map<ColumnName, Object> columns) throws IOException {
496522
}
497523

498524
if (dictCandidates.contains(colName)) {
499-
dictBuffers.computeIfAbsent(colName, _ -> new ArrayList<>()).add(data);
525+
List<Object> buffered = dictBuffers.computeIfAbsent(colName, _ -> new ArrayList<>());
526+
buffered.add(data);
527+
long delta = estimateRetainedBytes(data);
528+
dictRetainedBytes.merge(colName, delta, Long::sum);
529+
dictRetainedTotal += delta;
530+
if (dictRetainedTotal > dictRetainedBudget) {
531+
// Aggregate budget exceeded — the buffered columns together no longer fit the
532+
// memory budget. Demote the largest-retained columns (flush their buffered chunks
533+
// per-chunk, encode per-chunk thereafter) until back under budget, so writer
534+
// memory stays bounded regardless of file size or column count.
535+
evictLargestDictColumnsUntilUnderBudget();
536+
}
500537
} else {
501538
long rowCount = arrayLength(data);
502539
int segIdx = writeSegment(colDtype, data);
@@ -1164,6 +1201,82 @@ private static byte[] buildDictLayoutMetaBytes(PType codePType) {
11641201

11651202
// ── Global dict helpers ───────────────────────────────────────────────────
11661203

1204+
/// Estimates the heap footprint of one chunk's worth of a column's raw data, used to bound the
1205+
/// per-column global-dict retention budget. Primitive arrays cost their element bytes; string
1206+
/// arrays cost each present string's UTF-16 char bytes plus a fixed per-element object-header
1207+
/// allowance (reference + `String`/`char[]` overhead), which dominates on wide, sparse columns.
1208+
///
1209+
/// @param data the chunk data (primitive array, `String[]`, or a [NullableData] wrapper)
1210+
/// @return an approximate retained-byte count; never negative
1211+
private static long estimateRetainedBytes(Object data) {
1212+
Object values = data instanceof NullableData nd ? nd.values() : data;
1213+
long overhead = data instanceof NullableData nd ? (long) nd.validity().length : 0L;
1214+
return overhead + switch (values) {
1215+
case byte[] a -> (long) a.length;
1216+
case short[] a -> 2L * a.length;
1217+
case int[] a -> 4L * a.length;
1218+
case long[] a -> 8L * a.length;
1219+
case float[] a -> 4L * a.length;
1220+
case double[] a -> 8L * a.length;
1221+
case boolean[] a -> (long) a.length;
1222+
case String[] a -> {
1223+
long total = 0L;
1224+
for (String s : a) {
1225+
// 48 bytes ~ String + char[] object headers plus the array reference slot.
1226+
total += 48L + (s == null ? 0L : 2L * s.length());
1227+
}
1228+
yield total;
1229+
}
1230+
default -> 0L;
1231+
};
1232+
}
1233+
1234+
/// Demotes the largest-retained global-dict candidate columns to per-chunk encoding, one at a
1235+
/// time (largest first), until the aggregate retained bytes fall back under the budget. Demoting
1236+
/// the largest column frees the most memory per eviction, so the fewest columns lose their shared
1237+
/// dictionary. Called when a chunk pushes the running total over `dictRetainedBudget`.
1238+
///
1239+
/// @throws IOException if writing a flushed segment fails
1240+
private void evictLargestDictColumnsUntilUnderBudget() throws IOException {
1241+
while (dictRetainedTotal > dictRetainedBudget && !dictRetainedBytes.isEmpty()) {
1242+
ColumnName largest = null;
1243+
long largestBytes = -1L;
1244+
for (Map.Entry<ColumnName, Long> e : dictRetainedBytes.entrySet()) {
1245+
if (e.getValue() > largestBytes) {
1246+
largestBytes = e.getValue();
1247+
largest = e.getKey();
1248+
}
1249+
}
1250+
demoteDictColumn(largest);
1251+
}
1252+
}
1253+
1254+
/// Abandons the shared global dictionary for one column whose retention pushed the aggregate over
1255+
/// the memory budget: flushes its already-buffered chunks as ordinary per-chunk segments (so no
1256+
/// data is lost) and removes it from the candidate set, so subsequent chunks encode per-chunk
1257+
/// too. The buffered chunks are released for GC and the running retained total is decremented.
1258+
///
1259+
/// @param colName the column being demoted from global-dict to per-chunk encoding
1260+
/// @throws IOException if writing a flushed segment fails
1261+
private void demoteDictColumn(ColumnName colName) throws IOException {
1262+
List<Object> buffered = dictBuffers.remove(colName);
1263+
dictCandidates.remove(colName);
1264+
Long freed = dictRetainedBytes.remove(colName);
1265+
if (freed != null) {
1266+
dictRetainedTotal -= freed;
1267+
}
1268+
if (buffered == null) {
1269+
return;
1270+
}
1271+
DType colDtype = schema.fieldTypes().get(schema.fieldNames().indexOf(colName));
1272+
for (Object chunk : buffered) {
1273+
long rowCount = arrayLength(chunk);
1274+
int segIdx = writeSegment(colDtype, chunk);
1275+
colChunks.get(colName).add(
1276+
new ChunkRef(segIdx, rowCount, lastStatsMin, lastStatsMax, lastStatsSum, lastNullCount));
1277+
}
1278+
}
1279+
11671280
private void flushDictColumns() throws IOException {
11681281
for (ColumnName colName : dictCandidates) {
11691282
List<Object> chunks = dictBuffers.getOrDefault(colName, List.of());

writer/src/test/java/io/github/dfa1/vortex/writer/GlobalDictUtf8Test.java

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,66 @@ void utf8_globalDict_disabled_byOptions(@TempDir Path tmp) throws IOException {
216216
}
217217
}
218218

219+
@Test
220+
void retainedBytesBudgetExceeded_utf8_demotesToPerChunkChunkedLayout(@TempDir Path tmp) throws IOException {
221+
// Given — a column that looks low-cardinality on its FIRST chunk (3 distinct values, well
222+
// under the 50% ratio + 2048 cardinality gates) so it is admitted to the global dict and its
223+
// raw data starts being buffered. This is exactly the nyc-311 OOM shape: a "category-ish"
224+
// string column whose distinct set is small early but whose raw bytes accumulate across
225+
// millions of rows because a global dict must hold every chunk until close(). Rather than
226+
// pin the heap, the writer demotes the column once the aggregate retained bytes cross the
227+
// budget. We lower the budget via the test seam so this triggers on a handful of small
228+
// chunks instead of allocating the full budget. Cardinality never grows, so the OLD
229+
// cardinality-fallback would never fire — only the new memory-budget demotion catches this,
230+
// which is the bug under test.
231+
Path file = tmp.resolve("retained_budget_utf8.vortex");
232+
String[] dict = {"open", "closed", "delivered"};
233+
int rowsPerChunk = 2_000;
234+
int chunkCount = 6;
235+
String[] expected = new String[rowsPerChunk * chunkCount];
236+
237+
try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
238+
var sut = VortexWriter.create(ch, SCHEMA, WriteOptions.cascading(3))) {
239+
// Budget of ~120 KB is crossed after ~2 chunks of 2000 rows (each row ~48 B object
240+
// overhead + a short string), forcing demotion partway through the file.
241+
sut.setDictRetainedBudgetForTest(120_000L);
242+
// When
243+
for (int c = 0; c < chunkCount; c++) {
244+
String[] data = new String[rowsPerChunk];
245+
for (int i = 0; i < rowsPerChunk; i++) {
246+
String value = dict[(c + i) % dict.length];
247+
data[i] = value;
248+
expected[c * rowsPerChunk + i] = value;
249+
}
250+
sut.writeChunk(Map.of(ColumnName.of("status"), data));
251+
}
252+
}
253+
254+
// Then — the demoted column is a plain Chunked-of-Flats layout with one Flat per chunk (the
255+
// buffered-then-flushed chunks plus the per-chunk-encoded remainder), NOT a single Dict
256+
// layout. A Dict here would mean the whole column was still buffered in memory — the OOM.
257+
try (var vf = VortexReader.open(file, ReadRegistry.loadAll())) {
258+
var columnLayout = unwrapZoned(vf.layout().children().getFirst());
259+
assertThat(columnLayout.isDict()).as("demoted column must not be a global dict").isFalse();
260+
assertThat(columnLayout.isChunked()).as("demoted column is a chunked layout").isTrue();
261+
assertThat(columnLayout.children())
262+
.as("one Flat per written chunk after demotion")
263+
.hasSize(chunkCount)
264+
.allSatisfy(child -> assertThat(child.isFlat()).isTrue());
265+
266+
// And every value round-trips exactly across all chunks despite the mid-file demotion.
267+
List<String> got = readAllStrings(vf, "status");
268+
assertThat(got).containsExactly(expected);
269+
}
270+
}
271+
272+
/// Unwraps a column's [io.github.dfa1.vortex.reader.layout.Layout] Zoned/Stats wrapper (the
273+
/// writer wraps every column in one for zone-map pruning) to reach the encoding layout beneath.
274+
private static io.github.dfa1.vortex.reader.layout.Layout unwrapZoned(
275+
io.github.dfa1.vortex.reader.layout.Layout layout) {
276+
return layout.isZoned() ? layout.children().getFirst() : layout;
277+
}
278+
219279
/// Reads a nullable Utf8 column, mapping invalid rows to `null` so null positions are asserted
220280
/// alongside values. A nullable dict column decodes to a [MaskedArray] over the Utf8 payload.
221281
private static List<String> readNullableStrings(VortexReader vf, String col) {

0 commit comments

Comments
 (0)