Skip to content

Drop the timed PAUSED→STOPPED transition; let memory pressure be the only demotion trigger #227

Description

@ClaydeCode

Once an app is paused its anonymous pages have already been reclaimed to swap, so it costs almost no RAM. Stopping it on a timer after that buys nothing and guarantees a cold start on the next request. Proposal: let memory pressure be the only thing that takes an app from PAUSED to STOPPED.

The change

In _control_app_time, drop the timed second transition:

if app.status == Status.RUNNING and idle >= t1:
    await docker_pause_app(app.name)
elif app.status == Status.PAUSED and idle >= t2:   # <- this
    await docker_stop_app(app.name)

PAUSED → STOPPED would then happen only via _demote_lru when PSI some avg10 exceeds psi_threshold, and via disk_space_low, which already stops everything unconditionally. The RUNNING → PAUSED timer stays: pausing is cheap and reversible, and it is what gets the memory onto swap in the first place.

idle_for_stop does not disappear. The legacy path — pause_enabled off, or an app with skip_pause — is stop-only and still needs it. It stops being the paused apps' clock and becomes the stop threshold for the opt-out path alone.

Why

docker_pause_app freezes the stack and then calls reclaim_compose_stack, which writes each container's RSS to its cgroup memory.reclaim and pages the frozen processes out. The residual cost of a paused app is therefore swap, not RAM. Against that, the difference the user feels is large: unpause is a docker unpause plus page-in, while stop → start is a full container start on a stack that may be several containers deep.

The timer is a proxy for a resource constraint it never measures, and PSI already measures that constraint directly.

What has to hold before this is safe

  • Demotion has to keep up. _demote_lru demotes exactly one app per control cycle, by design ("a spike frees memory gradually instead of stopping everything at once"), and refresh_interval is 10 s. With the timer gone, that loop is the only thing standing between a memory spike and the OOM killer. Worth measuring against a real spike rather than reasoning about — if it cannot keep up, the fix is the demotion rate, not putting the timer back.
  • Swap has to actually exist on the shard. reclaim_compose_stack can only page out if there is somewhere to page to. freeshard-controller#370 records a shard whose swapfile was never activated (Swap=0); on such a host a paused app holds real RAM and this change makes pressure worse, not better. The swap reconciler being reliable becomes a precondition rather than a nicety.
  • Thrash. Keeping many idle stacks resident pushes the working set onto swap. Sustained swap thrashing has been observed to take a host to the point of being unrecoverable. The PSI threshold and the demotion rate are the whole safety margin here.
  • always_on apps are never demoted. _demote_lru skips them outright. Today that is fine because the timer independently clears the rest; with pressure as the only lever, the exemption becomes load-bearing — under sustained pressure where the only remaining candidates are always_on, nothing frees anything.
  • Paused is not free of everything. A paused container keeps its writable layer, its network endpoint and its PIDs. Disk is covered by disk_space_low; an unbounded number of paused stacks is not otherwise bounded by anything.

Relation to #226

#226 is the Immich threshold inversion — idle_for_pause 21600 against a global idle_for_stop of 10800, so it stops one cycle after pausing. If the timed stop goes away, that symptom goes with it, but #226 should still be fixed: a 6 h pause threshold is itself questionable now that pausing is cheap, and the validator gap that let the inversion through is independent of this issue.

Both are latent until the tier is rolled out — pause_enabled defaults to false in config.toml and settings.py, with a per-shard PAUSE_ENABLED override.

Follows on from #81. From an internal meeting, 2026-08-31.

Activity

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

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