Skip to content

perf(spark): append rows straight into the Arrow batch (FileFormat connector) - #45

Merged
gitRasheed merged 6 commits into
rasheed/spark-fileformatfrom
rasheed/ff-direct-arrow-write
Oct 30, 2026
Merged

gitRasheed merged 6 commits into
rasheed/spark-fileformatfrom
rasheed/ff-direct-arrow-write

Conversation

@gitRasheed

Copy link
Copy Markdown
Collaborator

Summary

The FileFormat writer copied every row into a list and converted the list to Arrow per value when a batch filled, re-exported the Arrow schema for every batch without releasing it, and could strand other partition writers when one write failed. Now each row is appended straight into the Arrow vectors, the schema is exported once per writer, and a failed writer lets Spark close every other writer.

Changes

  • ArrowFieldWriter: one typed writer per field, with struct and map children resolved once; strings are copied once from the row into the Arrow buffer, decimals of up to 18 digits are written from the unscaled long.
  • VortexOutputWriter appends each row into vectors that start at min(batch, 64) slots and grow. The schema is exported once and released on close; a batch native never took is released on the Java side.
  • After a write fails, later writes throw and close() releases everything without throwing, so Spark's concurrent partition writer, which stops at the first close that throws, still closes the other writers. The truncated file can't be committed: Spark commits only after every write returned normally.
  • Arrow memory left at close is logged and the allocator left open, so a late native release is still freed (Arrow marks an allocator closed even when its close throws).
  • Tests: the Spark 4.1 and 4.2 compat suites now compile the Scala test sources, and test JVMs open java.io as Spark's launcher does (Spark 4.2's serialization debugger needs it).

Testing

  • A/B on 4M rows, 6 columns, 18 runs each, with nothing else building: median 15.2 s against 15.3 s with 4 tasks, 21.8 s against 21.9 s with 1 task. No difference.
  • Memory: 2,000 one-row batches hold 1.4 MB of Arrow memory instead of 253 MB; 10,000 batches run where the old writer hit its direct-memory limit.
  • Tests that fail without the change: VortexOutputWriterMemoryTest (2,980 B left after close), the row-reuse test (binary values from a reused row read back wrong), VortexOutputWriterAbortTest (28 healthy partition writers stranded, 36,344 B), and the batch-size-1 nested-nulls round trip (out of direct memory).
  • Full check passes on Spark 3.5/2.12, 3.5/2.13, 4.0, and the 4.1 and 4.2 compat runs.
  • Reviewed by Astra (one round; its finding is fixed here) and Sol (two rounds on the allocator change).

Known: the native writer still holds imported batches until it has 8,192 rows and buffers per column, so memory grows with rows per file for small batch sizes.

Checked locally.

I used AI tools to help write and review this change.

VortexOutputWriter copied every row into a list and converted the list
to Arrow per value when the batch was full. Each row is now appended
straight into the Arrow vectors through one typed field writer per
column, so strings are copied once and decimals of up to 18 digits are
written from the unscaled long.

Vectors start at 64 slots and grow, so 1-row batches no longer allocate
Arrow's default capacity. The schema is exported once per writer and
released on close; each batch used to export it again and never release
it. A write that fails part-way through a row marks the writer failed:
later writes throw, and close releases everything and throws instead of
finishing the misaligned batch. Memory still allocated when the
allocator closes now fails close instead of being logged at debug.

Signed-off-by: gitRasheed <[email protected]>
…ailing

The previous commit made close fail when the writer's allocator still
held memory. Native code can release a batch after close has finished
the file, and failing then would fail a task whose output is complete.
Close now logs a warning and leaves the allocator open, so a late
release is still freed: Arrow marks an allocator closed even when its
close throws, and with assertions on, releasing into it throws and
keeps the memory. Tests still assert that nothing is left, through the
allocator() accessor.

Signed-off-by: gitRasheed <[email protected]>
Each batch used to export the schema again and never release it. The
writer now exports it once and releases it on close, so the test passes;
it reads the allocator through the package-private accessor instead of
reflection.

Signed-off-by: gitRasheed <[email protected]>
The native writer holds one-row batches until it has enough bytes to
write; with 10,000 rows that was every batch, about 1.2M Arrow buffer
ledgers, and the 512 MB test JVM ran out of heap. 2,000 rows still
cover every null pattern, and a batch size of 300 still crosses batch
boundaries and grows the vectors past their first 64 slots.

Signed-off-by: gitRasheed <[email protected]>
Spark's concurrent partition writer closes a task's writers one after
another when the task aborts and stops at the first close that throws.
close() rethrew an earlier write failure, so the writers after the
failed one were never closed and kept their Arrow memory, streams and
native writers.

After a write failure, close() now releases everything and returns,
logging any cleanup failure. The truncated file still can't be
committed: Spark commits only after every write returned, and write()
refuses every row once one has failed.

Signed-off-by: gitRasheed <[email protected]>
The compat suites compiled only test/java, so the Scala format the abort
test writes through was missing and the write failed to find its data
source before opening any writer.

With the format present, Spark 4.2 hung instead: Arrow's out-of-memory
exception doesn't serialize, and the SerializationDebugger Spark falls
back to needs java.io opened, which Spark's launcher does but the test
JVM didn't. Its failed initialization killed the executor thread before
it reported the task failure.

The test now reports the exception it caught when no writer opened.

Signed-off-by: gitRasheed <[email protected]>
@gitRasheed
gitRasheed merged commit 1d06f48 into rasheed/spark-fileformat Oct 30, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant