Skip to content

feat(templates): serve stale gallery cache immediately, revalidate in background (BE-3427)#566

Open
mattmillerai wants to merge 2 commits into
matt/be-3393-gallery-cache-ttlfrom
matt/be-3427-templates-stale-while-revalidate
Open

feat(templates): serve stale gallery cache immediately, revalidate in background (BE-3427)#566
mattmillerai wants to merge 2 commits into
matt/be-3393-gallery-cache-ttlfrom
matt/be-3427-templates-stale-while-revalidate

Conversation

@mattmillerai

Copy link
Copy Markdown
Collaborator

ELI-5

comfy templates ls/show/fetch reads its catalog from a small JSON file cached at ~/.cache/comfy-cli/gallery/index.json. PR #559 (BE-3393) gave that cache a 24h expiry, but past the expiry it re-fetches synchronously — so on an offline/firewalled machine every templates command hangs for the full 15s fetch timeout before falling back to the stale cache, once per invocation past the TTL.

This makes it stale-while-revalidate: a stale-but-present cache is served immediately, and the refresh happens in a detached background process that updates the cache for the next invocation. The current call never blocks on the network — online or offline.

Stacked on #559 (BE-3393)

This is a stacked PR: base = matt/be-3393-gallery-cache-ttl (the TTL + synchronous stale-fallback foundation, still open). It builds directly on that branch and changes its stale-path behavior from synchronous to SWR — explicitly the follow-up BE-3427 that #559 scoped out. GitHub will retarget this to main when #559 merges; review sees only the net SWR diff.

What changed

  • _load_gallery — a TTL-expired but present cache is now returned right away, and _spawn_background_refresh() is fired to revalidate it. No inline fetch on this path.
  • _spawn_background_refresh — launches a fully detached comfy templates _refresh-cache (start_new_session=True, stdio → /dev/null, spawn failure swallowed), mirroring the existing comfy run async job-watcher idiom (command/run/watcher.py). It outlives the parent without ever blocking it.
  • _refresh-cache (hidden command) — fetch + atomic persist only; no output, no telemetry, never a non-zero exit. It validates the body before persisting and swallows every error, so a bad/garbage/offline response can never clobber the last-known-good cache.
  • Staleness signal — a non-fatal renderer.warn on stderr ("serving the cached copy and refreshing in the background"), consistent with the foundation's existing fallback warning. The JSON envelope shape is unchanged (no per-payload stale field added).

Design decision (the ticket asked for one)

Detached subprocess, not a thread. CLI processes are short-lived, which rules out both thread options:

  • A daemon thread is killed at interpreter exit before the ~1s online fetch+write completes → the cache would almost never refresh: a regression from the foundation's reliable auto-refresh.
  • A non-daemon thread keeps the process alive until the fetch returns → on an offline machine it lingers up to the full 15s timeout after emitting output, every invocation. That just relocates the exact hang we're removing (and is just as bad for the agentic JSON callers this CLI targets, which wait on process exit).

A fully detached subprocess is the only option that returns the parent instantly in both the online and offline cases and reliably persists the refreshed cache for the next call. It's also the pattern already used in this repo for comfy run's async watcher. The "next invocation" refresh the ticket mentions is exactly what the detached child delivers — it writes the cache that the next ls/show/fetch reads.

Behavior preserved (Chesterton's fence)

  • --refresh stays synchronous and still surfaces fetch errors (fatal gallery_load_failed) — an explicit user request is not deferred.
  • No cache at all still fetches synchronously and is fatal on failure (nothing to serve).
  • An unreadable/corrupt stale cache falls through to the synchronous path rather than serving garbage.
  • The "don't clobber the last-good cache with a bad body / non-200 / offline" guarantees from feat(templates): add 24h TTL with stale-cache fallback to gallery cache (BE-3393) #559 are preserved — they now live in the _refresh-cache background entrypoint (which uses the same atomic _persist_cache + validate-before-write).

Tests

tests/comfy_cli/command/test_templates.py (updates the #559 TTL tests that asserted synchronous refetch, since the stale path is now SWR):

  • expired cache → serves stale immediately, spawns background refresh, no inline fetch (_fetch_gallery monkeypatched to fail loudly), cache untouched by the foreground.
  • future-mtime clock-skew → treated as stale: served + revalidated in background.
  • _refresh-cache entrypoint → persists a fresh index; keeps the stale cache on fetch failure / garbage-200 / non-200; never exits non-zero.
  • _spawn_background_refresh → asserts the detached spawn shape (argv ends templates _refresh-cache, start_new_session=True, stdio → DEVNULL); swallows OSError spawn failure.
  • --refresh synchronous-path tests (fatal on failure, read-only-dir resilience) retained.
  • Verified live end-to-end: hidden command routes via python -m comfy_cli; a stale cache serves in ~1s (no 15s block) with the staleness warning on stderr.

Full suite green locally (2587 passed, 37 skipped); ruff check + ruff format --check clean on the touched files.

Negative-claim falsification

Not applicable — this diff adds a capability (opportunistic background refresh) and makes stale-serving more available; it introduces no "unsupported"/deny/dead-end path. The staleness warning is a positive affordance, not a capability denial.

Judgment calls / known minors

  • Rapid-fire invocations within the ~1s refresh window can each spawn a background refresher. They all os.replace atomically (last-writer-wins, no corruption); once the first completes, the cache is fresh again and subsequent calls don't spawn. Not worth a lockfile for this path.
  • The ttl_auto_refresh synchronous fallback block below the SWR branch is now only reachable for the corrupt-stale-cache edge; left intact to minimize churn on the foundation's structure.
  • comfy-cli uses (BE-####) PR/commit refs (per feat(templates): add 24h TTL with stale-cache fallback to gallery cache (BE-3393) #559 and repo history), so this follows that convention rather than the no-Linear-ref guardrail that applies to the ComfyUI/ComfyUI_frontend public repos.

… background (BE-3427)

A TTL-expired but present gallery index is now served immediately and
re-fetched in a detached background process (stale-while-revalidate),
instead of blocking the current call on the 15s fetch. On an
offline/firewalled machine this removes the per-invocation 15s hang that
the synchronous stale-fallback (BE-3393) still incurred once past the TTL.

- _load_gallery: stale-but-present cache is returned right away + a
  detached refresher is spawned; --refresh and no-cache stay synchronous.
- _spawn_background_refresh: launches a fully detached
  'comfy templates _refresh-cache' (new session, stdio -> /dev/null),
  mirroring the 'comfy run' async job-watcher idiom, so it outlives the
  parent without ever blocking it.
- _refresh-cache (hidden): fetch + atomic persist only, swallows all
  errors and never clobbers the last-known-good cache with a bad body.
- staleness is signalled via a non-fatal renderer warning (stderr),
  consistent with the existing fallback warning.
@mattmillerai mattmillerai added the agent-coded PR authored by the agent-work loop label Jul 18, 2026
@mattmillerai
mattmillerai marked this pull request as ready for review July 18, 2026 01:54
@dosubot dosubot Bot added size:M This PR changes 30-99 lines, ignoring generated files. enhancement New feature or request labels Jul 18, 2026
@mattmillerai mattmillerai added the cursor-review Request Cursor bot review label Jul 18, 2026
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8e7ec8a5-5685-4145-8c0f-55012ba2451b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch matt/be-3427-templates-stale-while-revalidate
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch matt/be-3427-templates-stale-while-revalidate

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Cursor Review — Consolidated panel

Triggered by @mattmillerai.

Found 8 finding(s).

Severity Count
🟠 High 2
🟡 Medium 3
🟢 Low 2
⚪ Nit 1

Panel: 6/8 reviewers contributed findings.

Reviewers that did not contribute: kimi-k2.5:adversarial (empty), kimi-k2.5:edge-case (empty)

Comment thread comfy_cli/command/templates.py Outdated
Comment thread comfy_cli/command/templates.py Outdated
Comment thread comfy_cli/command/templates.py Outdated
Comment thread comfy_cli/command/templates.py Outdated
Comment thread comfy_cli/command/templates.py
Comment thread comfy_cli/command/templates.py
Comment thread comfy_cli/command/templates.py Outdated
Comment thread comfy_cli/command/templates.py Outdated
…427)

Address the cursor-review panel findings on the stale-while-revalidate
gallery refresh:

- Debounce the background refresher (index.refresh marker, 60s window) so
  an offline host past the TTL can't spawn a detached `_refresh-cache` on
  every `templates ls/show/fetch` — bounds the PID fan-out / local DoS.
- Anchor the detached child in our cache dir + pass -P (3.11+) so the
  `-m comfy_cli` cwd-import vector can't load an attacker-planted
  comfy_cli.py from the directory the user ran the command in.
- Opt the child out of telemetry (COMFY_NO_TELEMETRY/DO_NOT_TRACK) so its
  entry callback can't persist an anonymous user_id via a non-atomic
  config.ini rewrite that races the foreground process.
- Validate the payload SHAPE (JSON array), not just that it parses, before
  persisting or serving — a valid-JSON captive-portal `{"error": …}` /
  `null` / number can no longer poison the cache or crash _flatten_templates.
- Catch ValueError (covers UnicodeDecodeError on a non-UTF-8 body, a
  ValueError subclass that is not JSONDecodeError) in the gallery load/serve
  paths so it routes to gallery_load_failed instead of an uncaught crash.
- show/fetch (exact-name lookups) opt out of SWR (background_ok=False): a
  template added upstream after the cache went stale resolves on this call
  instead of reporting not-found until a later background refresh; still
  falls back to the stale cache when offline.
- Detach correctly on Windows (CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS;
  start_new_session is a no-op there).
- Only claim "refreshing in the background" when a refresh actually started.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. and removed size:M This PR changes 30-99 lines, ignoring generated files. labels Jul 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-coded PR authored by the agent-work loop cursor-review Request Cursor bot review enhancement New feature or request size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant