feat: add Quick (incremental) and Register refresh modes

- quick:    incremental rescan — re-walks only folders whose mtime changed
            (per-dir snapshot persisted to cache/scan_snapshot.json); reuses
            the cache for unchanged folders. Catches new/removed/renamed files.
- register: append specific file path(s) with NO folder walk (instant disk-wise)
- full:     unchanged default (clear cache -> full re-walk)

Frontend exposes all three as Extensions-menu commands; the graph node gains a
quick/full mode widget. POST /tenaciousload/refresh now takes {mode, folder, files}.
Unit-tested: incremental scan rescans only the changed dir; register adds/skips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 01:25:12 +02:00
parent 55a23d0fe9
commit a8d8b3792c
3 changed files with 326 additions and 51 deletions
+20 -7
View File
@@ -35,15 +35,28 @@ The only time a build runs is the first load after install, or when you
explicitly refresh (below). explicitly refresh (below).
## Refreshing after you add / remove models or LoRAs ## Refreshing after you add / remove models or LoRAs
The cache holds the old model lists until you refresh, so new files won't appear The cache holds the old model lists until you refresh. Three modes are available
until you do one of: from the **`Extensions`** menu (and the command palette):
- **Menu:** `Extensions ▸ 🔄 Refresh Models / LoRAs` (also in the command palette). | Mode | What it does | Speed |
- **Graph node:** `🔄 Refresh Models/LoRAs (Tenaciousload)` (for automated workflows). |------|--------------|-------|
- **HTTP:** `POST /tenaciousload/refresh`, then `GET /object_info?nocache=1`. | ⚡ **Quick refresh** | Re-walks only the folders whose timestamp **changed** since the last scan; reuses the cache for the rest. Catches new / removed / renamed files. | Fast on local disks; **~2× faster** on a slow network mount (it still has to stat every folder to find which changed). |
| 🔄 **Full refresh** | Clears ComfyUI's folder cache and re-walks **everything**. Catches moves/deletes anywhere. | Slowest (the original behaviour). |
| **Register new file…** | You give it the path(s) of the file(s) you just added; it appends them to the cache with **no folder walk**. | Instant disk-wise — only the `object_info` rebuild remains. |
A refresh re-walks your model folders (slow over a network mount, ~minutes) — the Also available:
button shows a "refreshing…" toast meanwhile. Normal loads stay instant. - **Graph node** `🔄 Refresh Models/LoRAs (Tenaciousload)` with a `mode` widget
(`quick` / `full`), for automated workflows.
- **HTTP:** `POST /tenaciousload/refresh` with
`{"mode": "quick" | "full" | "register", "folder": "loras", "files": ["pack/new.safetensors"]}`,
then `GET /object_info?nocache=1`.
> The **first** Quick refresh after install builds a folder index (one full walk),
> so it's as slow as a Full refresh that one time; every Quick refresh after that
> is incremental. The index is saved to `./cache/scan_snapshot.json`.
Whichever mode you pick, the button shows a "refreshing…" toast and normal loads
stay instant.
## Requirements ## Requirements
**None to install.** Only ComfyUI itself (tested on 0.23.0) and Python ≥ 3.8. **None to install.** Only ComfyUI itself (tested on 0.23.0) and Python ≥ 3.8.
+253 -32
View File
@@ -4,21 +4,28 @@ ComfyUI-Tenaciousload
Self-contained fix for slow / black-screen ComfyUI loading when you have a huge Self-contained fix for slow / black-screen ComfyUI loading when you have a huge
model/LoRA collection (especially on a network mount). model/LoRA collection (especially on a network mount).
The cause: /api/object_info is tens of MB and takes minutes to build on EVERY It injects an aiohttp middleware that caches the huge /api/object_info response
page load (it re-walks the model folders), and the build blocks ComfyUI's event in memory and on disk (survives restarts) and serves it gzipped, so the slow
loop the whole time. This pack installs an in-process caching layer that: build (which freezes ComfyUI's event loop) runs only on the first load or an
explicit refresh — not on every page load.
* caches the built object_info in memory AND on disk (so it survives restarts), Three refresh modes are exposed (menu buttons, a graph node, and HTTP):
* serves it gzipped in ~milliseconds instead of rebuilding every load, * full - clear ComfyUI's folder cache -> full re-walk of every model
* exposes a one-click "Refresh Models / LoRAs" button + graph node to rebuild folder. Most thorough (catches moves/deletes anywhere). Slowest.
the cache after you add/remove models. * quick - incremental: re-walk only the folders whose timestamp changed
since the last scan, reuse the cache for the rest. Much faster on
local disks; ~2x on a slow network mount (it still has to stat
every folder to find which changed).
* register - append specific file path(s) to the cache with NO folder walk.
Instant disk-wise; use right after downloading a known file.
No nginx, no docker, no extra port. Just install the pack and restart ComfyUI. All modes then rebuild the object_info cache so new files show up.
""" """
import os import os
import gzip import gzip
import json import json
import time
import logging import logging
import threading import threading
@@ -31,20 +38,22 @@ log = logging.getLogger("Tenaciousload")
WEB_DIRECTORY = "./web" WEB_DIRECTORY = "./web"
# --- cache storage -------------------------------------------------------------- # --------------------------------------------------------------------------- #
# object_info response cache (memory + disk)
# --------------------------------------------------------------------------- #
_CACHE_DIR = os.path.join(os.path.dirname(__file__), "cache") _CACHE_DIR = os.path.join(os.path.dirname(__file__), "cache")
_RAW_PATH = os.path.join(_CACHE_DIR, "object_info.json") _RAW_PATH = os.path.join(_CACHE_DIR, "object_info.json")
_GZ_PATH = os.path.join(_CACHE_DIR, "object_info.json.gz") _GZ_PATH = os.path.join(_CACHE_DIR, "object_info.json.gz")
_SNAP_PATH = os.path.join(_CACHE_DIR, "scan_snapshot.json")
_OBJECT_INFO_PATHS = ("/object_info", "/api/object_info") _OBJECT_INFO_PATHS = ("/object_info", "/api/object_info")
_GZIP_LEVEL = 5 _GZIP_LEVEL = 5
_lock = threading.Lock() _lock = threading.Lock()
_mem = {"raw": None, "gz": None} # bytes / bytes _mem = {"raw": None, "gz": None}
_disk_loaded = False _disk_loaded = False
def _load_from_disk(): def _load_from_disk():
"""Populate the in-memory cache from disk once (cheap, makes restarts instant)."""
global _disk_loaded global _disk_loaded
_disk_loaded = True _disk_loaded = True
try: try:
@@ -63,7 +72,6 @@ def _load_from_disk():
def _store(raw_bytes): def _store(raw_bytes):
"""Cache a freshly built object_info body (memory + disk)."""
with _lock: with _lock:
gz = gzip.compress(raw_bytes, _GZIP_LEVEL) gz = gzip.compress(raw_bytes, _GZIP_LEVEL)
_mem["raw"] = raw_bytes _mem["raw"] = raw_bytes
@@ -80,7 +88,6 @@ def _store(raw_bytes):
def invalidate_object_info_cache(): def invalidate_object_info_cache():
"""Drop the cached object_info so the next request rebuilds it."""
with _lock: with _lock:
_mem["raw"] = None _mem["raw"] = None
_mem["gz"] = None _mem["gz"] = None
@@ -94,7 +101,7 @@ def invalidate_object_info_cache():
def clear_comfy_model_cache(): def clear_comfy_model_cache():
"""Force ComfyUI to re-scan every model/LoRA folder on the next build.""" """Full reset: force ComfyUI to re-walk every model/LoRA folder next build."""
try: try:
folder_paths.filename_list_cache.clear() folder_paths.filename_list_cache.clear()
except Exception as e: # pragma: no cover except Exception as e: # pragma: no cover
@@ -105,11 +112,197 @@ def clear_comfy_model_cache():
log.warning("Tenaciousload: could not clear cache_helper: %s", e) log.warning("Tenaciousload: could not clear cache_helper: %s", e)
# --------------------------------------------------------------------------- #
# Incremental folder scanning (quick mode)
# --------------------------------------------------------------------------- #
# Snapshot layout: { folder_name: { root: { dirpath: {"m": mtime, "f": [names], "d": [subdir names]} } } }
_scan_lock = threading.Lock()
_snapshot = None
_SKIP_FOLDERS = {"custom_nodes"}
def _load_snapshot():
global _snapshot
if _snapshot is not None:
return
try:
with open(_SNAP_PATH) as f:
_snapshot = json.load(f)
except Exception:
_snapshot = {}
def _save_snapshot():
try:
os.makedirs(_CACHE_DIR, exist_ok=True)
tmp = _SNAP_PATH + ".tmp"
with open(tmp, "w") as f:
json.dump(_snapshot, f)
os.replace(tmp, _SNAP_PATH)
except Exception as e: # pragma: no cover
log.warning("Tenaciousload: could not save scan snapshot: %s", e)
def _scandir_immediate(d):
"""One scandir of a single directory -> {m, f, d} or None if inaccessible."""
files, subdirs = [], []
try:
with os.scandir(d) as it:
for e in it:
if e.name == ".git":
continue
try:
if e.is_dir(follow_symlinks=True):
subdirs.append(e.name)
elif e.is_file(follow_symlinks=True):
files.append(e.name)
except OSError:
continue
except (FileNotFoundError, NotADirectoryError, PermissionError):
return None
try:
m = os.path.getmtime(d)
except OSError:
m = 0.0
return {"m": m, "f": files, "d": subdirs}
def _scan_root_incremental(root, old):
"""Walk a root, scandir-ing only dirs whose mtime changed; reuse the rest."""
new = {}
scanned = reused = 0
stack = [root]
while stack:
d = stack.pop()
try:
m = os.path.getmtime(d)
except OSError:
continue # directory disappeared -> drop it
rec = old.get(d)
if rec is not None and rec["m"] == m:
new[d] = rec
reused += 1
for sub in rec["d"]:
stack.append(os.path.join(d, sub))
else:
fresh = _scandir_immediate(d)
if fresh is None:
continue
new[d] = fresh
scanned += 1
for sub in fresh["d"]:
stack.append(os.path.join(d, sub))
return new, scanned, reused
def incremental_scan_folder(folder_name):
"""Update folder_paths' cached file list for one folder type, incrementally."""
folder_name = folder_paths.map_legacy(folder_name)
fnp = folder_paths.folder_names_and_paths.get(folder_name)
if not fnp:
return None
roots, exts = fnp[0], fnp[1]
if _snapshot is None:
_load_snapshot()
folder_snap = _snapshot.setdefault(folder_name, {})
all_rel, dirs_mtime = set(), {}
scanned = reused = 0
for root in roots:
if not os.path.isdir(root):
folder_snap.pop(root, None)
continue
new, s, r = _scan_root_incremental(root, folder_snap.get(root, {}))
folder_snap[root] = new
scanned += s
reused += r
for d, rec in new.items():
dirs_mtime[d] = rec["m"]
for fname in rec["f"]:
all_rel.add(os.path.relpath(os.path.join(d, fname), root))
filtered = folder_paths.filter_files_extensions(all_rel, exts)
folder_paths.filename_list_cache[folder_name] = (filtered, dirs_mtime, time.perf_counter())
return {"folder": folder_name, "files": len(filtered), "scanned": scanned, "reused": reused}
def quick_rescan_all():
"""Incrementally refresh every model folder type. Returns a per-folder summary."""
with _scan_lock:
if _snapshot is None:
_load_snapshot()
results = []
for folder_name in list(folder_paths.folder_names_and_paths.keys()):
if folder_name in _SKIP_FOLDERS:
continue
try:
r = incremental_scan_folder(folder_name)
if r and (r["scanned"] or r["files"]):
results.append(r)
except Exception as e: # pragma: no cover
log.warning("Tenaciousload: quick scan of '%s' failed: %s", folder_name, e)
_save_snapshot()
# also drop ComfyUI's strong request-cache so the new lists are picked up
try:
folder_paths.cache_helper.clear()
except Exception:
pass
return results
def register_files(folder_name, rel_paths):
"""Append specific files to a folder's cache with no disk walk. Returns counts."""
folder_name = folder_paths.map_legacy(folder_name)
fnp = folder_paths.folder_names_and_paths.get(folder_name)
if not fnp:
return {"added": 0, "skipped": len(rel_paths), "folder": folder_name}
roots, exts = fnp[0], fnp[1]
cache = folder_paths.filename_list_cache.get(folder_name)
if cache is None:
cache = folder_paths.get_filename_list_(folder_name)
files, dirs = set(cache[0]), dict(cache[1])
added = skipped = 0
for rp in rel_paths:
rp = (rp or "").strip().strip("/\\")
if not rp:
continue
placed = False
for root in roots:
full = os.path.join(root, rp)
if os.path.exists(full):
d, rootn = os.path.dirname(full), os.path.normpath(root)
while True: # bump mtimes from the file's dir up to the root
try:
if os.path.isdir(d):
dirs[d] = os.path.getmtime(d)
except OSError:
pass
if os.path.normpath(d) == rootn:
break
parent = os.path.dirname(d)
if parent == d:
break
d = parent
files.add(rp)
placed = True
break
if placed:
added += 1
else:
skipped += 1
filtered = folder_paths.filter_files_extensions(files, exts)
folder_paths.filename_list_cache[folder_name] = (filtered, dirs, time.perf_counter())
try:
folder_paths.cache_helper.clear()
except Exception:
pass
return {"added": added, "skipped": skipped, "folder": folder_name, "files": len(filtered)}
# --------------------------------------------------------------------------- #
# object_info caching middleware
# --------------------------------------------------------------------------- #
def _serve_cached(request): def _serve_cached(request):
raw = _mem["raw"] raw, gz = _mem["raw"], _mem["gz"]
gz = _mem["gz"] if "gzip" in request.headers.get("Accept-Encoding", "") and gz is not None:
accepts_gzip = "gzip" in request.headers.get("Accept-Encoding", "")
if accepts_gzip and gz is not None:
return web.Response( return web.Response(
body=gz, status=200, content_type="application/json", body=gz, status=200, content_type="application/json",
headers={"Content-Encoding": "gzip", "X-Tenaciousload-Cache": "HIT", headers={"Content-Encoding": "gzip", "X-Tenaciousload-Cache": "HIT",
@@ -125,7 +318,6 @@ def _serve_cached(request):
@web.middleware @web.middleware
async def _object_info_cache_mw(request, handler): async def _object_info_cache_mw(request, handler):
# Only touch the full object_info list (not /object_info/{node_class}).
if request.method != "GET" or request.path not in _OBJECT_INFO_PATHS: if request.method != "GET" or request.path not in _OBJECT_INFO_PATHS:
return await handler(request) return await handler(request)
@@ -133,11 +325,9 @@ async def _object_info_cache_mw(request, handler):
if not _disk_loaded: if not _disk_loaded:
_load_from_disk() _load_from_disk()
nocache = "nocache" in request.query if "nocache" not in request.query and _mem["raw"] is not None:
if not nocache and _mem["raw"] is not None:
return _serve_cached(request) return _serve_cached(request)
# MISS (or forced refresh): build via the real handler, then cache the body.
resp = await handler(request) resp = await handler(request)
try: try:
body = getattr(resp, "body", None) body = getattr(resp, "body", None)
@@ -151,8 +341,7 @@ async def _object_info_cache_mw(request, handler):
def _install_middleware(): def _install_middleware():
try: try:
app = PromptServer.instance.app PromptServer.instance.app.middlewares.insert(0, _object_info_cache_mw)
app.middlewares.insert(0, _object_info_cache_mw)
log.info("Tenaciousload: object_info cache middleware installed") log.info("Tenaciousload: object_info cache middleware installed")
except Exception as e: except Exception as e:
log.error("Tenaciousload: could not install cache middleware (loads will be slow): %s", e) log.error("Tenaciousload: could not install cache middleware (loads will be slow): %s", e)
@@ -161,16 +350,42 @@ def _install_middleware():
_install_middleware() _install_middleware()
# --- refresh API ---------------------------------------------------------------- # --------------------------------------------------------------------------- #
# Refresh API
# --------------------------------------------------------------------------- #
@PromptServer.instance.routes.post("/tenaciousload/refresh") @PromptServer.instance.routes.post("/tenaciousload/refresh")
async def _refresh(request): async def _refresh(request):
try:
data = await request.json()
except Exception:
data = {}
mode = (data.get("mode") or "full").lower()
if mode == "quick":
summary = quick_rescan_all()
invalidate_object_info_cache()
rescanned = sum(s["scanned"] for s in summary)
log.info("Tenaciousload: quick refresh — %d folders touched, %d dirs rescanned", len(summary), rescanned)
return web.json_response({"status": "ok", "mode": "quick", "folders": summary})
if mode == "register":
folder = data.get("folder") or "loras"
files = data.get("files") or []
result = register_files(folder, files)
invalidate_object_info_cache()
log.info("Tenaciousload: register — %s", result)
return web.json_response({"status": "ok", "mode": "register", **result})
# default: full
clear_comfy_model_cache() clear_comfy_model_cache()
invalidate_object_info_cache() invalidate_object_info_cache()
log.info("Tenaciousload: caches cleared via refresh endpoint") log.info("Tenaciousload: full refresh — folder cache cleared")
return web.json_response({"status": "ok"}) return web.json_response({"status": "ok", "mode": "full"})
# --- optional graph node (for workflow automation) ------------------------------ # --------------------------------------------------------------------------- #
# Optional graph node (workflow automation)
# --------------------------------------------------------------------------- #
class _AnyType(str): class _AnyType(str):
def __ne__(self, other): def __ne__(self, other):
return False return False
@@ -182,17 +397,23 @@ ANY = _AnyType("*")
class TenaciousloadRefresh: class TenaciousloadRefresh:
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
return {"optional": {"trigger": (ANY, {})}} return {
"required": {"mode": (["quick", "full"], {"default": "quick"})},
"optional": {"trigger": (ANY, {})},
}
RETURN_TYPES = (ANY,) RETURN_TYPES = (ANY,)
RETURN_NAMES = ("trigger",) RETURN_NAMES = ("trigger",)
FUNCTION = "refresh" FUNCTION = "refresh"
CATEGORY = "Tenaciousload" CATEGORY = "Tenaciousload"
OUTPUT_NODE = True OUTPUT_NODE = True
DESCRIPTION = "Clear ComfyUI's model/LoRA cache and invalidate the object_info cache." DESCRIPTION = "Refresh the model/LoRA cache (quick = changed folders only, full = rescan everything)."
def refresh(self, trigger=None): def refresh(self, mode="quick", trigger=None):
clear_comfy_model_cache() if mode == "full":
clear_comfy_model_cache()
else:
quick_rescan_all()
invalidate_object_info_cache() invalidate_object_info_cache()
return (trigger,) return (trigger,)
+53 -12
View File
@@ -13,21 +13,38 @@ function notify(severity, detail, life = 6000) {
console[severity === "error" ? "error" : "log"]("[Tenaciousload]", detail); console[severity === "error" ? "error" : "log"]("[Tenaciousload]", detail);
} }
async function doRefresh() { async function runRefresh(mode, extra) {
if (busy) { if (busy) {
notify("warn", "A refresh is already running…"); notify("warn", "A refresh is already running…");
return; return;
} }
busy = true; busy = true;
notify("info", "Refreshing models/LoRAs — scanning over the network, this can take a few minutes…", 10000); const label =
mode === "quick" ? "Quick refresh (changed folders)" :
mode === "register" ? "Registering file(s)" : "Full refresh (rescan all)";
notify("info", `${label} — rebuilding model lists… this can take a moment.`, 10000);
try { try {
// 1) clear ComfyUI's folder cache + drop the object_info cache. // 1) run the chosen refresh mode on the server
await api.fetchApi("/tenaciousload/refresh", { method: "POST" }); const res = await api.fetchApi("/tenaciousload/refresh", {
// 2) force a fresh object_info build and re-cache it (this is the slow step). method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode, ...(extra || {}) }),
});
const data = await res.json().catch(() => ({}));
// 2) rebuild + re-cache object_info (the slow build, if any)
await api.fetchApi("/object_info?nocache=1").then((r) => r.arrayBuffer()); await api.fetchApi("/object_info?nocache=1").then((r) => r.arrayBuffer());
// 3) update open node dropdowns live if the frontend supports it. // 3) update open dropdowns live if supported
try { await app.refreshComboInNodes?.(); } catch (e) { /* non-fatal */ } try { await app.refreshComboInNodes?.(); } catch (e) { /* non-fatal */ }
notify("success", "Done — new models are now available (reload the page if a dropdown still looks stale).", 8000);
let detail = "Done — new models are available (reload the page if a dropdown still looks stale).";
if (mode === "register") {
detail = `Registered ${data.added || 0} file(s)` +
(data.skipped ? `, ${data.skipped} not found on disk` : "") + ". " + detail;
} else if (mode === "quick") {
const dirs = (data.folders || []).reduce((a, f) => a + (f.scanned || 0), 0);
detail = `Quick scan: ${dirs} folder(s) rescanned. ` + detail;
}
notify("success", detail, 8000);
} catch (e) { } catch (e) {
notify("error", "Refresh failed: " + (e?.message || e), 10000); notify("error", "Refresh failed: " + (e?.message || e), 10000);
} finally { } finally {
@@ -35,21 +52,45 @@ async function doRefresh() {
} }
} }
async function doRegister() {
const folder = (prompt("Model folder type (loras, checkpoints, vae, …):", "loras") || "").trim();
if (!folder) return;
const raw = prompt(
`New file path(s) relative to the '${folder}' folder\n(comma- or newline-separated, e.g. mypack/newlora.safetensors):`,
"",
);
if (!raw) return;
const files = raw.split(/[\n,]+/).map((s) => s.trim()).filter(Boolean);
if (!files.length) return;
await runRefresh("register", { folder, files });
}
app.registerExtension({ app.registerExtension({
name: "Tenaciousload.Refresh", name: "Tenaciousload.Refresh",
commands: [ commands: [
{ {
id: "Tenaciousload.refresh", id: "Tenaciousload.quick",
label: "🔄 Refresh Models / LoRAs", label: "⚡ Quick refresh (changed folders)",
icon: "pi pi-bolt",
function: () => runRefresh("quick"),
},
{
id: "Tenaciousload.full",
label: "🔄 Full refresh (rescan all models/LoRAs)",
icon: "pi pi-refresh", icon: "pi pi-refresh",
function: doRefresh, function: () => runRefresh("full"),
},
{
id: "Tenaciousload.register",
label: " Register new model file…",
icon: "pi pi-plus",
function: doRegister,
}, },
], ],
// Adds the command under the top "Extensions" menu (and the command palette).
menuCommands: [ menuCommands: [
{ {
path: ["Extensions"], path: ["Extensions"],
commands: ["Tenaciousload.refresh"], commands: ["Tenaciousload.quick", "Tenaciousload.full", "Tenaciousload.register"],
}, },
], ],
}); });