410 lines
15 KiB
Python
410 lines
15 KiB
Python
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,
|
|
}
|