Skip to content

[Bug] TraktIndexer worker permanently deadlocks when Trakt API rate limit is hit (v0.23.6) #1382

Description

@cki0000

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

  1. TraktIndexer processes a burst of items after startup (~373 in our case)
  2. Processing stops completely — no more "Service TraktIndexer executed" log entries
  3. Items continue to be submitted to the TraktIndexer executor every 15 minutes (MDBList cycle) but none execute
  4. No errors, no exceptions, no "Unable to index" messages for the stuck items
  5. _futures list grows unboundedly as new futures are submitted but the worker never completes
  6. 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:

  1. TraktIndexer makes ~1,000 Trakt API calls within a 5-minute window (processing a large list)
  2. Trakt returns HTTP 429
  3. requests_ratelimiter.send() catches the 429 and calls _fill_bucket() to add fake timestamps to the local rate-limit bucket
  4. _fill_bucket() calls bucket.put(now) directly on a MemoryQueueBucket — bypassing all rate-limit logic
  5. MemoryQueueBucket.put() is simply self._q.put(item)Queue.put(block=True, timeout=None)
  6. The queue is at capacity (1,000 items, all within the 5-minute window), so Queue.put() blocks forever
  7. No background thread drains expired timestamps, so the not_full condition is never notified
  8. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions