Files
OpenOPC/tests/test_org_concurrency.py
T
2026-07-01 17:56:31 +08:00

107 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Concurrency tests for the _config_lock pattern used in P2.
The real WSHandler requires a full engine/store/ws stack to boot, which makes
a pure-unit concurrency test prohibitively heavy. Instead we test the *pattern*
applied in P2 — a single asyncio.Lock serializing any number of mixed
`add_role` and `reorg_decide` style coroutines — and assert:
- Every operation completes (20 ops in our case).
- `asyncio.Lock` never produces a RuntimeError ("lock already held") — which
would indicate same-task reentry.
- The log shows strictly interleaved entries (no two tasks recorded an
"inside-lock" line between each other's enter/exit).
- The whole batch completes well under the 10s budget.
Evidence: ws_handler.py:175 defines `self._config_lock = asyncio.Lock()`.
P2 wraps `_handle_reorg_decide`, `_handle_set_mode`, and the `company_profile`
swap under this same lock. The proof of no deadlock is static (D4 in the plan);
this test is a dynamic smoke that the cooperative-locking behavior holds.
"""
from __future__ import annotations
import asyncio
import time
import pytest
@pytest.mark.asyncio
async def test_add_role_and_reorg_decide_serialized():
"""20 mixed ops share one lock; no exceptions, no deadlock, finishes fast."""
lock = asyncio.Lock()
# Event log records enter/exit around the critical section so we can verify
# the lock serialized the body. If two tasks ever interleaved between an
# enter and its matching exit, the test fails.
log: list[tuple[str, str]] = [] # (op, phase)
async def guarded(op: str) -> None:
async with lock:
log.append((op, "enter"))
# Yield to event loop so other tasks get a chance to observe
# whether they see an "inside-lock" window (they should not).
await asyncio.sleep(0)
log.append((op, "exit"))
started = time.monotonic()
tasks = []
for i in range(10):
tasks.append(asyncio.create_task(guarded(f"add_role:{i}")))
tasks.append(asyncio.create_task(guarded(f"reorg_decide:{i}")))
results = await asyncio.gather(*tasks, return_exceptions=True)
elapsed = time.monotonic() - started
exceptions = [r for r in results if isinstance(r, BaseException)]
assert exceptions == [], f"Unexpected exceptions: {exceptions}"
# Every op must produce exactly one enter + one exit.
assert len(log) == 40, f"Expected 40 log entries (20 ops × 2), got {len(log)}"
# Verify lock-mutual-exclusion: iterating the log, each enter must be
# immediately followed by the SAME op's exit (no interleave).
i = 0
while i < len(log):
assert log[i][1] == "enter", f"Expected enter at index {i}, got {log[i]}"
assert log[i + 1] == (log[i][0], "exit"), (
f"Enter for {log[i][0]} was not immediately followed by its exit; "
f"saw {log[i + 1]} — indicates lock was not held across the body."
)
i += 2
# Counts must balance.
add_role_count = sum(1 for (op, phase) in log if phase == "enter" and op.startswith("add_role"))
reorg_count = sum(1 for (op, phase) in log if phase == "enter" and op.startswith("reorg_decide"))
assert add_role_count == 10
assert reorg_count == 10
# 10s budget; a correct cooperative-lock implementation finishes in <1s.
assert elapsed < 10.0, f"Test took {elapsed:.2f}s, exceeded 10s budget"
@pytest.mark.asyncio
async def test_lock_is_not_reentrant_from_same_task():
"""asyncio.Lock must NOT be re-acquirable from the same task (would deadlock).
This is the invariant that the P2 plan relied on (D4 deadlock proof): if any
code path under `_handle_reorg_decide` re-acquired `_config_lock` we'd
deadlock. Here we verify the Lock primitive's behavior — the test passes
only when a re-acquire attempt blocks forever (we force it to time out).
"""
lock = asyncio.Lock()
async def try_reentrant():
async with lock:
# Attempting to acquire again from the same task must block.
try:
await asyncio.wait_for(lock.acquire(), timeout=0.2)
except asyncio.TimeoutError:
return "blocked_as_expected"
# Only reach here if stdlib ever became reentrant (it is not).
lock.release()
return "unexpected_reentry"
outcome = await try_reentrant()
assert outcome == "blocked_as_expected", (
f"Expected asyncio.Lock to block on same-task re-acquire, got {outcome}"
)