Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **Direct-call baseline and threaded-contention benchmarks**, closing out the
two gaps left in #84. The CodSpeed suite reported the absolute cost of every
protected path but not the number a prospective user actually wants — the
breaker's overhead relative to calling the function directly.
`test_baseline_direct_call` and its async twin make that ratio derivable
from any run. `benchmarks/test_contention.py` drives one breaker from four
worker threads so the work under the shared lock is tracked as a trend; its
docstring and `CONTRIBUTING.md` spell out that instruction counting runs
threads one at a time, so the number is lock-path work, not wall-clock
contention.

## [2.4.0] - 2026-08-06

### Added
Expand Down
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,14 @@ Every pull request runs the same suite through
Add a benchmark when you touch a hot path: the call paths, the state machine,
the sliding windows or the pipeline.

`test_baseline_direct_call` (and its async twin) measure the unwrapped
callable — divide any call-path result by the matching baseline to read the
breaker's overhead as a ratio rather than an absolute count. The contention
benchmark (`benchmarks/test_contention.py`) carries a caveat: CodSpeed counts
instructions under Valgrind, which runs threads one at a time, so it tracks
the work done under the breaker's lock when many threads call through it — not
the wall-clock cost of real contention.

## Expectations

- **Tests first.** New behaviour and bug fixes come with tests; the suite keeps
Expand Down
19 changes: 19 additions & 0 deletions benchmarks/test_call_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,25 @@ def runner() -> Iterator[asyncio.Runner]:
yield loop_runner


def test_baseline_direct_call(benchmark: BenchmarkFixture) -> None:
"""The unwrapped sync callable: the denominator for every overhead ratio."""
benchmark(lambda: _work(1, 2))


def test_baseline_direct_call_async(benchmark: BenchmarkFixture, runner: asyncio.Runner) -> None:
"""The unwrapped coroutine, behind the same wrapper frame the guarded paths pay.

Real callers await the protected work from inside their own coroutine either
way, so the baseline wraps ``_async_work`` exactly like ``test_call_async``
wraps ``breaker.call`` — the ratio then isolates the breaker, not a frame.
"""

async def bare() -> int:
return await _async_work(1, 2)

benchmark(lambda: runner.run(bare()))


def test_call_sync(benchmark: BenchmarkFixture) -> None:
"""``call`` on a closed breaker: detect, admit, run, classify, record."""
breaker = CircuitBreaker(name='bench-call-sync', config=_CLOSED_CONFIG)
Expand Down
57 changes: 57 additions & 0 deletions benchmarks/test_contention.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Benchmark for modest threaded contention around one shared breaker.

Every protected call takes the breaker's single ``threading.Lock`` twice —
once to admit, once to record the outcome — so calls from several threads all
funnel through it. This benchmark drives one closed breaker from four worker
threads so a change in the work done under that lock shows up as a trend.

Read the number with care: CodSpeed's simulation mode counts CPU instructions
under Valgrind, which runs threads one at a time. What is measured is the
instruction cost of the shared lock path when calls arrive from many threads —
not the waiting, scheduling or cache traffic real contention adds to
wall-clock time. Behaviour under real contention is asserted by
``tests/test_concurrency.py``, including on free-threaded CPython.
"""

from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor

import pytest
from pytest_codspeed import BenchmarkFixture

from interlock import CircuitBreaker, Config, State

_THREADS = 4
_CALLS_PER_THREAD = 25
_CLOSED_CONFIG = Config(window_size=100, minimum_number_of_calls=10)


def _work(left: int, right: int) -> int:
"""The protected payload: cheap enough to expose the breaker's own cost."""
return left + right


@pytest.fixture
def executor() -> Iterator[ThreadPoolExecutor]:
"""A worker pool reused across iterations, so thread startup is excluded."""
with ThreadPoolExecutor(max_workers=_THREADS) as pool:
yield pool


def test_contended_calls(benchmark: BenchmarkFixture, executor: ThreadPoolExecutor) -> None:
"""Four threads sharing one closed breaker: the lock path under contention."""
breaker = CircuitBreaker(name='bench-contended', config=_CLOSED_CONFIG)

def hammer() -> int:
total = 0
for _ in range(_CALLS_PER_THREAD):
total += breaker.call(_work, 1, 2)
return total

def contended_round() -> int:
futures = [executor.submit(hammer) for _ in range(_THREADS)]
return sum(future.result() for future in futures)

assert contended_round() == _THREADS * _CALLS_PER_THREAD * 3
assert breaker.state is State.CLOSED
benchmark(contended_round)
Loading