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.
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:PAUSED → STOPPED would then happen only via
_demote_lruwhen PSIsome avg10exceedspsi_threshold, and viadisk_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_stopdoes not disappear. The legacy path —pause_enabledoff, or an app withskip_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_appfreezes the stack and then callsreclaim_compose_stack, which writes each container's RSS to its cgroupmemory.reclaimand 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 adocker unpauseplus 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
_demote_lrudemotes exactly one app per control cycle, by design ("a spike frees memory gradually instead of stopping everything at once"), andrefresh_intervalis 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.reclaim_compose_stackcan 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.always_onapps are never demoted._demote_lruskips 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 arealways_on, nothing frees anything.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_pause21600 against a globalidle_for_stopof 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_enableddefaults tofalseinconfig.tomlandsettings.py, with a per-shardPAUSE_ENABLEDoverride.Follows on from #81. From an internal meeting, 2026-08-31.