Skip to content

Commit 1b93fe5

Browse files
fix: harden QueuePool hygiene with atomic last-used debounce
Use Redis SET NX for last-used debounce, require the request session in resolve_current_user, and avoid double-commit when TierService owns the session. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8164f51 commit 1b93fe5

5 files changed

Lines changed: 54 additions & 33 deletions

File tree

apps/api/app/services/auth/api_key_authentication_service.py

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -174,21 +174,28 @@ async def _update_last_used_best_effort(
174174
) -> None:
175175
"""Update last_used_at on the request session without a nested checkout.
176176
177-
Redis debounce skips redundant writes within the debounce window so job
178-
polls do not compete for QueuePool capacity via create_task+get_db_context.
177+
Redis SET NX debounce skips redundant writes within the debounce window
178+
so job polls do not compete for QueuePool via create_task+get_db_context.
179179
"""
180180
debounce_key = self._get_last_used_debounce_key(api_key_id)
181181
try:
182-
if await redis_service.exists(debounce_key):
182+
acquired = await redis_service.set_nx(
183+
debounce_key,
184+
"1",
185+
ex=_LAST_USED_DEBOUNCE_SECONDS,
186+
)
187+
if not acquired:
183188
return
184189
except Exception:
185190
logger.warning(
186-
"api_key_authentication: failed to read last-used debounce for api_key_id={}",
191+
"api_key_authentication: last-used debounce failed for api_key_id={}; "
192+
"updating anyway",
187193
api_key_id,
188194
)
189195

190196
try:
191197
await self._repository.update_last_used(session, api_key_id)
198+
# Request-scoped sessions do not auto-commit.
192199
await session.commit()
193200
except Exception as exc:
194201
logger.warning(
@@ -198,16 +205,3 @@ async def _update_last_used_best_effort(
198205
await session.rollback()
199206
except Exception:
200207
pass
201-
return
202-
203-
try:
204-
await redis_service.set(
205-
debounce_key,
206-
"1",
207-
ttl=_LAST_USED_DEBOUNCE_SECONDS,
208-
)
209-
except Exception:
210-
logger.warning(
211-
"api_key_authentication: failed to write last-used debounce for api_key_id={}",
212-
api_key_id,
213-
)

apps/api/app/services/rate_limit/job_admission_service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ async def resolve_current_user(
3333
*,
3434
route_context: RouteAdmissionContext,
3535
user_id: str,
36-
db: AsyncSession | None = None,
36+
db: AsyncSession,
3737
) -> CurrentUser:
3838
user_tier = await TierService.get_tier(user_id, session=db)
3939
self._route_policy_service.enforce_guest_api_key_scope(

apps/api/app/services/rate_limit/tier_service.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,19 +49,29 @@ async def get_tier(
4949
return cached_tier
5050

5151
if session is not None:
52-
user_tier = await TierService._resolve_tier_from_db(session, user_id)
52+
user_tier = await TierService._resolve_tier_from_db(
53+
session,
54+
user_id,
55+
commit_on_initialize=True,
56+
)
5357
else:
5458
async with get_db_context() as owned_session:
5559
user_tier = await TierService._resolve_tier_from_db(
5660
owned_session,
5761
user_id,
62+
commit_on_initialize=False,
5863
)
5964

6065
await TierService._set_cached_tier(redis_service, user_id, user_tier)
6166
return user_tier
6267

6368
@staticmethod
64-
async def _resolve_tier_from_db(session: AsyncSession, user_id: str) -> str:
69+
async def _resolve_tier_from_db(
70+
session: AsyncSession,
71+
user_id: str,
72+
*,
73+
commit_on_initialize: bool,
74+
) -> str:
6575
"""Load tier from DB, initializing missing first-use billing state."""
6676
try:
6777
return await TierService._get_tier_from_db(session, user_id)
@@ -70,9 +80,9 @@ async def _resolve_tier_from_db(session: AsyncSession, user_id: str) -> str:
7080
session,
7181
user_id,
7282
)
73-
# Persist first-use init when reusing the request session
74-
# (get_db_context commits on exit when we own the session).
75-
await session.commit()
83+
# Request-scoped sessions do not auto-commit; get_db_context does.
84+
if commit_on_initialize:
85+
await session.commit()
7686
return user_tier
7787

7888
@staticmethod

apps/api/tests/unit/test_job_poll_session_hygiene.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ async def test_validate_api_key_updates_last_used_on_same_session() -> None:
9898
session.commit = AsyncMock()
9999
redis_service = AsyncMock()
100100
redis_service.get = AsyncMock(return_value=None)
101-
redis_service.exists = AsyncMock(return_value=False)
101+
redis_service.set_nx = AsyncMock(return_value=True)
102102
redis_service.set = AsyncMock()
103103
redis_service.sadd = AsyncMock()
104104
redis_service.ttl = AsyncMock(return_value=-2)
@@ -125,10 +125,6 @@ async def test_validate_api_key_updates_last_used_on_same_session() -> None:
125125
"app.services.auth.api_key_authentication_service.hash_api_key",
126126
return_value="hash-1",
127127
),
128-
patch(
129-
"app.services.auth.api_key_authentication_service.get_db_context",
130-
create=True,
131-
) as get_db_context_mock,
132128
patch("asyncio.create_task") as create_task_mock,
133129
):
134130
user_id = await service.validate_api_key(session, "kw_test_key")
@@ -137,11 +133,10 @@ async def test_validate_api_key_updates_last_used_on_same_session() -> None:
137133
repository.update_last_used.assert_awaited_once_with(session, "key-1")
138134
session.commit.assert_awaited_once()
139135
create_task_mock.assert_not_called()
140-
get_db_context_mock.assert_not_called()
141-
redis_service.set.assert_any_await(
136+
redis_service.set_nx.assert_awaited_once_with(
142137
"api-key:last-used-debounce:key-1",
143138
"1",
144-
ttl=300,
139+
ex=300,
145140
)
146141

147142

@@ -151,7 +146,7 @@ async def test_validate_api_key_skips_last_used_when_debounced() -> None:
151146
session.commit = AsyncMock()
152147
redis_service = AsyncMock()
153148
redis_service.get = AsyncMock(return_value=None)
154-
redis_service.exists = AsyncMock(return_value=True)
149+
redis_service.set_nx = AsyncMock(return_value=False)
155150
redis_service.set = AsyncMock()
156151
redis_service.sadd = AsyncMock()
157152
redis_service.ttl = AsyncMock(return_value=-2)
@@ -203,7 +198,7 @@ async def __aexit__(self, *args: object) -> None:
203198

204199
redis_service = AsyncMock()
205200
redis_service.get = AsyncMock(side_effect=[None, None]) # api-key miss, tier miss
206-
redis_service.exists = AsyncMock(return_value=False)
201+
redis_service.set_nx = AsyncMock(return_value=True)
207202
redis_service.set = AsyncMock()
208203
redis_service.sadd = AsyncMock()
209204
redis_service.ttl = AsyncMock(return_value=-2)

packages/shared-python/shared/services/redis/redis_service.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,28 @@ async def _operation():
111111
original_exception=e,
112112
)
113113

114+
async def set_nx(self, key: str, value: str, ex: int) -> bool:
115+
"""Atomic SET NX EX — set only if the key does not already exist.
116+
117+
Returns ``True`` if the key was written, ``False`` if it already existed.
118+
Does not JSON-encode the value and does not fall back to a default TTL.
119+
"""
120+
try:
121+
client = await self._get_client()
122+
full_key = self._build_key(key)
123+
124+
async def _operation():
125+
return await client.set(full_key, value, nx=True, ex=ex)
126+
127+
return bool(await self._execute_with_retry(_operation))
128+
except Exception as e:
129+
logger.error(f"Redis SET NX operation failed: {e}")
130+
raise RedisOperationError(
131+
internal_message=f"SET NX operation failed: {str(e)}",
132+
operation="SET_NX",
133+
original_exception=e,
134+
)
135+
114136
async def get(self, key: str, default: Any = None) -> Any:
115137
"""Get a key value."""
116138
try:

0 commit comments

Comments
 (0)