fix(di): get_container() permanently caches failed audit subscriber in Singleton provider #1223
@@ -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
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user