From 0cd64e113a32b411480f40cea022b0e20a4c32f5 Mon Sep 17 00:00:00 2001 From: WhoamiI00 Date: Tue, 25 Aug 2026 22:54:29 +0530 Subject: [PATCH 1/5] fix(api): carry the full project id in cache and lock keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_pack` cut every scope segment down to the last 12 characters of the id, so two projects whose ids share that suffix shared every cache entry in every namespace that did not opt out — including `check_permissions` and `check_action_access`, which decide authorization. Project ids are server-generated UUID4s, so a caller cannot steer a collision and the odds of one arising are remote, but one project reading another's cached permission result is not a risk worth carrying by default. Ids are carried whole now. Readers, writers, the pattern branch of `invalidate_cache` and the lock keys all derive from this one function, so they move together and no namespace is left unable to clear its own entries. The dash padding stays, so an absent or short id produces the same fixed-width segment it always did. The lock namespace is the one place the key shape cannot simply change: during a rolling deploy, pods still on the previous release take the truncated key, and a lock held only under the new key would not exclude them. Lock operations therefore cover both keys for one release, claiming the legacy key first — a pod on the previous release sets only that one, so taking it is what makes the two generations exclude each other. That cover keeps colliding projects serializing against each other on locks until it is removed, which is deliberate: letting two pods into the same critical section is worse than two unrelated tenants queueing. Cache keys, where the permission caches live, separate immediately. Closes #6166 --- api/oss/src/utils/caching.py | 48 ++- api/oss/src/utils/locking.py | 127 ++++++-- .../unit/utils/test_cache_key_tenancy.py | 296 ++++++++++++++++++ 3 files changed, 435 insertions(+), 36 deletions(-) create mode 100644 api/oss/tests/pytest/unit/utils/test_cache_key_tenancy.py diff --git a/api/oss/src/utils/caching.py b/api/oss/src/utils/caching.py index 5a21373f462..a6b0cc98170 100644 --- a/api/oss/src/utils/caching.py +++ b/api/oss/src/utils/caching.py @@ -40,24 +40,50 @@ # HELPERS ---------------------------------------------------------------------- +AGENTA_CACHE_SCOPE_WIDTH = 12 # minimum width of a scope segment + + +def _scope( + value: Optional[str], + legacy_truncated: Optional[bool] = False, +) -> str: + """One scope segment (`p:` or `u:`) of a cache key. + + Scope segments used to be cut down to their last 12 characters. Two projects whose + ids share that suffix then shared every cache entry in every namespace — including + `check_permissions` and `check_action_access`, which decide authorization. Ids are + server-generated UUID4s so a caller cannot steer a collision, but one project reading + another's cached permission result is not a thing to leave to chance. The id is + carried whole now. + + The padding stays: an absent or short id still produces the same fixed-width segment + it always did, so key shape is unchanged everywhere the id was not being cut. + + `legacy_truncated` reproduces the pre-change shape and exists only so lock holders + can span a rolling deploy — see `oss.src.utils.locking`. + """ + value = value or "" + + if legacy_truncated and len(value) > AGENTA_CACHE_SCOPE_WIDTH: + value = value[-AGENTA_CACHE_SCOPE_WIDTH:] + + return value + "-" * (AGENTA_CACHE_SCOPE_WIDTH - len(value)) + + def _pack( namespace: Optional[str] = None, key: Optional[Union[str, dict]] = None, project_id: Optional[str] = None, user_id: Optional[str] = None, pattern: Optional[bool] = False, + legacy_truncated_scope: Optional[bool] = False, ) -> str: - if project_id: - project_id = project_id[-12:] if len(project_id) > 12 else project_id - else: - project_id = "" - project_id = project_id + "-" * (12 - len(project_id)) - - if user_id: - user_id = user_id[-12:] if len(user_id) > 12 else user_id - user_id = user_id + "-" * (12 - len(user_id)) - else: - user_id = "*" if pattern else "-" * 12 + project_id = _scope(project_id, legacy_truncated_scope) + # A pattern with no user scope matches every user, which the fixed-width padding + # cannot express — so the wildcard wins over the padded segment in that one case. + user_id = ( + "*" if pattern and not user_id else _scope(user_id, legacy_truncated_scope) + ) namespace = namespace or ("" if not pattern else "*") diff --git a/api/oss/src/utils/locking.py b/api/oss/src/utils/locking.py index cf0f5c14602..fddb67fdf75 100644 --- a/api/oss/src/utils/locking.py +++ b/api/oss/src/utils/locking.py @@ -38,6 +38,73 @@ """ +# TRANSITIONAL: SPANNING THE FULL-PROJECT-ID DEPLOY ----------------------------- +# +# Scope segments in cache and lock keys used to carry only the last 12 characters of +# an id (see `caching._scope`). During a rolling deploy, pods still on the previous +# release take the truncated key, so a lock held only under the full-id key would not +# exclude them and mutual exclusion would be lost for the length of the deploy. +# Every lock operation therefore covers both keys for one release. +# +# REMOVE once no pod predating the full-id change is running: drop the second element +# of `_lock_keys` and the `legacy_key` branches below. That is the whole surface. + + +def _lock_keys( + namespace: str, + key: Optional[Union[str, dict]] = None, + project_id: Optional[str] = None, + user_id: Optional[str] = None, +) -> tuple[str, Optional[str]]: + """This release's lock key, and the previous release's key when it differs.""" + lock_key = _pack( + namespace=f"lock:{namespace}", + key=key, + project_id=project_id, + user_id=user_id, + ) + legacy_key = _pack( + namespace=f"lock:{namespace}", + key=key, + project_id=project_id, + user_id=user_id, + legacy_truncated_scope=True, + ) + + # Ids no longer than the segment width were never truncated, so the two shapes + # coincide and there is no second key to cover. + return lock_key, (legacy_key if legacy_key != lock_key else None) + + +async def _renew_if_owner(lock_key: str, owner: Optional[str], ttl: int) -> bool: + if owner: + return bool( + await _lock_engine.eval( + _LOCK_RENEW_IF_OWNER_SCRIPT, + 1, + lock_key, + owner, + str(ttl), + ) + ) + + return bool(await _lock_engine.expire(lock_key, ttl)) + + +async def _release_if_owner(lock_key: str, owner: Optional[str]) -> bool: + if owner: + return bool( + await _lock_engine.eval( + _LOCK_RELEASE_IF_OWNER_SCRIPT, + 1, + lock_key, + owner, + ) + ) + + return bool(await _lock_engine.delete(lock_key)) + + # LOCK-STORE PRIMITIVES -------------------------------------------------------- # # Thin pass-throughs to the lock Redis client for callers that manage their own @@ -117,14 +184,26 @@ async def acquire_lock( ) """ try: - lock_key = _pack( - namespace=f"lock:{namespace}", + lock_key, legacy_key = _lock_keys( + namespace=namespace, key=key, project_id=project_id, user_id=user_id, ) lock_owner = uuid4().hex + # The legacy key is claimed first: a pod on the previous release sets only that + # one, so taking it is what makes the two generations exclude each other. Claiming + # it second would let both generations hold their own key and enter together. + if legacy_key is not None: + if not await _lock_engine.set(legacy_key, lock_owner, nx=True, ex=ttl): + if LOCK_DEBUG: + log.debug( + "[lock] BLOCKED", + key=legacy_key, + ) + return None + # Atomic SET NX: Returns True if lock acquired, False if already held acquired = await _lock_engine.set(lock_key, lock_owner, nx=True, ex=ttl) @@ -137,6 +216,11 @@ async def acquire_lock( ) return lock_owner else: + # This caller is not entering the critical section, so it must not leave the + # legacy key held until its TTL — that would block everyone for `ttl`. + if legacy_key is not None: + await _release_if_owner(legacy_key, lock_owner) + if LOCK_DEBUG: log.debug( "[lock] BLOCKED", @@ -180,23 +264,19 @@ async def renew_lock( True if lock was renewed, False if lock has already expired or on error """ try: - lock_key = _pack( - namespace=f"lock:{namespace}", + lock_key, legacy_key = _lock_keys( + namespace=namespace, key=key, project_id=project_id, user_id=user_id, ) - if owner: - renewed = await _lock_engine.eval( - _LOCK_RENEW_IF_OWNER_SCRIPT, - 1, - lock_key, - owner, - str(ttl), - ) - else: - renewed = await _lock_engine.expire(lock_key, ttl) + renewed = await _renew_if_owner(lock_key, owner, ttl) + + # Held for as long as the lock itself, or a pod on the previous release would + # take it the moment it lapsed while this holder was still inside the section. + if legacy_key is not None: + await _renew_if_owner(legacy_key, owner, ttl) if renewed: if LOCK_DEBUG: @@ -250,22 +330,19 @@ async def release_lock( await release_lock(namespace="account-creation", key=email) """ try: - lock_key = _pack( - namespace=f"lock:{namespace}", + lock_key, legacy_key = _lock_keys( + namespace=namespace, key=key, project_id=project_id, user_id=user_id, ) - if owner: - deleted = await _lock_engine.eval( - _LOCK_RELEASE_IF_OWNER_SCRIPT, - 1, - lock_key, - owner, - ) - else: - deleted = await _lock_engine.delete(lock_key) + deleted = await _release_if_owner(lock_key, owner) + + # Released even when the primary was already gone: the two were taken together, + # so leaving this one behind would block the section for the rest of its TTL. + if legacy_key is not None: + await _release_if_owner(legacy_key, owner) if deleted: if LOCK_DEBUG: diff --git a/api/oss/tests/pytest/unit/utils/test_cache_key_tenancy.py b/api/oss/tests/pytest/unit/utils/test_cache_key_tenancy.py new file mode 100644 index 00000000000..81f99bc9e1d --- /dev/null +++ b/api/oss/tests/pytest/unit/utils/test_cache_key_tenancy.py @@ -0,0 +1,296 @@ +"""Cache and lock keys must not merge two tenants that share an id suffix. + +Scope segments used to carry only the last 12 characters of an id, so two projects +whose ids ended the same way shared every cache entry in every namespace — including +`check_permissions` and `check_action_access`, which decide authorization (#6166). + +The lock tests use a real in-memory fakeredis instance so they run without an external +Redis process; they skip when `fakeredis` is not installed. +""" + +from unittest.mock import patch +from uuid import uuid4 + +import pytest +import pytest_asyncio + +from oss.src.utils.caching import _pack, _scope, AGENTA_CACHE_SCOPE_WIDTH +from oss.src.utils import locking + + +# Two distinct UUID4s contrived to end in the same 12 characters: the collision the old +# truncation produced. Server-generated ids make this astronomically unlikely rather than +# impossible, and the consequence is a cross-tenant read. +COLLIDING_SUFFIX = "b2c3d4e5f6a7" +PROJECT_A = f"11111111-1111-4111-8111-1111{COLLIDING_SUFFIX}" +PROJECT_B = f"22222222-2222-4222-8222-2222{COLLIDING_SUFFIX}" + +USER_A = f"33333333-3333-4333-8333-3333{COLLIDING_SUFFIX}" +USER_B = f"44444444-4444-4444-8444-4444{COLLIDING_SUFFIX}" + + +def _key(namespace="check_action_access", project_id=None, user_id=None, **kwargs): + return _pack( + namespace=namespace, + key="read", + project_id=project_id, + user_id=user_id, + **kwargs, + ) + + +# KEY SHAPE -------------------------------------------------------------------- + + +def test_projects_sharing_an_id_suffix_do_not_share_a_cache_key(): + assert _key(project_id=PROJECT_A) != _key(project_id=PROJECT_B) + + +def test_users_sharing_an_id_suffix_do_not_share_a_cache_key(): + assert _key(project_id=PROJECT_A, user_id=USER_A) != _key( + project_id=PROJECT_A, user_id=USER_B + ) + + +def test_the_whole_id_is_in_the_key(): + assert PROJECT_A in _key(project_id=PROJECT_A) + assert USER_A in _key(project_id=PROJECT_A, user_id=USER_A) + + +def test_an_invalidation_pattern_is_scoped_to_one_project(): + """`invalidate_cache` without a key scans a pattern; it must not span both projects.""" + pattern = _pack(project_id=PROJECT_A, pattern=True) + + assert PROJECT_A in pattern + assert PROJECT_B not in pattern + + +def test_an_invalidation_pattern_still_wildcards_an_absent_user(): + """Carrying the whole id must not cost the user wildcard a pattern relies on. + + A pattern with no user scope is meant to match every user, which the fixed-width + padded segment cannot express. + """ + assert "u:*" in _pack(project_id=PROJECT_A, pattern=True) + # A named user is still scoped, pattern or not. + assert "u:*" not in _pack(project_id=PROJECT_A, user_id=USER_A, pattern=True) + + +def test_a_short_or_absent_id_keeps_its_historical_segment(): + """Only ids that were being cut change shape; everything else is untouched.""" + assert _scope(None) == "-" * AGENTA_CACHE_SCOPE_WIDTH + assert _scope("") == "-" * AGENTA_CACHE_SCOPE_WIDTH + assert _scope("abc") == "abc" + "-" * (AGENTA_CACHE_SCOPE_WIDTH - 3) + + exactly_wide = "a" * AGENTA_CACHE_SCOPE_WIDTH + assert _scope(exactly_wide) == exactly_wide + # At or below the width, truncating was already a no-op, so both shapes agree. + assert _scope(exactly_wide, legacy_truncated=True) == _scope(exactly_wide) + + +def test_the_legacy_shape_still_collides(): + """The opt-in legacy shape reproduces the bug — that is what makes it transitional.""" + assert _key(project_id=PROJECT_A, legacy_truncated_scope=True) == _key( + project_id=PROJECT_B, legacy_truncated_scope=True + ) + + +# LOCKS ACROSS A ROLLING DEPLOY ------------------------------------------------ + + +@pytest_asyncio.fixture +async def fake_redis(): + """Point the lock engine at an in-memory redis, as `test_evaluation_runtime_locks`. + + fakeredis executes Lua only with the optional `lupa` backend, which is not a + dependency here, so the two ownership scripts are supplied as an equivalent shim. + Everything else — `acquire_lock`, `renew_lock`, `release_lock` — runs as written. + """ + fakeredis = pytest.importorskip("fakeredis") + aioredis = pytest.importorskip("fakeredis.aioredis") + engine = pytest.importorskip("oss.src.dbs.redis.shared.engine") + + server = fakeredis.FakeServer() + client = aioredis.FakeRedis(server=server, decode_responses=False) + + async def _eval(script, numkeys, *args): + """`GET`, compare against the owner token, then `EXPIRE` or `DEL`.""" + lock_key, owner, *rest = args + + if await client.get(lock_key) != owner.encode(): + return 0 + + if script == locking._LOCK_RENEW_IF_OWNER_SCRIPT: + return int(bool(await client.expire(lock_key, int(rest[0])))) + + return int(bool(await client.delete(lock_key))) + + lock_engine = engine.get_lock_engine() + + with ( + patch.object(lock_engine, "_client", return_value=client), + # `LockEngine.__getattr__` forwards to the client; a real attribute shadows it. + patch.object(lock_engine, "eval", _eval, create=True), + ): + yield client + + await client.aclose() + + +async def test_two_ordinary_projects_do_not_share_a_lock(fake_redis): + project_a, project_b = str(uuid4()), str(uuid4()) + + assert ( + await locking.acquire_lock(namespace="eval", key="run", project_id=project_a) + is not None + ) + assert ( + await locking.acquire_lock(namespace="eval", key="run", project_id=project_b) + is not None + ) + + +async def test_colliding_projects_still_share_a_lock_while_the_cover_lasts(fake_redis): + """A deliberate trade-off, confined to the lock namespace and to one release. + + Covering the previous release's key means two projects whose ids share a suffix keep + serializing against each other on locks. Dropping the cover instead would let pods on + either side of a rolling deploy into the same critical section, and losing mutual + exclusion is worse than two unrelated tenants queueing. Removing the transitional + cover (see `locking`) is what closes this last case. + + Cache keys are separated immediately either way — that is where the permission caches + live, and it is the part with a security consequence. + """ + assert _key(project_id=PROJECT_A) != _key(project_id=PROJECT_B) + + held = await locking.acquire_lock(namespace="eval", key="run", project_id=PROJECT_A) + assert held is not None + + assert ( + await locking.acquire_lock(namespace="eval", key="run", project_id=PROJECT_B) + is None + ) + + +async def test_a_lock_still_excludes_the_same_project(fake_redis): + held = await locking.acquire_lock(namespace="eval", key="run", project_id=PROJECT_A) + assert held is not None + + assert ( + await locking.acquire_lock(namespace="eval", key="run", project_id=PROJECT_A) + is None + ) + + await locking.release_lock( + namespace="eval", key="run", project_id=PROJECT_A, owner=held + ) + assert ( + await locking.acquire_lock(namespace="eval", key="run", project_id=PROJECT_A) + is not None + ) + + +async def test_a_holder_on_the_previous_release_still_blocks_this_one(fake_redis): + """The rolling-deploy case: an old pod holds only the truncated key. + + Without the transitional cover this is the window where two pods both believe they + hold the same logical lock. + """ + legacy_key = _pack( + namespace="lock:eval", + key="run", + project_id=PROJECT_A, + legacy_truncated_scope=True, + ) + # Exactly what a pod running the previous release writes. + await fake_redis.set(legacy_key, b"old-pod-owner", nx=True, ex=30) + + assert ( + await locking.acquire_lock(namespace="eval", key="run", project_id=PROJECT_A) + is None + ) + + +async def test_a_blocked_acquire_does_not_strand_the_legacy_key(fake_redis): + """Losing the race on the primary key must not leave the legacy one held. + + The legacy key is claimed first, so a caller that then loses the primary has to give + it back — otherwise a failed acquire would block the section for the full TTL. + """ + lock_key, legacy_key = locking._lock_keys( + namespace="eval", key="run", project_id=PROJECT_A + ) + assert legacy_key is not None + + # Someone already holds the primary; the caller below will claim the legacy key, + # fail on the primary, and must then release what it took. + await fake_redis.set(lock_key, b"another-owner", nx=True, ex=30) + + assert ( + await locking.acquire_lock(namespace="eval", key="run", project_id=PROJECT_A) + is None + ) + assert await fake_redis.get(legacy_key) is None + + +async def test_release_clears_both_generations(fake_redis): + lock_key, legacy_key = locking._lock_keys( + namespace="eval", key="run", project_id=PROJECT_A + ) + assert legacy_key is not None + + owner = await locking.acquire_lock( + namespace="eval", key="run", project_id=PROJECT_A + ) + assert owner is not None + assert await fake_redis.get(legacy_key) is not None + + await locking.release_lock( + namespace="eval", key="run", project_id=PROJECT_A, owner=owner + ) + + assert await fake_redis.get(lock_key) is None + assert await fake_redis.get(legacy_key) is None + + +async def test_a_short_scope_takes_only_one_key(fake_redis): + """When the two shapes coincide there is no second key — and no double SET NX. + + Claiming the same key twice with NX would fail the second call and make every + acquire in this shape look blocked. + """ + short_project = "abc" + lock_key, legacy_key = locking._lock_keys( + namespace="eval", key="run", project_id=short_project + ) + assert legacy_key is None + + assert ( + await locking.acquire_lock( + namespace="eval", key="run", project_id=short_project + ) + is not None + ) + assert await fake_redis.get(lock_key) is not None + + +async def test_renew_keeps_both_generations_alive(fake_redis): + lock_key, legacy_key = locking._lock_keys( + namespace="eval", key="run", project_id=PROJECT_A + ) + assert legacy_key is not None + + owner = await locking.acquire_lock( + namespace="eval", key="run", project_id=PROJECT_A, ttl=5 + ) + assert owner is not None + + assert await locking.renew_lock( + namespace="eval", key="run", project_id=PROJECT_A, ttl=90, owner=owner + ) + + # A legacy key left on the original TTL would lapse mid-section and let a pod on the + # previous release in. + assert await fake_redis.ttl(lock_key) > 5 + assert await fake_redis.ttl(legacy_key) > 5 From e9776e210f6a9b41bb21735ca6d38ca08e681aae Mon Sep 17 00:00:00 2001 From: WhoamiI00 Date: Tue, 25 Aug 2026 22:57:10 +0530 Subject: [PATCH 2/5] docs(pr): add before/after image for the cache key scope change --- .../6166-full-project-id-cache-keys.png | Bin 0 -> 35467 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .github/pr-assets/6166-full-project-id-cache-keys.png diff --git a/.github/pr-assets/6166-full-project-id-cache-keys.png b/.github/pr-assets/6166-full-project-id-cache-keys.png new file mode 100644 index 0000000000000000000000000000000000000000..d20f47d2025120a393ab2973dbdfb8db455f3e63 GIT binary patch literal 35467 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 Date: Wed, 2 Sep 2026 14:55:44 +0530 Subject: [PATCH 3/5] fix(api): fail lock renewal when the legacy key is gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transitional legacy key was renewed but its result discarded, so `renew_lock` reported the primary key's outcome alone. If the primary survived while the legacy key expired or was evicted, the call returned true and the holder carried on inside the critical section — while a pod on the previous release could acquire the now-missing legacy key and enter it too. That is the cross-release mutual exclusion the dual-key path exists to preserve. Both renewals now have to succeed. The legacy renewal is still attempted rather than short-circuited, so the primary is extended even when the legacy key is the one that failed and the caller can finish and release cleanly. --- api/oss/src/utils/locking.py | 7 ++- .../unit/utils/test_cache_key_tenancy.py | 48 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/api/oss/src/utils/locking.py b/api/oss/src/utils/locking.py index fddb67fdf75..e4411691b7a 100644 --- a/api/oss/src/utils/locking.py +++ b/api/oss/src/utils/locking.py @@ -275,8 +275,13 @@ async def renew_lock( # Held for as long as the lock itself, or a pod on the previous release would # take it the moment it lapsed while this holder was still inside the section. + # Its renewal has to count: if the legacy key is gone while the primary survives, + # the section is no longer mutually exclusive across releases, and reporting + # success would leave the caller believing otherwise. Renew it either way rather + # than short-circuiting, so the primary is still extended when the legacy key is + # what failed. if legacy_key is not None: - await _renew_if_owner(legacy_key, owner, ttl) + renewed = await _renew_if_owner(legacy_key, owner, ttl) and renewed if renewed: if LOCK_DEBUG: diff --git a/api/oss/tests/pytest/unit/utils/test_cache_key_tenancy.py b/api/oss/tests/pytest/unit/utils/test_cache_key_tenancy.py index 81f99bc9e1d..551df8fce04 100644 --- a/api/oss/tests/pytest/unit/utils/test_cache_key_tenancy.py +++ b/api/oss/tests/pytest/unit/utils/test_cache_key_tenancy.py @@ -294,3 +294,51 @@ async def test_renew_keeps_both_generations_alive(fake_redis): # previous release in. assert await fake_redis.ttl(lock_key) > 5 assert await fake_redis.ttl(legacy_key) > 5 + + +async def test_renew_fails_when_the_legacy_key_is_gone(fake_redis): + """A lapsed legacy key breaks mutual exclusion even while the primary survives. + + Renewal used to report the primary's result alone, so a holder kept working inside + the section believing the lock was safe while a pod on the previous release could + acquire the now-missing legacy key and enter it too. + """ + lock_key, legacy_key = locking._lock_keys( + namespace="eval", key="run", project_id=PROJECT_A + ) + assert legacy_key is not None + + owner = await locking.acquire_lock( + namespace="eval", key="run", project_id=PROJECT_A, ttl=5 + ) + assert owner is not None + + # The legacy key expires or is evicted while the primary is still held. + await fake_redis.delete(legacy_key) + + assert not await locking.renew_lock( + namespace="eval", key="run", project_id=PROJECT_A, ttl=90, owner=owner + ) + + # The primary is still renewed, so the caller can finish and release cleanly rather + # than having the section pulled out from under it twice over. + assert await fake_redis.ttl(lock_key) > 5 + + +async def test_renew_fails_when_the_primary_key_is_gone(fake_redis): + """The mirror case: the primary lapsing must not be masked by a live legacy key.""" + lock_key, legacy_key = locking._lock_keys( + namespace="eval", key="run", project_id=PROJECT_A + ) + assert legacy_key is not None + + owner = await locking.acquire_lock( + namespace="eval", key="run", project_id=PROJECT_A, ttl=5 + ) + assert owner is not None + + await fake_redis.delete(lock_key) + + assert not await locking.renew_lock( + namespace="eval", key="run", project_id=PROJECT_A, ttl=90, owner=owner + ) From 6b42d567ae86abb62c114fdfc2ab073ed4f2d77a Mon Sep 17 00:00:00 2001 From: WhoamiI00 Date: Thu, 3 Sep 2026 11:19:35 +0530 Subject: [PATCH 4/5] fix(api): release the legacy key when primary acquisition fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy key is claimed first, so if claiming the primary raised — or the task was cancelled between the two sets — the function returned without releasing it. The section went to nobody while a key that blocks both release generations stayed held for its full TTL. A flag now tracks the window where this call holds the legacy key without having handed the section to anyone, cleared once the caller owns it or it has already been released, and a `finally` releases exactly that case. `finally` rather than an except branch because cancellation leaves the key stranded just as an exception does, and `CancelledError` never reaches the existing `except Exception`. --- api/oss/src/utils/locking.py | 27 ++++++++++++- .../unit/utils/test_cache_key_tenancy.py | 39 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/api/oss/src/utils/locking.py b/api/oss/src/utils/locking.py index e4411691b7a..1944a86f85d 100644 --- a/api/oss/src/utils/locking.py +++ b/api/oss/src/utils/locking.py @@ -183,6 +183,13 @@ async def acquire_lock( owner=lock_owner, ) """ + lock_owner = uuid4().hex + legacy_key = None + # Set only while this call holds the legacy key without having handed the section to + # anyone. Cleared once the caller owns it or it has already been released, so the + # `finally` below cleans up exactly the abandoned case. + legacy_claimed = False + try: lock_key, legacy_key = _lock_keys( namespace=namespace, @@ -190,7 +197,6 @@ async def acquire_lock( project_id=project_id, user_id=user_id, ) - lock_owner = uuid4().hex # The legacy key is claimed first: a pod on the previous release sets only that # one, so taking it is what makes the two generations exclude each other. Claiming @@ -203,11 +209,14 @@ async def acquire_lock( key=legacy_key, ) return None + legacy_claimed = True # Atomic SET NX: Returns True if lock acquired, False if already held acquired = await _lock_engine.set(lock_key, lock_owner, nx=True, ex=ttl) if acquired: + # The caller owns both keys from here; `release_lock` clears them together. + legacy_claimed = False if LOCK_DEBUG: log.debug( "[lock] ACQUIRED", @@ -220,6 +229,7 @@ async def acquire_lock( # legacy key held until its TTL — that would block everyone for `ttl`. if legacy_key is not None: await _release_if_owner(legacy_key, lock_owner) + legacy_claimed = False if LOCK_DEBUG: log.debug( @@ -237,6 +247,21 @@ async def acquire_lock( raise return None + finally: + # Reached when claiming the primary key raised or the task was cancelled between + # the two sets. The section went to nobody, so leaving the legacy key held would + # block both generations for its full TTL. Cancellation matters as much as an + # exception here, which is why this is a `finally` rather than an except branch. + if legacy_claimed and legacy_key is not None: + try: + await _release_if_owner(legacy_key, lock_owner) + except Exception as cleanup_error: # pragma: no cover - best effort + log.error( + f"[lock] LEGACY CLEANUP ERROR: namespace={namespace} " + f"key={key} error={cleanup_error}", + exc_info=True, + ) + async def renew_lock( namespace: str, diff --git a/api/oss/tests/pytest/unit/utils/test_cache_key_tenancy.py b/api/oss/tests/pytest/unit/utils/test_cache_key_tenancy.py index 551df8fce04..a2a70040a6f 100644 --- a/api/oss/tests/pytest/unit/utils/test_cache_key_tenancy.py +++ b/api/oss/tests/pytest/unit/utils/test_cache_key_tenancy.py @@ -325,6 +325,45 @@ async def test_renew_fails_when_the_legacy_key_is_gone(fake_redis): assert await fake_redis.ttl(lock_key) > 5 +async def test_acquire_releases_the_legacy_key_when_the_primary_set_raises(fake_redis): + """A failed primary claim must not strand the legacy key for its whole TTL. + + The legacy key is taken first, so if claiming the primary raises — or the task is + cancelled between the two — the section goes to nobody while a key that blocks both + generations stays held. + """ + lock_key, legacy_key = locking._lock_keys( + namespace="eval", key="run", project_id=PROJECT_A + ) + assert legacy_key is not None + + real_set = fake_redis.set + + async def fail_on_primary(name, *args, **kwargs): + if name == lock_key: + raise RuntimeError("redis went away") + return await real_set(name, *args, **kwargs) + + with patch.object(locking._lock_engine, "set", side_effect=fail_on_primary): + assert ( + await locking.acquire_lock( + namespace="eval", key="run", project_id=PROJECT_A, ttl=90 + ) + is None + ) + + assert await fake_redis.get(legacy_key) is None + assert await fake_redis.get(lock_key) is None + + # The section is free, so the next caller gets it rather than waiting out the TTL. + assert ( + await locking.acquire_lock( + namespace="eval", key="run", project_id=PROJECT_A, ttl=5 + ) + is not None + ) + + async def test_renew_fails_when_the_primary_key_is_gone(fake_redis): """The mirror case: the primary lapsing must not be masked by a live legacy key.""" lock_key, legacy_key = locking._lock_keys( From 2178a0be48e91f6f5a90f04cc4b88e1898f3590d Mon Sep 17 00:00:00 2001 From: WhoamiI00 Date: Sat, 5 Sep 2026 21:53:39 +0530 Subject: [PATCH 5/5] fix(api): mark the legacy-key obligation before awaiting the claim `legacy_claimed` was set after the awaited SET NX returned, so a cancellation arriving once Redis had applied the write but before its reply reached the client skipped the assignment entirely. The `finally` block then saw no obligation, the key stayed held for its whole TTL, and pods on both releases read a false block while the section had gone to nobody. The flag is now raised before the await and lowered on a confirmed refusal. Claiming an obligation that may not exist is the safe direction: `_release_if_owner` compares the owner token, so cleanup is a no-op when the key belongs to another pod. --- api/oss/src/utils/locking.py | 11 ++- .../unit/utils/test_cache_key_tenancy.py | 70 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/api/oss/src/utils/locking.py b/api/oss/src/utils/locking.py index 1944a86f85d..d944ae5a4cd 100644 --- a/api/oss/src/utils/locking.py +++ b/api/oss/src/utils/locking.py @@ -202,14 +202,23 @@ async def acquire_lock( # one, so taking it is what makes the two generations exclude each other. Claiming # it second would let both generations hold their own key and enter together. if legacy_key is not None: + # Marked before the await rather than after it. Cancellation can arrive once + # Redis has applied the SET but before its reply reaches us, and a flag set + # afterwards would never record that this call owns the key — leaving it held + # for its whole TTL with nothing to release it. Claiming an obligation that + # may not exist is the safe direction: `_release_if_owner` is ownership + # checked, so it does nothing when the token is not ours. + legacy_claimed = True if not await _lock_engine.set(legacy_key, lock_owner, nx=True, ex=ttl): + # A confirmed refusal — the key belongs to someone else, so there is + # nothing of ours to drop. + legacy_claimed = False if LOCK_DEBUG: log.debug( "[lock] BLOCKED", key=legacy_key, ) return None - legacy_claimed = True # Atomic SET NX: Returns True if lock acquired, False if already held acquired = await _lock_engine.set(lock_key, lock_owner, nx=True, ex=ttl) diff --git a/api/oss/tests/pytest/unit/utils/test_cache_key_tenancy.py b/api/oss/tests/pytest/unit/utils/test_cache_key_tenancy.py index a2a70040a6f..c3bb3def5cd 100644 --- a/api/oss/tests/pytest/unit/utils/test_cache_key_tenancy.py +++ b/api/oss/tests/pytest/unit/utils/test_cache_key_tenancy.py @@ -8,6 +8,7 @@ Redis process; they skip when `fakeredis` is not installed. """ +import asyncio from unittest.mock import patch from uuid import uuid4 @@ -364,6 +365,75 @@ async def fail_on_primary(name, *args, **kwargs): ) +async def test_acquire_releases_the_legacy_key_when_cancelled_mid_claim(fake_redis): + """Cancellation between Redis applying the SET and the reply arriving. + + The claim has taken effect but the caller never learns it, so a flag set after the + await would never record the obligation and the key would sit held for its whole + TTL — blocking pods on both releases while the section went to nobody. + """ + lock_key, legacy_key = locking._lock_keys( + namespace="eval", key="run", project_id=PROJECT_A + ) + assert legacy_key is not None + + real_set = fake_redis.set + applied = asyncio.Event() + + async def set_then_suspend(name, *args, **kwargs): + result = await real_set(name, *args, **kwargs) + if name == legacy_key: + # Redis has the key; the reply has not been delivered yet. + applied.set() + await asyncio.sleep(3600) + return result + + with patch.object(locking._lock_engine, "set", side_effect=set_then_suspend): + task = asyncio.create_task( + locking.acquire_lock( + namespace="eval", key="run", project_id=PROJECT_A, ttl=90 + ) + ) + await asyncio.wait_for(applied.wait(), timeout=5) + assert await fake_redis.get(legacy_key) is not None + + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + # Cleanup ran despite the caller never seeing the reply. + assert await fake_redis.get(legacy_key) is None + assert await fake_redis.get(lock_key) is None + + assert ( + await locking.acquire_lock( + namespace="eval", key="run", project_id=PROJECT_A, ttl=5 + ) + is not None + ) + + +async def test_a_refused_legacy_claim_leaves_the_holders_key_alone(fake_redis): + """Marking the obligation before the await must not delete someone else's key. + + `_release_if_owner` is ownership checked, so the broader cleanup window is safe. + """ + _, legacy_key = locking._lock_keys( + namespace="eval", key="run", project_id=PROJECT_A + ) + assert legacy_key is not None + + await fake_redis.set(legacy_key, b"another-pod-owner", nx=True, ex=30) + + assert ( + await locking.acquire_lock(namespace="eval", key="run", project_id=PROJECT_A) + is None + ) + + # Still the other pod's, untouched. + assert await fake_redis.get(legacy_key) == b"another-pod-owner" + + async def test_renew_fails_when_the_primary_key_is_gone(fake_redis): """The mirror case: the primary lapsing must not be masked by a live legacy key.""" lock_key, legacy_key = locking._lock_keys(