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:
+253
-32
@@ -4,21 +4,28 @@ ComfyUI-Tenaciousload
|
||||
Self-contained fix for slow / black-screen ComfyUI loading when you have a huge
|
||||
model/LoRA collection (especially on a network mount).
|
||||
|
||||
The cause: /api/object_info is tens of MB and takes minutes to build on EVERY
|
||||
page load (it re-walks the model folders), and the build blocks ComfyUI's event
|
||||
loop the whole time. This pack installs an in-process caching layer that:
|
||||
It injects an aiohttp middleware that caches the huge /api/object_info response
|
||||
in memory and on disk (survives restarts) and serves it gzipped, so the slow
|
||||
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),
|
||||
* serves it gzipped in ~milliseconds instead of rebuilding every load,
|
||||
* exposes a one-click "Refresh Models / LoRAs" button + graph node to rebuild
|
||||
the cache after you add/remove models.
|
||||
Three refresh modes are exposed (menu buttons, a graph node, and HTTP):
|
||||
* full - clear ComfyUI's folder cache -> full re-walk of every model
|
||||
folder. Most thorough (catches moves/deletes anywhere). Slowest.
|
||||
* 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 gzip
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
|
||||
@@ -31,20 +38,22 @@ log = logging.getLogger("Tenaciousload")
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
# --- cache storage --------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- #
|
||||
# object_info response cache (memory + disk)
|
||||
# --------------------------------------------------------------------------- #
|
||||
_CACHE_DIR = os.path.join(os.path.dirname(__file__), "cache")
|
||||
_RAW_PATH = os.path.join(_CACHE_DIR, "object_info.json")
|
||||
_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")
|
||||
_GZIP_LEVEL = 5
|
||||
|
||||
_lock = threading.Lock()
|
||||
_mem = {"raw": None, "gz": None} # bytes / bytes
|
||||
_mem = {"raw": None, "gz": None}
|
||||
_disk_loaded = False
|
||||
|
||||
|
||||
def _load_from_disk():
|
||||
"""Populate the in-memory cache from disk once (cheap, makes restarts instant)."""
|
||||
global _disk_loaded
|
||||
_disk_loaded = True
|
||||
try:
|
||||
@@ -63,7 +72,6 @@ def _load_from_disk():
|
||||
|
||||
|
||||
def _store(raw_bytes):
|
||||
"""Cache a freshly built object_info body (memory + disk)."""
|
||||
with _lock:
|
||||
gz = gzip.compress(raw_bytes, _GZIP_LEVEL)
|
||||
_mem["raw"] = raw_bytes
|
||||
@@ -80,7 +88,6 @@ def _store(raw_bytes):
|
||||
|
||||
|
||||
def invalidate_object_info_cache():
|
||||
"""Drop the cached object_info so the next request rebuilds it."""
|
||||
with _lock:
|
||||
_mem["raw"] = None
|
||||
_mem["gz"] = None
|
||||
@@ -94,7 +101,7 @@ def invalidate_object_info_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:
|
||||
folder_paths.filename_list_cache.clear()
|
||||
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)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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):
|
||||
raw = _mem["raw"]
|
||||
gz = _mem["gz"]
|
||||
accepts_gzip = "gzip" in request.headers.get("Accept-Encoding", "")
|
||||
if accepts_gzip and gz is not None:
|
||||
raw, gz = _mem["raw"], _mem["gz"]
|
||||
if "gzip" in request.headers.get("Accept-Encoding", "") and gz is not None:
|
||||
return web.Response(
|
||||
body=gz, status=200, content_type="application/json",
|
||||
headers={"Content-Encoding": "gzip", "X-Tenaciousload-Cache": "HIT",
|
||||
@@ -125,7 +318,6 @@ def _serve_cached(request):
|
||||
|
||||
@web.middleware
|
||||
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:
|
||||
return await handler(request)
|
||||
|
||||
@@ -133,11 +325,9 @@ async def _object_info_cache_mw(request, handler):
|
||||
if not _disk_loaded:
|
||||
_load_from_disk()
|
||||
|
||||
nocache = "nocache" in request.query
|
||||
if not nocache and _mem["raw"] is not None:
|
||||
if "nocache" not in request.query and _mem["raw"] is not None:
|
||||
return _serve_cached(request)
|
||||
|
||||
# MISS (or forced refresh): build via the real handler, then cache the body.
|
||||
resp = await handler(request)
|
||||
try:
|
||||
body = getattr(resp, "body", None)
|
||||
@@ -151,8 +341,7 @@ async def _object_info_cache_mw(request, handler):
|
||||
|
||||
def _install_middleware():
|
||||
try:
|
||||
app = PromptServer.instance.app
|
||||
app.middlewares.insert(0, _object_info_cache_mw)
|
||||
PromptServer.instance.app.middlewares.insert(0, _object_info_cache_mw)
|
||||
log.info("Tenaciousload: object_info cache middleware installed")
|
||||
except Exception as e:
|
||||
log.error("Tenaciousload: could not install cache middleware (loads will be slow): %s", e)
|
||||
@@ -161,16 +350,42 @@ def _install_middleware():
|
||||
_install_middleware()
|
||||
|
||||
|
||||
# --- refresh API ----------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Refresh API
|
||||
# --------------------------------------------------------------------------- #
|
||||
@PromptServer.instance.routes.post("/tenaciousload/refresh")
|
||||
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()
|
||||
invalidate_object_info_cache()
|
||||
log.info("Tenaciousload: caches cleared via refresh endpoint")
|
||||
return web.json_response({"status": "ok"})
|
||||
log.info("Tenaciousload: full refresh — folder cache cleared")
|
||||
return web.json_response({"status": "ok", "mode": "full"})
|
||||
|
||||
|
||||
# --- optional graph node (for workflow automation) ------------------------------
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Optional graph node (workflow automation)
|
||||
# --------------------------------------------------------------------------- #
|
||||
class _AnyType(str):
|
||||
def __ne__(self, other):
|
||||
return False
|
||||
@@ -182,17 +397,23 @@ ANY = _AnyType("*")
|
||||
class TenaciousloadRefresh:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"optional": {"trigger": (ANY, {})}}
|
||||
return {
|
||||
"required": {"mode": (["quick", "full"], {"default": "quick"})},
|
||||
"optional": {"trigger": (ANY, {})},
|
||||
}
|
||||
|
||||
RETURN_TYPES = (ANY,)
|
||||
RETURN_NAMES = ("trigger",)
|
||||
FUNCTION = "refresh"
|
||||
CATEGORY = "Tenaciousload"
|
||||
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):
|
||||
clear_comfy_model_cache()
|
||||
def refresh(self, mode="quick", trigger=None):
|
||||
if mode == "full":
|
||||
clear_comfy_model_cache()
|
||||
else:
|
||||
quick_rescan_all()
|
||||
invalidate_object_info_cache()
|
||||
return (trigger,)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user