Skip to content

perf: count RetryBudget and the rate-mode window in time buckets - #181

Merged
lesnik512 merged 2 commits into
mainfrom
perf/bucketed-counters
Oct 17, 2026
Merged

lesnik512 merged 2 commits into
mainfrom
perf/bucketed-counters

Conversation

@lesnik512

Copy link
Copy Markdown
Member

What changed

RetryBudget used to keep one timestamp per deposit and per withdrawal in two deques, and trimmed them on every call. It now keeps counters in ten buckets of ttl / 10, plus running totals. A call moves the current bucket forward if time has moved on, then bumps one bucket and one total. The lock is held for a fixed amount of work no matter how much traffic the budget has seen.

The circuit breaker's rate-mode window (_RollingWindow) was already split into ten buckets, but it summed all ten on every recorded outcome. It now keeps running totals too, so recording an outcome and reading the rate are both constant-time. Its window semantics are unchanged, and nothing outside the class was touched.

The constructor parameters, their defaults, the 0.22.0 validation and the injectable clock are all unchanged.

Why

At 50k requests per second with the default 10 s ttl, the budget held about 500k timestamps (17 MB). After a quiet spell longer than ttl, the next call had to drop all of them while holding the lock, and every thread and event loop sharing the budget waited for it. Bucketed counters need about 1.3 KB at any traffic level, and the worst call does at most ten bucket resets.

Measurements

Simulated 50k rps over a 10 s window with the injectable clock, CPython 3.14.7 on an Apple Silicon laptop. Per-operation numbers are best-of-7 timeit with the clock-advance cost subtracted. The machine was busy with other work, so the ranges come from four alternating before/after runs.

before (main) after
RetryBudget.deposit 556 to 690 ns 500 to 600 ns
RetryBudget.try_withdraw 699 to 858 ns 450 to 806 ns
rate-mode outcome (_CircuitBreakerState.record) 3040 to 3310 ns 1020 to 1310 ns
rate-mode check (_RollingWindow.totals) 2130 to 2250 ns 270 to 325 ns
RetryBudget memory after 10 s at 50k rps 16.95 MB 1.3 KB
first RetryBudget call after an idle gap longer than ttl 26 to 70 ms 0.01 to 0.03 ms

The rate-mode window's memory was already constant and stays so. The slowest single steady-state call was in the millisecond range both before and after, which is scheduler preemption on a loaded machine, so those numbers are left out of the table.

Behaviour change for 0.22.0

RetryBudget now counts in steps of ttl / 10. A deposit or withdrawal stops counting between 0.9 * ttl and ttl after it was made, instead of exactly ttl after. With the default ttl=10.0, a request can drop out of the budget up to one second early. Nothing is ever counted for longer than ttl.

The rate-mode circuit breaker already moved its window in steps of window_seconds / 10, so its behaviour does not change. Both are now stated in docs/resilience.md.

Tests

  • New unit tests pin the bucket edge for the budget: a deposit at 0.5 s with ttl=10 stops counting at 10.0 s, withdrawals expire with their bucket, and an expired bucket releases only its own counts. Two of them fail on main on purpose, because they describe the new edge.
  • A new Hypothesis property drives random deposits, withdrawals and clock steps against a reference that keeps every timestamp. Each try_withdraw() answer must be one the reference allows when events expire anywhere between 0.9 * ttl and ttl. A similar property checks _RollingWindow.totals against a timestamp reference. Both fail when the bucket width or the eviction is broken.
  • A new stress test rotates buckets under 16 threads with a 10 ms ttl and checks that the running totals still equal the bucket sums. It fails within one run on free-threaded 3.14t if the lock is removed.
  • Tests that read the old deques (len(budget._deposits), len(budget._withdrawn)) now read the counters. No existing test about expiry timing had to change: they all step the clock by more than a bucket.

The window now moves in steps of ttl / 10, so a deposit or withdrawal stops counting between 0.9 * ttl and ttl after it was made. Memory stays constant and no call has to drop a backlog of timestamps under the lock.
@lesnik512
lesnik512 merged commit 96e9a02 into main Oct 17, 2026
13 checks passed
@lesnik512
lesnik512 deleted the perf/bucketed-counters branch October 17, 2026 09:54
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.

1 participant