celery 容灾升级

This commit is contained in:
2026-07-22 14:48:29 +08:00
parent 3f1c4063b0
commit 69e7dec807
67 changed files with 6161 additions and 1958 deletions
@@ -0,0 +1,13 @@
from app.services.celery_runtime.runtime_service import (
CeleryRuntimeLease,
RuntimeIdentity,
build_runtime_id,
runtime_lock_exists,
)
__all__ = [
"CeleryRuntimeLease",
"RuntimeIdentity",
"build_runtime_id",
"runtime_lock_exists",
]
@@ -0,0 +1,409 @@
from __future__ import annotations
import time
import json
from datetime import datetime, timezone
from typing import Any
from app.config import settings
from app.enums.celery_runtime import CeleryRuntimeEvent
from app.services.operation_log_service import log_operation_event
from app.services.redis_registry_service import (
RedisExecutionLockUnavailable,
datetime_to_epoch,
get_registry_redis,
)
def _worker_instance_key(worker_instance_id: str) -> str:
return f"{settings.CELERY_RUNTIME_WORKER_INSTANCE_PREFIX}:{worker_instance_id}"
def _worker_name_instances_key(worker_name: str) -> str:
return f"{settings.CELERY_RUNTIME_WORKER_NAME_INSTANCE_ZSET_PREFIX}:{worker_name}"
def _worker_task_set_key(worker_instance_id: str) -> str:
return f"{settings.CELERY_RUNTIME_WORKER_TASK_SET_PREFIX}:{worker_instance_id}"
async def startup_barrier_exists() -> bool:
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable("Redis unavailable while checking startup barrier")
return bool(await redis.exists(settings.CELERY_RUNTIME_STARTUP_BARRIER_KEY))
async def set_startup_barrier() -> None:
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable("Redis unavailable while setting startup barrier")
await redis.set(
settings.CELERY_RUNTIME_STARTUP_BARRIER_KEY,
str(datetime_to_epoch(datetime.now(timezone.utc))),
ex=max(30, int(settings.CELERY_RUNTIME_STARTUP_BARRIER_TTL_SECONDS or 120)),
)
async def clear_startup_barrier() -> None:
redis = await get_registry_redis()
if redis is None:
return
await redis.delete(settings.CELERY_RUNTIME_STARTUP_BARRIER_KEY)
async def guard_periodic_recovery(*, check_global_lock: bool = True) -> dict[str, Any] | None:
if await startup_barrier_exists():
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.RECOVERY_BARRIER_SKIPPED.value,
event_status="skipped",
source="recovery",
detail={"barrier_key": settings.CELERY_RUNTIME_STARTUP_BARRIER_KEY},
)
return {"skipped": "startup_barrier"}
if check_global_lock:
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable("Redis unavailable while checking global recovery lock")
if await redis.exists(settings.CELERY_RUNTIME_GLOBAL_RECOVERY_LOCK_KEY):
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.RECOVERY_BARRIER_SKIPPED.value,
event_status="skipped",
source="recovery",
detail={"global_lock_key": settings.CELERY_RUNTIME_GLOBAL_RECOVERY_LOCK_KEY},
)
return {"skipped": "global_recovery_lock"}
return None
async def garbage_collect_registry_pair(*, hash_key: str, zset_key: str, limit: int = 500) -> dict[str, int]:
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable("Redis unavailable during registry GC")
members = await redis.zrange(zset_key, 0, max(0, int(limit) - 1))
if not members:
return {"checked": 0, "removed": 0}
values = await redis.hmget(hash_key, members)
stale = [str(member) for member, value in zip(members, values) if not value]
if stale:
await redis.zrem(zset_key, *stale)
return {"checked": len(members), "removed": len(stale)}
async def mark_stale_worker_instance_candidates(
*,
worker_name: str,
current_worker_instance_id: str,
supports_targeted_recovery: bool,
emit_duplicate_log: bool = True,
) -> dict[str, Any]:
"""只扫描同一逻辑 Worker 名称下已经失活的旧主实例。
同名存在其他活跃实例时保守跳过,避免滚动发布或错误重名导致互相抢占。
旧实例中的任务只有在业务 String 锁已经不存在时才提前标记为恢复候选;
真正接管仍由各业务恢复服务执行 DB lease/claim/attempt CAS。
"""
if not supports_targeted_recovery:
return {
"skipped": "identity_fallback",
"checked_instances": 0,
"stale_instances": 0,
"marked": 0,
}
normalized_name = str(worker_name or "").strip()
normalized_current = str(current_worker_instance_id or "").strip()
if not normalized_name or not normalized_current:
return {
"skipped": "invalid_identity",
"checked_instances": 0,
"stale_instances": 0,
"marked": 0,
}
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable("Redis unavailable while discovering stale worker instances")
index_key = _worker_name_instances_key(normalized_name)
raw_instances = await redis.zrange(index_key, 0, -1, withscores=True)
instances = [
(str(member), int(float(score)))
for member, score in raw_instances
if str(member) != normalized_current
]
if not instances:
return {
"checked_instances": 0,
"active_instances": 0,
"stale_instances": 0,
"marked": 0,
"live": 0,
"invalid": 0,
}
keys = [_worker_instance_key(instance_id) for instance_id, _ in instances]
values = await redis.mget(keys)
active_instances = [
instance_id
for (instance_id, _), value in zip(instances, values)
if value
]
if active_instances:
if emit_duplicate_log:
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.WORKER_DUPLICATE_NAME.value,
event_status="warning",
source="worker_ready",
detail={
"worker_name": normalized_name,
"current_worker_instance_id": normalized_current,
"other_active_worker_instance_ids": active_instances,
},
)
return {
"skipped": "duplicate_active_worker_name",
"checked_instances": len(instances),
"active_instances": len(active_instances),
"stale_instances": 0,
"marked": 0,
}
# 实例 key 由主进程 heartbeat 独立维护。key 已不存在即可判定旧主实例
# 不再活跃;任务是否可以恢复仍必须继续检查业务 String 锁和 DB fencing。
stale_instances = [
instance_id
for (instance_id, _heartbeat_at), value in zip(instances, values)
if not value
]
totals: dict[str, Any] = {
"checked_instances": len(instances),
"active_instances": 0,
"stale_instances": len(stale_instances),
"checked": 0,
"marked": 0,
"live": 0,
"invalid": 0,
}
for stale_instance_id in stale_instances:
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.WORKER_STALE_INSTANCE_FOUND.value,
event_status="success",
source="worker_ready",
detail={
"worker_name": normalized_name,
"current_worker_instance_id": normalized_current,
"old_worker_instance_id": stale_instance_id,
},
)
result = await mark_worker_instance_runtime_candidates(
old_worker_instance_id=stale_instance_id,
worker_name=normalized_name,
)
for key in ("checked", "marked", "live", "invalid"):
totals[key] += int(result.get(key, 0) or 0)
return totals
async def mark_worker_instance_runtime_candidates(
*,
old_worker_instance_id: str,
worker_name: str | None = None,
) -> dict[str, int]:
"""按旧 Worker 主实例 ID 精准标记失锁任务。"""
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable("Redis unavailable while marking worker recovery candidates")
normalized_instance = str(old_worker_instance_id or "").strip()
if not normalized_instance:
return {"checked": 0, "marked": 0, "live": 0, "invalid": 0}
task_set_key = _worker_task_set_key(normalized_instance)
runtime_ids = [str(value) for value in await redis.smembers(task_set_key)]
if not runtime_ids:
return {"checked": 0, "marked": 0, "live": 0, "invalid": 0}
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.WORKER_INSTANCE_RECOVERY_START.value,
event_status="started",
source="worker_ready",
detail={
"worker_name": worker_name,
"old_worker_instance_id": normalized_instance,
"candidate_count": len(runtime_ids),
},
)
raw_locations = await redis.hmget(settings.CELERY_RUNTIME_LOCATION_HASH_KEY, runtime_ids)
locations: dict[str, dict[str, Any]] = {}
lock_keys: list[str] = []
invalid_ids: list[str] = []
for runtime_id, raw in zip(runtime_ids, raw_locations):
try:
payload = json.loads(raw) if raw else None
except Exception:
payload = None
if (
not isinstance(payload, dict)
or not payload.get("lock_key")
or not payload.get("hash_key")
or not payload.get("zset_key")
or str(payload.get("worker_instance_id") or "") != normalized_instance
):
invalid_ids.append(runtime_id)
continue
locations[runtime_id] = payload
lock_keys.append(str(payload["lock_key"]))
lock_values = await redis.mget(lock_keys) if lock_keys else []
live_by_key = {key: bool(value) for key, value in zip(lock_keys, lock_values)}
now_epoch = datetime_to_epoch(datetime.now(timezone.utc))
marked = 0
live = 0
stale_by_hash: dict[str, list[str]] = {}
for runtime_id, location in locations.items():
lock_key = str(location["lock_key"])
if live_by_key.get(lock_key):
live += 1
continue
stale_by_hash.setdefault(str(location["hash_key"]), []).append(runtime_id)
payload_by_runtime: dict[str, dict[str, Any]] = {}
for hash_key, hash_runtime_ids in stale_by_hash.items():
raw_payloads = await redis.hmget(hash_key, hash_runtime_ids)
for runtime_id, raw_payload in zip(hash_runtime_ids, raw_payloads):
try:
payload = json.loads(raw_payload) if raw_payload else {}
except Exception:
payload = {}
payload_by_runtime[runtime_id] = payload if isinstance(payload, dict) else {}
pipe = redis.pipeline(transaction=False)
for runtime_id, location in locations.items():
lock_key = str(location["lock_key"])
if live_by_key.get(lock_key):
continue
hash_key = str(location["hash_key"])
zset_key = str(location["zset_key"])
payload = payload_by_runtime.get(runtime_id, {})
payload.update(
{
"runtime_state": "recovery_candidate",
"check_at": now_epoch,
"reason": "worker_instance_inactive_lock_missing",
"old_worker_instance_id": normalized_instance,
}
)
pipe.hset(hash_key, runtime_id, json.dumps(payload, ensure_ascii=False, default=str))
pipe.zadd(zset_key, {runtime_id: now_epoch})
marked += 1
if invalid_ids:
pipe.srem(task_set_key, *invalid_ids)
await pipe.execute()
result = {
"checked": len(runtime_ids),
"marked": marked,
"live": live,
"invalid": len(invalid_ids),
}
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.WORKER_INSTANCE_RECOVERY_DONE.value,
event_status="success",
source="worker_ready",
detail={
"worker_name": worker_name,
"old_worker_instance_id": normalized_instance,
**result,
},
)
return result
async def garbage_collect_worker_registry(*, limit: int = 500) -> dict[str, int]:
"""清理 V2 Worker 名称索引和旧实例 Task Set 中的失配成员。"""
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable("Redis unavailable during worker registry GC")
checked_names = 0
checked_instances = 0
removed_instances = 0
removed_task_members = 0
now = int(time.time())
grace = max(
int(settings.CELERY_RUNTIME_WORKER_HEARTBEAT_TTL_SECONDS or 90),
int(settings.CELERY_RUNTIME_WORKER_STALE_GRACE_SECONDS or 120),
)
pattern = f"{settings.CELERY_RUNTIME_WORKER_NAME_INSTANCE_ZSET_PREFIX}:*"
async for raw_index_key in redis.scan_iter(match=pattern, count=max(10, min(limit, 500))):
if checked_names >= limit:
break
checked_names += 1
index_key = str(raw_index_key)
raw_instances = await redis.zrange(index_key, 0, -1, withscores=True)
for raw_instance_id, raw_score in raw_instances:
if checked_instances >= limit:
break
checked_instances += 1
instance_id = str(raw_instance_id)
score = int(float(raw_score))
if now - score < grace:
continue
if await redis.exists(_worker_instance_key(instance_id)):
continue
task_set_key = _worker_task_set_key(instance_id)
runtime_ids = [str(value) for value in await redis.smembers(task_set_key)]
if runtime_ids:
raw_locations = await redis.hmget(settings.CELERY_RUNTIME_LOCATION_HASH_KEY, runtime_ids)
stale_members: list[str] = []
for runtime_id, raw_location in zip(runtime_ids, raw_locations):
try:
location = json.loads(raw_location) if raw_location else None
except Exception:
location = None
if (
not isinstance(location, dict)
or str(location.get("worker_instance_id") or "") != instance_id
):
stale_members.append(runtime_id)
if stale_members:
removed_task_members += int(await redis.srem(task_set_key, *stale_members) or 0)
if int(await redis.scard(task_set_key) or 0) == 0:
await redis.delete(task_set_key)
removed_instances += int(await redis.zrem(index_key, instance_id) or 0)
if int(await redis.zcard(index_key) or 0) == 0:
await redis.delete(index_key)
return {
"checked_names": checked_names,
"checked_instances": checked_instances,
"removed_instances": removed_instances,
"removed_task_members": removed_task_members,
}
@@ -0,0 +1,543 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable, Mapping
from app.config import settings
from app.enums.celery_runtime import CeleryRuntimeEvent, CeleryRuntimeState
from app.services.celery_runtime.worker_service import current_worker_identity
from app.services.operation_log_service import log_operation_event
from app.services.redis_registry_service import (
RedisExecutionLockError,
RedisExecutionLockLost,
RedisExecutionLockUnavailable,
datetime_to_epoch,
get_registry_redis,
)
try:
from redis.exceptions import RedisError
except ImportError: # pragma: no cover
RedisError = RuntimeError # type: ignore[assignment]
logger = logging.getLogger("video_gen")
DbHeartbeat = Callable[[str], Awaitable[bool]]
# KEYS:
# 1 lock, 2 active hash, 3 active zset, 4 worker-instance task set,
# 5 runtime location hash.
#
# ARGV:
# 1 token, 2 lock ttl ms, 3 runtime id, 4 payload, 5 check_at,
# 6 worker task-set ttl seconds, 7 location payload, 8 track worker flag.
_ACQUIRE_SCRIPT = """
if redis.call('exists', KEYS[1]) == 1 then
return 0
end
redis.call('psetex', KEYS[1], ARGV[2], ARGV[1])
redis.call('hset', KEYS[2], ARGV[3], ARGV[4])
redis.call('zadd', KEYS[3], ARGV[5], ARGV[3])
if ARGV[8] == '1' then
redis.call('sadd', KEYS[4], ARGV[3])
redis.call('expire', KEYS[4], ARGV[6])
end
redis.call('hset', KEYS[5], ARGV[3], ARGV[7])
return 1
"""
_HEARTBEAT_SCRIPT = """
if redis.call('get', KEYS[1]) ~= ARGV[1] then
return 0
end
redis.call('pexpire', KEYS[1], ARGV[2])
local merged_payload = ARGV[4]
local current_payload = redis.call('hget', KEYS[2], ARGV[3])
if current_payload then
local current_ok, current_obj = pcall(cjson.decode, current_payload)
local update_ok, update_obj = pcall(cjson.decode, ARGV[4])
if current_ok and update_ok then
for key, value in pairs(update_obj) do
current_obj[key] = value
end
merged_payload = cjson.encode(current_obj)
end
end
redis.call('hset', KEYS[2], ARGV[3], merged_payload)
redis.call('zadd', KEYS[3], ARGV[5], ARGV[3])
if ARGV[8] == '1' then
redis.call('sadd', KEYS[4], ARGV[3])
redis.call('expire', KEYS[4], ARGV[6])
end
redis.call('hset', KEYS[5], ARGV[3], ARGV[7])
return 1
"""
_COMPLETE_SCRIPT = """
if redis.call('get', KEYS[1]) ~= ARGV[1] then
return 0
end
redis.call('del', KEYS[1])
redis.call('hdel', KEYS[2], ARGV[2])
redis.call('zrem', KEYS[3], ARGV[2])
redis.call('srem', KEYS[4], ARGV[2])
redis.call('hdel', KEYS[5], ARGV[2])
return 1
"""
def _now_epoch() -> int:
return datetime_to_epoch(datetime.now(timezone.utc))
def _token_digest(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
def build_runtime_id(domain: str, owner_type: str, owner_id: str, attempt_no: int | None = None) -> str:
attempt = max(1, int(attempt_no or 1))
return f"{domain}:{owner_type}:{owner_id}:attempt:{attempt}"
def _worker_task_set_key(worker_instance_id: str) -> str:
return f"{settings.CELERY_RUNTIME_WORKER_TASK_SET_PREFIX}:{worker_instance_id}"
@dataclass(frozen=True, slots=True)
class RuntimeIdentity:
domain: str
owner_type: str
owner_id: str
attempt_no: int
task_name: str
queue: str
registry_item_id: str | None = None
@property
def runtime_id(self) -> str:
return self.registry_item_id or build_runtime_id(
self.domain,
self.owner_type,
self.owner_id,
self.attempt_no,
)
@dataclass(slots=True)
class CeleryRuntimeLease:
identity: RuntimeIdentity
lock_key: str
hash_key: str
zset_key: str
token: str
ttl_seconds: int
heartbeat_interval_seconds: int
payload: dict[str, Any]
db_heartbeat: DbHeartbeat | None = None
db_heartbeat_grace_seconds: int = 60
_stop_event: asyncio.Event = field(default_factory=asyncio.Event, init=False, repr=False)
_heartbeat_task: asyncio.Task[Any] | None = field(default=None, init=False, repr=False)
_lost_error: RedisExecutionLockError | None = field(default=None, init=False, repr=False)
@classmethod
async def acquire(
cls,
*,
identity: RuntimeIdentity,
lock_key: str,
hash_key: str,
zset_key: str,
token: str,
ttl_seconds: int,
heartbeat_interval_seconds: int,
pipeline_stage: str | None = None,
input_hash: str | None = None,
business_version: int | str | None = None,
extra_payload: Mapping[str, Any] | None = None,
db_heartbeat: DbHeartbeat | None = None,
) -> "CeleryRuntimeLease | None":
redis = await get_registry_redis()
if redis is None:
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.RUNTIME_REDIS_UNAVAILABLE.value,
event_status="failed",
source="celery",
task_id=identity.owner_id,
detail={"runtime_id": identity.runtime_id, "domain": identity.domain},
)
raise RedisExecutionLockUnavailable(f"Redis runtime unavailable: {identity.runtime_id}")
worker = current_worker_identity()
now = _now_epoch()
ttl = max(1, int(ttl_seconds or 60))
heartbeat_interval = max(
1,
min(ttl - 1 if ttl > 1 else 1, int(heartbeat_interval_seconds or 30)),
)
check_at = now + ttl
task_set_ttl = max(
ttl * 2,
int(settings.CELERY_RUNTIME_WORKER_TASK_SET_TTL_SECONDS or 86400),
)
task_set_key = _worker_task_set_key(worker.worker_instance_id)
track_worker = "1" if worker.supports_targeted_recovery else "0"
payload: dict[str, Any] = {
"runtime_schema_version": int(settings.CELERY_RUNTIME_SCHEMA_VERSION or 2),
"runtime_id": identity.runtime_id,
"domain": identity.domain,
"task_name": identity.task_name,
"queue": identity.queue,
"owner_type": identity.owner_type,
"owner_id": identity.owner_id,
"attempt_no": identity.attempt_no,
"business_version": business_version,
"input_hash": input_hash,
"worker_name": worker.worker_name,
"worker_instance_id": worker.worker_instance_id,
"worker_main_pid": worker.worker_main_pid,
"execution_pid": worker.execution_pid,
"execution_thread_id": worker.execution_thread_id,
"worker_started_at": worker.started_at,
"worker_identity_quality": worker.identity_quality,
"supports_targeted_recovery": worker.supports_targeted_recovery,
"host": worker.host,
"host_boot_id": worker.host_boot_id,
"celery_task_id": worker.celery_task_id,
"lock_key": lock_key,
"lock_token_digest": _token_digest(token),
"runtime_state": CeleryRuntimeState.ACTIVE.value,
"pipeline_stage": pipeline_stage,
"started_at": now,
"heartbeat_at": now,
"lease_until": check_at,
"check_at": check_at,
"recovery_count": 0,
}
if extra_payload:
payload["extra"] = dict(extra_payload)
location_payload = json.dumps(
{
"runtime_schema_version": int(settings.CELERY_RUNTIME_SCHEMA_VERSION or 2),
"runtime_id": identity.runtime_id,
"domain": identity.domain,
"hash_key": hash_key,
"zset_key": zset_key,
"lock_key": lock_key,
"worker_name": worker.worker_name,
"worker_instance_id": worker.worker_instance_id,
"supports_targeted_recovery": worker.supports_targeted_recovery,
},
ensure_ascii=False,
default=str,
)
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.RUNTIME_ACQUIRE_START.value,
event_status="started",
source="celery",
task_id=identity.owner_id,
detail={
"runtime_id": identity.runtime_id,
"worker_instance_id": worker.worker_instance_id,
"execution_pid": worker.execution_pid,
},
)
try:
acquired = await redis.eval(
_ACQUIRE_SCRIPT,
5,
lock_key,
hash_key,
zset_key,
task_set_key,
settings.CELERY_RUNTIME_LOCATION_HASH_KEY,
token,
ttl * 1000,
identity.runtime_id,
json.dumps(payload, ensure_ascii=False, default=str),
check_at,
task_set_ttl,
location_payload,
track_worker,
)
except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc:
logger.exception("Celery runtime acquire failed. runtime_id=%s", identity.runtime_id)
raise RedisExecutionLockUnavailable(
f"Redis runtime acquire failed: {identity.runtime_id}: {exc}"
) from exc
if not acquired:
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.RUNTIME_LOCK_HELD.value,
event_status="skipped",
source="celery",
task_id=identity.owner_id,
detail={"runtime_id": identity.runtime_id, "lock_key": lock_key},
)
return None
lease = cls(
identity=identity,
lock_key=lock_key,
hash_key=hash_key,
zset_key=zset_key,
token=token,
ttl_seconds=ttl,
heartbeat_interval_seconds=heartbeat_interval,
payload=payload,
db_heartbeat=db_heartbeat,
db_heartbeat_grace_seconds=max(60, heartbeat_interval * 2),
)
lease._heartbeat_task = asyncio.create_task(lease._heartbeat_loop())
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.RUNTIME_ACQUIRED.value,
event_status="success",
source="celery",
task_id=identity.owner_id,
detail={
"runtime_id": identity.runtime_id,
"domain": identity.domain,
"attempt_no": identity.attempt_no,
"worker_instance_id": worker.worker_instance_id,
"worker_main_pid": worker.worker_main_pid,
"execution_pid": worker.execution_pid,
"lock_token_suffix": token[-8:],
"lease_until": check_at,
},
)
return lease
async def _heartbeat_loop(self) -> None:
while not self._stop_event.is_set():
try:
await asyncio.wait_for(
self._stop_event.wait(),
timeout=self.heartbeat_interval_seconds,
)
return
except asyncio.TimeoutError:
pass
try:
await self._heartbeat_once()
except RedisExecutionLockError as exc:
self._lost_error = exc
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.RUNTIME_HEARTBEAT_LOST.value,
event_status="failed",
source="celery",
task_id=self.identity.owner_id,
detail={"runtime_id": self.identity.runtime_id, "error": str(exc)},
error=str(exc),
)
return
except Exception as exc:
self._lost_error = RedisExecutionLockUnavailable(str(exc))
logger.exception(
"Celery runtime heartbeat failed. runtime_id=%s",
self.identity.runtime_id,
)
return
async def _heartbeat_once(self) -> None:
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable(
f"Redis runtime heartbeat unavailable: {self.identity.runtime_id}"
)
now = _now_epoch()
check_at = now + self.ttl_seconds
payload = dict(self.payload)
payload.update(
{
"heartbeat_at": now,
"lease_until": check_at,
"check_at": check_at,
# threads 模式下线程 ID可能随下一次执行变化,但同一 lease 生命周期固定。
"execution_pid": self.payload.get("execution_pid"),
"execution_thread_id": self.payload.get("execution_thread_id"),
}
)
worker_instance_id = str(payload.get("worker_instance_id") or "")
task_set_key = _worker_task_set_key(worker_instance_id)
task_set_ttl = max(
self.ttl_seconds * 2,
int(settings.CELERY_RUNTIME_WORKER_TASK_SET_TTL_SECONDS or 86400),
)
track_worker = "1" if bool(payload.get("supports_targeted_recovery")) else "0"
location_payload = json.dumps(
{
"runtime_schema_version": int(settings.CELERY_RUNTIME_SCHEMA_VERSION or 2),
"runtime_id": self.identity.runtime_id,
"domain": self.identity.domain,
"hash_key": self.hash_key,
"zset_key": self.zset_key,
"lock_key": self.lock_key,
"worker_name": payload.get("worker_name"),
"worker_instance_id": worker_instance_id,
"supports_targeted_recovery": bool(payload.get("supports_targeted_recovery")),
},
ensure_ascii=False,
default=str,
)
try:
renewed = await redis.eval(
_HEARTBEAT_SCRIPT,
5,
self.lock_key,
self.hash_key,
self.zset_key,
task_set_key,
settings.CELERY_RUNTIME_LOCATION_HASH_KEY,
self.token,
self.ttl_seconds * 1000,
self.identity.runtime_id,
json.dumps(payload, ensure_ascii=False, default=str),
check_at,
task_set_ttl,
location_payload,
track_worker,
)
except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc:
raise RedisExecutionLockUnavailable(
f"Redis runtime heartbeat failed: {self.identity.runtime_id}: {exc}"
) from exc
if not renewed:
raise RedisExecutionLockLost(
f"Redis runtime ownership lost: {self.identity.runtime_id}"
)
self.payload = payload
if self.db_heartbeat is not None:
started_at = int(self.payload.get("started_at") or now)
if now - started_at < self.db_heartbeat_grace_seconds:
return
owned = await self.db_heartbeat(self.token)
if not owned:
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.RUNTIME_DB_LEASE_LOST.value,
event_status="failed",
source="celery",
task_id=self.identity.owner_id,
detail={"runtime_id": self.identity.runtime_id},
)
raise RedisExecutionLockLost(
f"Database lease ownership lost: {self.identity.runtime_id}"
)
async def ensure_owned(self) -> None:
if self._lost_error is not None:
raise self._lost_error
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable(
f"Redis runtime unavailable: {self.identity.runtime_id}"
)
try:
value = await redis.get(self.lock_key)
except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc:
raise RedisExecutionLockUnavailable(
f"Redis runtime check failed: {self.identity.runtime_id}: {exc}"
) from exc
if str(value or "") != self.token:
self._lost_error = RedisExecutionLockLost(
f"Redis runtime ownership lost: {self.identity.runtime_id}"
)
raise self._lost_error
async def close(self) -> None:
self._stop_event.set()
if self._heartbeat_task is not None:
try:
await self._heartbeat_task
except Exception:
logger.debug("runtime heartbeat close failed", exc_info=True)
redis = await get_registry_redis()
if redis is None:
return
worker_instance_id = str(self.payload.get("worker_instance_id") or "")
task_set_key = _worker_task_set_key(worker_instance_id)
cleaned = False
try:
cleaned = bool(
await redis.eval(
_COMPLETE_SCRIPT,
5,
self.lock_key,
self.hash_key,
self.zset_key,
task_set_key,
settings.CELERY_RUNTIME_LOCATION_HASH_KEY,
self.token,
self.identity.runtime_id,
)
)
except Exception:
logger.warning(
"Celery runtime cleanup failed. runtime_id=%s",
self.identity.runtime_id,
exc_info=True,
)
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.RUNTIME_COMPLETED.value,
event_status="success" if cleaned else "skipped",
source="celery",
task_id=self.identity.owner_id,
detail={
"runtime_id": self.identity.runtime_id,
"ownership_cleanup": cleaned,
},
)
async def runtime_lock_exists(lock_key: str) -> bool:
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable(f"Redis unavailable while checking lock: {lock_key}")
try:
return bool(await redis.exists(lock_key))
except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc:
raise RedisExecutionLockUnavailable(f"Redis lock check failed: {lock_key}: {exc}") from exc
async def runtime_lock_values(lock_keys: list[str]) -> dict[str, str | None]:
"""批量读取执行锁;Redis 不可用时 fail-closed。"""
if not lock_keys:
return {}
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable("Redis unavailable while batch checking runtime locks")
try:
values = await redis.mget(lock_keys)
except (RedisError, OSError, RuntimeError, TypeError, ValueError) as exc:
raise RedisExecutionLockUnavailable(f"Redis batch lock check failed: {exc}") from exc
return {
lock_key: (str(value) if value not in (None, "") else None)
for lock_key, value in zip(lock_keys, values)
}
@@ -0,0 +1,477 @@
from __future__ import annotations
import ctypes
import json
import os
import socket
import threading
import time
import uuid
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from typing import Any
from app.config import settings
from app.enums.celery_runtime import CeleryRuntimeEvent, WorkerIdentityQuality
from app.services.operation_log_service import log_operation_event
from app.services.redis_registry_service import (
RedisExecutionLockUnavailable,
get_registry_redis,
)
try:
from celery import current_task
except Exception: # pragma: no cover
current_task = None # type: ignore[assignment]
_PROCESS_STARTED_AT = int(time.time())
_PROCESS_INSTANCE_ID = uuid.uuid4().hex
_PROCESS_IDENTITY_LOCK = threading.RLock()
_WORKER_REGISTRATION_LOCK = threading.RLock()
_HEARTBEAT_THROTTLE_LOCK = threading.RLock()
_ENV_INSTANCE_TOKEN = "CELERY_WORKER_INSTANCE_TOKEN"
_ENV_MAIN_PID = "CELERY_WORKER_MAIN_PID"
_ENV_STARTED_AT = "CELERY_WORKER_STARTED_AT"
_ENV_HOST_BOOT_ID = "CELERY_HOST_BOOT_ID"
_ENV_WORKER_NAME = "CELERY_WORKER_NODE_NAME"
_ENV_BEFORE_POOL = "CELERY_WORKER_IDENTITY_BEFORE_POOL"
_registered_worker: "WorkerRegistration | None" = None
_last_heartbeat_attempt_monotonic = 0.0
_last_stale_scan_attempt_monotonic = 0.0
_heartbeat_failure_count = 0
def _parse_optional_int(value: Any) -> int | None:
raw = str(value or "").strip()
if not raw:
return None
try:
parsed = int(raw)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def _parse_optional_float(value: Any) -> float | None:
raw = str(value or "").strip()
if not raw:
return None
try:
return float(raw)
except (TypeError, ValueError):
return None
@lru_cache(maxsize=1)
def _boot_id() -> str:
"""返回同一次操作系统启动期间稳定的主机启动标识。
Linux 直接读取内核 boot_idWindows 使用 GetTickCount64 估算启动时间并
生成 UUID5。无法获取时返回 unknown-boot。该字段仅用于辅助诊断,不参与
任务锁或数据库 fencing 的最终正确性判断。
"""
linux_path = Path("/proc/sys/kernel/random/boot_id")
try:
value = linux_path.read_text(encoding="utf-8").strip()
if value:
return value
except Exception:
pass
if os.name == "nt":
try:
uptime_ms = int(ctypes.windll.kernel32.GetTickCount64()) # type: ignore[attr-defined]
# 以 10 秒为粒度消除多个进程独立计算时的亚秒抖动。
boot_epoch_bucket = int((time.time() - uptime_ms / 1000.0) // 10 * 10)
source = f"windows-boot:{socket.gethostname()}:{boot_epoch_bucket}"
return str(uuid.uuid5(uuid.NAMESPACE_OID, source))
except Exception:
pass
return "unknown-boot"
def initialize_worker_main_identity(
worker_name: str | None = None,
*,
before_pool: bool,
) -> None:
"""在 Celery Worker 主进程中初始化一次实例 token。
before_pool=True 必须由 celeryd_init/worker_init 调用,以保证 Linux prefork
子进程通过 fork 继承相同 token。worker_ready 只允许做 late fallback,且
late fallback 会关闭实例级精准恢复,避免主进程与已创建子进程身份不一致。
"""
normalized_name = str(worker_name or "").strip()
current_pid = os.getpid()
should_log = False
with _PROCESS_IDENTITY_LOCK:
existing_pid = _parse_optional_int(os.getenv(_ENV_MAIN_PID))
existing_token = str(os.getenv(_ENV_INSTANCE_TOKEN, "") or "").strip()
existing_before_pool = str(os.getenv(_ENV_BEFORE_POOL, "") or "").strip() == "1"
if not existing_token or existing_pid != current_pid:
os.environ[_ENV_INSTANCE_TOKEN] = uuid.uuid4().hex
os.environ[_ENV_MAIN_PID] = str(current_pid)
os.environ[_ENV_STARTED_AT] = str(int(time.time()))
os.environ[_ENV_HOST_BOOT_ID] = _boot_id()
os.environ[_ENV_BEFORE_POOL] = "1" if before_pool else "0"
should_log = True
elif before_pool and not existing_before_pool:
# celeryd_init 与 worker_init 都可能触发,幂等提升为 before-pool。
os.environ[_ENV_BEFORE_POOL] = "1"
should_log = True
if normalized_name:
os.environ[_ENV_WORKER_NAME] = normalized_name
_process_identity_base.cache_clear()
if should_log:
identity = current_worker_identity(worker_name_override=normalized_name or None)
log_operation_event(
domain="celery_runtime",
event_type=(
CeleryRuntimeEvent.WORKER_IDENTITY_INITIALIZED.value
if identity.supports_targeted_recovery
else CeleryRuntimeEvent.WORKER_IDENTITY_FALLBACK.value
),
event_status="success" if identity.supports_targeted_recovery else "warning",
source="worker_init",
detail={
"worker_name": identity.worker_name,
"worker_instance_id": identity.worker_instance_id,
"worker_main_pid": identity.worker_main_pid,
"execution_pid": identity.execution_pid,
"host_boot_id": identity.host_boot_id,
"identity_quality": identity.identity_quality,
"supports_targeted_recovery": identity.supports_targeted_recovery,
"before_pool": before_pool,
},
)
def reset_process_identity_cache() -> None:
"""prefork 子进程启动后清理 fork 前的 Python 对象缓存。
环境变量中的主 Worker token 保留;每个子进程重新构建自己的执行 PID 和
线程 ID,不会生成新的 Worker 实例 token。
"""
_process_identity_base.cache_clear()
@dataclass(frozen=True, slots=True)
class _ProcessIdentityBase:
worker_instance_token: str
worker_main_pid: int | None
worker_started_at: int
host_boot_id: str
identity_quality: str
supports_targeted_recovery: bool
@lru_cache(maxsize=1)
def _process_identity_base() -> _ProcessIdentityBase:
token = str(os.getenv(_ENV_INSTANCE_TOKEN, "") or "").strip()
before_pool = str(os.getenv(_ENV_BEFORE_POOL, "") or "").strip() == "1"
worker_main_pid = _parse_optional_int(os.getenv(_ENV_MAIN_PID))
started_at = _parse_optional_float(os.getenv(_ENV_STARTED_AT))
host_boot_id = str(os.getenv(_ENV_HOST_BOOT_ID, "") or "").strip() or _boot_id()
supports_targeted_recovery = bool(token and before_pool)
if not token:
token = _PROCESS_INSTANCE_ID
execution_pid = os.getpid()
if not supports_targeted_recovery:
quality = WorkerIdentityQuality.FALLBACK.value
elif worker_main_pid is None or host_boot_id == "unknown-boot":
quality = WorkerIdentityQuality.INSTANCE_TOKEN_ONLY.value
else:
quality = WorkerIdentityQuality.FULL.value
return _ProcessIdentityBase(
worker_instance_token=token,
worker_main_pid=worker_main_pid,
worker_started_at=int(started_at or _PROCESS_STARTED_AT),
host_boot_id=host_boot_id,
identity_quality=quality,
supports_targeted_recovery=supports_targeted_recovery,
)
@dataclass(frozen=True, slots=True)
class WorkerIdentity:
worker_name: str
worker_instance_id: str
worker_instance_token: str
host: str
host_boot_id: str
worker_main_pid: int | None
execution_pid: int
execution_thread_id: int
started_at: int
identity_quality: str
supports_targeted_recovery: bool
celery_task_id: str | None = None
def current_worker_identity(*, worker_name_override: str | None = None) -> WorkerIdentity:
hostname = socket.gethostname()
celery_task_id: str | None = None
request_worker_name = ""
try:
request = getattr(current_task, "request", None)
request_worker_name = str(getattr(request, "hostname", "") or "").strip()
celery_task_id = str(getattr(request, "id", "") or "").strip() or None
except Exception:
pass
worker_name = (
str(worker_name_override or "").strip()
or request_worker_name
or str(os.getenv(_ENV_WORKER_NAME, "") or "").strip()
or hostname
)
base = _process_identity_base()
instance_id = f"{worker_name}:{base.host_boot_id}:{base.worker_instance_token}"
return WorkerIdentity(
worker_name=worker_name,
worker_instance_id=instance_id,
worker_instance_token=base.worker_instance_token,
host=hostname,
host_boot_id=base.host_boot_id,
worker_main_pid=base.worker_main_pid,
execution_pid=os.getpid(),
execution_thread_id=threading.get_ident(),
started_at=base.worker_started_at,
identity_quality=base.identity_quality,
supports_targeted_recovery=base.supports_targeted_recovery,
celery_task_id=celery_task_id,
)
@dataclass(frozen=True, slots=True)
class WorkerRegistration:
identity: WorkerIdentity
queues: tuple[str, ...] = field(default_factory=tuple)
pool_type: str | None = None
configured_concurrency: int | None = None
def payload(self, *, heartbeat_at: int) -> dict[str, Any]:
return {
"runtime_schema_version": int(settings.CELERY_RUNTIME_SCHEMA_VERSION or 2),
"worker_name": self.identity.worker_name,
"worker_instance_id": self.identity.worker_instance_id,
"host": self.identity.host,
"host_boot_id": self.identity.host_boot_id,
"worker_main_pid": self.identity.worker_main_pid,
"started_at": self.identity.started_at,
"identity_quality": self.identity.identity_quality,
"supports_targeted_recovery": self.identity.supports_targeted_recovery,
"queues": list(self.queues),
"pool_type": self.pool_type,
"configured_concurrency": self.configured_concurrency,
"heartbeat_at": heartbeat_at,
}
def _worker_instance_key(worker_instance_id: str) -> str:
return f"{settings.CELERY_RUNTIME_WORKER_INSTANCE_PREFIX}:{worker_instance_id}"
def _worker_name_instances_key(worker_name: str) -> str:
return f"{settings.CELERY_RUNTIME_WORKER_NAME_INSTANCE_ZSET_PREFIX}:{worker_name}"
def _normalize_queues(values: Any) -> tuple[str, ...]:
if values is None:
return ()
if isinstance(values, str):
values = [values]
result: list[str] = []
try:
iterator = iter(values)
except TypeError:
return ()
for value in iterator:
name = str(getattr(value, "name", value) or "").strip()
if name and name not in result:
result.append(name)
return tuple(result)
async def register_worker_instance(
*,
worker_name: str,
queues: Any = None,
pool_type: str | None = None,
configured_concurrency: int | None = None,
) -> WorkerIdentity:
"""注册 Worker 主实例;Redis 不可用时不阻塞 Worker 启动。"""
global _registered_worker, _last_heartbeat_attempt_monotonic
normalized_name = str(worker_name or "").strip() or socket.gethostname()
os.environ[_ENV_WORKER_NAME] = normalized_name
identity = current_worker_identity(worker_name_override=normalized_name)
registration = WorkerRegistration(
identity=identity,
queues=_normalize_queues(queues),
pool_type=str(pool_type or "").strip() or None,
configured_concurrency=_parse_optional_int(configured_concurrency),
)
with _WORKER_REGISTRATION_LOCK:
_registered_worker = registration
now = int(time.time())
redis = await get_registry_redis()
if redis is None:
raise RedisExecutionLockUnavailable("Redis unavailable while registering Celery worker instance")
ttl = max(30, int(settings.CELERY_RUNTIME_WORKER_HEARTBEAT_TTL_SECONDS or 90))
index_ttl = max(ttl * 2, int(settings.CELERY_RUNTIME_WORKER_TASK_SET_TTL_SECONDS or 86400))
payload = json.dumps(registration.payload(heartbeat_at=now), ensure_ascii=False, default=str)
pipe = redis.pipeline(transaction=False)
pipe.set(_worker_instance_key(identity.worker_instance_id), payload, ex=ttl)
pipe.zadd(_worker_name_instances_key(identity.worker_name), {identity.worker_instance_id: now})
pipe.expire(_worker_name_instances_key(identity.worker_name), index_ttl)
await pipe.execute()
with _HEARTBEAT_THROTTLE_LOCK:
_last_heartbeat_attempt_monotonic = time.monotonic()
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.WORKER_REGISTERED.value,
event_status="success",
source="worker_ready",
detail=registration.payload(heartbeat_at=now),
)
return identity
def claim_worker_heartbeat_slot() -> bool:
"""对 Celery heartbeat_sent 信号做本进程节流。"""
global _last_heartbeat_attempt_monotonic
interval = max(5, int(settings.CELERY_RUNTIME_WORKER_HEARTBEAT_INTERVAL_SECONDS or 30))
now = time.monotonic()
with _HEARTBEAT_THROTTLE_LOCK:
if now - _last_heartbeat_attempt_monotonic < interval:
return False
_last_heartbeat_attempt_monotonic = now
return True
def claim_worker_stale_scan_slot() -> bool:
"""限制同一 Worker 主进程的旧实例扫描频率。"""
global _last_stale_scan_attempt_monotonic
interval = max(30, int(settings.CELERY_RUNTIME_WORKER_STALE_SCAN_INTERVAL_SECONDS or 120))
now = time.monotonic()
with _HEARTBEAT_THROTTLE_LOCK:
if now - _last_stale_scan_attempt_monotonic < interval:
return False
_last_stale_scan_attempt_monotonic = now
return True
async def heartbeat_current_worker_instance() -> bool:
"""刷新 Worker 主实例 TTL;不刷新任何业务任务锁。"""
global _heartbeat_failure_count
with _WORKER_REGISTRATION_LOCK:
registration = _registered_worker
if registration is None:
return False
redis = await get_registry_redis()
if redis is None:
_heartbeat_failure_count += 1
_log_worker_heartbeat_failure_if_needed(registration, "redis_unavailable")
return False
now = int(time.time())
ttl = max(30, int(settings.CELERY_RUNTIME_WORKER_HEARTBEAT_TTL_SECONDS or 90))
index_ttl = max(ttl * 2, int(settings.CELERY_RUNTIME_WORKER_TASK_SET_TTL_SECONDS or 86400))
payload = json.dumps(registration.payload(heartbeat_at=now), ensure_ascii=False, default=str)
try:
pipe = redis.pipeline(transaction=False)
pipe.set(_worker_instance_key(registration.identity.worker_instance_id), payload, ex=ttl)
pipe.zadd(
_worker_name_instances_key(registration.identity.worker_name),
{registration.identity.worker_instance_id: now},
)
pipe.expire(_worker_name_instances_key(registration.identity.worker_name), index_ttl)
await pipe.execute()
except Exception as exc:
_heartbeat_failure_count += 1
_log_worker_heartbeat_failure_if_needed(registration, str(exc))
return False
_heartbeat_failure_count = 0
return True
def _log_worker_heartbeat_failure_if_needed(registration: WorkerRegistration, error: str) -> None:
threshold = max(1, int(settings.CELERY_RUNTIME_WORKER_HEARTBEAT_FAILURE_LOG_THRESHOLD or 3))
if _heartbeat_failure_count != threshold and _heartbeat_failure_count % (threshold * 5) != 0:
return
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.WORKER_HEARTBEAT_LOST.value,
event_status="failed",
source="worker_heartbeat",
detail={
"worker_name": registration.identity.worker_name,
"worker_instance_id": registration.identity.worker_instance_id,
"failure_count": _heartbeat_failure_count,
"error": error,
},
error=error,
)
async def unregister_current_worker_instance() -> None:
"""优雅退出时删除活跃实例 key,保留名称索引供旧任务精准恢复。"""
global _registered_worker
with _WORKER_REGISTRATION_LOCK:
registration = _registered_worker
_registered_worker = None
if registration is None:
return
redis = await get_registry_redis()
if redis is not None:
try:
await redis.delete(_worker_instance_key(registration.identity.worker_instance_id))
except Exception:
pass
log_operation_event(
domain="celery_runtime",
event_type=CeleryRuntimeEvent.WORKER_SHUTDOWN.value,
event_status="success",
source="worker_shutdown",
detail={
"worker_name": registration.identity.worker_name,
"worker_instance_id": registration.identity.worker_instance_id,
"worker_main_pid": registration.identity.worker_main_pid,
},
)
def registered_worker_identity() -> WorkerIdentity | None:
with _WORKER_REGISTRATION_LOCK:
return _registered_worker.identity if _registered_worker is not None else None