Files
robertvoy-ComfyUI-Distributed/tests/test_load_balance_orchestration.py
speedyrulzandClaude Fable 5.1 09c4554dd0 fix: audit follow-ups for the next-free-GPU dispatcher
Lifecycle:
- Queue clears and deletes leave a run whose master prompt is currently
  executing alone, as ComfyUI leaves the running prompt alone; previously an
  executing orchestrator-only head run could be cancelled out from under its
  waiting Collector.
- The sweep now also drops worker-assigned runs whose queued delegate prompt
  left the queue without the Collector finishing, releasing their result
  queues instead of leaking them and polling forever.
- Completed runs release their result queues, and orchestration no longer
  pre-creates result queues when load balance picks the master alone.

Transport:
- A websocket timeout during connect, before anything was sent, is a plain
  failure the dispatcher may retry elsewhere; only timeouts after the prompt
  was sent remain AmbiguousDispatchError.

Efficiency and cleanup:
- One WorkerSnapshot per worker per cycle answers every run's "is my job
  there" question (queue first, history only when needed).
- Sweep and cancellation snapshot queued/running prompt ids once per pass.
- probe_worker_queues reuses dispatch._probe_worker_queue; the dead
  aggregate idle/busy probe helper is gone.

Rebased on "never replay load-balanced runs on missing history": an
unresolved submission stays on hold until delivery or positive evidence,
so there is no rejection path to fix and no bounded hold.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-20 20:54:55 -04:00

284 lines
14 KiB
Python

"""Queue orchestration keeps a load-balanced run waiting when every GPU is busy."""
import asyncio
import importlib.util
import json
import sys
import types
import unittest
from unittest.mock import AsyncMock, patch
from test_serverless_integration import load_modules, module, PKG, ROOT
class FakeLoadBalanceQueue:
def __init__(self):
self.records = []
self.queued = []
def register(self, record):
self.records.append(record)
return record
def mark_queued(self, record):
assert record in self.records
self.queued.append(record)
def register_dispatched(self, record, worker):
self.records.append(record)
record.dispatched_worker = worker
def discard_run(self, prompt_id):
self.records = [record for record in self.records if record.prompt_id != prompt_id]
def mark_unresolved(self, record, worker, item=None):
record.unresolved = worker
def _remove(self, record):
self.records.remove(record)
@property
def _records(self):
return {record.prompt_id: record for record in self.records}
class FakePendingRun:
def __init__(self, prompt_id, job_ids, candidates, master_may_run, prepare_for_worker, send_to_worker,
requeue_as_delegate, trace_execution_id=None):
self.prompt_id = prompt_id
self.job_ids = set(job_ids)
self.candidates = list(candidates)
self.master_may_run = master_may_run
self.prepare_for_worker = prepare_for_worker
self.send_to_worker = send_to_worker
self.requeue_as_delegate = requeue_as_delegate
self.unresolved = None
self.cancelled = False
class AmbiguousDispatchError(RuntimeError):
pass
class DeferredLoadBalanceTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.q, modules, self.ps = load_modules()
self.patch = patch.dict(sys.modules, modules)
self.patch.start()
self.addCleanup(self.patch.stop)
self.worker = {"id": "w1", "name": "Worker", "type": "local", "port": 8189, "enabled": True}
self.settings = {"master_delegate_only": False}
self.q.load_config = lambda: {"workers": [self.worker], "settings": self.settings}
self.q.select_active_workers = AsyncMock(side_effect=lambda workers, _ws, delegate, **kw: (workers, delegate))
self.q.select_least_busy_worker = AsyncMock()
sys.modules[PKG + ".api.orchestration.dispatch"].AmbiguousDispatchError = AmbiguousDispatchError
# The real helper queues under the id it is handed; echo it like it does.
self.q.queue_prompt_payload = AsyncMock(side_effect=lambda *a, **kw: {
"prompt_id": kw.get("prompt_id", "master-prompt"), "number": 3, "node_errors": {}})
self.lb_queue = FakeLoadBalanceQueue()
self.select_idle_worker = AsyncMock(return_value=(None, 1))
sys.modules[PKG + ".api.orchestration.load_balance_queue"] = module(
"load_balance_queue",
select_idle_worker=self.select_idle_worker,
PendingRun=FakePendingRun,
get_load_balance_queue=lambda *a: self.lb_queue,
)
self.prompt = {
"1": {"class_type": "EmptyImage", "inputs": {"width": 32, "height": 32, "batch_size": 1, "color": 0}},
"2": {"class_type": "DistributedCollector", "inputs": {"images": ["1", 0], "load_balance": True}},
"3": {"class_type": "SaveImage", "inputs": {"images": ["2", 0], "filename_prefix": "local"}},
}
async def _run(self):
return await self.q.orchestrate_distributed_execution(self.prompt, None, "client")
async def test_all_busy_keeps_full_prompt_waiting_for_a_participating_master(self):
prompt_id, number, worker_count, _ = await self._run()
self.assertEqual(worker_count, 0)
self.q.dispatch_worker_prompt.assert_not_awaited()
self.q.select_least_busy_worker.assert_not_awaited()
candidates = self.select_idle_worker.call_args.args[0]
self.assertEqual([c["id"] for c in candidates], ["w1", "master"])
self.assertEqual(len(self.lb_queue.records), 1)
record = self.lb_queue.records[0]
self.assertEqual(record.prompt_id, prompt_id)
self.assertEqual(self.lb_queue.queued, [record]) # released only after the master prompt is queued
self.assertTrue(record.master_may_run)
self.assertEqual([c["id"] for c in record.candidates], ["w1"])
master_prompt = self.q.queue_prompt_payload.call_args.args[0]
self.assertEqual(self.q.queue_prompt_payload.call_args.kwargs["prompt_id"], prompt_id)
self.assertIn("1", master_prompt) # full prompt: the master may run it
collector = master_prompt["2"]["inputs"]
self.assertEqual(json.loads(collector["enabled_worker_ids"]), [])
self.assertFalse(collector["delegate_only"])
self.assertEqual(record.job_ids, {collector["multi_job_id"]})
async def test_all_busy_queues_delegate_prompt_for_orchestrator_only_master(self):
self.settings["master_delegate_only"] = True
prompt_id, *_ = await self._run()
record = self.lb_queue.records[0]
self.assertFalse(record.master_may_run)
candidates = self.select_idle_worker.call_args.args[0]
self.assertEqual([c["id"] for c in candidates], ["w1"])
master_prompt = self.q.queue_prompt_payload.call_args.args[0]
self.assertNotIn("1", master_prompt) # pruned: nothing runs on the master
self.assertTrue(master_prompt["2"]["inputs"]["delegate_only"])
self.assertEqual(json.loads(master_prompt["2"]["inputs"]["enabled_worker_ids"]), [])
async def test_waiting_run_dispatches_and_requeues_when_a_worker_frees_up(self):
prompt_id, *_ = await self._run()
record = self.lb_queue.records[0]
self.q.queue_prompt_payload.reset_mock()
payload = await record.prepare_for_worker(self.worker)
self.q.dispatch_worker_prompt.assert_not_awaited() # preparation submits nothing
await record.send_to_worker(self.worker, payload)
self.q.dispatch_worker_prompt.assert_awaited_once()
worker, worker_prompt = self.q.dispatch_worker_prompt.call_args.args[:2]
self.assertEqual(worker["id"], "w1")
self.assertTrue(worker_prompt["2"]["inputs"]["is_worker"])
self.assertEqual(json.loads(worker_prompt["2"]["inputs"]["enabled_worker_ids"]), ["w1"])
await record.requeue_as_delegate(self.worker)
self.q.queue_prompt_payload.assert_awaited_once()
delegate_prompt = self.q.queue_prompt_payload.call_args.args[0]
self.assertEqual(self.q.queue_prompt_payload.call_args.kwargs["prompt_id"], prompt_id)
self.assertNotIn("1", delegate_prompt)
self.assertEqual(json.loads(delegate_prompt["2"]["inputs"]["enabled_worker_ids"]), ["w1"])
self.assertTrue(delegate_prompt["2"]["inputs"]["delegate_only"])
# The insertion guard follows the run's cancellation flag.
insert_if = self.q.queue_prompt_payload.call_args.kwargs["insert_if"]
self.assertTrue(insert_if())
record.cancelled = True
self.assertFalse(insert_if())
async def test_idle_worker_is_dispatched_right_away_and_master_stays_free(self):
self.select_idle_worker.return_value = (self.worker, 2)
prompt_id, _, worker_count, _ = await self._run()
self.assertEqual(worker_count, 1)
self.q.dispatch_worker_prompt.assert_awaited_once()
# Participating master: no collect-only prompt parked on its executor.
self.q.queue_prompt_payload.assert_not_awaited()
self.assertEqual(len(self.lb_queue.records), 1)
record = self.lb_queue.records[0]
self.assertEqual(record.prompt_id, prompt_id)
self.assertEqual(record.dispatched_worker["id"], "w1")
self.assertFalse(record.master_may_run)
# Delivered later: the delegate prompt is queued under the same id.
await record.requeue_as_delegate(self.worker)
delegate_prompt = self.q.queue_prompt_payload.call_args.args[0]
self.assertEqual(self.q.queue_prompt_payload.call_args.kwargs["prompt_id"], prompt_id)
self.assertEqual(json.loads(delegate_prompt["2"]["inputs"]["enabled_worker_ids"]), ["w1"])
async def test_failed_dispatch_of_held_run_discards_it(self):
self.select_idle_worker.return_value = (self.worker, 2)
self.q.dispatch_worker_prompt.side_effect = RuntimeError("worker refused")
with self.assertRaises(RuntimeError):
await self._run()
self.assertEqual(self.lb_queue.records, [])
async def test_ambiguous_eager_dispatch_keeps_the_run_on_hold(self):
"""Lost acknowledgement on the immediate dispatch: report it, but never resend or forget it."""
self.select_idle_worker.return_value = (self.worker, 2)
self.q.dispatch_worker_prompt.side_effect = AmbiguousDispatchError("ack lost")
with self.assertRaises(AmbiguousDispatchError):
await self._run()
self.assertEqual(len(self.lb_queue.records), 1)
self.assertEqual(self.lb_queue.records[0].unresolved["id"], "w1")
self.q.queue_prompt_payload.assert_not_awaited()
async def test_master_selected_alone_creates_no_result_queues(self):
self.select_idle_worker.return_value = ({"id": "master", "name": "Master", "type": "local"}, 2)
_, _, worker_count, _ = await self._run()
self.assertEqual(worker_count, 0)
self.q.queue_prompt_payload.assert_awaited_once()
self.assertEqual(self.ps.distributed_pending_jobs, {})
self.assertEqual(self.lb_queue.records, [])
async def test_orchestrator_only_master_queues_delegate_immediately(self):
self.settings["master_delegate_only"] = True
self.select_idle_worker.return_value = (self.worker, 1)
_, _, worker_count, _ = await self._run()
self.assertEqual(worker_count, 1)
self.q.dispatch_worker_prompt.assert_awaited_once()
self.q.queue_prompt_payload.assert_awaited_once()
self.assertEqual(self.lb_queue.records, [])
async def test_unreachable_probe_falls_back_to_first_candidate(self):
self.select_idle_worker.return_value = (None, 0)
_, _, worker_count, _ = await self._run()
self.assertEqual(worker_count, 1)
self.q.dispatch_worker_prompt.assert_awaited_once()
# Sent to the first worker; the participating master still holds the delegate back.
self.assertEqual(len(self.lb_queue.records), 1)
self.assertEqual(self.lb_queue.records[0].dispatched_worker["id"], "w1")
self.q.queue_prompt_payload.assert_not_awaited()
async def test_master_queue_failure_discards_the_waiting_run(self):
self.q.queue_prompt_payload.side_effect = RuntimeError("validation failed")
with self.assertRaises(RuntimeError):
await self._run()
self.assertEqual(self.lb_queue.records, [])
self.assertEqual(self.lb_queue.queued, [])
async def test_dispatcher_sweep_during_prompt_validation_keeps_the_run(self):
"""Regression: validation yields to the loop before the prompt is queued."""
from test_load_balance_queue import FakePromptQueue
prompt_queue = FakePromptQueue()
self.ps.prompt_queue = prompt_queue
network = sys.modules[PKG + ".utils.network"]
network.build_worker_url = lambda worker, *a: f"http://{worker['id']}"
network.probe_worker = AsyncMock(return_value=None)
dispatch = sys.modules[PKG + ".api.orchestration.dispatch"]
async def always_busy_probe(worker, semaphore, probe_timeout):
return {"worker": worker, "queue_remaining": 1}
dispatch._probe_worker_queue = always_busy_probe
dispatch._select_idle_round_robin = lambda statuses: statuses[0]
spec = importlib.util.spec_from_file_location(
PKG + ".api.orchestration.load_balance_queue", ROOT / "api" / "orchestration" / "load_balance_queue.py")
lb = importlib.util.module_from_spec(spec)
spec.loader.exec_module(lb)
lb.select_idle_worker = AsyncMock(return_value=(None, 1))
sys.modules[PKG + ".api.orchestration.load_balance_queue"] = lb
queue = lb.get_load_balance_queue(self.ps)
queue.poll_interval = 0.01
sweeps = []
async def slow_validation(prompt, workflow_meta, client_id, include_queue_metadata=False, *, prompt_id=None, **kw):
# Yield long enough for the dispatcher task to run its sweep.
for _ in range(5):
await asyncio.sleep(0.02)
queue.sweep()
sweeps.append(len(queue))
prompt_queue.put((0, prompt_id, prompt, {}, [], {}))
return {"prompt_id": prompt_id, "number": 0, "node_errors": {}}
self.q.queue_prompt_payload = AsyncMock(side_effect=slow_validation)
try:
prompt_id, *_ = await self._run()
self.assertTrue(sweeps and all(count == 1 for count in sweeps))
record = queue._records.get(prompt_id)
self.assertIsNotNone(record)
self.assertTrue(record.queued)
self.assertEqual(prompt_queue.prompt_ids(), [prompt_id])
queue.sweep()
self.assertIs(queue._records.get(prompt_id), record)
finally:
queue._records.clear()
await asyncio.sleep(0.05)
async def test_upscale_prompts_keep_queue_depth_selection(self):
self.prompt["4"] = {"class_type": "UltimateSDUpscaleDistributed", "inputs": {"upscaled_image": ["1", 0]}}
self.q.select_least_busy_worker.return_value = self.worker
await self._run()
self.select_idle_worker.assert_not_awaited()
self.q.select_least_busy_worker.assert_awaited_once()
self.assertEqual(self.lb_queue.records, [])
if __name__ == "__main__":
unittest.main()