Skip to content

Commit 6790bf6

Browse files
committed
fix(flowcontrol): prevent aggregate stats underflow on flow GC
deleteFlow deducted a non-empty queue's stats from the registry aggregates without emptying the underlying SafeQueue. The cleanup sweep resolves ManagedQueue handles before processing, without registry locks, so a handle resolved before the GC could still drain the queue afterward and propagate the same deduction a second time. The int64 aggregates went negative and the uint64 casts in Stats() wrapped to near-MaxUint64, corrupting capacity checks and the flow_control_* gauges for the process lifetime. Drain the queue through the managedQueue wrapper instead, so the deduction is measured across the actual mutation and stale handles observe an empty queue. Signed-off-by: Luke Van Drie <lukevandrie@google.com>
1 parent 800ec0e commit 6790bf6

2 files changed

Lines changed: 77 additions & 10 deletions

File tree

pkg/epp/flowcontrol/registry/registry_helpers.go

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -170,17 +170,18 @@ func (fr *FlowRegistry) deleteFlow(key flowcontrol.FlowKey) {
170170
fr.logger.V(logging.DEBUG).Info("Deleting queue instance.", "flowKey", key)
171171
if val, ok := fr.priorityBands.Load(key.Priority); ok {
172172
band := val.(*priorityBand)
173-
// Requests in a queue that are asynchronously finalized (e.g., due to client
174-
// stream cancellation or context timeout), they are left in the queue for the
175-
// GC process to clean them up, including updating the capacity. Here we remove
176-
// a flow queue, potentially with such requests waiting for GC, therefor the
177-
// capacity stats are updated here before removing the queue.
173+
// Requests that are asynchronously finalized (e.g., due to client stream
174+
// cancellation or context timeout) are left in the queue for the cleanup sweep.
175+
// A queue deleted here may still hold such items, and the sweep may still hold a
176+
// ManagedQueue handle to it (handles are resolved before processing, without
177+
// registry locks). Draining through the wrapper both empties the queue and
178+
// deducts the stats in one critical section, so a later mutation through a stale
179+
// handle observes an empty queue and propagates nothing.
178180
if mq, ok := band.queues[key.ID]; ok && mq != nil {
179-
// Safe-guard: Deduct any unswept capacity before destroying the queue
180-
if mqLen := int64(mq.Len()); mqLen > 0 {
181-
fr.logger.V(logging.DEBUG).Info("Deregistering non-empty queue during GC, flushing stats",
182-
"flowKey", key, "unsweptCount", mqLen)
183-
fr.propagateStatsDelta(key.Priority, -mqLen, -int64(mq.ByteSize()))
181+
if mq.Len() > 0 {
182+
fr.logger.V(logging.DEBUG).Info("Deregistering non-empty queue during GC, draining unswept items",
183+
"flowKey", key, "unsweptCount", mq.Len())
184+
mq.Drain()
184185
}
185186
}
186187
delete(band.queues, key.ID)

pkg/epp/flowcontrol/registry/registry_helpers_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,72 @@ func TestRegistry_DeleteFlow(t *testing.T) {
381381
"Accessing a deleted flow must return ErrFlowInstanceNotFound")
382382
}
383383

384+
// TestRegistry_DeleteFlow_StaleHandleStats covers the interleaving where the cleanup sweep
385+
// resolves a ManagedQueue handle (processAllQueuesConcurrently phase 1), GC deletes the still
386+
// non-empty flow, and the sweep then mutates through the stale handle (phase 3). Each item must be
387+
// deducted from the aggregates exactly once: the uint64 casts in Stats() require the aggregates to
388+
// never go negative.
389+
func TestRegistry_DeleteFlow_StaleHandleStats(t *testing.T) {
390+
t.Parallel()
391+
392+
testCases := []struct {
393+
name string
394+
staleOp func(t *testing.T, mq contracts.ManagedQueue, item flowcontrol.QueueItemAccessor)
395+
}{
396+
{
397+
name: "Drain",
398+
staleOp: func(t *testing.T, mq contracts.ManagedQueue, _ flowcontrol.QueueItemAccessor) {
399+
assert.Empty(t, mq.Drain(), "Stale handle must observe an empty queue after deleteFlow")
400+
},
401+
},
402+
{
403+
name: "Cleanup",
404+
staleOp: func(t *testing.T, mq contracts.ManagedQueue, _ flowcontrol.QueueItemAccessor) {
405+
items := mq.Cleanup(func(_ flowcontrol.QueueItemAccessor) bool { return true })
406+
assert.Empty(t, items, "Stale handle must observe an empty queue after deleteFlow")
407+
},
408+
},
409+
{
410+
name: "Remove",
411+
staleOp: func(t *testing.T, mq contracts.ManagedQueue, item flowcontrol.QueueItemAccessor) {
412+
_, err := mq.Remove(item.Handle())
413+
assert.Error(t, err, "Removing an item already accounted for by deleteFlow must fail")
414+
},
415+
},
416+
}
417+
418+
for _, tc := range testCases {
419+
t.Run(tc.name, func(t *testing.T) {
420+
t.Parallel()
421+
h := newTestHarness(t)
422+
item := h.addItem(h.highPriorityKey1, 100)
423+
424+
// The sweep resolves handles before processing, without holding registry locks in between.
425+
mq, err := h.registry.ManagedQueue(h.highPriorityKey1)
426+
require.NoError(t, err, "Test setup: resolving the ManagedQueue handle must succeed")
427+
428+
h.registry.mu.Lock()
429+
h.registry.deleteFlow(h.highPriorityKey1)
430+
h.registry.mu.Unlock()
431+
432+
stats := h.registry.Stats()
433+
assert.Zero(t, stats.TotalLen, "deleteFlow must deduct the unswept items from the total length")
434+
assert.Zero(t, stats.TotalByteSize, "deleteFlow must deduct the unswept items from the total byte size")
435+
436+
tc.staleOp(t, mq, item)
437+
438+
stats = h.registry.Stats()
439+
assert.Zero(t, stats.TotalLen,
440+
"A stale-handle mutation after deleteFlow must not deduct the same items again (uint64 underflow)")
441+
assert.Zero(t, stats.TotalByteSize,
442+
"A stale-handle mutation after deleteFlow must not deduct the same items again (uint64 underflow)")
443+
bandStats := stats.PerPriorityBandStats[highPriority]
444+
assert.Zero(t, bandStats.Len, "Per-band length must not underflow after a stale-handle mutation")
445+
assert.Zero(t, bandStats.ByteSize, "Per-band byte size must not underflow after a stale-handle mutation")
446+
})
447+
}
448+
}
449+
384450
func TestRegistry_DynamicProvisioning(t *testing.T) {
385451
t.Parallel()
386452

0 commit comments

Comments
 (0)