Skip to content

Immich's migrated idle_for_pause (6h) exceeds the global idle_for_stop (3h), so it stops one cycle after pausing #226

Description

@ClaydeCode

Immich is the one app in the catalogue whose migrated pause threshold is longer than the global stop threshold, so once the pause tier is on it skips the paused state almost entirely: it runs for 6 h, pauses, and is stopped on the next control cycle ~10 s later.

The arithmetic

apps/immich/app_meta.json is still v: 1.0 and carries the pre-rework key:

"lifecycle": { "always_on": false, "idle_time_for_shutdown": 21600 }

migrate_1_2_to_1_3 maps that to idle_for_pause and deliberately leaves idle_for_stop unset so it falls back to the global default. The defaults in config.toml are:

default_idle_for_pause = 60
default_idle_for_stop  = 10800   # 3 h

So for Immich t1 = 21600 (6 h) and t2 = 10800 (3 h) — t1 > t2, inverting the intended ordering. In _control_app_time:

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

At the moment it pauses, idle is already ≥ 21600, which is comfortably past t2. The next cycle — refresh_interval = 10 — takes the PAUSED branch and stops it. Immich therefore never spends a useful interval paged-out-but-resumable, which is the state the whole tier exists to provide, and every return after a 6 h gap pays a full cold start instead of an unpause.

Immich is the only affected app: it is the only entry in the catalogue above 10800 (next highest is mirotalk at 4000).

Why the validator does not catch it

Lifecycle.validate_combinations compares the two only when both are explicitly set:

if (self.idle_for_pause is not None
        and self.idle_for_stop is not None
        and self.idle_for_pause >= self.idle_for_stop):
    raise ValueError("idle_for_pause must be less than idle_for_stop")

The migration never sets idle_for_stop, so the pair that actually governs behaviour — an explicit idle_for_pause against the global default stop — is never compared. Any future app that sets a pause threshold above 10800 lands in the same hole silently.

Severity

Latent right now: pause_enabled defaults to false in both config.toml and settings.py, where it is described as the rollout kill-switch, with a per-shard PAUSE_ENABLED override in the backend shard model. This bites when the tier is switched on — and it bites the app that most wants the tier, since a photo library is exactly the "come back to it hours later" case.

Options

  1. Set idle_for_stop explicitly on Immich (something above 21600) and bump the file to the current v. Narrowest fix.
  2. Lower Immich's idle_for_pause. A photo library has no reason to hold RAM for 6 h when pausing is cheap and resumable — arguably the pre-rework 21600 was tuned for stopping and never re-examined for pausing, which is the real root cause.
  3. Extend the validator to compare an explicit idle_for_pause against the effective idle_for_stop, defaults included, so the class of bug cannot recur. Worth doing alongside 1 or 2.

Found while checking the idle thresholds after 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