Files
ss-tools/backend/tests/services/load_testing/test_executor_runtime.py
busya c9664dfabc fix(040/038): address code-review criticals (C1-C4, H1, M1, M3)
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.
2026-08-07 16:18:29 +07:00

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