Skip to content

Commit baacda1

Browse files
committed
perf: keep running totals in the rate-mode circuit breaker window
1 parent 9336c45 commit baacda1

2 files changed

Lines changed: 88 additions & 28 deletions

File tree

‎src/httpware/middleware/resilience/circuit_breaker.py‎

Lines changed: 35 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -114,51 +114,58 @@ class CircuitState(enum.Enum):
114114
class _RollingWindow:
115115
"""Time-bucketed success/failure counters over a rolling window.
116116
117-
`window_seconds` is split into `_BUCKET_COUNT` buckets. Each bucket holds
118-
[successes, failures] tagged with the integer time-slot it represents; a
119-
bucket whose slot is stale is reset on write, and `totals` filters to the
120-
live slot range so data older than the window never counts. Every method is
121-
synchronous and reads `now` from its caller (so the breaker's critical
122-
section owns the clock read).
117+
`window_seconds` is split into `_BUCKET_COUNT` buckets, one per integer time-slot; the
118+
window is the current slot and the `_BUCKET_COUNT - 1` before it. Running totals are
119+
kept beside the buckets, so recording and reading are constant-time. Every method is
120+
synchronous and reads `now` from its caller (so the breaker's critical section owns
121+
the clock read).
123122
"""
124123

125124
def __init__(self, window_seconds: float) -> None:
126125
self._bucket_width = window_seconds / _BUCKET_COUNT
127-
self._slot = [-1] * _BUCKET_COUNT
126+
self._slot: float = -math.inf
127+
self._index = 0
128128
self._success = [0] * _BUCKET_COUNT
129129
self._failure = [0] * _BUCKET_COUNT
130-
131-
def _current_slot(self, now: float) -> int:
132-
return int(now // self._bucket_width)
130+
self._total = 0
131+
self._failures = 0
132+
133+
def _advance(self, now: float) -> None:
134+
slot = now // self._bucket_width
135+
if slot <= self._slot:
136+
return
137+
current = int(slot)
138+
if current - self._slot >= _BUCKET_COUNT:
139+
self.clear()
140+
else:
141+
for stale in range(int(self._slot) + 1, current + 1):
142+
index = stale % _BUCKET_COUNT
143+
self._total -= self._success[index] + self._failure[index]
144+
self._failures -= self._failure[index]
145+
self._success[index] = 0
146+
self._failure[index] = 0
147+
self._slot = current
148+
self._index = current % _BUCKET_COUNT
133149

134150
def record(self, now: float, *, failed: bool) -> None:
135-
slot = self._current_slot(now)
136-
index = slot % _BUCKET_COUNT
137-
if self._slot[index] != slot: # bucket reused for a new slot — evict
138-
self._slot[index] = slot
139-
self._success[index] = 0
140-
self._failure[index] = 0
151+
self._advance(now)
141152
if failed:
142-
self._failure[index] += 1
153+
self._failure[self._index] += 1
154+
self._failures += 1
143155
else:
144-
self._success[index] += 1
156+
self._success[self._index] += 1
157+
self._total += 1
145158

146159
def totals(self, now: float) -> tuple[int, int]:
147160
"""Return (total, failures) across buckets still inside the window at `now`."""
148-
slot = self._current_slot(now)
149-
oldest = slot - _BUCKET_COUNT + 1
150-
total = 0
151-
failures = 0
152-
for i in range(_BUCKET_COUNT):
153-
if oldest <= self._slot[i] <= slot:
154-
total += self._success[i] + self._failure[i]
155-
failures += self._failure[i]
156-
return total, failures
161+
self._advance(now)
162+
return self._total, self._failures
157163

158164
def clear(self) -> None:
159-
self._slot = [-1] * _BUCKET_COUNT
160165
self._success = [0] * _BUCKET_COUNT
161166
self._failure = [0] * _BUCKET_COUNT
167+
self._total = 0
168+
self._failures = 0
162169

163170

164171
_Event = tuple[str, int, str, dict[str, typing.Any]]

‎tests/test_rolling_window.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,56 @@ def test_totals_match_live_events(events: list[tuple[float, bool]]) -> None:
6868
assert total == len(expected_live)
6969
assert failures == sum(1 for _, f in expected_live if f)
7070
assert 0 <= failures <= total
71+
72+
73+
def test_partial_advance_evicts_only_expired_buckets() -> None:
74+
w = _RollingWindow(window_seconds=10.0)
75+
w.record(0.0, failed=True)
76+
w.record(4.0, failed=False)
77+
w.record(5.0, failed=True)
78+
assert w.totals(10.0) == (2, 1)
79+
assert w.totals(14.0) == (1, 1)
80+
assert w.totals(15.0) == (0, 0)
81+
82+
83+
def test_earlier_timestamp_counts_in_the_current_bucket() -> None:
84+
w = _RollingWindow(window_seconds=10.0)
85+
w.record(5.0, failed=False)
86+
w.record(4.0, failed=True)
87+
assert w.totals(14.5) == (2, 1)
88+
assert w.totals(15.0) == (0, 0)
89+
90+
91+
_EDGE = 1e-9
92+
93+
94+
@given(
95+
window_seconds=st.floats(min_value=0.1, max_value=120.0, allow_nan=False, allow_infinity=False),
96+
start=st.floats(min_value=0.0, max_value=1e6, allow_nan=False, allow_infinity=False),
97+
steps=st.lists(
98+
st.tuples(st.floats(min_value=0.0, max_value=0.5, allow_nan=False, allow_infinity=False), st.booleans()),
99+
min_size=1,
100+
max_size=300,
101+
),
102+
)
103+
@settings(max_examples=200, deadline=None)
104+
def test_totals_within_one_bucket_of_timestamp_reference(
105+
window_seconds: float,
106+
start: float,
107+
steps: list[tuple[float, bool]],
108+
) -> None:
109+
"""An outcome counts until somewhere between 0.9 and 1.0 window lengths after it was recorded."""
110+
w = _RollingWindow(window_seconds=window_seconds)
111+
shortest = 0.9 * window_seconds * (1 - _EDGE)
112+
longest = window_seconds * (1 + _EDGE)
113+
now = start
114+
events: list[tuple[float, bool]] = []
115+
for step, failed in steps:
116+
now += step * window_seconds
117+
w.record(now, failed=failed)
118+
events.append((now, failed))
119+
total, failures = w.totals(now)
120+
assert sum(1 for t, _ in events if now - t <= shortest) <= total
121+
assert total <= sum(1 for t, _ in events if now - t <= longest)
122+
assert sum(1 for t, f in events if f and now - t <= shortest) <= failures
123+
assert failures <= sum(1 for t, f in events if f and now - t <= longest)

0 commit comments

Comments
 (0)