feat(J): WebSocket live-events + replace polling + fix ffmpeg turntable timeout
- fix(render): ffmpeg overlay=0:0 -> overlay=0:0:shortest=1 to prevent hang on finite PNG sequences - feat(ws): add core/websocket.py ConnectionManager + Redis Pub/Sub subscriber loop - feat(ws): add /api/ws WebSocket endpoint with JWT query-param auth in main.py - feat(ws): emit render_complete/failed + cad_processing_complete events from step_tasks.py - feat(ws): emit order_status_change events from orders router - feat(ws): add beat_tasks.py broadcast_queue_status task (every 10s via Redis __broadcast__) - feat(frontend): add useWebSocket hook with auto-reconnect (exponential backoff, 25s ping) - feat(frontend): add WebSocketContext + WebSocketProvider wrapping App - refactor(frontend): remove polling from WorkerActivity (was 5s/3s) + OrderDetail (was 5s) - refactor(frontend): reduce polling in Layout (8s->60s) + NotificationCenter (15s->60s) - docs: add ffmpeg shortest=1 + WebSocket JWT auth learnings to LEARNINGS.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
"""Celery Beat periodic tasks."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(name="app.tasks.beat_tasks.broadcast_queue_status", queue="step_processing")
|
||||
def broadcast_queue_status() -> None:
|
||||
"""Broadcast current queue depths to all WebSocket clients every 10s.
|
||||
|
||||
Publishes to the Redis '__broadcast__' channel which the WebSocket
|
||||
subscriber in the FastAPI process forwards to all connected clients.
|
||||
"""
|
||||
try:
|
||||
import redis as sync_redis
|
||||
from app.config import settings
|
||||
|
||||
r = sync_redis.from_url(settings.redis_url, decode_responses=True)
|
||||
depths = {
|
||||
"step_processing": r.llen("step_processing"),
|
||||
"thumbnail_rendering": r.llen("thumbnail_rendering"),
|
||||
}
|
||||
event = {"type": "queue_update", "depths": depths}
|
||||
r.publish("__broadcast__", json.dumps(event))
|
||||
r.close()
|
||||
logger.debug("Broadcast queue_update: %s", depths)
|
||||
except Exception as exc:
|
||||
logger.warning("broadcast_queue_status failed: %s", exc)
|
||||
@@ -1,4 +1,5 @@
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
from app.config import settings
|
||||
|
||||
celery_app = Celery(
|
||||
@@ -8,6 +9,7 @@ celery_app = Celery(
|
||||
include=[
|
||||
"app.tasks.step_tasks",
|
||||
"app.tasks.ai_tasks",
|
||||
"app.tasks.beat_tasks",
|
||||
"app.domains.rendering.tasks",
|
||||
"app.domains.products.tasks",
|
||||
"app.domains.imports.tasks",
|
||||
@@ -23,7 +25,13 @@ celery_app.conf.update(
|
||||
task_routes={
|
||||
"app.tasks.step_tasks.*": {"queue": "step_processing"},
|
||||
"app.tasks.ai_tasks.*": {"queue": "ai_validation"},
|
||||
"app.tasks.beat_tasks.*": {"queue": "step_processing"},
|
||||
"app.domains.rendering.tasks.*": {"queue": "thumbnail_rendering"},
|
||||
},
|
||||
beat_schedule={},
|
||||
beat_schedule={
|
||||
"broadcast-queue-status-every-10s": {
|
||||
"task": "app.tasks.beat_tasks.broadcast_queue_status",
|
||||
"schedule": 10.0, # every 10 seconds
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -171,6 +171,28 @@ def render_step_thumbnail(self, cad_file_id: str):
|
||||
f"Auto material population failed for cad_file {cad_file_id} (non-fatal)"
|
||||
)
|
||||
|
||||
# Broadcast WebSocket event for live UI updates
|
||||
try:
|
||||
from sqlalchemy import create_engine, select as sql_select2
|
||||
from sqlalchemy.orm import Session as _Session
|
||||
from app.config import settings as _cfg
|
||||
from app.models.cad_file import CadFile as _CadFile
|
||||
_sync_url = _cfg.database_url.replace("+asyncpg", "")
|
||||
_eng = create_engine(_sync_url)
|
||||
with _Session(_eng) as _s:
|
||||
_cad = _s.get(_CadFile, cad_file_id)
|
||||
_tid = str(_cad.tenant_id) if _cad and _cad.tenant_id else None
|
||||
_eng.dispose()
|
||||
if _tid:
|
||||
from app.core.websocket import publish_event_sync
|
||||
publish_event_sync(_tid, {
|
||||
"type": "cad_processing_complete",
|
||||
"cad_file_id": cad_file_id,
|
||||
"status": "completed",
|
||||
})
|
||||
except Exception:
|
||||
logger.debug("WebSocket publish for CAD complete skipped (non-fatal)")
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="app.tasks.step_tasks.generate_stl_cache", queue="thumbnail_rendering")
|
||||
def generate_stl_cache(self, cad_file_id: str, quality: str):
|
||||
@@ -559,6 +581,22 @@ def render_order_line_task(self, order_line_id: str):
|
||||
else:
|
||||
emit(order_line_id, f"Render failed after {elapsed:.1f}s", "error")
|
||||
|
||||
# Broadcast WebSocket event for live UI updates
|
||||
try:
|
||||
from app.core.websocket import publish_event_sync
|
||||
_tenant_id = str(line.product.cad_file.tenant_id) if (
|
||||
line.product and line.product.cad_file and line.product.cad_file.tenant_id
|
||||
) else None
|
||||
if _tenant_id:
|
||||
publish_event_sync(_tenant_id, {
|
||||
"type": "render_complete" if success else "render_failed",
|
||||
"order_line_id": order_line_id,
|
||||
"order_id": str(line.order_id),
|
||||
"status": new_status,
|
||||
})
|
||||
except Exception:
|
||||
logger.debug("WebSocket publish skipped (non-fatal)")
|
||||
|
||||
# Notify order creator about render result
|
||||
try:
|
||||
from app.models.order import Order as OrderModel
|
||||
|
||||
Reference in New Issue
Block a user