Skip to content
Merged
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
149 changes: 149 additions & 0 deletions tests/integration/test_semaphore.py
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):

Copy link
Copy Markdown
Contributor

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-83 and five neighbours assert redis.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 an EVAL atomically. 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 in for _ in range(5) with a redis_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.5 would 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.

Copy link
Copy Markdown
Member Author

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.

"""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)