Summary
When the Trakt API returns HTTP 429 (rate limit exceeded), the TraktIndexer worker thread enters a permanent deadlock and never recovers. No errors are logged, no exceptions are propagated — the thread simply hangs forever inside pyrate_limiter's MemoryQueueBucket.put(). All subsequent content (from MDBList, Plex Watchlist, or any other source) is silently queued but never processed until manual restart.
After restart, the deadlock recurs within minutes once enough content is queued to hit the 1000 req/5min limit again.
Environment
- Riven version: v0.23.6 (
ghcr.io/rivenmedia/riven:latest, commit 2f20135)
- Deployment: Docker Compose (LXC container on Proxmox)
- Content sources: MDBList (1,122-item list), Plex Watchlist (~102 items)
- Real-Debrid + Torrentio scraper
Symptoms
- TraktIndexer processes a burst of items after startup (~373 in our case)
- Processing stops completely — no more
"Service TraktIndexer executed" log entries
- Items continue to be submitted to the TraktIndexer executor every 15 minutes (MDBList cycle) but none execute
- No errors, no exceptions, no
"Unable to index" messages for the stuck items
_futures list grows unboundedly as new futures are submitted but the worker never completes
- Manual restart temporarily fixes it, but the deadlock recurs within minutes
Log pattern:
12:55:00 | DEBUG | event_manager._process_future - Service TraktIndexer executed with External ID tt22325698
# ← Last execution. Dead silence after this.
12:55:32 | DEBUG | event_manager.submit_job - Submitting service TraktIndexer to be executed with External ID tt39034285
12:55:32 | DEBUG | event_manager._find_or_create_executor - Executor for TraktIndexer found.
# ← Submitted but never executes. This repeats every 15 min indefinitely.
Root Cause
Identified via py-spy thread dump on the live process.
Thread 47 (TraktIndexer_0) — idle forever:
Thread 47 (idle): "TraktIndexer_0"
wait (threading.py:327) ← blocking here
put (queue.py:140) ← Queue.put(block=True, timeout=None)
put (pyrate_limiter/bucket.py:87) ← MemoryQueueBucket.put()
_fill_bucket (requests_ratelimiter/requests_ratelimiter.py:138)
send (requests_ratelimiter/requests_ratelimiter.py:97)
request (requests/sessions.py:589)
_request (src/program/utils/request.py:156)
execute (src/program/apis/trakt_api.py:43)
get_show_aliases (src/program/apis/trakt_api.py:205)
map_item_from_data (src/program/apis/trakt_api.py:340)
create_item_from_imdb_id (src/program/apis/trakt_api.py:251)
run (indexers/trakt.py:69)
run_thread_with_db_item (src/program/db/db_functions.py:458)
run (concurrent/futures/thread.py:58)
_worker (concurrent/futures/thread.py:83)
The deadlock chain:
- TraktIndexer makes ~1,000 Trakt API calls within a 5-minute window (processing a large list)
- Trakt returns HTTP 429
requests_ratelimiter.send() catches the 429 and calls _fill_bucket() to add fake timestamps to the local rate-limit bucket
_fill_bucket() calls bucket.put(now) directly on a MemoryQueueBucket — bypassing all rate-limit logic
MemoryQueueBucket.put() is simply self._q.put(item) — Queue.put(block=True, timeout=None)
- The queue is at capacity (1,000 items, all within the 5-minute window), so
Queue.put() blocks forever
- No background thread drains expired timestamps, so the
not_full condition is never notified
- Worker thread is permanently stuck
Key code paths:
MemoryQueueBucket.put() in pyrate_limiter/bucket.py:87:
def put(self, item: float):
return self._q.put(item) # Queue.put(block=True, timeout=None) — BLOCKS FOREVER if full
_fill_bucket() in requests_ratelimiter/requests_ratelimiter.py:138:
def _fill_bucket(self, request):
...
for _ in range(rate.limit - item_count):
bucket.put(now) # ← calls MemoryQueueBucket.put() directly, no max_delay, no timeout
get_rate_limit_params() in src/program/utils/request.py returns max_delay=0 by default, which would correctly raise BucketFullException in the normal ratelimit() code path. But _fill_bucket() bypasses ratelimit() entirely and calls bucket.put() directly — so max_delay is never consulted here.
Fix
In src/program/apis/trakt_api.py, switch from MemoryQueueBucket (blocking) to MemoryListBucket (non-blocking):
# Before:
rate_limit_params = get_rate_limit_params(max_calls=1000, period=300)
# After:
rate_limit_params = get_rate_limit_params(max_calls=1000, period=300, use_memory_list=True)
MemoryListBucket.put() returns 0 when full instead of blocking:
def put(self, item: float):
with self._lock:
if self.size() < self.maxsize():
self._q.append(item)
return 1
return 0 # ← Non-blocking, never hangs
This is the minimal one-line fix in Riven's code. The get_rate_limit_params() helper already supports this via use_memory_list=True.
Secondary fix (defense-in-depth) — add try/except in requests_ratelimiter._fill_bucket():
# In requests_ratelimiter/requests_ratelimiter.py _fill_bucket():
for _ in range(rate.limit - item_count):
try:
bucket.put(now)
except Exception:
break # Don't block if bucket is full
After applying the use_memory_list=True fix, TraktIndexer processed 3,881+ items continuously without stalling, compared to getting stuck at 373 before.
Why This Is Not Configuration-Specific
Anyone with a large content list will hit the Trakt rate limit (1,000 req/5min public API):
- Each
create_item_from_imdb_id() call makes 2–3 Trakt API calls (/search/imdb, get_show_aliases, get_show)
- A 400-item list generates ~1,000–1,200 API calls
- MDBList lists with 1,000+ items will reliably trigger this on every restart
The bug is in the interaction between requests_ratelimiter._fill_bucket() and MemoryQueueBucket — the wrong bucket type for a scenario where _fill_bucket() can be called on an already-full bucket.
Additional Notes
TraktIndexer Cannot Be Disabled
TraktIndexer has self.initialized = True hardcoded — it runs unconditionally regardless of whether the user has enabled the "Trakt" content source. This makes sense architecturally (it's Riven's metadata backend, not a content source), but it means all Riven instances are dependent on Trakt's public API rate limit, even those that have "Trakt" disabled in settings. This should be documented.
Related Issues
Persistence of Fix
The patch to trakt_api.py lives in the container's writable layer and survives docker compose restart but is lost on docker compose up -d --force-recreate. A volume-mounted patched file or an upstream fix is needed for permanent resolution.
Labels
bug, TraktIndexer, rate-limiting, deadlock, pipeline
Summary
When the Trakt API returns HTTP 429 (rate limit exceeded), the
TraktIndexerworker thread enters a permanent deadlock and never recovers. No errors are logged, no exceptions are propagated — the thread simply hangs forever insidepyrate_limiter'sMemoryQueueBucket.put(). All subsequent content (from MDBList, Plex Watchlist, or any other source) is silently queued but never processed until manual restart.After restart, the deadlock recurs within minutes once enough content is queued to hit the 1000 req/5min limit again.
Environment
ghcr.io/rivenmedia/riven:latest, commit2f20135)Symptoms
"Service TraktIndexer executed"log entries"Unable to index"messages for the stuck items_futureslist grows unboundedly as new futures are submitted but the worker never completesLog pattern:
Root Cause
Identified via py-spy thread dump on the live process.
Thread 47 (TraktIndexer_0) — idle forever:
The deadlock chain:
requests_ratelimiter.send()catches the 429 and calls_fill_bucket()to add fake timestamps to the local rate-limit bucket_fill_bucket()callsbucket.put(now)directly on aMemoryQueueBucket— bypassing all rate-limit logicMemoryQueueBucket.put()is simplyself._q.put(item)—Queue.put(block=True, timeout=None)Queue.put()blocks forevernot_fullcondition is never notifiedKey code paths:
MemoryQueueBucket.put()inpyrate_limiter/bucket.py:87:_fill_bucket()inrequests_ratelimiter/requests_ratelimiter.py:138:get_rate_limit_params()insrc/program/utils/request.pyreturnsmax_delay=0by default, which would correctly raiseBucketFullExceptionin the normalratelimit()code path. But_fill_bucket()bypassesratelimit()entirely and callsbucket.put()directly — somax_delayis never consulted here.Fix
In
src/program/apis/trakt_api.py, switch fromMemoryQueueBucket(blocking) toMemoryListBucket(non-blocking):MemoryListBucket.put()returns0when full instead of blocking:This is the minimal one-line fix in Riven's code. The
get_rate_limit_params()helper already supports this viause_memory_list=True.Secondary fix (defense-in-depth) — add try/except in
requests_ratelimiter._fill_bucket():After applying the
use_memory_list=Truefix, TraktIndexer processed 3,881+ items continuously without stalling, compared to getting stuck at 373 before.Why This Is Not Configuration-Specific
Anyone with a large content list will hit the Trakt rate limit (1,000 req/5min public API):
create_item_from_imdb_id()call makes 2–3 Trakt API calls (/search/imdb,get_show_aliases,get_show)The bug is in the interaction between
requests_ratelimiter._fill_bucket()andMemoryQueueBucket— the wrong bucket type for a scenario where_fill_bucket()can be called on an already-full bucket.Additional Notes
TraktIndexer Cannot Be Disabled
TraktIndexerhasself.initialized = Truehardcoded — it runs unconditionally regardless of whether the user has enabled the "Trakt" content source. This makes sense architecturally (it's Riven's metadata backend, not a content source), but it means all Riven instances are dependent on Trakt's public API rate limit, even those that have "Trakt" disabled in settings. This should be documented.Related Issues
requests-ratelimiterto<0.10— v0.9.0 migrates to pyrate-limiter v4 and removesmax_delay, which may resolve the underlying bucket behaviorPersistence of Fix
The patch to
trakt_api.pylives in the container's writable layer and survivesdocker compose restartbut is lost ondocker compose up -d --force-recreate. A volume-mounted patched file or an upstream fix is needed for permanent resolution.Labels
bug,TraktIndexer,rate-limiting,deadlock,pipeline