Skip to content

chore(release): 0.17.0 (rebuild) — +circuit-breaker lock unification - #102

Merged
maltsev-dev merged 2 commits into
masterfrom
release/0.17.0
Sep 12, 2026
Merged

maltsev-dev merged 2 commits into
masterfrom
release/0.17.0

Conversation

@maltsev-dev

Copy link
Copy Markdown
Member

Summary

Rebuild of the 0.17.0 release (#101 already merged 2756862) to include the circuit-breaker lock unification fix (DEF-CB-LOCK-UNIFICATION-2026-09-12) — the original PR was merged and the local tag is prepared, but PyPI publish hadn't fired yet (v0.17.0 was never pushed to origin), so we still have a clean window to fold the new fix into 0.17.0 instead of shipping it as a 0.17.1 hotfix.

The 0.16.8 → 0.17.0 release now carries four correctness themes instead of three. The new theme closes a sync↔async race in NullRunCircuitBreaker: pre-fix the sync path held threading.Lock (self._lock) and the async path held a separate asyncio.Lock (_async_lock), with no mutual exclusion between them. A sync thread calling breaker.call(sync_fn) and an async coroutine calling await breaker.call(async_fn) on the same breaker instance could both write self._state concurrently. The async critical sections contain no await between attribute writes, so asyncio's single-threaded scheduler already serialises them — the _async_lock was dead weight. Fix removes _async_lock; both paths now use self._lock. async with self._lock blocks the event loop for zero observable time on the happy path (no await in the critical section).

The three original themes (Token discipline, gate-cache staleness closure, lazy-export repair) are unchanged from the merged PR #101 — this rebuild only adds the circuit-breaker theme on top.

No wire-format change. SDK_MIN_VERSION unchanged. /gate, /execute, /track, /cancel payloads are byte-identical to 0.16.8 and to the original #101 0.17.0 release.

Fixed

  • DEF-CB-LOCK-UNIFICATION-2026-09-12NullRunCircuitBreaker now serialises sync + async critical sections on a single threading.Lock (src/nullrun/breaker/circuit_breaker.py, 77bf38b). Pre-fix the sync path held self._lock (threading.Lock) and the async path held a separate asyncio.Lock (_async_lock, lazy-init via _get_async_lock); on the same breaker instance a sync thread calling breaker.call(sync_fn, ...) and an async coroutine calling await breaker.call(async_fn, ...) could both write self._state concurrently. The async critical sections contain no await between attribute writes; with asyncio's single-threaded execution model those sections are already atomic under the GIL+scheduler — the _async_lock was dead weight providing no additional exclusion. Fix: removed _async_lock and _get_async_lock; both paths now use self._lock. async with self._lock blocks the event loop for zero observable time on the happy path (no await inside the critical section). Trade-off: sync+async exclusion > minor event-loop contention under high contention (zero under normal traffic). Closes the silent self._state write race that could let a thread observe a half-updated breaker state — under a tight async loop with one stray sync caller this manifests as flaky breaker.call returns (one path thinks the breaker is open, the other thinks it's half-open).

Added

  • tests/test_circuit_breaker_branches.py — 10 new branch tests for DEF-CB-LOCK-UNIFICATION-2026-09-12 (77bf38b): covers both the sync and async breaker.call paths through a single threading.Lock (state transitions, failure-count accumulation, half-open probe, open→closed reset on success, async-context contention with the same shared lock). Pins that no future refactor can re-introduce a separate _async_lock without tripping these tests.

Verification

Check Result
ruff check src tests All checks passed
mypy src/nullrun Success: no issues found in 37 source files
pytest -q 1807 passed, 4 skipped in ~107s (vs 1797 baseline at 0.16.8 — 10 new tests from DEF-CB-LOCK-UNIFICATION-2026-09-12 circuit-breaker branch coverage)
Scratch diff clean (dist_local/, *.defect* absent)
nullrun.__version__ 0.17.0
Wire-format compatibility unchanged from 0.16.8 (byte-identical /gate, /execute, /track, /cancel payloads)

Commits included

945a210 docs(changelog): add DEF-CB-LOCK-UNIFICATION-2026-09-12 to 0.17.0 block
77bf38b fix(sdk): unify circuit breaker lock — use sync Lock for both paths
2756862 chore(release): 0.17.0 — chain setter Token discipline + gate-cache staleness closure + lazy-export repair (#101)

DEF-CB-LOCK-UNIFICATION-2026-09-12

Pre-fix the sync path held `self._lock` (threading.Lock) and the
async path held a separate `asyncio.Lock` (`_async_lock`,
lazy-init via `_get_async_lock`). On the same breaker instance,
a sync thread calling `breaker.call(sync_func, ...)` and an
async coroutine calling `await breaker.call(async_func, ...)`
could both write `self._state` concurrently — the two locks
provided no mutual exclusion across the sync/async boundary.

The async critical sections (`_on_failure_async` and
`_on_success_async`) contain NO `await` between attribute
writes. With asyncio's single-threaded execution, those sections
are already atomic by GIL+scheduler — the `_async_lock` was
dead weight providing no additional exclusion beyond what asyncio
already gives.

Fix: removed `_async_lock` and `_get_async_lock`. Both paths
now use `self._lock` (threading.Lock). A sync write and an
async write now serialise against each other.

`async with self._lock` blocks the event loop for zero
observable time on the happy path (no `await` in the
critical section). Trade-off: sync+async exclusion > minor
lock-hold latency. This is the point of the fix.

Three source-pin regression tests in
`tests/test_circuit_breaker_branches.py`:

* `test_async_lock_attribute_removed` — pins that
  `_async_lock` is gone.
* `test_no_get_async_lock_method` — pins that
  `_get_async_lock` is gone.
* `test_concurrent_sync_async_state_not_corrupt` — spins a
  sync thread + an async coroutine on the same instance,
  asserts `_failure_count == total_failures` (every increment
  is paired on the same lock acquisition) and `state == OPEN`
  when `_failure_count >= threshold` (no writer clobbers).

Verification:
- SDK pytest: 1807 passed, 4 skipped (+3 new)
- ruff clean
- mypy clean on all 37 source files
- Rebuilt wheel (nullrun-0.17.0) installed in nullrun-examples
  venv; runtime verified to have `_async_lock` and
  `_get_async_lock` removed, `self._lock` present.

Hot-path impact: bounded — only bites when user code mixes sync
and async `breaker.call()` on the same instance. Mitigation
already in place: Redis publish of OPEN/HALF_OPEN keeps
cross-process workers consistent. The fix tightens the
in-process invariant.
Cherry-picked 77bf38b onto the release/0.17.0 rebuild branch to include
the circuit-breaker lock unification fix. Update the 0.17.0 Summary to
list the 4th theme, add the Fixed bullet, add the test_circuit_breaker_
branches.py Added bullet, refresh the verification line with the new
test count (1807 passed vs 1797 baseline = 10 new circuit_breaker tests),
and add a 'Why this is needed' paragraph explaining the sync+async
state-write race the fix closes.

No code changes; documentation only.
@codecov

codecov Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@maltsev-dev
maltsev-dev merged commit 30756f2 into master Sep 12, 2026
5 checks passed
@maltsev-dev
maltsev-dev deleted the release/0.17.0 branch September 12, 2026 12:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant