Skip to content

perf(reload): reclaim predecessor memory during handoff - #479

Merged
timtofan merged 14 commits into
masterfrom
atimofieiev/up-handoff-reload-memory
Nov 4, 2026
Merged

timtofan merged 14 commits into
masterfrom
atimofieiev/up-handoff-reload-memory

Conversation

@timtofan

@timtofan timtofan commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator

Depends on #478

Why

A SIGUSR2 reload runs the old and new relay side by side while accepted connections, stream dictionaries and queued records move to the successor. On relays holding tens of GB of stream dictionaries, the predecessor keeps pages it no longer needs while the successor restores state, so the overlap can reach roughly twice steady RSS. When that exceeds a memory limit the kernel kills a process mid-handoff and queued records are lost.

A second problem: the handoff waited for every transferred reader to finish its final flush. Under main-queue byte backpressure that flush waits for budget and queue space, so on relays with tens of thousands of plaintext connections the whole reload serialized behind it.

Architecture

  • Predecessor reclamation. The predecessor scavenges before fork/exec and again after the successor reports ready. After each successful transfer it releases that reader's reservations and reclaims memory in batches (heap/16, minimum 8 MiB), with at most five batch collections and one final collection; heaps below 64 MiB skip collection. GC settings, wire state, routing and the drain of already-parsed records are unchanged.
  • No wait on reader flush. Cleanup of readers that hold state is observed asynchronously by a single collector; the transfer goroutine never waits on a reader's flush or on the collector. Readers with no state do not hold the collector open, and the state exchange ends without joining it.

Before / after

Before After
Reload peak RSS (scaled local test: 85 stream inputs, 33 output sockets, 672k shared output names) 1.69x steady 1.07–1.22x steady
Explicit GC calls per reload (20–25 GB heap, 5 GB reader state) ~19 ≤8
Transfer of the next socket while a reader is stuck in its final flush blocked continues
Records delivered across reload exact exact (11,379,285 records at each of 3 sinks, 87 transfers, 0 output resets)

Usage

No configuration change. Keep a release RSS guard for the first upgrade, because the predecessor of that reload does not have this reclamation yet; later reloads have it on both sides.

Validation

Go 1.27.0: build, vet and full go test ./... pass on this branch. New tests cover batching, the cleanup fence, large-dictionary release, and a regression that holds a reader inside BatchChan.Close with a full budget or a full queue, transfers the following sockets, then checks its records and state are released after unblocking; restoring the old wait makes it fail. The same commits have run in production reloads of large stream relays: connections and dictionaries handed over with no restarts and no write errors.

Anton Timofieiev added 14 commits November 3, 2026 09:14
Sync the decoder copy with the receiver: reserve an upper bound for an
uncompressed snapshot instead of growing it while many connections hand
over their state together.
During a SIGUSR2 replacement the old and new relay processes run side by
side. With large stream dictionaries the predecessor keeps tens of GB of
pages it no longer needs while the successor restores state, so the pair
can exceed a memory limit; an OOM kill at that point loses queued records.

Scavenge before fork/exec and after successor readiness. Fence each
successful transfer on reader cleanup, then batch reclamation using
released reader reservations. Keep GC configuration, wire state,
connections, routing and queued-record drain unchanged. The batch size
is heap/8, clamped to 8-256 MiB; heaps below 64 MiB skip collection.

In a scaled local test (85 stream inputs, plaintext/zstd, 33 output
sockets and 672k shared output names), combined reload RSS falls from
889.6 to 615.1 MB, or 1.69x to 1.20x steady RSS. All 11,379,285 records
arrive exactly once at each of three sinks with no dictionary resets.
A smaller control is 1.83x to 1.32x, exposing the fixed-workspace floor.
These are scaled measurements, not a whole-process RSS guarantee; keep
a release RSS guard, especially for the first upgrade whose predecessor
does not contain this reclamation policy.

Add batching, cleanup-fence and large-dictionary release checks.

Co-Authored-By: Codex <noreply@openai.com>
Waiting for c.done after every accepted-socket transfer serializes reload
behind deferred BatchChan.Close. Under TCPMainQueueBytes backpressure,
its final Flush can wait for both budget admission and queue space. This
hurts most on relays with tens of thousands of plaintext connections,
whose releaseBytes is zero.

Observe cleanup asynchronously only for nonzero reader state. A single
collector consumes completion notifications; neither waiting for c.done
nor reporting to the collector runs on the transfer goroutine. End the
state exchange without a cleanup or collector join. Keep pre-fork and
post-readiness collection, existing parsed-record drain and input state.

Replace the 256 MiB collection cap with HeapAlloc/16 (minimum 8 MiB),
at most five batch collections and one final collection. Including the
two initial calls, a replacement makes at most eight explicit calls.
For a 20-25 GB heap and 5 GB of reader state this models about 6-8 calls,
rather than roughly 19 with 85 similarly sized readers under the old cap.

The regression holds a reader in actual BatchChan.Close with either a
full budget or full queue, transfers the following sockets, then verifies
its record and state release after unblocking. Zero-state readers do not
hold the collector open. Restoring the old wait makes this test fail.

A scaled local case delivers 11,379,285 exact records at each of three
replicas, with 87 successful input transfers and no output resets; peak
to steady RSS is 1.22x/1.07x with eight explicit collections.

Co-Authored-By: Codex <noreply@openai.com>
@timtofan
timtofan merged commit 2c1448b into master Nov 4, 2026
4 checks passed
@timtofan
timtofan deleted the atimofieiev/up-handoff-reload-memory branch November 4, 2026 13:55
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