Skip to content

Commit 69010a2

Browse files
authored
Merge branch 'main' into fix/fc-saturation-staleness
2 parents bd8d84d + 26df3f1 commit 69010a2

4 files changed

Lines changed: 200 additions & 4 deletions

File tree

pkg/epp/flowcontrol/controller/internal/processor.go

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ type Processor struct {
9191
wg sync.WaitGroup
9292
isShuttingDown atomic.Bool
9393
shutdownOnce sync.Once
94+
95+
// dropCounts accumulates non-dispatched request outcomes between periodic summary flushes.
96+
// Written by both the main Run goroutine (enqueue, shutdown) and the runCleanupSweep goroutine (sweep),
97+
// so each slot is an atomic to avoid a data race.
98+
dropCounts [types.NumQueueOutcomes]atomic.Uint64
9499
}
95100

96101
// NewProcessor creates a new Processor instance.
@@ -263,6 +268,7 @@ func (p *Processor) enqueue(item *FlowItem) {
263268
if finalState := outcome; finalState != nil {
264269
p.logger.V(logutil.TRACE).Info("Item finalized externally before processing, discarding.",
265270
"outcome", finalState.Outcome, "err", finalState.Err, "flowKey", key, "requestID", req.ID())
271+
p.recordDrop(finalState.Outcome)
266272
return
267273
}
268274

@@ -272,6 +278,7 @@ func (p *Processor) enqueue(item *FlowItem) {
272278
finalErr := fmt.Errorf("configuration error: failed to get queue for flow key %s: %w", key, err)
273279
p.logger.Error(finalErr, "Rejecting request, queue lookup failed", "flowKey", key, "requestID", req.ID())
274280
item.FinalizeWithOutcome(types.QueueOutcomeRejectedOther, fmt.Errorf("%w: %w", types.ErrRejected, finalErr))
281+
p.recordDrop(types.QueueOutcomeRejectedOther)
275282
return
276283
}
277284

@@ -280,6 +287,7 @@ func (p *Processor) enqueue(item *FlowItem) {
280287
finalErr := fmt.Errorf("configuration error: failed to get priority band for priority %d: %w", key.Priority, err)
281288
p.logger.Error(finalErr, "Rejecting request, priority band lookup failed", "flowKey", key, "requestID", req.ID())
282289
item.FinalizeWithOutcome(types.QueueOutcomeRejectedOther, fmt.Errorf("%w: %w", types.ErrRejected, finalErr))
290+
p.recordDrop(types.QueueOutcomeRejectedOther)
283291
return
284292
}
285293

@@ -290,9 +298,12 @@ func (p *Processor) enqueue(item *FlowItem) {
290298
// that state reflects genuine unavailability (surfaced as 503), not backpressure against a contended pool (429).
291299
if p.poolEmpty {
292300
p.logger.V(logutil.DEBUG).Info("Rejecting request, queue at capacity with no endpoints",
293-
"flowKey", key, "requestID", req.ID(), "reqByteSize", req.ByteSize())
301+
"flowKey", key, "requestID", req.ID(), "reqByteSize", req.ByteSize(),
302+
"totalLen", stats.TotalLen, "totalCapacityRequests", stats.TotalCapacityRequests,
303+
"totalByteSize", stats.TotalByteSize, "totalCapacityBytes", stats.TotalCapacityBytes)
294304
item.FinalizeWithOutcome(types.QueueOutcomeRejectedNoEndpoints, fmt.Errorf("%w: %w",
295305
types.ErrRejected, types.ErrNoEndpoints))
306+
p.recordDrop(types.QueueOutcomeRejectedNoEndpoints)
296307
return
297308
}
298309
p.logger.V(logutil.DEBUG).Info("Rejecting request, queue at capacity",
@@ -301,6 +312,7 @@ func (p *Processor) enqueue(item *FlowItem) {
301312
"totalByteSize", stats.TotalByteSize, "totalCapacityBytes", stats.TotalCapacityBytes)
302313
item.FinalizeWithOutcome(types.QueueOutcomeRejectedCapacity, fmt.Errorf("%w: %w",
303314
types.ErrRejected, types.ErrQueueAtCapacity))
315+
p.recordDrop(types.QueueOutcomeRejectedCapacity)
304316
return
305317
}
306318

@@ -311,6 +323,7 @@ func (p *Processor) enqueue(item *FlowItem) {
311323
p.logger.Error(finalErr, "Rejecting request, queue add failed",
312324
"flowKey", key, "requestID", req.ID())
313325
item.FinalizeWithOutcome(types.QueueOutcomeRejectedOther, fmt.Errorf("%w: %w", types.ErrRejected, finalErr))
326+
p.recordDrop(types.QueueOutcomeRejectedOther)
314327
return
315328
}
316329
p.logger.V(logutil.TRACE).Info("Item enqueued.",
@@ -446,7 +459,7 @@ func (p *Processor) dispatchItem(itemAcc flowcontrol.QueueItemAccessor) error {
446459
// This happens benignly if the item was already removed by the cleanup sweep loop.
447460
// We log it at a low level for visibility but return nil so the dispatch cycle proceeds.
448461
p.logger.V(logutil.DEBUG).Info("Failed to remove item during dispatch (likely already finalized and swept).",
449-
"flowKey", key, "requestID", req.ID(), "error", err)
462+
"flowKey", key, "requestID", req.ID(), "err", err)
450463
return nil
451464
}
452465

@@ -473,6 +486,7 @@ func (p *Processor) runCleanupSweep(ctx context.Context) {
473486
return
474487
case <-ticker.C():
475488
p.sweepFinalizedItems()
489+
p.flushDropSummary()
476490
}
477491
}
478492
}
@@ -486,6 +500,11 @@ func (p *Processor) sweepFinalizedItems() {
486500
}
487501
removedItems := managedQ.Cleanup(predicate)
488502
if len(removedItems) > 0 {
503+
for _, itemAcc := range removedItems {
504+
if fi, ok := itemAcc.(*FlowItem); ok && fi != nil && fi.FinalState() != nil {
505+
p.recordDrop(fi.FinalState().Outcome)
506+
}
507+
}
489508
logger.V(logutil.TRACE).Info("Swept finalized items and released capacity.",
490509
"count", len(removedItems))
491510
}
@@ -510,13 +529,15 @@ func (p *Processor) shutdown() {
510529
// Finalize buffered items.
511530
item.FinalizeWithOutcome(types.QueueOutcomeRejectedOther,
512531
fmt.Errorf("%w: %w", types.ErrRejected, types.ErrFlowControllerNotRunning))
532+
p.recordDrop(types.QueueOutcomeRejectedOther)
513533
default:
514534
break DrainLoop
515535
}
516536
}
517537
// We do not close enqueueChan because external goroutines (Controller) send on it.
518538
// The channel will be garbage collected when the processor terminates.
519539
p.evictAll()
540+
p.flushDropSummary()
520541
})
521542
}
522543

@@ -539,11 +560,36 @@ func (p *Processor) evictAll() {
539560
// Finalization is idempotent; safe to call even if already finalized externally.
540561
// The per-request log is emitted by EnqueueAndWait when it unblocks.
541562
item.FinalizeWithOutcome(outcome, errShutdown)
563+
p.recordDrop(item.FinalState().Outcome)
542564
}
543565
}
544566
p.processAllQueuesConcurrently("evictAll", processFn)
545567
}
546568

569+
func (p *Processor) recordDrop(outcome types.QueueOutcome) {
570+
if outcome == types.QueueOutcomeDispatched || outcome == types.QueueOutcomeNotYetFinalized {
571+
return
572+
}
573+
p.dropCounts[outcome].Add(1)
574+
}
575+
576+
func (p *Processor) flushDropSummary() {
577+
var total uint64
578+
counts := make(map[string]uint64)
579+
for i := range p.dropCounts {
580+
if c := p.dropCounts[i].Swap(0); c > 0 {
581+
total += c
582+
counts[types.QueueOutcome(i).String()] = c
583+
}
584+
}
585+
if total > 0 {
586+
p.logger.V(logutil.DEFAULT).Info("Flow control request drop summary",
587+
"poolName", p.poolName,
588+
"totalDropped", total,
589+
"counts", counts)
590+
}
591+
}
592+
547593
// processAllQueuesConcurrently iterates over all queues in all priority bands and executes the given
548594
// `processFn` for each queue using a dynamically sized worker pool.
549595
func (p *Processor) processAllQueuesConcurrently(

pkg/epp/flowcontrol/controller/internal/processor_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1191,6 +1191,10 @@ func TestProcessor(t *testing.T) {
11911191
assert.Equal(t, types.QueueOutcomeEvictedContextCancelled, finalState.Outcome,
11921192
"Outcome should be EvictedContextCancelled")
11931193
assert.ErrorIs(t, finalState.Err, types.ErrContextCancelled, "Error should be ErrContextCancelled")
1194+
1195+
// Verify the sweep path recorded the drop.
1196+
assert.Equal(t, uint64(1), h.processor.dropCounts[types.QueueOutcomeEvictedContextCancelled].Load(),
1197+
"Drop should be recorded for EvictedContextCancelled")
11941198
})
11951199

11961200
t.Run("should not sweep items not finalized", func(t *testing.T) {
@@ -1447,3 +1451,124 @@ func TestProcessor(t *testing.T) {
14471451
})
14481452
})
14491453
}
1454+
1455+
// TestProcessor_DropSummary verifies the periodic drop-count accounting introduced in #2101.
1456+
func TestProcessor_DropSummary(t *testing.T) {
1457+
t.Parallel()
1458+
1459+
// Verify that the dropCounts array is large enough to hold every QueueOutcome value.
1460+
// If new outcomes are added in the future, this check will catch them at compile time.
1461+
var _ = [types.NumQueueOutcomes]struct{}{}
1462+
1463+
t.Run("capacity rejection increments counter", func(t *testing.T) {
1464+
t.Parallel()
1465+
h := newTestHarness(t, testCleanupTick)
1466+
h.addQueue(testFlow)
1467+
1468+
// Force capacity-full: band has 1 slot already used, CapacityRequests=1.
1469+
h.StatsFunc = func() contracts.AggregateStats {
1470+
return contracts.AggregateStats{
1471+
TotalCapacityBytes: 1e9,
1472+
PerPriorityBandStats: map[int]contracts.PriorityBandStats{
1473+
testFlow.Priority: {CapacityRequests: 1, Len: 1},
1474+
},
1475+
}
1476+
}
1477+
1478+
item := h.newTestItem("req-cap", testFlow, testTTL)
1479+
h.processor.enqueue(item)
1480+
1481+
outcome, _ := h.waitForFinalization(item)
1482+
require.Equal(t, types.QueueOutcomeRejectedCapacity, outcome)
1483+
1484+
count := h.processor.dropCounts[types.QueueOutcomeRejectedCapacity].Load()
1485+
assert.Equal(t, uint64(1), count, "RejectedCapacity counter should be 1 after one capacity rejection")
1486+
})
1487+
1488+
t.Run("counters reset after flush", func(t *testing.T) {
1489+
t.Parallel()
1490+
h := newTestHarness(t, testCleanupTick)
1491+
h.addQueue(testFlow)
1492+
1493+
// Force capacity-full.
1494+
h.StatsFunc = func() contracts.AggregateStats {
1495+
return contracts.AggregateStats{
1496+
TotalCapacityBytes: 1e9,
1497+
PerPriorityBandStats: map[int]contracts.PriorityBandStats{
1498+
testFlow.Priority: {CapacityRequests: 1, Len: 1},
1499+
},
1500+
}
1501+
}
1502+
1503+
item := h.newTestItem("req-flush", testFlow, testTTL)
1504+
h.processor.enqueue(item)
1505+
h.waitForFinalization(item) //nolint:errcheck
1506+
1507+
require.Equal(t, uint64(1), h.processor.dropCounts[types.QueueOutcomeRejectedCapacity].Load())
1508+
1509+
// Flushing should zero out all counters.
1510+
h.processor.flushDropSummary()
1511+
assert.Equal(t, uint64(0), h.processor.dropCounts[types.QueueOutcomeRejectedCapacity].Load(),
1512+
"Counter should be 0 after flush")
1513+
})
1514+
1515+
t.Run("multiple outcome types are counted independently", func(t *testing.T) {
1516+
t.Parallel()
1517+
h := newTestHarness(t, testCleanupTick)
1518+
h.addQueue(testFlow)
1519+
1520+
// Reject via capacity: band has 1 slot already used, CapacityRequests=1.
1521+
h.StatsFunc = func() contracts.AggregateStats {
1522+
return contracts.AggregateStats{
1523+
TotalCapacityBytes: 1e9,
1524+
PerPriorityBandStats: map[int]contracts.PriorityBandStats{
1525+
testFlow.Priority: {CapacityRequests: 1, Len: 1},
1526+
},
1527+
}
1528+
}
1529+
for i := range 2 {
1530+
item := h.newTestItem(fmt.Sprintf("req-multi-cap-%d", i), testFlow, testTTL)
1531+
h.processor.enqueue(item)
1532+
h.waitForFinalization(item) //nolint:errcheck
1533+
}
1534+
1535+
// Reject via configuration error (RejectedOther): pass capacity, fail priority band lookup.
1536+
h.StatsFunc = func() contracts.AggregateStats {
1537+
return contracts.AggregateStats{
1538+
TotalCapacityBytes: 1e9,
1539+
PerPriorityBandStats: map[int]contracts.PriorityBandStats{
1540+
testFlow.Priority: {CapacityRequests: 100, Len: 0},
1541+
},
1542+
}
1543+
}
1544+
h.PriorityBandAccessorFunc = func(priority int) (flowcontrol.PriorityBandAccessor, error) {
1545+
return nil, errors.New("forced priority band failure")
1546+
}
1547+
item := h.newTestItem("req-multi-other", testFlow, testTTL)
1548+
h.processor.enqueue(item)
1549+
h.waitForFinalization(item) //nolint:errcheck
1550+
1551+
assert.Equal(t, uint64(2), h.processor.dropCounts[types.QueueOutcomeRejectedCapacity].Load(),
1552+
"RejectedCapacity counter should be 2")
1553+
assert.Equal(t, uint64(1), h.processor.dropCounts[types.QueueOutcomeRejectedOther].Load(),
1554+
"RejectedOther counter should be 1")
1555+
})
1556+
1557+
t.Run("shutdown eviction counts unfinalized queued items exactly once", func(t *testing.T) {
1558+
t.Parallel()
1559+
h := newTestHarness(t, testCleanupTick)
1560+
mockQueue := h.addQueue(testFlow)
1561+
1562+
item := h.newTestItem("req-evict-shutdown", testFlow, testTTL)
1563+
require.NoError(t, mockQueue.Add(item))
1564+
require.Nil(t, item.FinalState(), "item must not be finalized before evictAll")
1565+
1566+
// evictAll runs on shutdown to drain all queues.
1567+
h.processor.evictAll()
1568+
1569+
h.waitForFinalization(item) //nolint:errcheck
1570+
1571+
assert.Equal(t, uint64(1), h.processor.dropCounts[types.QueueOutcomeEvictedOther].Load(),
1572+
"evictAll should count the unfinalized queued item exactly once")
1573+
})
1574+
}

pkg/epp/flowcontrol/registry/managedqueue.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,17 @@ func (mq *managedQueue) Cleanup(predicate contracts.PredicateFunc) []flowcontrol
146146
return nil
147147
}
148148
mq.propagateStatsDeltaForRemovedItemsLocked(cleanedItems)
149-
mq.logger.V(logging.DEBUG).Info("Cleaned up queue", "removedItemCount", len(cleanedItems))
149+
if v := mq.logger.V(logging.DEBUG); v.Enabled() {
150+
reqIDs := make([]string, 0, len(cleanedItems))
151+
for _, item := range cleanedItems {
152+
if req := item.OriginalRequest(); req != nil {
153+
reqIDs = append(reqIDs, req.ID())
154+
}
155+
}
156+
v.Info("Cleaned up queue", "removedItemCount", len(cleanedItems), "requestIDs", reqIDs)
157+
} else {
158+
mq.logger.V(logging.DEBUG).Info("Cleaned up queue", "removedItemCount", len(cleanedItems))
159+
}
150160
return cleanedItems
151161
}
152162

@@ -160,7 +170,17 @@ func (mq *managedQueue) Drain() []flowcontrol.QueueItemAccessor {
160170
return nil
161171
}
162172
mq.propagateStatsDeltaForRemovedItemsLocked(drainedItems)
163-
mq.logger.V(logging.DEBUG).Info("Drained queue", "itemCount", len(drainedItems))
173+
if v := mq.logger.V(logging.DEBUG); v.Enabled() {
174+
reqIDs := make([]string, 0, len(drainedItems))
175+
for _, item := range drainedItems {
176+
if req := item.OriginalRequest(); req != nil {
177+
reqIDs = append(reqIDs, req.ID())
178+
}
179+
}
180+
v.Info("Drained queue", "itemCount", len(drainedItems), "requestIDs", reqIDs)
181+
} else {
182+
mq.logger.V(logging.DEBUG).Info("Drained queue", "itemCount", len(drainedItems))
183+
}
164184
return drainedItems
165185
}
166186

pkg/epp/flowcontrol/types/outcomes.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ const (
7171
// The specific underlying cause can be determined from the associated error (e.g., controller shutdown while the item
7272
// was queued), which will be wrapped by `ErrEvicted`.
7373
QueueOutcomeEvictedOther
74+
75+
// NumQueueOutcomes is a sentinel that equals the total number of QueueOutcome values.
76+
// It is not a valid outcome; it exists to size arrays indexed by QueueOutcome and to allow tests to detect
77+
// when new values are added without a corresponding update to dependent code.
78+
NumQueueOutcomes
7479
)
7580

7681
// String returns a human-readable string representation of the QueueOutcome.

0 commit comments

Comments
 (0)