Files
JasonHoku-ComfyUI-Ultimate-…/distribution_worker.py
T
JasonHokuandClaude Opus 4.6 d87947bb11 Fix worker 503 race condition and skip lora trigger lookups with master encoding
1. Worker registration now retries up to 5 times with 3s delay on 503,
   handling the case where master is still starting up (e.g. pre-encoding).
2. Worker claim_job on 503 no longer immediately stops - gives master up to
   5 poll cycles to become active before giving up (only when no jobs processed yet).
3. When master pre-encoding is enabled, worker skips build_prompt_with_triggers()
   entirely (avoids CivitAI lookups and LoRA file hashing). Master now includes
   the final prompt text (with triggers) in the job dict for metadata use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:53:47 -08:00

542 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Distributed Worker Thread
Runs on remote ComfyUI instances. Polls the master for jobs,
processes them using the local GPU, and uploads results back.
"""
import threading
import time
import uuid
import json
import io
import os
import random
import urllib.request
import urllib.error
import urllib.parse
import torch
class _MasterFinishedError(Exception):
"""Raised when the master has deactivated (503 on submit).
Not a real error — means master already completed all jobs."""
pass
class WorkerThread(threading.Thread):
"""
Background thread that polls master for jobs and processes them.
Lifecycle:
1. Register with master
2. Claim job (GET /distribution/claim_job)
3. Process job locally (model load → encode → sample → VAE decode)
4. Submit result (POST /distribution/submit_result)
5. Repeat from 2 until no more jobs
6. Exit
"""
def __init__(self, master_url):
super().__init__(daemon=True)
self.master_url = master_url.rstrip("/")
self.worker_id = f"w_{uuid.uuid4().hex[:8]}"
self.jobs_processed = 0
self.current_model = None
self._stop_event = threading.Event()
self.poll_interval = 2 # seconds between polls when idle
self.heartbeat_interval = 30 # seconds between heartbeats
self.consecutive_empty = 0 # Track consecutive empty poll responses
self.max_empty_polls = 30 # Stop after this many consecutive empty polls
# Cached state for model reuse between jobs
self._loaded_model = None
self._loaded_clip = None
self._loaded_vae = None
self._patched_model = None
self._patched_clip = None
self._cached_model_key = None
self._cached_lora_key = None
self._incompatible_loras = {}
def run(self):
"""Main worker loop."""
print(f"[Worker {self.worker_id}] 🚀 Starting worker thread, master: {self.master_url}")
# Worker threads run outside ComfyUI's normal prompt execution context.
# The sampling callback (latent_preview.py → comfy/utils.py → main.py hook)
# expects PromptServer.instance.last_prompt_id to exist. Set a dummy value
# so the progress bar callback doesn't crash with AttributeError.
try:
from server import PromptServer
if PromptServer and hasattr(PromptServer, 'instance') and PromptServer.instance is not None:
if not hasattr(PromptServer.instance, 'last_prompt_id'):
PromptServer.instance.last_prompt_id = f"dist_worker_{self.worker_id}"
print(f"[Worker {self.worker_id}] 🔧 Set last_prompt_id for sampling callbacks")
except Exception:
pass
# Register with master
self._register()
# Start background heartbeat thread so heartbeats flow even during
# long-running job processing (model loading, generation, etc.).
# Without this, the main loop blocks on _process_job() and no heartbeats
# are sent, causing the master to wrongly reclaim jobs from slow workers.
self._heartbeat_thread = threading.Thread(
target=self._heartbeat_loop, daemon=True
)
self._heartbeat_thread.start()
while not self._stop_event.is_set():
# Claim a job
job = self._claim_job()
if job is None:
self.consecutive_empty += 1
if self.consecutive_empty >= self.max_empty_polls:
print(f"[Worker {self.worker_id}] 🏁 No more jobs after "
f"{self.max_empty_polls} polls, stopping")
break
self._stop_event.wait(self.poll_interval)
continue
self.consecutive_empty = 0
# Validate job has required fields
if not all(k in job for k in ("job_id", "config", "input_job")):
print(f"[Worker {self.worker_id}] ⚠️ Malformed job, skipping: {list(job.keys())}")
continue
# Process the job
try:
image_bytes, meta = self._process_job(job)
# Submit result (with retry on network error)
self._submit_result(job["job_id"], image_bytes, meta)
self.jobs_processed += 1
print(f"[Worker {self.worker_id}] ✅ Job {job['job_id']} complete "
f"(total: {self.jobs_processed})")
# Periodic VRAM cleanup
if self.jobs_processed % 5 == 0:
import gc
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
except _MasterFinishedError:
# Master has shut down (503 on submit). This is NOT a failure —
# the master already finished all jobs (possibly reclaimed ours).
# Stop gracefully instead of reporting failure.
print(f"[Worker {self.worker_id}] 🏁 Master finished before we could submit "
f"job {job['job_id']} — stopping gracefully")
break
except Exception as e:
# Check if ComfyUI interrupted processing on this worker
try:
from comfy.model_management import InterruptProcessingException
if isinstance(e, InterruptProcessingException):
print(f"[Worker {self.worker_id}] 🛑 Processing interrupted, stopping worker")
self._report_failure(job["job_id"], "Worker interrupted")
break
except ImportError:
pass
print(f"[Worker {self.worker_id}] ❌ Job {job['job_id']} failed: {e}")
import traceback
traceback.print_exc()
self._report_failure(job["job_id"], str(e))
# Cleanup
self._cleanup_models()
print(f"[Worker {self.worker_id}] 🛑 Worker stopped. "
f"Processed {self.jobs_processed} jobs.")
def stop(self):
"""Signal the worker to stop after its current job."""
self._stop_event.set()
def _register(self):
"""Register with master. Retries on 503 (master still starting up)."""
max_retries = 5
retry_delay = 3 # seconds between retries
for attempt in range(1, max_retries + 1):
data = json.dumps({
"worker_id": self.worker_id,
"worker_url": ""
}).encode("utf-8")
req = urllib.request.Request(
f"{self.master_url}/distribution/register_worker",
data=data,
headers={"Content-Type": "application/json"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
result = json.loads(resp.read().decode("utf-8"))
print(f"[Worker {self.worker_id}] 🤝 Registered with master "
f"(session: {result.get('session_name', 'unknown')})")
return # Success
except urllib.error.HTTPError as e:
if e.code == 503 and attempt < max_retries:
print(f"[Worker {self.worker_id}] ⏳ Master not ready yet "
f"(attempt {attempt}/{max_retries}), retrying in {retry_delay}s...")
self._stop_event.wait(retry_delay)
if self._stop_event.is_set():
return
continue
print(f"[Worker {self.worker_id}] ⚠️ Registration failed: {e}")
return
except Exception as e:
print(f"[Worker {self.worker_id}] ⚠️ Registration failed: {e}")
return
def _claim_job(self):
"""Claim next job from master. Returns job dict or None."""
url = (f"{self.master_url}/distribution/claim_job"
f"?worker_id={urllib.parse.quote(self.worker_id)}")
if self.consecutive_empty == 0 and self.jobs_processed == 0:
print(f"[Worker {self.worker_id}] 🔍 Claiming from: {url}")
req = urllib.request.Request(url, method="GET")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
if resp.status == 204:
return None
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if e.code == 204:
return None
if e.code == 503:
# Distribution not active yet — master may still be starting up
# (e.g. pre-encoding prompts). Don't immediately stop; let the
# normal empty-poll counter handle eventual timeout.
if self.jobs_processed == 0 and self.consecutive_empty < 5:
print(f"[Worker {self.worker_id}] ⏳ Master not active yet, "
f"waiting... ({self.consecutive_empty + 1}/5)")
elif self.jobs_processed > 0:
# Already processed jobs — master has finished and deactivated.
# Stop immediately to avoid wasting time.
print(f"[Worker {self.worker_id}] ℹ️ Distribution ended on master")
self.consecutive_empty = self.max_empty_polls
else:
print(f"[Worker {self.worker_id}] ℹ️ Distribution not active on master")
self.consecutive_empty = self.max_empty_polls
return None
print(f"[Worker {self.worker_id}] ⚠️ Claim failed: HTTP {e.code}")
return None
except Exception as e:
print(f"[Worker {self.worker_id}] ⚠️ Claim failed: {e}")
return None
def _process_job(self, job):
"""
Process a single job using local ComfyUI infrastructure.
Steps:
1. Load model/LoRA (reuse if same as previous job)
2. Encode prompts with CLIP
3. Create latent noise tensor
4. Generate image (KSampler)
5. Decode with local VAE
6. Return image bytes + metadata
Args:
job: Job dict from master with config, input_job, gen_index
Returns:
tuple: (image_bytes, metadata_dict)
"""
from .model_loader import (
load_checkpoint, load_loras, load_vae_by_name,
load_diffusion_model_and_clip, get_latent_channels
)
from .image_generation import (
generate_image, decode_latent_with_vae, create_image_metadata
)
from .batch_encoding import encode_prompt_with_combinators
from .trigger_words import build_prompt_with_triggers
from .generation_orchestrator import get_model_cache_key
config = job["config"]
input_job = job["input_job"]
w = input_job["width"]
h = input_job["height"]
batch_idx = input_job.get("batch_idx", 0)
# Handle seed_behavior: "randomize" (same as main generation loop)
if config.get("seed_behavior") == "randomize":
config["seed"] = random.randint(0, 2**63 - 1)
# --- Model Loading (with caching between jobs) ---
target_model_key = get_model_cache_key(config)
self.current_model = config.get("model", "unknown")
if target_model_key != self._cached_model_key:
# Need to load a different model
self._cleanup_models()
model_type = config.get("model_type", "checkpoint")
if model_type == "checkpoint":
self._loaded_model, self._loaded_clip, self._loaded_vae = load_checkpoint(
config["model"], config["model"], False,
None, None, None, None, None, None, None,
model_cache=None
)
else:
self._loaded_model, self._loaded_clip, self._loaded_vae = load_diffusion_model_and_clip(
model_name=config["model"],
model_type=model_type,
text_encoder_paths=config.get("text_encoders", []),
clip_type_str=config.get("clip_type", "stable_diffusion"),
gguf_options=config.get("gguf_options"),
use_remote_vae=False,
optional_model=None,
optional_clip=None,
optional_vae=None,
model_cache=None
)
self._cached_model_key = target_model_key
self._cached_lora_key = None # Force LoRA reload on model switch
print(f"[Worker {self.worker_id}] 📦 Loaded model: {config['model']}")
# --- Per-config VAE loading ---
config_vae = config.get("vae", "Default")
if config_vae != "Default":
vae = load_vae_by_name(config_vae)
else:
vae = self._loaded_vae
# Validate VAE is available (non-checkpoint models like GGUF may not bundle a VAE)
if vae is None:
raise RuntimeError(
f"No VAE available for model '{config.get('model', 'unknown')}'. "
f"Non-checkpoint models (GGUF/diffusion) require a VAE to be specified "
f"in the config builder, or connect one via optional_vae on the master."
)
# --- LoRA Loading (with caching) ---
lora_key = config.get("lora_expanded", config.get("lora", "None"))
if lora_key != self._cached_lora_key:
if lora_key != "None":
self._patched_model, self._patched_clip, should_skip = load_loras(
self._loaded_model, self._loaded_clip, lora_key,
config["model"], self._incompatible_loras,
model_cache=None
)
if should_skip:
raise RuntimeError(f"LoRA incompatible: {lora_key}")
else:
self._patched_model = self._loaded_model
self._patched_clip = self._loaded_clip
self._cached_lora_key = lora_key
print(f"[Worker {self.worker_id}] 🔗 Applied LoRA: {lora_key}")
# --- Prompt Encoding ---
# Check for master pre-encoded conditionings (skip CLIP encoding if available)
encoded_pos = job.get("encoded_positive")
encoded_neg = job.get("encoded_negative")
if encoded_pos is not None and encoded_neg is not None:
# Master already encoded these prompts — deserialize and use directly.
# Use the master's prompt text for metadata (avoids CivitAI trigger lookups
# on the worker since the master already applied trigger words during encoding).
from .generation_orchestrator import _serializable_to_conditioning
pos_cond = _serializable_to_conditioning(encoded_pos)
neg_cond = _serializable_to_conditioning(encoded_neg)
actual_positive = job.get("actual_positive_text", config.get("positive", ""))
actual_negative = job.get("actual_negative_text", config.get("negative", ""))
print(f"[Worker {self.worker_id}] 🧠 Using master pre-encoded conditionings")
else:
# Fallback: encode locally with CLIP (need to build prompt with trigger words)
lora_triggerwords_mode = config.get("_lora_triggerwords_mode", "None")
try:
actual_positive, _ = build_prompt_with_triggers(config, lora_triggerwords_mode)
except Exception:
actual_positive = config.get("positive", "")
actual_negative = config.get("negative", "")
clip_skip = config.get("clip_skip", 0)
pos_cond = encode_prompt_with_combinators(self._patched_clip, actual_positive, clip_skip)
neg_cond = encode_prompt_with_combinators(self._patched_clip, actual_negative, clip_skip)
# --- Create Latent ---
latent_channels = get_latent_channels(self._loaded_model, None)
latent_in = {"samples": torch.zeros([1, latent_channels, h // 8, w // 8])}
# --- Generate Image ---
attention_mode = config.get("attention_mode", "default")
result_latent, duration = generate_image(
self._patched_model, config["seed"], config["steps"], config["cfg"],
config["sampler"], config["scheduler"], pos_cond, neg_cond,
latent_in, config["denoise"],
attention_mode=attention_mode
)
# --- VAE Decode ---
image = decode_latent_with_vae(vae, result_latent["samples"])
# Free latent tensors ASAP (before image conversion, which is CPU-only)
del result_latent, latent_in, pos_cond, neg_cond
# --- Convert to bytes ---
buf = io.BytesIO()
image.save(buf, format="WEBP", quality=80)
image_bytes = buf.getvalue()
del image, buf # Free PIL image and buffer
# --- Create metadata ---
ts = int(time.time() * 100000) + random.randint(0, 1000)
meta = create_image_metadata(
config, w, h, duration, config["seed"],
batch_idx, actual_positive, actual_negative,
gen_index=job.get("gen_index")
)
meta["id"] = ts
return image_bytes, meta
def _submit_result(self, job_id, image_bytes, meta, max_retries=3):
"""
Upload image + metadata to master via multipart POST.
Uses urllib (no 'requests' library for ComfyUI Registry compliance).
Retries on transient network errors (connection reset, timeout).
"""
# JSON-safe meta (ensure no non-serializable types from config round-trip)
try:
safe_meta = json.loads(json.dumps(meta, default=str))
except Exception:
safe_meta = meta
boundary = f"----DistWorker{uuid.uuid4().hex[:16]}"
# Build multipart body manually
body = b""
# Metadata part
body += f"--{boundary}\r\n".encode()
body += b'Content-Disposition: form-data; name="metadata"\r\n'
body += b'Content-Type: application/json\r\n\r\n'
body += json.dumps({
"job_id": job_id,
"meta": safe_meta,
"worker_id": self.worker_id
}).encode("utf-8")
body += b"\r\n"
# Image part
body += f"--{boundary}\r\n".encode()
body += b'Content-Disposition: form-data; name="image"; filename="image.webp"\r\n'
body += b'Content-Type: image/webp\r\n\r\n'
body += image_bytes
body += b"\r\n"
body += f"--{boundary}--\r\n".encode()
last_error = None
for attempt in range(max_retries):
try:
req = urllib.request.Request(
f"{self.master_url}/distribution/submit_result",
data=body,
headers={
"Content-Type": f"multipart/form-data; boundary={boundary}",
},
method="POST"
)
with urllib.request.urlopen(req, timeout=60) as resp:
if resp.status != 200:
raise RuntimeError(f"Submit failed: HTTP {resp.status}")
return # Success
except urllib.error.HTTPError as e:
# 503 = master deactivated (all jobs done). Don't retry — it won't help.
# Raise a special exception so the main loop can stop gracefully.
if e.code == 503:
raise _MasterFinishedError(
f"Master distribution is no longer active (HTTP 503)"
)
last_error = e
if attempt < max_retries - 1:
wait = 2 ** attempt # Exponential backoff: 1s, 2s, 4s
print(f"[Worker {self.worker_id}] ⚠️ Submit retry {attempt + 1}/{max_retries} "
f"for job {job_id}: {e} (waiting {wait}s)")
time.sleep(wait)
except (urllib.error.URLError, ConnectionError, OSError) as e:
last_error = e
if attempt < max_retries - 1:
wait = 2 ** attempt # Exponential backoff: 1s, 2s, 4s
print(f"[Worker {self.worker_id}] ⚠️ Submit retry {attempt + 1}/{max_retries} "
f"for job {job_id}: {e} (waiting {wait}s)")
time.sleep(wait)
raise RuntimeError(f"Submit failed after {max_retries} attempts: {last_error}")
def _send_heartbeat(self):
"""Send heartbeat to master."""
data = json.dumps({"worker_id": self.worker_id}).encode("utf-8")
req = urllib.request.Request(
f"{self.master_url}/distribution/heartbeat",
data=data,
headers={"Content-Type": "application/json"},
method="POST"
)
try:
urllib.request.urlopen(req, timeout=5)
except Exception:
pass
def _heartbeat_loop(self):
"""
Background heartbeat loop. Runs in a separate daemon thread so that
heartbeats are sent continuously even while _process_job() blocks the
main worker loop. This prevents the master from incorrectly reclaiming
jobs from slow-but-alive workers.
"""
while not self._stop_event.is_set():
self._send_heartbeat()
self._stop_event.wait(self.heartbeat_interval)
def _report_failure(self, job_id, error):
"""Report job failure to master."""
data = json.dumps({
"job_id": job_id,
"error": error,
"worker_id": self.worker_id
}).encode("utf-8")
req = urllib.request.Request(
f"{self.master_url}/distribution/fail_job",
data=data,
headers={"Content-Type": "application/json"},
method="POST"
)
try:
urllib.request.urlopen(req, timeout=10)
except Exception:
pass
def _cleanup_models(self):
"""Release cached model references."""
self._loaded_model = None
self._loaded_clip = None
self._loaded_vae = None
self._patched_model = None
self._patched_clip = None
self._cached_model_key = None
self._cached_lora_key = None
self.current_model = None
import gc
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()