|
| 1 | +--- |
| 2 | +summary: Share AsyncBulkhead/AsyncCircuitBreaker's identical single-event-loop guard via one module-level check_event_loop function in a new _event_loop_guard.py. |
| 3 | +--- |
| 4 | + |
| 5 | +# Change: Share the single-event-loop guard between AsyncBulkhead and AsyncCircuitBreaker |
| 6 | + |
| 7 | +**Lane:** lightweight — 4 files (above the usual ≤2 guard; one is the |
| 8 | +`architecture/overview.md` module-layout promotion this change requires, per |
| 9 | +the `2026-06-13.04`/`2026-07-13.07` precedent of the file-count guard proxying |
| 10 | +*code* risk, not file count). No public-API change, no test changes (existing |
| 11 | +suite is the parity net). |
| 12 | + |
| 13 | +Spec: [`planning/audits/2026-06-14-deep-audit.md`](../../audits/2026-06-14-deep-audit.md) |
| 14 | +("Sync/async duplication, no divergence yet" — `_check_loop` in bulkhead vs |
| 15 | +circuit_breaker), the last item from that list not yet addressed by this |
| 16 | +session's refactor series (`2026-07-13.01`–`.06`). |
| 17 | + |
| 18 | +## Goal |
| 19 | + |
| 20 | +`AsyncBulkhead._check_loop` (`bulkhead.py:103-121`) and |
| 21 | +`AsyncCircuitBreaker._check_loop` (`circuit_breaker.py:340-354`) are |
| 22 | +byte-identical double-checked-locking logic — same structure, differing only |
| 23 | +in which cross-loop-message constant they format. Unlike the other items in |
| 24 | +this refactor series, this pair lives in two different classes across two |
| 25 | +different files, not a sync/async split within one file. No behavior change. |
| 26 | + |
| 27 | +## Approach |
| 28 | + |
| 29 | +New module `src/httpware/middleware/resilience/_event_loop_guard.py` |
| 30 | +(sibling of the existing `_backoff.py` shared helper) with one function: |
| 31 | + |
| 32 | +```python |
| 33 | +def check_event_loop( |
| 34 | + get_loop: Callable[[], asyncio.AbstractEventLoop | None], |
| 35 | + set_loop: Callable[[asyncio.AbstractEventLoop], None], |
| 36 | + loop_lock: threading.Lock, |
| 37 | + message_template: str, |
| 38 | +) -> None: ... |
| 39 | +``` |
| 40 | + |
| 41 | +`get_loop`/`set_loop` are closures over the caller's `self._loop`, not a |
| 42 | +passed-in `self` — passing `self` and reaching into `instance._loop` from a |
| 43 | +free function would trip ruff's `SLF001` (private-member access from outside |
| 44 | +the class), which is exactly why `decoders/_caching.py`'s `_get_or_build` |
| 45 | +precedent takes the mutable container by value instead of the owning object. |
| 46 | +The inner check re-invokes `get_loop()` rather than reusing the outer |
| 47 | +snapshot, preserving the original's double-checked-locking correctness: a |
| 48 | +thread that loses the race to acquire `loop_lock` must still observe |
| 49 | +whichever loop the winner just bound, not its own stale pre-lock read |
| 50 | +(load-bearing, per the 2026-06-14 deep audit's own note that this is a |
| 51 | +correctness mechanism, not a decorative optimization). |
| 52 | + |
| 53 | +Each `_check_loop` method becomes a 5-line call: |
| 54 | + |
| 55 | +```python |
| 56 | +def _check_loop(self) -> None: |
| 57 | + check_event_loop( |
| 58 | + lambda: self._loop, |
| 59 | + lambda loop: setattr(self, "_loop", loop), |
| 60 | + self._loop_lock, |
| 61 | + _ASYNCBULKHEAD_CROSS_LOOP_MSG, # or _CROSS_LOOP_MSG in circuit_breaker.py |
| 62 | + ) |
| 63 | +``` |
| 64 | + |
| 65 | +## Files |
| 66 | + |
| 67 | +- `src/httpware/middleware/resilience/_event_loop_guard.py` — new, the shared |
| 68 | + `check_event_loop` function. |
| 69 | +- `src/httpware/middleware/resilience/bulkhead.py` — `AsyncBulkhead._check_loop` |
| 70 | + now delegates. |
| 71 | +- `src/httpware/middleware/resilience/circuit_breaker.py` — |
| 72 | + `AsyncCircuitBreaker._check_loop` now delegates. |
| 73 | +- `architecture/overview.md` — module-layout tree gets the new file, matching |
| 74 | + `_backoff.py`'s existing entry. |
| 75 | +- No test file changes — `tests/test_bulkhead.py::test_cross_loop_acquire_raises_runtimeerror` |
| 76 | + and `tests/test_circuit_breaker.py::test_cross_loop_use_raises_runtimeerror` |
| 77 | + (plus the same-loop and construct-outside-loop tests) already exercise both |
| 78 | + extraction targets and stay green unmodified. |
| 79 | + |
| 80 | +## Verification |
| 81 | + |
| 82 | +- [x] `just test` — full suite green, 100% coverage maintained, same test |
| 83 | + count (pure refactor, no new/removed tests). |
| 84 | +- [x] `just lint-ci` — clean. |
| 85 | +- [x] Grep both files to confirm `_check_loop`'s body no longer contains |
| 86 | + `asyncio.get_running_loop()` or the inline double-checked-locking block |
| 87 | + — only the 5-line delegating call remains. |
0 commit comments