-
Notifications
You must be signed in to change notification settings - Fork 5
Add integration tests for the Redis semaphore #2548
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ideaship
merged 1 commit into
main
from
implement/issue-2401-semaphore-integration-tests
Aug 5, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| """Redis-semaphore integration tests against a live Redis. | ||
|
|
||
| ``RedisSemaphore`` caps concurrent NetBox API requests: ``osism.tasks.netbox`` | ||
| builds one per NetBox URL through ``create_netbox_semaphore``. Holders live in a | ||
| sorted set and are admitted by a server-side Lua script (``ZREMRANGEBYSCORE`` | ||
| plus ``ZCARD`` plus ``ZADD``). | ||
|
|
||
| The script's logic is not what needs a live server: | ||
| ``tests/unit/utils/test_init_semaphore.py`` runs the production ``_ACQUIRE_LUA`` | ||
| against ``fakeredis``, which executes Lua through ``lupa``. What only the real | ||
| server provides is Redis' own Lua sandbox and ``redis.call`` bindings, the | ||
| numeric coercion of the ``now``, ``maxsize`` and expiry arguments that ``lupa`` | ||
| only approximates, and the race the unit suite explicitly defers to here: | ||
| many clients contending for the same free slot at the same moment. | ||
| """ | ||
|
|
||
| import concurrent.futures | ||
| import hashlib | ||
| import threading | ||
| import uuid | ||
|
|
||
| import pytest | ||
|
|
||
| from osism import utils | ||
|
|
||
| pytestmark = pytest.mark.integration | ||
|
|
||
|
|
||
| def semaphore_redis_key(key): | ||
| """The prefixed key ``RedisSemaphore`` stores its sorted set under.""" | ||
| return f"semaphore:{key}" | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def redis_client(): | ||
| """The shared Redis client the semaphore itself uses. | ||
|
|
||
| ``create_netbox_semaphore`` wires this very client into the semaphores it | ||
| builds, so seeding and inspecting through it observes what the code under | ||
| test sees. It is a cached module global held for the whole session, so it | ||
| is not closed here: that would break every later test that touches it. | ||
| """ | ||
| return utils.redis | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def semaphore_key(redis_client): | ||
| """A key unique to this test, removed from Redis afterwards. | ||
|
|
||
| ``RedisSemaphore`` prefixes what it is handed, so the sorted set lives under | ||
| ``semaphore:<key>``. Raw seeding and inspection use that prefixed form. | ||
| """ | ||
| key = f"itest-sem-{uuid.uuid4()}" | ||
| yield key | ||
| redis_client.delete(semaphore_redis_key(key)) | ||
|
|
||
|
|
||
| def test_release_frees_slot(redis_client, semaphore_key): | ||
| """The slot a holder gives up is granted to the caller that was refused. | ||
|
|
||
| The unit suite covers a slot freed by expiry, never one freed by | ||
| ``release`` and granted to a waiter inside its retry loop. | ||
| """ | ||
| holder = utils.RedisSemaphore(redis_client, semaphore_key, 1, timeout=5) | ||
| assert holder.acquire() is True | ||
|
|
||
| waiter = utils.RedisSemaphore(redis_client, semaphore_key, 1, timeout=0.5) | ||
| assert waiter.acquire() is False | ||
|
|
||
| holder.release() | ||
|
|
||
| assert waiter.acquire() is True | ||
| assert redis_client.zcard(waiter.key) == 1 | ||
|
|
||
| waiter.release() | ||
|
|
||
|
|
||
| def test_create_netbox_semaphore_key_and_maxsize(redis_client): | ||
| """The NetBox helper derives its key from the URL and works against Redis.""" | ||
| url = f"https://netbox-{uuid.uuid4()}.example" | ||
| url_hash = hashlib.md5(url.encode()).hexdigest()[:8] | ||
| redis_key = f"semaphore:netbox_semaphore_{url_hash}" | ||
|
|
||
| sem = utils.create_netbox_semaphore(url, max_connections=2) | ||
|
|
||
| try: | ||
| assert isinstance(sem, utils.RedisSemaphore) | ||
| assert sem.key == redis_key | ||
| assert sem.maxsize == 2 | ||
|
|
||
| assert sem.acquire() is True | ||
| members = {member.decode() for member in redis_client.zrange(redis_key, 0, -1)} | ||
| assert members == {sem.identifier} | ||
|
|
||
| sem.release() | ||
| assert redis_client.zcard(redis_key) == 0 | ||
| finally: | ||
| redis_client.delete(redis_key) | ||
|
|
||
|
|
||
| def test_concurrent_acquire_never_exceeds_maxsize(redis_client, semaphore_key): | ||
| """Under a real race only ``maxsize`` of many contending clients get in. | ||
|
|
||
| The atomic Lua script is what makes this hold: a capacity check and a slot | ||
| reservation split across two round trips would let several clients observe | ||
| the same free slot and all take it. This is the many-client race the unit | ||
| suite defers to a live server; ``fakeredis`` never reproduces it, because a | ||
| single sequential client cannot over-admit. | ||
|
|
||
| One round only lands the interleaving that exposes an over-admitting | ||
| implementation about nine times in ten, so the race is run repeatedly. A | ||
| round costs a full acquire timeout, since the losers each wait theirs out, | ||
| which is why the timeout here is much shorter than elsewhere in this file. | ||
|
|
||
| Every thread hands its result back through a future, so a worker that dies | ||
| fails the test. Left unchecked it would only warn, and the race this test | ||
| exists for would silently shrink to the threads that survived. | ||
| """ | ||
| maxsize = 3 | ||
| thread_count = 12 | ||
| rounds = 5 | ||
| redis_key = semaphore_redis_key(semaphore_key) | ||
|
|
||
| def contend(barrier): | ||
| sem = utils.RedisSemaphore(redis_client, semaphore_key, maxsize, timeout=0.1) | ||
| barrier.wait() | ||
| return sem if sem.acquire() else None | ||
|
|
||
| for _ in range(rounds): | ||
| # A timeout on the barrier turns a thread that never arrives into a | ||
| # failed assertion rather than a hung CI job. | ||
| barrier = threading.Barrier(thread_count, timeout=30) | ||
|
|
||
| with concurrent.futures.ThreadPoolExecutor(max_workers=thread_count) as pool: | ||
| futures = [pool.submit(contend, barrier) for _ in range(thread_count)] | ||
| acquired = [sem for sem in (future.result() for future in futures) if sem] | ||
|
|
||
| # Nothing is released before every future has been collected, so the | ||
| # holder count is stable here. | ||
| assert len(acquired) == maxsize | ||
| assert redis_client.zcard(redis_key) == maxsize | ||
|
|
||
| for sem in acquired: | ||
| sem.release() | ||
|
|
||
| assert redis_client.zcard(redis_key) == 0 | ||
| redis_client.delete(redis_key) | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the only test that exercises the invariant under real concurrency, and it runs the race exactly once. Worth being precise about what that covers, because the obvious reading is stronger than the truth.
A revert of the atomic script to separate round trips is caught deterministically elsewhere:
tests/unit/utils/test_init_locks.py:61-83and five neighbours assertredis.eval.assert_called_once_with(_ACQUIRE_LUA, …), so they fail whether or not an interleaving lands. And the race cannot be reintroduced inside the script, since Redis executes anEVALatomically. So this test is not what stands between a straight revert and a release.Where it is the only guard is a refactor rather than a revert —
register_script/evalsha, say — where those mock assertions get rewritten to match the new call, and part of the capacity decision moves client-side in the process. The rewritten shape tests bless the new shape, the sequential fakeredis tests cannot over-admit with a single client, and this is the only test left that would notice.For that case, one round is roughly a 1-in-10 miss. With
acquire()mutated to three round trips, this test caught it in 54 of 60 cold-process runs against a live Redis (and in 0 of 100 runs against fakeredis, which is why it belongs here rather than in the unit suite). Wrapping the barrier, threads and assertions infor _ in range(5)with aredis_client.delete(redis_key)between rounds takes the miss probability to ~1e-5.One cost note, since this file's runtime is already mostly waiting: a round costs a full acquire timeout, because the nine losers each wait theirs out — 0.51 s measured. Five rounds at
timeout=0.5would add ~2 s. Dropping this test's acquire timeout to 0.1 makes a round 0.11 s with detection unchanged (58 of 60 measured), so five rounds cost about what one round costs today.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Five rounds now, with the acquire timeout dropped to 0.1 for this test and a
delete()on the key between rounds. The docstring states what the repetition buys and why the timeout is short here, so neither reads as an arbitrary constant.