Skip to content

perf(arrow): stream serialize-to-backend writes via BufferWritableBackend (LAB-766) - #247

Merged
27Bslash6 merged 4 commits into
mainfrom
lab-766-streaming-write
Jul 27, 2026
Merged

perf(arrow): stream serialize-to-backend writes via BufferWritableBackend (LAB-766)#247
27Bslash6 merged 4 commits into
mainfrom
lab-766-streaming-write

Conversation

@27Bslash6

@27Bslash6 27Bslash6 commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Closes #245. Tracked as LAB-766.

What

The write path materialized the whole serialized envelope before a single byte reached the backend — pyarrow's BufferOutputStream doubling-growth + memory-pool retention held ~3.75x the payload, putting write peak RSS at ~5.6x logical for large DataFrames (the residual PR #152 left behind). This is the write-side twin of the get_buffer mmap read path (#171 / #187): stream serialized record batches straight to the backend, never holding the full blob.

Measured (400MB incompressible frame, subprocess ru_maxrss): streaming write peaks at 2.29x logical vs 5.58x buffered. Locked in by a peak-RSS regression test (bound 3.2x — between the two with margin on both sides).

How

  • BufferWritableBackend capability protocol (backends/base.py), same @runtime_checkable + TypeGuard idiom as BufferReadableBackend. Writer-callback shape — set_streaming(key, write_payload, ttl) against a seekable sink — rather than an Iterable[bytes], because the Arrow envelope stores its 8-byte xxHash3-64 checksum BEFORE the IPC bytes it covers: a forward-only chunk iterator cannot express that without serializing twice. The producer hashes incrementally while streaming and patches the checksum in place, so the stored envelope is byte-identical to the buffered path (asserted in tests) and every existing read path (get, mmap get_buffer) works unchanged.
  • FileBackend.set_streaming: incremental write behind the existing temp-file + exclusive-flock + fsync + atomic-rename pattern. Producer failures discard the partial write, leave any previous value for the key intact, and propagate unwrapped. max_value_mb enforced on the on-disk size before rename.
  • ArrowSerializer.serialize_to_sink: streams the ~8 MiB record-batch IPC frames through a hashing tee into the sink, bypassing BufferOutputStream entirely. Optional byte budget aborts over-limit values mid-stream (the issue L1 budget is not configurable + three dead/unenforced config knobs #163 max_value_size ceiling, enforced incrementally instead of post-materialization).
  • Transparent gating in store_result(_async): streams only for plaintext Arrow on a streaming-capable backend. Everything else — Redis, CachekitIO, L1, memcached, non-Arrow serializers, SWR writes — keeps using set(bytes) with no behaviour change (byte-for-byte, asserted).
  • Encryption is gated OUT (mirrors feat: zero-copy mmap read path for large plaintext Arrow on the File backend (#171) #187's mmap exclusion): AES-256-GCM emits its auth tag only after the whole ciphertext exists, so the secure path stays buffered. Chunked AEAD is an explicit non-goal.
  • Streamed values intentionally skip L1 — copying a multi-GB envelope into the in-process cache would defeat the point (mirrors the mmap read path's L1 exclusion).

Not a protocol/crypto change

SerializationWrapper is Python-SDK-internal and opaque to other SDKs and the SaaS; the cross-SDK ByteStorage wire format, AAD construction, key derivation, and cache-key format are untouched. wrap() was refactored to wrap_prefix() + payload with byte-identical output (covered by existing wrapper doctests + new equality tests).

Docs pass

  • docs/backends/file.md: new "Bounded-Memory Large Values (Arrow)" section (both capabilities, measured numbers, encryption carve-out, L1 exclusion).
  • docs/serializers/arrow.md: write-side streaming added to the Memory Usage section.
  • docs/configuration.md + settings.py (arrow_compression): clarified that write streaming is codec-independent; mmap reads still require none.
  • backends/base.py module docstring: capability-protocol list brought up to date.
  • Protocol repo / preset feature matrix: no change needed — SDK-internal backend capability, no cross-SDK or preset surface.

Testing

  • 27 new unit tests (tests/unit/test_streaming_write_path.py): eligibility, delegation, byte-identity vs buffered, fallback, no-buffered-retry-on-failure, atomicity (old value survives failed stream, no temp litter), TTL bounds, max_value_mb, mmap-read-of-streamed-entry composition, async twins.
  • Peak-RSS regression test (tests/performance/test_large_object_memory.py), same subprocess harness as the fix: bound memory for large DataFrame/Arrow caching (was OOMing at real sizes) #152/EPIC: Zero-copy mmap read path for the File backend (large-object read RSS) #171 tests.
  • Full suite: 1906 unit + 364 docs/critical/backends/architecture + module doctests green. ruff check / ruff format / basedpyright (pre-commit) pass. Remaining failures in tests/integration/saas + timing-sensitive perf benchmarks reproduce on the clean base (environmental).

Summary by CodeRabbit

  • New Features

    • Large plaintext Arrow values can now be streamed to the file cache, reducing peak memory usage.
    • Uncompressed Arrow values support efficient zero-copy reads where applicable.
    • Streaming writes preserve cache limits, expiry settings, atomicity and data integrity.
  • Documentation

    • Added guidance on streaming, compression behaviour, memory usage and backend compatibility.

…kend (LAB-766)

The write path materialized the whole serialized envelope before a single
byte reached the backend — pyarrow's BufferOutputStream doubling-growth plus
memory-pool retention held ~3.75x the payload, putting write peak RSS at
~5.6x logical for large DataFrames. Write-side twin of the get_buffer mmap
read path (#171/#187):

- BufferWritableBackend capability protocol (backends/base.py): writer-callback
  set_streaming(key, write_payload, ttl) against a seekable sink. A chunk
  iterator cannot express the Arrow envelope's checksum-first layout (the
  8-byte xxHash3-64 precedes the IPC bytes it covers), so the producer hashes
  incrementally while streaming and patches the checksum in place — stored
  bytes stay byte-identical to the buffered path.
- FileBackend.set_streaming: incremental write behind the existing temp-file +
  exclusive-flock + fsync + atomic-rename pattern; producer failures discard
  the partial write and leave any previous value intact; max_value_mb enforced
  on the on-disk size before rename.
- ArrowSerializer.serialize_to_sink: streams the ~8 MiB record-batch IPC
  frames through a hashing tee straight into the sink, bypassing
  BufferOutputStream entirely; optional byte budget aborts over-limit values
  mid-stream (max_value_size, issue #163 ceiling preserved).
- Write-flow gating: plaintext Arrow only (AES-256-GCM needs the whole
  ciphertext for its tag — the secure path stays buffered, mirroring the mmap
  read exclusion); every other backend/serializer falls back to set(bytes)
  unchanged; streamed values skip L1 by design.

Measured (400MB incompressible frame, subprocess ru_maxrss): streaming write
peaks at 2.29x logical vs 5.58x buffered. Locked in by a peak-RSS regression
test alongside the #152/#171 harness.

Closes #245
@kodus-27b

This comment has been minimized.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 55 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9574cd0f-c9e4-454f-9449-d5c4d12e9234

📥 Commits

Reviewing files that changed from the base of the PR and between 8cc9226 and a85c9fa.

📒 Files selected for processing (5)
  • src/cachekit/backends/file/backend.py
  • src/cachekit/cache_handler.py
  • src/cachekit/serializers/arrow_serializer.py
  • tests/performance/test_large_object_memory.py
  • tests/unit/test_streaming_write_path.py

Walkthrough

Adds a bounded-memory streaming write path for plaintext Arrow values. Arrow data is serialized directly to supported backend sinks, FileBackend commits temporary files atomically, handlers provide buffered fallback, and tests and documentation cover eligibility, limits, failures, and memory usage.

Changes

Streaming Arrow write path

Layer / File(s) Summary
Streaming contracts and serialization
src/cachekit/backends/base.py, src/cachekit/serializers/*, src/cachekit/cache_handler.py
Adds the BufferWritableBackend contract and streams checksum-protected Arrow envelopes into seekable sinks while enforcing byte limits and preserving buffered envelope compatibility.
Atomic FileBackend streaming writes
src/cachekit/backends/file/backend.py
Streams payloads into temporary files, validates TTL and size constraints, then flushes, fsyncs, atomically renames, and cleans up failed writes.
Cache-handler routing
src/cachekit/cache_handler.py
Adds synchronous and asynchronous streaming APIs, plaintext Arrow capability checks, failure handling, and operation-level routing that avoids buffered L1 envelopes.
Coverage and behaviour documentation
tests/unit/test_streaming_write_path.py, tests/performance/test_large_object_memory.py, docs/backends/file.md, docs/configuration.md, docs/serializers/arrow.md, src/cachekit/config/settings.py, .secrets.baseline
Tests eligibility, serialization equivalence, routing, backend atomicity, mmap interoperability, and RSS bounds; documents backend and compression behaviour and updates the secret baseline metadata.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CacheOperationHandler
  participant CacheSerializationHandler
  participant StandardCacheHandler
  participant FileBackend
  participant ArrowSerializer
  CacheOperationHandler->>CacheSerializationHandler: check streaming eligibility
  CacheOperationHandler->>StandardCacheHandler: set_streaming with payload callback
  StandardCacheHandler->>FileBackend: stream payload to backend
  FileBackend->>CacheSerializationHandler: invoke callback on temporary sink
  CacheSerializationHandler->>ArrowSerializer: serialize_to_sink
  ArrowSerializer->>FileBackend: write Arrow IPC bytes and patched checksum
  FileBackend-->>CacheOperationHandler: commit result
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy #245 by adding BufferWritableBackend, streaming FileBackend writes, Arrow sink serialisation, fallback behaviour, and RSS regression coverage.
Out of Scope Changes check ✅ Passed The docs, tests, and secrets baseline update all align with the streaming write-path work and do not introduce unrelated scope.
Title check ✅ Passed The title is concise and accurately highlights the main change: streaming Arrow writes via BufferWritableBackend.
Description check ✅ Passed The description covers the problem, implementation, docs, tests, and compatibility, though it does not mirror the template headings exactly.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch lab-766-streaming-write

Comment @coderabbitai help to get the list of available commands.

Comment thread src/cachekit/backends/base.py
Comment thread src/cachekit/backends/file/backend.py Outdated
Comment thread src/cachekit/backends/file/backend.py Outdated
Comment thread src/cachekit/backends/file/backend.py Outdated
Comment thread tests/performance/test_large_object_memory.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/cachekit/backends/file/backend.py`:
- Around line 413-458: Refactor set_streaming so the producer callback and
temporary-file write run outside the process-wide self._lock, avoiding
serialization of other FileBackend operations during long streams. Reacquire
self._lock only around the final capacity check, atomic rename, and _maybe_evict
commit sequence, while retaining _acquire_file_lock for file-level safety and
cleaning up the temporary file on failures. Preserve and explicitly confirm the
intended capacity-check ordering now that it occurs after streaming.
- Around line 771-787: Update refresh_ttl to use the existing _expiry_from_ttl
helper for TTL validation and expiry calculation, removing its duplicated
zero-TTL handling, bounds checks, error construction, and time arithmetic while
preserving the helper’s established behavior.

In `@src/cachekit/cache_handler.py`:
- Around line 1490-1511: Update the Returns documentation for store_result at
src/cachekit/cache_handler.py lines 1490-1511 to distinguish bytes for a
buffered store, which is eligible for L1, from None for a streamed store that
intentionally skips L1 or for a failed store. Apply the same Returns
clarification to store_result_async at src/cachekit/cache_handler.py lines
1560-1578; do not change the store behavior.

In `@src/cachekit/serializers/arrow_serializer.py`:
- Around line 373-374: Add a concise comment immediately before the exception
handler around the Arrow IPC serialization path explaining that ValueError is
intentionally not wrapped because _HashingSink budget errors must propagate
unchanged for the handler and tests. Keep the existing ArrowInvalid and
ArrowTypeError wrapping behavior unchanged.

In `@tests/unit/test_streaming_write_path.py`:
- Around line 260-263: Update test_ttl_zero_and_none_mean_no_expiry to exercise
set_streaming with both ttl=0 and ttl=None, asserting each stored value remains
retrievable without expiry. Keep the test focused on verifying that both TTL
values mean no expiration.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1cd3aaf6-7b70-49bc-86bb-40d3182ef92d

📥 Commits

Reviewing files that changed from the base of the PR and between e67db58 and 8cc9226.

📒 Files selected for processing (12)
  • .secrets.baseline
  • docs/backends/file.md
  • docs/configuration.md
  • docs/serializers/arrow.md
  • src/cachekit/backends/base.py
  • src/cachekit/backends/file/backend.py
  • src/cachekit/cache_handler.py
  • src/cachekit/config/settings.py
  • src/cachekit/serializers/arrow_serializer.py
  • src/cachekit/serializers/wrapper.py
  • tests/performance/test_large_object_memory.py
  • tests/unit/test_streaming_write_path.py

Comment thread src/cachekit/backends/file/backend.py Outdated
Comment thread src/cachekit/backends/file/backend.py
Comment thread src/cachekit/cache_handler.py
Comment thread src/cachekit/serializers/arrow_serializer.py
Comment thread tests/unit/test_streaming_write_path.py
- FileBackend.set_streaming no longer holds the process-wide lock for the
  whole stream: the producer runs the entire serialization (minutes at
  multi-GB scale), and safety comes from the unique temp path + flock +
  atomic rename, not the RLock. Lock now covers only the capacity precheck
  and the rename/eviction commit.
- refresh_ttl reuses _expiry_from_ttl instead of duplicating TTL bounds.
- store_result(_async) Returns docs: None is 'nothing for L1', not a
  failure signal (streamed success, streamed failure, or serialize error).
- serialize_to_sink: comment why ValueError is deliberately not wrapped
  (mid-stream max_value_size budget contract).
- test_ttl_zero_and_none_mean_no_expiry now actually covers ttl=0.
@27Bslash6 27Bslash6 changed the title LAB-766: perf(arrow): streaming serialize-to-backend write path (BufferWritableBackend) perf(arrow): stream serialize-to-backend writes via BufferWritableBackend (LAB-766) Jul 25, 2026
@kodus-27b

This comment has been minimized.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 25, 2026
…p cleanup (LAB-766)

- f: BinaryIO | None instead of Any (type safety preserved on the handle).
- Replace the BaseException catch-and-reraise with a committed-flag finally:
  same guarantee (no temp-file leak on any exit path, incl. KeyboardInterrupt),
  no overly-broad catch, producer exceptions still propagate unwrapped.
@27Bslash6

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 25, 2026
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

kodus-27b[bot]
kodus-27b Bot previously approved these changes Jul 25, 2026
@27Bslash6

Copy link
Copy Markdown
Contributor Author

@kody start-review

@kodus-27b

kodus-27b Bot commented Jul 25, 2026

Copy link
Copy Markdown

Kody Review Complete

Great news! 🎉
No issues were found that match your current review configurations.

Keep up the excellent work! 🚀

Kody Guide: Usage and Configuration
Interacting with Kody
  • Request a Review: Ask Kody to review your PR manually by adding a comment with the @kody start-review command at the root of your PR.

  • Validate Business Logic: Ask Kody to validate your code against business rules by adding a comment with the @kody -v business-logic command.

  • Provide Feedback: Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

Current Kody Configuration
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug
Performance
Security
Business Logic

Access your configuration settings here.

@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.03352% with 25 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/cachekit/cache_handler.py 78.57% 7 Missing and 5 partials ⚠️
src/cachekit/serializers/arrow_serializer.py 87.09% 7 Missing and 1 partial ⚠️
src/cachekit/backends/file/backend.py 90.90% 3 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

…B-766)

Textual conflict in tests/performance/test_large_object_memory.py: kept both
the new streaming-write RSS test (this branch) and the skipif decorator
LAB-350 added to test_byte_storage_store_has_no_full_payload_copy.

Silent (non-textual) conflict in arrow_serializer.py: LAB-110 replaced
_bounded_chunksize with _write_bounded_batches on main to fix skewed-frame
batch overshoot (cachekit-py#161), but this branch's serialize_to_sink still
called the removed function. Switched it to _write_bounded_batches to match
serialize() and keep the byte-aware batching fix on the streaming path too.
@27Bslash6
27Bslash6 merged commit 539fde9 into main Jul 27, 2026
34 checks passed
@27Bslash6
27Bslash6 deleted the lab-766-streaming-write branch July 27, 2026 02:41
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.

Streaming serialize-to-backend write path: cut the ~6x write-peak residual (LAB-766)

1 participant