diff --git a/steam_backlog_enforcer/_shadow.py b/steam_backlog_enforcer/_shadow.py index 2d762d9..978d17b 100644 --- a/steam_backlog_enforcer/_shadow.py +++ b/steam_backlog_enforcer/_shadow.py @@ -128,8 +128,12 @@ def _should_warn(exc: BaseException) -> bool: """Whether *exc* was not already logged within :data:`WARN_EVERY_SECONDS`.""" key = f"{type(exc).__name__}: {exc}" now = time.monotonic() - last = _last_warned.get(key) - if last is not None and now - last < WARN_EVERY_SECONDS: + # Forget expired errors first: a message that varies per tick (a JSON + # offset, say) must not grow a long-lived daemon's memory without bound. + expired = [k for k, at in _last_warned.items() if now - at >= WARN_EVERY_SECONDS] + for old in expired: + del _last_warned[old] + if key in _last_warned: return False _last_warned[key] = now return True diff --git a/steam_backlog_enforcer/tests/test_shadow_guard.py b/steam_backlog_enforcer/tests/test_shadow_guard.py index bc3532d..256cb21 100644 --- a/steam_backlog_enforcer/tests/test_shadow_guard.py +++ b/steam_backlog_enforcer/tests/test_shadow_guard.py @@ -129,3 +129,17 @@ def test_tick_completes_and_enforced_count_is_untouched( assert state.seconds == seconds assert state.last_tick_at == T0 assert state.shadow_per_source == {shadow: 3.0} + + +def test_errors_older_than_the_window_are_forgotten() -> None: + """A message that varies per tick must not grow the daemon's memory.""" + window = _shadow.WARN_EVERY_SECONDS + clock = [0.0, 1.0, window + 2.0] + errors = [ValueError(f"char {i}") for i in range(3)] + with ( + patch.object(_shadow, "read_now", side_effect=errors), + patch.object(_shadow.time, "monotonic", side_effect=clock), + ): + for _ in errors: + observe_shadow({}, stamp=T0) + assert list(_shadow._last_warned) == ["ValueError: char 2"]