Skip to content

Commit 6caa388

Browse files
committed
Size the worker fleet against connections that can actually be obtained (#722)
Two defects in the connection-budget guard, both deferred from the #719 review because neither is reachable at the shipped defaults. The guard admitted budgets its own error message rejected. It refused below connsPerWorker (2) while telling the operator to raise the pool to 4, so 2 and 3 passed a check whose own advice called them insufficient, and the max(..., 1) floor then manufactured a worker the pool could not serve: it pins the only connection for GET_LOCK and waits forever for a claim connection. That is the stall the guard exists to prevent. And affordability ignored the leader loops. Three RunIfLeader loops each pin a pooled connection for the whole process lifetime, so counting them as available sizes the fleet against connections that are never coming back. Connections pinned for the process lifetime are now subtracted before anything is sized. The count is injected rather than assumed, because this package should not encode how many sweeps its caller starts; the number lives next to the lock names it counts, as LeaderGatedLoops, with a test pinning the two together so a fourth sweep cannot drift from it. The threshold and the message now quote one number instead of two that disagreed, and it includes the reservation, so the figure an operator is told to reach is the figure enforced. With an honest threshold the max(..., 1) floor stops being a fiction and is gone: the refusal already guarantees one affordable worker, so a floor could only ever manufacture one the pool cannot serve. Verified rather than assumed that the shipped defaults do not change, since a sizing fix that quietly shrank production would be worse than the bug: 25 budget, 3 reserved, 4 configured still yields 4 workers, and there is a case that fails if that stops being true. One existing test case had to move from the bounds table to the refusal table: budget 3 was asserted to clamp to one worker, which is defect one written down as expected behaviour.
1 parent 4421a4f commit 6caa388

7 files changed

Lines changed: 183 additions & 35 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Size the worker fleet against connections that are actually available
2+
3+
## Why
4+
5+
Two defects in the processor's connection-budget guard, both found by Copilot in the #719 review and deliberately deferred there because neither is reachable at the shipped defaults (issue #722).
6+
7+
**The guard admitted budgets its own error message rejected.** It refused below `connsPerWorker` (2) while telling the operator to raise the pool to `connsPerWorker * workerPoolShareDivisor` (4). Budgets of 2 and 3 passed a check whose own advice called them insufficient, and the `max(..., 1)` floor then manufactured a worker the pool could not serve: it pins the only connection for `GET_LOCK` and waits forever for a claim connection. That is precisely the stall the guard exists to prevent.
8+
9+
**Affordability ignored the leader loops.** In full mode three `RunIfLeader` loops each pin a pooled connection for the whole process lifetime. Confirmed empirically rather than from source: a dev server logs `acquired leadership` for exactly `edr_process_ttl`, `edr_queue_prune` and `edr_retention`. The worker sizing counted those three connections as available, so a small pool could be consumed entirely by the leader locks plus one worker's `GET_LOCK`, with the claim waiting on a connection nobody will release.
10+
11+
Neither is reachable today because `dbMaxOpenConns` is a fixed 25: three leader connections plus four workers at two each is 11 of 25. Both become reachable the moment the pool size becomes configurable or concurrency rises far enough.
12+
13+
## What changes
14+
15+
Connections that are pinned for the process lifetime are subtracted before anything is sized. The count is injected (`ProcessorOptions.ReservedConns`) rather than assumed, because the processor should not encode how many background sweeps its caller starts; the bootstrap that wires those loops is the only thing that knows. The number itself lives next to the lock names it counts, as `pipeline.LeaderGatedLoops`, with a test pinning the two together.
16+
17+
The refusal threshold and the error message now quote one number instead of two that disagreed, and the message includes the reservation so the figure an operator is told to raise the pool to is the figure actually enforced. With an honest threshold the `max(..., 1)` floor stops being a fiction and is gone: the refusal already guarantees at least one worker is affordable, so a floor could only ever manufacture one the pool cannot serve.
18+
19+
## Not a behaviour change at the shipped defaults
20+
21+
Deliberately verified rather than assumed, because a sizing fix that quietly shrank the production fleet would be a worse bug than the one being fixed: 25 budget, 3 reserved, 4 configured still yields 4 workers, and there is a test case that fails if that ever stops being true.
22+
23+
## Why a new requirement
24+
25+
The natural home is `The processor scales across replicas via SKIP LOCKED`, which owns the existing sizing scenarios. It already carries an in-flight MODIFIED delta from `per-host-claim-affinity`, and two in-flight deltas against one requirement is a known archive hazard here: the second to archive silently drops the first's scenarios. The existing scenarios also remain true as written; what is new is the reservation, which is a separable property.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# server-availability delta: worker sizing subtracts connections that are already pinned
2+
3+
## ADDED Requirements
4+
5+
### Requirement: Worker sizing counts only connections that can actually be obtained
6+
7+
When the system sizes its worker fleet against the database connection pool, it SHALL first subtract every connection that is held for the lifetime of the process and therefore never returned to the pool. Today those are the leader-gated periodic sweeps, each of which pins one connection for its advisory lock from boot to shutdown.
8+
9+
Counting a permanently pinned connection as available is not a rounding error, it is the same stall the sizing check exists to prevent: the fleet is sized against connections that are never coming back, so workers pin what is left for their locks and then wait forever for a claim connection that no one will release. The result is a pipeline that boots cleanly and processes nothing.
10+
11+
The reserved count SHALL be supplied by whatever wires those long-lived holders, not assumed by the component doing the sizing, so that adding or removing a sweep cannot leave the sizing silently wrong.
12+
13+
Where the remaining budget cannot serve even one worker, the system SHALL refuse to start rather than reduce the fleet, and the threshold it enforces SHALL be the threshold its error reports, including the reservation. A guard that admits a value its own message calls insufficient is worse than no guard: it produces a running deployment whose configuration was already diagnosed as unusable. Once the refusal is honest, the system SHALL NOT floor the computed worker count to a minimum of one, because the refusal already guarantees one is affordable and a floor could only ever manufacture a worker the pool cannot serve.
14+
15+
#### Scenario: A pool with room for the sweeps but not for a worker is refused
16+
17+
- **GIVEN** a connection pool large enough for the leader-gated sweeps but with too little left for one worker
18+
- **WHEN** the processor is constructed with a coordinator
19+
- **THEN** construction fails rather than starting a worker that would stall on its first claim
20+
- **AND** the error names the pool size the deployment needs, counting the reservation
21+
22+
#### Scenario: A budget the guard's own advice rejects is refused rather than reduced
23+
24+
- **GIVEN** a connection budget smaller than the threshold the refusal message tells operators to reach
25+
- **WHEN** the processor is constructed with a coordinator
26+
- **THEN** construction fails
27+
- **AND** the fleet is not silently reduced to a worker that cannot make progress
28+
29+
#### Scenario: Reserving the long-lived holders does not shrink a healthy deployment
30+
31+
- **GIVEN** the shipped connection pool, the shipped worker count, and the leader-gated sweeps reserved
32+
- **WHEN** the processor is constructed
33+
- **THEN** the configured worker count is honored in full
34+
- **AND** no reduction is reported
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Tasks
2+
3+
- [x] Add `ProcessorOptions.ReservedConns` and subtract it before both the refusal check and the affordability calculation.
4+
- [x] Make the refusal threshold and its error message quote the same number, reservation included.
5+
- [x] Drop the `max(..., 1)` floor, now that the refusal guarantees one affordable worker.
6+
- [x] Export `pipeline.LeaderGatedLoops` next to the lock names, and pin it to that list with a test.
7+
- [x] Set the reservation from the detection bootstrap, which is what wires the loops, and only when a coordinator gates them.
8+
- [x] Cover: budgets of 2 and 3 refused rather than clamped, a pool consumed by the leader loops refused, reserved connections excluded from sizing, and the shipped default unchanged.

server/detection/bootstrap/bootstrap.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,11 @@ func (d *Detection) wireFullMode(deps Deps, store *mysql.Store, intakeH *intake.
219219
// deadlocking on it. Passing the real cap lets the processor size itself, and refuse a pool too small for one worker
220220
// rather than boot a pipeline that would stall on its first claim.
221221
ConnBudget: connBudget(deps.DB),
222+
// The leader-gated sweeps wired into the Runner below each pin a pooled connection for the whole process lifetime, so
223+
// they are not available to workers however large the pool looks. Reserve them here, where we know they are being
224+
// started, rather than letting the processor guess (issue #722). No coordinator means no leader loops and nothing to
225+
// reserve.
226+
ReservedConns: reservedLeaderConns(deps.Coordinator),
222227
},
223228
)
224229
if err != nil {
@@ -394,6 +399,16 @@ func (d *Detection) RegisterAuthedRoutes(mux httpserver.Router) {
394399

395400
// connBudget reports the MySQL pool's MaxOpenConns for the processor's concurrency clamp, or 0 when there is no handle to ask (the
396401
// intake-only modes and tests that wire no DB), which skips the clamp.
402+
// reservedLeaderConns is how many pooled connections the leader-gated sweeps hold for the lifetime of the process. Setup wires all
403+
// of them unconditionally, so the only question is whether a coordinator exists to gate them: without one they run ungated on this
404+
// replica and take no lock connection.
405+
func reservedLeaderConns(coord leader.Coordinator) int {
406+
if coord == nil {
407+
return 0
408+
}
409+
return pipeline.LeaderGatedLoops
410+
}
411+
397412
func connBudget(db *sqlx.DB) int {
398413
if db == nil {
399414
return 0

server/detection/internal/pipeline/processor.go

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,13 @@ type ProcessorOptions struct {
9393
// refuses, because clamping to one worker there would produce exactly the stall the sizing exists to prevent. Zero means unknown
9494
// and skips both the sizing and the refusal.
9595
ConnBudget int
96+
// ReservedConns is how many pooled connections are already spoken for by something else that holds them for its whole
97+
// lifetime, and so are not available to workers however large the pool looks. Today that is the coordinator's leader-gated
98+
// loops (LeaderGatedLoops), each of which pins one connection for its lock from boot to shutdown.
99+
//
100+
// It is injected rather than assumed because this package should not encode how many background sweeps its caller starts:
101+
// the bootstrap that wires those loops is the only thing that knows. Zero means nothing is reserved (issue #722).
102+
ReservedConns int
96103
}
97104

98105
// connsPerWorker is how many pooled connections one worker occupies inside its critical section: the GET_LOCK connection the
@@ -106,6 +113,15 @@ const connsPerWorker = 2
106113
// affords 6 workers against a configured 4); it is what keeps a future concurrency increase from quietly consuming the pool.
107114
const workerPoolShareDivisor = 2
108115

116+
// minConnsForOneWorker is the smallest usable budget: below it there is no worker count that can run, so the configuration is
117+
// refused rather than clamped. It is connsPerWorker scaled by the share divisor, which is the same arithmetic the affordability
118+
// calculation uses, so the guard and its error message quote one number instead of two that disagree.
119+
//
120+
// They used to disagree: the guard refused below connsPerWorker (2) while its message told the operator to raise the pool to 4, so
121+
// budgets of 2 and 3 passed a check whose own advice rejected them and were then clamped to a worker that could not make progress
122+
// (issue #722).
123+
const minConnsForOneWorker = connsPerWorker * workerPoolShareDivisor
124+
109125
// concurrencyClamp records that the effective worker count came out below the configured one, and why. The constructor decides it but
110126
// cannot log it: it has no context, and fabricating a background one there is what contextcheck (rightly) rejects. Run emits it once
111127
// at startup instead, so an operator whose configured concurrency was not honored sees the reason in the logs.
@@ -151,18 +167,25 @@ func NewProcessor(
151167
// forever for a claim connection. That is the exact stall the clamp exists to avoid, so refuse the configuration instead. A
152168
// deployment that will not boot states its problem; one that boots and silently never processes an event does not.
153169
if opts.Coordinator != nil && opts.ConnBudget > 0 {
154-
if opts.ConnBudget < connsPerWorker {
170+
// Subtract what is already pinned before sizing anything. Connections held by the leader-gated loops are never returned
171+
// while the process runs, so counting them as available sizes the fleet against connections that do not exist.
172+
available := opts.ConnBudget - opts.ReservedConns
173+
if available < minConnsForOneWorker {
155174
return nil, fmt.Errorf(
156-
"detection processor: MySQL pool of %d connection(s) cannot serve one worker; the per-host claim lock needs %d "+
157-
"connections per worker (one pinned by GET_LOCK, one for the claim and flush), so raise the pool to at least %d",
158-
opts.ConnBudget, connsPerWorker, connsPerWorker*workerPoolShareDivisor)
175+
"detection processor: MySQL pool of %d connection(s) leaves %d after %d reserved for the leader-gated loops, which "+
176+
"cannot serve one worker; the per-host claim lock needs %d connections per worker (one pinned by GET_LOCK, one "+
177+
"for the claim and flush) and workers take at most half the pool, so raise the pool to at least %d",
178+
opts.ConnBudget, available, opts.ReservedConns, connsPerWorker, opts.ReservedConns+minConnsForOneWorker)
159179
}
160-
if affordable := max(opts.ConnBudget/(connsPerWorker*workerPoolShareDivisor), 1); concurrency > affordable {
180+
// No max(..., 1) floor: the refusal above guarantees at least one worker is affordable, so a floor here could only ever
181+
// manufacture a worker the pool cannot serve, which is the stall this whole check exists to prevent.
182+
if affordable := available / minConnsForOneWorker; concurrency > affordable {
161183
clamp = &concurrencyClamp{
162184
reason: "detection processor concurrency clamped to the connection budget",
163185
attrs: []any{
164186
"requested_concurrency", concurrency, "effective_concurrency", affordable,
165-
"max_open_conns", opts.ConnBudget, "conns_per_worker", connsPerWorker,
187+
"max_open_conns", opts.ConnBudget, "reserved_conns", opts.ReservedConns,
188+
"available_conns", available, "conns_per_worker", connsPerWorker,
166189
"pool_share_divisor", workerPoolShareDivisor,
167190
},
168191
}

server/detection/internal/pipeline/processor_hostclaim_test.go

Lines changed: 61 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package pipeline
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"strings"
78
"testing"
89

@@ -89,31 +90,38 @@ func TestNewProcessor_ConcurrencyBounds(t *testing.T) {
8990
t.Parallel()
9091

9192
cases := []struct {
92-
name string
93-
concurrency int
94-
connBudget int
95-
coordinator leader.Coordinator
96-
want int
93+
name string
94+
concurrency int
95+
connBudget int
96+
reservedConns int
97+
coordinator leader.Coordinator
98+
want int
9799
}{
98-
{"non-positive concurrency floors to one worker", 0, 25, stubCoordinator{}, 1},
99-
{"the shipped default pool leaves the default worker count alone", 4, 25, stubCoordinator{}, 4},
100-
{"budget exactly the worker share leaves it alone", 4, 16, stubCoordinator{}, 4},
101-
{"tight budget clamps to the share the pool can serve", 8, 16, stubCoordinator{}, 4},
102-
{"a budget serving one worker's share clamps to one", 8, 4, stubCoordinator{}, 1},
103-
{"a budget below a full share still leaves one worker", 8, 3, stubCoordinator{}, 1},
104-
{"unknown budget skips the sizing", 8, 0, stubCoordinator{}, 8},
105-
{"no coordinator forces a single worker", 8, 25, nil, 1},
106-
{"a lockless single worker needs no pool share", 8, 2, nil, 1},
100+
{"non-positive concurrency floors to one worker", 0, 25, 0, stubCoordinator{}, 1},
101+
{"the shipped default pool leaves the default worker count alone", 4, 25, 0, stubCoordinator{}, 4},
102+
// The production shape, and the case this must never regress: reserving the leader loops out of the real pool still has
103+
// to leave the configured fleet intact, or the fix for issue #722 would quietly shrink every deployment.
104+
{"spec:server-availability/worker-sizing-counts-only-connections-that-can-actually-be-obtained/reserving-the-long-lived-holders-does-not-shrink-a-healthy-deployment", 4, 25, LeaderGatedLoops, stubCoordinator{}, 4},
105+
{"budget exactly the worker share leaves it alone", 4, 16, 0, stubCoordinator{}, 4},
106+
{"tight budget clamps to the share the pool can serve", 8, 16, 0, stubCoordinator{}, 4},
107+
{"a budget serving one worker's share clamps to one", 8, 4, 0, stubCoordinator{}, 1},
108+
// The reservation is subtracted BEFORE sizing: 16 looks like 4 workers, but 8 of it is already pinned for the whole
109+
// process lifetime and is never coming back.
110+
{"reserved connections are not available to workers", 8, 16, 8, stubCoordinator{}, 2},
111+
{"unknown budget skips the sizing", 8, 0, 0, stubCoordinator{}, 8},
112+
{"no coordinator forces a single worker", 8, 25, 0, nil, 1},
113+
{"a lockless single worker needs no pool share", 8, 2, 0, nil, 1},
107114
}
108115
for _, tc := range cases {
109116
t.Run(tc.name, func(t *testing.T) {
110117
t.Parallel()
111118
proc, err := NewProcessor(&scriptedEventLog{}, nil, nil, ProcessorOptions{
112-
Logger: discardLogger(),
113-
Batch: 10,
114-
Concurrency: tc.concurrency,
115-
Coordinator: tc.coordinator,
116-
ConnBudget: tc.connBudget,
119+
Logger: discardLogger(),
120+
Batch: 10,
121+
Concurrency: tc.concurrency,
122+
Coordinator: tc.coordinator,
123+
ConnBudget: tc.connBudget,
124+
ReservedConns: tc.reservedConns,
117125
})
118126
require.NoError(t, err)
119127
assert.Equal(t, tc.want, proc.concurrency)
@@ -138,31 +146,55 @@ func TestNewProcessor_RefusesPoolBelowOneWorker(t *testing.T) {
138146
t.Parallel()
139147

140148
cases := []struct {
141-
name string
142-
connBudget int
149+
name string
150+
connBudget int
151+
reservedConns int
143152
}{
144-
{"a single-connection pool cannot hold a lock and claim at once", 1},
145-
{"a pool one short of a worker's needs is still a stall", connsPerWorker - 1},
153+
{"a single-connection pool cannot hold a lock and claim at once", 1, 0},
154+
{"a pool one short of a worker's needs is still a stall", connsPerWorker - 1, 0},
155+
// These two used to be ADMITTED by a guard whose own error message called them insufficient: it refused below
156+
// connsPerWorker (2) while telling the operator to raise the pool to 4, then clamped 2 and 3 to a worker that could not
157+
// make progress. A guard must not accept a value its message rejects (issue #722).
158+
{"spec:server-availability/worker-sizing-counts-only-connections-that-can-actually-be-obtained/a-budget-the-guard-s-own-advice-rejects-is-refused-rather-than-reduced", connsPerWorker, 0},
159+
{"one short of a full worker share is refused too", minConnsForOneWorker - 1, 0},
160+
// Room for the leader loops but not for a worker afterwards. Before the reservation existed this looked like a healthy
161+
// pool and produced a fleet that would stall on its first claim.
162+
{"spec:server-availability/worker-sizing-counts-only-connections-that-can-actually-be-obtained/a-pool-with-room-for-the-sweeps-but-not-for-a-worker-is-refused", LeaderGatedLoops + 1, LeaderGatedLoops},
146163
}
147164
for _, tc := range cases {
148165
t.Run(tc.name, func(t *testing.T) {
149166
t.Parallel()
150167
proc, err := NewProcessor(&scriptedEventLog{}, nil, nil, ProcessorOptions{
151-
Logger: discardLogger(),
152-
Batch: 10,
153-
Concurrency: 4,
154-
Coordinator: stubCoordinator{},
155-
ConnBudget: tc.connBudget,
168+
Logger: discardLogger(),
169+
Batch: 10,
170+
Concurrency: 4,
171+
Coordinator: stubCoordinator{},
172+
ConnBudget: tc.connBudget,
173+
ReservedConns: tc.reservedConns,
156174
})
157175
require.Error(t, err, "a pool that cannot serve one worker must not produce a processor")
158176
assert.Nil(t, proc)
159-
// The operator's next action is to raise the pool, so the error has to carry the number to raise it to.
177+
// The operator's next action is to raise the pool, so the error has to carry the number to raise it to, and that
178+
// number has to be the one the guard actually enforces. The two disagreeing is what let 2 and 3 through.
160179
assert.Contains(t, err.Error(), "raise the pool to at least",
161180
"the refusal must tell the operator what to change, not just that something is wrong")
181+
assert.Contains(t, err.Error(), fmt.Sprintf("at least %d", tc.reservedConns+minConnsForOneWorker),
182+
"the advertised threshold must be the one enforced, reservation included")
162183
})
163184
}
164185
}
165186

187+
// TestLeaderGatedLoopsMatchesLockNames pins the exported connection reservation to the loops it counts. The processor subtracts
188+
// LeaderGatedLoops from the pool before sizing its fleet, so if a fourth leader-gated sweep were added without bumping it, the
189+
// processor would size itself against a connection that is permanently pinned and could stall on its first claim (issue #722). The
190+
// count is in a different file from the Run that starts the loops, which is exactly the drift this catches.
191+
func TestLeaderGatedLoopsMatchesLockNames(t *testing.T) {
192+
t.Parallel()
193+
locks := []string{lockProcessTTL, lockRetention, lockQueuePrune}
194+
assert.Len(t, locks, LeaderGatedLoops,
195+
"every leader-gated lock pins a pooled connection, so the reservation must count all of them")
196+
}
197+
166198
// A non-positive batch would spin the drain loop, since an empty claim returns 0 and 0 >= 0 never breaks.
167199
func TestNewProcessor_BatchFloorsToOne(t *testing.T) {
168200
t.Parallel()

0 commit comments

Comments
 (0)