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
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:
@@ -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}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user