Skip to content

Commit 53adea3

Browse files
fix: bound ErrorBudget._events with deque(maxlen=N) (#172)
1 parent 4f97e61 commit 53adea3

File tree

2 files changed

+50
-1
lines changed

2 files changed

+50
-1
lines changed

packages/agent-sre/src/agent_sre/slo/objectives.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ class ErrorBudget:
4646
4747
Error Budget = 1 - SLO target
4848
Burn Rate = actual error rate / allowed error rate
49+
50+
Events are stored in a bounded ``deque(maxlen=max_events)`` to
51+
prevent unbounded memory growth in long-running SLOs. When the
52+
buffer is full, the oldest events are silently evicted on append.
4953
"""
5054

5155
total: float = 0.0 # Set from SLO target
@@ -58,6 +62,11 @@ class ErrorBudget:
5862
_events: collections.deque = field(default_factory=lambda: collections.deque(maxlen=100_000))
5963
_monotonic_offset: float = field(default_factory=lambda: time.time() - time.monotonic())
6064

65+
def __post_init__(self) -> None:
66+
"""Ensure _events is a bounded deque with the correct maxlen."""
67+
if not isinstance(self._events, collections.deque) or self._events.maxlen != self.max_events:
68+
self._events = collections.deque(self._events, maxlen=self.max_events)
69+
6170
@property
6271
def remaining(self) -> float:
6372
"""Remaining error budget as a fraction (0.0 to 1.0)."""
@@ -76,11 +85,24 @@ def is_exhausted(self) -> bool:
7685
return self.consumed >= self.total
7786

7887
def record_event(self, good: bool) -> None:
79-
"""Record a good or bad event against the budget."""
88+
"""Record a good or bad event against the budget.
89+
90+
Events are stored in a bounded deque. When ``max_events`` is
91+
reached, the oldest event is silently evicted.
92+
"""
8093
if not good:
8194
self.consumed += 1.0
8295
self._events.append({"good": good, "timestamp": time.monotonic()})
8396

97+
def clear_events(self) -> None:
98+
"""Clear all recorded events (does **not** reset ``consumed``)."""
99+
self._events.clear()
100+
101+
@property
102+
def event_count(self) -> int:
103+
"""Number of events currently in the buffer."""
104+
return len(self._events)
105+
84106
def burn_rate(self, window_seconds: int | None = None) -> float:
85107
"""Calculate current burn rate within a time window.
86108

packages/agent-sre/tests/unit/test_objectives.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,33 @@ def test_alerts(self) -> None:
5757
assert "burn_rate_warning" in names
5858
assert "burn_rate_critical" in names
5959

60+
def test_events_bounded_by_max_events(self) -> None:
61+
"""Events beyond max_events are silently evicted (oldest first)."""
62+
budget = ErrorBudget(total=10.0, max_events=100)
63+
for _ in range(200):
64+
budget.record_event(good=True)
65+
assert budget.event_count == 100 # capped at maxlen
66+
67+
def test_custom_max_events(self) -> None:
68+
"""Custom max_events parameter is respected."""
69+
budget = ErrorBudget(total=10.0, max_events=5)
70+
for i in range(10):
71+
budget.record_event(good=(i % 2 == 0))
72+
assert budget.event_count == 5
73+
# The deque should contain only the last 5 events
74+
assert budget._events.maxlen == 5
75+
76+
def test_clear_events(self) -> None:
77+
"""clear_events() empties the deque but does not reset consumed."""
78+
budget = ErrorBudget(total=10.0)
79+
for _ in range(5):
80+
budget.record_event(good=False)
81+
assert budget.consumed == 5.0
82+
assert budget.event_count == 5
83+
budget.clear_events()
84+
assert budget.event_count == 0
85+
assert budget.consumed == 5.0 # consumed is NOT reset
86+
6087

6188
class TestSLO:
6289
def test_creation(self) -> None:

0 commit comments

Comments
 (0)