Skip to content

Commit abb4830

Browse files
feat(spark): bound Arrow write batches by bytes (#62)
## Summary Closes #58. Adds optional `vortex.write.batch.bytes`: after each complete row, the writer flushes when the open batch's Arrow allocations reach this positive byte threshold or the existing row limit. Unset preserves row-only batching. A single row or buffer growth can exceed the threshold; it is not a bound on total process memory. ## Changes - Count the open batch's unique backing allocations, including nested buffers and allocation rounding. Previous batches retained or released by native code do not affect the measurement; subtracting an allocator baseline would undercount when older batches are released. - Cache the measurement until Arrow allocates again, avoiding a wide-schema traversal after every row. Install tracking only when the byte option is set. - Reset capacity estimates after a byte-triggered flush so one oversized row does not force every later small row into its own oversized batch. - Validate positive long byte values on the driver, with case-insensitive lookup and clear errors for malformed, zero, negative and overflowing values. Document the option and its limits. - Add regressions for row/byte limits, oversized and nested rows, subsequent small-row batching, shared allocations, old-batch releases, and string/binary preservation during measurement. ## Complete review Completed four independent reviews for reuse, quality, efficiency and correctness. Revalidated and applied the worthwhile findings: use non-mutating Arrow buffer accessors during unfinished batches, reset oversized capacity estimates, and request ordered scans in exact-order round-trip tests. No unresolved review findings remain. ## Validation - Targeted tests: **52 passed on Spark 3.5 / Scala 2.12 and 52 passed on Spark 4.0 / Scala 2.13**, including the native writer memory stress tests. - Java compilation with Error Prone and `-Werror` passed. - `./gradlew spotlessJavaCheck` and `:vortex-spark-4.0_2.13:javadoc` passed. - `git diff --check` passed. - The full Spark variant matrix and workspace suite were not run. I used AI tools to help implement and review this change. Signed-off-by: Christopher Wirt <[email protected]>
1 parent cb719c5 commit abb4830

9 files changed

Lines changed: 342 additions & 9 deletions

File tree

‎docs/user-guide/spark.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,13 @@ derive the column and partition by it, for example `withColumn("year", year(col(
137137
|---------------------------|---------|-------------|
138138
| `batch.size` | `8192` | Rows buffered per write batch; range 1–65536. Each string or binary field of a batch must fit in 2 GiB, so lower it for values over about 256 KB. |
139139
| `vortex.write.batch.size` | — | Rows buffered per write batch for Vortex alone, overriding `batch.size`. |
140+
| `vortex.write.batch.bytes` | — | Positive byte threshold for the open batch's Arrow allocations; flush after a row reaches it or the row limit, whichever comes first. Unset disables byte-based flushing. |
140141
| `vortex.listLayout` | `false` | Store array columns in the unstable list layout; see below. |
141142

143+
The byte threshold includes nested buffers and Arrow's allocation rounding. It is checked after
144+
each complete row, so one row or a buffer growth can exceed it. It bounds batching rather than
145+
total process memory: native code may retain earlier batches and allocate compression buffers.
146+
142147
By default an array column is stored as whole chunks, so reading one field of the structs in an
143148
array reads every field of them. With `vortex.listLayout` set to `true`, the writer stores each
144149
array's offsets, nulls, and elements apart, and splits struct elements into one column per field,

‎java/vortex-spark/common/src/main/java/dev/vortex/spark/VortexOptions.java‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,24 @@ public int getInt(String key, int fallback) {
139139
}
140140
}
141141

142+
/**
143+
* The value set for {@code key} as a long, or {@code fallback} when it is not set.
144+
*
145+
* @throws IllegalArgumentException if the value is not a long
146+
*/
147+
public long getLong(String key, long fallback) {
148+
Optional<String> value = get(key);
149+
if (value.isEmpty()) {
150+
return fallback;
151+
}
152+
try {
153+
return Long.parseLong(value.get().trim());
154+
} catch (NumberFormatException e) {
155+
throw new IllegalArgumentException(
156+
String.format(Locale.ROOT, "%s must be a long, got '%s'", key, value.get()), e);
157+
}
158+
}
159+
142160
/**
143161
* The value set for {@code key} as a boolean, or {@code fallback} when it is not set.
144162
*
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
package dev.vortex.spark.write;
5+
6+
import dev.vortex.relocated.org.apache.arrow.memory.AllocationListener;
7+
import dev.vortex.relocated.org.apache.arrow.memory.ArrowBuf;
8+
import dev.vortex.relocated.org.apache.arrow.memory.ReferenceManager;
9+
import dev.vortex.relocated.org.apache.arrow.vector.BaseFixedWidthVector;
10+
import dev.vortex.relocated.org.apache.arrow.vector.BaseVariableWidthVector;
11+
import dev.vortex.relocated.org.apache.arrow.vector.FieldVector;
12+
import dev.vortex.relocated.org.apache.arrow.vector.VectorSchemaRoot;
13+
import dev.vortex.relocated.org.apache.arrow.vector.complex.ListVector;
14+
import dev.vortex.relocated.org.apache.arrow.vector.complex.StructVector;
15+
import java.util.IdentityHashMap;
16+
import java.util.concurrent.atomic.AtomicLong;
17+
18+
/** Measures the open batch independently of older batches retained or released by native code. */
19+
final class ArrowBatchMemory implements AllocationListener {
20+
private final AtomicLong allocations = new AtomicLong();
21+
private final IdentityHashMap<ReferenceManager, Boolean> seen = new IdentityHashMap<>();
22+
private long measuredAllocations = -1;
23+
private long bytes;
24+
25+
@Override
26+
public void onAllocation(long size) {
27+
allocations.incrementAndGet();
28+
}
29+
30+
long bytes(VectorSchemaRoot root) {
31+
long currentAllocations = allocations.get();
32+
// Between buffer growths the batch owns the same allocations; do not walk a wide schema for every row.
33+
if (currentAllocations != measuredAllocations) {
34+
bytes = 0;
35+
try {
36+
for (FieldVector vector : root.getFieldVectors()) {
37+
bytes += bytes(vector);
38+
}
39+
} finally {
40+
seen.clear();
41+
}
42+
measuredAllocations = currentAllocations;
43+
}
44+
return bytes;
45+
}
46+
47+
void reset() {
48+
measuredAllocations = -1;
49+
bytes = 0;
50+
}
51+
52+
private long bytes(FieldVector vector) {
53+
// getFieldBuffers() finalizes variable-width offsets using valueCount, which is still zero mid-batch.
54+
long total = bytes(vector.getValidityBuffer());
55+
if (vector instanceof BaseFixedWidthVector) {
56+
total += bytes(vector.getDataBuffer());
57+
} else if (vector instanceof BaseVariableWidthVector) {
58+
total += bytes(vector.getDataBuffer());
59+
total += bytes(vector.getOffsetBuffer());
60+
} else if (vector instanceof ListVector) {
61+
total += bytes(vector.getOffsetBuffer());
62+
} else if (!(vector instanceof StructVector)) {
63+
throw new IllegalArgumentException(
64+
"Unsupported writer vector: " + vector.getClass().getName());
65+
}
66+
for (FieldVector child : vector.getChildrenFromFields()) {
67+
total += bytes(child);
68+
}
69+
return total;
70+
}
71+
72+
private long bytes(ArrowBuf buffer) {
73+
ReferenceManager manager = buffer.getReferenceManager();
74+
// Fixed-width vectors can slice validity and data out of one allocation.
75+
return seen.put(manager, Boolean.TRUE) == null ? manager.getSize() : 0;
76+
}
77+
}

‎java/vortex-spark/common/src/main/java/dev/vortex/spark/write/VortexOutputWriter.java‎

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ public final class VortexOutputWriter extends OutputWriter {
4040
/** Option sizing the write batch for Vortex alone, overriding {@value #BATCH_SIZE_OPTION}. */
4141
public static final String WRITE_BATCH_SIZE_OPTION = "vortex.write.batch.size";
4242

43+
/** Optional positive byte threshold for the open batch's Arrow allocations, checked after each row. */
44+
public static final String WRITE_BATCH_BYTES_OPTION = "vortex.write.batch.bytes";
45+
4346
/**
4447
* Option storing list columns in the unstable list layout, so a read of some fields of array elements skips the
4548
* others. Off by default; future versions may not read files written with it.
@@ -54,6 +57,8 @@ public final class VortexOutputWriter extends OutputWriter {
5457
private final String filePath;
5558
private final VortexOptions options;
5659
private final int batchSize;
60+
private final long batchBytes;
61+
private final ArrowBatchMemory batchMemory;
5762
private NativeWritable writable;
5863

5964
private Session session;
@@ -82,19 +87,16 @@ public VortexOutputWriter(String filePath, StructType schema, VortexOptions opti
8287
this.writable = writable;
8388

8489
this.batchSize = configuredBatchSize(options);
90+
this.batchBytes = configuredBatchBytes(options);
91+
this.batchMemory = batchBytes == 0 ? null : new ArrowBatchMemory();
8592

8693
try {
87-
this.allocator = new RootAllocator();
94+
this.allocator = batchMemory == null ? new RootAllocator() : new RootAllocator(batchMemory, Long.MAX_VALUE);
8895
var arrowSchema = SparkToArrowSchema.convert(schema);
8996

9097
this.vectorSchemaRoot = VectorSchemaRoot.create(arrowSchema, allocator);
9198
// Small start so writers that see few rows hold little memory; Arrow grows on demand.
92-
// It also keeps the largest capacity a batch needed, so one batch of huge values makes
93-
// later batches allocate as much.
94-
int initialCapacity = Math.min(batchSize, INITIAL_VECTOR_CAPACITY);
95-
for (FieldVector vector : vectorSchemaRoot.getFieldVectors()) {
96-
vector.setInitialCapacity(initialCapacity);
97-
}
99+
resetInitialCapacity();
98100
StructField[] fields = schema.fields();
99101
this.fieldWriters = new ArrowFieldWriter[fields.length];
100102
for (int i = 0; i < fields.length; i++) {
@@ -144,6 +146,19 @@ static int configuredBatchSize(VortexOptions options) {
144146
return batchSize;
145147
}
146148

149+
/** Returns the positive batch byte threshold, or zero when unset. */
150+
static long configuredBatchBytes(VortexOptions options) {
151+
if (options.get(WRITE_BATCH_BYTES_OPTION).isEmpty()) {
152+
return 0;
153+
}
154+
long bytes = options.getLong(WRITE_BATCH_BYTES_OPTION, 0);
155+
if (bytes <= 0) {
156+
throw new IllegalArgumentException(
157+
WRITE_BATCH_BYTES_OPTION + " must be a positive number of bytes, got " + bytes);
158+
}
159+
return bytes;
160+
}
161+
147162
/**
148163
* Writes a single row to the Vortex file.
149164
*
@@ -171,8 +186,13 @@ public void write(InternalRow row) {
171186
fieldWriters[i].write(row, i);
172187
}
173188
batchRowCount++;
174-
if (batchRowCount >= batchSize) {
189+
boolean reachedBytes = batchMemory != null && batchMemory.bytes(vectorSchemaRoot) >= batchBytes;
190+
if (batchRowCount >= batchSize || reachedBytes) {
175191
writeBatch();
192+
if (reachedBytes) {
193+
// An oversized row must not make every later small batch allocate its remembered capacity.
194+
resetInitialCapacity();
195+
}
176196
}
177197
} catch (IOException e) {
178198
writeFailure = e;
@@ -185,6 +205,13 @@ public void write(InternalRow row) {
185205
}
186206
}
187207

208+
private void resetInitialCapacity() {
209+
int initialCapacity = Math.min(batchSize, INITIAL_VECTOR_CAPACITY);
210+
for (FieldVector vector : vectorSchemaRoot.getFieldVectors()) {
211+
vector.setInitialCapacity(initialCapacity);
212+
}
213+
}
214+
188215
/** Hands the rows appended since the last batch to the Vortex writer. */
189216
private void writeBatch() throws IOException {
190217
if (batchRowCount == 0) {
@@ -202,6 +229,9 @@ private void writeBatch() throws IOException {
202229
writer.reset();
203230
}
204231
batchRowCount = 0;
232+
if (batchMemory != null) {
233+
batchMemory.reset();
234+
}
205235
}
206236

207237
private static long arrowBytes(List<FieldVector> vectors) {
@@ -296,6 +326,11 @@ BufferAllocator allocator() {
296326
return allocator;
297327
}
298328

329+
/** Package-private so tests can observe batch boundaries without depending on native scheduling. */
330+
int batchRowCount() {
331+
return batchRowCount;
332+
}
333+
299334
private IOException closeArrowResources(IOException failure) {
300335
if (vectorSchemaRoot != null) {
301336
try {

‎java/vortex-spark/common/src/main/java/dev/vortex/spark/write/VortexOutputWriterFactory.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ public VortexOutputWriterFactory(StructType schema, VortexOptions options) {
3232
*/
3333
public static void validate(VortexOptions options) {
3434
VortexOutputWriter.configuredBatchSize(options);
35+
VortexOutputWriter.configuredBatchBytes(options);
3536
VortexOutputWriter.listLayout(options);
3637
VortexSparkSession.validate(options);
3738
}

‎java/vortex-spark/common/src/test/java/dev/vortex/spark/VortexOptionsTest.java‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ void anUnsetOptionFallsBack() {
4242
assertEquals(Optional.empty(), options.get("vortex.absent"));
4343
assertEquals("fallback", options.get("vortex.absent", "fallback"));
4444
assertEquals(7, options.getInt("vortex.absent", 7));
45+
assertEquals(7, options.getLong("vortex.absent", 7));
4546
assertTrue(options.getBoolean("vortex.absent", true));
4647
}
4748

@@ -53,6 +54,19 @@ void booleansAreReadWithoutRegardToCase() {
5354
assertFalse(options.getBoolean("off", true));
5455
}
5556

57+
@Test
58+
void longOptionsAcceptValuesBeyondIntegerRangeAndTrimWhitespace() {
59+
assertEquals(
60+
3_000_000_000L,
61+
VortexOptions.of(Map.of("BYTES", " 3000000000 ")).getLong("bytes", 0));
62+
for (String value : new String[] {"many", "9223372036854775808"}) {
63+
VortexOptions options = VortexOptions.of(Map.of("bytes", value));
64+
assertTrue(assertThrows(IllegalArgumentException.class, () -> options.getLong("bytes", 0))
65+
.getMessage()
66+
.contains("bytes"));
67+
}
68+
}
69+
5670
@Test
5771
void aValueOfTheWrongShapeNamesItsOption() {
5872
VortexOptions options = VortexOptions.of(Map.of("vortex.count", "many", "vortex.flag", "yes"));

‎java/vortex-spark/common/src/test/java/dev/vortex/spark/VortexWriteOptionValidationTest.java‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ public void tearDown() {
5353
"batch.size, lots",
5454
"batch.size, 0",
5555
"vortex.write.batch.size, 65537",
56+
"vortex.write.batch.bytes, lots",
57+
"vortex.write.batch.bytes, 0",
58+
"vortex.write.batch.bytes, -1",
59+
"vortex.write.batch.bytes, 9223372036854775808",
5660
"vortex.listLayout, yes",
5761
"vortex.session.provider, dev.vortex.spark.NoSuchProvider",
5862
"vortex.session.provider, java.lang.String",
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
package dev.vortex.spark.write;
5+
6+
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
7+
import static org.junit.jupiter.api.Assertions.assertEquals;
8+
import static org.junit.jupiter.api.Assertions.assertTrue;
9+
10+
import dev.vortex.relocated.org.apache.arrow.memory.ArrowBuf;
11+
import dev.vortex.relocated.org.apache.arrow.memory.RootAllocator;
12+
import dev.vortex.relocated.org.apache.arrow.vector.BigIntVector;
13+
import dev.vortex.relocated.org.apache.arrow.vector.VarBinaryVector;
14+
import dev.vortex.relocated.org.apache.arrow.vector.VarCharVector;
15+
import dev.vortex.relocated.org.apache.arrow.vector.VectorSchemaRoot;
16+
import dev.vortex.relocated.org.apache.arrow.vector.complex.ListVector;
17+
import dev.vortex.relocated.org.apache.arrow.vector.complex.StructVector;
18+
import java.nio.charset.StandardCharsets;
19+
import java.util.Arrays;
20+
import org.apache.spark.sql.types.DataTypes;
21+
import org.apache.spark.sql.types.StructType;
22+
import org.junit.jupiter.api.Test;
23+
24+
final class ArrowBatchMemoryTest {
25+
@Test
26+
void measuringAnUnfinishedBatchPreservesStringsAndBinaryAcrossBufferGrowth() {
27+
ArrowBatchMemory memory = new ArrowBatchMemory();
28+
StructType schema = new StructType().add("s", DataTypes.StringType).add("b", DataTypes.BinaryType);
29+
try (RootAllocator allocator = new RootAllocator(memory, Long.MAX_VALUE);
30+
VectorSchemaRoot root = VectorSchemaRoot.create(SparkToArrowSchema.convert(schema), allocator)) {
31+
root.allocateNew();
32+
VarCharVector strings = (VarCharVector) root.getVector("s");
33+
VarBinaryVector binary = (VarBinaryVector) root.getVector("b");
34+
strings.setSafe(0, "first".getBytes(StandardCharsets.UTF_8));
35+
binary.setSafe(0, new byte[] {1, 2, 3});
36+
memory.bytes(root);
37+
38+
String largeString = "x".repeat(100_000);
39+
byte[] largeBinary = new byte[100_000];
40+
Arrays.fill(largeBinary, (byte) 7);
41+
strings.setSafe(1, largeString.getBytes(StandardCharsets.UTF_8));
42+
binary.setSafe(1, largeBinary);
43+
memory.bytes(root);
44+
root.setRowCount(2);
45+
46+
assertEquals("first", strings.getObject(0).toString());
47+
assertEquals(largeString, strings.getObject(1).toString());
48+
assertArrayEquals(new byte[] {1, 2, 3}, binary.get(0));
49+
assertArrayEquals(largeBinary, binary.get(1));
50+
}
51+
}
52+
53+
@Test
54+
void measuresNestedAllocationsOnceAndIgnoresOlderBatchReleases() {
55+
ArrowBatchMemory memory = new ArrowBatchMemory();
56+
StructType schema = new StructType()
57+
.add("id", DataTypes.LongType, false)
58+
.add("nested", new StructType().add("items", DataTypes.createArrayType(DataTypes.LongType, false)));
59+
try (RootAllocator allocator = new RootAllocator(memory, Long.MAX_VALUE);
60+
VectorSchemaRoot root = VectorSchemaRoot.create(SparkToArrowSchema.convert(schema), allocator)) {
61+
long batchBytes;
62+
try (ArrowBuf previousBatch = allocator.buffer(1_000_000)) {
63+
root.allocateNew();
64+
batchBytes = memory.bytes(root);
65+
assertEquals(
66+
allocator.getAllocatedMemory()
67+
- previousBatch.getReferenceManager().getSize(),
68+
batchBytes);
69+
assertTrue(batchBytes > 0);
70+
}
71+
// A baseline subtraction would shrink here, although the open batch has not changed.
72+
assertEquals(batchBytes, memory.bytes(root));
73+
assertEquals(allocator.getAllocatedMemory(), memory.bytes(root));
74+
75+
StructVector nested = (StructVector) root.getVector("nested");
76+
ListVector items = (ListVector) nested.getChild("items");
77+
((BigIntVector) items.getDataVector()).setSafe(100_000, 1L);
78+
assertTrue(memory.bytes(root) > batchBytes);
79+
assertEquals(allocator.getAllocatedMemory(), memory.bytes(root));
80+
81+
root.clear();
82+
memory.reset();
83+
assertEquals(0, memory.bytes(root));
84+
root.allocateNew();
85+
assertEquals(allocator.getAllocatedMemory(), memory.bytes(root));
86+
}
87+
}
88+
}

0 commit comments

Comments
 (0)