Skip to content

fix(cache): stream library refresh instead of loading every episode at once - #102

Merged
JourneyOver merged 1 commit into
JourneyDocker:mainfrom
nickwolf:fix/stream-library-cache-refresh
Jul 27, 2026
Merged

fix(cache): stream library refresh instead of loading every episode at once#102
JourneyOver merged 1 commit into
JourneyDocker:mainfrom
nickwolf:fix/stream-library-cache-refresh

Conversation

@nickwolf

Copy link
Copy Markdown

refresh_library_cache() loads the entire library into memory before it starts iterating, so peak memory scales with library size. On my server (65,906 episodes after ignores) that's enough to get the process OOM-killed before the refresh finishes, and since refresh_library_on_scan defaults to true, every Plex scan starts it again. I ended up at 40 OOM kills in one day and a RestartCount of 228.

I spent a while assuming this was just my memory limit being too low. Bumped 512M to 1G, and it died at 1G too:

[Wed Jul 15 13:54:35 2026] Memory cgroup out of memory: Killed process 3794228 (python)
    total-vm:651508kB, anon-rss:518240kB          <- 512M limit
[Wed Jul 15 16:03:55 2026] Memory cgroup out of memory: Killed process 3866010 (python)
    total-vm:1130496kB, anon-rss:1044976kB        <- 1G limit
    oom-kill:constraint=CONSTRAINT_MEMCG ... task=python

It took me embarrassingly long to even confirm these were OOMs. Docker reported OOMKilled: false, the healthcheck stayed green the whole time, and docker stats showed 19% because I kept sampling minutes after a restart, when it sits flat at ~116M. The kills are only visible in the host's dmesg (I'm on a nested LXC, so the container's own cgroup counters read clean). I also burned time on the AttributeError: 'NoneType' object has no attribute 'replace' in plexapi's Episode.__repr__ that shows up in my logs. That one's real but separate, and it isn't what's killing the process.

The cause is episodes(). section.all(libtype="episode", container_size=1000) already pages the HTTP requests in 1000s, but episodes.extend(...) accumulates every page into one list, and nothing is released until the refresh returns. That's fine on a library that fits in RAM. Mine doesn't, and there's no limit I can set that fixes it, because the list grows with the library and I just hit the new ceiling instead.

refresh_library_cache() only needs one episode at a time. So this adds iter_episodes(), which pages each show section and yields each page before requesting the next, and leaves episodes() as a list(iter_episodes()) wrapper. Measured on the same 65,906-episode library, plexapi 4.18.1:

before:  OOM-killed at >1024MB, refresh never completed
after:   92MB peak RSS, 65,906/65,906 episodes, no duplicates, 129s

The 92MB is mostly the episode_parts dict of part-key strings, roughly 0.3KB per episode. I haven't taken a heap snapshot, so I can't claim that's 100% of what remains.

Four things in the diff that aren't obvious:

maxresults=page_size might look redundant next to container_size, but without it fetchItems() keeps paginating internally up to the section's total size and hands back the whole thing in one list, which is exactly what this is trying to avoid.

The cursor advances by len(page) and stops only on an empty page, rather than advancing by a fixed page_size. A short page mid-section would otherwise leave a permanent gap, and a skipped episode isn't harmless: it drops out of episode_parts, so the next refresh sees it as newly added and reapplies the show's track selection over whatever the user picked manually.

Results are sorted by addedAt so episodes imported while the scan is running land after the cursor instead of shifting the offsets of pages that haven't been read yet. The refresh runs because something was just added, so that window isn't theoretical. Offset paging can still shift on deletes; I don't think there's a fix for that short of keyset pagination, which Plex doesn't offer.

The unprivileged branch used to do a library-wide library.all(libtype="episode"). Episodes only live in show sections, so iterating those covers the same items. It also avoids a whole-library query that reliably times out at 30s on my server.

One related change: refresh_library_cache() now skips collecting added/updated when the cache is cold. There's nothing to diff against at that point, so every episode counts as "added" and gets retained to build a list that the cold-cache caller in __init__ discards anyway.

Something I did not fix here: in the normal scan path, added/updated still hold full Episode objects. If a bulk operation changes part keys across the library (quality upgrades, a path remap, a reimport), everything lands in updated and you're back around 790MB on a library this size. Fixing that means collecting lightweight records and having the callers refetch, which changes the contract in status.py and plex_server.py. That's a bigger change and it'd bury this one, so I'll send it as a second PR once this is settled.

Does this approach look acceptable to you? I'm happy to rework it if you'd rather solve it a different way. There are no tests in the repo so I verified against a live server; I can share the paging-correctness check I ran (pages disjoint, no overlap, stable order across calls) if that's useful.

This may also be what's behind #39.

…t once

refresh_library_cache() iterated over PlexServer.episodes(), which materializes
every episode of every non-ignored show library into a single list before the
loop body runs. Peak memory therefore scales with library size.

On a 65,906-episode library this exceeds 1GB and the process is OOM-killed part
way through the refresh, before it can complete. Because refresh_library_on_scan
defaults to true, every Plex library scan retriggers it, so the container ends up
in a restart loop (observed: 40 OOM kills in one day, 228 restarts). Raising the
memory limit only moves the ceiling, as the allocation grows with the library.

Add UnprivilegedPlexServer.iter_episodes(), which pages through each show section
and yields each page before requesting the next, so pages are released as they
are consumed. episodes() is kept as a thin list(iter_episodes()) wrapper for
compatibility.

Notes on the implementation:

- maxresults is required, not redundant. Without it fetchItems() paginates
  internally up to the section's total size and returns it in one list, which
  would silently reinstate the behaviour being fixed.
- The cursor advances by len(page) and stops only on an empty page. Advancing by
  a fixed page_size would leave a permanent gap if a short page were ever
  returned mid-section, and a skipped episode is not benign: it is evicted from
  episode_parts, so the next refresh treats it as newly added and re-applies the
  show's track selection over every user's manual choice.
- Results are sorted by addedAt so episodes imported while the scan is running
  sort after the cursor, rather than shifting the offsets of unread pages. The
  refresh runs precisely because content was just added, so this window is real.
- The unprivileged branch previously issued a library-wide query; episodes only
  exist in show sections, so iterating those covers the same items without
  asking the server for everything at once.

refresh_library_cache() additionally skips collecting added/updated when the
cache is cold. There is nothing to diff against, so every episode would be
reported as "added" and retained to build a list that the only cold-cache caller
discards.

Measured against a 65,906-episode library (plexapi 4.18.1):

  before: OOM-killed, >1024MB, refresh never completed
  after:  92MB peak RSS, 65,906/65,906 episodes, no duplicates, completes in 129s

The remaining growth is the episode_parts dict of part-key strings (~0.3KB per
episode), not the Episode objects.
@JourneyOver

JourneyOver commented Jul 16, 2026

Copy link
Copy Markdown
Member

Does this approach look acceptable to you

Yes it looks acceptable to me, and will gladly be appreciated and merged in hopefully soon.

I'm happy to rework it if you'd rather solve it a different way.

No need to do so unless you think you can do/make a better approach at it before I merge in this.

There are no tests in the repo so I verified against a live server

Yea sorry about that I've been meaning to set up tests again at some point in the repo so I could stop needing to test everything against my own machine, I just really haven't had time honestly to do so yet. Feel free to post the if you want, but I plan on just running things when I get a chance against my own server since it has grown a lot over the past month, though I'll probably still end up merging this in regardless if I can reproduce things or not and let it sit in main a bit before pushing an actual release.

Anyways as noted in other threads I am/have pretty busy and will be for the next couple months so I don't have a whole lot of time to sit down and really check issues/prs (especially testing them to make sure things are good and such) right now, so at the moment everything with this repo is kind of just on hold sadly until I have some free time to actually sit down and look over things, test things and such for any of the current open issues/prs.

@nickwolf

Copy link
Copy Markdown
Author

Sounds good! I'm running this on my end locally and I'll report back if anything changes beyond what I've found.

@JourneyOver

Copy link
Copy Markdown
Member

Looks like I might have a tiny bit of free time next week to sit down and test this PR out a bit and possibly merge it in. You haven't run into any issues due to this PR so far while waiting though hopefully?

@nickwolf

Copy link
Copy Markdown
Author

Yup, still running smoothly on my end. In the last 8 days, the RAM high water mark is only 180MG, instead of the 1G it was hitting prior. Still seems to be working well.

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.

2 participants