Skip to content

feat(ipc): Write dictionary deltas from ArrowIpcWriterWriteArrayViewWithDictionaries - #980

Draft
rustyconover wants to merge 12 commits into
apache:mainfrom
rustyconover:feat/ipc-writer-dictionary-deltas
Draft

rustyconover wants to merge 12 commits into
apache:mainfrom
rustyconover:feat/ipc-writer-dictionary-deltas

Conversation

@rustyconover

Copy link
Copy Markdown
Contributor

Stacked on #979 (and through it on #968 and #928). Until those merge, the diff here includes their commits. Only the last commit, "Write dictionary deltas from ArrowIpcWriterWriteArrayViewWithDictionaries", is new. It's a draft until they land.

With #979, ArrowIpcWriterWriteArrayViewWithDictionaries() skips a dictionary that is unchanged since the last batch, but any change still replaces the whole dictionary. A common change is a dictionary that only grows: a producer encodes each batch against a dictionary that it appends new values to. Arrow streams can send just the appended values as a delta (DictionaryBatch.isDelta), which nanoarrow's reader supports since #955.

When a batch's dictionary is longer than the one last written for its ID and starts with the same values, the writer now writes only the appended values, as a delta. To check this, it copies the first n values, where n is the length of the saved copy, and compares that with the saved copy. On a match, it copies the appended values and writes them as a delta, then appends them to the prefix copy, which becomes the new saved copy. Anything else is a replacement, as before.

Deltas are only written to streams. In a file, a dictionary that grew is a replacement and returns ENOTSUP, matching #928, whose writer doesn't write deltas to files. A dictionary that is written again because a nested dictionary changed is written in full.

Cost: the same benchmark as #979, 1000 batches of 2048 rows, dictionary<int32, string>, written to a memory buffer (Release, Linux aarch64; best of 3):

#968 #979 this PR
10,000 values, growing by 16 a batch 205 ms, 458.3 MB 531 ms, 458.3 MB 346 ms, 9.2 MB
10,000 values, unchanged 126 ms, 258.5 MB 23 ms, 8.6 MB 23 ms, 8.6 MB

Tests (writer_test.cc):

  • WriteArrayViewWithDictionariesDeltas: dictionaries [foo, bar], [foo, bar, baz], [foo, bar, baz], [foo, qux] write a full dictionary, a delta, nothing, and a replacement (isDelta 0, 1, 0). Read back with nanoarrow and Arrow C++.
  • WriteArrayViewWithDictionariesNullableDelta: int64 values [1, null, 3] growing to [1, null, 3, null, 5], where the delta starts partway through a byte of the validity bitmap.
  • WriteArrayViewWithDictionariesNoDeltaInFile: in a file, a dictionary that grew returns ENOTSUP.

ctest passes (Linux aarch64, Debug, 881 tests with NANOARROW_BUILD_TESTS_WITH_ARROW), and 406 tests under ASan/UBSan with clang. The writer tests are clean under valgrind. The changed files are formatted with clang-format 16.0.6, the pre-commit version.

This PR's code and description were written by AI (Claude Code) and reviewed by a human maintainer.

🤖 Generated with Claude Code

rustyconover and others added 9 commits October 4, 2026 11:42
ArrowIpcEncoderSetMessageMetadata() was ignored by
ArrowIpcEncoderEncodeSimpleDictionaryBatch(); encode it like the schema and
record batch messages do.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The IPC Field for a dictionary-encoded column describes the dictionary value
type, so its children must come from schema->dictionary rather than from the
index schema. This replaces the ENOTSUP for dictionary values with children
(e.g., struct dictionaries whose children are themselves dictionary-encoded).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Add ArrowIpcEncoderSetDictionaryReplacement() to declare the
DICTIONARY_REPLACEMENT feature in an encoded Schema message. The writer
enables it in stream mode, where a dictionary ID may be written more than
once, and the reader no longer rejects streams that declare it (the decoder
already replaces dictionaries).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On main, a file could contain exactly one DictionaryBatch, so a schema
with more than one dictionary-encoded field couldn't be written, and the
footer schema couldn't resolve its dictionary IDs when finalizing the
file. Record the dictionary encodings of the footer schema and track the
dictionary IDs written to the file: a second batch for the same ID
returns EINVAL because IPC files don't support dictionary replacement
(deltas remain unsupported when writing files).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When dictionary values contain dictionary-encoded children, a reader needs
the nested dictionaries before it can decode the DictionaryBatch that
references them. Keep assigning IDs in schema order but write each
dictionary after the dictionaries nested in its values.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ArrowIpcWriterWriteArrayStream() wrote every dictionary before every
record batch. Skip a dictionary when it has the same length, offset,
null count, and buffer addresses (recursively) as the corresponding
dictionary of the previous array, which is the case when a producer
shares one dictionary among the arrays of a stream (e.g., the nanoarrow
IPC reader).

Comparing addresses is only safe while both arrays are alive, so the
writer keeps the previous array (if it has dictionaries) until the next
one is written. ArrowIpcWriterWriteDictionaryBatch() only sees a
borrowed view that it can't keep alive and always writes the dictionary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Write the integration files back using ArrowIpcWriterWriteArrayStream(),
which writes dictionaries (writing the arrays with
ArrowIpcWriterWriteArrayView() alone does not), and enable the write
roundtrip for the dictionary, nested dictionary, shared dictionary, and
extension (whose dict-extension column is dictionary-encoded) files.
Because the reader shares dictionaries among the batches it decodes,
these roundtrips also exercise skipping unchanged dictionaries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… a repeated file dictionary ID

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@codecov-commenter

codecov-commenter commented Oct 19, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.51938% with 58 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.54%. Comparing base (b9e0f5f) to head (09d8978).

Files with missing lines Patch % Lines
src/nanoarrow/ipc/writer.c 77.64% 11 Missing and 44 partials ⚠️
src/nanoarrow/ipc/encoder.c 75.00% 0 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #980      +/-   ##
==========================================
+ Coverage   78.44%   78.54%   +0.09%     
==========================================
  Files         106      106              
  Lines       17050    17288     +238     
  Branches     2018     2097      +79     
==========================================
+ Hits        13375    13578     +203     
+ Misses       2432     2430       -2     
- Partials     1243     1280      +37     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

rustyconover and others added 3 commits October 19, 2026 23:38
…message-metadata

# Conflicts:
#	src/nanoarrow/ipc/writer.c
…wWithDictionaries

ArrowIpcWriterWriteArrayViewWithDictionaries() wrote every dictionary before
every RecordBatch: its input is borrowed, so it couldn't compare a dictionary
with the previous batch's by address as ArrowIpcWriterWriteArrayStream() does.
The writer now keeps a copy of each dictionary it writes and compares the next
batch's values with it, writing only dictionaries that changed (and those whose
values contain a nested dictionary that was written). In a file, batches whose
dictionaries are unchanged can now be written this way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ithDictionaries

When a batch's dictionary starts with the values last written for its ID, the
writer now writes only the appended values, as a delta DictionaryBatch, instead
of replacing the whole dictionary. This only applies to streams: a file does not
support deltas.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@rustyconover
rustyconover force-pushed the feat/ipc-writer-dictionary-deltas branch from 2569825 to 09d8978 Compare October 20, 2026 03:48

This branch has not been deployed

No deployments
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.

2 participants