fix(di): get_container() permanently caches failed audit subscriber in Singleton provider #1223

Merged
freemo merged 1 commits from bugfix/m7-di-audit-cache-failure into master 2026-04-02 16:51:29 +00:00
4 changed files with 184 additions and 3 deletions
@@ -0,0 +1,75 @@
"""ASV benchmarks for audit subscriber retry in get_container().
Measures the performance impact of the retry mechanism introduced by
bug #992: ``get_container()`` now retries ``audit_event_subscriber()``
on every call until it succeeds instead of permanently caching the
failure.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
try:
from cleveragents.application import container as container_module
from cleveragents.application.container import get_container, reset_container
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.application import container as container_module
from cleveragents.application.container import get_container, reset_container
class TimeGetContainerWithSuccessfulAudit:
"""Benchmark get_container() when audit subscriber succeeds immediately."""
timeout = 60
def setup(self) -> None:
os.environ["CLEVERAGENTS_DATABASE_URL"] = "sqlite:///:memory:"
reset_container()
def time_get_container_first_call(self) -> None:
reset_container()
get_container()
def time_get_container_cached(self) -> None:
get_container()
def teardown(self) -> None:
reset_container()
os.environ.pop("CLEVERAGENTS_DATABASE_URL", None)
class TimeGetContainerRetryAfterFailure:
"""Benchmark get_container() retry path after audit init failure."""
timeout = 60
def setup(self) -> None:
os.environ["CLEVERAGENTS_DATABASE_URL"] = "sqlite:///:memory:"
reset_container()
class _FailOnceContainer:
def __init__(self) -> None:
self._attempts = 0
def audit_event_subscriber(self) -> object:
self._attempts += 1
if self._attempts == 1:
raise RuntimeError("database not ready")
return object()
self._original_cls = container_module.Container
container_module.Container = _FailOnceContainer # type: ignore[assignment]
def time_retry_after_failure(self) -> None:
reset_container()
get_container() # first call — audit fails
get_container() # second call — audit retried and succeeds
def teardown(self) -> None:
container_module.Container = self._original_cls # type: ignore[assignment]
reset_container()
os.environ.pop("CLEVERAGENTS_DATABASE_URL", None)
@@ -0,0 +1,71 @@
"""Step definitions for tdd_di_audit_cache_failure.feature.
This test captures bug #992: ``get_container()`` cached a container even when
eager audit subscriber initialization failed during startup, and it did
not retry on subsequent ``get_container()`` calls.
Originally tagged ``@tdd_expected_fail`` so the expected assertion failure
(which proved the bug existed) was inverted to a CI pass until the fix for
#992 landed. After the fix, ``@tdd_expected_fail`` was removed and the test
now runs as a normal regression guard.
"""
from __future__ import annotations
from typing import Any
from behave import given, then, when
from behave.runner import Context
@given("g992- a container whose audit subscriber init fails once then succeeds")
def step_given_container_with_transient_audit_init_failure(context: Context) -> None:
"""Patch container factory so audit subscriber fails once, then succeeds."""
from cleveragents.application import container as container_module
class FakeContainer:
"""Minimal fake container to track audit subscriber initialization calls."""
def __init__(self) -> None:
self.audit_init_attempts: int = 0
def audit_event_subscriber(self) -> object:
"""Fail first call (simulating DB not ready), then succeed."""
self.audit_init_attempts += 1
if self.audit_init_attempts == 1:
raise RuntimeError("database not ready yet")
return object()
context.g992_original_container_class = container_module.Container
container_module.Container = FakeContainer # type: ignore[assignment]
def _cleanup() -> None:
container_module.Container = context.g992_original_container_class # type: ignore[assignment]
container_module.reset_container()
context.add_cleanup(_cleanup)
container_module.reset_container()
@when("g992- I call get_container twice")
def step_when_call_get_container_twice(context: Context) -> None:
"""Call get_container twice and keep references for assertions."""
from cleveragents.application.container import get_container
context.g992_first_container = get_container()
context.g992_second_container = get_container()
@then("g992- audit subscriber init should have been attempted twice")
def step_then_audit_subscriber_should_be_retried(context: Context) -> None:
"""Expect retry on second access after first initialization failure."""
first: Any = context.g992_first_container
second: Any = context.g992_second_container
assert first is second, "Expected get_container() to return a singleton instance"
attempts: int = getattr(first, "audit_init_attempts", 0)
assert attempts == 2, (
"Bug #992: get_container() did not retry audit subscriber initialization "
"after the first failure. Expected 2 attempts after two get_container() "
f"calls, but observed {attempts}."
)
@@ -0,0 +1,20 @@
@tdd_issue @tdd_issue_992
Feature: TDD Bug #992 — get_container() caches failed audit subscriber initialization
As a long-lived process using the shared DI container
I want get_container() to retry audit subscriber initialization after an initial failure
So that audit subscriptions are eventually registered once prerequisites become available
This feature captures bug #992. Previously, get_container() eagerly called
audit_event_subscriber() only during first container creation. If that first call
failed, the exception was logged and swallowed, but the global _container remained set.
Subsequent get_container() calls returned the cached container without retrying the
subscriber initialization.
Originally tagged @tdd_expected_fail while the bug was unfixed (see TDD issue
#1096). The @tdd_expected_fail tag was removed when the fix for #992 was
implemented so this now runs as a normal regression test.
Scenario: Bug #992 — get_container retries audit subscriber initialization after first failure
Given g992- a container whose audit subscriber init fails once then succeeds
When g992- I call get_container twice
Then g992- audit subscriber init should have been attempted twice
+18 -3
View File
@@ -828,6 +828,7 @@ class Container(containers.DeclarativeContainer):
# Global container instance
_container: Container | None = None
_audit_subscriber_initialized: bool = False
def get_container() -> Container:
@@ -837,10 +838,22 @@ def get_container() -> Container:
Eagerly instantiates the :class:`AuditEventSubscriber` singleton so
that EventBus subscriptions are registered at startup and
security-relevant events are automatically routed to the audit log.
If audit subscriber initialization fails (e.g., database not yet
initialized), it is retried on every subsequent call until it
succeeds. This ensures that long-lived server processes eventually
register audit subscriptions once prerequisites become available
(bug #992).
"""
global _container
global _container, _audit_subscriber_initialized
if _container is None:
_container = Container()
# Retry audit subscriber initialization on every call until it
# succeeds. Previously, a failed attempt during the first
# get_container() call was never retried, leaving audit
# subscriptions permanently unregistered in long-lived processes
# (bug #992).
if not _audit_subscriber_initialized:
# Don't initialize database here - let services handle it
# Eagerly wire the audit subscriber so EventBus subscriptions
# are active from startup (lazy singletons are only created on
@@ -848,11 +861,12 @@ def get_container() -> Container:
# register its handlers).
try:
_container.audit_event_subscriber()
_audit_subscriber_initialized = True
except Exception as exc:
_logger.warning(
"audit_subscriber_deferred",
reason="Database not yet initialised; audit subscriptions "
"will be registered on first AuditEventSubscriber access.",
"will be retried on next get_container() call.",
error_type=type(exc).__name__,
error_message=redact_value(str(exc)),
)
@@ -864,8 +878,9 @@ def reset_container() -> None:
Useful for testing to ensure clean state between tests.
"""
global _container
global _container, _audit_subscriber_initialized
_container = None
_audit_subscriber_initialized = False
def override_providers(**overrides: object) -> None: