celery 容灾升级

This commit is contained in:
2026-07-22 14:48:29 +08:00
parent 3f1c4063b0
commit 69e7dec807
67 changed files with 6161 additions and 1958 deletions
@@ -6,10 +6,8 @@ from typing import Any, Dict, Iterable, List, Optional, Union
from app.config import settings
from app.services.redis_registry_service import (
close_registry_redis,
datetime_to_epoch,
ensure_aware_utc,
get_registry_redis,
redis_get_due_registry_ids,
redis_get_registry_payloads,
redis_postpone_registry_item,
@@ -72,6 +70,15 @@ async def upsert_download_active(
payload: Dict[str, Any],
check_at: Optional[Union[datetime, int, float]],
) -> None:
existing = await redis_get_registry_payloads(
hash_key=settings.DOWNLOAD_ACTIVE_REDIS_HASH_KEY,
item_ids=[record_id],
log_context="download_active",
)
if record_id in existing:
merged = dict(existing[record_id])
merged.update(payload)
payload = merged
await redis_upsert_registry_item(
hash_key=settings.DOWNLOAD_ACTIVE_REDIS_HASH_KEY,
zset_key=settings.DOWNLOAD_ACTIVE_REDIS_ZSET_KEY,
@@ -0,0 +1,13 @@
from app.services.celery_runtime.runtime_service import (
CeleryRuntimeLease,
RuntimeIdentity,
build_runtime_id,
runtime_lock_exists,
)
__all__ = [
"CeleryRuntimeLease",
"RuntimeIdentity",
"build_runtime_id",
"runtime_lock_exists",
]
@@ -0,0 +1,409 @@
from __future__ import annotations
import time
import json
from datetime import datetime, timezone
from typing import Any
from app.config import settings
from app.enums.celery_runtime import CeleryRuntimeEvent
from app.services.operation_log_service import log_operation_event
from app.services.redis_registry_service import (
RedisExecutionLockUnavailable,
datetime_to_epoch,
get_registry_redis,
)
def _worker_instance_key(worker_instance_id: str) -> str:
return f"{settings.CELERY_RUNTIME_WORKER_INSTANCE_PREFIX}:{worker_instance_id}"
def _worker_name_instances_key(worker_name: str) -> str:
return f"{settings.CELERY_RUNTIME_WORKER_NAME_INSTANCE_ZSET_PREFIX}:{worker_name}"
def _worker_task_set_key(worker_instance_id: str) -> str:
return f"{settings.CELERY_RUNTIME_WORKER_TASK_SET_PREFIX}:{worker_instance_id}"
async def startup_barrier_exists() -> bool:
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable("Redis unavailable while checking startup barrier")
return bool(await redis.exists(settings.CELERY_RUNTIME_STARTUP_BARRIER_KEY))
async def set_startup_barrier() -> None:
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable("Redis unavailable while setting startup barrier")
await redis.set(
settings.CELERY_RUNTIME_STARTUP_BARRIER_KEY,
str(datetime_to_epoch(datetime.now(timezone.utc))),
ex=max(30, int(settings.CELERY_RUNTIME_STARTUP_BARRIER_TTL_SECONDS or 120)),
)
async def clear_startup_barrier() -> None:
redis = await get_registry_redis()
if redis is None:
return
await redis.delete(settings.CELERY_RUNTIME_STARTUP_BARRIER_KEY)
async def guard_periodic_recovery(*, check_global_lock: bool = True) -> dict[str, Any] | None:
if await startup_barrier_exists():
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.RECOVERY_BARRIER_SKIPPED.value,
event_status="skipped",
source="recovery",
detail={"barrier_key": settings.CELERY_RUNTIME_STARTUP_BARRIER_KEY},
)
return {"skipped": "startup_barrier"}
if check_global_lock:
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable("Redis unavailable while checking global recovery lock")
if await redis.exists(settings.CELERY_RUNTIME_GLOBAL_RECOVERY_LOCK_KEY):
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.RECOVERY_BARRIER_SKIPPED.value,
event_status="skipped",
source="recovery",
detail={"global_lock_key": settings.CELERY_RUNTIME_GLOBAL_RECOVERY_LOCK_KEY},
)
return {"skipped": "global_recovery_lock"}
return None
async def garbage_collect_registry_pair(*, hash_key: str, zset_key: str, limit: int = 500) -> dict[str, int]:
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable("Redis unavailable during registry GC")
members = await redis.zrange(zset_key, 0, max(0, int(limit) - 1))
if not members:
return {"checked": 0, "removed": 0}
values = await redis.hmget(hash_key, members)
stale = [str(member) for member, value in zip(members, values) if not value]
if stale:
await redis.zrem(zset_key, *stale)
return {"checked": len(members), "removed": len(stale)}
async def mark_stale_worker_instance_candidates(
*,
worker_name: str,
current_worker_instance_id: str,
supports_targeted_recovery: bool,
emit_duplicate_log: bool = True,
) -> dict[str, Any]:
"""只扫描同一逻辑 Worker 名称下已经失活的旧主实例。
同名存在其他活跃实例时保守跳过,避免滚动发布或错误重名导致互相抢占。
旧实例中的任务只有在业务 String 锁已经不存在时才提前标记为恢复候选;
真正接管仍由各业务恢复服务执行 DB lease/claim/attempt CAS。
"""
if not supports_targeted_recovery:
return {
"skipped": "identity_fallback",
"checked_instances": 0,
"stale_instances": 0,
"marked": 0,
}
normalized_name = str(worker_name or "").strip()
normalized_current = str(current_worker_instance_id or "").strip()
if not normalized_name or not normalized_current:
return {
"skipped": "invalid_identity",
"checked_instances": 0,
"stale_instances": 0,
"marked": 0,
}
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable("Redis unavailable while discovering stale worker instances")
index_key = _worker_name_instances_key(normalized_name)
raw_instances = await redis.zrange(index_key, 0, -1, withscores=True)
instances = [
(str(member), int(float(score)))
for member, score in raw_instances
if str(member) != normalized_current
]
if not instances:
return {
"checked_instances": 0,
"active_instances": 0,
"stale_instances": 0,
"marked": 0,
"live": 0,
"invalid": 0,
}
keys = [_worker_instance_key(instance_id) for instance_id, _ in instances]
values = await redis.mget(keys)
active_instances = [
instance_id
for (instance_id, _), value in zip(instances, values)
if value
]
if active_instances:
if emit_duplicate_log:
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.WORKER_DUPLICATE_NAME.value,
event_status="warning",
source="worker_ready",
detail={
"worker_name": normalized_name,
"current_worker_instance_id": normalized_current,
"other_active_worker_instance_ids": active_instances,
},
)
return {
"skipped": "duplicate_active_worker_name",
"checked_instances": len(instances),
"active_instances": len(active_instances),
"stale_instances": 0,
"marked": 0,
}
# 实例 key 由主进程 heartbeat 独立维护。key 已不存在即可判定旧主实例
# 不再活跃;任务是否可以恢复仍必须继续检查业务 String 锁和 DB fencing。
stale_instances = [
instance_id
for (instance_id, _heartbeat_at), value in zip(instances, values)
if not value
]
totals: dict[str, Any] = {
"checked_instances": len(instances),
"active_instances": 0,
"stale_instances": len(stale_instances),
"checked": 0,
"marked": 0,
"live": 0,
"invalid": 0,
}
for stale_instance_id in stale_instances:
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.WORKER_STALE_INSTANCE_FOUND.value,
event_status="success",
source="worker_ready",
detail={
"worker_name": normalized_name,
"current_worker_instance_id": normalized_current,
"old_worker_instance_id": stale_instance_id,
},
)
result = await mark_worker_instance_runtime_candidates(
old_worker_instance_id=stale_instance_id,
worker_name=normalized_name,
)
for key in ("checked", "marked", "live", "invalid"):
totals[key] += int(result.get(key, 0) or 0)
return totals
async def mark_worker_instance_runtime_candidates(
*,
old_worker_instance_id: str,
worker_name: str | None = None,
) -> dict[str, int]:
"""按旧 Worker 主实例 ID 精准标记失锁任务。"""
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable("Redis unavailable while marking worker recovery candidates")
normalized_instance = str(old_worker_instance_id or "").strip()
if not normalized_instance:
return {"checked": 0, "marked": 0, "live": 0, "invalid": 0}
task_set_key = _worker_task_set_key(normalized_instance)
runtime_ids = [str(value) for value in await redis.smembers(task_set_key)]
if not runtime_ids:
return {"checked": 0, "marked": 0, "live": 0, "invalid": 0}
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.WORKER_INSTANCE_RECOVERY_START.value,
event_status="started",
source="worker_ready",
detail={
"worker_name": worker_name,
"old_worker_instance_id": normalized_instance,
"candidate_count": len(runtime_ids),
},
)
raw_locations = await redis.hmget(settings.CELERY_RUNTIME_LOCATION_HASH_KEY, runtime_ids)
locations: dict[str, dict[str, Any]] = {}
lock_keys: list[str] = []
invalid_ids: list[str] = []
for runtime_id, raw in zip(runtime_ids, raw_locations):
try:
payload = json.loads(raw) if raw else None
except Exception:
payload = None
if (
not isinstance(payload, dict)
or not payload.get("lock_key")
or not payload.get("hash_key")
or not payload.get("zset_key")
or str(payload.get("worker_instance_id") or "") != normalized_instance
):
invalid_ids.append(runtime_id)
continue
locations[runtime_id] = payload
lock_keys.append(str(payload["lock_key"]))
lock_values = await redis.mget(lock_keys) if lock_keys else []
live_by_key = {key: bool(value) for key, value in zip(lock_keys, lock_values)}
now_epoch = datetime_to_epoch(datetime.now(timezone.utc))
marked = 0
live = 0
stale_by_hash: dict[str, list[str]] = {}
for runtime_id, location in locations.items():
lock_key = str(location["lock_key"])
if live_by_key.get(lock_key):
live += 1
continue
stale_by_hash.setdefault(str(location["hash_key"]), []).append(runtime_id)
payload_by_runtime: dict[str, dict[str, Any]] = {}
for hash_key, hash_runtime_ids in stale_by_hash.items():
raw_payloads = await redis.hmget(hash_key, hash_runtime_ids)
for runtime_id, raw_payload in zip(hash_runtime_ids, raw_payloads):
try:
payload = json.loads(raw_payload) if raw_payload else {}
except Exception:
payload = {}
payload_by_runtime[runtime_id] = payload if isinstance(payload, dict) else {}
pipe = redis.pipeline(transaction=False)
for runtime_id, location in locations.items():
lock_key = str(location["lock_key"])
if live_by_key.get(lock_key):
continue
hash_key = str(location["hash_key"])
zset_key = str(location["zset_key"])
payload = payload_by_runtime.get(runtime_id, {})
payload.update(
{
"runtime_state": "recovery_candidate",
"check_at": now_epoch,
"reason": "worker_instance_inactive_lock_missing",
"old_worker_instance_id": normalized_instance,
}
)
pipe.hset(hash_key, runtime_id, json.dumps(payload, ensure_ascii=False, default=str))
pipe.zadd(zset_key, {runtime_id: now_epoch})
marked += 1
if invalid_ids:
pipe.srem(task_set_key, *invalid_ids)
await pipe.execute()
result = {
"checked": len(runtime_ids),
"marked": marked,
"live": live,
"invalid": len(invalid_ids),
}
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.WORKER_INSTANCE_RECOVERY_DONE.value,
event_status="success",
source="worker_ready",
detail={
"worker_name": worker_name,
"old_worker_instance_id": normalized_instance,
**result,
},
)
return result
async def garbage_collect_worker_registry(*, limit: int = 500) -> dict[str, int]:
"""清理 V2 Worker 名称索引和旧实例 Task Set 中的失配成员。"""
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable("Redis unavailable during worker registry GC")
checked_names = 0
checked_instances = 0
removed_instances = 0
removed_task_members = 0
now = int(time.time())
grace = max(
int(settings.CELERY_RUNTIME_WORKER_HEARTBEAT_TTL_SECONDS or 90),
int(settings.CELERY_RUNTIME_WORKER_STALE_GRACE_SECONDS or 120),
)
pattern = f"{settings.CELERY_RUNTIME_WORKER_NAME_INSTANCE_ZSET_PREFIX}:*"
async for raw_index_key in redis.scan_iter(match=pattern, count=max(10, min(limit, 500))):
if checked_names >= limit:
break
checked_names += 1
index_key = str(raw_index_key)
raw_instances = await redis.zrange(index_key, 0, -1, withscores=True)
for raw_instance_id, raw_score in raw_instances:
if checked_instances >= limit:
break
checked_instances += 1
instance_id = str(raw_instance_id)
score = int(float(raw_score))
if now - score < grace:
continue
if await redis.exists(_worker_instance_key(instance_id)):
continue
task_set_key = _worker_task_set_key(instance_id)
runtime_ids = [str(value) for value in await redis.smembers(task_set_key)]
if runtime_ids:
raw_locations = await redis.hmget(settings.CELERY_RUNTIME_LOCATION_HASH_KEY, runtime_ids)
stale_members: list[str] = []
for runtime_id, raw_location in zip(runtime_ids, raw_locations):
try:
location = json.loads(raw_location) if raw_location else None
except Exception:
location = None
if (
not isinstance(location, dict)
or str(location.get("worker_instance_id") or "") != instance_id
):
stale_members.append(runtime_id)
if stale_members:
removed_task_members += int(await redis.srem(task_set_key, *stale_members) or 0)
if int(await redis.scard(task_set_key) or 0) == 0:
await redis.delete(task_set_key)
removed_instances += int(await redis.zrem(index_key, instance_id) or 0)
if int(await redis.zcard(index_key) or 0) == 0:
await redis.delete(index_key)
return {
"checked_names": checked_names,
"checked_instances": checked_instances,
"removed_instances": removed_instances,
"removed_task_members": removed_task_members,
}
@@ -0,0 +1,543 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable, Mapping
from app.config import settings
from app.enums.celery_runtime import CeleryRuntimeEvent, CeleryRuntimeState
from app.services.celery_runtime.worker_service import current_worker_identity
from app.services.operation_log_service import log_operation_event
from app.services.redis_registry_service import (
RedisExecutionLockError,
RedisExecutionLockLost,
RedisExecutionLockUnavailable,
datetime_to_epoch,
get_registry_redis,
)
try:
from redis.exceptions import RedisError
except ImportError: # pragma: no cover
RedisError = RuntimeError # type: ignore[assignment]
logger = logging.getLogger("video_gen")
DbHeartbeat = Callable[[str], Awaitable[bool]]
# KEYS:
# 1 lock, 2 active hash, 3 active zset, 4 worker-instance task set,
# 5 runtime location hash.
#
# ARGV:
# 1 token, 2 lock ttl ms, 3 runtime id, 4 payload, 5 check_at,
# 6 worker task-set ttl seconds, 7 location payload, 8 track worker flag.
_ACQUIRE_SCRIPT = """
if redis.call('exists', KEYS[1]) == 1 then
return 0
end
redis.call('psetex', KEYS[1], ARGV[2], ARGV[1])
redis.call('hset', KEYS[2], ARGV[3], ARGV[4])
redis.call('zadd', KEYS[3], ARGV[5], ARGV[3])
if ARGV[8] == '1' then
redis.call('sadd', KEYS[4], ARGV[3])
redis.call('expire', KEYS[4], ARGV[6])
end
redis.call('hset', KEYS[5], ARGV[3], ARGV[7])
return 1
"""
_HEARTBEAT_SCRIPT = """
if redis.call('get', KEYS[1]) ~= ARGV[1] then
return 0
end
redis.call('pexpire', KEYS[1], ARGV[2])
local merged_payload = ARGV[4]
local current_payload = redis.call('hget', KEYS[2], ARGV[3])
if current_payload then
local current_ok, current_obj = pcall(cjson.decode, current_payload)
local update_ok, update_obj = pcall(cjson.decode, ARGV[4])
if current_ok and update_ok then
for key, value in pairs(update_obj) do
current_obj[key] = value
end
merged_payload = cjson.encode(current_obj)
end
end
redis.call('hset', KEYS[2], ARGV[3], merged_payload)
redis.call('zadd', KEYS[3], ARGV[5], ARGV[3])
if ARGV[8] == '1' then
redis.call('sadd', KEYS[4], ARGV[3])
redis.call('expire', KEYS[4], ARGV[6])
end
redis.call('hset', KEYS[5], ARGV[3], ARGV[7])
return 1
"""
_COMPLETE_SCRIPT = """
if redis.call('get', KEYS[1]) ~= ARGV[1] then
return 0
end
redis.call('del', KEYS[1])
redis.call('hdel', KEYS[2], ARGV[2])
redis.call('zrem', KEYS[3], ARGV[2])
redis.call('srem', KEYS[4], ARGV[2])
redis.call('hdel', KEYS[5], ARGV[2])
return 1
"""
def _now_epoch() -> int:
return datetime_to_epoch(datetime.now(timezone.utc))
def _token_digest(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
def build_runtime_id(domain: str, owner_type: str, owner_id: str, attempt_no: int | None = None) -> str:
attempt = max(1, int(attempt_no or 1))
return f"{domain}:{owner_type}:{owner_id}:attempt:{attempt}"
def _worker_task_set_key(worker_instance_id: str) -> str:
return f"{settings.CELERY_RUNTIME_WORKER_TASK_SET_PREFIX}:{worker_instance_id}"
@dataclass(frozen=True, slots=True)
class RuntimeIdentity:
domain: str
owner_type: str
owner_id: str
attempt_no: int
task_name: str
queue: str
registry_item_id: str | None = None
@property
def runtime_id(self) -> str:
return self.registry_item_id or build_runtime_id(
self.domain,
self.owner_type,
self.owner_id,
self.attempt_no,
)
@dataclass(slots=True)
class CeleryRuntimeLease:
identity: RuntimeIdentity
lock_key: str
hash_key: str
zset_key: str
token: str
ttl_seconds: int
heartbeat_interval_seconds: int
payload: dict[str, Any]
db_heartbeat: DbHeartbeat | None = None
db_heartbeat_grace_seconds: int = 60
_stop_event: asyncio.Event = field(default_factory=asyncio.Event, init=False, repr=False)
_heartbeat_task: asyncio.Task[Any] | None = field(default=None, init=False, repr=False)
_lost_error: RedisExecutionLockError | None = field(default=None, init=False, repr=False)
@classmethod
async def acquire(
cls,
*,
identity: RuntimeIdentity,
lock_key: str,
hash_key: str,
zset_key: str,
token: str,
ttl_seconds: int,
heartbeat_interval_seconds: int,
pipeline_stage: str | None = None,
input_hash: str | None = None,
business_version: int | str | None = None,
extra_payload: Mapping[str, Any] | None = None,
db_heartbeat: DbHeartbeat | None = None,
) -> "CeleryRuntimeLease | None":
redis = await get_registry_redis()
if redis is None:
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.RUNTIME_REDIS_UNAVAILABLE.value,
event_status="failed",
source="celery",
task_id=identity.owner_id,
detail={"runtime_id": identity.runtime_id, "domain": identity.domain},
)
raise RedisExecutionLockUnavailable(f"Redis runtime unavailable: {identity.runtime_id}")
worker = current_worker_identity()
now = _now_epoch()
ttl = max(1, int(ttl_seconds or 60))
heartbeat_interval = max(
1,
min(ttl - 1 if ttl > 1 else 1, int(heartbeat_interval_seconds or 30)),
)
check_at = now + ttl
task_set_ttl = max(
ttl * 2,
int(settings.CELERY_RUNTIME_WORKER_TASK_SET_TTL_SECONDS or 86400),
)
task_set_key = _worker_task_set_key(worker.worker_instance_id)
track_worker = "1" if worker.supports_targeted_recovery else "0"
payload: dict[str, Any] = {
"runtime_schema_version": int(settings.CELERY_RUNTIME_SCHEMA_VERSION or 2),
"runtime_id": identity.runtime_id,
"domain": identity.domain,
"task_name": identity.task_name,
"queue": identity.queue,
"owner_type": identity.owner_type,
"owner_id": identity.owner_id,
"attempt_no": identity.attempt_no,
"business_version": business_version,
"input_hash": input_hash,
"worker_name": worker.worker_name,
"worker_instance_id": worker.worker_instance_id,
"worker_main_pid": worker.worker_main_pid,
"execution_pid": worker.execution_pid,
"execution_thread_id": worker.execution_thread_id,
"worker_started_at": worker.started_at,
"worker_identity_quality": worker.identity_quality,
"supports_targeted_recovery": worker.supports_targeted_recovery,
"host": worker.host,
"host_boot_id": worker.host_boot_id,
"celery_task_id": worker.celery_task_id,
"lock_key": lock_key,
"lock_token_digest": _token_digest(token),
"runtime_state": CeleryRuntimeState.ACTIVE.value,
"pipeline_stage": pipeline_stage,
"started_at": now,
"heartbeat_at": now,
"lease_until": check_at,
"check_at": check_at,
"recovery_count": 0,
}
if extra_payload:
payload["extra"] = dict(extra_payload)
location_payload = json.dumps(
{
"runtime_schema_version": int(settings.CELERY_RUNTIME_SCHEMA_VERSION or 2),
"runtime_id": identity.runtime_id,
"domain": identity.domain,
"hash_key": hash_key,
"zset_key": zset_key,
"lock_key": lock_key,
"worker_name": worker.worker_name,
"worker_instance_id": worker.worker_instance_id,
"supports_targeted_recovery": worker.supports_targeted_recovery,
},
ensure_ascii=False,
default=str,
)
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.RUNTIME_ACQUIRE_START.value,
event_status="started",
source="celery",
task_id=identity.owner_id,
detail={
"runtime_id": identity.runtime_id,
"worker_instance_id": worker.worker_instance_id,
"execution_pid": worker.execution_pid,
},
)
try:
acquired = await redis.eval(
_ACQUIRE_SCRIPT,
5,
lock_key,
hash_key,
zset_key,
task_set_key,
settings.CELERY_RUNTIME_LOCATION_HASH_KEY,
token,
ttl * 1000,
identity.runtime_id,
json.dumps(payload, ensure_ascii=False, default=str),
check_at,
task_set_ttl,
location_payload,
track_worker,
)
except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc:
logger.exception("Celery runtime acquire failed. runtime_id=%s", identity.runtime_id)
raise RedisExecutionLockUnavailable(
f"Redis runtime acquire failed: {identity.runtime_id}: {exc}"
) from exc
if not acquired:
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.RUNTIME_LOCK_HELD.value,
event_status="skipped",
source="celery",
task_id=identity.owner_id,
detail={"runtime_id": identity.runtime_id, "lock_key": lock_key},
)
return None
lease = cls(
identity=identity,
lock_key=lock_key,
hash_key=hash_key,
zset_key=zset_key,
token=token,
ttl_seconds=ttl,
heartbeat_interval_seconds=heartbeat_interval,
payload=payload,
db_heartbeat=db_heartbeat,
db_heartbeat_grace_seconds=max(60, heartbeat_interval * 2),
)
lease._heartbeat_task = asyncio.create_task(lease._heartbeat_loop())
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.RUNTIME_ACQUIRED.value,
event_status="success",
source="celery",
task_id=identity.owner_id,
detail={
"runtime_id": identity.runtime_id,
"domain": identity.domain,
"attempt_no": identity.attempt_no,
"worker_instance_id": worker.worker_instance_id,
"worker_main_pid": worker.worker_main_pid,
"execution_pid": worker.execution_pid,
"lock_token_suffix": token[-8:],
"lease_until": check_at,
},
)
return lease
async def _heartbeat_loop(self) -> None:
while not self._stop_event.is_set():
try:
await asyncio.wait_for(
self._stop_event.wait(),
timeout=self.heartbeat_interval_seconds,
)
return
except asyncio.TimeoutError:
pass
try:
await self._heartbeat_once()
except RedisExecutionLockError as exc:
self._lost_error = exc
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.RUNTIME_HEARTBEAT_LOST.value,
event_status="failed",
source="celery",
task_id=self.identity.owner_id,
detail={"runtime_id": self.identity.runtime_id, "error": str(exc)},
error=str(exc),
)
return
except Exception as exc:
self._lost_error = RedisExecutionLockUnavailable(str(exc))
logger.exception(
"Celery runtime heartbeat failed. runtime_id=%s",
self.identity.runtime_id,
)
return
async def _heartbeat_once(self) -> None:
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable(
f"Redis runtime heartbeat unavailable: {self.identity.runtime_id}"
)
now = _now_epoch()
check_at = now + self.ttl_seconds
payload = dict(self.payload)
payload.update(
{
"heartbeat_at": now,
"lease_until": check_at,
"check_at": check_at,
# threads 模式下线程 ID可能随下一次执行变化,但同一 lease 生命周期固定。
"execution_pid": self.payload.get("execution_pid"),
"execution_thread_id": self.payload.get("execution_thread_id"),
}
)
worker_instance_id = str(payload.get("worker_instance_id") or "")
task_set_key = _worker_task_set_key(worker_instance_id)
task_set_ttl = max(
self.ttl_seconds * 2,
int(settings.CELERY_RUNTIME_WORKER_TASK_SET_TTL_SECONDS or 86400),
)
track_worker = "1" if bool(payload.get("supports_targeted_recovery")) else "0"
location_payload = json.dumps(
{
"runtime_schema_version": int(settings.CELERY_RUNTIME_SCHEMA_VERSION or 2),
"runtime_id": self.identity.runtime_id,
"domain": self.identity.domain,
"hash_key": self.hash_key,
"zset_key": self.zset_key,
"lock_key": self.lock_key,
"worker_name": payload.get("worker_name"),
"worker_instance_id": worker_instance_id,
"supports_targeted_recovery": bool(payload.get("supports_targeted_recovery")),
},
ensure_ascii=False,
default=str,
)
try:
renewed = await redis.eval(
_HEARTBEAT_SCRIPT,
5,
self.lock_key,
self.hash_key,
self.zset_key,
task_set_key,
settings.CELERY_RUNTIME_LOCATION_HASH_KEY,
self.token,
self.ttl_seconds * 1000,
self.identity.runtime_id,
json.dumps(payload, ensure_ascii=False, default=str),
check_at,
task_set_ttl,
location_payload,
track_worker,
)
except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc:
raise RedisExecutionLockUnavailable(
f"Redis runtime heartbeat failed: {self.identity.runtime_id}: {exc}"
) from exc
if not renewed:
raise RedisExecutionLockLost(
f"Redis runtime ownership lost: {self.identity.runtime_id}"
)
self.payload = payload
if self.db_heartbeat is not None:
started_at = int(self.payload.get("started_at") or now)
if now - started_at < self.db_heartbeat_grace_seconds:
return
owned = await self.db_heartbeat(self.token)
if not owned:
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.RUNTIME_DB_LEASE_LOST.value,
event_status="failed",
source="celery",
task_id=self.identity.owner_id,
detail={"runtime_id": self.identity.runtime_id},
)
raise RedisExecutionLockLost(
f"Database lease ownership lost: {self.identity.runtime_id}"
)
async def ensure_owned(self) -> None:
if self._lost_error is not None:
raise self._lost_error
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable(
f"Redis runtime unavailable: {self.identity.runtime_id}"
)
try:
value = await redis.get(self.lock_key)
except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc:
raise RedisExecutionLockUnavailable(
f"Redis runtime check failed: {self.identity.runtime_id}: {exc}"
) from exc
if str(value or "") != self.token:
self._lost_error = RedisExecutionLockLost(
f"Redis runtime ownership lost: {self.identity.runtime_id}"
)
raise self._lost_error
async def close(self) -> None:
self._stop_event.set()
if self._heartbeat_task is not None:
try:
await self._heartbeat_task
except Exception:
logger.debug("runtime heartbeat close failed", exc_info=True)
redis = await get_registry_redis()
if redis is None:
return
worker_instance_id = str(self.payload.get("worker_instance_id") or "")
task_set_key = _worker_task_set_key(worker_instance_id)
cleaned = False
try:
cleaned = bool(
await redis.eval(
_COMPLETE_SCRIPT,
5,
self.lock_key,
self.hash_key,
self.zset_key,
task_set_key,
settings.CELERY_RUNTIME_LOCATION_HASH_KEY,
self.token,
self.identity.runtime_id,
)
)
except Exception:
logger.warning(
"Celery runtime cleanup failed. runtime_id=%s",
self.identity.runtime_id,
exc_info=True,
)
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.RUNTIME_COMPLETED.value,
event_status="success" if cleaned else "skipped",
source="celery",
task_id=self.identity.owner_id,
detail={
"runtime_id": self.identity.runtime_id,
"ownership_cleanup": cleaned,
},
)
async def runtime_lock_exists(lock_key: str) -> bool:
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable(f"Redis unavailable while checking lock: {lock_key}")
try:
return bool(await redis.exists(lock_key))
except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc:
raise RedisExecutionLockUnavailable(f"Redis lock check failed: {lock_key}: {exc}") from exc
async def runtime_lock_values(lock_keys: list[str]) -> dict[str, str | None]:
"""批量读取执行锁;Redis 不可用时 fail-closed。"""
if not lock_keys:
return {}
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable("Redis unavailable while batch checking runtime locks")
try:
values = await redis.mget(lock_keys)
except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc:
raise RedisExecutionLockUnavailable(f"Redis batch lock check failed: {exc}") from exc
return {
lock_key: (str(value) if value not in (None, "") else None)
for lock_key, value in zip(lock_keys, values)
}
@@ -0,0 +1,477 @@
from __future__ import annotations
import ctypes
import json
import os
import socket
import threading
import time
import uuid
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from typing import Any
from app.config import settings
from app.enums.celery_runtime import CeleryRuntimeEvent, WorkerIdentityQuality
from app.services.operation_log_service import log_operation_event
from app.services.redis_registry_service import (
RedisExecutionLockUnavailable,
get_registry_redis,
)
try:
from celery import current_task
except Exception: # pragma: no cover
current_task = None # type: ignore[assignment]
_PROCESS_STARTED_AT = int(time.time())
_PROCESS_INSTANCE_ID = uuid.uuid4().hex
_PROCESS_IDENTITY_LOCK = threading.RLock()
_WORKER_REGISTRATION_LOCK = threading.RLock()
_HEARTBEAT_THROTTLE_LOCK = threading.RLock()
_ENV_INSTANCE_TOKEN = "CELERY_WORKER_INSTANCE_TOKEN"
_ENV_MAIN_PID = "CELERY_WORKER_MAIN_PID"
_ENV_STARTED_AT = "CELERY_WORKER_STARTED_AT"
_ENV_HOST_BOOT_ID = "CELERY_HOST_BOOT_ID"
_ENV_WORKER_NAME = "CELERY_WORKER_NODE_NAME"
_ENV_BEFORE_POOL = "CELERY_WORKER_IDENTITY_BEFORE_POOL"
_registered_worker: "WorkerRegistration | None" = None
_last_heartbeat_attempt_monotonic = 0.0
_last_stale_scan_attempt_monotonic = 0.0
_heartbeat_failure_count = 0
def _parse_optional_int(value: Any) -> int | None:
raw = str(value or "").strip()
if not raw:
return None
try:
parsed = int(raw)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def _parse_optional_float(value: Any) -> float | None:
raw = str(value or "").strip()
if not raw:
return None
try:
return float(raw)
except (TypeError, ValueError):
return None
@lru_cache(maxsize=1)
def _boot_id() -> str:
"""返回同一次操作系统启动期间稳定的主机启动标识。
Linux 直接读取内核 boot_idWindows 使用 GetTickCount64 估算启动时间并
生成 UUID5。无法获取时返回 unknown-boot。该字段仅用于辅助诊断,不参与
任务锁或数据库 fencing 的最终正确性判断。
"""
linux_path = Path("/proc/sys/kernel/random/boot_id")
try:
value = linux_path.read_text(encoding="utf-8").strip()
if value:
return value
except Exception:
pass
if os.name == "nt":
try:
uptime_ms = int(ctypes.windll.kernel32.GetTickCount64()) # type: ignore[attr-defined]
# 以 10 秒为粒度消除多个进程独立计算时的亚秒抖动。
boot_epoch_bucket = int((time.time() - uptime_ms / 1000.0) // 10 * 10)
source = f"windows-boot:{socket.gethostname()}:{boot_epoch_bucket}"
return str(uuid.uuid5(uuid.NAMESPACE_OID, source))
except Exception:
pass
return "unknown-boot"
def initialize_worker_main_identity(
worker_name: str | None = None,
*,
before_pool: bool,
) -> None:
"""在 Celery Worker 主进程中初始化一次实例 token。
before_pool=True 必须由 celeryd_init/worker_init 调用,以保证 Linux prefork
子进程通过 fork 继承相同 token。worker_ready 只允许做 late fallback,且
late fallback 会关闭实例级精准恢复,避免主进程与已创建子进程身份不一致。
"""
normalized_name = str(worker_name or "").strip()
current_pid = os.getpid()
should_log = False
with _PROCESS_IDENTITY_LOCK:
existing_pid = _parse_optional_int(os.getenv(_ENV_MAIN_PID))
existing_token = str(os.getenv(_ENV_INSTANCE_TOKEN, "") or "").strip()
existing_before_pool = str(os.getenv(_ENV_BEFORE_POOL, "") or "").strip() == "1"
if not existing_token or existing_pid != current_pid:
os.environ[_ENV_INSTANCE_TOKEN] = uuid.uuid4().hex
os.environ[_ENV_MAIN_PID] = str(current_pid)
os.environ[_ENV_STARTED_AT] = str(int(time.time()))
os.environ[_ENV_HOST_BOOT_ID] = _boot_id()
os.environ[_ENV_BEFORE_POOL] = "1" if before_pool else "0"
should_log = True
elif before_pool and not existing_before_pool:
# celeryd_init 与 worker_init 都可能触发,幂等提升为 before-pool。
os.environ[_ENV_BEFORE_POOL] = "1"
should_log = True
if normalized_name:
os.environ[_ENV_WORKER_NAME] = normalized_name
_process_identity_base.cache_clear()
if should_log:
identity = current_worker_identity(worker_name_override=normalized_name or None)
log_operation_event(
domain="celery_runtime",
event_type=(
CeleryRuntimeEvent.WORKER_IDENTITY_INITIALIZED.value
if identity.supports_targeted_recovery
else CeleryRuntimeEvent.WORKER_IDENTITY_FALLBACK.value
),
event_status="success" if identity.supports_targeted_recovery else "warning",
source="worker_init",
detail={
"worker_name": identity.worker_name,
"worker_instance_id": identity.worker_instance_id,
"worker_main_pid": identity.worker_main_pid,
"execution_pid": identity.execution_pid,
"host_boot_id": identity.host_boot_id,
"identity_quality": identity.identity_quality,
"supports_targeted_recovery": identity.supports_targeted_recovery,
"before_pool": before_pool,
},
)
def reset_process_identity_cache() -> None:
"""prefork 子进程启动后清理 fork 前的 Python 对象缓存。
环境变量中的主 Worker token 保留;每个子进程重新构建自己的执行 PID 和
线程 ID,不会生成新的 Worker 实例 token。
"""
_process_identity_base.cache_clear()
@dataclass(frozen=True, slots=True)
class _ProcessIdentityBase:
worker_instance_token: str
worker_main_pid: int | None
worker_started_at: int
host_boot_id: str
identity_quality: str
supports_targeted_recovery: bool
@lru_cache(maxsize=1)
def _process_identity_base() -> _ProcessIdentityBase:
token = str(os.getenv(_ENV_INSTANCE_TOKEN, "") or "").strip()
before_pool = str(os.getenv(_ENV_BEFORE_POOL, "") or "").strip() == "1"
worker_main_pid = _parse_optional_int(os.getenv(_ENV_MAIN_PID))
started_at = _parse_optional_float(os.getenv(_ENV_STARTED_AT))
host_boot_id = str(os.getenv(_ENV_HOST_BOOT_ID, "") or "").strip() or _boot_id()
supports_targeted_recovery = bool(token and before_pool)
if not token:
token = _PROCESS_INSTANCE_ID
execution_pid = os.getpid()
if not supports_targeted_recovery:
quality = WorkerIdentityQuality.FALLBACK.value
elif worker_main_pid is None or host_boot_id == "unknown-boot":
quality = WorkerIdentityQuality.INSTANCE_TOKEN_ONLY.value
else:
quality = WorkerIdentityQuality.FULL.value
return _ProcessIdentityBase(
worker_instance_token=token,
worker_main_pid=worker_main_pid,
worker_started_at=int(started_at or _PROCESS_STARTED_AT),
host_boot_id=host_boot_id,
identity_quality=quality,
supports_targeted_recovery=supports_targeted_recovery,
)
@dataclass(frozen=True, slots=True)
class WorkerIdentity:
worker_name: str
worker_instance_id: str
worker_instance_token: str
host: str
host_boot_id: str
worker_main_pid: int | None
execution_pid: int
execution_thread_id: int
started_at: int
identity_quality: str
supports_targeted_recovery: bool
celery_task_id: str | None = None
def current_worker_identity(*, worker_name_override: str | None = None) -> WorkerIdentity:
hostname = socket.gethostname()
celery_task_id: str | None = None
request_worker_name = ""
try:
request = getattr(current_task, "request", None)
request_worker_name = str(getattr(request, "hostname", "") or "").strip()
celery_task_id = str(getattr(request, "id", "") or "").strip() or None
except Exception:
pass
worker_name = (
str(worker_name_override or "").strip()
or request_worker_name
or str(os.getenv(_ENV_WORKER_NAME, "") or "").strip()
or hostname
)
base = _process_identity_base()
instance_id = f"{worker_name}:{base.host_boot_id}:{base.worker_instance_token}"
return WorkerIdentity(
worker_name=worker_name,
worker_instance_id=instance_id,
worker_instance_token=base.worker_instance_token,
host=hostname,
host_boot_id=base.host_boot_id,
worker_main_pid=base.worker_main_pid,
execution_pid=os.getpid(),
execution_thread_id=threading.get_ident(),
started_at=base.worker_started_at,
identity_quality=base.identity_quality,
supports_targeted_recovery=base.supports_targeted_recovery,
celery_task_id=celery_task_id,
)
@dataclass(frozen=True, slots=True)
class WorkerRegistration:
identity: WorkerIdentity
queues: tuple[str, ...] = field(default_factory=tuple)
pool_type: str | None = None
configured_concurrency: int | None = None
def payload(self, *, heartbeat_at: int) -> dict[str, Any]:
return {
"runtime_schema_version": int(settings.CELERY_RUNTIME_SCHEMA_VERSION or 2),
"worker_name": self.identity.worker_name,
"worker_instance_id": self.identity.worker_instance_id,
"host": self.identity.host,
"host_boot_id": self.identity.host_boot_id,
"worker_main_pid": self.identity.worker_main_pid,
"started_at": self.identity.started_at,
"identity_quality": self.identity.identity_quality,
"supports_targeted_recovery": self.identity.supports_targeted_recovery,
"queues": list(self.queues),
"pool_type": self.pool_type,
"configured_concurrency": self.configured_concurrency,
"heartbeat_at": heartbeat_at,
}
def _worker_instance_key(worker_instance_id: str) -> str:
return f"{settings.CELERY_RUNTIME_WORKER_INSTANCE_PREFIX}:{worker_instance_id}"
def _worker_name_instances_key(worker_name: str) -> str:
return f"{settings.CELERY_RUNTIME_WORKER_NAME_INSTANCE_ZSET_PREFIX}:{worker_name}"
def _normalize_queues(values: Any) -> tuple[str, ...]:
if values is None:
return ()
if isinstance(values, str):
values = [values]
result: list[str] = []
try:
iterator = iter(values)
except TypeError:
return ()
for value in iterator:
name = str(getattr(value, "name", value) or "").strip()
if name and name not in result:
result.append(name)
return tuple(result)
async def register_worker_instance(
*,
worker_name: str,
queues: Any = None,
pool_type: str | None = None,
configured_concurrency: int | None = None,
) -> WorkerIdentity:
"""注册 Worker 主实例;Redis 不可用时不阻塞 Worker 启动。"""
global _registered_worker, _last_heartbeat_attempt_monotonic
normalized_name = str(worker_name or "").strip() or socket.gethostname()
os.environ[_ENV_WORKER_NAME] = normalized_name
identity = current_worker_identity(worker_name_override=normalized_name)
registration = WorkerRegistration(
identity=identity,
queues=_normalize_queues(queues),
pool_type=str(pool_type or "").strip() or None,
configured_concurrency=_parse_optional_int(configured_concurrency),
)
with _WORKER_REGISTRATION_LOCK:
_registered_worker = registration
now = int(time.time())
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable("Redis unavailable while registering Celery worker instance")
ttl = max(30, int(settings.CELERY_RUNTIME_WORKER_HEARTBEAT_TTL_SECONDS or 90))
index_ttl = max(ttl * 2, int(settings.CELERY_RUNTIME_WORKER_TASK_SET_TTL_SECONDS or 86400))
payload = json.dumps(registration.payload(heartbeat_at=now), ensure_ascii=False, default=str)
pipe = redis.pipeline(transaction=False)
pipe.set(_worker_instance_key(identity.worker_instance_id), payload, ex=ttl)
pipe.zadd(_worker_name_instances_key(identity.worker_name), {identity.worker_instance_id: now})
pipe.expire(_worker_name_instances_key(identity.worker_name), index_ttl)
await pipe.execute()
with _HEARTBEAT_THROTTLE_LOCK:
_last_heartbeat_attempt_monotonic = time.monotonic()
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.WORKER_REGISTERED.value,
event_status="success",
source="worker_ready",
detail=registration.payload(heartbeat_at=now),
)
return identity
def claim_worker_heartbeat_slot() -> bool:
"""对 Celery heartbeat_sent 信号做本进程节流。"""
global _last_heartbeat_attempt_monotonic
interval = max(5, int(settings.CELERY_RUNTIME_WORKER_HEARTBEAT_INTERVAL_SECONDS or 30))
now = time.monotonic()
with _HEARTBEAT_THROTTLE_LOCK:
if now - _last_heartbeat_attempt_monotonic < interval:
return False
_last_heartbeat_attempt_monotonic = now
return True
def claim_worker_stale_scan_slot() -> bool:
"""限制同一 Worker 主进程的旧实例扫描频率。"""
global _last_stale_scan_attempt_monotonic
interval = max(30, int(settings.CELERY_RUNTIME_WORKER_STALE_SCAN_INTERVAL_SECONDS or 120))
now = time.monotonic()
with _HEARTBEAT_THROTTLE_LOCK:
if now - _last_stale_scan_attempt_monotonic < interval:
return False
_last_stale_scan_attempt_monotonic = now
return True
async def heartbeat_current_worker_instance() -> bool:
"""刷新 Worker 主实例 TTL;不刷新任何业务任务锁。"""
global _heartbeat_failure_count
with _WORKER_REGISTRATION_LOCK:
registration = _registered_worker
if registration is None:
return False
redis = await get_registry_redis()
if redis is None:
_heartbeat_failure_count += 1
_log_worker_heartbeat_failure_if_needed(registration, "redis_unavailable")
return False
now = int(time.time())
ttl = max(30, int(settings.CELERY_RUNTIME_WORKER_HEARTBEAT_TTL_SECONDS or 90))
index_ttl = max(ttl * 2, int(settings.CELERY_RUNTIME_WORKER_TASK_SET_TTL_SECONDS or 86400))
payload = json.dumps(registration.payload(heartbeat_at=now), ensure_ascii=False, default=str)
try:
pipe = redis.pipeline(transaction=False)
pipe.set(_worker_instance_key(registration.identity.worker_instance_id), payload, ex=ttl)
pipe.zadd(
_worker_name_instances_key(registration.identity.worker_name),
{registration.identity.worker_instance_id: now},
)
pipe.expire(_worker_name_instances_key(registration.identity.worker_name), index_ttl)
await pipe.execute()
except Exception as exc:
_heartbeat_failure_count += 1
_log_worker_heartbeat_failure_if_needed(registration, str(exc))
return False
_heartbeat_failure_count = 0
return True
def _log_worker_heartbeat_failure_if_needed(registration: WorkerRegistration, error: str) -> None:
threshold = max(1, int(settings.CELERY_RUNTIME_WORKER_HEARTBEAT_FAILURE_LOG_THRESHOLD or 3))
if _heartbeat_failure_count != threshold and _heartbeat_failure_count % (threshold * 5) != 0:
return
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.WORKER_HEARTBEAT_LOST.value,
event_status="failed",
source="worker_heartbeat",
detail={
"worker_name": registration.identity.worker_name,
"worker_instance_id": registration.identity.worker_instance_id,
"failure_count": _heartbeat_failure_count,
"error": error,
},
error=error,
)
async def unregister_current_worker_instance() -> None:
"""优雅退出时删除活跃实例 key,保留名称索引供旧任务精准恢复。"""
global _registered_worker
with _WORKER_REGISTRATION_LOCK:
registration = _registered_worker
_registered_worker = None
if registration is None:
return
redis = await get_registry_redis()
if redis is not None:
try:
await redis.delete(_worker_instance_key(registration.identity.worker_instance_id))
except Exception:
pass
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.WORKER_SHUTDOWN.value,
event_status="success",
source="worker_shutdown",
detail={
"worker_name": registration.identity.worker_name,
"worker_instance_id": registration.identity.worker_instance_id,
"worker_main_pid": registration.identity.worker_main_pid,
},
)
def registered_worker_identity() -> WorkerIdentity | None:
with _WORKER_REGISTRATION_LOCK:
return _registered_worker.identity if _registered_worker is not None else None
@@ -40,6 +40,7 @@ class ImageBatchClaim:
task_snapshot: SimpleNamespace | None = None
runtime_engine: SimpleNamespace | None = None
existing_child_ids: list[str] | None = None
staged_provider_result: dict | None = None
reason: str | None = None
@@ -53,6 +54,23 @@ def _json(value) -> str | None:
return json.dumps(value, ensure_ascii=False, default=str)
def _parse_staged_provider_result(raw: str | None) -> dict | None:
if not raw:
return None
try:
value = json.loads(raw)
except (TypeError, ValueError, json.JSONDecodeError):
return None
if not isinstance(value, dict):
return None
items = value.get("items")
if not isinstance(items, list) or not items:
return None
return value
def _aware(value: datetime | None) -> datetime | None:
if value is None:
return None
@@ -125,6 +143,13 @@ async def _claim_image_main_batch(
return ImageBatchClaim(False, main_task_id, reason=f"status_{status}")
now = _now()
staged_provider_result = None
if main.pipeline_stage == ChatGenerationPipelineStage.PROVIDER_RESULT_STAGED.value:
staged_provider_result = _parse_staged_provider_result(main.provider_response_json)
if staged_provider_result is None:
main.pipeline_stage = ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value
main.provider_response_json = None
if _lease_alive(main, now):
user_id = str(main.user_id)
group_id = str(main.id)
@@ -143,7 +168,7 @@ async def _claim_image_main_batch(
return ImageBatchClaim(False, main_task_id, reason="lease_alive")
deadline = _aware(main.deadline_at)
if deadline and deadline <= now:
if deadline and deadline <= now and staged_provider_result is None:
main.provider_create_claim_token = None
main.provider_create_lease_until = None
await mark_chat_generation_task_failed_and_refund_once(
@@ -159,7 +184,11 @@ async def _claim_image_main_batch(
main.provider_create_claim_token = claim_token
main.provider_create_started_at = now
main.provider_create_lease_until = now + timedelta(seconds=IMAGE_PROVIDER_CLAIM_LEASE_SECONDS)
main.pipeline_stage = ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value
main.pipeline_stage = (
ChatGenerationPipelineStage.PROVIDER_RESULT_STAGED.value
if staged_provider_result is not None
else ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value
)
runtime_engine = await get_runtime_engine(db, main)
snapshot = _task_snapshot(main)
user_id = str(main.user_id)
@@ -187,6 +216,8 @@ async def _claim_image_main_batch(
claim_token=claim_token,
task_snapshot=snapshot,
runtime_engine=runtime_engine,
staged_provider_result=staged_provider_result,
reason="provider_result_staged" if staged_provider_result is not None else None,
)
@@ -299,6 +330,58 @@ async def _fail_claimed_main(
return True
async def _stage_provider_result(
db: AsyncSession,
*,
main_task_id: str,
claim_token: str,
provider_result: dict,
) -> None:
try:
await db.rollback()
except Exception:
pass
result = await execute_with_lock_timeout(
db,
select(ChatGenerationTask)
.where(
ChatGenerationTask.id == main_task_id,
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_MAIN.value,
ChatGenerationTask.gen_type == GenerationType.IMAGE.value,
ChatGenerationTask.deleted_at.is_(None),
)
.with_for_update()
.limit(1),
)
main = result.scalar_one_or_none()
if not main:
raise RuntimeError("图片主任务不存在或已删除")
if main.provider_create_claim_token != claim_token:
raise RuntimeError("图片主任务执行租约已失效,拒绝暂存供应商结果")
if main.status != ChatGenerationTaskStatus.GENERATING.value:
raise RuntimeError(f"图片主任务当前状态不允许暂存: {main.status}")
main.provider_response_json = _json(provider_result)
main.image_tokens_used = int(provider_result.get("image_tokens") or 0)
main.pipeline_stage = ChatGenerationPipelineStage.PROVIDER_RESULT_STAGED.value
user_id_snapshot = str(main.user_id)
generation_count_snapshot = int(main.generation_count or 1)
image_tokens_snapshot = int(main.image_tokens_used or 0)
await db.commit()
log_operation_event(
domain="generation_ai_batch",
event_type="IMAGE_BATCH_PROVIDER_RESULT_STAGED",
event_status="success",
source="celery",
user_id=user_id_snapshot,
group_id=main_task_id,
task_id=main_task_id,
detail={
"generation_count": generation_count_snapshot,
"image_tokens": image_tokens_snapshot,
},
)
async def _split_children(
db: AsyncSession,
*,
@@ -474,54 +557,75 @@ async def run_image_main_batch(
return []
generation_count = max(1, int(claim.task_snapshot.generation_count or 1))
try:
log_operation_event(
domain="generation_ai_batch",
event_type=ChatGenerationTaskEventType.IMAGE_BATCH_PROVIDER_START.value,
event_status="started",
source="celery",
user_id=claim.task_snapshot.user_id,
group_id=main_task_id,
task_id=main_task_id,
detail={"generation_count": generation_count},
)
provider_result = await create_image_sync_batch_result_with_engine(
claim.task_snapshot,
claim.runtime_engine,
generation_count=generation_count,
)
await execution_guard()
provider_result = claim.staged_provider_result
if provider_result is None:
try:
log_operation_event(
domain="generation_ai_batch",
event_type=ChatGenerationTaskEventType.IMAGE_BATCH_PROVIDER_START.value,
event_status="started",
source="celery",
user_id=claim.task_snapshot.user_id,
group_id=main_task_id,
task_id=main_task_id,
detail={"generation_count": generation_count},
)
provider_result = await create_image_sync_batch_result_with_engine(
claim.task_snapshot,
claim.runtime_engine,
generation_count=generation_count,
)
await execution_guard()
provider_items = _validate_provider_batch(provider_result, generation_count)
await _stage_provider_result(
db,
main_task_id=main_task_id,
claim_token=claim.claim_token,
provider_result=provider_result,
)
log_operation_event(
domain="generation_ai_batch",
event_type=ChatGenerationTaskEventType.IMAGE_BATCH_PROVIDER_SUCCESS.value,
event_status="success",
source="celery",
user_id=claim.task_snapshot.user_id,
group_id=main_task_id,
task_id=main_task_id,
detail={
"generation_count": generation_count,
"result_count": len(provider_items),
"image_tokens": int(provider_result.get("image_tokens") or 0),
"single_provider_request": True,
"fallback_to_single_requests": False,
"provider_result_staged": True,
},
)
except RedisExecutionLockError:
raise
except Exception as exc:
await execution_guard()
message = exc.safe_message if isinstance(exc, ImageProviderError) else str(exc)
await _fail_claimed_main(
db,
main_task_id=main_task_id,
claim_token=claim.claim_token,
error_message=message or "图片批量生成失败",
event_type=ChatGenerationTaskEventType.IMAGE_BATCH_PROVIDER_FAILED,
exception=exc,
)
return []
else:
provider_items = _validate_provider_batch(provider_result, generation_count)
log_operation_event(
domain="generation_ai_batch",
event_type=ChatGenerationTaskEventType.IMAGE_BATCH_PROVIDER_SUCCESS.value,
event_type="IMAGE_BATCH_STAGED_RESULT_RECOVERED",
event_status="success",
source="celery",
source="recovery",
user_id=claim.task_snapshot.user_id,
group_id=main_task_id,
task_id=main_task_id,
detail={
"generation_count": generation_count,
"result_count": len(provider_items),
"image_tokens": int(provider_result.get("image_tokens") or 0),
"single_provider_request": True,
"fallback_to_single_requests": False,
},
detail={"generation_count": generation_count, "provider_regenerated": False},
)
except RedisExecutionLockError:
raise
except Exception as exc:
await execution_guard()
message = exc.safe_message if isinstance(exc, ImageProviderError) else str(exc)
await _fail_claimed_main(
db,
main_task_id=main_task_id,
claim_token=claim.claim_token,
error_message=message or "图片批量生成失败",
event_type=ChatGenerationTaskEventType.IMAGE_BATCH_PROVIDER_FAILED,
exception=exc,
)
return []
try:
await execution_guard()
@@ -535,14 +639,22 @@ async def run_image_main_batch(
except RedisExecutionLockError:
raise
except Exception as exc:
await execution_guard()
await _fail_claimed_main(
db,
main_task_id=main_task_id,
claim_token=claim.claim_token,
error_message=f"图片批量结果拆分失败: {exc}",
event_type=ChatGenerationTaskEventType.IMAGE_BATCH_SPLIT_FAILED,
exception=exc,
# 供应商结果已经落库;拆分失败只记录并等待恢复,绝不退款或重新调用供应商。
try:
await db.rollback()
except Exception:
pass
log_operation_event(
domain="generation_ai_batch",
event_type=ChatGenerationTaskEventType.IMAGE_BATCH_SPLIT_FAILED.value,
event_status="failed",
source="celery",
user_id=claim.task_snapshot.user_id,
group_id=main_task_id,
task_id=main_task_id,
message=f"图片批量结果拆分失败: {exc}",
detail={"provider_result_staged": True, "provider_regenerated": False},
error=str(exc),
)
return []
@@ -27,6 +27,7 @@ ACTIVE_STAGES = {
ChatGenerationPipelineStage.QUEUED.value,
ChatGenerationPipelineStage.PREPARING.value,
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
ChatGenerationPipelineStage.PROVIDER_RESULT_STAGED.value,
ChatGenerationPipelineStage.WAITING_REMOTE.value,
ChatGenerationPipelineStage.POLLING.value,
ChatGenerationPipelineStage.RESULT_READY.value,
@@ -153,31 +154,7 @@ def _build_summary(children: list[ChatGenerationTask]) -> str | None:
return f"{len(children)}项中" + "".join(parts)
async def aggregate_main_task_status(
db: AsyncSession,
*,
parent_task_id: str,
) -> ChatGenerationTask | None:
result = await execute_with_lock_timeout(
db,
select(ChatGenerationTask)
.where(
ChatGenerationTask.id == parent_task_id,
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_MAIN.value,
)
.with_for_update()
.limit(1)
)
main = result.scalar_one_or_none()
if not main or main.deleted_at is not None:
return main
children_map = await load_children_map(db, [parent_task_id], include_deleted=True)
children = children_map.get(parent_task_id, [])
if not children:
return main
def _apply_main_task_status(main: ChatGenerationTask, children: list[ChatGenerationTask]) -> dict[str, object]:
previous_status = main.status
previous_stage = main.pipeline_stage
active_children = [child for child in children if is_task_active(child)]
@@ -217,7 +194,6 @@ async def aggregate_main_task_status(
)
main.error_message = _build_summary(children)
else:
# 所有子任务真实生成结果均成功;资源是否软删除不改变生成历史终态。
main.status = ChatGenerationTaskStatus.COMPLETED.value
main.pipeline_stage = ChatGenerationPipelineStage.DONE.value
main.generated_at = max(
@@ -232,29 +208,73 @@ async def aggregate_main_task_status(
main.text_tokens_used = sum(int(child.text_tokens_used or 0) for child in children)
main.image_tokens_used = sum(int(child.image_tokens_used or 0) for child in children)
main.video_tokens_used = sum(int(child.video_tokens_used or 0) for child in children)
# main 的手动重试次数只代表 main 自身,不能累加 child 的轮询/重试次数。
main.retry_count = int(main.manual_retry_count or 0)
main.poll_count = sum(int(child.poll_count or 0) for child in children)
main.poll_error_count = sum(int(child.poll_error_count or 0) for child in children)
await db.flush()
log_operation_event(
domain="generation_ai_batch",
event_type="MAIN_STATUS_AGGREGATED",
event_status="success",
source="service",
user_id=main.user_id,
group_id=main.id,
task_id=main.id,
detail={
"before_status": previous_status,
"before_stage": previous_stage,
"after_status": main.status,
"after_stage": main.pipeline_stage,
"summary": _build_summary(children),
},
return {
"before_status": previous_status,
"before_stage": previous_stage,
"after_status": main.status,
"after_stage": main.pipeline_stage,
"summary": _build_summary(children),
}
async def aggregate_main_tasks_status_batch(
db: AsyncSession,
*,
parent_task_ids: Sequence[str] | Iterable[str],
) -> dict[str, ChatGenerationTask]:
ids = list(dict.fromkeys(str(item) for item in parent_task_ids if item))
if not ids:
return {}
result = await execute_with_lock_timeout(
db,
select(ChatGenerationTask)
.where(
ChatGenerationTask.id.in_(ids),
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_MAIN.value,
)
.order_by(ChatGenerationTask.id.asc())
.with_for_update(),
)
return main
mains = list(result.scalars().all())
children_map = await load_children_map(db, ids, include_deleted=True)
log_snapshots: list[tuple[str, str | None, dict[str, object]]] = []
main_map: dict[str, ChatGenerationTask] = {}
for main in mains:
main_id = str(main.id)
main_map[main_id] = main
if main.deleted_at is not None:
continue
children = children_map.get(main_id, [])
if not children:
continue
detail = _apply_main_task_status(main, children)
log_snapshots.append((main_id, str(main.user_id) if main.user_id else None, detail))
await db.flush()
for main_id, user_id, detail in log_snapshots:
log_operation_event(
domain="generation_ai_batch",
event_type="MAIN_STATUS_AGGREGATED",
event_status="success",
source="service",
user_id=user_id,
group_id=main_id,
task_id=main_id,
detail=detail,
)
return main_map
async def aggregate_main_task_status(
db: AsyncSession,
*,
parent_task_id: str,
) -> ChatGenerationTask | None:
main_map = await aggregate_main_tasks_status_batch(db, parent_task_ids=[parent_task_id])
return main_map.get(str(parent_task_id))
async def aggregate_parent_for_child(db: AsyncSession, child: ChatGenerationTask | None) -> ChatGenerationTask | None:
@@ -7,7 +7,7 @@ from typing import Any, Mapping
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType, CreditRecordSourceModule
from app.enums.credit_record import CreditRecordChargeKind, CreditRecordOwnerType, CreditRecordSourceModule
from app.models.credit_record import CreditRecord
from app.models.generation_record import GenerationRecord
from app.services.generation.media_reference_service import calculate_media_reference_usage
@@ -22,6 +22,7 @@ from app.services.credit_record_meta_service import (
build_shot_video_analysis_meta,
)
from app.services.credits import calc_image_credits, calc_text_credits, calc_video_credits, deduct_credits
from app.utils.id_gen import generate_id
CHARGE_TEXT_PROMPT = CreditRecordChargeKind.TEXT_PROMPT.value
@@ -416,12 +417,44 @@ async def charge_shot_video_analysis_usage(
charge_kind=CHARGE_VIDEO_ANALYSIS,
action="charge",
)
usage_snapshot = dict(usage)
token_usage_result = await db.execute(
select(TokenUsage)
.where(
TokenUsage.owner_type == owner_type,
TokenUsage.owner_id == owner_id,
TokenUsage.biz_key == biz_key,
)
.order_by(TokenUsage.created_at.asc())
.limit(1)
)
token_usage = token_usage_result.scalar_one_or_none()
if token_usage is None:
token_usage = TokenUsage(
id=generate_id(),
model_config_id=usage_snapshot.get("model_config_id"),
user_id=user_id,
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=_safe_int(
usage_snapshot.get("total_tokens"),
input_tokens + output_tokens,
),
owner_type=owner_type,
owner_id=owner_id,
biz_key=biz_key,
source_module=CreditRecordSourceModule.SHOT_REPLICATE.value,
source_step_code="video_analysis",
)
db.add(token_usage)
await db.flush()
usage_snapshot["token_usage_id"] = token_usage.id
record_meta = await build_shot_video_analysis_meta(
db,
owner_type=owner_type,
owner_id=owner_id,
attempt_no=attempt_no,
usage=usage,
usage=usage_snapshot,
billing_scene=billing_scene,
source_project_id=source_project_id,
source_step_id=source_step_id,
@@ -212,3 +212,36 @@ def parse_redis_owner_item_id(value: str) -> GenerationOwnerRef:
return GenerationOwnerRef(owner_type, rest, None)
# Historical Redis/Celery identifiers always belonged to ChatGenerationTask.
return GenerationOwnerRef(GenerationOwnerType.CHAT_GENERATION_TASK.value, text, None)
async def renew_generation_owner_claim_lease(
*,
owner_type: str | GenerationOwnerType | None,
owner_id: str,
attempt_no: int,
claim_field: str,
lease_field: str,
token: str,
lease_seconds: int,
) -> bool:
"""CAS 续期生成所有者租约,不加载 ORM 对象,避免 heartbeat 产生懒加载风险。"""
from datetime import datetime, timedelta, timezone
from sqlalchemy import update
from app.models.base import async_session
normalized = normalize_owner_type(owner_type)
model = ChatGenerationTask if normalized == GenerationOwnerType.CHAT_GENERATION_TASK.value else GenerationRecord
claim_column = getattr(model, claim_field)
now = datetime.now(timezone.utc)
async with async_session() as db:
result = await db.execute(
update(model)
.where(
model.id == owner_id,
model.deleted_at.is_(None),
model.generation_attempt_no == int(attempt_no),
claim_column == token,
)
.values({lease_field: now + timedelta(seconds=max(1, int(lease_seconds)))})
)
await db.commit()
return bool(result.rowcount == 1)
@@ -37,7 +37,7 @@ def _load_refs(record: ChatGenerationTask) -> list[dict]:
async def _build_user_content(record: ChatGenerationTask, db: AsyncSession | None = None) -> list[dict[str, Any]]:
from app.utils.media import media_to_base64
from app.utils.media import get_llm_media_as_base64, media_to_base64
if record.gen_type == "image":
params = f"图片参数:分辨率档位={record.image_size or '2K'},比例={record.image_proportion or '1:1'},像素={record.image_px or '2048x2048'}"
@@ -5,7 +5,7 @@ import logging
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -20,6 +20,7 @@ from app.enums.generation_task import (
GenerationType,
)
from app.models.chat_generation_task import ChatGenerationTask
from app.services.celery_runtime.runtime_service import runtime_lock_exists, runtime_lock_values
from app.services.celery_download_recovery_service import (
ensure_aware_utc,
get_download_active_payloads,
@@ -49,6 +50,31 @@ logger = logging.getLogger("video_gen")
POLL_QUEUE = CeleryQueue.GEN_PROVIDER_POLL.value
def _create_lock_key(task: ChatGenerationTask) -> str:
return (
f"{settings.GENERATION_CREATE_LOCK_KEY_PREFIX}:"
f"{GenerationOwnerType.CHAT_GENERATION_TASK.value}:{task.id}:"
f"attempt:{int(task.generation_attempt_no or 1)}"
)
def _poll_lock_key(task: ChatGenerationTask) -> str:
return (
f"{settings.GENERATION_POLL_LOCK_KEY_PREFIX}:"
f"{GenerationOwnerType.CHAT_GENERATION_TASK.value}:{task.id}:"
f"attempt:{int(task.generation_attempt_no or 1)}"
)
def _download_lock_key(task: ChatGenerationTask) -> str:
return (
f"{settings.GENERATION_DOWNLOAD_LOCK_KEY_PREFIX}:"
f"{GenerationOwnerType.CHAT_GENERATION_TASK.value}:{task.id}:"
f"attempt:{int(task.generation_attempt_no or 1)}"
)
def _chat_registry_id(task: ChatGenerationTask) -> str:
return redis_owner_item_id(
GenerationOwnerType.CHAT_GENERATION_TASK.value,
@@ -211,6 +237,9 @@ async def recover_one_download_task(
)
return "skip_no_remote_result_url"
if await runtime_lock_exists(_download_lock_key(task)):
return "skip_live_download_runtime_lock"
stage = task.pipeline_stage
redis_payload = payload or {}
@@ -478,6 +507,14 @@ async def recover_one_generation_task(
has_provider_task_id = bool(str(task.provider_task_id or "").strip() or str(task.seedance_task_id or "").strip())
is_deadline_expired = bool(task.deadline_at and _is_expired(task.deadline_at, current_time))
runtime_lock_key = (
_download_lock_key(task)
if has_remote_result
else (_poll_lock_key(task) if has_provider_task_id else _create_lock_key(task))
)
if await runtime_lock_exists(runtime_lock_key):
return "skip_live_runtime_lock"
# 最高优先级:只要远程结果 URL 已经落库,说明生成侧已经成功。
# 不管当前 pipeline_stage 是 queued/creating/waiting/result_ready/download_*,恢复时都不能重复 create 或 poll。
if has_remote_result:
@@ -658,6 +695,152 @@ async def recover_one_generation_task(
return f"skip_stage_{task.pipeline_stage}"
async def recover_image_main_create_tasks_once(db: AsyncSession) -> dict[str, int]:
"""批量恢复同步多图主任务;锁活跃或 DB lease 未过期时绝不接管。"""
from app.tasks.generation_create_tasks import chatapi_create_generation_task
results: dict[str, int] = {}
cursor: str | None = None
batch_size = max(1, int(settings.GENERATION_RECOVERY_BATCH_SIZE or 100))
allowed_stages = [
ChatGenerationPipelineStage.QUEUED.value,
ChatGenerationPipelineStage.PREPARING.value,
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
ChatGenerationPipelineStage.PROVIDER_RESULT_STAGED.value,
]
while True:
query = (
select(ChatGenerationTask)
.where(
ChatGenerationTask.deleted_at.is_(None),
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_MAIN.value,
ChatGenerationTask.gen_type == GenerationType.IMAGE.value,
ChatGenerationTask.status == ChatGenerationTaskStatus.GENERATING.value,
ChatGenerationTask.pipeline_stage.in_(allowed_stages),
or_(
ChatGenerationTask.provider_create_lease_until <= _now(),
(
ChatGenerationTask.provider_create_lease_until.is_(None)
& (
ChatGenerationTask.updated_at
<= _now()
- timedelta(
seconds=max(
1,
int(settings.GENERATION_CREATE_QUEUE_TIMEOUT_SECONDS or 300),
)
)
)
),
),
)
.order_by(ChatGenerationTask.id.asc())
.limit(batch_size)
.with_for_update(skip_locked=True)
)
if cursor:
query = query.where(ChatGenerationTask.id > cursor)
row_result = await db.execute(query)
mains = list(row_result.scalars().all())
if not mains:
break
main_ids = [str(main.id) for main in mains]
cursor = main_ids[-1]
child_rows = await db.execute(
select(ChatGenerationTask.parent_task_id)
.where(
ChatGenerationTask.parent_task_id.in_(main_ids),
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_CHILD.value,
ChatGenerationTask.deleted_at.is_(None),
)
.distinct()
)
split_parent_ids = {str(value) for value in child_rows.scalars().all() if value}
lock_key_by_id = {str(main.id): _create_lock_key(main) for main in mains}
lock_values = await runtime_lock_values(list(lock_key_by_id.values()))
now = _now()
dispatches: list[tuple[str, int, bool]] = []
expired_claim_logs: list[tuple[str, int, str]] = []
for main in mains:
main_id = str(main.id)
attempt_no = int(main.generation_attempt_no or 1)
if lock_values.get(lock_key_by_id[main_id]):
results["live_lock"] = results.get("live_lock", 0) + 1
continue
if main_id in split_parent_ids:
main.provider_create_claim_token = None
main.provider_create_lease_until = None
results["already_split"] = results.get("already_split", 0) + 1
continue
lease_until = ensure_aware_utc(main.provider_create_lease_until)
if main.provider_create_claim_token and lease_until and lease_until > now:
results["waiting_db_lease"] = results.get("waiting_db_lease", 0) + 1
continue
has_staged_result = bool(
main.pipeline_stage == ChatGenerationPipelineStage.PROVIDER_RESULT_STAGED.value
and main.provider_response_json
)
if _is_expired(main.deadline_at, now) and not has_staged_result:
main.provider_create_claim_token = None
main.provider_create_lease_until = None
await mark_chat_generation_task_failed_and_refund_once(
db,
task=main,
error_message="图片生成任务超时,系统已自动退回本轮媒体生成积分",
pipeline_stage=ChatGenerationPipelineStage.TIMEOUT.value,
)
results["timeout"] = results.get("timeout", 0) + 1
continue
old_claim = str(main.provider_create_claim_token or "")
main.provider_create_claim_token = None
main.provider_create_lease_until = None
if not has_staged_result:
main.pipeline_stage = ChatGenerationPipelineStage.QUEUED.value
if old_claim:
expired_claim_logs.append((main_id, attempt_no, str(main.generation_mode)))
dispatches.append((main_id, attempt_no, has_staged_result))
await db.commit()
for main_id, attempt_no, generation_mode in expired_claim_logs:
await log_task_event(
task_id=main_id,
generation_attempt_no=attempt_no,
generation_mode=generation_mode,
event_type=ChatGenerationTaskEventType.IMAGE_MAIN_CLAIM_EXPIRED.value,
message="图片主任务执行锁已失效且数据库租约已过期,恢复重新投递",
)
for main_id, attempt_no, has_staged_result in dispatches:
try:
chatapi_create_generation_task.apply_async(
args=[main_id],
kwargs={
"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value,
"generation_attempt_no": attempt_no,
},
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
countdown=0,
task_id=(
f"generation-create:{GenerationOwnerType.CHAT_GENERATION_TASK.value}:"
f"{main_id}:attempt:{attempt_no}"
),
)
key = "recover_staged_split" if has_staged_result else "recover_create"
results[key] = results.get(key, 0) + 1
except Exception:
logger.exception("恢复投递图片主任务失败 task_id=%s", main_id)
results["enqueue_failed"] = results.get("enqueue_failed", 0) + 1
if len(mains) < batch_size:
break
return results
async def recover_generation_tasks_once(db: AsyncSession) -> dict[str, Any]:
"""启动时生成链路容灾扫描。
@@ -670,108 +853,9 @@ async def recover_generation_tasks_once(db: AsyncSession) -> dict[str, Any]:
checked_ids: set[str] = set()
results: dict[str, int] = {}
# 图片多份主任务只补投递,不在恢复服务内直接调用供应商。
# 有效 claim 未过期时必须跳过,防止与正在运行的 Worker 重复调用组图 API。
from app.tasks.generation_create_tasks import chatapi_create_generation_task
image_main_cursor: str | None = None
image_main_batch_size = max(1, int(settings.GENERATION_RECOVERY_BATCH_SIZE or 100))
while True:
image_main_query = select(ChatGenerationTask).where(
ChatGenerationTask.deleted_at.is_(None),
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_MAIN.value,
ChatGenerationTask.gen_type == GenerationType.IMAGE.value,
ChatGenerationTask.status == ChatGenerationTaskStatus.GENERATING.value,
ChatGenerationTask.pipeline_stage.in_([
ChatGenerationPipelineStage.QUEUED.value,
ChatGenerationPipelineStage.PREPARING.value,
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
]),
)
if image_main_cursor:
image_main_query = image_main_query.where(ChatGenerationTask.id > image_main_cursor)
image_main_result = await db.execute(
image_main_query.with_only_columns(ChatGenerationTask.id)
.order_by(ChatGenerationTask.id.asc())
.limit(image_main_batch_size)
)
image_main_ids = [str(value) for value in image_main_result.scalars().all()]
if not image_main_ids:
break
child_parent_result = await db.execute(
select(ChatGenerationTask.parent_task_id)
.where(
ChatGenerationTask.parent_task_id.in_(image_main_ids),
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_CHILD.value,
)
.distinct()
)
split_parent_ids = {str(value) for value in child_parent_result.scalars().all() if value}
for main_id in image_main_ids:
image_main_cursor = main_id
checked_ids.add(main_id)
main = await _load_chat_task_for_update(db, main_id)
if main is None:
await db.rollback()
continue
if main_id in split_parent_ids:
main.provider_create_claim_token = None
main.provider_create_lease_until = None
await db.commit()
results["image_main_already_split"] = results.get("image_main_already_split", 0) + 1
continue
now = _now()
lease_until = ensure_aware_utc(main.provider_create_lease_until)
lease_alive = bool(main.provider_create_claim_token and lease_until and lease_until > now)
if lease_alive:
await db.rollback()
results["image_main_claim_alive"] = results.get("image_main_claim_alive", 0) + 1
continue
if _is_expired(main.deadline_at, now):
main.provider_create_claim_token = None
main.provider_create_lease_until = None
await mark_chat_generation_task_failed_and_refund_once(
db,
task=main,
error_message="图片批量生成任务超时",
pipeline_stage=ChatGenerationPipelineStage.TIMEOUT.value,
)
await db.commit()
results["image_main_timeout"] = results.get("image_main_timeout", 0) + 1
continue
claim_expired = False
if main.provider_create_claim_token or main.provider_create_lease_until:
main.provider_create_claim_token = None
main.provider_create_lease_until = None
main.pipeline_stage = ChatGenerationPipelineStage.QUEUED.value
claim_expired = True
attempt_no = int(main.generation_attempt_no or 1)
await db.commit()
if claim_expired:
await log_task_event(
main,
event_type=ChatGenerationTaskEventType.IMAGE_MAIN_CLAIM_EXPIRED.value,
message="图片主任务供应商执行租约已过期,恢复重新投递",
)
try:
chatapi_create_generation_task.apply_async(
args=[main_id],
kwargs={"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": attempt_no},
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
countdown=0,
)
results["recover_image_main_create"] = results.get("recover_image_main_create", 0) + 1
except Exception as exc:
logger.exception("恢复投递图片主任务失败 task_id=%s: %s", main_id, exc)
results["recover_image_main_enqueue_failed"] = results.get("recover_image_main_enqueue_failed", 0) + 1
if len(image_main_ids) < image_main_batch_size:
break
image_main_results = await recover_image_main_create_tasks_once(db)
for key, value in image_main_results.items():
results[f"image_main_{key}"] = value
due_poll_ids = await redis_get_due_registry_ids(
zset_key=settings.POLL_ACTIVE_REDIS_ZSET_KEY,
@@ -868,7 +952,7 @@ async def recover_generation_tasks_once(db: AsyncSession) -> dict[str, Any]:
break
# 子任务可能在 worker 中断前已进入终态但主任务尚未汇总,按稳定游标完整重算全部主任务。
from app.services.generation.ai.task_group_service import aggregate_main_task_status
from app.services.generation.ai.task_group_service import aggregate_main_tasks_status_batch
reconciled = 0
main_cursor: str | None = None
while True:
@@ -882,11 +966,10 @@ async def recover_generation_tasks_once(db: AsyncSession) -> dict[str, Any]:
parent_ids = list(main_result.scalars().all())
if not parent_ids:
break
for parent_task_id in parent_ids:
main_cursor = str(parent_task_id)
await aggregate_main_task_status(db, parent_task_id=str(parent_task_id))
await db.commit()
reconciled += 1
main_cursor = str(parent_ids[-1])
await aggregate_main_tasks_status_batch(db, parent_task_ids=[str(value) for value in parent_ids])
await db.commit()
reconciled += len(parent_ids)
if len(parent_ids) < batch_size:
break
if reconciled:
@@ -938,6 +1021,9 @@ async def recover_stale_create_tasks_once(db: AsyncSession) -> dict[str, Any]:
)
task_ids = [str(value) for value in result.scalars().all()]
counts: dict[str, int] = {}
image_main_counts = await recover_image_main_create_tasks_once(db)
for key, value in image_main_counts.items():
counts[f"image_main_{key}"] = value
for task_id in task_ids:
task = await _load_chat_task_for_update(db, task_id)
if task is None:
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
import json
from datetime import datetime
from typing import Any
from typing import Any, Awaitable, Callable
from fastapi import HTTPException
from sqlalchemy import String, cast, func, or_, select
@@ -874,7 +874,13 @@ async def submit_image_prompt_optimize(
return project, step
async def run_image_prompt_optimize(db: AsyncSession, *, project_id: str, step_id: str | None = None) -> ModuleGenerationStep | None:
async def run_image_prompt_optimize(
db: AsyncSession,
*,
project_id: str,
step_id: str | None = None,
execution_guard: Callable[[], Awaitable[None]] | None = None,
) -> ModuleGenerationStep | None:
await apply_short_lock_timeout(db)
project_result = await db.execute(
select(ModuleGenerationProject)
@@ -966,6 +972,8 @@ async def run_image_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
references=references,
gen_type="image",
)
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
db,
project_id=project_id_value,
@@ -1029,6 +1037,8 @@ async def run_image_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
raise
except Exception as exc:
await db.rollback()
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
db,
project_id=project_id_value,
@@ -1227,7 +1237,13 @@ async def submit_video_prompt_optimize(
return project, step
async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_id: str | None = None) -> ModuleGenerationStep | None:
async def run_video_prompt_optimize(
db: AsyncSession,
*,
project_id: str,
step_id: str | None = None,
execution_guard: Callable[[], Awaitable[None]] | None = None,
) -> ModuleGenerationStep | None:
await apply_short_lock_timeout(db)
project_result = await db.execute(
select(ModuleGenerationProject)
@@ -1336,6 +1352,8 @@ async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
step_id=step_id_value,
trace_id=f"hot-video-prompt:{step_id_value}",
)
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
db,
project_id=project_id_value,
@@ -1402,6 +1420,8 @@ async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
raise
except Exception as exc:
await db.rollback()
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
db,
project_id=project_id_value,
@@ -1,29 +1,28 @@
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Iterable
from celery import current_task
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.common import ModuleStepStatusEnum
from app.enums.celery_queue import CeleryQueue
from app.enums.celery_runtime import CeleryRuntimeDomain
from app.enums.hot_opening_replicate import HotOpeningStepCodeEnum, ModuleCodeEnum as HotModuleCodeEnum
from app.enums.shot_replicate import (
ModuleCodeEnum as ShotModuleCodeEnum,
ShotAnalysisStatusEnum,
ShotReplicateStepCodeEnum,
ShotSegmentAnalysisStatusEnum,
ShotSplitStatusEnum,
)
from app.models.module_generation_project import ModuleGenerationProject
from app.models.module_generation_step import ModuleGenerationStep
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.shot_replicate_task_set import ShotReplicateTaskSet
from app.services.redis_registry_service import (
datetime_to_epoch,
redis_acquire_lock,
redis_get_due_registry_ids,
redis_get_registry_payloads,
redis_postpone_registry_item,
@@ -32,26 +31,20 @@ from app.services.redis_registry_service import (
redis_upsert_registry_item,
utc_now,
)
from app.services.celery_runtime.runtime_service import CeleryRuntimeLease, RuntimeIdentity, runtime_lock_values
from app.tasks.celery_app import celery_app
logger = logging.getLogger("video_gen")
QUEUE_CREATE = "gen_chatapi_create"
QUEUE_DOWNLOAD = "gen_result_download"
QUEUE_CREATE = CeleryQueue.GEN_CHATAPI_CREATE.value
OBJECT_MODULE_STEP = "module_step"
OBJECT_SHOT_TASK_SET_ANALYSIS = "shot_task_set_analysis"
OBJECT_SHOT_SEGMENT_ANALYSIS = "shot_segment_analysis"
OBJECT_SHOT_SPLIT_SEGMENT = "shot_split_segment"
TASK_HOT_IMAGE_PROMPT = "hot_opening.start_image_prompt_optimize"
TASK_HOT_VIDEO_PROMPT = "hot_opening.start_video_prompt_optimize"
TASK_SHOT_IMAGE_PROMPT = "shot_replicate.start_image_prompt_optimize"
TASK_SHOT_VIDEO_PROMPT = "shot_replicate.start_video_prompt_optimize"
TASK_MODULE_V2_VIDEO_PROMPT = "module_generation_v2.start_video_prompt_optimize"
TASK_SHOT_ANALYZE_ORIGINAL = "shot_replicate.analyze_original_video"
TASK_SHOT_ANALYZE_CUSTOM_SEGMENT = "shot_replicate.analyze_custom_segment_video"
TASK_SHOT_SPLIT_ONE = "shot_replicate.split_one_segment"
HOT_MODULE = HotModuleCodeEnum.HOT_OPENING_REPLICATE.value
SHOT_MODULE = ShotModuleCodeEnum.SHOT_REPLICATE.value
@@ -61,20 +54,6 @@ TERMINAL_STEP_STATUSES = {
ModuleStepStatusEnum.FAILED.value,
ModuleStepStatusEnum.CANCELLED.value,
}
TERMINAL_ANALYSIS_STATUSES = {
ShotAnalysisStatusEnum.COMPLETED.value,
ShotAnalysisStatusEnum.FAILED.value,
}
TERMINAL_SEGMENT_ANALYSIS_STATUSES = {
ShotSegmentAnalysisStatusEnum.COMPLETED.value,
ShotSegmentAnalysisStatusEnum.FAILED.value,
ShotSegmentAnalysisStatusEnum.NOT_REQUIRED.value,
}
TERMINAL_SPLIT_STATUSES = {
ShotSplitStatusEnum.COMPLETED.value,
ShotSplitStatusEnum.FAILED.value,
}
def _now() -> datetime:
return datetime.now(timezone.utc)
@@ -100,11 +79,6 @@ def _lease_seconds() -> int:
return max(1, int(settings.MODULE_ASYNC_LEASE_SECONDS or 600))
def _shot_analysis_lease_seconds() -> int:
timeout = max(1, int(getattr(settings, "SHOT_ANALYSIS_TIMEOUT_SECONDS", 3600) or 3600))
return max(_lease_seconds(), timeout + 120)
def _queue_timeout_seconds() -> int:
return max(1, int(settings.MODULE_ASYNC_QUEUE_TIMEOUT_SECONDS or 300))
@@ -190,6 +164,15 @@ async def register_active_task(
segment_id=segment_id,
reason=reason,
)
existing = await redis_get_registry_payloads(
hash_key=_hash_key(),
item_ids=[item_id],
log_context="module_async_active",
)
if item_id in existing:
merged = dict(existing[item_id])
merged.update(payload)
payload = merged
await redis_upsert_registry_item(
hash_key=_hash_key(),
zset_key=_zset_key(),
@@ -224,50 +207,6 @@ async def register_module_step_task(
)
async def register_shot_task_set_analysis_task(task_set_id: str) -> str:
return await register_active_task(
object_type=OBJECT_SHOT_TASK_SET_ANALYSIS,
object_id=task_set_id,
task_name=TASK_SHOT_ANALYZE_ORIGINAL,
queue=QUEUE_CREATE,
args=[task_set_id],
module=SHOT_MODULE,
project_id=task_set_id,
task_set_id=task_set_id,
check_after_seconds=_shot_analysis_lease_seconds(),
)
async def register_shot_segment_analysis_task(segment_id: str, *, task_set_id: str | None = None) -> str:
return await register_active_task(
object_type=OBJECT_SHOT_SEGMENT_ANALYSIS,
object_id=segment_id,
task_name=TASK_SHOT_ANALYZE_CUSTOM_SEGMENT,
queue=QUEUE_CREATE,
args=[segment_id],
module=SHOT_MODULE,
project_id=task_set_id,
task_set_id=task_set_id,
segment_id=segment_id,
check_after_seconds=_shot_analysis_lease_seconds(),
)
async def register_shot_split_task(segment_id: str, *, task_set_id: str | None = None) -> str:
return await register_active_task(
object_type=OBJECT_SHOT_SPLIT_SEGMENT,
object_id=segment_id,
task_name=TASK_SHOT_SPLIT_ONE,
queue=QUEUE_DOWNLOAD,
args=[segment_id],
module=SHOT_MODULE,
project_id=task_set_id,
task_set_id=task_set_id,
segment_id=segment_id,
check_after_seconds=max(_lease_seconds(), int(settings.SHOT_SPLIT_LEASE_SECONDS or 600)),
)
async def remove_active_task(*, object_type: str, object_id: str) -> None:
await redis_remove_registry_item(
hash_key=_hash_key(),
@@ -304,85 +243,95 @@ async def postpone_active_task(
async def mark_active_started(*, object_type: str, object_id: str, reason: str = "started") -> None:
delay_seconds = _lease_seconds()
if object_type in {OBJECT_SHOT_TASK_SET_ANALYSIS, OBJECT_SHOT_SEGMENT_ANALYSIS}:
delay_seconds = _shot_analysis_lease_seconds()
elif object_type == OBJECT_SHOT_SPLIT_SEGMENT:
delay_seconds = max(_lease_seconds(), int(settings.SHOT_SPLIT_LEASE_SECONDS or 600))
await postpone_active_task(
object_type=object_type,
object_id=object_id,
delay_seconds=delay_seconds,
delay_seconds=_lease_seconds(),
reason=reason,
)
_OBJECT_LEASES: dict[str, CeleryRuntimeLease] = {}
def _current_task_metadata() -> tuple[str, str, str | None]:
task = current_task
task_name = str(getattr(task, "name", "") or "module_async.unknown")
request = getattr(task, "request", None)
delivery = getattr(request, "delivery_info", None) or {}
queue = str(delivery.get("routing_key") or delivery.get("exchange") or QUEUE_CREATE)
celery_task_id = str(getattr(request, "id", "") or "") or None
return task_name, queue, celery_task_id
async def acquire_object_lock(*, object_type: str, object_id: str) -> str | None:
return await redis_acquire_lock(
task_name, queue, celery_task_id = _current_task_metadata()
token = uuid.uuid4().hex
lease = await CeleryRuntimeLease.acquire(
identity=RuntimeIdentity(
domain=CeleryRuntimeDomain.MODULE_ASYNC.value,
owner_type=object_type,
owner_id=object_id,
attempt_no=1,
task_name=task_name,
queue=queue,
registry_item_id=_item_id(object_type, object_id),
),
token=token,
lock_key=_lock_key(object_type, object_id),
ttl_seconds=int(settings.MODULE_ASYNC_LOCK_TTL_SECONDS or _lease_seconds()),
hash_key=_hash_key(),
zset_key=_zset_key(),
ttl_seconds=max(60, int(settings.MODULE_ASYNC_LOCK_TTL_SECONDS or _lease_seconds())),
heartbeat_interval_seconds=max(10, min(30, int(settings.REDIS_EXECUTION_LOCK_RENEW_INTERVAL_SECONDS or 30))),
pipeline_stage="processing",
extra_payload={
"object_type": object_type,
"object_id": object_id,
"celery_task_id": celery_task_id,
},
)
if lease is None:
return None
_OBJECT_LEASES[token] = lease
return token
async def ensure_object_lock_owned(*, token: str | None) -> None:
if not token:
raise RuntimeError("module async execution token is missing")
lease = _OBJECT_LEASES.get(token)
if lease is None:
raise RuntimeError("module async execution lease is unavailable")
await lease.ensure_owned()
async def release_object_lock(*, object_type: str, object_id: str, token: str | None) -> None:
if not token:
return
lease = _OBJECT_LEASES.pop(token, None)
if lease is not None:
await lease.close()
return
await redis_release_lock(
lock_key=_lock_key(object_type, object_id),
token=token,
log_context="module_async_object_lock",
)
async def release_object_lock(*, object_type: str, object_id: str, token: str | None) -> None:
if token:
await redis_release_lock(
lock_key=_lock_key(object_type, object_id),
token=token,
log_context="module_async_object_lock",
)
async def cleanup_active_if_terminal(db: AsyncSession, *, object_type: str, object_id: str) -> bool:
if object_type == OBJECT_MODULE_STEP:
result = await db.execute(
select(ModuleGenerationStep)
.where(ModuleGenerationStep.id == object_id)
.limit(1)
)
step = result.scalar_one_or_none()
if not step or step.deleted_at is not None or step.status in TERMINAL_STEP_STATUSES:
await remove_active_task(object_type=object_type, object_id=object_id)
return True
return False
if object_type == OBJECT_SHOT_TASK_SET_ANALYSIS:
result = await db.execute(
select(ShotReplicateTaskSet)
.where(ShotReplicateTaskSet.id == object_id)
.limit(1)
)
task_set = result.scalar_one_or_none()
if not task_set or task_set.deleted_at is not None or task_set.analysis_status in TERMINAL_ANALYSIS_STATUSES:
await remove_active_task(object_type=object_type, object_id=object_id)
return True
return False
if object_type == OBJECT_SHOT_SEGMENT_ANALYSIS:
result = await db.execute(
select(ShotReplicateSegment)
.where(ShotReplicateSegment.id == object_id)
.limit(1)
)
segment = result.scalar_one_or_none()
if not segment or segment.deleted_at is not None or segment.analysis_status in TERMINAL_SEGMENT_ANALYSIS_STATUSES:
await remove_active_task(object_type=object_type, object_id=object_id)
return True
return False
if object_type == OBJECT_SHOT_SPLIT_SEGMENT:
result = await db.execute(
select(ShotReplicateSegment)
.where(ShotReplicateSegment.id == object_id)
.limit(1)
)
segment = result.scalar_one_or_none()
if not segment or segment.deleted_at is not None or segment.split_status in TERMINAL_SPLIT_STATUSES:
await remove_active_task(object_type=object_type, object_id=object_id)
return True
return False
if object_type != OBJECT_MODULE_STEP:
await remove_active_task(object_type=object_type, object_id=object_id)
return True
result = await db.execute(
select(ModuleGenerationStep)
.where(ModuleGenerationStep.id == object_id)
.limit(1)
)
step = result.scalar_one_or_none()
if not step or step.deleted_at is not None or step.status in TERMINAL_STEP_STATUSES:
await remove_active_task(object_type=object_type, object_id=object_id)
return True
return False
@@ -390,13 +339,8 @@ def _payload_args(payload: dict[str, Any]) -> list[Any]:
args = payload.get("args")
if isinstance(args, list):
return args
object_type = str(payload.get("object_type") or "")
if object_type == OBJECT_MODULE_STEP:
if str(payload.get("object_type") or "") == OBJECT_MODULE_STEP:
return [payload.get("project_id"), payload.get("step_id")]
if object_type == OBJECT_SHOT_TASK_SET_ANALYSIS:
return [payload.get("task_set_id") or payload.get("object_id")]
if object_type in {OBJECT_SHOT_SEGMENT_ANALYSIS, OBJECT_SHOT_SPLIT_SEGMENT}:
return [payload.get("segment_id") or payload.get("object_id")]
return []
@@ -429,25 +373,9 @@ async def _recover_payload_from_redis(db: AsyncSession, item_id: str, payload: d
if await cleanup_active_if_terminal(db, object_type=object_type, object_id=object_id):
return "remove_terminal"
if object_type == OBJECT_SHOT_SPLIT_SEGMENT:
from app.services.shot_replicate_recovery_service import recover_one_split_segment
result = await db.execute(
select(ShotReplicateSegment)
.where(ShotReplicateSegment.id == object_id, ShotReplicateSegment.deleted_at.is_(None))
.with_for_update(skip_locked=True)
.limit(1)
)
segment = result.scalar_one_or_none()
if not segment:
await remove_active_task(object_type=object_type, object_id=object_id)
return "remove_missing_split_segment"
action = await recover_one_split_segment(db, segment, source="redis_active")
if action.startswith("recover_"):
await postpone_active_task(object_type=object_type, object_id=object_id, delay_seconds=int(settings.SHOT_SPLIT_LEASE_SECONDS or _lease_seconds()), reason="redis_recovered")
elif action.startswith("skip_completed") or action.startswith("skip_failed") or action.startswith("mark_failed"):
await remove_active_task(object_type=object_type, object_id=object_id)
return f"split_{action}"
if object_type != OBJECT_MODULE_STEP:
await remove_active_task(object_type=object_type, object_id=object_id)
return "remove_legacy_non_module_payload"
try:
_send_task(task_name, args=args, queue=queue, countdown=0, priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER)
@@ -468,26 +396,63 @@ async def _recover_due_redis_items(db: AsyncSession, *, limit: int) -> dict[str,
)
if not item_ids:
return {}
payloads = await redis_get_registry_payloads(hash_key=_hash_key(), item_ids=item_ids, log_context="module_async_active")
payloads = await redis_get_registry_payloads(
hash_key=_hash_key(),
item_ids=item_ids,
log_context="module_async_active",
)
valid_payloads: dict[str, dict[str, Any]] = {}
step_ids: set[str] = set()
results: dict[str, int] = {}
for item_id in item_ids:
payload = payloads.get(item_id)
if not payload:
await redis_remove_registry_item(hash_key=_hash_key(), zset_key=_zset_key(), item_id=item_id, log_context="module_async_active")
action = "remove_missing_payload"
else:
action = await _recover_payload_from_redis(db, item_id, payload)
await redis_remove_registry_item(
hash_key=_hash_key(), zset_key=_zset_key(), item_id=item_id, log_context="module_async_active"
)
results["remove_missing_payload"] = results.get("remove_missing_payload", 0) + 1
continue
object_type = str(payload.get("object_type") or "")
object_id = str(payload.get("object_id") or "")
if object_type != OBJECT_MODULE_STEP or not object_id:
await redis_remove_registry_item(
hash_key=_hash_key(), zset_key=_zset_key(), item_id=item_id, log_context="module_async_active"
)
results["remove_legacy_non_module_payload"] = results.get("remove_legacy_non_module_payload", 0) + 1
continue
valid_payloads[item_id] = payload
step_ids.add(object_id)
step_map: dict[str, ModuleGenerationStep] = {}
if step_ids:
step_result = await db.execute(
select(ModuleGenerationStep).where(ModuleGenerationStep.id.in_(step_ids))
)
step_map = {str(step.id): step for step in step_result.scalars().all()}
lock_keys = [_lock_key(OBJECT_MODULE_STEP, step_id) for step_id in step_ids]
lock_values = await runtime_lock_values(lock_keys)
for item_id, payload in valid_payloads.items():
object_id = str(payload.get("object_id") or "")
step = step_map.get(object_id)
if not step or step.deleted_at is not None or step.status in TERMINAL_STEP_STATUSES:
await remove_active_task(object_type=OBJECT_MODULE_STEP, object_id=object_id)
results["remove_terminal"] = results.get("remove_terminal", 0) + 1
continue
if lock_values.get(_lock_key(OBJECT_MODULE_STEP, object_id)):
await postpone_active_task(
object_type=OBJECT_MODULE_STEP,
object_id=object_id,
delay_seconds=_lease_seconds(),
reason="live_runtime_lock",
)
results["skip_live_step_lock"] = results.get("skip_live_step_lock", 0) + 1
continue
action = await _recover_payload_from_redis(db, item_id, payload)
results[action] = results.get(action, 0) + 1
return results
def _is_stale_datetime(value: datetime | None, *, seconds: int, now: datetime) -> bool:
checked = _ensure_aware(value)
if checked is None:
return True
return checked + timedelta(seconds=max(1, int(seconds))) <= now
async def _recover_stale_module_steps(db: AsyncSession, *, limit: int) -> dict[str, int]:
now = _now()
stale_cutoff = now - timedelta(seconds=_lease_seconds())
@@ -527,7 +492,13 @@ async def _recover_stale_module_steps(db: AsyncSession, *, limit: int) -> dict[s
for project_id, flow_version in project_result.all()
}
results: dict[str, int] = {}
dispatches: list[tuple[str, str, str, str, str]] = []
lock_keys = [_lock_key(OBJECT_MODULE_STEP, str(step.id)) for step in steps]
live_locks = await runtime_lock_values(lock_keys)
for step in steps:
if live_locks.get(_lock_key(OBJECT_MODULE_STEP, str(step.id))):
results["skip_live_step_lock"] = results.get("skip_live_step_lock", 0) + 1
continue
if project_flow_map.get(step.project_id, "v1") == "v2" and step.step_code == HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value:
task_name = TASK_MODULE_V2_VIDEO_PROMPT
elif step.module == HOT_MODULE and step.step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value:
@@ -542,78 +513,23 @@ async def _recover_stale_module_steps(db: AsyncSession, *, limit: int) -> dict[s
results["skip_unknown_step"] = results.get("skip_unknown_step", 0) + 1
continue
dispatches.append((task_name, step.module, step.project_id, step.id, step.step_code))
await db.commit()
for task_name, module, project_id, step_id, step_code in dispatches:
await register_module_step_task(
module=step.module,
project_id=step.project_id,
step_id=step.id,
step_code=step.step_code,
module=module,
project_id=project_id,
step_id=step_id,
step_code=step_code,
task_name=task_name,
queue=QUEUE_CREATE,
)
try:
_send_task(task_name, args=[step.project_id, step.id], queue=QUEUE_CREATE, countdown=0, priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER)
_send_task(task_name, args=[project_id, step_id], queue=QUEUE_CREATE, countdown=0, priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER)
results["db_step_requeued"] = results.get("db_step_requeued", 0) + 1
except Exception:
logger.exception("DB fallback 恢复模块步骤失败。step_id=%s", step.id)
logger.exception("DB fallback 恢复模块步骤失败。step_id=%s", step_id)
results["db_step_requeue_failed"] = results.get("db_step_requeue_failed", 0) + 1
await db.commit()
return results
async def _recover_stale_shot_task_sets(db: AsyncSession, *, limit: int) -> dict[str, int]:
now = _now()
stale_cutoff = now - timedelta(seconds=_lease_seconds())
result = await db.execute(
select(ShotReplicateTaskSet)
.where(
ShotReplicateTaskSet.deleted_at.is_(None),
ShotReplicateTaskSet.analysis_status.in_([ShotAnalysisStatusEnum.PENDING.value, ShotAnalysisStatusEnum.PROCESSING.value]),
ShotReplicateTaskSet.updated_at <= stale_cutoff,
)
.order_by(ShotReplicateTaskSet.updated_at.asc())
.limit(limit)
.with_for_update(skip_locked=True)
)
task_sets = list(result.scalars().all())
results: dict[str, int] = {}
for task_set in task_sets:
await register_shot_task_set_analysis_task(task_set.id)
try:
_send_task(TASK_SHOT_ANALYZE_ORIGINAL, args=[task_set.id], queue=QUEUE_CREATE, countdown=0, priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER)
results["db_task_set_analysis_requeued"] = results.get("db_task_set_analysis_requeued", 0) + 1
except Exception:
logger.exception("DB fallback 恢复拆镜原视频分析失败。task_set_id=%s", task_set.id)
results["db_task_set_analysis_requeue_failed"] = results.get("db_task_set_analysis_requeue_failed", 0) + 1
await db.commit()
return results
async def _recover_stale_shot_segment_analysis(db: AsyncSession, *, limit: int) -> dict[str, int]:
now = _now()
stale_cutoff = now - timedelta(seconds=_lease_seconds())
result = await db.execute(
select(ShotReplicateSegment)
.where(
ShotReplicateSegment.deleted_at.is_(None),
ShotReplicateSegment.segment_video_url.is_not(None),
ShotReplicateSegment.analysis_status.in_([ShotSegmentAnalysisStatusEnum.PENDING.value, ShotSegmentAnalysisStatusEnum.PROCESSING.value]),
ShotReplicateSegment.updated_at <= stale_cutoff,
)
.order_by(ShotReplicateSegment.updated_at.asc())
.limit(limit)
.with_for_update(skip_locked=True)
)
segments = list(result.scalars().all())
results: dict[str, int] = {}
for segment in segments:
await register_shot_segment_analysis_task(segment.id, task_set_id=segment.task_set_id)
try:
_send_task(TASK_SHOT_ANALYZE_CUSTOM_SEGMENT, args=[segment.id], queue=QUEUE_CREATE, countdown=0, priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER)
results["db_segment_analysis_requeued"] = results.get("db_segment_analysis_requeued", 0) + 1
except Exception:
logger.exception("DB fallback 恢复拆镜片段分析失败。segment_id=%s", segment.id)
results["db_segment_analysis_requeue_failed"] = results.get("db_segment_analysis_requeue_failed", 0) + 1
await db.commit()
return results
@@ -623,7 +539,11 @@ def _merge_counts(target: dict[str, int], items: Iterable[tuple[str, int]]) -> N
async def recover_module_async_tasks_once(db: AsyncSession) -> dict[str, Any]:
"""统一恢复模块异步任务。\n\n 覆盖范围:\n - hot_opening / shot_replicate 的图片、视频 AI 提词步骤;\n - shot_replicate 原视频分析;\n - shot_replicate 自定义片段分析;\n - shot_replicate split active 注册项。\n\n 拆镜 split 的 DB fallback 仍保留在 shot_replicate_recovery_service\n 这里主要补 Redis active 恢复和非 split 类任务的 DB fallback。\n """
"""恢复爆款开头与拆镜复刻的短 LLM 提词步骤。
长视频分析与 FFmpeg 切片分别由独立队列和恢复服务负责,
避免多套恢复链路重复投递同一业务任务。
"""
batch_size = max(1, int(settings.MODULE_ASYNC_RECOVERY_BATCH_SIZE or 100))
results: dict[str, int] = {}
@@ -633,10 +553,4 @@ async def recover_module_async_tasks_once(db: AsyncSession) -> dict[str, Any]:
step_results = await _recover_stale_module_steps(db, limit=batch_size)
_merge_counts(results, step_results.items())
task_set_results = await _recover_stale_shot_task_sets(db, limit=batch_size)
_merge_counts(results, task_set_results.items())
segment_results = await _recover_stale_shot_segment_analysis(db, limit=batch_size)
_merge_counts(results, segment_results.items())
return {"checked": sum(results.values()), "results": results}
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any
from typing import Any, Awaitable, Callable
from fastapi import HTTPException
from sqlalchemy import select
@@ -730,6 +730,7 @@ async def run_video_prompt_optimize_v2(
*,
project_id: str,
step_id: str,
execution_guard: Callable[[], Awaitable[None]] | None = None,
) -> ModuleGenerationStep | None:
try:
meta_result = await execute_with_lock_timeout(
@@ -800,6 +801,8 @@ async def run_video_prompt_optimize_v2(
step_id=project_snapshot["step_id"],
)
if execution_guard is not None:
await execution_guard()
locked = await execute_with_lock_timeout(
db,
select(ModuleGenerationProject, ModuleGenerationStep)
@@ -885,6 +888,8 @@ async def run_video_prompt_optimize_v2(
except Exception as exc:
await db.rollback()
try:
if execution_guard is not None:
await execution_guard()
result = await execute_with_lock_timeout(
db,
select(ModuleGenerationProject, ModuleGenerationStep)
@@ -1,8 +1,9 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
from typing import Any, Awaitable, Callable
from urllib.parse import urlencode
from fastapi import HTTPException
@@ -42,21 +43,26 @@ from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAss
from app.models.upload_resource import UploadResource
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, PrivatePortraitSelectableAssetOut, PrivatePortraitValidateSessionOut
from app.services.operation_log_service import log_operation_error, log_operation_event
from app.services.private_portrait.ark_client import ArkPrivateAssetClient
from app.services.private_portrait.ark_client import ArkPrivateAssetClient, ArkPrivateAssetRemoteError
from app.services.private_portrait.project_service import get_user_project, refresh_project_counters
from app.services.private_portrait.upload_service import private_portrait_upload_module
from app.services.upload_resource import bind_upload_resources, release_upload_resources_by_source
from app.services.upload_resource.path_resolver import upload_url_to_storage_path
from app.services.private_portrait.quota_service import (
count_user_counting_assets,
ensure_private_portrait_asset_quota_available,
get_user_private_portrait_config,
set_user_private_portrait_limit,
)
from app.utils.id_gen import generate_id
DOMAIN = "private_portrait"
def _remote_delete_not_found(exc: BaseException) -> bool:
if isinstance(exc, ArkPrivateAssetRemoteError):
code = str(exc.code or "").lower()
return "notfound" in code or code.startswith("not_found")
message = str(exc).lower()
return "not found" in message or "notfound" in message
PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS = 2
PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS = 15
@@ -577,61 +583,208 @@ async def create_asset(
raise
async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id: str) -> PrivatePortraitAsset:
@dataclass(frozen=True, slots=True)
class PrivatePortraitAssetPollSnapshot:
id: str
user_id: str
project_id: str
remote_asset_id: str
remote_project_name: str
library_type: str
asset_type: str
status: str
poll_count: int
async def _load_asset_poll_snapshot(
db: AsyncSession,
*,
user_id: str | None,
asset_id: str,
) -> PrivatePortraitAssetPollSnapshot:
filters = [PrivatePortraitAsset.id == asset_id]
if user_id is not None:
filters.append(PrivatePortraitAsset.user_id == user_id)
asset = (await db.execute(select(PrivatePortraitAsset).where(*filters).limit(1))).scalar_one_or_none()
asset = (
await db.execute(select(PrivatePortraitAsset).where(*filters).limit(1))
).scalar_one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="私域人像素材不存在")
if asset.deleted_at is not None:
raise HTTPException(status_code=400, detail="私域人像素材已删除")
if not asset.remote_asset_id:
raise HTTPException(status_code=400, detail="私域人像素材尚未创建远程 Asset")
return PrivatePortraitAssetPollSnapshot(
id=str(asset.id),
user_id=str(asset.user_id),
project_id=str(asset.project_id),
remote_asset_id=str(asset.remote_asset_id),
remote_project_name=str(asset.remote_project_name or ""),
library_type=str(asset.library_type or ""),
asset_type=str(asset.asset_type or ""),
status=str(asset.status or ""),
poll_count=int(asset.poll_count or 0),
)
source = PrivatePortraitEventSource.CELERY.value if user_id is None else PrivatePortraitEventSource.API.value
async def _apply_asset_poll_response(
db: AsyncSession,
*,
snapshot: PrivatePortraitAssetPollSnapshot,
remote_resp: dict[str, Any],
source: str,
) -> PrivatePortraitAsset:
asset = (
await db.execute(
select(PrivatePortraitAsset)
.where(PrivatePortraitAsset.id == snapshot.id)
.with_for_update()
.limit(1)
)
).scalar_one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="私域人像素材不存在")
if asset.deleted_at is not None:
raise HTTPException(status_code=400, detail="私域人像素材已删除")
if str(asset.remote_asset_id or "") != snapshot.remote_asset_id:
raise RuntimeError("私域素材远程 Asset 已变化,旧轮询结果已丢弃")
if int(asset.poll_count or 0) != snapshot.poll_count:
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_SYNC_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SKIPPED.value,
source=source,
user_id=asset.user_id,
project_id=asset.project_id,
asset_id=asset.id,
message="检测到更新的轮询结果,当前旧结果已丢弃",
detail={
"snapshot_poll_count": snapshot.poll_count,
"current_poll_count": int(asset.poll_count or 0),
},
)
return asset
status = remote_resp.get("Status") or remote_resp.get("status")
now = datetime.now(timezone.utc)
asset.last_poll_at = now
asset.poll_count = snapshot.poll_count + 1
asset.raw_response_json = _json(remote_resp)
if status:
asset.status = str(status)
asset.remote_url = remote_resp.get("URL") or remote_resp.get("url") or asset.remote_url
asset.moderation_json = _json(remote_resp.get("Moderation") or remote_resp.get("moderation"))
max_count = _poll_max_count(asset.asset_type)
if asset.status == PrivatePortraitAssetStatus.PROCESSING.value and asset.poll_count >= max_count:
asset.status = PrivatePortraitAssetStatus.FAILED.value
asset.error_message = "素材入库轮询超时"
asset.next_poll_at = None
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_POLL_TIMEOUT.value,
event_status=PrivatePortraitEventStatus.FAILED.value,
source=source,
user_id=asset.user_id,
project_id=asset.project_id,
asset_id=asset.id,
detail={
"poll_count": asset.poll_count,
"max_count": max_count,
"remote_asset_id": asset.remote_asset_id,
"library_type": asset.library_type,
"asset_type": asset.asset_type,
},
error=asset.error_message,
)
elif asset.status == PrivatePortraitAssetStatus.PROCESSING.value:
asset.next_poll_at = now + timedelta(seconds=_poll_interval_seconds(asset.asset_type))
else:
asset.next_poll_at = None
if asset.status == PrivatePortraitAssetStatus.FAILED.value and not asset.error_message:
asset.error_message = (
remote_resp.get("ErrorMessage")
or remote_resp.get("error_message")
or "素材入库失败"
)
await refresh_project_counters(db, [asset.project_id])
await db.flush()
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_SYNC_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=source,
user_id=asset.user_id,
project_id=asset.project_id,
asset_id=asset.id,
detail={
"status": asset.status,
"remote_asset_id": asset.remote_asset_id,
"next_poll_at": asset.next_poll_at,
"poll_count": asset.poll_count,
"library_type": asset.library_type,
"asset_type": asset.asset_type,
},
)
return asset
async def sync_asset_status(
db: AsyncSession,
*,
user_id: str | None,
asset_id: str,
execution_guard: Callable[[], Awaitable[None]] | None = None,
) -> PrivatePortraitAsset:
snapshot = await _load_asset_poll_snapshot(db, user_id=user_id, asset_id=asset_id)
source = (
PrivatePortraitEventSource.CELERY.value
if user_id is None
else PrivatePortraitEventSource.API.value
)
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_SYNC_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=source,
user_id=asset.user_id,
project_id=asset.project_id,
asset_id=asset.id,
detail={"status": asset.status, "poll_count": int(asset.poll_count or 0), "remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type},
user_id=snapshot.user_id,
project_id=snapshot.project_id,
asset_id=snapshot.id,
detail={
"status": snapshot.status,
"poll_count": snapshot.poll_count,
"remote_asset_id": snapshot.remote_asset_id,
"remote_project_name": snapshot.remote_project_name,
"library_type": snapshot.library_type,
"asset_type": snapshot.asset_type,
},
)
# 远程调用期间不能持有数据库事务,避免 Celery 长请求形成 idle in transaction。
await db.rollback()
try:
remote_resp = await ArkPrivateAssetClient(for_celery=(user_id is None)).get_asset(project_name=asset.remote_project_name, asset_id=asset.remote_asset_id)
status = remote_resp.get("Status") or remote_resp.get("status")
now = datetime.now(timezone.utc)
asset.last_poll_at = now
asset.poll_count = int(asset.poll_count or 0) + 1
asset.raw_response_json = _json(remote_resp)
if status:
asset.status = status
asset.remote_url = remote_resp.get("URL") or remote_resp.get("url") or asset.remote_url
asset.moderation_json = _json(remote_resp.get("Moderation") or remote_resp.get("moderation"))
max_count = _poll_max_count(asset.asset_type)
if asset.status == PrivatePortraitAssetStatus.PROCESSING.value and asset.poll_count >= max_count:
asset.status = PrivatePortraitAssetStatus.FAILED.value
asset.error_message = "素材入库轮询超时"
asset.next_poll_at = None
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_POLL_TIMEOUT.value, event_status=PrivatePortraitEventStatus.FAILED.value, source=source, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"poll_count": asset.poll_count, "max_count": max_count, "remote_asset_id": asset.remote_asset_id, "library_type": asset.library_type, "asset_type": asset.asset_type}, error=asset.error_message)
elif asset.status == PrivatePortraitAssetStatus.PROCESSING.value:
asset.next_poll_at = now + timedelta(seconds=_poll_interval_seconds(asset.asset_type))
else:
asset.next_poll_at = None
if asset.status == PrivatePortraitAssetStatus.FAILED.value and not asset.error_message:
asset.error_message = remote_resp.get("ErrorMessage") or remote_resp.get("error_message") or "素材入库失败"
await refresh_project_counters(db, [asset.project_id])
await db.flush()
await db.refresh(asset)
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_SYNC_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=source, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"status": asset.status, "remote_asset_id": asset.remote_asset_id, "next_poll_at": asset.next_poll_at, "poll_count": asset.poll_count, "library_type": asset.library_type, "asset_type": asset.asset_type})
return asset
remote_resp = await ArkPrivateAssetClient(for_celery=(user_id is None)).get_asset(
project_name=snapshot.remote_project_name,
asset_id=snapshot.remote_asset_id,
)
if execution_guard is not None:
await execution_guard()
return await _apply_asset_poll_response(
db,
snapshot=snapshot,
remote_resp=remote_resp,
source=source,
)
except Exception as exc:
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_SYNC_FAILED.value, source=source, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, exc=exc)
log_operation_error(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_SYNC_FAILED.value,
source=source,
user_id=snapshot.user_id,
project_id=snapshot.project_id,
asset_id=snapshot.id,
exc=exc,
)
raise
@@ -722,118 +875,372 @@ async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str, li
return asset
async def delete_asset_remote(db: AsyncSession, *, asset_id: str) -> None:
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id).limit(1))).scalar_one_or_none()
@dataclass(frozen=True, slots=True)
class PrivatePortraitRemoteDeleteSnapshot:
owner_id: str
owner_type: str
user_id: str
project_id: str
remote_id: str | None
remote_project_name: str
library_type: str
asset_type: str | None = None
async def _load_asset_delete_snapshot(
db: AsyncSession,
*,
asset_id: str,
) -> PrivatePortraitRemoteDeleteSnapshot | None:
asset = (
await db.execute(
select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id).limit(1)
)
).scalar_one_or_none()
if not asset:
return None
if asset.remote_delete_status in {
PrivatePortraitRemoteDeleteStatus.SUCCESS.value,
PrivatePortraitRemoteDeleteStatus.SKIPPED.value,
}:
return PrivatePortraitRemoteDeleteSnapshot(
owner_id=str(asset.id),
owner_type="asset_terminal",
user_id=str(asset.user_id),
project_id=str(asset.project_id),
remote_id=str(asset.remote_asset_id) if asset.remote_asset_id else None,
remote_project_name=str(asset.remote_project_name or ""),
library_type=str(asset.library_type or ""),
asset_type=str(asset.asset_type or ""),
)
return PrivatePortraitRemoteDeleteSnapshot(
owner_id=str(asset.id),
owner_type="asset",
user_id=str(asset.user_id),
project_id=str(asset.project_id),
remote_id=str(asset.remote_asset_id) if asset.remote_asset_id else None,
remote_project_name=str(asset.remote_project_name or ""),
library_type=str(asset.library_type or ""),
asset_type=str(asset.asset_type or ""),
)
async def _apply_asset_delete_result(
db: AsyncSession,
*,
snapshot: PrivatePortraitRemoteDeleteSnapshot,
succeeded: bool,
skipped: bool = False,
error: BaseException | None = None,
) -> None:
asset = (
await db.execute(
select(PrivatePortraitAsset)
.where(PrivatePortraitAsset.id == snapshot.owner_id)
.with_for_update()
.limit(1)
)
).scalar_one_or_none()
if not asset:
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.SKIPPED.value, source=PrivatePortraitEventSource.CELERY.value, asset_id=asset_id, message="远程删除跳过:本地素材不存在")
return
if not asset.remote_asset_id:
if asset.remote_delete_status in {
PrivatePortraitRemoteDeleteStatus.SUCCESS.value,
PrivatePortraitRemoteDeleteStatus.SKIPPED.value,
}:
return
if str(asset.remote_asset_id or "") != str(snapshot.remote_id or ""):
raise RuntimeError("私域素材远程 Asset 已变化,旧删除结果已丢弃")
now = datetime.now(timezone.utc)
if skipped:
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
asset.remote_delete_error = None
await db.flush()
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SKIPPED.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, message="远程删除跳过:素材没有 remote_asset_id")
return
now = datetime.now(timezone.utc)
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type, "upload_resource_release": {k: v for k, v in getattr(asset, "_upload_resource_release", {}).items() if k != "released_resource_ids"}, "pending_upload_resource_count": len(getattr(asset, "_pending_upload_resource_ids", []))})
try:
await ArkPrivateAssetClient(for_celery=True).delete_asset(project_name=asset.remote_project_name, asset_id=asset.remote_asset_id)
elif succeeded:
asset.status = PrivatePortraitAssetStatus.REMOTE_DELETED.value
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
asset.remote_deleted_at = now
asset.remote_delete_error = None
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type})
except Exception as exc:
else:
asset.status = PrivatePortraitAssetStatus.DELETE_FAILED.value
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value
asset.remote_delete_error = str(exc)
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_FAILED.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, exc=exc)
asset.remote_delete_error = str(error or "远程删除失败")
await db.flush()
async def _delete_asset_group_remote(db: AsyncSession, *, group: PrivatePortraitAssetGroup, client: ArkPrivateAssetClient | None = None) -> None:
if not group.remote_group_id:
async def delete_asset_remote(
db: AsyncSession,
*,
asset_id: str,
execution_guard: Callable[[], Awaitable[None]] | None = None,
) -> None:
snapshot = await _load_asset_delete_snapshot(db, asset_id=asset_id)
if snapshot is None:
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.SKIPPED.value,
source=PrivatePortraitEventSource.CELERY.value,
asset_id=asset_id,
message="远程删除跳过:本地素材不存在",
)
await db.rollback()
return
if snapshot.owner_type == "asset_terminal":
await db.rollback()
return
if not snapshot.remote_id:
await _apply_asset_delete_result(db, snapshot=snapshot, succeeded=False, skipped=True)
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SKIPPED.value,
source=PrivatePortraitEventSource.CELERY.value,
user_id=snapshot.user_id,
project_id=snapshot.project_id,
asset_id=snapshot.owner_id,
message="远程删除跳过:素材没有 remote_asset_id",
)
return
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.CELERY.value,
user_id=snapshot.user_id,
project_id=snapshot.project_id,
asset_id=snapshot.owner_id,
detail={
"remote_asset_id": snapshot.remote_id,
"remote_project_name": snapshot.remote_project_name,
"library_type": snapshot.library_type,
"asset_type": snapshot.asset_type,
},
)
await db.rollback()
remote_error: BaseException | None = None
succeeded = False
try:
await ArkPrivateAssetClient(for_celery=True).delete_asset(
project_name=snapshot.remote_project_name,
asset_id=snapshot.remote_id,
)
succeeded = True
except Exception as exc:
remote_error = exc
succeeded = _remote_delete_not_found(exc)
if execution_guard is not None:
await execution_guard()
await _apply_asset_delete_result(
db,
snapshot=snapshot,
succeeded=succeeded,
error=remote_error,
)
if succeeded:
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.CELERY.value,
user_id=snapshot.user_id,
project_id=snapshot.project_id,
asset_id=snapshot.owner_id,
message=(
"远程资源不存在,按幂等删除成功处理"
if remote_error is not None
else None
),
detail={
"remote_asset_id": snapshot.remote_id,
"remote_project_name": snapshot.remote_project_name,
"library_type": snapshot.library_type,
},
)
else:
assert remote_error is not None
log_operation_error(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_FAILED.value,
source=PrivatePortraitEventSource.CELERY.value,
user_id=snapshot.user_id,
project_id=snapshot.project_id,
asset_id=snapshot.owner_id,
exc=remote_error,
)
async def _load_group_delete_snapshot(
db: AsyncSession,
*,
group_id: str,
) -> PrivatePortraitRemoteDeleteSnapshot | None:
group = (
await db.execute(
select(PrivatePortraitAssetGroup)
.where(PrivatePortraitAssetGroup.id == group_id)
.limit(1)
)
).scalar_one_or_none()
if not group:
return None
owner_type = (
"group_terminal"
if group.remote_delete_status
in {
PrivatePortraitRemoteDeleteStatus.SUCCESS.value,
PrivatePortraitRemoteDeleteStatus.SKIPPED.value,
}
else "group"
)
return PrivatePortraitRemoteDeleteSnapshot(
owner_id=str(group.id),
owner_type=owner_type,
user_id=str(group.user_id),
project_id=str(group.project_id),
remote_id=str(group.remote_group_id) if group.remote_group_id else None,
remote_project_name=str(group.remote_project_name or ""),
library_type=str(group.library_type or ""),
)
async def _apply_group_delete_result(
db: AsyncSession,
*,
snapshot: PrivatePortraitRemoteDeleteSnapshot,
succeeded: bool,
skipped: bool = False,
error: BaseException | None = None,
) -> None:
group = (
await db.execute(
select(PrivatePortraitAssetGroup)
.where(PrivatePortraitAssetGroup.id == snapshot.owner_id)
.with_for_update()
.limit(1)
)
).scalar_one_or_none()
if not group:
return
if group.remote_delete_status in {
PrivatePortraitRemoteDeleteStatus.SUCCESS.value,
PrivatePortraitRemoteDeleteStatus.SKIPPED.value,
}:
return
if str(group.remote_group_id or "") != str(snapshot.remote_id or ""):
raise RuntimeError("私域素材组远程 ID 已变化,旧删除结果已丢弃")
now = datetime.now(timezone.utc)
if skipped:
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
group.remote_delete_error = None
await db.flush()
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SKIPPED.value, source=PrivatePortraitEventSource.CELERY.value, user_id=group.user_id, project_id=group.project_id, group_id=group.id, message="远程删除跳过:素材组没有 remote_group_id")
return
client = client or ArkPrivateAssetClient(for_celery=True)
now = datetime.now(timezone.utc)
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, user_id=group.user_id, project_id=group.project_id, group_id=group.id, detail={"remote_group_id": group.remote_group_id, "remote_project_name": group.remote_project_name, "library_type": group.library_type})
try:
await client.delete_asset_group(project_name=group.remote_project_name, group_id=group.remote_group_id)
elif succeeded:
group.status = PrivatePortraitAssetGroupStatus.REMOTE_DELETED.value
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
group.remote_deleted_at = now
group.remote_delete_error = None
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, user_id=group.user_id, project_id=group.project_id, group_id=group.id, detail={"remote_group_id": group.remote_group_id, "remote_project_name": group.remote_project_name, "library_type": group.library_type})
except Exception as exc:
else:
group.status = PrivatePortraitAssetGroupStatus.DELETE_FAILED.value
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value
group.remote_delete_error = str(exc)
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_FAILED.value, source=PrivatePortraitEventSource.CELERY.value, user_id=group.user_id, project_id=group.project_id, group_id=group.id, exc=exc)
group.remote_delete_error = str(error or "远程删除失败")
await db.flush()
async def delete_project_remote(db: AsyncSession, *, project_id: str) -> None:
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, project_id=project_id, message="开始远程删除私域人像素材项目资源")
rows = await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.project_id == project_id))
for asset in rows.scalars().all():
await delete_asset_remote(db, asset_id=asset.id)
groups = await db.execute(select(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.project_id == project_id))
client = ArkPrivateAssetClient(for_celery=True)
for group in groups.scalars().all():
await _delete_asset_group_remote(db, group=group, client=client)
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, project_id=project_id, message="远程删除私域人像素材项目资源完成")
await db.flush()
async def poll_due_assets_once(db: AsyncSession, *, limit: int) -> int:
now = datetime.now(timezone.utc)
rows = await db.execute(
select(PrivatePortraitAsset.id)
.where(
PrivatePortraitAsset.deleted_at.is_(None),
PrivatePortraitAsset.status == PrivatePortraitAssetStatus.PROCESSING.value,
PrivatePortraitAsset.next_poll_at.is_not(None),
PrivatePortraitAsset.next_poll_at <= now,
async def delete_asset_group_remote(
db: AsyncSession,
*,
group_id: str,
execution_guard: Callable[[], Awaitable[None]] | None = None,
) -> None:
snapshot = await _load_group_delete_snapshot(db, group_id=group_id)
if snapshot is None:
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.SKIPPED.value,
source=PrivatePortraitEventSource.CELERY.value,
group_id=group_id,
message="远程删除跳过:本地素材组不存在",
)
.order_by(PrivatePortraitAsset.next_poll_at.asc())
.limit(limit)
await db.rollback()
return
if snapshot.owner_type == "group_terminal":
await db.rollback()
return
if not snapshot.remote_id:
await _apply_group_delete_result(db, snapshot=snapshot, succeeded=False, skipped=True)
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SKIPPED.value,
source=PrivatePortraitEventSource.CELERY.value,
user_id=snapshot.user_id,
project_id=snapshot.project_id,
group_id=snapshot.owner_id,
message="远程删除跳过:素材组没有 remote_group_id",
)
return
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.CELERY.value,
user_id=snapshot.user_id,
project_id=snapshot.project_id,
group_id=snapshot.owner_id,
detail={
"remote_group_id": snapshot.remote_id,
"remote_project_name": snapshot.remote_project_name,
"library_type": snapshot.library_type,
},
)
ids = [row[0] for row in rows.all()]
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, detail={"limit": limit, "matched_count": len(ids)})
success_count = 0
failed_count = 0
for asset_id in ids:
try:
await sync_asset_status(db, user_id=None, asset_id=asset_id)
success_count += 1
except Exception as exc:
failed_count += 1
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_POLL_FAILED.value, source=PrivatePortraitEventSource.CELERY.value, asset_id=asset_id, exc=exc)
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_DONE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value if failed_count == 0 else PrivatePortraitEventStatus.WARNING.value, source=PrivatePortraitEventSource.CELERY.value, detail={"matched_count": len(ids), "success_count": success_count, "failed_count": failed_count})
return len(ids)
async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[str, int]:
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, detail={"limit": limit})
statuses = [PrivatePortraitRemoteDeleteStatus.PENDING.value, PrivatePortraitRemoteDeleteStatus.FAILED.value]
asset_rows = await db.execute(select(PrivatePortraitAsset.id).where(PrivatePortraitAsset.remote_delete_status.in_(statuses)).order_by(PrivatePortraitAsset.updated_at.asc()).limit(limit))
asset_ids = [row[0] for row in asset_rows.all()]
for asset_id in asset_ids:
await delete_asset_remote(db, asset_id=asset_id)
remaining = max(0, limit - len(asset_ids))
group_count = 0
if remaining > 0:
group_rows = await db.execute(select(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.remote_delete_status.in_(statuses)).order_by(PrivatePortraitAssetGroup.updated_at.asc()).limit(remaining))
client = ArkPrivateAssetClient(for_celery=True)
groups = list(group_rows.scalars().all())
group_count = len(groups)
for group in groups:
await _delete_asset_group_remote(db, group=group, client=client)
result = {"asset_count": len(asset_ids), "group_count": group_count, "total_count": len(asset_ids) + group_count}
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_DONE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, detail=result)
return result
await db.rollback()
remote_error: BaseException | None = None
succeeded = False
try:
await ArkPrivateAssetClient(for_celery=True).delete_asset_group(
project_name=snapshot.remote_project_name,
group_id=snapshot.remote_id,
)
succeeded = True
except Exception as exc:
remote_error = exc
succeeded = _remote_delete_not_found(exc)
if execution_guard is not None:
await execution_guard()
await _apply_group_delete_result(
db,
snapshot=snapshot,
succeeded=succeeded,
error=remote_error,
)
if succeeded:
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.CELERY.value,
user_id=snapshot.user_id,
project_id=snapshot.project_id,
group_id=snapshot.owner_id,
message=(
"远程素材组不存在,按幂等删除成功处理"
if remote_error is not None
else None
),
detail={
"remote_group_id": snapshot.remote_id,
"remote_project_name": snapshot.remote_project_name,
"library_type": snapshot.library_type,
},
)
else:
assert remote_error is not None
log_operation_error(
domain=DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_FAILED.value,
source=PrivatePortraitEventSource.CELERY.value,
user_id=snapshot.user_id,
project_id=snapshot.project_id,
group_id=snapshot.owner_id,
exc=remote_error,
)
@@ -467,6 +467,7 @@ class RedisExecutionLockLease:
_stop_event: asyncio.Event = field(default_factory=asyncio.Event, init=False, repr=False)
_heartbeat_task: asyncio.Task[Any] | None = field(default=None, init=False, repr=False)
_lost_error: RedisExecutionLockError | None = field(default=None, init=False, repr=False)
_closed: bool = field(default=False,init=False,repr=False,)
@classmethod
async def acquire(
@@ -496,7 +497,44 @@ class RedisExecutionLockLease:
lease.start_heartbeat()
return lease
async def __aenter__(self) -> "RedisExecutionLockLease":
"""进入 async with 前确认当前实例仍持有锁。"""
if self._closed:
raise RedisExecutionLockLost(
f"Redis execution lock lease already closed: {self.lock_key}"
)
try:
await self.ensure_owned()
except BaseException:
# __aenter__ 抛异常时,Python 不会调用 __aexit__
# 因此这里必须主动停止 heartbeat 并尝试释放锁。
await self.close()
raise
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
traceback: Any,
) -> bool:
"""退出 async with 时停止 heartbeat 并安全释放锁。
返回 False,确保业务异常继续向上抛出,
不会被锁清理逻辑吞掉。
"""
await self.close()
return False
def start_heartbeat(self) -> None:
if self._closed:
raise RedisExecutionLockLost(
f"Cannot start heartbeat for closed lease: {self.lock_key}"
)
if self._heartbeat_task is not None:
return
self._heartbeat_task = asyncio.create_task(self._heartbeat())
@@ -529,6 +567,11 @@ class RedisExecutionLockLease:
return
async def ensure_owned(self) -> None:
if self._closed:
raise RedisExecutionLockLost(
f"Redis execution lock lease already closed: {self.lock_key}"
)
if self._lost_error is not None:
raise self._lost_error
owned = await redis_check_lock_owner(
@@ -543,11 +586,22 @@ class RedisExecutionLockLease:
raise self._lost_error
async def close(self) -> None:
"""停止 heartbeat,并且只释放自己仍持有的 Redis 锁。"""
if self._closed:
return
self._closed = True
self._stop_event.set()
heartbeat = self._heartbeat_task
self._heartbeat_task = None
if heartbeat is not None:
try:
await heartbeat
except asyncio.CancelledError:
# 上层 event loop 正在结束时允许 heartbeat 被取消。
pass
except Exception:
logger.debug(
"Redis execution lock heartbeat close failed. context=%s key=%s",
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
import json
from datetime import datetime
from typing import Any
from typing import Any, Awaitable, Callable
from fastapi import HTTPException
from sqlalchemy import func, select
@@ -824,7 +824,13 @@ async def submit_image_prompt_optimize(
return project, step
async def run_image_prompt_optimize(db: AsyncSession, *, project_id: str, step_id: str | None = None) -> ModuleGenerationStep | None:
async def run_image_prompt_optimize(
db: AsyncSession,
*,
project_id: str,
step_id: str | None = None,
execution_guard: Callable[[], Awaitable[None]] | None = None,
) -> ModuleGenerationStep | None:
await apply_short_lock_timeout(db)
project_result = await db.execute(
select(ModuleGenerationProject)
@@ -916,6 +922,8 @@ async def run_image_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
references=references,
gen_type="image",
)
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
db,
project_id=project_id_value,
@@ -979,6 +987,8 @@ async def run_image_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
raise
except Exception as exc:
await db.rollback()
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
db,
project_id=project_id_value,
@@ -1187,7 +1197,13 @@ async def submit_video_prompt_optimize(
return project, step
async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_id: str | None = None) -> ModuleGenerationStep | None:
async def run_video_prompt_optimize(
db: AsyncSession,
*,
project_id: str,
step_id: str | None = None,
execution_guard: Callable[[], Awaitable[None]] | None = None,
) -> ModuleGenerationStep | None:
await apply_short_lock_timeout(db)
project_result = await db.execute(
select(ModuleGenerationProject)
@@ -1296,6 +1312,8 @@ async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
step_id=step_id_value,
trace_id=f"shot-video-prompt:{step_id_value}",
)
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
db,
project_id=project_id_value,
@@ -1362,6 +1380,8 @@ async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
raise
except Exception as exc:
await db.rollback()
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
db,
project_id=project_id_value,
@@ -1,19 +1,23 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import logging
from typing import Any
from sqlalchemy import select
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.celery_queue import CeleryQueue
from app.enums.shot_replicate import ShotSplitStatusEnum
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.shot_replicate_task_set import ShotReplicateTaskSet
from app.services.shot_replicate_taskset_service import refresh_task_set_split_summary
from app.services.module_async_recovery_service import register_shot_split_task
from app.services.shot_replicate_taskset_service import refresh_task_set_split_summaries
from app.services.celery_runtime.runtime_service import runtime_lock_values
from app.tasks.celery_app import celery_app
logger = logging.getLogger("video_gen")
def _now() -> datetime:
return datetime.now(timezone.utc)
@@ -41,61 +45,12 @@ def _queue_timeout(segment: ShotReplicateSegment, now: datetime | None = None) -
return enqueued_at + timedelta(seconds=int(settings.SHOT_SPLIT_PENDING_TIMEOUT_SECONDS or 300)) <= (now or _now())
async def recover_one_split_segment(db: AsyncSession, segment: ShotReplicateSegment, *, source: str = "startup_db") -> str:
async def recover_shot_split_tasks_once(db: AsyncSession) -> dict[str, Any]:
"""批量恢复拆镜 ffmpeg 任务;只接管执行锁消失且业务租约到期的记录。"""
from app.tasks.shot_replicate_tasks import split_one_segment
if not segment:
return "skip_missing_segment"
if segment.deleted_at is not None:
return "skip_deleted"
if segment.split_status == ShotSplitStatusEnum.COMPLETED.value:
return "skip_completed"
if segment.split_status == ShotSplitStatusEnum.FAILED.value:
return "skip_failed"
batch_size = max(1, int(settings.SHOT_SPLIT_RECOVERY_BATCH_SIZE or 50))
current_time = _now()
should_recover = False
if segment.split_status == ShotSplitStatusEnum.PENDING.value:
should_recover = _queue_timeout(segment, current_time)
elif segment.split_status == ShotSplitStatusEnum.PROCESSING.value:
should_recover = _expired(segment.split_lease_until, current_time)
elif segment.split_status == ShotSplitStatusEnum.RETRY_WAITING.value:
should_recover = _expired(segment.split_next_retry_at, current_time)
if not should_recover:
return f"skip_{segment.split_status}_not_due"
if int(segment.split_retry_count or 0) >= int(settings.SHOT_SPLIT_MAX_RETRY_COUNT or 3):
segment.split_status = ShotSplitStatusEnum.FAILED.value
segment.split_last_error = segment.split_last_error or f"{source} 恢复时超过最大重试次数"
segment.split_lease_until = None
segment.split_next_retry_at = None
await refresh_task_set_split_summary(db, segment.task_set_id)
await db.commit()
return "mark_failed_max_retry"
segment.split_status = ShotSplitStatusEnum.PENDING.value
segment.split_enqueued_at = current_time
segment.split_lease_until = None
segment.split_next_retry_at = None
await refresh_task_set_split_summary(db, segment.task_set_id)
await db.commit()
await register_shot_split_task(segment.id, task_set_id=segment.task_set_id)
if celery_app:
split_one_segment.apply_async(
args=[segment.id],
queue="gen_result_download",
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
countdown=0,
)
return f"recover_{source}"
async def recover_shot_split_tasks_once(db: AsyncSession) -> dict[str, Any]:
"""拆镜 ffmpeg 任务容灾恢复。独立扫描 shot_replicate_segments,不复用 Chat 下载 active registry。"""
batch_size = int(settings.SHOT_SPLIT_RECOVERY_BATCH_SIZE or 50)
result = await db.execute(
select(ShotReplicateSegment)
.where(
@@ -108,23 +63,236 @@ async def recover_shot_split_tasks_once(db: AsyncSession) -> dict[str, Any]:
]
),
)
.order_by(ShotReplicateSegment.updated_at.asc())
.order_by(ShotReplicateSegment.updated_at.asc(), ShotReplicateSegment.id.asc())
.limit(batch_size)
.with_for_update(skip_locked=True)
)
segments = list(result.scalars().all())
checked = 0
due_segments: list[ShotReplicateSegment] = []
results: dict[str, int] = {}
touched_task_set_ids: set[str] = set()
for segment in segments:
action = await recover_one_split_segment(db, segment, source="startup_db")
checked += 1
touched_task_set_ids.add(segment.task_set_id)
results[action] = results.get(action, 0) + 1
if segment.split_status == ShotSplitStatusEnum.PENDING.value:
due = _queue_timeout(segment, current_time)
elif segment.split_status == ShotSplitStatusEnum.PROCESSING.value:
due = _expired(segment.split_lease_until, current_time)
else:
due = _expired(segment.split_next_retry_at, current_time)
if not due:
key = f"skip_{segment.split_status}_not_due"
results[key] = results.get(key, 0) + 1
continue
due_segments.append(segment)
for task_set_id in touched_task_set_ids:
await refresh_task_set_split_summary(db, task_set_id)
lock_key_by_id: dict[str, str] = {}
for segment in due_segments:
current_attempt = max(0, int(segment.split_retry_count or 0))
active_attempt = (
max(1, current_attempt)
if segment.split_status == ShotSplitStatusEnum.PROCESSING.value
else current_attempt + 1
)
lock_key_by_id[str(segment.id)] = (
f"{settings.SHOT_SPLIT_LOCK_KEY_PREFIX}:{segment.id}:attempt:{active_attempt}"
)
live_locks = await runtime_lock_values(list(lock_key_by_id.values()))
dispatches: list[tuple[str, int]] = []
touched_task_set_ids: set[str] = set()
for segment in due_segments:
lock_key = lock_key_by_id[str(segment.id)]
if live_locks.get(lock_key):
results["skip_live_runtime_lock"] = results.get("skip_live_runtime_lock", 0) + 1
continue
touched_task_set_ids.add(str(segment.task_set_id))
if int(segment.split_retry_count or 0) >= int(settings.SHOT_SPLIT_MAX_RETRY_COUNT or 3):
segment.split_status = ShotSplitStatusEnum.FAILED.value
segment.split_last_error = segment.split_last_error or "恢复时超过最大重试次数"
segment.split_claim_token = None
segment.split_lease_until = None
segment.split_next_retry_at = None
results["mark_failed_max_retry"] = results.get("mark_failed_max_retry", 0) + 1
continue
segment.split_status = ShotSplitStatusEnum.PENDING.value
segment.split_claim_token = None
segment.split_enqueued_at = current_time
segment.split_lease_until = None
segment.split_next_retry_at = None
next_attempt = int(segment.split_retry_count or 0) + 1
dispatches.append((str(segment.id), next_attempt))
results["recover_db"] = results.get("recover_db", 0) + 1
await refresh_task_set_split_summaries(db, touched_task_set_ids)
await db.commit()
return {"checked": checked, "results": results}
enqueue_failed = 0
if celery_app:
for segment_id, next_attempt in dispatches:
try:
split_one_segment.apply_async(
args=[segment_id],
queue=CeleryQueue.GEN_SHOT_SPLIT.value,
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
countdown=0,
task_id=f"shot-split:{segment_id}:attempt:{next_attempt}",
)
except Exception:
enqueue_failed += 1
if enqueue_failed:
results["enqueue_failed"] = enqueue_failed
return {"checked": len(segments), "results": results}
async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]:
"""恢复长视频分析;只接管执行锁消失且数据库租约已到期的记录。"""
from app.enums.shot_replicate import (
ShotAnalysisStatusEnum,
ShotSegmentAnalysisStatusEnum,
ShotTaskSetStatusEnum,
)
from app.services.celery_runtime.runtime_service import runtime_lock_values
from app.tasks.shot_replicate_tasks import analyze_custom_segment_video, analyze_original_video
now = _now()
queue_cutoff = now - timedelta(seconds=max(60, int(settings.MODULE_ASYNC_QUEUE_TIMEOUT_SECONDS or 300)))
batch_size = max(1, int(settings.MODULE_ASYNC_RECOVERY_BATCH_SIZE or 100))
task_set_result = await db.execute(
select(ShotReplicateTaskSet)
.where(
ShotReplicateTaskSet.deleted_at.is_(None),
ShotReplicateTaskSet.analysis_status.in_([
ShotAnalysisStatusEnum.PENDING.value,
ShotAnalysisStatusEnum.PROCESSING.value,
]),
or_(
(
(ShotReplicateTaskSet.analysis_status == ShotAnalysisStatusEnum.PENDING.value)
& (ShotReplicateTaskSet.updated_at <= queue_cutoff)
),
(
(ShotReplicateTaskSet.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value)
& or_(
ShotReplicateTaskSet.analysis_lease_until <= now,
(
ShotReplicateTaskSet.analysis_lease_until.is_(None)
& (ShotReplicateTaskSet.updated_at <= queue_cutoff)
),
)
),
),
)
.order_by(ShotReplicateTaskSet.updated_at.asc(), ShotReplicateTaskSet.id.asc())
.limit(batch_size)
.with_for_update(skip_locked=True)
)
task_sets = list(task_set_result.scalars().all())
remaining = max(0, batch_size - len(task_sets))
segments: list[ShotReplicateSegment] = []
if remaining:
segment_result = await db.execute(
select(ShotReplicateSegment)
.where(
ShotReplicateSegment.deleted_at.is_(None),
ShotReplicateSegment.segment_video_url.is_not(None),
ShotReplicateSegment.analysis_status.in_([
ShotSegmentAnalysisStatusEnum.PENDING.value,
ShotSegmentAnalysisStatusEnum.PROCESSING.value,
]),
or_(
(
(ShotReplicateSegment.analysis_status == ShotSegmentAnalysisStatusEnum.PENDING.value)
& (ShotReplicateSegment.updated_at <= queue_cutoff)
),
(
(ShotReplicateSegment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value)
& or_(
ShotReplicateSegment.analysis_lease_until <= now,
(
ShotReplicateSegment.analysis_lease_until.is_(None)
& (ShotReplicateSegment.updated_at <= queue_cutoff)
),
)
),
),
)
.order_by(ShotReplicateSegment.updated_at.asc(), ShotReplicateSegment.id.asc())
.limit(remaining)
.with_for_update(skip_locked=True)
)
segments = list(segment_result.scalars().all())
lock_keys: list[str] = []
task_set_lock_keys: dict[str, str] = {}
segment_lock_keys: dict[str, str] = {}
for item in task_sets:
attempt = max(1, int(item.analysis_attempt_no or 1))
key = f"{settings.SHOT_ANALYSIS_LOCK_KEY_PREFIX}:shot_task_set:{item.id}:attempt:{attempt}"
task_set_lock_keys[str(item.id)] = key
lock_keys.append(key)
for item in segments:
attempt = max(1, int(item.analysis_attempt_no or 1))
key = f"{settings.SHOT_ANALYSIS_LOCK_KEY_PREFIX}:shot_segment:{item.id}:attempt:{attempt}"
segment_lock_keys[str(item.id)] = key
lock_keys.append(key)
live_locks = await runtime_lock_values(lock_keys)
dispatches: list[tuple[str, str, int]] = []
results: dict[str, int] = {}
for item in task_sets:
key = task_set_lock_keys[str(item.id)]
if live_locks.get(key):
results["skip_live_task_set_lock"] = results.get("skip_live_task_set_lock", 0) + 1
continue
attempt = max(1, int(item.analysis_attempt_no or 1))
item.analysis_status = ShotAnalysisStatusEnum.PENDING.value
item.status = ShotTaskSetStatusEnum.PENDING_ANALYSIS.value
item.analysis_claim_token = None
item.analysis_started_at = None
item.analysis_lease_until = None
dispatches.append(("task_set", str(item.id), attempt))
results["task_set_recovered"] = results.get("task_set_recovered", 0) + 1
for item in segments:
key = segment_lock_keys[str(item.id)]
if live_locks.get(key):
results["skip_live_segment_lock"] = results.get("skip_live_segment_lock", 0) + 1
continue
attempt = max(1, int(item.analysis_attempt_no or 1))
item.analysis_status = ShotSegmentAnalysisStatusEnum.PENDING.value
item.analysis_claim_token = None
item.analysis_started_at = None
item.analysis_lease_until = None
dispatches.append(("segment", str(item.id), attempt))
results["segment_recovered"] = results.get("segment_recovered", 0) + 1
await db.commit()
for owner_type, owner_id, attempt in dispatches:
try:
if owner_type == "task_set":
analyze_original_video.apply_async(
args=[owner_id],
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
countdown=0,
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
task_id=f"shot-analysis:task-set:{owner_id}:attempt:{attempt}",
)
else:
analyze_custom_segment_video.apply_async(
args=[owner_id],
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
countdown=0,
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
task_id=f"shot-analysis:segment:{owner_id}:attempt:{attempt}",
)
results["enqueue_success"] = results.get("enqueue_success", 0) + 1
except Exception:
logger.exception(
"恢复投递拆镜分析失败。owner_type=%s owner_id=%s attempt=%s",
owner_type,
owner_id,
attempt,
)
results["enqueue_failed"] = results.get("enqueue_failed", 0) + 1
return {"checked": len(task_sets) + len(segments), "results": results}
@@ -8,7 +8,8 @@ from typing import Any
from fastapi import HTTPException
from app.config import settings
from sqlalchemy import String, cast, func, or_, select
from app.enums.celery_queue import CeleryQueue
from sqlalchemy import String, case, cast, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.shot_replicate import (
@@ -341,67 +342,96 @@ async def _next_segment_index(db: AsyncSession, task_set_id: str) -> int:
return int(result.scalar() or 0) + 1
async def refresh_task_set_split_summary(db: AsyncSession, task_set_id: str) -> None:
task_set_result = await db.execute(select(ShotReplicateTaskSet).where(ShotReplicateTaskSet.id == task_set_id).with_for_update().limit(1))
task_set = task_set_result.scalar_one_or_none()
if not task_set:
async def refresh_task_set_split_summaries(db: AsyncSession, task_set_ids: set[str] | list[str]) -> None:
ids = sorted({str(item) for item in task_set_ids if item})
if not ids:
return
result = await db.execute(
select(ShotReplicateSegment).where(
ShotReplicateSegment.task_set_id == task_set_id,
task_set_result = await db.execute(
select(ShotReplicateTaskSet)
.where(ShotReplicateTaskSet.id.in_(ids))
.order_by(ShotReplicateTaskSet.id.asc())
.with_for_update()
)
task_sets = list(task_set_result.scalars().all())
if not task_sets:
return
count_result = await db.execute(
select(
ShotReplicateSegment.task_set_id,
func.count(ShotReplicateSegment.id).label("total"),
func.sum(
case(
(ShotReplicateSegment.split_status == ShotSplitStatusEnum.COMPLETED.value, 1),
else_=0,
)
).label("completed"),
func.sum(
case(
(ShotReplicateSegment.split_status == ShotSplitStatusEnum.FAILED.value, 1),
else_=0,
)
).label("failed"),
)
.where(
ShotReplicateSegment.task_set_id.in_(ids),
ShotReplicateSegment.deleted_at.is_(None),
)
.group_by(ShotReplicateSegment.task_set_id)
)
segments = list(result.scalars().all())
total = len(segments)
completed = len([s for s in segments if s.split_status == ShotSplitStatusEnum.COMPLETED.value])
failed = len([s for s in segments if s.split_status == ShotSplitStatusEnum.FAILED.value])
count_map = {
str(row.task_set_id): (int(row.total or 0), int(row.completed or 0), int(row.failed or 0))
for row in count_result.all()
}
task_set.segment_count = total
task_set.completed_segment_count = completed
task_set.failed_segment_count = failed
for task_set in task_sets:
total, completed, failed = count_map.get(str(task_set.id), (0, 0, 0))
task_set.segment_count = total
task_set.completed_segment_count = completed
task_set.failed_segment_count = failed
if total <= 0:
task_set.split_status = ShotSplitStatusEnum.NONE.value
if task_set.analysis_status == ShotAnalysisStatusEnum.COMPLETED.value:
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_COMPLETED.value
return
old_status = task_set.status
old_split_status = task_set.split_status
if total <= 0:
task_set.split_status = ShotSplitStatusEnum.NONE.value
if task_set.analysis_status == ShotAnalysisStatusEnum.COMPLETED.value:
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_COMPLETED.value
elif completed == total:
task_set.split_status = ShotSplitStatusEnum.COMPLETED.value
task_set.status = ShotTaskSetStatusEnum.SPLIT_COMPLETED.value
elif failed == total:
task_set.split_status = ShotSplitStatusEnum.FAILED.value
task_set.status = ShotTaskSetStatusEnum.FAILED.value
elif failed > 0:
task_set.split_status = ShotSplitStatusEnum.FAILED.value
task_set.status = ShotTaskSetStatusEnum.PARTIAL_FAILED.value
else:
task_set.split_status = ShotSplitStatusEnum.PROCESSING.value
task_set.status = ShotTaskSetStatusEnum.SPLITTING.value
old_status = task_set.status
old_split_status = task_set.split_status
if old_status != task_set.status or old_split_status != task_set.split_status:
log_module_event_file(
module=MODULE,
event_type="SHOT_SPLIT_STATUS_CHANGED",
project_id=task_set.id,
user_id=task_set.user_id,
message="拆镜总任务集拆分状态变更",
detail={
"task_set_id": task_set.id,
"from_status": old_status,
"to_status": task_set.status,
"from_split_status": old_split_status,
"to_split_status": task_set.split_status,
"segment_count": total,
"completed_segment_count": completed,
"failed_segment_count": failed,
},
)
if completed == total:
task_set.split_status = ShotSplitStatusEnum.COMPLETED.value
task_set.status = ShotTaskSetStatusEnum.SPLIT_COMPLETED.value
elif failed == total:
task_set.split_status = ShotSplitStatusEnum.FAILED.value
task_set.status = ShotTaskSetStatusEnum.FAILED.value
elif failed > 0:
task_set.split_status = ShotSplitStatusEnum.FAILED.value
task_set.status = ShotTaskSetStatusEnum.PARTIAL_FAILED.value
else:
task_set.split_status = ShotSplitStatusEnum.PROCESSING.value
task_set.status = ShotTaskSetStatusEnum.SPLITTING.value
if old_status != task_set.status or old_split_status != task_set.split_status:
log_module_event_file(
module=MODULE,
event_type="SHOT_SPLIT_STATUS_CHANGED",
project_id=task_set.id,
user_id=task_set.user_id,
message="拆镜总任务集拆分状态变更",
detail={
"task_set_id": task_set.id,
"from_status": old_status,
"to_status": task_set.status,
"from_split_status": old_split_status,
"to_split_status": task_set.split_status,
"segment_count": total,
"completed_segment_count": completed,
"failed_segment_count": failed,
},
)
async def refresh_task_set_split_summary(db: AsyncSession, task_set_id: str) -> None:
await refresh_task_set_split_summaries(db, {task_set_id})
async def create_segments_by_ai(
@@ -582,7 +612,7 @@ async def prepare_retry_split_segment(
"reason": reason,
"source_path": task_set.video_path,
"celery_task_name": "shot_replicate.split_one_segment",
"queue": "gen_result_download",
"queue": CeleryQueue.GEN_SHOT_SPLIT.value,
"status": "pending",
},
)
@@ -658,7 +688,7 @@ async def enqueue_segment_split(segment_id: str, *, countdown: int | None = None
split_one_segment.apply_async(
args=[segment_id],
queue="gen_result_download",
queue=CeleryQueue.GEN_SHOT_SPLIT.value,
countdown=countdown,
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER if recover else settings.DOWNLOAD_TASK_PRIORITY_NORMAL,
)
@@ -1019,6 +1049,10 @@ async def prepare_reanalyze_task_set(
task_set.status = ShotTaskSetStatusEnum.PENDING_ANALYSIS.value
task_set.analysis_status = ShotAnalysisStatusEnum.PENDING.value
task_set.analysis_attempt_no = max(1, int(task_set.analysis_attempt_no or 1)) + 1
task_set.analysis_claim_token = None
task_set.analysis_started_at = None
task_set.analysis_lease_until = None
task_set.analysis_error_message = None
task_set.original_video_content = None
task_set.original_video_category = None
@@ -1076,6 +1110,10 @@ async def prepare_reanalyze_segment(
raise HTTPException(status_code=409, detail="AI 建议片段默认无需单独分析,如确需重跑请传 force=true")
segment.analysis_status = ShotSegmentAnalysisStatusEnum.PENDING.value
segment.analysis_attempt_no = max(1, int(segment.analysis_attempt_no or 1)) + 1
segment.analysis_claim_token = None
segment.analysis_started_at = None
segment.analysis_lease_until = None
segment.analysis_error_message = None
segment.analysis_json = None
segment.original_video_content = None
@@ -1,12 +1,9 @@
from __future__ import annotations
import base64
import json
import mimetypes
import os
import re
from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Literal
import httpx
@@ -15,13 +12,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.model_config import ModelConfig
from app.models.token_usage import TokenUsage
from app.services.upload_video_asset_service import resolve_upload_video_path
from app.services.resource_signed_url_service import build_resource_signed_url
from app.utils.id_gen import generate_id
from app.enums.common import LogEventStatusEnum, LogSourceEnum
from app.enums.shot_replicate import ModuleCodeEnum, ShotReplicateLogEventEnum, ShotReplicateRemoteActionEnum
from app.services.operation_log_service import log_ai_model_event
from app.utils.id_gen import generate_id
AnalysisMode = Literal["full_breakdown", "summary_only"]
@@ -533,9 +529,20 @@ async def analyze_video_for_shot_split(
也不再 fallback 到 SEEDANCE_*,避免拆镜分析走错通道。
"""
trace_id = trace_id or generate_id()
config = await _select_model_config(db)
if not config:
config_row = await _select_model_config(db)
if not config_row:
raise RuntimeError("拆镜分析模型未配置:请先在 model_configs 表启用可用模型")
# 外部调用前转换为纯数据快照,随后释放数据库事务,避免一小时 HTTP 请求期间 idle in transaction。
config = SimpleNamespace(
id=str(config_row.id),
name=str(config_row.name or ""),
provider=str(config_row.provider or ""),
api_base=str(config_row.api_base or ""),
api_key=str(config_row.api_key or ""),
model_name=str(config_row.model_name or ""),
max_tokens=getattr(config_row, "max_tokens", None),
temperature=getattr(config_row, "temperature", None),
)
if not str(config.api_key or "").strip():
raise RuntimeError(f"拆镜分析模型 API Key 为空: model_config_id={config.id}")
if not str(config.api_base or "").strip():
@@ -574,6 +581,9 @@ async def analyze_video_for_shot_split(
}
url = f"{str(config.api_base).rstrip('/')}/chat/completions"
# 释放模型配置和媒体开关查询产生的事务;HTTP 调用期间不占用数据库连接。
await db.rollback()
_log_shot_ai_model_event(
event_type=(
ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_STARTED.value
@@ -713,20 +723,7 @@ async def analyze_video_for_shot_split(
if not token_usage["total_tokens"]:
token_usage["total_tokens"] = token_usage["input_tokens"] + token_usage["output_tokens"]
token_usage_id = generate_id()
db.add(
TokenUsage(
id=token_usage_id,
model_config_id=config.id,
user_id=user_id,
input_tokens=token_usage["input_tokens"],
output_tokens=token_usage["output_tokens"],
total_tokens=token_usage["total_tokens"],
)
)
await db.flush()
token_usage.update({
"token_usage_id": token_usage_id,
"model_config_id": config.id,
"model_config_name": config.name,
"model_provider": config.provider,
@@ -4,6 +4,7 @@ import asyncio
import os
import subprocess
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from app.config import settings
@@ -19,12 +20,10 @@ class ShotSplitResult:
url: str
path: str
file_size_bytes: int
temporary_path: str | None = None
def _date_dir_from_segment_id(segment_id: str) -> str:
# 由调用方更适合按 created_at 传入;这里兜底按当前日期。
from datetime import datetime
return datetime.now().strftime("%Y/%m/%d")
@@ -43,17 +42,21 @@ def split_video_segment(
start_second: float,
end_second: float,
date_dir: str | None = None,
attempt_key: str | None = None,
finalize: bool = False,
) -> ShotSplitResult:
"""使用 ffmpeg 拆出单个视频片段,输出到 storage/uploads/shot_segments。"""
source_path = Path(source_path)
"""执行 ffmpeg 切片。
默认只产出 attempt 专属临时文件;调用方完成 Redis/DB fencing 后再原子移动到正式路径,
避免旧 Worker 在恢复任务接管后覆盖新结果。
"""
source_path = Path(source_path)
if not source_path.exists():
raise RuntimeError(f"ffmpeg 拆镜失败:源视频不存在 {source_path}")
start = max(float(start_second), 0.0)
end = max(float(end_second), 0.0)
duration = end - start
if duration <= 0:
raise RuntimeError(
f"ffmpeg 拆镜失败:非法时间范围 start_second={start_second}, end_second={end_second}"
@@ -62,17 +65,11 @@ def split_video_segment(
date_dir = date_dir or _date_dir_from_segment_id(segment_id)
output_dir = ensure_shot_segment_dir(date_dir)
output_path = output_dir / f"{segment_id}.mp4"
# 注意:
# 不能用 xxx.mp4.part,因为 ffmpeg 会按最后一个扩展名 .part 判断输出格式,导致:
# Unable to choose an output format
# 这里改为 xxx.part.mp4,让 ffmpeg 能识别 mp4 容器。
part_path = output_dir / f"{segment_id}.part.mp4"
suffix = str(attempt_key or "default").replace("/", "_").replace(":", "_")[:80]
part_path = output_dir / f"{segment_id}.{suffix}.part.mp4"
_safe_unlink(part_path)
timeout = int(getattr(settings, "SHOT_FFMPEG_TIMEOUT_SECONDS", 120) or 120)
cmd = [
get_ffmpeg_bin(),
"-y",
@@ -100,19 +97,16 @@ def split_video_segment(
"23",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
# 即使临时文件扩展名未来被改坏,也强制指定 mp4 muxer。
"-f",
"mp4",
str(part_path),
]
@@ -136,20 +130,49 @@ def split_video_segment(
raise RuntimeError(
f"ffmpeg 拆镜失败: {completed.stderr.strip() or completed.stdout.strip()}"
)
if not part_path.exists() or part_path.stat().st_size <= 0:
_safe_unlink(part_path)
raise RuntimeError("ffmpeg 拆镜失败:输出文件为空")
os.replace(part_path, output_path)
if finalize:
os.replace(part_path, output_path)
return ShotSplitResult(
url=build_upload_url_from_path(output_path),
path=str(output_path),
file_size_bytes=output_path.stat().st_size,
temporary_path=None,
)
return ShotSplitResult(
url=build_upload_url_from_path(output_path),
path=str(output_path),
file_size_bytes=output_path.stat().st_size,
file_size_bytes=part_path.stat().st_size,
temporary_path=str(part_path),
)
def finalize_split_result(result: ShotSplitResult) -> ShotSplitResult:
if not result.temporary_path:
return result
temp_path = Path(result.temporary_path)
final_path = Path(result.path)
if not temp_path.exists() or temp_path.stat().st_size <= 0:
raise RuntimeError("ffmpeg 拆镜临时结果不存在或为空")
final_path.parent.mkdir(parents=True, exist_ok=True)
os.replace(temp_path, final_path)
return ShotSplitResult(
url=result.url,
path=str(final_path),
file_size_bytes=final_path.stat().st_size,
temporary_path=None,
)
def cleanup_split_result(result: ShotSplitResult | None) -> None:
if result and result.temporary_path:
_safe_unlink(Path(result.temporary_path))
async def split_video_segment_async(
*,
source_path: str | Path,
@@ -157,12 +180,9 @@ async def split_video_segment_async(
start_second: float,
end_second: float,
date_dir: str | None = None,
attempt_key: str | None = None,
finalize: bool = False,
) -> ShotSplitResult:
"""异步拆镜入口。
ffmpeg 本身是同步阻塞命令,不能直接在 Celery 进程内唯一 event loop 中执行。
这里通过 asyncio.to_thread 跑同步拆镜函数,避免阻塞 asyncpg / Redis / HTTP 等异步任务。
"""
return await asyncio.to_thread(
split_video_segment,
source_path=source_path,
@@ -170,4 +190,6 @@ async def split_video_segment_async(
start_second=start_second,
end_second=end_second,
date_dir=date_dir,
)
attempt_key=attempt_key,
finalize=finalize,
)