diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 07597c2..38d7fcf 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -147,7 +147,7 @@ jobs: sed -i "s|airshelf.airlabs.art|${{ env.DOMAIN_WEB }}|g" k8s/ingress.yaml # ===== Core (real app) image + domain substitution ===== - sed -i "s|\${CI_REGISTRY_IMAGE}/airshelf-core-api:latest|${CR_IMAGE}/airshelf-core-api:${{ env.IMAGE_TAG }}|g" k8s/core/api-deployment.yaml k8s/core/worker-deployment.yaml + sed -i "s|\${CI_REGISTRY_IMAGE}/airshelf-core-api:latest|${CR_IMAGE}/airshelf-core-api:${{ env.IMAGE_TAG }}|g" k8s/core/api-deployment.yaml k8s/core/worker-deployment.yaml k8s/core/worker-quick-deployment.yaml sed -i "s|\${CI_REGISTRY_IMAGE}/airshelf-core-web:latest|${CR_IMAGE}/airshelf-core-web:${{ env.IMAGE_TAG }}|g" k8s/core/web-deployment.yaml sed -i "s|airshelf-web.airlabs.art|${{ env.DOMAIN_CORE }}|g" k8s/core/ingress.yaml @@ -196,6 +196,7 @@ jobs: # Core real app (api + celery worker + web + ingress) kubectl $KUBECTL_TIMEOUT apply -f k8s/core/api-deployment.yaml kubectl $KUBECTL_TIMEOUT apply -f k8s/core/worker-deployment.yaml + kubectl $KUBECTL_TIMEOUT apply -f k8s/core/worker-quick-deployment.yaml kubectl $KUBECTL_TIMEOUT apply -f k8s/core/web-deployment.yaml kubectl $KUBECTL_TIMEOUT apply -f k8s/core/ingress.yaml @@ -205,6 +206,7 @@ jobs: kubectl $KUBECTL_TIMEOUT rollout restart deployment/airshelf-web kubectl $KUBECTL_TIMEOUT rollout restart deployment/airshelf-core-api kubectl $KUBECTL_TIMEOUT rollout restart deployment/airshelf-core-worker + kubectl $KUBECTL_TIMEOUT rollout restart deployment/airshelf-core-worker-quick kubectl $KUBECTL_TIMEOUT rollout restart deployment/airshelf-core-web } 2>&1 | tee /tmp/deploy.log && { ok=1; break; } echo "Attempt $attempt failed, retrying in 30s..." diff --git a/core/backend/airshelf/settings/base.py b/core/backend/airshelf/settings/base.py index 7f2169a..6f6b770 100644 --- a/core/backend/airshelf/settings/base.py +++ b/core/backend/airshelf/settings/base.py @@ -204,6 +204,14 @@ CELERY_TASK_ACKS_LATE = True CELERY_TASK_REJECT_ON_WORKER_LOST = True CELERY_WORKER_PREFETCH_MULTIPLIER = 1 CELERY_TIMEZONE = TIME_ZONE +# 快活走 airshelf.quick(独立 worker-quick Pod);出图/出片仍默认 celery 队列。 +CELERY_TASK_ROUTES = { + "apps.ai.tasks.extract_entities_task": {"queue": "airshelf.quick"}, + "apps.ai.tasks.poll_free_video_task": {"queue": "airshelf.quick"}, + "apps.ai.tasks.drain_asset_reviews_task": {"queue": "airshelf.quick"}, + "apps.ops.tasks.ensure_team_notifications_task": {"queue": "airshelf.quick"}, + "apps.projects.tasks.run_quick_script_task": {"queue": "airshelf.quick"}, +} REDIS_LOCK_URL = env("REDIS_LOCK_URL", "redis://127.0.0.1:6379/3") diff --git a/core/backend/apps/ai/tasks.py b/core/backend/apps/ai/tasks.py index c65534d..cf7f3ef 100644 --- a/core/backend/apps/ai/tasks.py +++ b/core/backend/apps/ai/tasks.py @@ -1,5 +1,7 @@ from airshelf.celery import app +QUICK_QUEUE = "airshelf.quick" + @app.task(bind=True, max_retries=3) def submit_ai_task(self, task_id: str) -> str: @@ -21,7 +23,7 @@ def generate_standalone_image_task(self, task_id: str) -> str: return task_id -@app.task(bind=True, max_retries=0) +@app.task(bind=True, max_retries=0, queue=QUICK_QUEUE) def extract_entities_task(self, task_id: str) -> str: """实体提取的慢活(豆包思考模型流式,可达数十秒)在 worker 内跑,Web 层秒回不被占住、不再 502。 幂等且失败自退费(见 run_extract_entities_task),故 max_retries=0,不向上抛重试。""" @@ -83,7 +85,7 @@ def generate_model_triview_task(self, task_id: str) -> str: return task_id -@app.task(bind=True, max_retries=0) +@app.task(bind=True, max_retries=0, queue=QUICK_QUEUE) def poll_free_video_task(self, task_id: str, attempt: int = 0) -> str: """自由创作视频·worker 兜底轮询:每 30s 一次自重排(不依赖 celery beat), 最多 60 次(约 30 分钟);用尽后只停止 Worker 兜底,不把业务任务判失败。 @@ -119,7 +121,7 @@ def poll_free_video_task(self, task_id: str, attempt: int = 0) -> str: return task_id -@app.task(bind=True, max_retries=0) +@app.task(bind=True, max_retries=0, queue=QUICK_QUEUE) def drain_asset_reviews_task(self) -> str: """全平台人脸审核兜底:未送审自动送火山、审核中自动拉绿/红盾。 自重排(无 celery beat);eager 下不自转,避免单测死循环。""" diff --git a/core/backend/apps/ops/tasks.py b/core/backend/apps/ops/tasks.py index c88e5a7..5ff7195 100644 --- a/core/backend/apps/ops/tasks.py +++ b/core/backend/apps/ops/tasks.py @@ -4,7 +4,7 @@ from airshelf.celery import app from apps.accounts.models import Team -@app.task +@app.task(queue="airshelf.quick") def ensure_team_notifications_task(team_id: str, user_id: str | None = None) -> None: """后台生成/补齐团队站内通知。 diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 2b9a26d..ec9ad71 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -453,7 +453,13 @@ export function App() { } const active = detail.video_segments.filter((segment) => ["running", "queued"].includes(segment.status)); if (active.length === 0) return; - await Promise.all(active.map((segment) => api.pollVideo(activeProjectId, segment.id).catch(() => undefined))); + const polls = await Promise.all(active.map((segment) => api.pollVideo(activeProjectId, segment.id).catch(() => undefined))); + // 仍在 queued/running 就别拉整棵项目树(26KB);只有某段终态才回读详情。 + const settled = polls.some((row) => { + const status = row && typeof row === "object" ? (row as { status?: string }).status : ""; + return Boolean(status) && status !== "running" && status !== "queued"; + }) || polls.some((row) => row && typeof row === "object" && "id" in (row as object)); + if (!settled) return; const next = await api.project(activeProjectId).catch(() => null); // 晚到的旧项目轮询结果不要冲掉已切换的当前项目详情 if (next && next.id === activeProjectIdRef.current) { diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index 2905cf1..d906598 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -2668,8 +2668,17 @@ export function PipelinePage(props: { const activeVideoCount = segments.filter((s) => ["running", "queued"].includes(s.status)).length; useEffect(() => { if (activeVideoCount === 0) return; - const timer = window.setInterval(() => { void onPollVideosQuiet(); }, 5000); - return () => window.clearInterval(timer); + const tick = () => { + if (document.hidden) return; + void onPollVideosQuiet(); + }; + const timer = window.setInterval(tick, 5000); + const onVis = () => { if (!document.hidden) void onPollVideosQuiet(); }; + document.addEventListener("visibilitychange", onVis); + return () => { + window.clearInterval(timer); + document.removeEventListener("visibilitychange", onVis); + }; }, [activeVideoCount, onPollVideosQuiet]); // 进入视频 / 拼接阶段时回填已有成片(此前合成过就直接给出播放/下载入口) diff --git a/k8s/core/api-deployment.yaml b/k8s/core/api-deployment.yaml index 12286f5..6bfc859 100644 --- a/k8s/core/api-deployment.yaml +++ b/k8s/core/api-deployment.yaml @@ -5,7 +5,7 @@ metadata: labels: app: airshelf-core-api spec: - replicas: 1 + replicas: 2 selector: matchLabels: app: airshelf-core-api diff --git a/k8s/core/worker-deployment.yaml b/k8s/core/worker-deployment.yaml index 4151abe..8ead918 100644 --- a/k8s/core/worker-deployment.yaml +++ b/k8s/core/worker-deployment.yaml @@ -23,7 +23,8 @@ spec: # Celery worker connects to the external (Volcano managed) Redis broker # configured via the airshelf-core-env secret. Uses `args` (not `command`) # so the image ENTRYPOINT still runs but skips migrate/collectstatic ($1=celery). - args: ["celery", "-A", "airshelf.celery:app", "worker", "-l", "info", "-Q", "celery,airshelf.quick", "--concurrency", "4"] + # 本 Pod 只吃默认 celery 队列(出图/出片等慢活)。提取/通知/脚本思考在 worker-quick。 + args: ["celery", "-A", "airshelf.celery:app", "worker", "-l", "info", "-Q", "celery", "--concurrency", "4"] envFrom: - secretRef: name: airshelf-core-env diff --git a/k8s/core/worker-quick-deployment.yaml b/k8s/core/worker-quick-deployment.yaml new file mode 100644 index 0000000..26c636f --- /dev/null +++ b/k8s/core/worker-quick-deployment.yaml @@ -0,0 +1,42 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: airshelf-core-worker-quick + labels: + app: airshelf-core-worker-quick +spec: + replicas: 1 + selector: + matchLabels: + app: airshelf-core-worker-quick + template: + metadata: + labels: + app: airshelf-core-worker-quick + spec: + imagePullSecrets: + - name: cr-pull-secret + containers: + - name: airshelf-core-worker-quick + image: ${CI_REGISTRY_IMAGE}/airshelf-core-api:latest + imagePullPolicy: Always + # 只消费 airshelf.quick:提取角色、通知补齐、脚本思考、视频状态轻轮询。 + # 与出片 worker 隔离,Seedance 占满时提取仍能跑。 + args: ["celery", "-A", "airshelf.celery:app", "worker", "-l", "info", "-Q", "airshelf.quick", "--concurrency", "4"] + envFrom: + - secretRef: + name: airshelf-core-env + livenessProbe: + exec: + command: ["sh", "-c", "celery -A airshelf.celery:app inspect ping"] + initialDelaySeconds: 40 + periodSeconds: 60 + timeoutSeconds: 15 + failureThreshold: 3 + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "1Gi" + cpu: "500m"