Skip to content

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

Merged
mattmillerai merged 6 commits into
mainfrom
matt/be-3427-templates-stale-while-revalidate
Aug 11, 2026
Merged

feat(templates): serve stale gallery cache immediately, revalidate in background (BE-3427)#566
mattmillerai merged 6 commits into
mainfrom
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.

mattmillerai and others added 3 commits July 17, 2026 17:24
…(BE-3393)

Address cursor-review findings on the 24h-TTL gallery cache:

- Validate the fetched body as JSON *before* writing the cache, so a 200
  with a non-JSON body (rate-limit HTML, captive portal, truncated
  response) can no longer clobber the last-known-good cache with garbage.
- Persist atomically via a temp file + os.replace so a concurrent
  `templates` reader never sees a half-written index.
- Make the cache write best-effort: a read-only cache dir / full disk no
  longer breaks the command once valid data is in hand.
- Route a non-200 status (RuntimeError from _fetch_gallery) through the
  same stale-cache fallback / gallery_load_failed path instead of letting
  it escape as an uncaught traceback (shared _GALLERY_LOAD_ERRORS tuple).
- Guard the stale-fallback read: if the cache vanished/corrupted under us,
  surface the original fetch error rather than the read error.
- Treat a future mtime (clock skew) as stale so the cache can't be pinned
  "fresh" indefinitely.

Deferred: serve-stale-while-revalidate so offline machines don't block on
the 15s fetch each call past the TTL (needs background-refresh design).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… 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

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 30 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 64d1c638-1f04-4670-b876-c08385f0acbe

📥 Commits

Reviewing files that changed from the base of the PR and between 978c1c5 and b7829f0.

📒 Files selected for processing (2)
  • comfy_cli/command/templates.py
  • tests/comfy_cli/command/test_templates.py
✨ 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
Base automatically changed from matt/be-3393-gallery-cache-ttl to main July 30, 2026 08:09
@bigcat88

Copy link
Copy Markdown
Contributor

Needs a rebase — and it's worth doing promptly, because I verified this PR closes two holes that are live on main right now.

Why it conflicts: its base (#559, matt/be-3393-gallery-cache-ttl) just landed as a squash merge, so the stacked commits no longer match main's history. GitHub retargeted this to main automatically and it flipped to CONFLICTING. Mechanical: rebase onto main and drop the commits that came in with #559.

What it fixes, confirmed empirically while reviewing #559:

  1. Non-UTF-8 200 body escapes as an uncaught traceback. feat(templates): add 24h TTL with stale-cache fallback to gallery cache (BE-3393) #559's _GALLERY_LOAD_ERRORS catches json.JSONDecodeError, but a non-UTF-8 body raises UnicodeDecodeError — a ValueError, not a JSONDecodeError:

    json.loads(b'caf\xe9 error page, not json')
    -> UnicodeDecodeError   ValueError=True   JSONDecodeError=False
    

    With _fetch_gallery returning latin-1 bytes against a stale cache, both main and feat(templates): add 24h TTL with stale-cache fallback to gallery cache (BE-3393) #559 die with UNCAUGHT UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 …. Broadening the tuple to ValueError here is what actually makes feat(templates): add 24h TTL with stale-cache fallback to gallery cache (BE-3393) #559's "never an uncaught traceback" comment true.

  2. Valid-JSON-wrong-shape gets cached and served. json.loads(b'{"error":"rate limited"}') parses fine as a dict, so today a captive-portal or rate-limit JSON body passes validation, overwrites the cache, and reaches _flatten_templates. _looks_like_gallery is the missing gate.

So this isn't just a stale-while-revalidate nicety — it completes the error handling #559 started. The _REFRESH_DEBOUNCE_SECONDS reasoning is sound too: without it, an offline host never advances the cache mtime and would spawn a detached refresher on every ls/show/fetch.

Ping me once it's rebased and I'll review and merge it.

…s-stale-while-revalidate

# Conflicts:
#	comfy_cli/command/templates.py
#	tests/comfy_cli/command/test_templates.py
@mattmillerai

Copy link
Copy Markdown
Collaborator Author

Rebased onto main — the conflict was exactly as diagnosed: #559 landed as a squash, so this branch's stacked commits no longer matched main's history. Merged main in, dropped the now-duplicate #559 commits, kept this PR's SWR + cursor-review-hardening commits on top. Full test suite passes (3659 passed, 37 skipped) and lint/format are clean on the touched files. Ready for another look @bigcat88.

@skishore23 skishore23 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving the design — the spawn hardening here is the best in this stack. But the rebase is semantic, not mechanical, and one conflict is a correctness trap. Please read the first section before resolving.

#559 is merged and this PR's base is already main, so the stacking note is moot.

⚠️ The _GALLERY_LOAD_ERRORS conflict — both sides carry a guard the other lacks

HEAD:        (urllib.error.URLError, OSError, RuntimeError, ValueError)
origin/main: (urllib.error.URLError, OSError, RuntimeError, json.JSONDecodeError, ResponseTooLarge)

I checked the hierarchy:

JSONDecodeError    -> ValueError : True     # your ValueError subsumes main's entry
UnicodeDecodeError -> ValueError : True     # main's narrower tuple MISSES this
ResponseTooLarge   -> ValueError : False    # derives from Exception — NOT covered by yours

So neither naive resolution is safe:

  • Keep HEAD → an over-cap body raises ResponseTooLarge, which nothing catches → uncaught traceback instead of the stale-cache fallback.
  • Keep main → a non-UTF-8 200 raises bare UnicodeDecodeError, which json.JSONDecodeError doesn't catch → same traceback. Your comment calling this out is a real catch that main doesn't have.

Correct merge:

_GALLERY_LOAD_ERRORS = (urllib.error.URLError, OSError, RuntimeError, ValueError, ResponseTooLarge)

I applied exactly that plus your comment and debounce constant — ruff check clean. Keep your comment text (it explains the ValueError breadth) and extend it to say ResponseTooLarge is listed separately because it isn't a ValueError, so nobody "simplifies" it away later.

The 3 test conflicts are semantic too

Both sides restructured the same tests, so a union doesn't work — I tried it and got duplicate defs of test_spawn_background_refresh_debounces_rapid_calls and test_oversize_gallery_during_ttl_refresh_falls_back_to_stale (3 failures, my artifact, not yours). These need hand-reconciliation: keep main's ResponseTooLarge/oversize coverage and your debounce + SWR tests. Please re-run the file after.

34 passed as-authored, so the PR is sound in isolation; I just can't certify the merged result until those are reconciled.

The spawn hardening is excellent

Worth calling out explicitly, because this is the part most likely to be "simplified" by a future reader:

  • -P on 3.11+ plus cwd=_refresh_cwd() — a genuine belt-and-braces defense against the -m comfy_cli cwd-import vector. Anchoring the child in the cache dir rather than inheriting the user's cwd is the right call, and the docstring explains why a planted comfy_cli.py would otherwise execute. Same vector #641 addresses for nvidia-smi; nice to see a consistent posture.
  • Telemetry opt-out in child_env — spotting that the child's entry callback would persist an anonymous user_id via a non-atomic config.ini rewrite, racing the foreground process, is a subtle one. Good catch.
  • _note_refresh_launched is not called on spawn failure — so a transient failure retries next invocation instead of being debounced away for 60s. Easy to get backwards.
  • Windows native DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP rather than assuming start_new_session works there — it's silently ignored on Windows, which would have left the child tied to the console.
  • List-form argv, no shell, close_fds=True, stdio to DEVNULL.

The debounce also closes the unbounded-PID-fan-out hole that plain SWR would have opened on exactly the offline host this feature targets — which reads like it came out of review, and was the right thing to add.

Design choice

The detached-subprocess-over-thread reasoning is correct and worth keeping in the commit message. A daemon thread dies at interpreter exit before the write lands; a non-daemon thread relocates the 15s hang to after output, which is just as bad for agentic callers that wait on process exit. Subprocess is the only option that satisfies both.

@dosubot dosubot Bot added the lgtm This PR has been approved by a maintainer label Aug 4, 2026
@mattmillerai
mattmillerai merged commit d55a231 into main Aug 11, 2026
17 checks passed
@mattmillerai
mattmillerai deleted the matt/be-3427-templates-stale-while-revalidate branch August 11, 2026 17:08
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 11, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants