Skip to content

fix(cache): give each cache instance its own dict - #233

Open
JediBrooker wants to merge 1 commit into
markbeep:mainfrom
JediBrooker:fix/shared-cache-instances
Open

JediBrooker wants to merge 1 commit into
markbeep:mainfrom
JediBrooker:fix/shared-cache-instances

Conversation

@JediBrooker

Copy link
Copy Markdown

What

_cache was declared as a class attribute on both SimpleCache and StringConfigCache, so every instance in the app shared a single dict.

class SimpleCache[VT, *KTs]:
    _cache: dict[tuple[*KTs], tuple[int, VT]] = {}   # shared by every instance

For StringConfigCache this is harmless today — the config classes have disjoint key literals, so they were only sharing storage.

For SimpleCache it's a bug. prowlarr_source_cache and prowlarr_indexer_cache both key off a single string, so cached search results land in the same keyspace as cached indexers. get_indexers() then reads those search results back out:

indexers = list(prowlarr_indexer_cache.get_all(source_ttl).values())
if len(indexers) > 0:
    return IndexerResponse(indexers={indexer.id: indexer for indexer in indexers}, ...)

indexer is a list[ProwlarrSource], and the whole thing lands in the except Exception in get_indexers.

Reproducing

Search for any book, then open Settings > Indexers. It renders the error state with:

Failed to access Prowlarr to fetch indexers  error="'list' object has no attribute 'id'"

Minimal version:

from datetime import datetime
from app.internal.models import TorrentSource
from app.internal.prowlarr.util import prowlarr_source_cache, prowlarr_indexer_cache

src = TorrentSource(guid="g1", indexer_id=1, indexer="MAM", title="Some Release", size=1,
                    publish_date=datetime.now(), info_url=None, indexer_flags=[],
                    seeders=1, leechers=0)
prowlarr_source_cache.set([src], "Dune")

# the indexer cache now returns the search results
assert prowlarr_indexer_cache.get_all(9999) == {}   # fails on main

It's intermittent in practice, which is probably why it hasn't been pinned down: it only shows up once something has been cached, and flush_prowlarr_cache() (called when the Prowlarr settings are saved) happens to paper over it — flush() does self._cache = {}, which creates an instance attribute and silently diverges from the shared class one from then on. So the fix also makes flush() mean what it looks like it means.

The change

Moves both dicts into __init__. Six lines, no behaviour change beyond the instances no longer colliding.

Checked

  • uv run basedpyright — clean
  • uv run ruff format --check app — clean
  • uv run alembic check on a fresh DB — no new operations (no schema change here)
  • every SimpleCache/StringConfigCache subclass is instantiated exactly once as a module-level singleton, so nothing depended on the sharing
  • config key literals are disjoint across AuthConfig, oidcConfig, ProwlarrConfig, ABSConfig and the quality/indexer configs, so no config loses a value it was previously reading out of a sibling's cache

Written with AI assistance; the diagnosis and the reproduction above were verified by hand against main.

`_cache` was declared as a class attribute on both SimpleCache and
StringConfigCache, so every instance in the app shared one dict.

For StringConfigCache this only meant the configs shared storage, since
their keys do not overlap. For SimpleCache it is a bug: both
prowlarr_source_cache and prowlarr_indexer_cache key off a single string,
so cached search results land in the same keyspace as cached indexers.
Once any book has been searched for, get_indexers() reads those search
results back out of prowlarr_indexer_cache and Settings>Indexers fails
with "'list' object has no attribute 'id'".

Moving the dict into __init__ keeps the instances independent. flush()
already rebound self._cache, so it was silently creating an instance
attribute and diverging from the shared one anyway.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant