Skip to content

proxy: replace streamBuffer with chunkedResponseWriter for capped response storage#88

Open
elevran wants to merge 6 commits into
mainfrom
worktree-capped-response-buffer
Open

proxy: replace streamBuffer with chunkedResponseWriter for capped response storage#88
elevran wants to merge 6 commits into
mainfrom
worktree-capped-response-buffer

Conversation

@elevran

@elevran elevran commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Summary

  • Replaces the all-at-once Store call with a staging-based chunked protocol: POST /stagingPUT /staging/{sid}/chunks/{k} (×N) → PUT /staging/{sid}/complete, or PUT /staging/{sid}/abort on empty output
  • New chunkedResponseWriter in cmd/proxy/chunk.go splits response bytes at a configurable MaxChunkBytes cap (default 1 MiB), including within a single large Add call
  • All three proxy paths (buffered, SSE streaming, WebSocket) funnel through the same writer; streamBuffer is removed

Changes

  • pkg/charon/client.go: extend Backend interface with AppendChunk, Complete, Abort
  • cmd/proxy/chunk.go: chunkedResponseWriter — byte-level splitter, mutex-protected, idempotent Close/Abort
  • internal/proxyconfig/proxyconfig.go: --max-chunk-bytes flag (0 → default 1 MiB)
  • cmd/proxy/handler.go: wire writer into HandleCreate; WithMaxChunkBytes option
  • cmd/proxy/sse.go: remove streamBuffer; marshal full blob at end, write via writer
  • cmd/proxy/ws.go: same pattern as sse.go
  • cmd/proxy/main.go: pass MaxChunkBytes from config to handler

Test Plan

  • Unit tests in chunk_test.go: single chunk, cap flush, abort-on-empty, idempotent close, cancel during flush, flush failure propagation
  • TestBufferedCapConfigurable / TestBufferedProxyMultipleChunks: tiny cap forces ≥2 AppendChunk calls
  • TestBufferedProxySingleChunk / TestStreamedProxySingleChunk: default cap → exactly 1 chunk + 1 complete
  • TestBufferedProxyStoreFalseNoStagingCalls: store:false makes no staging calls
  • TestStreamingInferenceFailureEmitsFailedNotCompleted: truncated stream → 0 chunks, 0 complete
  • Full suite passes: go test ./... -race and make presubmit clean

elevran added 6 commits July 9, 2026 20:04
…ponse storage

Replace the all-at-once Store call with a staging-based chunked protocol:
- POST /staging → PUT /staging/{sid}/chunks/{k} (×N) → PUT /staging/{sid}/complete
- Abort via PUT /staging/{sid}/abort when no bytes were written

Key changes:
- pkg/charon/client.go: extend Backend interface with AppendChunk, Complete, Abort
- cmd/proxy/chunk.go: new chunkedResponseWriter — byte-level splitter with
  configurable MaxChunkBytes cap; splits within a single Add call
- internal/proxyconfig/proxyconfig.go: add --max-chunk-bytes flag (default 1 MiB)
- cmd/proxy/handler.go: wire chunkedResponseWriter into HandleCreate; add
  WithMaxChunkBytes option
- cmd/proxy/sse.go: remove streamBuffer; accumulate output items, marshal once,
  write via chunkedResponseWriter
- cmd/proxy/ws.go: same pattern as sse.go
- cmd/proxy/main.go: pass MaxChunkBytes from config to handler

Tests: unit tests in chunk_test.go; integration tests in handler_test.go,
backend_routing_test.go, and disruptive_test.go verify chunk counts, cap
splitting, store:false no-op, and mid-stream failure non-persistence.
Apply consensus findings from /simplify and /ponytail-review:

- chunk.go: drop errWriterClosed sentinel. No caller or test compares
  against it; closedErr now stores backend.Abort's error directly.
  Removes the 'errors' import.

- chunk.go (Abort): replace the errWriterClosed default + override
  with a single closedErr = err assignment.

- backend_routing_test.go: rewrite the docstring above
  TestBufferedProxyStoreFalseNoStagingCalls to match what the test
  actually asserts (no staging calls on store:false). The prior
  comment named a non-existent test and rambled about aborts.

- chunk_test.go (TestWriterCapFlush): fix 'chunk indices must be 0,1,2'
  comment — the loop iterates only 0,1 for the 2-element chunks slice.
The chunk tests added in this PR (TestBufferedProxySingleChunk,
TestStreamedProxySingleChunk, TestBufferedProxyMultipleChunks,
TestBufferedProxyStoreFalseNoStagingCalls, TestBufferedCapConfigurable)
all manually constructed 'rec := &routingRecorder{}; s := newTestStack(t,
withCharonMiddleware(rec.middleware()))' — the exact pattern that
newRoutingStack(t) already encapsulates.

Extend newRoutingStack to accept variadic stackOption so the cap test
can pass withMaxChunkBytes(64) alongside the recorder middleware.

Net: -5 lines per test, single source of truth for the recorder setup.
- New internal/bytesize package: ByteSize type with UnmarshalJSON,
  plus KiB/MiB/GiB/TiB constants. The constants are exported so call
  sites can write 'bytesize.MiB' instead of '1 << 20'.

- charonconfig: drop ByteSize; reference bytesize.ByteSize in
  CharonOptions and fileStorageConfig. JSON parsing is preserved
  (the unmarshal method moved with the type).

- internal/server/handlers.go: defaultMaxChunkBytes = bytesize.MiB,
  maxChunkBytes = 4 * bytesize.MiB.

- cmd/proxy/handler.go: NewHandler's default cap is bytesize.MiB.

- internal/proxyconfig/proxyconfig.go: defaultMaxChunkBytes const
  is bytesize.MiB.
The code-reviewer agent found a real concurrency bug in chunkedResponseWriter.Close:
the second concurrent Close caller read w.closedErr under the lock BEFORE waiting
on <-ch, racing against the first Close's deferred write of closedErr (which
happens after the unlocked Complete network call). On failure, the second caller
could return a stale nil, masking the real error.

Reproduction (reviewer's 200-iter trial without the fix): ~3/200 failures under
go test -race.

Fix: in the early-return path, drop the lock, wait on <-ch, then re-acquire
the lock to read closedErr. The channel close provides happens-before for
closedErr, and the second lock acquisition prevents torn interface reads.

Symmetric with Abort, which already reads closedErr after <-ch.

Also add TestWriterCloseIdempotentOnError that exercises the failure path
concurrently to lock in the contract. Pin both paths at 200 iter under -race.

Drive-by: fix the misleading comment in TestBufferedProxyStoreFalseNoStagingCalls
— the prior wording pointed at disruptive_test.go for the empty-response abort
path, but disruptive_test.go only covers inference-truncation. The unit tests in
chunk_test.go (TestWriterZeroBytesAborts) actually exercise that path.
Apply every Important and Minor finding from the code-reviewer agent:

1. Symmetric Abort + log Abort errors (handler.go/sse.go/ws.go):
   Extract (h *Handler) commitStoredResponse(ctx, stagingID, id, tenantKey,
   blob) error. The helper owns Add → Abort-on-error → Close → Abort-on-error,
   logging all stages. Abort failures are logged at warn but never mask the
   original error. Each call site collapses from ~12 lines to a single if
   that maps the error to its surface (HTTP / SSE / WS).

   This addresses the deferred altitude finding (three-way duplication)
   and the symmetry finding in one shot.

2. chunk.flush(): make flush failure terminal. When AppendChunk returns an
   error, the writer now sets closed=true and closes closedCh (gated by
   !w.closed so a Close-driven flush doesn't double-close the channel).
   Subsequent Add/Close/Abort calls return the same error without
   retrying the same chunk index. Removed the redundant closedErr = err
   in Close since flush now owns that write.

3. internal/server/handlers.go: rename defaultMaxChunkBytes to
   defaultChunkBodyBytes — disambiguates from proxyconfig's
   defaultMaxChunkBytes (different meaning, same value). Also remove the
   unused maxChunkBytes = 4*MiB const.

4. internal/proxyconfig/proxyconfig.go: add maxMaxChunkBytes = MiB cap and
   Validate() check that rejects --max-chunk-bytes values exceeding it
   with a clear error. Update --max-chunk-bytes help text to advertise
   the upper bound. Hardcoded rather than imported from internal/server
   to avoid a config→server import edge; comment cross-references
   server.defaultChunkBodyBytes.

Verified: presubmit green; go test -race -count=200 -run TestWriter ./cmd/proxy/
passes (covers the new terminal-flush state path).
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