Pool marshal buffers for Duo Workflow action writes
What does this MR do and why?
This removes a per-connection buffer in the Duo Workflow WebSocket handler in Workhorse, and replaces it with a shared pool.
wsManager had a buf []byte field. It was used to marshal outbound protobuf
Action messages into JSON before writing them to the client's WebSocket. The
buffer grew to the largest action the connection had seen, and then stayed that
large for the whole life of the connection. These connections stay open for
over an hour.
Production actions are routinely 1-2MB. Peak concurrency in gprd is about 950 simultaneous connections. So the fleet pinned roughly 1.4MB per open connection, over a gigabyte in total, which brought the container close to its 2GB memory limit.
The per-connection field is removed. WriteAction now borrows a buffer from a
package-level sync.Pool and returns it when the function returns. Retention
now scales with the number of writes happening at the same time, instead of the
number of connections that are open. That matters because connections are idle
nearly all the time, waiting on model and tool latency measured in seconds,
while marshaling and writing takes milliseconds. The pool keeps the grown
capacity, so repeated large actions do not reallocate. And because sync.Pool
is drained by the garbage collector, memory is returned under pressure instead
of being held at a high-water mark.
Measurements
Local benchmarks on an Apple M3 Max, 500 connections each writing one 1.42MB action.
| Measurement | Before | After |
|---|---|---|
| Retained per connection | 1,458,176 bytes | 24 bytes |
| Steady-state write cost | 2,207 B/op | 1,469 B/op, 19 allocs/op |
A linear regression of production heap against the
gitlab_workhorse_duo_workflow_connections_open gauge gives a marginal cost of
about 1.63MB per connection. That is close to the 1.39MB measured locally,
which confirms the buffer was the dominant per-connection cost.
Tradeoff
The buffer starts at 4KB. In a shared pool, every pool miss has to grow from 4KB again, which is roughly 22 reallocations to reach a 1.4MB action. Misses cluster right after garbage collection, because the collector drains the pool. So this trades a steady, always-present live heap for some allocation churn that is correlated with GC. It is still a clear net win against pinning 950 times 1.4MB, but tuning the initial size against the production size distribution is a sensible follow-up.
Test change
mockWebSocketConn.WriteMessage now copies the slice it is given. The real
gorilla/websocket WriteMessage writes synchronously and keeps no reference,
but the mock retained it, which is unsafe once the buffer is recycled.
Verification
Package tests pass, including 35 runs with -shuffle=on and 4 runs with
-race (zero data races). gofmt and go vet are clean.
One thing so reviewers are not surprised: TestHeaderParsing in this package
is an order-dependent pre-existing failure. It fails even when run alone,
because it depends on another test seeding a global JWT secret path. This MR
does not touch it.
References
- Tracking issue: https://gitlab.com/gitlab-com/gl-infra/capacity-planning-trackers/gitlab-com/-/work_items/2668
- Earlier related merge request: !254456 (merged)
Screenshots or screen recordings
Not applicable. This is a backend memory change with no user interface.
How to set up and validate locally
Run all commands from the workhorse/ directory.
-
Start GDK Redis first:
gdk start redis. Without it, unrelated tests in this package fail on a missing socket. -
Run the package tests:
go test ./internal/ai_assist/duoworkflow/ -count=1 -
Measure retained memory per connection and look at the
B-retained/connmetric:go test ./internal/ai_assist/duoworkflow/ -run XXX -bench 'BenchmarkConnectionsRetainedBytes' -benchtime=1x -
Confirm the per-write cost did not regress:
go test ./internal/ai_assist/duoworkflow/ -run XXX -bench 'BenchmarkWriteActionLarge' -benchtime=2000x -benchmem
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.