celery 容灾升级
This commit is contained in:
@@ -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_id;Windows 使用 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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user