Skip to content

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

Merged
elevran merged 6 commits into
mainfrom
worktree-capped-response-buffer
Jul 10, 2026
Merged

elevran merged 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 /staging → PUT /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).
@elevran
elevran merged commit 26193e9 into main Jul 10, 2026
11 of 12 checks passed
elevran added a commit that referenced this pull request Jul 10, 2026
All store paths (new chain and continuation) now use the staging protocol:
POST /staging → PUT chunks → PUT complete. The old GET /responses/{id}
(resolve chain) + POST /responses/{id} (buffered store) flows no longer
reflect what the proxy actually does.

Specific fixes:
- System diagram: replace wrong Charon calls with actual endpoints
- Proxy-Charon interaction: unify new chain and continuation under staging
  protocol; document store:false path (GET /chain/{id}, added in PR #87)
- Charon API section: rename buffered-store section to staging protocol
  (primary path), add GET /chain/{id} section, fix POST /responses body
  fields (prev_id/response_id/request_blob/response_blob + X-Tenant-Key
  header, not previous_response_id/tenant_key in body)
- Remove stale 'open question' note (answered by GET /chain/{id})
- ID flow diagrams: collapse to one staging-protocol diagram; remove wrong
  GET /responses (chain) + POST /responses/{id} buffered diagrams
- store:false semantics: describe GET /chain/{id} for continuations
- Performance section: update endpoint names to match current API
elevran added a commit that referenced this pull request Jul 12, 2026
Closes a test gap surfaced after PR #88 merged: nothing proved that a response saved via the chunked staging protocol (POST /staging → PUT /staging/{sid}/chunks/{k} → PUT /staging/{sid}/complete) was correctly reassembled into a chain entry that the proxy could serve back as part of a subsequent turn's flat context.

## What this PR adds

**`cmd/proxy/inference/mock.go`** — `MockServer` gains a `RequestBodies()` accessor that snapshots every POST /responses body in arrival order (deep-copy per element so callers may mutate without affecting each other or future snapshots).

**`cmd/proxy/chunked_roundtrip_test.go`** — `TestChunkedRoundtripReassemblesIntoChain` saves an anchor turn with `maxChunkBytes=64` (forces ≥2 `AppendChunk`, pinned by `TestBufferedProxyMultipleChunks`), issues a continuation via `previous_response_id`, and verifies the continuation's inference call received the anchor's response output (`msg_ok`, role=assistant) in its input array — the strongest end-to-end proof that chunks → chain → served-context is intact.

## Existing coverage this complements

| Test | Layer | Validates |
|---|---|---|
| `chunk_test.go` | unit | chunkedResponseWriter chunk→chunked byte round-trip |
| `internal/chainstore/streaming_test.go` | server | AppendChunk/Complete/Retrieve reassembly |
| `backend_routing_test.go` | proxy | wire pattern (path counts) — no byte check |
| `handler_test.go::TestStoreTrueContinuation` | proxy | continuation returns 200 with different ID |
| **This test** | **proxy end-to-end** | **chunked save → chain serve → inference context** |

make presubmit is green.
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