QA review of the 036-041 closure range returned FAIL with 3 criticals, all confirmed. Fixes: C1 - breaker dead: on_result=persist_batch is now wired into RunnerPool (breaker.record() fed per result); added test_breaker_abort_persists_partials proving CIRCUIT_BREAKER_ABORT reachability + partial persistence. C2 - index-based result mapping corrupted data under concurrency: results now map by execution_id to their source item; test uses two distinct payloads and asserts chart->digest pairing (previously masked by identical fixtures). C3 - double-acquire of the shared client semaphore (deadlock invariant): RunnerPool no longer manually acquires the client semaphore; capacity is enforced by worker count, the client bounds total concurrency. C4 - duplicated ScenarioGraph.Vlm.Analyze region: outer region renamed ScenarioGraph.Vlm [TYPE Module]. H1 - _default_submit stub removed: analyze_screenshot requires submit=; no silent empty-findings fallback. M1 - test_capture_dispatch.py region closed. M3 - capture.py raw_sha256 bypass removed: digest always derived from real capture_bytes (no caller-supplied hash). Verification: load_testing (77) + scenario (103) = 180 passed; ruff clean; all region pairs balanced.
224 lines
11 KiB
Python
224 lines
11 KiB
Python
# #region Test.LoadTesting.ExecutorRuntime [C:3] [TYPE Module] [SEMANTICS test,load-testing,executor,runtime,closure]
|
|
# @BRIEF 040 MVP runtime closure (T075-T079): build_execution_items, executor adapter contract, and
|
|
# run_load_run real execution that persists LoadExecution rows and honours breaker abort.
|
|
# @RELATION BINDS_TO -> [LoadTesting.Executor]
|
|
# @RELATION BINDS_TO -> [Plugin.LoadTesting.RunLoadRun]
|
|
# @TEST_INVARIANT Executor.MustUse037Client -> VERIFIED_BY: test_executor_builds_items, test_run_real_execution_persists
|
|
# @TEST_EDGE: missing_environment -> executor raises LookupError
|
|
# @TEST_EDGE: missing_chart_and_dataset -> executor raises ValueError
|
|
# @TEST_EDGE: breaker_abort -> run reaches circuit_breaker_abort with partials preserved
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import pytest
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from sqlalchemy import create_engine, event
|
|
from sqlalchemy.orm import Session
|
|
|
|
from src.models.load_testing import LoadExecution, LoadRun, RunPhase, RunStatus
|
|
from src.models.mapping import Base, Environment
|
|
from src.services.load_testing.executor import build_execution_items
|
|
|
|
_ENV = "env-lt-exec"
|
|
|
|
|
|
@pytest.fixture()
|
|
def db():
|
|
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
|
event.listen(engine, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON"))
|
|
Base.metadata.create_all(bind=engine)
|
|
session = Session(bind=engine)
|
|
session.add(Environment(id=_ENV, name="lt-exec", url="https://s.example.com", credentials_id="c1"))
|
|
session.commit()
|
|
yield session
|
|
session.close()
|
|
engine.dispose()
|
|
|
|
|
|
# #region Test.LoadTesting.ExecutorRuntime.BuildItems [C:2] [TYPE Class]
|
|
# @ingroup Test
|
|
class TestBuildExecutionItems:
|
|
def test_cartesian_chart_by_variation_with_stable_ids(self) -> None:
|
|
run = LoadRun(id="r-build", environment_id=_ENV, dashboard_id=400, effective_concurrency=2)
|
|
charts = [{"chart_id": 11, "result_key": "count"}, {"chart_id": 12, "result_key": "sum"}]
|
|
variations = [{"variation_id": "v1", "coordinates": {"filters": [{"column": "x"}]}},
|
|
{"variation_id": "v2", "coordinates": {"filters": [{"column": "y"}]}}]
|
|
items = build_execution_items(run=run, charts=charts, variations=variations)
|
|
assert len(items) == 4
|
|
ids = [it["execution_id"] for it in items]
|
|
assert len(set(ids)) == 4
|
|
for it in items:
|
|
assert it["run_id"] == "r-build"
|
|
assert it["environment_id"] == _ENV
|
|
assert it["filters_hash"]
|
|
# Deterministic: same inputs -> same ids
|
|
again = build_execution_items(run=run, charts=charts, variations=variations)
|
|
assert [it["execution_id"] for it in again] == ids
|
|
# #endregion Test.LoadTesting.ExecutorRuntime.BuildItems
|
|
|
|
|
|
# #region Test.LoadTesting.ExecutorRuntime.Executor [C:2] [TYPE Class]
|
|
# @ingroup Test
|
|
class TestExecutor:
|
|
async def test_executor_delegates_to_037_envelope_and_returns_raw_bytes(self) -> None:
|
|
from src.services.load_testing.executor import execute_superset_chart
|
|
|
|
envelope = AsyncMock()
|
|
envelope.raw_response_content = b'{"result": [{"is_cached": null, "cache_timeout": 300}]}'
|
|
envelope.source_response_hash = "abc123"
|
|
|
|
async def fake_envelope(_client, _request):
|
|
return envelope
|
|
|
|
with patch("src.services.dashboard_testing.query_executor.execute_dashboard_query_envelope", side_effect=fake_envelope), \
|
|
patch("src.core.utils.client_registry.get_superset_client", AsyncMock()), \
|
|
patch("src.core.utils.client_registry.get_semaphore", new=AsyncMock(return_value=asyncio.Semaphore(2))), \
|
|
patch("src.dependencies.get_config_manager") as cm:
|
|
cm.return_value.get_environment.return_value = object() # non-None env
|
|
|
|
item = {
|
|
"execution_id": "e1", "run_id": "r1", "environment_id": _ENV,
|
|
"dashboard_id": 400, "chart_id": 11, "result_key": "count",
|
|
"filters": [], "filters_hash": "fh", "force": False,
|
|
}
|
|
out = await execute_superset_chart(item)
|
|
assert out["raw_sha256"] == "abc123"
|
|
assert out["parsed"]["result"][0]["cache_timeout"] == 300
|
|
assert out["filters_hash"] == "fh"
|
|
|
|
async def test_executor_raises_on_missing_chart_and_dataset(self) -> None:
|
|
from src.services.load_testing.executor import execute_superset_chart
|
|
|
|
with patch("src.core.utils.client_registry.get_superset_client", AsyncMock()), \
|
|
patch("src.dependencies.get_config_manager") as cm:
|
|
cm.return_value.get_environment.return_value = object()
|
|
with pytest.raises(ValueError):
|
|
await execute_superset_chart({"environment_id": _ENV, "dashboard_id": 400})
|
|
# #endregion Test.LoadTesting.ExecutorRuntime.Executor
|
|
|
|
|
|
# #region Test.LoadTesting.ExecutorRuntime.RunLoadRun [C:2] [TYPE Class]
|
|
# @ingroup Test
|
|
class TestRunLoadRunRealExecution:
|
|
async def test_run_real_execution_persists_load_executions(self, db: Session) -> None:
|
|
"""run_load_run must invoke the 037 executor per item and persist LoadExecution rows
|
|
(T075-T079) — previously the lifecycle only slept and transitioned phases."""
|
|
run = LoadRun(
|
|
id="r-real", environment_id=_ENV, dashboard_id=400,
|
|
status=RunStatus.RAMPING, phase=RunPhase.RAMP,
|
|
effective_concurrency=2, total_executions=2,
|
|
)
|
|
db.add(run)
|
|
db.commit()
|
|
|
|
# Different payload per execution_id so a wrong index-based mapping is caught (C2):
|
|
# chart 11 -> payload A, chart 12 -> payload B. Under concurrency > 1 results return in
|
|
# completion order; the runner must map by execution_id, not position.
|
|
async def fake_envelope(_client, request):
|
|
chart_id = request.chart_id
|
|
if chart_id == 11:
|
|
return SimpleNamespace(
|
|
raw_response_content=b'{"result": [{"is_cached": null, "cache_timeout": 300}]}',
|
|
source_response_hash="hash-a",
|
|
)
|
|
if chart_id == 12:
|
|
return SimpleNamespace(
|
|
raw_response_content=b'{"result": [{"is_cached": null, "cache_timeout": 500}]}',
|
|
source_response_hash="hash-b",
|
|
)
|
|
raise AssertionError(f"unexpected chart_id {chart_id}")
|
|
|
|
# Explicit execution_specs path -> no query-model inspection needed.
|
|
specs = [
|
|
{"execution_id": "e1", "run_id": "r-real", "environment_id": _ENV,
|
|
"dashboard_id": 400, "chart_id": 11, "result_key": "count",
|
|
"filters": [], "filters_hash": "fh1", "force": False},
|
|
{"execution_id": "e2", "run_id": "r-real", "environment_id": _ENV,
|
|
"dashboard_id": 400, "chart_id": 12, "result_key": "sum",
|
|
"filters": [], "filters_hash": "fh2", "force": False},
|
|
]
|
|
|
|
with patch("src.services.dashboard_testing.query_executor.execute_dashboard_query_envelope", side_effect=fake_envelope), \
|
|
patch("src.core.utils.client_registry.get_superset_client", AsyncMock()), \
|
|
patch("src.core.utils.client_registry.get_semaphore", new=AsyncMock(return_value=asyncio.Semaphore(2))), \
|
|
patch("src.dependencies.get_config_manager") as cm:
|
|
cm.return_value.get_environment.return_value = object()
|
|
|
|
from src.plugins.load_testing import run_load_run
|
|
|
|
result = await run_load_run(db, run, {"execution_specs": specs}, context=None)
|
|
|
|
assert result["status"] == "completed"
|
|
executions = db.query(LoadExecution).filter(LoadExecution.run_id == "r-real").all()
|
|
assert len(executions) == 2
|
|
# Each LoadExecution must carry the digest of ITS OWN payload, mapped by execution_id (C2).
|
|
import hashlib
|
|
|
|
expected_a = hashlib.sha256(b'{"result": [{"is_cached": null, "cache_timeout": 300}]}').hexdigest()
|
|
expected_b = hashlib.sha256(b'{"result": [{"is_cached": null, "cache_timeout": 500}]}').hexdigest()
|
|
by_chart = {e.chart_id: e.response_sha256 for e in executions}
|
|
assert by_chart[11] == expected_a
|
|
assert by_chart[12] == expected_b
|
|
reloaded = db.get(LoadRun, "r-real")
|
|
assert reloaded.status == RunStatus.COMPLETED
|
|
assert reloaded.phase == RunPhase.TERMINAL
|
|
|
|
async def test_missing_environment_degrades_to_lifecycle_only(self, db: Session) -> None:
|
|
run = LoadRun(id="r-noenv", environment_id="missing-env", dashboard_id=400,
|
|
status=RunStatus.RAMPING, phase=RunPhase.RAMP, effective_concurrency=1)
|
|
db.add(run)
|
|
db.commit()
|
|
|
|
with patch("src.dependencies.get_config_manager") as cm:
|
|
cm.return_value.get_environment.return_value = None
|
|
from src.plugins.load_testing import run_load_run
|
|
|
|
result = await run_load_run(db, run, {"execution_specs": []}, context=None)
|
|
|
|
assert result["status"] == "completed"
|
|
assert db.query(LoadExecution).filter(LoadExecution.run_id == "r-noenv").count() == 0
|
|
|
|
async def test_breaker_abort_persists_partials(self, db: Session) -> None:
|
|
"""C1: breaker.record() is fed per result via on_result; a breach trips the breaker and
|
|
run_load_run reaches CIRCUIT_BREAKER_ABORT with partials preserved."""
|
|
from src.services.load_testing.breaker import CircuitBreaker
|
|
|
|
run = LoadRun(
|
|
id="r-brk", environment_id=_ENV, dashboard_id=400,
|
|
status=RunStatus.RAMPING, phase=RunPhase.RAMP,
|
|
effective_concurrency=2, total_executions=6,
|
|
)
|
|
db.add(run)
|
|
db.commit()
|
|
|
|
# A breaker with min_samples=2 and zero error tolerance trips on 2 failures.
|
|
breaker = CircuitBreaker(min_samples=2, error_rate_threshold=0.0)
|
|
|
|
async def failing_executor(_item):
|
|
raise RuntimeError("boom")
|
|
|
|
# Patch execute_superset_chart to always fail so breaker sees errors.
|
|
with patch("src.services.load_testing.executor.execute_superset_chart", side_effect=failing_executor):
|
|
from src.plugins.load_testing import _run_bounded_pool
|
|
|
|
specs = [
|
|
{"execution_id": f"e{i}", "run_id": "r-brk", "environment_id": _ENV,
|
|
"dashboard_id": 400, "chart_id": i, "result_key": "count",
|
|
"filters": [], "filters_hash": f"fh{i}", "force": False}
|
|
for i in range(1, 7)
|
|
]
|
|
await _run_bounded_pool(db, run, specs, breaker)
|
|
|
|
# breaker fed real outcomes -> tripped
|
|
assert breaker.tripped is True
|
|
# partials persisted: at least the first item's LoadExecution exists (errors still recorded)
|
|
executions = db.query(LoadExecution).filter(LoadExecution.run_id == "r-brk").all()
|
|
assert len(executions) >= 1
|
|
# every persisted record carries its own chart_id (C2 mapping by execution_id)
|
|
charts = {e.chart_id for e in executions}
|
|
assert charts.issubset({1, 2, 3, 4, 5, 6})
|
|
# #endregion Test.LoadTesting.ExecutorRuntime.RunLoadRun
|
|
# #endregion Test.LoadTesting.ExecutorRuntime
|