Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecebc71d3a |
+17
-1
@@ -14,7 +14,7 @@ from PIL import Image
|
|||||||
from ..utils.logging import debug_log
|
from ..utils.logging import debug_log
|
||||||
from ..utils.image import pil_to_tensor, ensure_contiguous
|
from ..utils.image import pil_to_tensor, ensure_contiguous
|
||||||
from ..utils.network import handle_api_error
|
from ..utils.network import handle_api_error
|
||||||
from ..utils.constants import JOB_INIT_GRACE_PERIOD, MEMORY_CLEAR_DELAY
|
from ..utils.constants import CLOSED_JOB_TTL_SECONDS, JOB_INIT_GRACE_PERIOD, MEMORY_CLEAR_DELAY
|
||||||
try:
|
try:
|
||||||
from .queue_orchestration import ensure_distributed_state, orchestrate_distributed_execution
|
from .queue_orchestration import ensure_distributed_state, orchestrate_distributed_execution
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -149,6 +149,9 @@ async def prepare_job_endpoint(request):
|
|||||||
|
|
||||||
ensure_distributed_state()
|
ensure_distributed_state()
|
||||||
async with prompt_server.distributed_jobs_lock:
|
async with prompt_server.distributed_jobs_lock:
|
||||||
|
closed_jobs = getattr(prompt_server, "distributed_closed_jobs", None)
|
||||||
|
if closed_jobs is not None:
|
||||||
|
closed_jobs.pop(multi_job_id, None)
|
||||||
if multi_job_id not in prompt_server.distributed_pending_jobs:
|
if multi_job_id not in prompt_server.distributed_pending_jobs:
|
||||||
prompt_server.distributed_pending_jobs[multi_job_id] = asyncio.Queue()
|
prompt_server.distributed_pending_jobs[multi_job_id] = asyncio.Queue()
|
||||||
|
|
||||||
@@ -335,6 +338,19 @@ async def job_complete_endpoint(request):
|
|||||||
queue_size = pending.qsize()
|
queue_size = pending.qsize()
|
||||||
break
|
break
|
||||||
|
|
||||||
|
closed_jobs = getattr(prompt_server, "distributed_closed_jobs", {})
|
||||||
|
closed_at = closed_jobs.get(multi_job_id)
|
||||||
|
if closed_at is not None:
|
||||||
|
if time.monotonic() - closed_at <= CLOSED_JOB_TTL_SECONDS:
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"code": "job_closed",
|
||||||
|
"error": "job no longer accepts results",
|
||||||
|
},
|
||||||
|
status=410,
|
||||||
|
)
|
||||||
|
closed_jobs.pop(multi_job_id, None)
|
||||||
|
|
||||||
if time.monotonic() > deadline:
|
if time.monotonic() > deadline:
|
||||||
return await handle_api_error(request, "job not initialized", 404)
|
return await handle_api_error(request, "job not initialized", 404)
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ def ensure_distributed_state(server_instance=None):
|
|||||||
ps = server_instance or prompt_server
|
ps = server_instance or prompt_server
|
||||||
if not hasattr(ps, "distributed_pending_jobs"):
|
if not hasattr(ps, "distributed_pending_jobs"):
|
||||||
ps.distributed_pending_jobs = {}
|
ps.distributed_pending_jobs = {}
|
||||||
|
if not hasattr(ps, "distributed_closed_jobs"):
|
||||||
|
ps.distributed_closed_jobs = {}
|
||||||
if not hasattr(ps, "distributed_jobs_lock"):
|
if not hasattr(ps, "distributed_jobs_lock"):
|
||||||
ps.distributed_jobs_lock = asyncio.Lock()
|
ps.distributed_jobs_lock = asyncio.Lock()
|
||||||
|
|
||||||
@@ -56,6 +58,7 @@ async def _ensure_distributed_queue(job_id):
|
|||||||
"""Ensure a queue exists for the given distributed job ID."""
|
"""Ensure a queue exists for the given distributed job ID."""
|
||||||
ensure_distributed_state()
|
ensure_distributed_state()
|
||||||
async with prompt_server.distributed_jobs_lock:
|
async with prompt_server.distributed_jobs_lock:
|
||||||
|
prompt_server.distributed_closed_jobs.pop(job_id, None)
|
||||||
if job_id not in prompt_server.distributed_pending_jobs:
|
if job_id not in prompt_server.distributed_pending_jobs:
|
||||||
prompt_server.distributed_pending_jobs[job_id] = asyncio.Queue()
|
prompt_server.distributed_pending_jobs[job_id] = asyncio.Queue()
|
||||||
|
|
||||||
|
|||||||
+55
-10
@@ -12,7 +12,11 @@ from comfy.utils import ProgressBar
|
|||||||
|
|
||||||
from ..utils.logging import debug_log, log
|
from ..utils.logging import debug_log, log
|
||||||
from ..utils.config import get_worker_timeout_seconds, load_config, is_master_delegate_only
|
from ..utils.config import get_worker_timeout_seconds, load_config, is_master_delegate_only
|
||||||
from ..utils.constants import HEARTBEAT_INTERVAL
|
from ..utils.constants import (
|
||||||
|
CLOSED_JOB_TTL_SECONDS,
|
||||||
|
HEARTBEAT_INTERVAL,
|
||||||
|
MAX_COLLECTOR_BUSY_GRACE_PERIODS,
|
||||||
|
)
|
||||||
from ..utils.image import tensor_to_pil, pil_to_tensor, ensure_contiguous
|
from ..utils.image import tensor_to_pil, pil_to_tensor, ensure_contiguous
|
||||||
from ..utils.network import build_worker_url, get_client_session, probe_worker
|
from ..utils.network import build_worker_url, get_client_session, probe_worker
|
||||||
from ..utils.audio_payload import encode_audio_payload
|
from ..utils.audio_payload import encode_audio_payload
|
||||||
@@ -57,6 +61,22 @@ class DistributedCollectorNode:
|
|||||||
RETURN_NAMES = ("images", "audio")
|
RETURN_NAMES = ("images", "audio")
|
||||||
FUNCTION = "run"
|
FUNCTION = "run"
|
||||||
CATEGORY = "image"
|
CATEGORY = "image"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mark_job_closed(multi_job_id):
|
||||||
|
closed_jobs = getattr(prompt_server, "distributed_closed_jobs", None)
|
||||||
|
if closed_jobs is None:
|
||||||
|
closed_jobs = {}
|
||||||
|
prompt_server.distributed_closed_jobs = closed_jobs
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
cutoff = now - CLOSED_JOB_TTL_SECONDS
|
||||||
|
stale_job_ids = [
|
||||||
|
job_id for job_id, closed_at in closed_jobs.items() if closed_at < cutoff
|
||||||
|
]
|
||||||
|
for job_id in stale_job_ids:
|
||||||
|
closed_jobs.pop(job_id, None)
|
||||||
|
closed_jobs[str(multi_job_id)] = now
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _unwrap_list_input(value):
|
def _unwrap_list_input(value):
|
||||||
@@ -196,6 +216,17 @@ class DistributedCollectorNode:
|
|||||||
json=payload,
|
json=payload,
|
||||||
timeout=aiohttp.ClientTimeout(total=timeout_seconds),
|
timeout=aiohttp.ClientTimeout(total=timeout_seconds),
|
||||||
) as response:
|
) as response:
|
||||||
|
if getattr(response, "status", 200) == 410:
|
||||||
|
response_payload = await response.json(content_type=None)
|
||||||
|
if (
|
||||||
|
isinstance(response_payload, dict)
|
||||||
|
and response_payload.get("code") == "job_closed"
|
||||||
|
):
|
||||||
|
log(
|
||||||
|
f"Worker - Master closed job {multi_job_id} before the result arrived; "
|
||||||
|
"discarding the remaining completion payloads."
|
||||||
|
)
|
||||||
|
return
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
media_type = "image/audio" if "image" in payload else "audio-only"
|
media_type = "image/audio" if "image" in payload else "audio-only"
|
||||||
@@ -345,6 +376,9 @@ class DistributedCollectorNode:
|
|||||||
|
|
||||||
# Create the queue before any expensive local work to avoid job_complete race.
|
# Create the queue before any expensive local work to avoid job_complete race.
|
||||||
async with prompt_server.distributed_jobs_lock:
|
async with prompt_server.distributed_jobs_lock:
|
||||||
|
closed_jobs = getattr(prompt_server, "distributed_closed_jobs", None)
|
||||||
|
if closed_jobs is not None:
|
||||||
|
closed_jobs.pop(multi_job_id, None)
|
||||||
if multi_job_id not in prompt_server.distributed_pending_jobs:
|
if multi_job_id not in prompt_server.distributed_pending_jobs:
|
||||||
prompt_server.distributed_pending_jobs[multi_job_id] = asyncio.Queue()
|
prompt_server.distributed_pending_jobs[multi_job_id] = asyncio.Queue()
|
||||||
debug_log(f"Master - Initialized queue early for job {multi_job_id}")
|
debug_log(f"Master - Initialized queue early for job {multi_job_id}")
|
||||||
@@ -380,6 +414,7 @@ class DistributedCollectorNode:
|
|||||||
base_timeout = float(get_worker_timeout_seconds())
|
base_timeout = float(get_worker_timeout_seconds())
|
||||||
slice_timeout = min(max(0.1, HEARTBEAT_INTERVAL / 20.0), base_timeout)
|
slice_timeout = min(max(0.1, HEARTBEAT_INTERVAL / 20.0), base_timeout)
|
||||||
last_activity = time.time()
|
last_activity = time.time()
|
||||||
|
busy_grace_periods = 0
|
||||||
|
|
||||||
|
|
||||||
# Get queue size before starting
|
# Get queue size before starting
|
||||||
@@ -434,6 +469,7 @@ class DistributedCollectorNode:
|
|||||||
# Record activity and refresh timeout baseline
|
# Record activity and refresh timeout baseline
|
||||||
last_activity = time.time()
|
last_activity = time.time()
|
||||||
base_timeout = float(get_worker_timeout_seconds())
|
base_timeout = float(get_worker_timeout_seconds())
|
||||||
|
busy_grace_periods = 0
|
||||||
|
|
||||||
if is_last:
|
if is_last:
|
||||||
mark_worker_done(worker_id)
|
mark_worker_done(worker_id)
|
||||||
@@ -479,10 +515,6 @@ class DistributedCollectorNode:
|
|||||||
)
|
)
|
||||||
if payload is not None and queue_remaining and queue_remaining > 0:
|
if payload is not None and queue_remaining and queue_remaining > 0:
|
||||||
any_busy = True
|
any_busy = True
|
||||||
log(
|
|
||||||
f"Master - Probe grace: worker {wid} appears busy "
|
|
||||||
f"(queue_remaining={queue_remaining}). Continuing to wait."
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
debug_log(f"Collector probe failed for worker {wid}: {e}")
|
debug_log(f"Collector probe failed for worker {wid}: {e}")
|
||||||
@@ -490,11 +522,22 @@ class DistributedCollectorNode:
|
|||||||
debug_log(f"Collector probe setup error: {e}")
|
debug_log(f"Collector probe setup error: {e}")
|
||||||
|
|
||||||
if any_busy:
|
if any_busy:
|
||||||
# Refresh last_activity and continue waiting
|
if busy_grace_periods < MAX_COLLECTOR_BUSY_GRACE_PERIODS:
|
||||||
last_activity = time.time()
|
busy_grace_periods += 1
|
||||||
# Refresh base timeout in case the user changed it in UI
|
log(
|
||||||
base_timeout = float(get_worker_timeout_seconds())
|
"Master - Probe grace: a missing worker still appears busy; "
|
||||||
continue
|
f"continuing wait period {busy_grace_periods}/"
|
||||||
|
f"{MAX_COLLECTOR_BUSY_GRACE_PERIODS}."
|
||||||
|
)
|
||||||
|
last_activity = time.time()
|
||||||
|
# Refresh base timeout in case the user changed it in UI.
|
||||||
|
base_timeout = float(get_worker_timeout_seconds())
|
||||||
|
continue
|
||||||
|
log(
|
||||||
|
"Master - Busy-worker grace exhausted after "
|
||||||
|
f"{MAX_COLLECTOR_BUSY_GRACE_PERIODS} additional wait periods; "
|
||||||
|
"finishing with the results received so far."
|
||||||
|
)
|
||||||
|
|
||||||
# Check queue size again with lock
|
# Check queue size again with lock
|
||||||
async with prompt_server.distributed_jobs_lock:
|
async with prompt_server.distributed_jobs_lock:
|
||||||
@@ -529,6 +572,7 @@ class DistributedCollectorNode:
|
|||||||
async with prompt_server.distributed_jobs_lock:
|
async with prompt_server.distributed_jobs_lock:
|
||||||
if multi_job_id in prompt_server.distributed_pending_jobs:
|
if multi_job_id in prompt_server.distributed_pending_jobs:
|
||||||
del prompt_server.distributed_pending_jobs[multi_job_id]
|
del prompt_server.distributed_pending_jobs[multi_job_id]
|
||||||
|
self._mark_job_closed(multi_job_id)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
total_collected = sum(len(imgs) for imgs in worker_images.values())
|
total_collected = sum(len(imgs) for imgs in worker_images.values())
|
||||||
@@ -537,6 +581,7 @@ class DistributedCollectorNode:
|
|||||||
async with prompt_server.distributed_jobs_lock:
|
async with prompt_server.distributed_jobs_lock:
|
||||||
if multi_job_id in prompt_server.distributed_pending_jobs:
|
if multi_job_id in prompt_server.distributed_pending_jobs:
|
||||||
del prompt_server.distributed_pending_jobs[multi_job_id]
|
del prompt_server.distributed_pending_jobs[multi_job_id]
|
||||||
|
self._mark_job_closed(multi_job_id)
|
||||||
|
|
||||||
combined_audio = self._combine_audio(master_audio, worker_audio, self.EMPTY_AUDIO, enabled_workers)
|
combined_audio = self._combine_audio(master_audio, worker_audio, self.EMPTY_AUDIO, enabled_workers)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import types
|
import types
|
||||||
import unittest
|
import unittest
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -153,6 +154,7 @@ def _load_job_routes_module():
|
|||||||
sys.modules[f"{package_name}.utils.network"] = network_module
|
sys.modules[f"{package_name}.utils.network"] = network_module
|
||||||
|
|
||||||
constants_module = types.ModuleType(f"{package_name}.utils.constants")
|
constants_module = types.ModuleType(f"{package_name}.utils.constants")
|
||||||
|
constants_module.CLOSED_JOB_TTL_SECONDS = 3600.0
|
||||||
constants_module.MEMORY_CLEAR_DELAY = 0.0
|
constants_module.MEMORY_CLEAR_DELAY = 0.0
|
||||||
constants_module.JOB_INIT_GRACE_PERIOD = 10.0
|
constants_module.JOB_INIT_GRACE_PERIOD = 10.0
|
||||||
sys.modules[f"{package_name}.utils.constants"] = constants_module
|
sys.modules[f"{package_name}.utils.constants"] = constants_module
|
||||||
@@ -325,6 +327,53 @@ class JobCompleteAudioPayloadTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertIsNone(queued["tensor"])
|
self.assertIsNone(queued["tensor"])
|
||||||
self.assertEqual(queued["audio"]["sample_rate"], 44100)
|
self.assertEqual(queued["audio"]["sample_rate"], 44100)
|
||||||
|
|
||||||
|
async def test_job_complete_returns_gone_for_known_closed_job(self):
|
||||||
|
job_routes.prompt_server.distributed_jobs_lock = asyncio.Lock()
|
||||||
|
job_routes.prompt_server.distributed_pending_jobs = {}
|
||||||
|
job_routes.prompt_server.distributed_closed_jobs = {
|
||||||
|
"closed-job": time.monotonic()
|
||||||
|
}
|
||||||
|
request = _FakeRequest(
|
||||||
|
{
|
||||||
|
"job_id": "closed-job",
|
||||||
|
"worker_id": "worker-1",
|
||||||
|
"batch_idx": 0,
|
||||||
|
"image": "data:image/png;base64,AAAA",
|
||||||
|
"is_last": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(job_routes, "JOB_INIT_GRACE_PERIOD", 0.0),
|
||||||
|
patch.object(job_routes, "_decode_canonical_png_tensor", return_value="tensor-data"),
|
||||||
|
):
|
||||||
|
response = await job_routes.job_complete_endpoint(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status, 410)
|
||||||
|
self.assertEqual(response.payload.get("code"), "job_closed")
|
||||||
|
|
||||||
|
async def test_job_complete_keeps_unknown_job_distinct_from_closed_job(self):
|
||||||
|
job_routes.prompt_server.distributed_jobs_lock = asyncio.Lock()
|
||||||
|
job_routes.prompt_server.distributed_pending_jobs = {}
|
||||||
|
job_routes.prompt_server.distributed_closed_jobs = {}
|
||||||
|
request = _FakeRequest(
|
||||||
|
{
|
||||||
|
"job_id": "unknown-job",
|
||||||
|
"worker_id": "worker-1",
|
||||||
|
"batch_idx": 0,
|
||||||
|
"image": "data:image/png;base64,AAAA",
|
||||||
|
"is_last": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(job_routes, "JOB_INIT_GRACE_PERIOD", 0.0),
|
||||||
|
patch.object(job_routes, "_decode_canonical_png_tensor", return_value="tensor-data"),
|
||||||
|
):
|
||||||
|
response = await job_routes.job_complete_endpoint(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status, 404)
|
||||||
|
|
||||||
async def test_job_complete_rejects_payload_without_image_or_audio(self):
|
async def test_job_complete_rejects_payload_without_image_or_audio(self):
|
||||||
request = _FakeRequest(
|
request = _FakeRequest(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import asyncio
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import types
|
import types
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -87,5 +89,54 @@ class QueuePromptPayloadTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(extra_data["extra_pnginfo"]["workflow"], {"id": "workflow-1"})
|
self.assertEqual(extra_data["extra_pnginfo"]["workflow"], {"id": "workflow-1"})
|
||||||
|
|
||||||
|
|
||||||
|
class RunAsyncInServerLoopTests(unittest.TestCase):
|
||||||
|
def test_propagates_comfy_style_base_exception(self):
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
loop_thread = threading.Thread(target=loop.run_forever, daemon=True)
|
||||||
|
loop_thread.start()
|
||||||
|
original_get_server_loop = async_helpers.get_server_loop
|
||||||
|
async_helpers.get_server_loop = lambda: loop
|
||||||
|
|
||||||
|
class ComfyStyleInterrupt(BaseException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def raise_interrupt():
|
||||||
|
raise ComfyStyleInterrupt("cancelled")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self.assertRaisesRegex(ComfyStyleInterrupt, "cancelled"):
|
||||||
|
async_helpers.run_async_in_server_loop(raise_interrupt(), timeout=1.0)
|
||||||
|
finally:
|
||||||
|
async_helpers.get_server_loop = original_get_server_loop
|
||||||
|
loop.call_soon_threadsafe(loop.stop)
|
||||||
|
loop_thread.join(timeout=1.0)
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
def test_timeout_cancels_scheduled_coroutine(self):
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
loop_thread = threading.Thread(target=loop.run_forever, daemon=True)
|
||||||
|
loop_thread.start()
|
||||||
|
original_get_server_loop = async_helpers.get_server_loop
|
||||||
|
async_helpers.get_server_loop = lambda: loop
|
||||||
|
cancelled = threading.Event()
|
||||||
|
|
||||||
|
async def wait_forever():
|
||||||
|
try:
|
||||||
|
await asyncio.Future()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
cancelled.set()
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self.assertRaisesRegex(TimeoutError, "timed out after 0.05 seconds"):
|
||||||
|
async_helpers.run_async_in_server_loop(wait_forever(), timeout=0.05)
|
||||||
|
self.assertTrue(cancelled.wait(timeout=1.0))
|
||||||
|
finally:
|
||||||
|
async_helpers.get_server_loop = original_get_server_loop
|
||||||
|
loop.call_soon_threadsafe(loop.stop)
|
||||||
|
loop_thread.join(timeout=1.0)
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ def _load_collector_module():
|
|||||||
comfy_module = types.ModuleType("comfy")
|
comfy_module = types.ModuleType("comfy")
|
||||||
model_management = types.ModuleType("comfy.model_management")
|
model_management = types.ModuleType("comfy.model_management")
|
||||||
|
|
||||||
class InterruptProcessingException(Exception):
|
class InterruptProcessingException(BaseException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
model_management.InterruptProcessingException = InterruptProcessingException
|
model_management.InterruptProcessingException = InterruptProcessingException
|
||||||
@@ -85,7 +85,9 @@ def _load_collector_module():
|
|||||||
sys.modules[f"{package_name}.utils.config"] = config_module
|
sys.modules[f"{package_name}.utils.config"] = config_module
|
||||||
|
|
||||||
constants_module = types.ModuleType(f"{package_name}.utils.constants")
|
constants_module = types.ModuleType(f"{package_name}.utils.constants")
|
||||||
|
constants_module.CLOSED_JOB_TTL_SECONDS = 3600.0
|
||||||
constants_module.HEARTBEAT_INTERVAL = 1.0
|
constants_module.HEARTBEAT_INTERVAL = 1.0
|
||||||
|
constants_module.MAX_COLLECTOR_BUSY_GRACE_PERIODS = 10
|
||||||
sys.modules[f"{package_name}.utils.constants"] = constants_module
|
sys.modules[f"{package_name}.utils.constants"] = constants_module
|
||||||
|
|
||||||
image_module = types.ModuleType(f"{package_name}.utils.image")
|
image_module = types.ModuleType(f"{package_name}.utils.image")
|
||||||
@@ -248,6 +250,90 @@ def test_worker_list_input_sends_one_completion_sequence_with_last_only_on_final
|
|||||||
assert {payload["worker_id"] for payload in posted_payloads} == {"worker-a"}
|
assert {payload["worker_id"] for payload in posted_payloads} == {"worker-a"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_treats_closed_master_job_as_terminal_acknowledgement():
|
||||||
|
module = _load_collector_module()
|
||||||
|
collector = module.DistributedCollectorNode()
|
||||||
|
images = torch.zeros(2, 2, 2, 3)
|
||||||
|
posted_payloads = []
|
||||||
|
|
||||||
|
class _FakeImage:
|
||||||
|
def save(self, fp, format=None, compress_level=None):
|
||||||
|
fp.write(b"png-bytes")
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
status = 410
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def json(self, content_type=None):
|
||||||
|
return {"code": "job_closed", "error": "job no longer accepts results"}
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
raise AssertionError("known closed jobs must not raise")
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
def post(self, url, json, timeout):
|
||||||
|
posted_payloads.append(json)
|
||||||
|
return _FakeResponse()
|
||||||
|
|
||||||
|
async def _fake_get_client_session():
|
||||||
|
return _FakeSession()
|
||||||
|
|
||||||
|
module.tensor_to_pil = lambda *_args, **_kwargs: _FakeImage()
|
||||||
|
module.get_client_session = _fake_get_client_session
|
||||||
|
module.encode_audio_payload = lambda _audio: None
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
collector.send_batch_to_master(
|
||||||
|
images,
|
||||||
|
None,
|
||||||
|
"closed-job",
|
||||||
|
"http://master",
|
||||||
|
"worker-a",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(posted_payloads) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_master_busy_probe_has_a_finite_grace_limit():
|
||||||
|
module = _load_collector_module()
|
||||||
|
collector = module.DistributedCollectorNode()
|
||||||
|
module.prompt_server.distributed_jobs_lock = asyncio.Lock()
|
||||||
|
module.prompt_server.distributed_pending_jobs = {}
|
||||||
|
module.MAX_COLLECTOR_BUSY_GRACE_PERIODS = 1
|
||||||
|
module.get_worker_timeout_seconds = lambda: 0.05
|
||||||
|
module.load_config = lambda: {
|
||||||
|
"workers": [{"id": "worker-a", "host": "127.0.0.1", "port": 8189}]
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _busy_probe(*_args, **_kwargs):
|
||||||
|
return {"exec_info": {"queue_remaining": 1}}
|
||||||
|
|
||||||
|
module.probe_worker = _busy_probe
|
||||||
|
images = torch.zeros(1, 2, 2, 3)
|
||||||
|
|
||||||
|
combined, _audio = asyncio.run(
|
||||||
|
asyncio.wait_for(
|
||||||
|
collector.execute(
|
||||||
|
images=images,
|
||||||
|
audio=None,
|
||||||
|
multi_job_id="bounded-job",
|
||||||
|
enabled_worker_ids='["worker-a"]',
|
||||||
|
),
|
||||||
|
timeout=0.5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert torch.equal(combined, images)
|
||||||
|
assert "bounded-job" not in module.prompt_server.distributed_pending_jobs
|
||||||
|
assert "bounded-job" in module.prompt_server.distributed_closed_jobs
|
||||||
|
|
||||||
|
|
||||||
def test_audio_only_worker_sends_one_completion_without_image():
|
def test_audio_only_worker_sends_one_completion_without_image():
|
||||||
module = _load_collector_module()
|
module = _load_collector_module()
|
||||||
collector = module.DistributedCollectorNode()
|
collector = module.DistributedCollectorNode()
|
||||||
|
|||||||
+11
-25
@@ -2,7 +2,6 @@
|
|||||||
Async helper utilities for ComfyUI-Distributed.
|
Async helper utilities for ComfyUI-Distributed.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import execution
|
import execution
|
||||||
@@ -26,32 +25,19 @@ def run_async_in_server_loop(coro: Coroutine, timeout: Optional[float] = None) -
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TimeoutError: If the operation times out
|
TimeoutError: If the operation times out
|
||||||
Exception: Any exception raised by the coroutine
|
BaseException: Any error or processing interrupt raised by the coroutine
|
||||||
"""
|
"""
|
||||||
event = threading.Event()
|
|
||||||
result = None
|
|
||||||
error = None
|
|
||||||
|
|
||||||
async def wrapper():
|
|
||||||
nonlocal result, error
|
|
||||||
try:
|
|
||||||
result = await coro
|
|
||||||
except Exception as e:
|
|
||||||
error = e
|
|
||||||
finally:
|
|
||||||
event.set()
|
|
||||||
|
|
||||||
# Schedule on server's event loop
|
|
||||||
loop = get_server_loop()
|
loop = get_server_loop()
|
||||||
asyncio.run_coroutine_threadsafe(wrapper(), loop)
|
future = asyncio.run_coroutine_threadsafe(coro, loop)
|
||||||
|
|
||||||
# Wait for completion
|
try:
|
||||||
if not event.wait(timeout):
|
return future.result(timeout)
|
||||||
raise TimeoutError(f"Async operation timed out after {timeout} seconds")
|
except TimeoutError:
|
||||||
|
# Do not misclassify a TimeoutError raised by the coroutine itself.
|
||||||
if error:
|
if future.done():
|
||||||
raise error
|
raise
|
||||||
return result
|
future.cancel()
|
||||||
|
raise TimeoutError(f"Async operation timed out after {timeout} seconds") from None
|
||||||
|
|
||||||
|
|
||||||
prompt_server = server.PromptServer.instance
|
prompt_server = server.PromptServer.instance
|
||||||
|
|||||||
@@ -45,6 +45,16 @@ MAX_BATCH = int(os.environ.get('COMFYUI_MAX_BATCH', '20')) # Maximum items per
|
|||||||
# Heartbeat monitoring
|
# Heartbeat monitoring
|
||||||
HEARTBEAT_INTERVAL = float(os.environ.get('COMFYUI_HEARTBEAT_INTERVAL', '10')) # Heartbeat/check interval in seconds
|
HEARTBEAT_INTERVAL = float(os.environ.get('COMFYUI_HEARTBEAT_INTERVAL', '10')) # Heartbeat/check interval in seconds
|
||||||
HEARTBEAT_TIMEOUT = int(os.environ.get('COMFYUI_HEARTBEAT_TIMEOUT', '60')) # Worker heartbeat timeout in seconds (default 60s)
|
HEARTBEAT_TIMEOUT = int(os.environ.get('COMFYUI_HEARTBEAT_TIMEOUT', '60')) # Worker heartbeat timeout in seconds (default 60s)
|
||||||
|
# Cap consecutive worker-timeout extensions when probes keep reporting "busy".
|
||||||
|
MAX_COLLECTOR_BUSY_GRACE_PERIODS = max(
|
||||||
|
0,
|
||||||
|
int(os.environ.get('COMFYUI_MAX_COLLECTOR_BUSY_GRACE_PERIODS', '10')),
|
||||||
|
)
|
||||||
|
# Retain terminal job IDs long enough to classify delayed worker callbacks.
|
||||||
|
CLOSED_JOB_TTL_SECONDS = max(
|
||||||
|
0.0,
|
||||||
|
float(os.environ.get('COMFYUI_CLOSED_JOB_TTL_SECONDS', '3600')),
|
||||||
|
)
|
||||||
|
|
||||||
# USDU result collection
|
# USDU result collection
|
||||||
DYNAMIC_MODE_MAX_POLL_TIMEOUT = 10.0
|
DYNAMIC_MODE_MAX_POLL_TIMEOUT = 10.0
|
||||||
|
|||||||
Reference in New Issue
Block a user