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
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>
385 lines
15 KiB
Python
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}"
|
|
)
|