feat: PAUSED+PAGED app tier — freeze idle apps, page to swap, wake in ms (#81)#145
feat: PAUSED+PAGED app tier — freeze idle apps, page to swap, wake in ms (#81)#145ClaydeCode wants to merge 10 commits into
Conversation
The PAUSED+PAGED tier (#81) needs a status between RUNNING and STOPPED, and per-app control over the two idle thresholds. Lifecycle is reworked per the design spec: always_on (unchanged), skip_pause (opt out of the pause tier, e.g. apps that misbehave when frozen), idle_for_pause (T1) and idle_for_stop (T2), both optional with fallback to new global defaults. AppMeta v1.2 -> v1.3 migrates on read: idle_time_for_shutdown carries over as idle_for_stop, so existing app-repository files keep their tuned stop timing without regeneration. No DB migration: installed_apps.status is a text column, only the accepted value set grows. Migration is exercised against every mock_app_store fixture (the app-repository stand-ins), including the always_on and missing-lifecycle shapes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…switch Global defaults for the two idle tiers (T1 pause 60s, T2 stop 1800s), the PSI demotion threshold, and pause_enabled=false as the rollout kill-switch — flipping it back must revert the control loop to legacy stop-only behavior. Prod values are stated explicitly in config.toml for operator visibility; tests get fast tiers (5s/12s) while keeping the flag off so the existing suite exercises the legacy path unchanged. Env override verified: FREESHARD_APPS__LIFECYCLE__PAUSE_ENABLED=true lands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
read_memory_pressure() parses `some avg10` from the PSI file the controller mounts at /host/pressure/memory (freeshard-controller#246), returning 0.0 when the mount is absent so shards on older core versions and dev setups degrade to no pressure signal instead of failing. reclaim_compose_stack() writes each container's memory.current into its cgroup v2 memory.reclaim, covering both the systemd and cgroupfs driver layouts. The write blocks while the kernel pages out — potentially seconds for a GB-RSS app — so it runs via asyncio.to_thread. A partial reclaim (EAGAIN) is logged at WARN and not treated as failure, per the design's no-defensive-fallbacks rule: pause still succeeded, only less was paged out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docker_pause_app freezes a RUNNING app via compose pause, pages its memory out through memory_pressure.reclaim_compose_stack, and lands on Status.PAUSED. docker_unpause_app reverses it in milliseconds — the fast wake this tier exists for. The frozen state has one sharp edge, handled at every affected site: a frozen container can be neither stopped nor removed. docker_stop_app accepts PAUSED and unfreezes before compose stop (the PAUSED -> STOPPED tier transition and docker_stop_all_apps); docker_shutdown_app unfreezes on the force path (process shutdown while apps are paused); the uninstall worker unfreezes while the DB status still says PAUSED, before flipping to UNINSTALLING. docker_start_app short-circuits PAUSED into an unpause so every legacy start path wakes a paused app correctly. Status audit per the architecture contract: worker assertions unchanged (pause never touches *_QUEUED), write_traefik_dyn_config keeps routing PAUSED apps (wake-on-request needs the route), control_apps handles PAUSED in the follow-up lifecycle commit. Every new status write fires on_apps_update, keeping the forwardAuth app cache honest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
control_apps gains the second tier: RUNNING pauses after idle >= T1, PAUSED stops after idle >= T2, per-app Lifecycle overrides winning over the global defaults. With pause_enabled=false or a skip_pause app, the branch collapses to the exact legacy stop-only behavior — the backout path costs nothing new. When PSI `some avg10` exceeds psi_threshold, _demote_lru pushes exactly one app (the least recently used; never always_on, never accessed within the 5s grace window) down one tier per cycle. One per cycle is deliberate: the next cycle re-reads PSI, so pressure relief is gradual instead of a stop-flood. ensure_app_is_running dispatches PAUSED wakes straight to docker_unpause_app: keeps the wake off docker_start_app's global 5s throttle (which silently drops calls) and stays active when the flag is off, so already-paused apps still wake after a backout. Behavior change vs the old loop, per the approved design: always_on apps are no longer stopped when disk space runs low — the always_on branch now runs first. Flagged in the PR for review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pause_metrics is a new dependency-light accumulator module: per-app transition counters, pause/unpause latency samples (recorded by app_tools around the compose calls), and a PSI snapshot per control cycle (recorded by control_apps when the tier is on). It lives outside service.telemetry because app_tools importing telemetry trips the pre-existing signed_call/portal_controller import cycle. send_telemetry packages the accumulators into the Telemetry payload's new optional pause_tier field (p50/p95 computed at send time, swap total/free read from /proc/meminfo) and resets them on success. Without pause activity the field stays None — the wire shape for old controllers is unchanged, and a controller without the matching change simply drops the extra field. telemetry_model.py in data_model/backend/ is synced from the freeshard-controller change (PR freeshard-controller#358) that adds the field and its jsonb storage; equivalent to a `just get-types` run once that PR is merged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ainers New pause_cycle mock app (nginx:alpine, empty lifecycle) rides the global test tiers (T1=5s, T2=12s, 2s refresh). Two api_client tests: - Full cycle: install -> wake -> PAUSED at T1 (docker reports 'paused') -> wake-on-request back to RUNNING -> re-pause -> STOPPED at T2. Pins the compose unpause latency under the 2s design target via the recorded pause_metrics sample (wall time only gets a loose CI-safe ceiling), and checks the transition counters. - Uninstall while PAUSED: the frozen stack must unfreeze on the way out and the containers must actually be gone afterwards. Both passed locally against real Docker (72s). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
max-tet
left a comment
There was a problem hiding this comment.
Always-on apps should still stop on low disk. We need to prevent apps from completely filling the disk.
| idle_for_pause: Optional[int] = None # None = use global default | ||
| idle_for_stop: Optional[int] = None # None = use global default |
There was a problem hiding this comment.
Fixed in 41ecc27 — both fields now int | None.
| class AppLifecycleSettings(BaseModel): | ||
| refresh_interval: int = 30 | ||
| default_idle_for_pause: int = 60 # T1: RUNNING -> PAUSED, seconds | ||
| default_idle_for_stop: int = 1800 # T2: PAUSED -> STOPPED, seconds |
There was a problem hiding this comment.
Should be longer, few hours. Will stop with memory pressure anyway.
There was a problem hiding this comment.
Set to 10800 (3h) in settings and config.toml (41ecc27). Pressure demotion covers the RAM-needed case earlier.
| await docker_stop_app(app.name) | ||
|
|
||
|
|
||
| async def _demote_lru(apps: List[InstalledApp]): |
There was a problem hiding this comment.
paused apps will mostly have a less recent last access, so this favours stopping over pausing. Since pausing is the thing with less user impact, it should be preferred. Consider running apps first and pause one, only if no candidates exist, go to paused apps and stop one.
There was a problem hiding this comment.
Reworked in 41ecc27: candidates split by status — LRU among RUNNING is paused first (or stopped for skip_pause apps); a PAUSED app is only stopped when nothing is left to pause. Tests pin both preferences, including the case where the paused app is older.
| no_of_requests += 1 | ||
|
|
||
|
|
||
| def _percentile(samples: List[float], fraction: float) -> float: |
There was a problem hiding this comment.
guard the allowed values for fraction. Easy to pass 95 instead of 0.95.
There was a problem hiding this comment.
Guarded in 41ecc27 — fraction outside [0, 1] raises ValueError; test covers 95 and -0.1.
… demotion, longer T2, misc Review feedback from max-tet on PR #145, all four inline items plus the top-level change request: - Low disk stops every app again, always_on included — the disk check moves back in front of the always_on branch. Preventing a full disk beats keeping an always_on app up; the app restarts once disk recovers. (Reverts the spec-sketch ordering this PR had introduced.) - _demote_lru prefers pausing a RUNNING app over stopping a PAUSED one: paused apps mostly have older last-access times, so a single LRU sort across both tiers systematically stopped paused apps — the higher-impact action. Now: LRU among RUNNING first, PAUSED only when nothing is left to pause. - default_idle_for_stop 1800 -> 10800 (3h) in settings and config.toml. A paused app costs little RAM, and pressure demotion stops it earlier when the RAM is actually needed. - Lifecycle idle fields use int | None instead of Optional[int]. - _percentile rejects fractions outside [0, 1] — passing 95 instead of 0.95 now raises instead of silently indexing nonsense. New tests pin the disk-stop-for-always_on behavior, both demotion preferences, and the fraction guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All five review items addressed in 41ecc27:
Ran locally: pause unit + integration suites and telemetry tests, 24 passed (integration on real Docker). CI running on the push. |
Closes #81 (tracker). Design spec:
freeshard-controllerdocs/superpowers/specs/2026-05-01-app-pause-page-tier-design.md(PR FreeshardBase/freeshard-controller#244).What
The PAUSED+PAGED app tier: idle apps are frozen (
docker compose pause) with their memory proactively paged to swap via cgroup v2memory.reclaim, instead of cold-stopped. Wake from PAUSED is a compose unpause — measured <2s (integration-pinned) vs 30s+ cold start. Two-tier progressionRUNNING → (idle ≥ T1) PAUSED → (idle ≥ T2) STOPPED, plus PSI-driven LRU demotion under memory pressure. Everything behindapps.lifecycle.pause_enabled(default off).Per the tracker, one bundled PR:
Status.PAUSED+Lifecyclev1.3 (always_on,skip_pause,idle_for_pause,idle_for_stop) with the full validator;AppMeta1.2→1.3 migrates on read (idle_time_for_shutdown→idle_for_stop), app-repository files untouched. No DB migration (status is a text column).memory_pressure.py(new): PSIsome avg10from/host/pressure/memory(0.0 when the mount is absent → older core versions degrade gracefully),memory.reclaimwrites per container (systemd + cgroupfs layouts), reclaim runs viaasyncio.to_threadbecause the write blocks while the kernel pages out.app_tools.py:docker_pause_app/docker_unpause_app; frozen-stack handling everywhere a paused app can be stopped/removed —docker_stop_app(unpause→stop),docker_shutdown_appforce path (process shutdown), uninstall worker (unpauses while status still says PAUSED),docker_start_appshort-circuits PAUSED→unpause.app_lifecycle.py: two-tier_control_app_time,_demote_lru(one demotion per cycle; excludesalways_on, <5s-accessed), wake dispatch inensure_app_is_runningroutes PAUSED to unpause directly — also dodgesdocker_start_app's global 5s throttle, which would silently drop wakes.default_idle_for_pause=60,default_idle_for_stop=1800,psi_threshold=10.0,pause_enabled=falseunder[apps.lifecycle]; env override verified (FREESHARD_APPS__LIFECYCLE__PAUSE_ENABLED=true).pause_metricsaccumulator module (transitions per app, pause/unpause latency samples, PSI snapshots);send_telemetrypackages them into the payload's optionalpause_tierfield (p50/p95 at send time, swap from/proc/meminfo) — controller-side ingestion is PR FreeshardBase/freeshard-controller#358;backend/telemetry_model.pysynced from that branch.Status-audit (architecture contract: "adding a status? audit all six")
docker_start_appallow-listdocker_stop_appallow-listdocker_shutdown_app*_QUEUED)write_traefik_dyn_configexclusionscontrol_appsexclusionsEvery new status write fires
on_apps_update.send_async()(forwardAuth cache invalidation).Deliberate behavior changes / decisions to review
always_onapps are no longer stopped on low disk — the spec's_control_app_timeputs thealways_onbranch before the disk check; the old loop stopped everything. Flagging since it's a subtle semantic change.pause_enabled=false, already-PAUSED apps are not force-woken by the loop; they wake on next request (the wake dispatch stays status-aware). Matches the spec's backout section.status == RUNNINGbefore stopping (previously implicit viadocker_stop_app's allow-list). Net behavior identical.Tests (all passed locally)
Lifecyclevalidator combos; 1.2→1.3 migration incl. everymock_app_storefixture; PSI parsing (malformed/missing);memory.reclaimwrites against a fake cgroup tree (both driver layouts);_control_app_timetier decisions (flag on/off,skip_pause, per-app overrides,always_on);_demote_lruselection/exclusion/one-per-cycle; telemetrypause_tierpackaging + reset + omission when inactive.pause_cyclemock app): full cycle RUNNING→PAUSED→wake→PAUSED→STOPPED with docker-reported container states; recorded unpause latency asserted <2000ms; uninstall-while-PAUSED removes the frozen stack.test_app_starts_and_stops, which cannot run on this dev VM — thequick_stopmock app publishes host port 80, which is occupied by an unrelated local Traefik. Environmental collision (Bind for :::80 failed: port is already allocated), pre-existing on this machine; CI runners are clean and will exercise it.memory.reclaim, and the PSI memory-hog pressure test — per the spec these are the manual pre-rollout benchmark (Immich/Paperless wake latency, WebSocket long-pause behavior).Cross-repo / rollout
pause_enabledon internal shards → observe telemetry → staged rollout. Merged ≠ shipped: needs version bump, release, and a controller core-version image bump.Recommended reading order
shard_core/data_model/app_meta.py+app_meta_migration.py(model)shard_core/settings.py,config.toml,tests/config.tomlshard_core/service/memory_pressure.py(new leaf)shard_core/service/app_tools.py(primitives)shard_core/service/app_lifecycle.py(control loop)shard_core/service/app_installation/worker.py(uninstall edge)shard_core/service/pause_metrics.py+telemetry.py+data_model/backend/telemetry_model.pytest_model.py,test_app_meta_migration.py,test_memory_pressure.py,test_app_lifecycle_pause.py,test_telemetry.py,test_app_pause_lifecycle.pyagents.md🤖 Generated with Claude Code