feat: add Modal cache reset menu actions

This commit is contained in:
Ken Simpson
2026-06-26 12:53:08 -07:00
parent fce996475e
commit de20ae4e9e
6 changed files with 339 additions and 2 deletions
+1
View File
@@ -487,6 +487,7 @@ COMFYUI_ROOT=/tmp/comfyui-modal-test/ComfyUI \
- Local ComfyUI output caching now works again for cache-safe remote proxies, including session-backed split proxies. The rewritten proxy payload strips all run-scoped queue metadata from `original_node_data` and restores it only if the proxy actually executes, so rerunning an unchanged graph can skip the remote call entirely instead of merely hitting Modal Dict quickly. The sanitized payload also carries a stable internal context id so rehydration still works when ComfyUI omits the hidden `unique_id`.
- Session-backed split proxies now use durable replay refs rather than raw prompt-scoped session ids. Live remote-only values still stay in the in-memory session store for the fast path, but a second Modal `Dict` stores replay metadata so later split phases can rebuild those values after container churn. Session misses are treated as routing/state failures rather than poisoned-worker crashes, and every synthesized static or mapped phase payload now preserves `remote_session` so replayed producers can still export session-backed outputs safely.
- Split-phase remote distributed caching now carries stable provenance for non-transportable boundary-fed inputs. When a later phase receives a live object like `ModelPatcher` or `CONDITIONING` through boundary injection, the persisted Modal Dict key is derived from the original upstream prompt structure instead of the live Python object identity, so repeated runs can reuse sampler-adjacent cached nodes across containers.
- The node context menu's `Modal` submenu includes maintenance actions for deleting the configured Modal cache Dicts and deleting the configured Modal Volume. Cache deletion clears interrupt flags, persisted node outputs, durable session bridges, sync-index records, and snapshot-profile records; volume deletion is separate because it forces asset and custom-node bundle reupload.
- Queue-time rewrite and implicit mapped execution now handle the list-shaped edge cases that showed up online: local `ModalMapInput` markers still produce mapped remote work, raw semantic lists like `CONDITIONING` stay broadcast, lists of remote session refs split per item, wildcard LATENT-list boundaries still itemize for concrete `LATENT` sockets like `KSampler.latent_image`, primitive list values from upstream nodes are allowed to follow ComfyUI's normal over-list behavior, singleton-list cleanup preserves semantic payloads, and `INPUT_IS_LIST` targets suppress outer Modal fan-out so the remote subgraph runs once unchanged.
- Remote interrupt polling now shuts down cleanly when Modal closes the request-scoped client during container teardown. A late `modal.exception.ClientClosed` from the shared interrupt `Dict` is treated as normal cleanup rather than an unhandled post-success thread crash.
- Split mapped proxies now carry a bridge-only `static_phase`, and the local async mapped scheduler seeds one bound remote lane per worker before dispatching per-item sampler calls. That keeps mapped fan-out from landing on a fresh worker and immediately replaying the full static producer branch just to reconstruct remote-only bridge inputs.
+121 -2
View File
@@ -23,7 +23,7 @@ from .modal_executor_node import (
)
from .session_state import RemoteSessionHandle
from .settings import ModalSyncSettings, get_settings
from .sync_engine import ModalAssetSyncEngine, ModalVolumeBackend, SyncedAsset
from .sync_engine import ModalAssetSyncEngine, ModalVolumeBackend, SyncedAsset, modal
logger = logging.getLogger(__name__)
@@ -3788,6 +3788,99 @@ def _progress_state_route_path(route_path: str) -> str:
return f"{route_path.rstrip('/')}/progress_state"
def _delete_modal_caches_route_path(route_path: str) -> str:
"""Return the sibling HTTP route used to delete persistent Modal cache Dicts."""
if route_path.endswith("/queue_prompt"):
return f"{route_path.removesuffix('/queue_prompt')}/delete_caches"
return f"{route_path.rstrip('/')}/delete_caches"
def _delete_modal_volume_route_path(route_path: str) -> str:
"""Return the sibling HTTP route used to delete the configured Modal Volume."""
if route_path.endswith("/queue_prompt"):
return f"{route_path.removesuffix('/queue_prompt')}/delete_volume"
return f"{route_path.rstrip('/')}/delete_volume"
def _modal_not_found_error_types() -> tuple[type[BaseException], ...]:
"""Return Modal SDK exception classes that mean a named object is absent."""
if modal is None:
return ()
exception_namespace = getattr(modal, "exception", None)
candidates = [
getattr(exception_namespace, "NotFoundError", None),
getattr(exception_namespace, "InvalidError", None),
]
return tuple(
candidate
for candidate in candidates
if isinstance(candidate, type) and issubclass(candidate, BaseException)
)
def _modal_cache_dict_names(settings: ModalSyncSettings) -> list[str]:
"""Return the configured persistent Modal Dict names used as local-reset caches."""
return [
settings.interrupt_dict_name,
settings.node_output_cache_dict_name,
settings.session_bridge_dict_name,
settings.sync_index_dict_name,
settings.snapshot_profile_dict_name,
]
def delete_modal_cache_dicts(settings: ModalSyncSettings) -> dict[str, Any]:
"""Delete all configured Modal Dict caches and return a reset summary."""
if modal is None:
raise RuntimeError("Modal SDK is unavailable; cannot delete Modal caches.")
modal_dict = getattr(modal, "Dict", None)
if modal_dict is None:
raise RuntimeError("Modal SDK does not expose modal.Dict; cannot delete Modal caches.")
deleted: list[str] = []
skipped: list[str] = []
not_found_errors = _modal_not_found_error_types()
for dict_name in _modal_cache_dict_names(settings):
try:
cache = modal_dict.from_name(dict_name, create_if_missing=False)
except not_found_errors:
skipped.append(dict_name)
continue
clear_method = getattr(cache, "clear", None)
if callable(clear_method):
clear_method()
delete_method = getattr(cache, "delete", None)
if not callable(delete_method):
raise RuntimeError(f"Modal Dict {dict_name!r} does not expose delete().")
delete_method()
deleted.append(dict_name)
logger.info("Deleted Modal cache Dicts deleted=%s skipped=%s.", deleted, skipped)
return {"deleted": deleted, "skipped": skipped}
def delete_modal_volume(settings: ModalSyncSettings) -> dict[str, Any]:
"""Delete the configured Modal Volume and return a reset summary."""
if modal is None:
raise RuntimeError("Modal SDK is unavailable; cannot delete Modal volume.")
modal_volume = getattr(modal, "Volume", None)
if modal_volume is None:
raise RuntimeError("Modal SDK does not expose modal.Volume; cannot delete Modal volume.")
try:
volume = modal_volume.from_name(settings.volume_name, create_if_missing=False)
except _modal_not_found_error_types():
logger.info("Skipped deleting missing Modal Volume %s.", settings.volume_name)
return {"deleted": [], "skipped": [settings.volume_name]}
delete_method = getattr(volume, "delete", None)
if not callable(delete_method):
raise RuntimeError(f"Modal Volume {settings.volume_name!r} does not expose delete().")
delete_method()
logger.info("Deleted Modal Volume %s.", settings.volume_name)
return {"deleted": [settings.volume_name], "skipped": []}
def _install_modal_interrupt_queue_bridge(prompt_server: Any) -> None:
"""Expose active Modal prompts to ComfyUI's targeted interrupt route."""
prompt_queue = getattr(prompt_server, "prompt_queue", None)
@@ -3844,6 +3937,8 @@ def setup_modal_queue_route(
resolved_sync_engine = sync_engine or ModalAssetSyncEngine.from_environment(resolved_settings)
analysis_route_path = _analysis_route_path(resolved_settings.route_path)
progress_state_route_path = _progress_state_route_path(resolved_settings.route_path)
delete_caches_route_path = _delete_modal_caches_route_path(resolved_settings.route_path)
delete_volume_route_path = _delete_modal_volume_route_path(resolved_settings.route_path)
_install_modal_interrupt_queue_bridge(prompt_server)
if hasattr(prompt_server.routes, "get"):
@@ -3911,6 +4006,28 @@ def setup_modal_queue_route(
logger.warning("Modal remote-node analysis request was invalid: %s", exc)
return web.json_response({"error": str(exc), "node_errors": []}, status=400)
@prompt_server.routes.post(delete_caches_route_path)
async def modal_delete_caches(request: web.Request) -> web.Response:
"""Delete persistent Modal cache Dicts for the active configuration."""
del request
logger.info("Received Modal cache deletion request.")
try:
return web.json_response(delete_modal_cache_dicts(resolved_settings))
except RuntimeError as exc:
logger.warning("Modal cache deletion request failed: %s", exc)
return web.json_response({"error": str(exc), "node_errors": []}, status=400)
@prompt_server.routes.post(delete_volume_route_path)
async def modal_delete_volume(request: web.Request) -> web.Response:
"""Delete the configured Modal Volume for the active configuration."""
del request
logger.info("Received Modal volume deletion request.")
try:
return web.json_response(delete_modal_volume(resolved_settings))
except RuntimeError as exc:
logger.warning("Modal volume deletion request failed: %s", exc)
return web.json_response({"error": str(exc), "node_errors": []}, status=400)
@prompt_server.routes.post(resolved_settings.route_path)
async def modal_queue_prompt(request: web.Request) -> web.Response:
"""Handle prompt queue requests that include Modal remote markers."""
@@ -4091,8 +4208,10 @@ def setup_modal_queue_route(
_ROUTE_REGISTERED = True
logger.info(
"Registered Modal queue route at %s, analysis route at %s, and progress state route at %s",
"Registered Modal queue route at %s, analysis route at %s, progress state route at %s, cache deletion route at %s, and volume deletion route at %s",
resolved_settings.route_path,
analysis_route_path,
progress_state_route_path,
delete_caches_route_path,
delete_volume_route_path,
)
+1
View File
@@ -282,3 +282,4 @@
- Remote session bridge refs need bridge-keyed live cache entries, not only `(node_id, output_index)` entries. A mapped CLIP output can produce multiple bridge refs for the same node output slot, and target-session lookup by node/output alone makes downstream mapped sampler items reuse the first conditioning vector.
- Comfy can wrap a proxy output value in an outer list before handing it to an `INPUT_IS_LIST`-aware local/debug node or another proxy. For mapped remote session refs this creates `[[bridge_ref_a, bridge_ref_b]]`; implicit mapped splitting must unwrap exactly that singleton ref-list wrapper before deciding whether nontransportable `CONDITIONING` should broadcast.
- Distributed remote node-cache signatures also need bridge identity, not only boundary source provenance. By the time `PromptExecutor` prepares its cache keys, bridge refs have already been resolved to live runtime objects, so the remote worker must carry the unresolved `bridge_key` into boundary cache metadata or mapped sampler items with different conditioning refs can collide and restore the first item's cached output.
- Modal app deletion is not a complete reset for Modal-Sync. Persistent state also lives in named Modal Dicts for interrupts, node output cache, durable session bridges, sync index, and snapshot profiles, plus the shared Modal Volume for uploaded assets and custom-node bundles. Expose Dict deletion separately from Volume deletion because clearing caches is a debugging reset, while deleting the Volume forces a full reupload.
+160
View File
@@ -3558,6 +3558,166 @@ def test_progress_state_route_is_queue_route_sibling(api_intercept_module: Any)
)
def test_modal_reset_route_paths_are_queue_route_siblings(api_intercept_module: Any) -> None:
"""The frontend should have stable sibling routes for Modal maintenance actions."""
assert api_intercept_module._delete_modal_caches_route_path("/modal/queue_prompt") == (
"/modal/delete_caches"
)
assert api_intercept_module._delete_modal_volume_route_path("/modal/queue_prompt") == (
"/modal/delete_volume"
)
assert api_intercept_module._delete_modal_caches_route_path("/custom/modal") == (
"/custom/modal/delete_caches"
)
assert api_intercept_module._delete_modal_volume_route_path("/custom/modal") == (
"/custom/modal/delete_volume"
)
def test_delete_modal_cache_dicts_deletes_configured_dicts(
api_intercept_module: Any,
settings_module: Any,
monkeypatch: Any,
tmp_path: Path,
) -> None:
"""Deleting Modal caches should clear and delete every configured cache Dict."""
class FakeNotFoundError(Exception):
"""Stand-in for Modal object misses."""
class FakeDictObject:
"""Minimal Modal Dict object that records maintenance calls."""
def __init__(self, name: str) -> None:
"""Store the configured Dict name."""
self.name = name
def clear(self) -> None:
"""Record a clear call."""
cleared.append(self.name)
def delete(self) -> None:
"""Record a delete call."""
deleted.append(self.name)
class FakeDict:
"""Minimal Modal Dict namespace."""
@staticmethod
def from_name(name: str, create_if_missing: bool = False) -> FakeDictObject:
"""Return fake Dict objects, except for one missing cache."""
assert create_if_missing is False
if name == "app-interrupts":
raise FakeNotFoundError(name)
return FakeDictObject(name)
class FakeModal:
"""Minimal Modal SDK double."""
exception = SimpleNamespace(NotFoundError=FakeNotFoundError)
Dict = FakeDict
settings = settings_module.ModalSyncSettings(
app_name="app",
auto_deploy=True,
allow_ephemeral_fallback=False,
enable_memory_snapshot=True,
enable_gpu_memory_snapshot=False,
execution_mode="remote",
sync_custom_nodes=False,
volume_name="volume",
route_path="/modal/queue_prompt",
marker_property="is_modal_remote",
local_storage_root=tmp_path / "storage",
remote_storage_root="/storage",
custom_nodes_archive_name="custom_nodes_bundle.zip",
comfyui_root=None,
custom_nodes_dir=None,
interrupt_dict_name="app-interrupts",
node_output_cache_dict_name="app-node-cache",
session_bridge_dict_name="app-session-bridges",
sync_index_dict_name="app-sync-index",
snapshot_profile_dict_name="app-snapshot-profiles",
)
cleared: list[str] = []
deleted: list[str] = []
monkeypatch.setattr(api_intercept_module, "modal", FakeModal)
result = api_intercept_module.delete_modal_cache_dicts(settings)
assert result == {
"deleted": [
"app-node-cache",
"app-session-bridges",
"app-sync-index",
"app-snapshot-profiles",
],
"skipped": ["app-interrupts"],
}
assert cleared == result["deleted"]
assert deleted == result["deleted"]
def test_delete_modal_volume_deletes_configured_volume(
api_intercept_module: Any,
settings_module: Any,
monkeypatch: Any,
tmp_path: Path,
) -> None:
"""Deleting the Modal volume should target only the configured volume name."""
class FakeVolumeObject:
"""Minimal Modal Volume object that records deletion."""
def __init__(self, name: str) -> None:
"""Store the configured Volume name."""
self.name = name
def delete(self) -> None:
"""Record a delete call."""
deleted.append(self.name)
class FakeVolume:
"""Minimal Modal Volume namespace."""
@staticmethod
def from_name(name: str, create_if_missing: bool = False) -> FakeVolumeObject:
"""Return a fake Volume object."""
assert create_if_missing is False
return FakeVolumeObject(name)
class FakeModal:
"""Minimal Modal SDK double."""
exception = SimpleNamespace()
Volume = FakeVolume
settings = settings_module.ModalSyncSettings(
app_name="app",
auto_deploy=True,
allow_ephemeral_fallback=False,
enable_memory_snapshot=True,
enable_gpu_memory_snapshot=False,
execution_mode="remote",
sync_custom_nodes=False,
volume_name="configured-volume",
route_path="/modal/queue_prompt",
marker_property="is_modal_remote",
local_storage_root=tmp_path / "storage",
remote_storage_root="/storage",
custom_nodes_archive_name="custom_nodes_bundle.zip",
comfyui_root=None,
custom_nodes_dir=None,
)
deleted: list[str] = []
monkeypatch.setattr(api_intercept_module, "modal", FakeModal)
result = api_intercept_module.delete_modal_volume(settings)
assert result == {"deleted": ["configured-volume"], "skipped": []}
assert deleted == ["configured-volume"]
def test_modal_interrupt_queue_bridge_exposes_active_remote_prompts(
api_intercept_module: Any,
remote_modal_app_module: Any,
+8
View File
@@ -397,8 +397,11 @@ def test_modal_context_menu_can_expand_required_upstream_nodes() -> None:
assert "function findNodeByWorkflowPath(workflowPath)" in source
assert "function selectedWorkflowNodePaths(node)" in source
assert "function analyzeAndSetUpstreamRemoteNodes(node, value)" in source
assert "function requestModalMaintenance(route, successMessage)" in source
assert "function installModalContextMenu(nodeType, nodeData)" in source
assert 'api.fetchApi(MODAL_ANALYZE_ROUTE, {' in source
assert 'const MODAL_DELETE_CACHES_ROUTE = MODAL_ROUTE.replace(/\\/queue_prompt$/, "/delete_caches");' in source
assert 'const MODAL_DELETE_VOLUME_ROUTE = MODAL_ROUTE.replace(/\\/queue_prompt$/, "/delete_volume");' in source
assert "async beforeRegisterNodeDef(nodeType, nodeData)" in source
assert "installModalContextMenu(nodeType, nodeData);" in source
assert 'content: "Modal"' in source
@@ -410,6 +413,8 @@ def test_modal_context_menu_can_expand_required_upstream_nodes() -> None:
assert '"Disable on Upstream Nodes for Selection"' in source
assert '"Enable All Nodes"' in source
assert '"Disable All Nodes"' in source
assert '"Delete Modal Caches"' in source
assert '"Delete Modal Volume"' in source
assert '"Modal: Enable on Upstream Nodes"' not in source
assert '"Modal: Disable on Upstream Nodes"' not in source
assert '"Modal: Enable All Nodes"' not in source
@@ -417,6 +422,9 @@ def test_modal_context_menu_can_expand_required_upstream_nodes() -> None:
assert "analyzeAndSetUpstreamRemoteNodes(this, false)" in source
assert "setAllEligibleWorkflowNodesRemote(true);" in source
assert "setAllEligibleWorkflowNodesRemote(false);" in source
assert "requestModalMaintenance(" in source
assert "MODAL_DELETE_CACHES_ROUTE" in source
assert "MODAL_DELETE_VOLUME_ROUTE" in source
def test_modal_context_menu_marks_nodes_across_subgraphs() -> None:
+48
View File
@@ -5,6 +5,8 @@ const REMOTE_PROPERTY = "is_modal_remote";
const MODAL_ROUTE = "/modal/queue_prompt";
const MODAL_ANALYZE_ROUTE = MODAL_ROUTE.replace(/\/queue_prompt$/, "/analyze_remote_nodes");
const MODAL_PROGRESS_STATE_ROUTE = MODAL_ROUTE.replace(/\/queue_prompt$/, "/progress_state");
const MODAL_DELETE_CACHES_ROUTE = MODAL_ROUTE.replace(/\/queue_prompt$/, "/delete_caches");
const MODAL_DELETE_VOLUME_ROUTE = MODAL_ROUTE.replace(/\/queue_prompt$/, "/delete_volume");
const COMFY_QUEUE_ROUTE = "/queue";
const COMFY_HISTORY_ROUTE = "/history";
const INTERNAL_NODE_PREFIX = "ModalUniversalExecutor";
@@ -1957,6 +1959,22 @@ function notifyModal(value) {
});
}
/**
* Request one local Modal maintenance action from the backend.
* @param {string} route
* @param {string} successMessage
*/
async function requestModalMaintenance(route, successMessage) {
if (typeof api.fetchApi !== "function") {
throw new Error("ComfyUI API fetch is unavailable.");
}
const response = await api.fetchApi(route, { method: "POST" });
if (response.status !== 200) {
throw new PromptExecutionError(await response.json());
}
notifyModal(successMessage);
}
/**
* Apply the remote marker value to the workflow nodes named by composed workflow paths.
* @param {string[]} workflowNodePaths
@@ -2125,6 +2143,36 @@ function installModalContextMenu(nodeType, nodeData) {
setAllEligibleWorkflowNodesRemote(false);
},
},
{
content: "Delete Modal Caches",
callback: () => {
void requestModalMaintenance(
MODAL_DELETE_CACHES_ROUTE,
"Deleted Modal caches.",
).catch((error) => {
console.error("Modal cache deletion failed.", error);
notifyModal(`Modal cache deletion failed: ${String(error?.message ?? error)}`);
});
},
},
{
content: "Delete Modal Volume",
callback: () => {
if (typeof window !== "undefined" && typeof window.confirm === "function") {
const confirmed = window.confirm("Delete the configured Modal volume?");
if (!confirmed) {
return;
}
}
void requestModalMaintenance(
MODAL_DELETE_VOLUME_ROUTE,
"Deleted Modal volume.",
).catch((error) => {
console.error("Modal volume deletion failed.", error);
notifyModal(`Modal volume deletion failed: ${String(error?.message ?? error)}`);
});
},
},
],
},
});