Files
nilor-corp-nilor-nodes/worker_consumer.py
T
Sebastian Monroy 207de4487c fix(worker_consumer): propagate submit failures to retry/DLQ and emit failed status
- raise a dedicated JobSubmissionError when ComfyUI prompt submission fails
- re-raise submission failures in process_message so consume loop does not delete SQS messages
- emit failed status updates on submit-time errors using status_policy.fail_status fallback
- remove failed-status dedupe guard so each retry attempt remains observable
- align parse-error logging with retry behavior ("Message will be retried")
2026-02-17 15:40:02 +00:00

715 lines
31 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.
"""
Worker Consumer Service for ComfyUI
This script runs as a continuous background service on each ComfyUI worker.
Its purpose is to poll the `jobs_to_process-comfyui` SQS queue for new jobs,
submit them to the local ComfyUI server, and manage the message lifecycle.
It also listens to the ComfyUI websocket to send a "running" status update
at the precise moment that job execution begins.
"""
import os
import json
import logging
import asyncio
import time
import aiohttp
from aiobotocore.session import get_session
from botocore.exceptions import EndpointConnectionError, ClientError
from .logger import logger
from .comfyui_client import ComfyUILocalClient, ComfyUIClientError
from .memory_hygiene import MemoryHygiene
from .workflow_normalizer import normalize_comfyui_prompt_for_current_os
from .config.config import load_nilor_nodes_config, NilorNodesConfig
# --- Configuration ---
# Centralized loader provides precedence env > JSON5 and validation
_CFG: NilorNodesConfig = load_nilor_nodes_config()
class JobSubmissionError(Exception):
"""Raised when a job cannot be submitted to local ComfyUI."""
class WorkerConsumer:
def __init__(self, cfg: NilorNodesConfig):
self.session = get_session()
self.prompt_id_to_content_id_map = {}
self.sent_running_status_prompts = set()
self.content_context_by_content_id = {}
self.jobs_queue_url = None
self.status_updates_queue_url = None
self.http_session = None
self.comfy_client = None
self.is_busy = False
self.websocket_sid = None
# Memory hygiene component (initialized once a client is available)
self.hygiene = None
# Stable client_id for routing events to this worker
self.cfg = cfg
self.worker_client_id = cfg.worker.worker_client_id
self.current_prompt_id = None
# Hygiene cadence tracking
self._last_hygiene_check_ts = 0.0
async def _initialize_sqs(self):
"""Initializes SQS queue URLs. Returns True on success, False on failure."""
async with self.session.create_client(
"sqs",
region_name=self.cfg.worker.aws_region,
endpoint_url=self.cfg.worker.sqs_endpoint_url,
aws_access_key_id=self.cfg.worker.aws_access_key_id,
aws_secret_access_key=self.cfg.worker.aws_secret_access_key,
) as client:
try:
self.jobs_queue_url = await self._get_queue_url(
client, self.cfg.worker.jobs_queue
)
self.status_updates_queue_url = await self._get_queue_url(
client, self.cfg.worker.status_queue
)
return True
except EndpointConnectionError as e:
# Quiet the noisy traceback by logging a concise warning instead
logger.warning(
f"⚠️\u2009 Nilor-Nodes (worker_consumer): SQS endpoint is unreachable at {self.cfg.worker.sqs_endpoint_url}: {e}. "
)
return False
except Exception as e:
logger.error(
f"⚠️\u2009 Nilor-Nodes (worker_consumer): Failed to initialize SQS queues: {e}"
)
return False
async def _get_queue_url(self, client, queue_name):
"""Retrieves the SQS queue URL."""
try:
response = await client.get_queue_url(QueueName=queue_name)
return response["QueueUrl"]
except client.exceptions.QueueDoesNotExist:
logger.error(
f"⚠️\u2009 Nilor-Nodes (worker_consumer): SQS queue '{queue_name}' does not exist."
)
raise
async def listen_for_comfy_events(self):
while True:
try:
# Wait until a websocket-capable client is available
if self.comfy_client is None:
logger.debug(
"ℹ️\u2009 Nilor-Nodes (worker_consumer): Comfy client not constructed; skipping WS listen this cycle."
)
await asyncio.sleep(5)
continue
# Consume events from client iterator (handles reconnects internally)
async for evt in self.comfy_client.ws_connect(self.worker_client_id):
event_type = evt.get("type")
data = (
evt.get("data", {}) if isinstance(evt.get("data"), dict) else {}
)
prompt_id = data.get("prompt_id")
if not prompt_id and "sid" in data:
prompt_id = data["sid"]
# Allow 'executing' events through even if missing prompt_id
if not prompt_id and event_type != "executing":
continue
# Capture our websocket client id from initial status message
if event_type == "status":
sid = data.get("sid")
if sid:
self.websocket_sid = sid
logger.debug(
f"ℹ️\u2009 Nilor-Nodes (worker_consumer): Captured websocket SID: {sid}"
)
# Use the first progress event as a signal that the job is running.
if (
event_type in ["progress", "progress_state"]
and prompt_id in self.prompt_id_to_content_id_map
and prompt_id not in self.sent_running_status_prompts
):
content_id = self.prompt_id_to_content_id_map[prompt_id]
ctx = self.content_context_by_content_id.get(content_id, {})
policy = ctx.get("status_policy") or {}
running_status = policy.get("running_status", "running")
logger.info(
f"ℹ️\u2009 Nilor-Nodes (worker_consumer): Execution started for prompt_id {prompt_id} (content_id: {content_id}) via '{event_type}' event."
)
# Mark worker busy as soon as execution starts
self.is_busy = True
await self._send_status_update(
content_id,
running_status,
ctx.get("venue"),
ctx.get("canvas"),
ctx.get("scene"),
ctx.get("job_type"),
)
self.sent_running_status_prompts.add(prompt_id)
# Handle execution errors
elif event_type == "execution_error":
logger.error(
f"🛑\u2009 Nilor-Nodes (worker_consumer): Received execution error for prompt_id {prompt_id}: {data}"
)
if prompt_id in self.prompt_id_to_content_id_map:
content_id = self.prompt_id_to_content_id_map.pop(prompt_id)
ctx = self.content_context_by_content_id.get(content_id, {})
policy = ctx.get("status_policy") or {}
fail_status = policy.get("fail_status", "failed")
try:
await self._send_status_update(
content_id,
fail_status,
ctx.get("venue"),
ctx.get("canvas"),
ctx.get("scene"),
ctx.get("job_type"),
)
except Exception:
pass
self.content_context_by_content_id.pop(content_id, None)
effective_prompt_id = prompt_id or self.current_prompt_id
if effective_prompt_id:
self._finalize_prompt(
effective_prompt_id,
reason="execution_error",
)
# Node-level executed event (many per prompt) — ignore for busy/reset
elif event_type == "executed":
logger.debug(
f"ℹ️\u2009 Nilor-Nodes (worker_consumer): Received node executed event for prompt_id {prompt_id}: {data}"
)
# Prompt-level completion signal: executing with node None
elif event_type == "executing":
node_id = data.get("node")
logger.debug(
f"ℹ️\u2009 Nilor-Nodes (worker_consumer): Received 'executing' event. prompt_id={prompt_id}, node_id={node_id}"
)
norm_node_id = self._normalize_node_id(node_id)
if norm_node_id is None:
effective_prompt_id = prompt_id or self.current_prompt_id
if effective_prompt_id:
self._finalize_prompt(
effective_prompt_id,
reason="executing node=None",
)
elif event_type == "execution_success":
effective_prompt_id = prompt_id or self.current_prompt_id
if effective_prompt_id:
self._finalize_prompt(
effective_prompt_id,
reason="execution_success",
)
except Exception as e:
logger.error(
f"🛑\u2009 Nilor-Nodes (worker_consumer): Websocket listener error: {e}",
exc_info=True,
)
await asyncio.sleep(5)
async def consume_loop(self):
"""The main loop to continuously poll for and process messages.
Keeps retrying SQS initialization and polling if the endpoint is down.
"""
# Start the websocket listener in the background immediately
listener_task = asyncio.create_task(self.listen_for_comfy_events())
try:
while True:
# Ensure SQS is initialized; if not, keep attempting to initialize
if self.jobs_queue_url is None or self.status_updates_queue_url is None:
initialized = await self._initialize_sqs()
if not initialized:
logging.warning(
"⚠️\u2009 Nilor-Nodes (worker_consumer): SQS initialization failed. Retrying in 10 seconds..."
)
await asyncio.sleep(10)
continue
logger.info(
f"ℹ️\u2009 Nilor-Nodes (worker_consumer): Starting worker consumer. Polling queue: {self.jobs_queue_url}"
)
# Capacity gate: avoid pulling a new job while local ComfyUI is busy
if self.is_busy:
logger.debug(
"ℹ️\u2009 Nilor-Nodes (worker_consumer): Skipping poll; worker is busy executing a job."
)
await asyncio.sleep(5)
continue
# Run memory hygiene between jobs (no-op if disabled/unsupported)
try:
now = time.monotonic()
min_interval = max(0, int(self.cfg.hygiene.idle_poll_seconds))
if now - self._last_hygiene_check_ts >= min_interval:
await self._run_memory_hygiene(debounce_seconds=0)
self._last_hygiene_check_ts = now
except Exception:
pass
logger.debug(
"ℹ️\u2009 Nilor-Nodes (worker_consumer): Polling for messages..."
)
try:
async with self.session.create_client(
"sqs",
region_name=self.cfg.worker.aws_region,
endpoint_url=self.cfg.worker.sqs_endpoint_url,
aws_access_key_id=self.cfg.worker.aws_access_key_id,
aws_secret_access_key=self.cfg.worker.aws_secret_access_key,
) as client:
response = await client.receive_message(
QueueUrl=self.jobs_queue_url,
MaxNumberOfMessages=self.cfg.worker.max_messages,
WaitTimeSeconds=self.cfg.worker.poll_wait_s,
)
messages = response.get("Messages", [])
if not messages:
logger.debug(
"ℹ️\u2009 Nilor-Nodes (worker_consumer): No messages received."
)
continue
for message in messages:
try:
await self.process_message(message)
# On successful processing, delete the message
async with self.session.create_client(
"sqs",
region_name=self.cfg.worker.aws_region,
endpoint_url=self.cfg.worker.sqs_endpoint_url,
aws_access_key_id=self.cfg.worker.aws_access_key_id,
aws_secret_access_key=self.cfg.worker.aws_secret_access_key,
) as client:
await client.delete_message(
QueueUrl=self.jobs_queue_url,
ReceiptHandle=message["ReceiptHandle"],
)
logger.debug(
f"ℹ️\u2009 Nilor-Nodes (worker_consumer): Deleted message {message['MessageId']} from queue."
)
except json.JSONDecodeError:
# This is a poison pill message, log it but don't retry.
# It will be moved to the DLQ after enough failed receives.
logger.error(
f"🛑\u2009 Nilor-Nodes (worker_consumer): Message {message['MessageId']} is a poison pill (JSON decode failed) and will be ignored."
)
except Exception as e:
logger.error(
f"🛑\u2009 Nilor-Nodes (worker_consumer): Processing failed for message {message['MessageId']}: {e}. It will be returned to the queue for retry."
)
except ClientError as e:
# Some SQS providers/endpoints may sporadically return 503 for ReceiveMessage when the queue is idle
error_code = None
try:
error_code = e.response.get("Error", {}).get("Code")
except Exception:
pass
operation_name = getattr(e, "operation_name", "")
if operation_name == "ReceiveMessage" and str(error_code) in (
"503",
"ServiceUnavailable",
):
logger.debug(
"ℹ️\u2009 Nilor-Nodes (worker_consumer): Queue is empty or endpoint timed out (ReceiveMessage 503). Polling again shortly..."
)
# await asyncio.sleep(2)
continue
# Unhandled ClientError; fall back to generic handling
logger.error(
f"🛑\u2009 Nilor-Nodes (worker_consumer): SQS client error during ReceiveMessage: {e}"
)
await asyncio.sleep(10)
except EndpointConnectionError as e:
# Lost connection to SQS; reset and re-initialize on next loop
logger.warning(
f"⚠️\u2009 Nilor-Nodes (worker_consumer): Lost connection to SQS at {self.cfg.worker.sqs_endpoint_url}: {e}. Will retry initialization in 10 seconds."
)
self.jobs_queue_url = None
self.status_updates_queue_url = None
await asyncio.sleep(10)
except Exception as e:
logger.error(
f"🛑\u2009 Nilor-Nodes (worker_consumer): An error occurred in the consume loop: {e}"
)
await asyncio.sleep(10) # Wait before retrying
finally:
listener_task.cancel()
await asyncio.gather(listener_task, return_exceptions=True)
# Close shared HTTP session if created
if self.http_session is not None:
try:
await self.http_session.close()
except Exception:
pass
logger.info(
"⚠️\u2009 Nilor-Nodes (worker_consumer): Websocket listener stopped."
)
async def process_message(self, message):
"""Processes a single SQS message."""
logger.info(
f"ℹ️\u2009 Nilor-Nodes (worker_consumer): Processing message: {message['MessageId']}"
)
try:
body = json.loads(message["Body"])
# SQS messages are often double-encoded, with the actual payload inside a 'Message' key.
if "Message" in body:
job_payload = json.loads(body["Message"])
else:
job_payload = body
content_id = job_payload.get("content_id")
# Prefer execution_spec for other engines; ComfyUI strictly requires 'prompt'
if "execution_spec" in job_payload and "prompt" not in job_payload:
logger.warning(
f"⚠️\u2009 Nilor-Nodes (worker_consumer): Received 'execution_spec' without 'prompt'. ComfyUI path requires 'prompt'; skipping message {message['MessageId']}."
)
return
if "execution_spec" in job_payload and "prompt" in job_payload:
logger.debug(
"ℹ️\u2009 Nilor-Nodes (worker_consumer): 'execution_spec' present alongside 'prompt'; ignoring 'execution_spec' for ComfyUI."
)
# Validate that the payload has the required keys before submitting.
if not content_id or "prompt" not in job_payload:
logger.error(
f"🛑\u2009 Nilor-Nodes (worker_consumer): Invalid message format: missing 'content_id' or 'prompt'. Payload: {job_payload}"
)
return
# Submit to ComfyUI
try:
await self._submit_job_to_comfyui(content_id, job_payload)
except JobSubmissionError as e:
logger.error(
f"🛑\u2009 Nilor-Nodes (worker_consumer): Submission failed for content_id {content_id}: {e}. Message will be retried/DLQ'd."
)
await self._emit_failed_status_for_submission_error(
content_id=content_id,
job_payload=job_payload,
error_message=str(e),
)
# Re-raise so consume_loop does not delete the message.
raise
# Cache context for subsequent status updates
try:
self.content_context_by_content_id[content_id] = {
"venue": job_payload.get("venue"),
"canvas": job_payload.get("canvas"),
"scene": job_payload.get("scene"),
"job_type": job_payload.get("job_type"),
"status_policy": job_payload.get("status_policy") or {},
}
except Exception:
self.content_context_by_content_id[content_id] = {}
except Exception as e:
logger.error(
f"🛑\u2009 Nilor-Nodes (worker_consumer): An unexpected error occurred while processing message: {e}. It will be retried."
)
# Re-raise to prevent deletion from queue if we want SQS to handle retry
raise
async def _emit_failed_status_for_submission_error(
self, content_id, job_payload, error_message: str
):
"""Best-effort failed status emission for submit-time errors."""
policy = job_payload.get("status_policy") or {}
fail_status = policy.get("fail_status", "failed")
await self._send_status_update(
content_id,
fail_status,
job_payload.get("venue"),
job_payload.get("canvas"),
job_payload.get("scene"),
job_payload.get("job_type"),
)
logger.info(
"ℹ️\u2009 Nilor-Nodes (worker_consumer): Emitted failed status '%s' for content_id %s after submit error: %s",
fail_status,
content_id,
error_message,
)
async def _submit_job_to_comfyui(self, content_id, workflow_data):
"""Submits a single job to the ComfyUI API."""
try:
# Attach/override websocket client_id so server targets events to this worker
payload = (
dict(workflow_data)
if isinstance(workflow_data, dict)
else workflow_data
)
if isinstance(payload, dict):
# Normalize OS-sensitive path formatting inside the ComfyUI prompt graph.
# This lets us accept workflows authored on a different OS (e.g. Windows
# backslashes) and run them on the current worker OS.
enabled = self.cfg.worker.workflow_os_normalization_enabled
if enabled and "prompt" in payload:
try:
normalized_prompt, rewritten = (
normalize_comfyui_prompt_for_current_os(payload["prompt"])
)
if rewritten:
payload["prompt"] = normalized_prompt
logger.debug(
"ℹ️\u2009 Nilor-Nodes (worker_consumer): Normalized %s path-like values in ComfyUI prompt for os=%s.",
rewritten,
os.name,
)
except Exception as e:
# Don't fail the job if normalization fails; submit as-is.
logger.warning(
"⚠️\u2009 Nilor-Nodes (worker_consumer): Workflow normalization failed; submitting original prompt. Error: %s",
e,
)
# Force top-level client_id to this worker's stable ID
payload["client_id"] = self.worker_client_id
# Ensure extra_data exists and force its client_id too
extra = payload.get("extra_data") or {}
if isinstance(extra, dict):
extra["client_id"] = self.worker_client_id
payload["extra_data"] = extra
if self.comfy_client is None:
# Construct client using shared session when available; fallback to creating a temp one
self.comfy_client = ComfyUILocalClient(
base_url=self.cfg.comfy.api_url,
ws_url=self.cfg.comfy.ws_url,
session=self.http_session,
logger=logger,
timeout=float(self.cfg.comfy.timeout_s),
)
prompt_id = await self.comfy_client.submit_prompt(payload)
logger.debug(
f"✅ Nilor-Nodes (worker_consumer): Successfully submitted job to ComfyUI. Prompt ID: {prompt_id}"
)
self.prompt_id_to_content_id_map[prompt_id] = content_id
# Mark worker busy after successful submission to avoid over-queuing on this machine
self.is_busy = True
self.current_prompt_id = prompt_id
# No need to delete here, the consume_loop handles message deletion
except ComfyUIClientError as e:
logger.error(
f"🛑\u2009 Nilor-Nodes (worker_consumer): Failed to submit job to ComfyUI: {e}. Message will be retried."
)
raise JobSubmissionError(str(e)) from e
except (json.JSONDecodeError, KeyError) as e:
logger.error(
f"🛑\u2009 Nilor-Nodes (worker_consumer): Failed to parse ComfyUI response: {e}. Message will be retried."
)
raise JobSubmissionError(f"Malformed ComfyUI response: {e}") from e
except Exception as e:
logger.error(
f"🛑\u2009 Nilor-Nodes (worker_consumer): An unexpected error occurred while submitting job to ComfyUI: {e}",
exc_info=True,
)
raise JobSubmissionError(str(e)) from e
async def _send_status_update(
self, content_id, status, venue=None, canvas=None, scene=None, job_type=None
):
try:
body = {"content_id": content_id, "status": status}
if venue is not None:
body["venue"] = venue
if canvas is not None:
body["canvas"] = canvas
if scene is not None:
body["scene"] = scene
if job_type is not None:
body["job_type"] = job_type
message_body = json.dumps(body)
async with self.session.create_client(
"sqs",
region_name=self.cfg.worker.aws_region,
endpoint_url=self.cfg.worker.sqs_endpoint_url,
aws_access_key_id=self.cfg.worker.aws_access_key_id,
aws_secret_access_key=self.cfg.worker.aws_secret_access_key,
) as client:
await client.send_message(
QueueUrl=self.status_updates_queue_url, MessageBody=message_body
)
logger.info(
f"✅ Nilor-Nodes (worker_consumer): Sent status update for content {content_id}: {status}"
)
except Exception as e:
logger.error(
f"🛑\u2009 Nilor-Nodes (worker_consumer): Failed to send status update for content {content_id}: {e}",
exc_info=True,
)
def _finalize_prompt(self, prompt_id, reason: str | None = None):
# No-op if we're not currently busy; avoids redundant work on duplicate signals
if not self.is_busy:
return
try:
if reason:
logger.info(
f"✅ Nilor-Nodes (worker_consumer): Finalizing prompt via '{reason}'. Using prompt_id={prompt_id}"
)
content_id = self.prompt_id_to_content_id_map.pop(prompt_id, None)
if content_id is not None:
self.content_context_by_content_id.pop(content_id, None)
self.sent_running_status_prompts.discard(prompt_id)
finally:
# Only clear busy/state if this finalize corresponds to the current in-flight prompt
if self.current_prompt_id == prompt_id:
self.current_prompt_id = None
self.is_busy = False
# Debounced hygiene after job completion
try:
asyncio.create_task(self._run_memory_hygiene(debounce_seconds=1))
except Exception:
pass
@staticmethod
def _normalize_node_id(node_id):
if node_id is None:
return None
if isinstance(node_id, str) and node_id.strip() in ("", "None"):
return None
return node_id
async def _run_memory_hygiene(self, debounce_seconds: int = 0) -> None:
"""Run memory hygiene with optional debounce, guarded by busy state.
Delegates to the shared hygiene component and ensures we do not
pull new work while remediation is running.
"""
await _run_hygiene_guarded(self, debounce_seconds)
async def consume_jobs():
"""Entry point function to be called in a background thread."""
# Create shared HTTP session once and reuse throughout lifecycle
consumer = WorkerConsumer(cfg=_CFG)
# Initialize shared HTTP session at startup
consumer.http_session = aiohttp.ClientSession()
# Construct ComfyUI client using shared session (WS is always client-driven)
consumer.comfy_client = ComfyUILocalClient(
base_url=_CFG.comfy.api_url,
ws_url=_CFG.comfy.ws_url,
session=consumer.http_session,
logger=logger,
timeout=float(_CFG.comfy.timeout_s),
)
# Initialize Memory Hygiene with the constructed client and loaded config
try:
consumer.hygiene = MemoryHygiene(
client=consumer.comfy_client,
cfg=_CFG.hygiene,
logger=logger,
)
except Exception:
consumer.hygiene = None
# Emit concise startup configuration summary (no secrets)
try:
logger.info(
(
"ℹ️\u2009 Nilor-Nodes: startup config — Comfy API=%s, WS=%s, timeout_s=%s, "
"SQS endpoint=%s, jobs_queue=%s, status_queue=%s"
),
_CFG.comfy.api_url,
_CFG.comfy.ws_url,
str(_CFG.comfy.timeout_s),
_CFG.worker.sqs_endpoint_url,
_CFG.worker.jobs_queue,
_CFG.worker.status_queue,
)
except Exception:
pass
# Emit concise hygiene summary (effective values)
try:
h = _CFG.hygiene
logger.info(
(
"ℹ️\u2009 Nilor-Nodes: startup hygiene — enabled=%s, idle_poll_s=%s, "
"vram_used_pct_max=%s, ram_used_pct_max=%s, vram_min_free_mb=%s, ram_min_free_mb=%s, "
"policy=%s, max_retries=%s, cooldown_s=%s, sleep_between_s=%s, max_cycle_s=%s"
),
str(h.enabled),
str(h.idle_poll_seconds),
str(h.vram_usage_pct_max),
str(h.ram_usage_pct_max),
str(h.vram_min_free_mb),
str(h.ram_min_free_mb),
h.action_policy,
str(h.max_retries),
str(h.cooldown_seconds),
str(h.sleep_between_attempts_seconds),
str(h.max_cycle_duration_seconds),
)
except Exception:
pass
await consumer.consume_loop()
async def _sleep_seconds(seconds: int) -> None:
try:
await asyncio.sleep(max(0, int(seconds)))
except Exception:
pass
async def _maybe_bool(value) -> bool:
try:
return bool(value)
except Exception:
return False
async def _run_hygiene_guarded(
self_ref: "WorkerConsumer", debounce_seconds: int
) -> None:
# No-op if not available
if not getattr(self_ref, "hygiene", None):
return
# Optional debounce
if debounce_seconds > 0:
await _sleep_seconds(debounce_seconds)
# Set maintenance busy gate
if self_ref.is_busy:
return
self_ref.is_busy = True
try:
await self_ref.hygiene.check_and_remediate()
except Exception:
pass
finally:
self_ref.is_busy = False