Files
cleveragents-core/features/steps/validation_pipeline_concurrency_steps.py
HAL9000 29a3e70c36
CI / lint (pull_request) Successful in 38s
CI / helm (pull_request) Successful in 27s
CI / build (pull_request) Successful in 29s
CI / quality (pull_request) Successful in 53s
CI / typecheck (pull_request) Successful in 1m0s
CI / push-validation (pull_request) Successful in 33s
CI / security (pull_request) Successful in 1m5s
CI / integration_tests (pull_request) Successful in 8m13s
CI / unit_tests (pull_request) Successful in 8m16s
CI / docker (pull_request) Successful in 1m31s
CI / coverage (pull_request) Successful in 7m44s
CI / status-check (pull_request) Successful in 4s
fix(concurrency): add @tdd_issue tag and fix ruff formatting
- Add missing @tdd_issue companion tag to @tdd_issue_7623 scenario;
  before_scenario hook raised ValueError causing hook_error on every run
- Apply ruff format to validation_pipeline.py and
  validation_pipeline_concurrency_steps.py (2 files reformatted)
- Add use_step_matcher("parse") reset at end of concurrency steps
  to match convention in validation_pipeline_steps.py

ISSUES CLOSED: #7623
2026-06-02 04:29:20 -04:00

170 lines
5.7 KiB
Python

"""Step definitions for concurrent validation pipeline scenarios.
Tests that concurrent ValidationPipeline.run() calls correctly restore
sys.stdout and sys.stderr to the true originals after all pipelines finish,
and that each pipeline captures its own per-thread output independently.
The concurrency test avoids nested threading (threads-within-threads) by
using a simple sequential approach: install the stream wrappers twice
(simulating two concurrent pipelines), verify the reference count is 2,
then release twice and verify restoration.
"""
from __future__ import annotations
import sys
import threading
from typing import Any
from behave import given, then, use_step_matcher, when
from cleveragents.application.services.validation_pipeline import (
ValidationCommand,
ValidationPipeline,
_ThreadLocalStream,
)
from cleveragents.domain.models.core.tool import ValidationMode
use_step_matcher("re")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _FastPrintingExecutor:
"""Executor that prints a unique message and returns passed immediately."""
def __init__(self, pipeline_id: str) -> None:
self._pipeline_id = pipeline_id
def __call__(
self, validation_name: str, arguments: dict[str, Any]
) -> dict[str, Any]:
print(f"output-from-{self._pipeline_id}")
return {"passed": True, "message": f"{validation_name} ok"}
# ---------------------------------------------------------------------------
# Given steps
# ---------------------------------------------------------------------------
@given(r"two concurrent validation pipelines are prepared")
def step_given_two_concurrent_pipelines(context: Any) -> None:
context.vp_concurrent_summaries: list[Any] = [None, None]
context.vp_concurrent_errors: list[Exception | None] = [None, None]
context.vp_stdout_before: Any = sys.stdout
context.vp_stderr_before: Any = sys.stderr
def _make_command(name: str, resource: str) -> ValidationCommand:
return ValidationCommand(
validation_name=name,
resource_id="R00001",
resource_name=resource,
mode=ValidationMode.REQUIRED,
arguments={},
)
context.vp_pipeline_a = ValidationPipeline(
commands=[_make_command("check-a", "resource-a")],
executor=_FastPrintingExecutor("pipeline-a"),
max_workers=1,
)
context.vp_pipeline_b = ValidationPipeline(
commands=[_make_command("check-b", "resource-b")],
executor=_FastPrintingExecutor("pipeline-b"),
max_workers=1,
)
# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------
@when(r"both pipelines run concurrently using a barrier")
def step_when_both_pipelines_run_concurrently(context: Any) -> None:
"""Run both pipelines in separate threads and collect results."""
summaries: list[Any] = [None, None]
errors: list[Exception | None] = [None, None]
def _run(idx: int, pipeline: ValidationPipeline) -> None:
try:
summaries[idx] = pipeline.run()
except Exception as exc:
errors[idx] = exc
t_a = threading.Thread(target=_run, args=(0, context.vp_pipeline_a))
t_b = threading.Thread(target=_run, args=(1, context.vp_pipeline_b))
t_a.start()
t_b.start()
t_a.join(timeout=30.0)
t_b.join(timeout=30.0)
context.vp_concurrent_summaries = summaries
context.vp_concurrent_errors = errors
# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------
@then(r"sys\.stdout is not a _ThreadLocalStream after both pipelines finish")
def step_then_stdout_restored(context: Any) -> None:
assert not isinstance(sys.stdout, _ThreadLocalStream), (
f"sys.stdout was not restored: {type(sys.stdout)}"
)
@then(r"sys\.stderr is not a _ThreadLocalStream after both pipelines finish")
def step_then_stderr_restored(context: Any) -> None:
assert not isinstance(sys.stderr, _ThreadLocalStream), (
f"sys.stderr was not restored: {type(sys.stderr)}"
)
@then(r"each pipeline captured its own stdout output independently")
def step_then_each_pipeline_captured_independently(context: Any) -> None:
summaries = context.vp_concurrent_summaries
errors = context.vp_concurrent_errors
for i, err in enumerate(errors):
assert err is None, f"Pipeline {i} raised an error: {err}"
for i, summary in enumerate(summaries):
assert summary is not None, f"Pipeline {i} produced no summary"
assert summary.total == 1, (
f"Pipeline {i} expected 1 result, got {summary.total}"
)
assert summary.all_required_passed, f"Pipeline {i} had required failures"
# Verify captured stdout is isolated per pipeline
result_a = summaries[0].results[0]
result_b = summaries[1].results[0]
data_a = result_a.data or {}
data_b = result_b.data or {}
captured_a = data_a.get("_captured_stdout", "")
captured_b = data_b.get("_captured_stdout", "")
assert "pipeline-a" in captured_a, (
f"Pipeline A did not capture its own output. Got: {captured_a!r}"
)
assert "pipeline-b" not in captured_a, (
f"Pipeline A captured pipeline B's output: {captured_a!r}"
)
assert "pipeline-b" in captured_b, (
f"Pipeline B did not capture its own output. Got: {captured_b!r}"
)
assert "pipeline-a" not in captured_b, (
f"Pipeline B captured pipeline A's output: {captured_b!r}"
)
# Reset to default parse matcher
use_step_matcher("parse")