Files
nilor-corp-nilor-nodes/worker_consumer.py
T
Sebastian Monroy 4a56d4dff5 feat(nilor-nodes): integrate typed config loader into worker_consumer
- load Config once with env>JSON5 precedence and pass cfg into WorkerConsumer
- replace direct os.getenv reads with cfg.comfy and cfg.worker values
- compute Comfy HTTP/WS endpoints from cfg and reuse single aiohttp session
- keep behavior identical; no new features introduced
2025-10-16 17:36:42 +01:00

585 lines
28 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 aiohttp
import socket
import urllib.parse
import websockets
from aiobotocore.session import get_session
from dotenv import load_dotenv
from botocore.exceptions import EndpointConnectionError, ClientError
from .logger import logger
from .config.config import load_nilor_nodes_config, NilorNodesConfig
# --- Load Environment Variables ---
# Load from the .env file in the same directory
current_dir = os.path.dirname(os.path.abspath(__file__))
dotenv_path = os.path.join(current_dir, ".env")
if os.path.exists(dotenv_path):
load_dotenv(dotenv_path=dotenv_path)
logger.info(
f"✅\u2009 Nilor-Nodes: Loaded environment variables from {dotenv_path}"
)
else:
logger.info(
"⚠️\u2009 Nilor-Nodes: No .env file found, relying on shell environment variables."
)
# --- Configuration ---
# Centralized loader provides precedence env > JSON5 and validation
_CFG: NilorNodesConfig = load_nilor_nodes_config()
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.is_busy = False
self.websocket_sid = None
# Stable client_id for routing events to this worker
self.cfg = cfg
self.worker_client_id = cfg.worker.worker_client_id
# Computed endpoints
self.comfy_api_url = cfg.comfy.api_url.rstrip("/") + "/prompt"
self.comfy_ws_url = cfg.comfy.ws_url.rstrip("/") + "/ws"
self.current_prompt_id = None
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:
ws_url = f"{self.comfy_ws_url}?clientId={urllib.parse.quote(self.worker_client_id)}"
# Allow large preview frames from ComfyUI without dropping the connection (1009: message too big)
async with websockets.connect(
ws_url,
max_size=None, # disable message size limit
read_limit=64 * 1024 * 1024, # buffer up to 64 MiB per frame
max_queue=4, # small queue to avoid memory spikes
ping_interval=20,
ping_timeout=20,
) as websocket:
logger.debug(
f"ℹ️\u2009 Nilor-Nodes (worker_consumer): Connected to ComfyUI websocket at {ws_url}"
)
while True:
message = await websocket.recv()
if isinstance(message, str):
try:
event = json.loads(message)
event_type = event.get("type")
data = event.get("data", {})
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 json.JSONDecodeError:
logger.debug(
"⚠️\u2009 Nilor-Nodes (worker_consumer): Received non-JSON text message from websocket, ignoring."
)
else:
logger.debug(
"⚠️\u2009 Nilor-Nodes (worker_consumer): Received binary message from websocket, ignoring."
)
except (
websockets.exceptions.ConnectionClosedError,
ConnectionRefusedError,
) as e:
logger.warning(
f"🛑\u2009 Nilor-Nodes (worker_consumer): ComfyUI websocket connection failed: {e}. Retrying in 5 seconds..."
)
await asyncio.sleep(5)
except Exception as e:
logger.error(
f"🛑\u2009 Nilor-Nodes (worker_consumer): An unexpected error occurred in the websocket listener: {e}",
exc_info=True,
)
await asyncio.sleep(10)
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
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
await self._submit_job_to_comfyui(content_id, job_payload)
# 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 _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):
# 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.http_session is None:
# Lazily create if not created by the loop yet
self.http_session = aiohttp.ClientSession()
async with self.http_session.post(
self.comfy_api_url, json=payload, timeout=self.cfg.comfy.timeout_s
) as response:
response.raise_for_status()
response_json = await response.json()
prompt_id = response_json.get("prompt_id")
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 aiohttp.ClientError as e:
logger.error(
f"🛑\u2009 Nilor-Nodes (worker_consumer): Failed to submit job to ComfyUI: {e}. Message will be retried."
)
except (json.JSONDecodeError, KeyError) as e:
logger.error(
f"🛑\u2009 Nilor-Nodes (worker_consumer): Failed to parse ComfyUI response: {e}. Discarding malformed response."
)
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,
)
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
@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 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()
await consumer.consume_loop()