Skip to content

Commit da44dd4

Browse files
committed
pubsub: stop leaking Redis connections from abandoned subscriptions
Every pub/sub subscription pins one dedicated Redis connection in the API process. redis-py 8 lowered the default asyncio pool size to 100, and once 100 subscriptions are held every other Redis command (INCR on subscribe, PUBLISH on node updates) fails with MaxConnectionsError, which surfaced as HTTP 500 for all writes and took the whole pipeline down. The subscriptions piled up because a client that subscribes and dies before its first listen() leaves last_poll unset, and cleanup_stale_subscriptions() only ever looked at last_poll, so such entries were never removed. A crash-looping external client exhausted the pool in about an hour. Fixes: - cleanup_stale_subscriptions() ages never-polled subscriptions from their creation time. - On every subscribe, evict that user's never-polled subscriptions older than abandoned_subscription_seconds (60s) and then enforce a cap of max_subscriptions_per_user (20) active subscriptions, returning HTTP 429 when exceeded. - Pass an explicit redis_max_connections (1000) to the Redis pool. - Close the previous PubSub when rebuilding after a connection error, and close the half-initialised PubSub if SUBSCRIBE fails, so neither path leaks a pooled connection. - Keep the keep-alive loop alive across transient Redis errors. - Map Redis MaxConnectionsError / ConnectionError to a 503 with Retry-After instead of a generic 500. All new settings are overridable through the environment via PubSubSettings. Unit tests cover the cleanup, eviction, limit, rebuild and keep-alive paths. Signed-off-by: Denys Fedoryshchenko <denys.f@collabora.com>
1 parent e9c829d commit da44dd4

4 files changed

Lines changed: 332 additions & 10 deletions

File tree

‎api/config.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ class PubSubSettings(BaseSettings):
2727
cloud_events_source: str = "https://api.kernelci.org/"
2828
redis_host: str = "redis"
2929
redis_db_number: int = 1
30+
# Every pub/sub subscription pins one dedicated Redis connection, so the
31+
# pool must be large enough for all concurrent subscribers plus regular
32+
# commands. redis-py >= 8 defaults to only 100.
33+
redis_max_connections: int = 1000
34+
# Maximum number of concurrent subscriptions a single user may hold.
35+
# Before the limit is enforced, that user's subscriptions which were
36+
# never polled and are older than abandoned_subscription_seconds are
37+
# evicted, so a crash-looping client cannot pile them up.
38+
max_subscriptions_per_user: int = 20
39+
abandoned_subscription_seconds: int = 60
3040
keep_alive_period: int = 45
3141
# MongoDB durable pub/sub settings
3242
event_ttl_days: int = 7 # Auto-delete events after N days

‎api/main.py‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
parse_node_obj,
5858
)
5959
from pydantic import BaseModel
60+
from redis import exceptions as redis_exceptions
6061

6162
from .auth import Authentication
6263
from .config import AuthSettings
@@ -80,7 +81,7 @@
8081
UserUpdate,
8182
UserUpdateRequest,
8283
)
83-
from .pubsub_mongo import PubSub
84+
from .pubsub_mongo import PubSub, SubscriptionLimitError
8485
from .user_manager import create_user_manager, get_user_manager
8586

8687
SUBSCRIPTION_CLEANUP_INTERVAL_MINUTES = 15 # How often to run cleanup task
@@ -2146,7 +2147,13 @@ async def subscribe(
21462147
options["promiscuous"] = promisc
21472148
if subscriber_id:
21482149
options["subscriber_id"] = subscriber_id
2149-
return await pubsub.subscribe(channel, user.username, options)
2150+
try:
2151+
return await pubsub.subscribe(channel, user.username, options)
2152+
except SubscriptionLimitError as error:
2153+
raise HTTPException(
2154+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
2155+
detail=str(error),
2156+
) from error
21502157

21512158

21522159
@app.post("/unsubscribe/{sub_id}")
@@ -2385,10 +2392,27 @@ def traceback_exception_handler(request: Request, exc: Exception):
23852392
)
23862393

23872394

2395+
def redis_unavailable_exception_handler(request: Request, exc: Exception):
2396+
"""Report Redis connection problems as a retryable 503, not a 500"""
2397+
print(f"Redis unavailable: {exc}")
2398+
return JSONResponse(
2399+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
2400+
headers={"Retry-After": "5"},
2401+
content={"message": "Redis unavailable, please retry"},
2402+
)
2403+
2404+
23882405
versioned_app.add_exception_handler(ValueError, value_error_exception_handler)
23892406
versioned_app.add_exception_handler(
23902407
errors.InvalidId, invalid_id_exception_handler
23912408
)
2409+
versioned_app.add_exception_handler(
2410+
redis_exceptions.MaxConnectionsError,
2411+
redis_unavailable_exception_handler,
2412+
)
2413+
versioned_app.add_exception_handler(
2414+
redis_exceptions.ConnectionError, redis_unavailable_exception_handler
2415+
)
23922416
versioned_app.add_exception_handler(Exception, traceback_exception_handler)
23932417

23942418

‎api/pubsub_mongo.py‎

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
logger = logging.getLogger(__name__)
3434

3535

36+
class SubscriptionLimitError(Exception):
37+
"""Raised when a user already holds too many active subscriptions"""
38+
39+
3640
class PubSub:
3741
"""Hybrid Pub/Sub implementation with MongoDB durability
3842
@@ -80,7 +84,9 @@ def __init__(
8084
db_number = self._settings.redis_db_number
8185

8286
self._redis = aioredis.from_url(
83-
"redis://" + host + "/" + str(db_number), health_check_interval=30
87+
"redis://" + host + "/" + str(db_number),
88+
health_check_interval=30,
89+
max_connections=self._settings.redis_max_connections,
8490
)
8591

8692
# MongoDB setup
@@ -226,9 +232,26 @@ async def _keep_alive(self):
226232
break
227233
for channel in channels:
228234
# Use _publish_keepalive to avoid storing BEEP in MongoDB
229-
await self._publish_keepalive(channel, "BEEP")
235+
try:
236+
await self._publish_keepalive(channel, "BEEP")
237+
except aioredis.RedisError as exc:
238+
# A transient Redis error (e.g. exhausted connection
239+
# pool) must not kill the keep-alive loop for good
240+
logger.warning("Keep-alive publish failed: %s", exc)
230241
await asyncio.sleep(self._settings.keep_alive_period)
231242

243+
@staticmethod
244+
async def _close_redis_sub(redis_sub):
245+
"""Best-effort close of a Redis PubSub, releasing its connection"""
246+
try:
247+
await redis_sub.unsubscribe()
248+
except aioredis.RedisError as exc:
249+
logger.debug("PubSub unsubscribe failed: %s", exc)
250+
try:
251+
await redis_sub.aclose()
252+
except aioredis.RedisError as exc:
253+
logger.debug("PubSub close failed: %s", exc)
254+
232255
async def _publish_keepalive(self, channel: str, data: str):
233256
"""Publish keep-alive message (Redis only, no MongoDB storage)"""
234257
attributes = {
@@ -372,16 +395,23 @@ async def subscribe(
372395
Returns:
373396
Subscription object with id, channel, user, promiscuous fields
374397
"""
375-
sub_id = await self._redis.incr(self.ID_KEY)
376398
subscriber_id = options.get("subscriber_id") if options else None
377399
promiscuous = options.get("promiscuous", False) if options else False
378400

401+
await self._enforce_subscription_limit(user)
402+
sub_id = await self._redis.incr(self.ID_KEY)
403+
379404
async with self._lock:
380405
redis_sub = self._redis.pubsub()
381406
sub = Subscription(
382407
id=sub_id, channel=channel, user=user, promiscuous=promiscuous
383408
)
384-
await redis_sub.subscribe(channel)
409+
try:
410+
await redis_sub.subscribe(channel)
411+
except aioredis.RedisError:
412+
# Never leave a half-initialised PubSub holding a connection
413+
await self._close_redis_sub(redis_sub)
414+
raise
385415

386416
self._subscriptions[sub_id] = {
387417
"redis_sub": redis_sub,
@@ -407,6 +437,54 @@ async def subscribe(
407437

408438
return sub
409439

440+
async def _enforce_subscription_limit(self, user: str):
441+
"""Evict the user's abandoned subscriptions, then enforce the cap
442+
443+
A subscription that has never been polled and is older than
444+
abandoned_subscription_seconds belongs to a client that died (or
445+
crash-loops) right after subscribing. Those are evicted first so
446+
they do not count against the user. If the user still holds
447+
max_subscriptions_per_user active subscriptions afterwards, a
448+
SubscriptionLimitError is raised.
449+
"""
450+
limit = self._settings.max_subscriptions_per_user
451+
cutoff = datetime.utcnow() - timedelta(
452+
seconds=self._settings.abandoned_subscription_seconds
453+
)
454+
455+
async with self._lock:
456+
user_sub_ids = []
457+
abandoned_ids = []
458+
for sub_id, sub_data in self._subscriptions.items():
459+
if sub_data["sub"].user != user:
460+
continue
461+
user_sub_ids.append(sub_id)
462+
if (
463+
sub_data.get("last_poll") is None
464+
and sub_data["created"] < cutoff
465+
):
466+
abandoned_ids.append(sub_id)
467+
468+
# unsubscribe() takes the lock itself, so call it outside of it
469+
for sub_id in abandoned_ids:
470+
try:
471+
await self.unsubscribe(sub_id)
472+
except KeyError:
473+
pass
474+
if abandoned_ids:
475+
logger.warning(
476+
"Evicted %d abandoned (never polled) subscriptions of %s",
477+
len(abandoned_ids),
478+
user,
479+
)
480+
481+
active = len(user_sub_ids) - len(abandoned_ids)
482+
if limit and active >= limit:
483+
raise SubscriptionLimitError(
484+
f"User {user} already holds {active} active subscriptions "
485+
f"(limit {limit}); unsubscribe or poll them first"
486+
)
487+
410488
async def _setup_durable_subscription(
411489
self,
412490
sub_id: int,
@@ -479,8 +557,7 @@ async def unsubscribe(self, sub_id: int, user: Optional[str] = None):
479557

480558
self._subscriptions.pop(sub_id)
481559
self._update_channels()
482-
await sub["redis_sub"].unsubscribe()
483-
await sub["redis_sub"].close()
560+
await self._close_redis_sub(sub["redis_sub"])
484561

485562
async def _get_listen_subscription(
486563
self, sub_id: int, user: Optional[str] = None
@@ -532,10 +609,14 @@ async def _rebuild_redis_subscription(
532609
):
533610
async with self._lock:
534611
channel = sub.channel
612+
old_redis_sub = sub_data.get("redis_sub")
535613
new_redis_sub = self._redis.pubsub()
536614
await new_redis_sub.subscribe(channel)
537615
self._subscriptions[sub_id]["redis_sub"] = new_redis_sub
538616
sub_data["redis_sub"] = new_redis_sub
617+
# Release the broken PubSub, otherwise its pooled connection leaks
618+
if old_redis_sub is not None:
619+
await self._close_redis_sub(old_redis_sub)
539620

540621
def _maybe_update_delivery_offset(
541622
self, subscriber_id: Optional[str], sub_data: dict, msg_data: Any
@@ -695,6 +776,10 @@ async def cleanup_stale_subscriptions(
695776
) -> int:
696777
"""Remove subscriptions not polled recently
697778
779+
Subscriptions that were never polled at all are aged from their
780+
creation time, otherwise a client that subscribes and dies before
781+
its first listen() leaks a Redis connection forever.
782+
698783
For durable subscriptions, only the in-memory state is cleaned up.
699784
The MongoDB subscriber state is preserved for reconnection.
700785
"""
@@ -703,8 +788,10 @@ async def cleanup_stale_subscriptions(
703788

704789
async with self._lock:
705790
for sub_id, sub_data in self._subscriptions.items():
706-
last_poll = sub_data.get("last_poll")
707-
if last_poll and last_poll < cutoff:
791+
last_activity = sub_data.get("last_poll") or sub_data.get(
792+
"created"
793+
)
794+
if last_activity and last_activity < cutoff:
708795
stale_ids.append(sub_id)
709796

710797
for sub_id in stale_ids:

0 commit comments

Comments
 (0)