fix(tests): correct ScenarioOutline syntax and step parameter mismatches
CI / load-versions (pull_request) Successful in 18s
CI / push-validation (pull_request) Successful in 26s
CI / helm (pull_request) Successful in 54s
CI / build (pull_request) Successful in 1m0s
CI / typecheck (pull_request) Successful in 1m5s
CI / quality (pull_request) Successful in 1m6s
CI / lint (pull_request) Successful in 1m11s
CI / security (pull_request) Successful in 1m20s
CI / integration_tests (pull_request) Failing after 12m28s
CI / unit_tests (pull_request) Failing after 12m28s
CI / coverage (pull_request) Has been cancelled
CI / docker (pull_request) Has been cancelled
CI / status-check (pull_request) Has been cancelled

Replace {col}/{col:d} curly-brace column refs with Behave-required <col>
angle-bracket syntax in all ScenarioOutline step text. Rename ambiguous
given step to avoid duplicate step definition conflicts. Fix min_ -> min_count
parameter mismatch, move _error_mutex init before thread start to eliminate
lock race, apply ruff format wraps, and add get_invariant() call in snapshot
step to cover invariant_service.py:560-561.

ISSUES CLOSED: #7524
This commit is contained in:
2026-06-17 16:23:11 -04:00
committed by Forgejo
parent 1a2d2d8960
commit 6b31005a20
2 changed files with 61 additions and 39 deletions
@@ -14,10 +14,10 @@ Feature: TDD Issue #7524 - InvariantService Thread Safety
plan execution threads cannot race on _invariants or _enforcement_records.
Scenario Outline: Concurrent add_invariant() must not corrupt the dict
Given an invariant service
When {n} threads concurrently add {count:d} invariants through a barrier
Given an invariant service for thread-safety testing
When <n> threads concurrently add <count> invariants through a barrier
Then the service has no RuntimeError raised during concurrent access
And the service should have exactly {total:d} active invariants
And the service should have exactly <total> active invariants
And all added invariants should be retrievable via get_invariant_snapshot
Examples:
@@ -28,20 +28,20 @@ Feature: TDD Issue #7524 - InvariantService Thread Safety
| 10| 2 | 20 |
Scenario Outline: Concurrent list_invariants() must not raise during iteration
Given an invariant service with {count:d} pre-existing invariants
Given an invariant service with <count> pre-existing invariants
When 5 threads concurrently list all invariants through a barrier
Then no thread raised RuntimeError during concurrent reads
And the caller should receive a valid list of at least {min:d} active invariants
And the caller should receive a valid list of at least <min_count> active invariants
Examples:
| count | min |
| 10 | 10 |
| 20 | 20 |
| 5 | 5 |
| count | min_count |
| 10 | 10 |
| 20 | 20 |
| 5 | 5 |
Scenario Outline: Concurrent remove_invariant() must not race with add
Given an invariant service
When {n} threads concurrently add invariants while {m:d} concurrent threads remove different ones through a barrier
Given an invariant service for thread-safety testing
When <n> threads concurrently add invariants while <m> concurrent threads remove different ones through a barrier
Then no thread raised RuntimeError during mixed access
And the service should have a consistent count of active invariants
@@ -52,20 +52,20 @@ Feature: TDD Issue #7524 - InvariantService Thread Safety
| 8 | 4 |
Scenario Outline: Concurrent enforce_invariants() must not corrupt the record list
Given an invariant service with {count:d} invariants to enforce
When {n:d} threads concurrently call enforce_invariants on the same set through a barrier
Given an invariant service with <count> invariants to enforce
When <n> threads concurrently call enforce_invariants on the same set through a barrier
Then no thread raised RuntimeError during enforcement
And each thread should have received exactly {total:d} enforcement records
And each thread should have received exactly <total> enforcement records
Examples:
| count | n | total |
| 3 | 4 | 3 |
| 5 | 8 | 5 |
| 2 | 10| 2 |
| count | n | total |
| 3 | 4 | 3 |
| 5 | 8 | 5 |
| 2 | 10 | 2 |
Scenario Outline: Concurrent enforce and list must both succeed
Given an invariant service with {count:d} invariants already enforced by a previous thread
When {n:d} threads concurrently enforce new invariants while other threads list them through a barrier
Given an invariant service with <count> invariants already enforced by a previous thread
When <n> threads concurrently enforce new invariants while other threads list them through a barrier
Then no thread raised RuntimeError during mixed enforcement and listing
And the enforcement record count should be consistent across all threads
@@ -25,7 +25,9 @@ from cleveragents.domain.models.core.invariant import InvariantScope
# ================================================================
def _create_barrier_and_threads(n: int) -> tuple[threading.Barrier, list[threading.Thread]]:
def _create_barrier_and_threads(
n: int,
) -> tuple[threading.Barrier, list[threading.Thread]]:
"""Create a barrier for n threads and an empty thread list."""
barrier = threading.Barrier(n)
threads: list[threading.Thread] = []
@@ -37,7 +39,7 @@ def _create_barrier_and_threads(n: int) -> tuple[threading.Barrier, list[threadi
# ================================================================
@given("an invariant service")
@given("an invariant service for thread-safety testing")
def step_service(context: Context) -> None:
"""Create a fresh InvariantService with empty concurrency tracking."""
context.service = InvariantService()
@@ -70,12 +72,18 @@ def step_service_with_invariants(context: Context, count: int) -> None:
source_name="enforcement-test",
)
# Store the invariant IDs so enforcement scenarios can reference them
context.enforcement_inv_ids = list(context.concurrent_results) if hasattr(context, "concurrent_results") else []
context.enforcement_inv_ids = (
list(context.concurrent_results)
if hasattr(context, "concurrent_results")
else []
)
context.concurrent_errors = []
context.concurrent_results = {}
@given("an invariant service with {count:d} invariants already enforced by a previous thread")
@given(
"an invariant service with {count:d} invariants already enforced by a previous thread"
)
def step_service_with_enforced_invariants(context: Context, count: int) -> None:
"""Create an InvariantService where enforcement records already exist."""
context.service = InvariantService()
@@ -108,6 +116,8 @@ def step_concurrent_add(context: Context, n: int, count: int) -> None:
_ = n * count # actual total added by this scenario
barrier, threads = _create_barrier_and_threads(n)
context._error_mutex = threading.Lock()
def worker(thread_index: int) -> None:
try:
barrier.wait(timeout=10)
@@ -118,7 +128,7 @@ def step_concurrent_add(context: Context, n: int, count: int) -> None:
source_name="concurrent-test",
)
except Exception as exc:
with context._error_mutex if hasattr(context, "_error_mutex") else threading.Lock():
with context._error_mutex:
context.concurrent_errors.append(exc)
for idx in range(n):
@@ -126,10 +136,6 @@ def step_concurrent_add(context: Context, n: int, count: int) -> None:
threads.append(t)
t.start()
# Also create a mutex for the first call since it may not exist yet
if not hasattr(context, "_error_mutex"):
context._error_mutex = threading.Lock()
for t in threads:
t.join(timeout=30)
@@ -158,13 +164,17 @@ def step_concurrent_add_count(context: Context, total: int) -> None:
@then("all added invariants should be retrievable via get_invariant_snapshot")
def step_snapshot_contains_all(context: Context) -> None:
"""Confirm get_invariants_snapshot returns consistent data."""
"""Confirm get_invariants_snapshot and get_invariant return consistent data."""
snapshot = context.service.get_invariants_snapshot()
listed = context.service.list_invariants()
assert len(snapshot) == len(listed), (
f"Snapshot has {len(snapshot)} invariants but list_invariants found "
f"{len(listed)}. Inconsistent reads detected."
)
for inv_id in snapshot:
assert context.service.get_invariant(inv_id) is not None, (
f"get_invariant({inv_id!r}) returned None but id is in snapshot."
)
# ================================================================
@@ -202,21 +212,25 @@ def step_concurrent_list(context: Context) -> None:
@then("no thread raised RuntimeError during concurrent reads")
def step_no_runtime_error_lists(context: Context) -> None:
"""Verify no thread hit a RuntimeError during list_invariants()."""
runtime_errors = [e for e in context.concurrent_errors if isinstance(e, RuntimeError)]
runtime_errors = [
e for e in context.concurrent_errors if isinstance(e, RuntimeError)
]
assert not runtime_errors, (
f"RuntimeError raised during concurrent reads: {runtime_errors}. "
"The RLock is missing in list_invariants()."
)
@then("the caller should receive a valid list of at least {min:d} active invariants")
def step_list_produces_valid_result(context: Context, min_: int) -> None:
@then(
"the caller should receive a valid list of at least {min_count:d} active invariants"
)
def step_list_produces_valid_result(context: Context, min_count: int) -> None:
"""Each thread's list result should be consistent."""
# All threads should have seen the same count (snapshot consistency)
for results in context._list_results:
assert len(results) >= min_, (
assert len(results) >= min_count, (
f"Thread saw only {len(results)} active invariants "
f"(expected at least {min_}). "
f"(expected at least {min_count}). "
"A reader may have seen a partially-written dict."
)
@@ -279,7 +293,9 @@ def step_concurrent_mixed(context: Context, n: int, m: int) -> None:
@then("no thread raised RuntimeError during mixed access")
def step_no_runtime_error_mixed(context: Context) -> None:
"""Verify no RuntimeError was thrown during concurrent add + remove."""
runtime_errors = [e for e in context.concurrent_errors if isinstance(e, RuntimeError)]
runtime_errors = [
e for e in context.concurrent_errors if isinstance(e, RuntimeError)
]
assert not runtime_errors, (
f"RuntimeError raised during mixed access: {runtime_errors}. "
"The RLock is missing or ineffective for concurrent read+write."
@@ -333,7 +349,9 @@ def step_concurrent_enforce(context: Context, n: int) -> None:
if not hasattr(context, "_enf_lock"):
context._enf_lock = threading.Lock()
threads = [threading.Thread(target=worker, args=(i,), daemon=True) for i in range(n)]
threads = [
threading.Thread(target=worker, args=(i,), daemon=True) for i in range(n)
]
for t in threads:
t.start()
for t in threads:
@@ -353,7 +371,9 @@ def step_enforce_record_count(context: Context, count: int) -> None:
@then("no thread raised RuntimeError during enforcement")
def step_no_runtime_error_enforce(context: Context) -> None:
"""Verify no RuntimeError during concurrent enforce_invariants()."""
runtime_errors = [e for e in context.concurrent_errors if isinstance(e, RuntimeError)]
runtime_errors = [
e for e in context.concurrent_errors if isinstance(e, RuntimeError)
]
assert not runtime_errors, (
f"RuntimeError raised during enforcement: {runtime_errors}. "
"_enforcement_records.extend() is likely unprotected."
@@ -414,7 +434,9 @@ def step_concurrent_enforce_list(context: Context, n: int) -> None:
@then("no thread raised RuntimeError during mixed enforcement and listing")
def step_no_runtime_error_mixed_enforce(context: Context) -> None:
"""Verify no RuntimeError during concurrent enforce + list."""
runtime_errors = [e for e in context.concurrent_errors if isinstance(e, RuntimeError)]
runtime_errors = [
e for e in context.concurrent_errors if isinstance(e, RuntimeError)
]
assert not runtime_errors, (
f"RuntimeError raised during mixed enforcement and listing: {runtime_errors}"
)