Files
cleveragents-core/features/steps/fast_init_upgrade_steps.py
T
brent.edwards 5437c73420
CI / lint (push) Has been cancelled
CI / e2e_tests (push) Has been cancelled
CI / typecheck (push) Has been cancelled
CI / build (push) Has been cancelled
CI / integration_tests (push) Has been cancelled
CI / quality (push) Has been cancelled
CI / benchmark-publish (push) Has been cancelled
CI / security (push) Has been cancelled
CI / helm (push) Has been cancelled
CI / unit_tests (push) Has been cancelled
CI / docker (push) Has been cancelled
CI / status-check (push) Has been cancelled
CI / coverage (push) Has been cancelled
CI / benchmark-regression (push) Has been cancelled
perf(test): replace per-instance _database_initialized flag with process-global cache (#1264)
Add a process-global `_INITIALIZED_DBS: set[str]` to `features/environment.py` that caches database URLs after `_fast_init_or_upgrade` has processed them. On subsequent calls with the same URL, the function returns immediately — skipping all URL parsing, path extraction, prefix matching, and `stat()` syscalls that previously executed on every new `UnitOfWork` instance.

The cache is cleared at the start of each scenario in `before_scenario` to prevent cross-scenario state leaks, since each scenario receives fresh temp-DB paths via `tempfile.mktemp()`.

Four new Behave scenarios validate cache hits on repeated calls, cache clearing between scenarios, and non-caching of non-matching-prefix URLs.

Estimated savings: ~65,000 unnecessary function-body executions eliminated per full test run (~7 UnitOfWork instantiations × ~10,700 scenarios).

ISSUES CLOSED: #735

Reviewed-by: Jeffrey Phillips Freeman <jeffrey.freeman@cleverthis.com>
Co-authored-by: Brent E. Edwards <brent.edwards@cleverthis.com>
Co-committed-by: Brent E. Edwards <brent.edwards@cleverthis.com>
2026-04-02 16:58:39 +00:00

385 lines
15 KiB
Python

"""Step definitions for fast_init_upgrade.feature.
Exercises the ``_fast_init_or_upgrade`` closure installed by
``features.environment._install_template_db_patch`` — specifically the
early-return path for non-empty databases, the template-copy path for
new databases, and the fallback delegation to the original
``MigrationRunner.init_or_upgrade``.
"""
from __future__ import annotations
import contextlib
import filecmp
import os
import shutil
import tempfile
import types
from pathlib import Path
from typing import Any
from behave import given, then, when
from behave.runner import Context
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _register_path_cleanup(context: Context, path: str) -> None:
"""Schedule *path* (and its SQLite sidecar files) for removal."""
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
def _remove() -> None:
for suffix in ("", "-journal", "-wal", "-shm"):
with contextlib.suppress(OSError):
os.unlink(path + suffix)
context._cleanup_handlers.append(_remove)
def _register_directory_cleanup(context: Context, path: str) -> None:
"""Schedule directory *path* for recursive removal."""
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
def _remove() -> None:
with contextlib.suppress(OSError):
shutil.rmtree(path)
context._cleanup_handlers.append(_remove)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("the fast-init template-DB patch is installed")
def step_verify_patch_installed(context: Context) -> None:
"""Verify that ``_install_template_db_patch`` has been applied."""
from cleveragents.infrastructure.database.migration_runner import (
MigrationRunner,
)
fast_fn: Any = MigrationRunner.init_or_upgrade
if not isinstance(fast_fn, types.FunctionType):
raise AssertionError(
"MigrationRunner.init_or_upgrade is not a plain function — "
"the _fast_init_or_upgrade patch does not appear to be installed. "
"Ensure CLEVERAGENTS_TEMPLATE_DB is set and the template exists."
)
freevars: tuple[str, ...] = fast_fn.__code__.co_freevars
if "_original_init_or_upgrade" not in freevars:
raise AssertionError(
"MigrationRunner.init_or_upgrade does not capture "
"'_original_init_or_upgrade' — the template-DB patch is not "
"installed as expected."
)
# Store template path for later steps.
template_db: str | None = os.environ.get("CLEVERAGENTS_TEMPLATE_DB")
if not template_db or not Path(template_db).is_file():
raise AssertionError(
"CLEVERAGENTS_TEMPLATE_DB is not set or the file does not exist."
)
context.fast_init_template_path = template_db
# ---------------------------------------------------------------------------
# Given — non-empty DB with matching prefix
# ---------------------------------------------------------------------------
@given("a non-empty SQLite database file with a matching scenario prefix")
def step_create_nonempty_db(context: Context) -> None:
"""Create a temporary non-empty file whose name starts with a matching prefix."""
fd, path = tempfile.mkstemp(suffix=".db", prefix="test_fastinit_")
os.write(fd, b"non-empty-content-to-simulate-migrated-db")
os.close(fd)
context.fast_init_db_path = path
context.fast_init_db_url = f"sqlite:///{path}"
context.fast_init_original_content = Path(path).read_bytes()
_register_path_cleanup(context, path)
# ---------------------------------------------------------------------------
# Given — template-copy path (new DB)
# ---------------------------------------------------------------------------
@given("a non-existent SQLite database path with a matching scenario prefix")
def step_create_nonexistent_db_path(context: Context) -> None:
"""Generate a path for a non-existent file with a matching prefix."""
temp_dir = tempfile.mkdtemp(prefix="test_fastinit_new_")
path = str(Path(temp_dir) / "test_fastinit.db")
context.fast_init_db_path = path
context.fast_init_db_url = f"sqlite:///{path}"
_register_path_cleanup(context, path)
_register_directory_cleanup(context, temp_dir)
@given("an empty SQLite database file with a matching scenario prefix")
def step_create_empty_db(context: Context) -> None:
"""Create an existing zero-byte DB file with a matching prefix."""
fd, path = tempfile.mkstemp(suffix=".db", prefix="test_fastinit_empty_")
os.close(fd)
context.fast_init_db_path = path
context.fast_init_db_url = f"sqlite:///{path}"
_register_path_cleanup(context, path)
# ---------------------------------------------------------------------------
# Given — fallback paths (non-matching / in-memory / non-SQLite)
# ---------------------------------------------------------------------------
@given("a SQLite database URL whose filename has a non-matching prefix")
def step_create_nonmatching_prefix_url(context: Context) -> None:
"""Create a URL whose filename prefix is not in _SCENARIO_DB_PREFIXES."""
fd, path = tempfile.mkstemp(suffix=".db", prefix="nomatch_fastinit_")
os.close(fd)
context.fast_init_db_path = path
context.fast_init_db_url = f"sqlite:///{path}"
_register_path_cleanup(context, path)
@given("an in-memory SQLite database URL for fast-init testing")
def step_create_inmemory_url(context: Context) -> None:
"""Set up an in-memory SQLite URL."""
context.fast_init_db_path = None
context.fast_init_db_url = "sqlite:///:memory:"
@given("a non-SQLite database URL for fast-init testing")
def step_create_nonsqlite_url(context: Context) -> None:
"""Set up a non-SQLite URL (PostgreSQL placeholder)."""
context.fast_init_db_path = None
context.fast_init_db_url = "postgresql://fake_user:fake_pass@localhost/testdb"
@given("a bare sqlite:// database URL for fast-init testing")
def step_create_bare_sqlite_url(context: Context) -> None:
"""Set up a bare ``sqlite://`` URL (no path, no ``:memory:``)."""
context.fast_init_db_path = None
context.fast_init_db_url = "sqlite://"
# ---------------------------------------------------------------------------
# When
# ---------------------------------------------------------------------------
@when("I call init_or_upgrade on the fast-init database")
def step_call_init_or_upgrade(context: Context) -> None:
"""Invoke ``MigrationRunner.init_or_upgrade`` via the fast-init patch."""
from cleveragents.infrastructure.database.migration_runner import (
MigrationRunner,
)
from features.mocks.fast_init_test_helpers import (
patch_original_init_or_upgrade,
)
runner = MigrationRunner(database_url=context.fast_init_db_url)
with patch_original_init_or_upgrade() as mock_original:
runner.init_or_upgrade()
context.fast_init_mock_called = mock_original.called
context.fast_init_mock_call_count = mock_original.call_count
context.fast_init_mock_call_args = mock_original.call_args
context.fast_init_runner = runner
@when("I call init_or_upgrade with keyword arguments on the fast-init database")
def step_call_init_or_upgrade_with_kwargs(context: Context) -> None:
"""Invoke ``init_or_upgrade`` with keyword arguments to verify forwarding."""
from cleveragents.infrastructure.database.migration_runner import (
MigrationRunner,
)
from features.mocks.fast_init_test_helpers import (
patch_original_init_or_upgrade,
)
runner = MigrationRunner(database_url=context.fast_init_db_url)
with patch_original_init_or_upgrade() as mock_original:
runner.init_or_upgrade(require_confirmation=True)
context.fast_init_mock_called = mock_original.called
context.fast_init_mock_call_count = mock_original.call_count
context.fast_init_mock_call_args = mock_original.call_args
context.fast_init_runner = runner
# ---------------------------------------------------------------------------
# Then — original invocation assertions
# ---------------------------------------------------------------------------
@then("the original init_or_upgrade should not have been invoked")
def step_assert_original_not_called(context: Context) -> None:
"""Assert the mocked original was never called."""
assert not context.fast_init_mock_called, (
"Expected _original_init_or_upgrade NOT to be called, "
f"but it was called {context.fast_init_mock_call_count} time(s)."
)
@then("the original init_or_upgrade should have been invoked exactly once")
def step_assert_original_called_once(context: Context) -> None:
"""Assert the mocked original was called exactly once."""
assert context.fast_init_mock_call_count == 1, (
"Expected _original_init_or_upgrade to be called exactly once, "
f"but it was called {context.fast_init_mock_call_count} time(s)."
)
@then("the original should have received the runner instance and keyword arguments")
def step_assert_original_received_runner_and_kwargs(context: Context) -> None:
"""Assert the mock was called with the correct runner and kwargs."""
call_args = context.fast_init_mock_call_args
assert call_args is not None, (
"Expected _original_init_or_upgrade to have been called, but call_args is None."
)
positional = call_args.args
assert len(positional) >= 1, (
"Expected _original_init_or_upgrade to receive the runner as "
f"first positional argument, but got {len(positional)} positional args."
)
assert positional[0] is context.fast_init_runner, (
"Expected the first positional argument to be the MigrationRunner "
"instance, but a different object was passed."
)
assert call_args.kwargs.get("require_confirmation") is True, (
"Expected keyword argument 'require_confirmation=True' to be "
f"forwarded, but got kwargs={call_args.kwargs}."
)
# ---------------------------------------------------------------------------
# Then — file-content assertions
# ---------------------------------------------------------------------------
@then("the database file content should be unchanged")
def step_assert_file_unchanged(context: Context) -> None:
"""Assert the DB file still has its original content."""
current_content = Path(context.fast_init_db_path).read_bytes()
assert current_content == context.fast_init_original_content, (
"Database file content changed unexpectedly."
)
@then("the database file should be a copy of the template")
def step_assert_file_is_template_copy(context: Context) -> None:
"""Assert the DB file was created as a copy of the template."""
db_path = Path(context.fast_init_db_path)
assert db_path.exists(), f"Database file was not created at {db_path}"
assert db_path.stat().st_size > 0, "Database file is empty"
assert filecmp.cmp(
str(db_path),
context.fast_init_template_path,
shallow=False,
), "Database file does not match the template"
# ---------------------------------------------------------------------------
# Steps — process-global _INITIALIZED_DBS cache (issue #735)
# ---------------------------------------------------------------------------
def _get_initialized_dbs() -> set[str]:
"""Return the ``_INITIALIZED_DBS`` set used by the ``_fast_init_or_upgrade`` closure.
Behave's ``Runner`` loads environment.py via ``exec_file()`` into a
private hooks dict — **not** via Python's import system. This means
module-level variables defined in environment.py exist in the exec'd
globals, which is a *separate* namespace from
``features.environment.__dict__``. To access the same set that the
closure actually mutates at runtime, we read it from the closure
function's ``__globals__`` dict.
"""
from cleveragents.infrastructure.database.migration_runner import (
MigrationRunner,
)
fast_fn: Any = MigrationRunner.init_or_upgrade
if not isinstance(fast_fn, types.FunctionType):
raise RuntimeError(
"MigrationRunner.init_or_upgrade is not a plain function — "
"the _fast_init_or_upgrade patch does not appear to be installed."
)
cache: set[str] = fast_fn.__globals__.get("_INITIALIZED_DBS", set())
return cache
@given("the initialized-DB cache is cleared")
def step_clear_initialized_db_cache(context: Context) -> None:
"""Explicitly clear the global cache before exercising the fast-path."""
_get_initialized_dbs().clear()
@when("I call init_or_upgrade on the fast-init database a second time")
def step_call_init_or_upgrade_second_time(context: Context) -> None:
"""Invoke init_or_upgrade a second time for the same URL.
The second call should be a cache hit — ``_fast_init_or_upgrade``
returns immediately without executing any of its body. We record
whether the mock original was invoked during this second call.
"""
from cleveragents.infrastructure.database.migration_runner import (
MigrationRunner,
)
from features.mocks.fast_init_test_helpers import (
patch_original_init_or_upgrade,
)
runner = MigrationRunner(database_url=context.fast_init_db_url)
with patch_original_init_or_upgrade() as mock_original:
runner.init_or_upgrade()
context.fast_init_second_call_mock_called = mock_original.called
context.fast_init_second_call_mock_count = mock_original.call_count
@then("the second init_or_upgrade call should have been a cache hit")
def step_assert_second_call_cache_hit(context: Context) -> None:
"""Assert the second call was short-circuited by the global cache.
A cache hit means the function returned immediately — neither the
original ``init_or_upgrade`` nor any of the URL-parsing / stat logic
was executed.
"""
assert not context.fast_init_second_call_mock_called, (
"Expected second init_or_upgrade call to be a cache hit "
"(no delegation to original), but the original was invoked "
f"{context.fast_init_second_call_mock_count} time(s)."
)
@then("the URL should be present in the initialized-DB cache")
def step_assert_url_in_cache(context: Context) -> None:
"""Assert the fast-init database URL was added to _INITIALIZED_DBS."""
cache = _get_initialized_dbs()
assert context.fast_init_db_url in cache, (
f"Expected URL {context.fast_init_db_url!r} to be present in "
f"_INITIALIZED_DBS, but the cache contains: {cache}"
)
@when("the initialized-DB cache is cleared for a new scenario")
def step_simulate_scenario_boundary_cache_clear(context: Context) -> None:
"""Simulate the cache clearing that happens in before_scenario."""
_get_initialized_dbs().clear()
@then("the URL should not be present in the initialized-DB cache")
def step_assert_url_not_in_cache(context: Context) -> None:
"""Assert the fast-init database URL is not in _INITIALIZED_DBS."""
cache = _get_initialized_dbs()
assert context.fast_init_db_url not in cache, (
f"Expected URL {context.fast_init_db_url!r} NOT to be in "
f"_INITIALIZED_DBS, but it was found. Cache: {cache}"
)