已合并
feat(service): 增加锁、缓存与资源装配能力 #413
m0u55e创建于 8月7日
feat(service): 增加锁、缓存与资源装配能力 #413
已合并
共 31 个文件变更+4709-54
| @@ -0,0 +1 @@ | |||
| 1 | +"""Runnable examples for openjiuwen-runtime-service.""" | ||
| @@ -0,0 +1,330 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Service context capability example backed by MySQL, Redis, and etcd.""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +import asyncio | ||
| 9 | +import time | ||
| 10 | +from collections.abc import AsyncIterator, Mapping | ||
| 11 | +from datetime import datetime, timezone | ||
| 12 | +from typing import Any | ||
| 13 | + | ||
| 14 | +from pydantic import BaseModel, ConfigDict, EmailStr, Field | ||
| 15 | +from sqlalchemy import text | ||
| 16 | +from sqlalchemy.exc import IntegrityError | ||
| 17 | + | ||
| 18 | +from openjiuwen_runtime.foundation.db.table_def import ( | ||
| 19 | + ColumnDefinition, | ||
| 20 | + TableDefinition, | ||
| 21 | +) | ||
| 22 | +from openjiuwen_runtime.service import ( | ||
| 23 | + App, | ||
| 24 | + Envelope, | ||
| 25 | + ErrorCode, | ||
| 26 | + FrameworkError, | ||
| 27 | + LockBackendUnavailable, | ||
| 28 | + LockLost, | ||
| 29 | + NotFoundError, | ||
| 30 | + ServiceConfig, | ||
| 31 | + SystemContext, | ||
| 32 | + TypedAppContext, | ||
| 33 | +) | ||
| 34 | + | ||
| 35 | + | ||
| 36 | +USER_TABLE_NAME = "context_capability_users" | ||
| 37 | +USER_CACHE_TTL_SECONDS = 60.0 | ||
| 38 | +USER_LOCK_TTL_SECONDS = 30.0 | ||
| 39 | +USER_LOCK_WAIT_SECONDS = 5.0 | ||
| 40 | + | ||
| 41 | +USER_TABLE = TableDefinition( | ||
| 42 | + table_name=USER_TABLE_NAME, | ||
| 43 | + columns=[ | ||
| 44 | + ColumnDefinition( | ||
| 45 | + "id", | ||
| 46 | + "integer", | ||
| 47 | + primary_key=True, | ||
| 48 | + nullable=False, | ||
| 49 | + autoincrement=True, | ||
| 50 | + ), | ||
| 51 | + ColumnDefinition("email", "string", nullable=False, unique=True, length=320), | ||
| 52 | + ColumnDefinition("name", "string", nullable=False, length=128), | ||
| 53 | + ColumnDefinition("fence_token", "integer", nullable=False, default=0), | ||
| 54 | + ColumnDefinition("created_at", "datetime", nullable=False), | ||
| 55 | + ], | ||
| 56 | +) | ||
| 57 | + | ||
| 58 | + | ||
| 59 | +class CreateUserInput(BaseModel): | ||
| 60 | + model_config = ConfigDict(str_strip_whitespace=True) | ||
| 61 | + | ||
| 62 | + email: EmailStr | ||
| 63 | + name: str = Field(min_length=1, max_length=128) | ||
| 64 | + | ||
| 65 | + | ||
| 66 | +class UserIdInput(BaseModel): | ||
| 67 | + id: int = Field(gt=0) | ||
| 68 | + | ||
| 69 | + | ||
| 70 | +class UpdateUserInput(UserIdInput): | ||
| 71 | + model_config = ConfigDict(str_strip_whitespace=True) | ||
| 72 | + | ||
| 73 | + name: str = Field(min_length=1, max_length=128) | ||
| 74 | + | ||
| 75 | + | ||
| 76 | +class ManualLockInput(BaseModel): | ||
| 77 | + key: str = Field(min_length=1, max_length=256) | ||
| 78 | + ttl: float = Field(default=30.0, gt=0, le=300) | ||
| 79 | + wait_timeout: float = Field(default=5.0, ge=0, le=60) | ||
| 80 | + | ||
| 81 | + | ||
| 82 | +class ChatInput(BaseModel): | ||
| 83 | + text: str = Field(min_length=1, max_length=4096) | ||
| 84 | + delay_seconds: float = Field(default=0.05, ge=0, le=1) | ||
| 85 | + | ||
| 86 | + | ||
| 87 | +class UserView(BaseModel): | ||
| 88 | + id: int | ||
| 89 | + email: EmailStr | ||
| 90 | + name: str | ||
| 91 | + fence_token: int | ||
| 92 | + created_at: datetime | ||
| 93 | + | ||
| 94 | + | ||
| 95 | +def _cache_key(user_id: int) -> str: | ||
| 96 | + return f"user:{user_id}" | ||
| 97 | + | ||
| 98 | + | ||
| 99 | +def _record_values(record: Any) -> Mapping[str, Any]: | ||
| 100 | + if isinstance(record, Mapping): | ||
| 101 | + return record | ||
| 102 | + mapping = getattr(record, "_mapping", None) | ||
| 103 | + if mapping is not None: | ||
| 104 | + return mapping | ||
| 105 | + to_dict = getattr(record, "to_dict", None) | ||
| 106 | + if callable(to_dict): | ||
| 107 | + return to_dict() | ||
| 108 | + return { | ||
| 109 | + name: getattr(record, name) | ||
| 110 | + for name in ("id", "email", "name", "fence_token", "created_at") | ||
| 111 | + } | ||
| 112 | + | ||
| 113 | + | ||
| 114 | +def _user_view(record: Any) -> UserView: | ||
| 115 | + return UserView.model_validate(_record_values(record)) | ||
| 116 | + | ||
| 117 | + | ||
| 118 | +def _credential_view(credential: Any) -> dict[str, Any]: | ||
| 119 | + return { | ||
| 120 | + "key": credential.key, | ||
| 121 | + "backend": credential.backend, | ||
| 122 | + "lease_id": credential.lease_id, | ||
| 123 | + "fencing_token": credential.fencing_token, | ||
| 124 | + "acquired_at": credential.acquired_at, | ||
| 125 | + "expires_at": credential.expires_at, | ||
| 126 | + "remaining_seconds": max(0.0, credential.expires_at - time.monotonic()), | ||
| 127 | + } | ||
| 128 | + | ||
| 129 | + | ||
| 130 | +def create_system_context() -> SystemContext: | ||
| 131 | + """Build process resources from ``OPENJIUWEN_SERVICE_*`` variables.""" | ||
| 132 | + settings = ServiceConfig.from_env() | ||
| 133 | + return SystemContext.from_settings( | ||
| 134 | + settings=settings, | ||
| 135 | + table_definitions=(USER_TABLE,), | ||
| 136 | + ) | ||
| 137 | + | ||
| 138 | + | ||
| 139 | +app = App( | ||
| 140 | + create_system_context, | ||
| 141 | + title="Service Context Capabilities", | ||
| 142 | +) | ||
| 143 | +asgi = app.asgi | ||
| 144 | + | ||
| 145 | + | ||
| 146 | + | ||
| 147 | +async def create_user( | ||
| 148 | + ctx: TypedAppContext[CreateUserInput], | ||
| 149 | + env: Envelope[CreateUserInput], | ||
| 150 | +) -> dict[str, Any]: | ||
| 151 | + request = ctx.request | ||
| 152 | + try: | ||
| 153 | + record = await ctx.db_create( | ||
| 154 | + USER_TABLE_NAME, | ||
| 155 | + { | ||
| 156 | + "email": str(request.email), | ||
| 157 | + "name": request.name, | ||
| 158 | + "fence_token": 0, | ||
| 159 | + "created_at": datetime.now(timezone.utc), | ||
| 160 | + }, | ||
| 161 | + ) | ||
| 162 | + except IntegrityError as exc: | ||
| 163 | + raise FrameworkError( | ||
| 164 | + f"user email {request.email!s} already exists", | ||
| 165 | + code=ErrorCode.CONFLICT, | ||
| 166 | + ) from exc | ||
| 167 | + | ||
| 168 | + user = _user_view(record) | ||
| 169 | + await ctx.cache.set_json(_cache_key(user.id), user, ttl=USER_CACHE_TTL_SECONDS) | ||
| 170 | + await ctx.audit( | ||
| 171 | + "users.create", | ||
| 172 | + resource=f"user:{user.id}", | ||
| 173 | + details={"email": str(user.email)}, | ||
| 174 | + ) | ||
| 175 | + return user.model_dump(mode="json") | ||
| 176 | + | ||
| 177 | + | ||
| 178 | + | ||
| 179 | +async def get_user( | ||
| 180 | + ctx: TypedAppContext[UserIdInput], | ||
| 181 | + env: Envelope[UserIdInput], | ||
| 182 | +) -> dict[str, Any]: | ||
| 183 | + key = _cache_key(ctx.request.id) | ||
| 184 | + cached = await ctx.cache.get_model(key, UserView) | ||
| 185 | + if cached is not None: | ||
| 186 | + return {"user": cached.model_dump(mode="json"), "cache_hit": True} | ||
| 187 | + | ||
| 188 | + record = await ctx.db_get(USER_TABLE_NAME, {"id": ctx.request.id}) | ||
| 189 | + if record is None: | ||
| 190 | + raise NotFoundError(f"user {ctx.request.id} not found") | ||
| 191 | + user = _user_view(record) | ||
| 192 | + await ctx.cache.set_json(key, user, ttl=USER_CACHE_TTL_SECONDS) | ||
| 193 | + return {"user": user.model_dump(mode="json"), "cache_hit": False} | ||
| 194 | + | ||
| 195 | + | ||
| 196 | + | ||
| 197 | +async def update_user( | ||
| 198 | + ctx: TypedAppContext[UpdateUserInput], | ||
| 199 | + env: Envelope[UpdateUserInput], | ||
| 200 | +) -> dict[str, Any]: | ||
| 201 | + request = ctx.request | ||
| 202 | + locks = ctx.require_locks(distributed=True, fencing=True) | ||
| 203 | + lock_key = f"user:{request.id}" | ||
| 204 | + | ||
| 205 | + async with locks.hold( | ||
| 206 | + lock_key, | ||
| 207 | + ttl=USER_LOCK_TTL_SECONDS, | ||
| 208 | + wait_timeout=USER_LOCK_WAIT_SECONDS, | ||
| 209 | + auto_renew=True, | ||
| 210 | + ) as lease: | ||
| 211 | + fence_token = lease.credential.fencing_token | ||
| 212 | + if fence_token is None: | ||
| 213 | + raise LockBackendUnavailable( | ||
| 214 | + "the selected lock backend did not issue a fencing token" | ||
| 215 | + ) | ||
| 216 | + | ||
| 217 | + async with ctx.transaction() as session: | ||
| 218 | + claim = await session.execute( | ||
| 219 | + text( | ||
| 220 | + f"UPDATE {USER_TABLE_NAME} " | ||
| 221 | + "SET fence_token = :fence_token " | ||
| 222 | + "WHERE id = :user_id AND fence_token < :fence_token" | ||
| 223 | + ), | ||
| 224 | + {"user_id": request.id, "fence_token": fence_token}, | ||
| 225 | + ) | ||
| 226 | + if claim.rowcount != 1: | ||
| 227 | + existing = await session.execute( | ||
| 228 | + text(f"SELECT id FROM {USER_TABLE_NAME} WHERE id = :user_id"), | ||
| 229 | + {"user_id": request.id}, | ||
| 230 | + ) | ||
| 231 | + if existing.scalar_one_or_none() is None: | ||
| 232 | + raise NotFoundError(f"user {request.id} not found") | ||
| 233 | + raise LockLost(f"stale lock credential for user:{request.id}") | ||
| 234 | + | ||
| 235 | + updated = await session.execute( | ||
| 236 | + text( | ||
| 237 | + f"UPDATE {USER_TABLE_NAME} " | ||
| 238 | + "SET name = :name " | ||
| 239 | + "WHERE id = :user_id AND fence_token = :fence_token" | ||
| 240 | + ), | ||
| 241 | + { | ||
| 242 | + "user_id": request.id, | ||
| 243 | + "name": request.name, | ||
| 244 | + "fence_token": fence_token, | ||
| 245 | + }, | ||
| 246 | + ) | ||
| 247 | + if updated.rowcount != 1: | ||
| 248 | + raise LockLost(f"lock credential superseded for user:{request.id}") | ||
| 249 | + result = await session.execute( | ||
| 250 | + text( | ||
| 251 | + f"SELECT id, email, name, fence_token, created_at " | ||
| 252 | + f"FROM {USER_TABLE_NAME} WHERE id = :user_id" | ||
| 253 | + ), | ||
| 254 | + {"user_id": request.id}, | ||
| 255 | + ) | ||
| 256 | + user = _user_view(result.mappings().one()) | ||
| 257 | + lease.ensure_valid() | ||
| 258 | + | ||
| 259 | + await ctx.cache.delete(_cache_key(request.id)) | ||
| 260 | + await ctx.audit( | ||
| 261 | + "users.update", | ||
| 262 | + resource=f"user:{request.id}", | ||
| 263 | + details={"fencing_token": fence_token}, | ||
| 264 | + ) | ||
| 265 | + return user.model_dump(mode="json") | ||
| 266 | + | ||
| 267 | + | ||
| 268 | + | ||
| 269 | +async def remove_user( | ||
| 270 | + ctx: TypedAppContext[UserIdInput], | ||
| 271 | + env: Envelope[UserIdInput], | ||
| 272 | +) -> dict[str, Any]: | ||
| 273 | + user_id = ctx.request.id | ||
| 274 | + removed = await ctx.db_delete(USER_TABLE_NAME, {"id": user_id}) | ||
| 275 | + await ctx.cache.delete(_cache_key(user_id)) | ||
| 276 | + await ctx.audit( | ||
| 277 | + "users.remove", | ||
| 278 | + outcome="success" if removed else "not_found", | ||
| 279 | + resource=f"user:{user_id}", | ||
| 280 | + details={"removed": removed}, | ||
| 281 | + ) | ||
| 282 | + return {"id": user_id, "removed": removed} | ||
| 283 | + | ||
| 284 | + | ||
| 285 | + | ||
| 286 | +async def manual_lock( | ||
| 287 | + ctx: TypedAppContext[ManualLockInput], | ||
| 288 | + env: Envelope[ManualLockInput], | ||
| 289 | +) -> dict[str, Any]: | ||
| 290 | + request = ctx.request | ||
| 291 | + lease = await ctx.locks.acquire( | ||
| 292 | + request.key, | ||
| 293 | + ttl=request.ttl, | ||
| 294 | + wait_timeout=request.wait_timeout, | ||
| 295 | + auto_renew=False, | ||
| 296 | + ) | ||
| 297 | + acquired = _credential_view(lease.credential) | ||
| 298 | + released = False | ||
| 299 | + try: | ||
| 300 | + renewed = _credential_view(await lease.renew()) | ||
| 301 | + finally: | ||
| 302 | + released = await lease.release() | ||
| 303 | + return { | ||
| 304 | + "acquired": acquired, | ||
| 305 | + "renewed": renewed, | ||
| 306 | + "released": released, | ||
| 307 | + } | ||
| 308 | + | ||
| 309 | + | ||
| 310 | + | ||
| 311 | +async def chat( | ||
| 312 | + ctx: TypedAppContext[ChatInput], | ||
| 313 | + env: Envelope[ChatInput], | ||
| 314 | +) -> AsyncIterator[dict[str, Any]]: | ||
| 315 | + request = ctx.request | ||
| 316 | + | ||
| 317 | + async def log_cleanup() -> None: | ||
| 318 | + ctx.logger.info("chat request resources released") | ||
| 319 | + | ||
| 320 | + ctx.add_cleanup(log_cleanup) | ||
| 321 | + for sequence, chunk in enumerate(request.text.split(), start=1): | ||
| 322 | + ctx.check_interrupted() | ||
| 323 | + if request.delay_seconds: | ||
| 324 | + await asyncio.sleep(request.delay_seconds) | ||
| 325 | + ctx.check_interrupted() | ||
| 326 | + yield {"sequence": sequence, "text": chunk} | ||
| 327 | + | ||
| 328 | + | ||
| 329 | +if __name__ == "__main__": | ||
| 330 | + app.run() | ||
| @@ -9,11 +9,15 @@ | |||
| 9 | from .envelope import Envelope, Metadata, ResponseEnvelope, StreamChunk | 9 | from .envelope import Envelope, Metadata, ResponseEnvelope, StreamChunk |
| 10 | from .config import ServiceConfig | 10 | from .config import ServiceConfig |
| 11 | from .errors import ( | 11 | from .errors import ( |
| 12 | + CacheUnavailable, | ||
| 12 | DatabaseUnavailable, | 13 | DatabaseUnavailable, |
| 13 | DeadlineExceeded, | 14 | DeadlineExceeded, |
| 14 | ErrorCode, | 15 | ErrorCode, |
| 15 | FrameworkError, | 16 | FrameworkError, |
| 16 | IdempotentConflict, | 17 | IdempotentConflict, |
| 18 | + InvalidLockLease, | ||
| 19 | + LockAcquireTimeout, | ||
| 20 | + LockBackendUnavailable, | ||
| 17 | LockLost, | 21 | LockLost, |
| 18 | LockNotAcquired, | 22 | LockNotAcquired, |
| 19 | NotFoundError, | 23 | NotFoundError, |
| @@ -24,11 +28,43 @@ from .errors import ( | |||
| 24 | from .context import ( | 28 | from .context import ( |
| 25 | AuditEvent, | 29 | AuditEvent, |
| 26 | AuditLogger, | 30 | AuditLogger, |
| 31 | + BaseCacheBackend, | ||
| 32 | + Cache, | ||
| 33 | + CacheBackend, | ||
| 34 | + CacheBackendFactory, | ||
| 35 | + CacheMetrics, | ||
| 36 | + CacheSerializer, | ||
| 37 | + EtcdLockBackend, | ||
| 27 | LoggingAuditLogger, | 38 | LoggingAuditLogger, |
| 39 | + LeaseState, | ||
| 40 | + LockBackend, | ||
| 41 | + LockBackendFactory, | ||
| 42 | + LockCapabilities, | ||
| 43 | + LockCredential, | ||
| 44 | + LockLease, | ||
| 45 | + LockManager, | ||
| 46 | + JsonCacheSerializer, | ||
| 47 | + MemoryCacheBackend, | ||
| 48 | + MemoryLockBackend, | ||
| 28 | NoopAuditLogger, | 49 | NoopAuditLogger, |
| 50 | + RedisLockBackend, | ||
| 51 | + RedisCacheBackend, | ||
| 29 | RequestContext, | 52 | RequestContext, |
| 30 | SystemContext, | 53 | SystemContext, |
| 31 | TypedAppContext, | 54 | TypedAppContext, |
| 55 | + build_lock_backend, | ||
| 56 | + build_cache_backend, | ||
| 57 | + create_cache_backend, | ||
| 58 | + create_lock_backend, | ||
| 59 | +) | ||
| 60 | +from .bootstrap import ( | ||
| 61 | + bootstrap_system, | ||
| 62 | + build_db_handler, | ||
| 63 | + build_redis_client, | ||
| 64 | + build_redis_handler, | ||
| 65 | + build_system_context, | ||
| 66 | + create_system_context, | ||
| 67 | + shutdown_system, | ||
| 32 | ) | 68 | ) |
| 33 | from .context.primitives.idempotency import idempotency_guard | 69 | from .context.primitives.idempotency import idempotency_guard |
| 34 | from .routing.handlers import ( | 70 | from .routing.handlers import ( |
| @@ -60,9 +96,13 @@ __all__ = [ | |||
| 60 | "DeadlineExceeded", | 96 | "DeadlineExceeded", |
| 61 | "DatabaseUnavailable", | 97 | "DatabaseUnavailable", |
| 62 | "RedisUnavailable", | 98 | "RedisUnavailable", |
| 99 | + "CacheUnavailable", | ||
| 63 | "ValidationError", | 100 | "ValidationError", |
| 64 | "NotFoundError", | 101 | "NotFoundError", |
| 65 | "IdempotentConflict", | 102 | "IdempotentConflict", |
| 103 | + "LockAcquireTimeout", | ||
| 104 | + "LockBackendUnavailable", | ||
| 105 | + "InvalidLockLease", | ||
| 66 | "LockNotAcquired", | 106 | "LockNotAcquired", |
| 67 | "LockLost", | 107 | "LockLost", |
| 68 | # context | 108 | # context |
| @@ -71,8 +111,40 @@ __all__ = [ | |||
| 71 | "TypedAppContext", | 111 | "TypedAppContext", |
| 72 | "AuditEvent", | 112 | "AuditEvent", |
| 73 | "AuditLogger", | 113 | "AuditLogger", |
| 114 | + "BaseCacheBackend", | ||
| 115 | + "Cache", | ||
| 116 | + "CacheBackend", | ||
| 117 | + "CacheBackendFactory", | ||
| 118 | + "CacheMetrics", | ||
| 119 | + "CacheSerializer", | ||
| 120 | + "EtcdLockBackend", | ||
| 74 | "LoggingAuditLogger", | 121 | "LoggingAuditLogger", |
| 75 | "NoopAuditLogger", | 122 | "NoopAuditLogger", |
| 123 | + # locks | ||
| 124 | + "LeaseState", | ||
| 125 | + "LockBackend", | ||
| 126 | + "LockBackendFactory", | ||
| 127 | + "LockCapabilities", | ||
| 128 | + "LockCredential", | ||
| 129 | + "LockLease", | ||
| 130 | + "LockManager", | ||
| 131 | + "JsonCacheSerializer", | ||
| 132 | + "MemoryCacheBackend", | ||
| 133 | + "MemoryLockBackend", | ||
| 134 | + "RedisLockBackend", | ||
| 135 | + "RedisCacheBackend", | ||
| 136 | + "build_cache_backend", | ||
| 137 | + "build_lock_backend", | ||
| 138 | + "create_cache_backend", | ||
| 139 | + "create_lock_backend", | ||
| 140 | + # bootstrap | ||
| 141 | + "bootstrap_system", | ||
| 142 | + "build_db_handler", | ||
| 143 | + "build_redis_client", | ||
| 144 | + "build_redis_handler", | ||
| 145 | + "build_system_context", | ||
| 146 | + "create_system_context", | ||
| 147 | + "shutdown_system", | ||
| 76 | # handlers | 148 | # handlers |
| 77 | "HandlerSpec", | 149 | "HandlerSpec", |
| 78 | "MessageHandler", | 150 | "MessageHandler", |
| @@ -0,0 +1,268 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Service resource construction and startup orchestration. | ||
| 5 | + | ||
| 6 | +The builders in this module only construct resources. ``bootstrap_system`` | ||
| 7 | +starts them through :class:`SystemContext`, which keeps startup ordering and | ||
| 8 | +failure cleanup in one place. | ||
| 9 | +""" | ||
| 10 | + | ||
| 11 | +from __future__ import annotations | ||
| 12 | + | ||
| 13 | +import socket | ||
| 14 | +from dataclasses import fields | ||
| 15 | +from typing import Any | ||
| 16 | +from uuid import uuid4 | ||
| 17 | + | ||
| 18 | +from .config import ServiceConfig | ||
| 19 | +from .context.cache.factory import build_cache_backend as _build_cache_backend | ||
| 20 | +from .context.locks.factory import build_lock_backend as _build_lock_backend | ||
| 21 | +from .errors import RedisUnavailable | ||
| 22 | + | ||
| 23 | + | ||
| 24 | +def coerce_config( | ||
| 25 | + settings: ServiceConfig | dict[str, Any] | Any | None = None, | ||
| 26 | +) -> ServiceConfig: | ||
| 27 | + """Normalize a config object while retaining compatibility with mappings.""" | ||
| 28 | + if settings is None: | ||
| 29 | + return ServiceConfig.from_env() | ||
| 30 | + if isinstance(settings, ServiceConfig): | ||
| 31 | + return settings | ||
| 32 | + if isinstance(settings, dict): | ||
| 33 | + names = {item.name for item in fields(ServiceConfig)} | ||
| 34 | + return ServiceConfig( | ||
| 35 | + **{name: settings[name] for name in names if name in settings} | ||
| 36 | + ) | ||
| 37 | + values = { | ||
| 38 | + item.name: getattr(settings, item.name) | ||
| 39 | + for item in fields(ServiceConfig) | ||
| 40 | + if hasattr(settings, item.name) | ||
| 41 | + } | ||
| 42 | + return ServiceConfig(**values) | ||
| 43 | + | ||
| 44 | + | ||
| 45 | +def should_bootstrap_db(settings: ServiceConfig) -> bool: | ||
| 46 | + return settings.db_type != "none" | ||
| 47 | + | ||
| 48 | + | ||
| 49 | +def should_bootstrap_redis(settings: ServiceConfig) -> bool: | ||
| 50 | + return bool( | ||
| 51 | + settings.redis_url and settings.redis_url.lower() not in {"none", "disabled"} | ||
| 52 | + ) | ||
| 53 | + | ||
| 54 | + | ||
| 55 | +def build_db_handler(settings: ServiceConfig | dict[str, Any] | Any) -> Any | None: | ||
| 56 | + """Build a foundation DB handler without opening a connection.""" | ||
| 57 | + cfg = coerce_config(settings) | ||
| 58 | + if cfg.db_type == "none": | ||
| 59 | + return None | ||
| 60 | + if cfg.db_type == "sqlite": | ||
| 61 | + from openjiuwen_runtime.foundation.db import SQLiteHandler | ||
| 62 | + | ||
| 63 | + return SQLiteHandler(cfg.db_name or ":memory:") | ||
| 64 | + if cfg.db_type == "mysql": | ||
| 65 | + from openjiuwen_runtime.foundation.db import MySQLHandler | ||
| 66 | + | ||
| 67 | + return MySQLHandler( | ||
| 68 | + host=cfg.db_host, | ||
| 69 | + port=cfg.db_port, | ||
| 70 | + database=cfg.db_name, | ||
| 71 | + user=cfg.db_user, | ||
| 72 | + password=cfg.db_password, | ||
| 73 | + ) | ||
| 74 | + raise ValueError(f"unsupported db_type={cfg.db_type!r}; expected mysql|sqlite|none") | ||
| 75 | + | ||
| 76 | + | ||
| 77 | +def build_redis_client(settings: ServiceConfig | dict[str, Any] | Any) -> Any | None: | ||
| 78 | + """Build a Redis client from the configured URL without pinging it.""" | ||
| 79 | + cfg = coerce_config(settings) | ||
| 80 | + if not should_bootstrap_redis(cfg): | ||
| 81 | + return None | ||
| 82 | + try: | ||
| 83 | + import redis.asyncio | ||
| 84 | + | ||
| 85 | + return redis.asyncio.from_url(cfg.redis_url, decode_responses=False) | ||
| 86 | + except ( | ||
| 87 | + Exception | ||
| 88 | + ) as exc: # pragma: no cover - import failures are environment-specific | ||
| 89 | + raise RedisUnavailable(f"cannot create Redis client: {exc}") from exc | ||
| 90 | + | ||
| 91 | + | ||
| 92 | +def build_lock_backend( | ||
| 93 | + settings: ServiceConfig | str, | ||
| 94 | + *, | ||
| 95 | + redis_client: Any = None, | ||
| 96 | + redis: Any = None, | ||
| 97 | + etcd_client: Any = None, | ||
| 98 | + instance_id: str | None = None, | ||
| 99 | + request_id: str | None = None, | ||
| 100 | + owns_etcd_client: bool = False, | ||
| 101 | +) -> Any: | ||
| 102 | + """Select the lock backend once using the configured startup policy.""" | ||
| 103 | + if redis_client is None: | ||
| 104 | + redis_client = redis | ||
| 105 | + if isinstance(settings, str): | ||
| 106 | + return _build_lock_backend( | ||
| 107 | + settings, | ||
| 108 | + redis=redis_client, | ||
| 109 | + etcd_client=etcd_client, | ||
| 110 | + instance_id=instance_id, | ||
| 111 | + request_id=request_id, | ||
| 112 | + owns_etcd_client=owns_etcd_client, | ||
| 113 | + ) | ||
| 114 | + cfg = coerce_config(settings) | ||
| 115 | + owns_etcd_client = owns_etcd_client or ( | ||
| 116 | + etcd_client is None and bool(cfg.etcd_endpoints) | ||
| 117 | + ) | ||
| 118 | + return _build_lock_backend( | ||
| 119 | + cfg.lock_backend, | ||
| 120 | + redis=redis_client, | ||
| 121 | + etcd_client=etcd_client, | ||
| 122 | + etcd_endpoints=cfg.etcd_endpoints, | ||
| 123 | + etcd_username=cfg.etcd_username, | ||
| 124 | + etcd_password=cfg.etcd_password, | ||
| 125 | + etcd_connect_timeout=cfg.etcd_connect_timeout_seconds, | ||
| 126 | + etcd_ca_cert=cfg.etcd_ca_cert, | ||
| 127 | + etcd_cert=cfg.etcd_cert, | ||
| 128 | + etcd_key=cfg.etcd_key, | ||
| 129 | + key_prefix=cfg.lock_key_prefix, | ||
| 130 | + deploy_replicas=cfg.deploy_replicas, | ||
| 131 | + instance_id=instance_id, | ||
| 132 | + request_id=request_id, | ||
| 133 | + owns_etcd_client=owns_etcd_client, | ||
| 134 | + ) | ||
| 135 | + | ||
| 136 | + | ||
| 137 | +def build_cache_backend( | ||
| 138 | + settings: ServiceConfig | str, | ||
| 139 | + *, | ||
| 140 | + redis_client: Any = None, | ||
| 141 | + redis: Any = None, | ||
| 142 | + owns_redis: bool = False, | ||
| 143 | +) -> Any | None: | ||
| 144 | + """Build the configured memory, Redis, or disabled cache backend.""" | ||
| 145 | + if redis_client is None: | ||
| 146 | + redis_client = redis | ||
| 147 | + if isinstance(settings, str): | ||
| 148 | + return _build_cache_backend(settings, redis=redis_client, owns_redis=owns_redis) | ||
| 149 | + cfg = coerce_config(settings) | ||
| 150 | + return _build_cache_backend( | ||
| 151 | + cfg.cache_backend, | ||
| 152 | + redis=redis_client, | ||
| 153 | + key_prefix=cfg.cache_key_prefix, | ||
| 154 | + default_ttl=cfg.cache_default_ttl_seconds, | ||
| 155 | + max_entries=cfg.cache_max_entries, | ||
| 156 | + owns_redis=owns_redis, | ||
| 157 | + ) | ||
| 158 | + | ||
| 159 | + | ||
| 160 | +def build_system_context( | ||
| 161 | + settings: ServiceConfig | dict[str, Any] | Any | None = None, | ||
| 162 | + *, | ||
| 163 | + db: Any = None, | ||
| 164 | + redis: Any = None, | ||
| 165 | + etcd_client: Any = None, | ||
| 166 | + lock_backend: Any = None, | ||
| 167 | + cache_backend: Any = None, | ||
| 168 | + table_definitions: Any = None, | ||
| 169 | + instance_id: str | None = None, | ||
| 170 | +) -> Any: | ||
| 171 | + """Construct a :class:`SystemContext` and mark resources it created.""" | ||
| 172 | + from .context.system_context import SystemContext | ||
| 173 | + | ||
| 174 | + cfg = coerce_config(settings) | ||
| 175 | + resolved_instance_id = instance_id or f"{socket.gethostname()}:{uuid4().hex[:8]}" | ||
| 176 | + owns_db = db is None and should_bootstrap_db(cfg) | ||
| 177 | + owns_redis = redis is None and should_bootstrap_redis(cfg) | ||
| 178 | + db_resource = build_db_handler(cfg) if db is None else db | ||
| 179 | + redis_resource = build_redis_client(cfg) if redis is None else redis | ||
| 180 | + lock_resource = lock_backend | ||
| 181 | + if lock_resource is None: | ||
| 182 | + lock_resource = build_lock_backend( | ||
| 183 | + cfg, | ||
| 184 | + redis_client=redis_resource, | ||
| 185 | + etcd_client=etcd_client, | ||
| 186 | + instance_id=resolved_instance_id, | ||
| 187 | + owns_etcd_client=etcd_client is None and bool(cfg.etcd_endpoints), | ||
| 188 | + ) | ||
| 189 | + cache_resource = cache_backend | ||
| 190 | + if cache_resource is None: | ||
| 191 | + cache_resource = build_cache_backend(cfg, redis_client=redis_resource) | ||
| 192 | + return SystemContext( | ||
| 193 | + redis=redis_resource, | ||
| 194 | + db=db_resource, | ||
| 195 | + settings=cfg, | ||
| 196 | + key_prefix=cfg.key_prefix, | ||
| 197 | + instance_id=resolved_instance_id, | ||
| 198 | + etcd=etcd_client | ||
| 199 | + if etcd_client is not None | ||
| 200 | + else getattr(lock_resource, "_client", None), | ||
| 201 | + lock_backend=lock_resource, | ||
| 202 | + cache_backend=cache_resource, | ||
| 203 | + table_definitions=table_definitions, | ||
| 204 | + request_timeout_seconds=cfg.request_timeout_seconds, | ||
| 205 | + _owns_db=owns_db, | ||
| 206 | + _owns_redis=owns_redis, | ||
| 207 | + _owns_lock_backend=lock_backend is None, | ||
| 208 | + _owns_cache_backend=cache_backend is None and cache_resource is not None, | ||
| 209 | + ) | ||
| 210 | + | ||
| 211 | + | ||
| 212 | +async def bootstrap_system( | ||
| 213 | + system: Any, | ||
| 214 | + settings: ServiceConfig | dict[str, Any] | Any | None = None, | ||
| 215 | + *, | ||
| 216 | + force: bool = False, | ||
| 217 | + etcd_client: Any = None, | ||
| 218 | +) -> Any: | ||
| 219 | + """Attach configured resources to an existing context and start it.""" | ||
| 220 | + cfg = coerce_config(settings if settings is not None else system.settings) | ||
| 221 | + if force and getattr(system, "_started", False): | ||
| 222 | + await system.stop() | ||
| 223 | + system.settings = cfg | ||
| 224 | + system.key_prefix = cfg.key_prefix | ||
| 225 | + system.request_timeout_seconds = cfg.request_timeout_seconds | ||
| 226 | + if force or system.db is None: | ||
| 227 | + db = build_db_handler(cfg) | ||
| 228 | + system.set_db(db, owned=db is not None) | ||
| 229 | + if force or system.redis is None: | ||
| 230 | + redis = build_redis_client(cfg) | ||
| 231 | + system.set_redis(redis, owned=redis is not None) | ||
| 232 | + if force or getattr(system, "lock_backend", None) is None: | ||
| 233 | + system.set_lock_backend( | ||
| 234 | + build_lock_backend( | ||
| 235 | + cfg, | ||
| 236 | + redis_client=system.redis, | ||
| 237 | + etcd_client=etcd_client, | ||
| 238 | + instance_id=system.instance_id, | ||
| 239 | + ) | ||
| 240 | + ) | ||
| 241 | + if force or getattr(system, "cache_backend", None) is None: | ||
| 242 | + system.set_cache_backend(build_cache_backend(cfg, redis_client=system.redis)) | ||
| 243 | + await system.start() | ||
| 244 | + return system | ||
| 245 | + | ||
| 246 | + | ||
| 247 | +async def shutdown_system(system: Any) -> None: | ||
| 248 | + await system.stop() | ||
| 249 | + | ||
| 250 | + | ||
| 251 | +build_redis_handler = build_redis_client | ||
| 252 | +create_system_context = build_system_context | ||
| 253 | + | ||
| 254 | + | ||
| 255 | +__all__ = [ | ||
| 256 | + "build_cache_backend", | ||
| 257 | + "build_db_handler", | ||
| 258 | + "build_lock_backend", | ||
| 259 | + "build_redis_client", | ||
| 260 | + "build_redis_handler", | ||
| 261 | + "build_system_context", | ||
| 262 | + "create_system_context", | ||
| 263 | + "bootstrap_system", | ||
| 264 | + "coerce_config", | ||
| 265 | + "should_bootstrap_db", | ||
| 266 | + "should_bootstrap_redis", | ||
| 267 | + "shutdown_system", | ||
| 268 | +] | ||
| @@ -15,18 +15,34 @@ | |||
| 15 | | ``OPENJIUWEN_SERVICE_TITLE`` | 服务标题(OpenAPI/日志) | ``service`` | | 15 | | ``OPENJIUWEN_SERVICE_TITLE`` | 服务标题(OpenAPI/日志) | ``service`` | |
| 16 | | ``OPENJIUWEN_SERVICE_REQUEST_TIMEOUT_SECONDS`` | 请求超时秒数,0 表示不设置 deadline | ``0`` | | 16 | | ``OPENJIUWEN_SERVICE_REQUEST_TIMEOUT_SECONDS`` | 请求超时秒数,0 表示不设置 deadline | ``0`` | |
| 17 | """ | 17 | """ |
| 18 | + | ||
| 18 | from __future__ import annotations | 19 | from __future__ import annotations |
| 19 | 20 | ||
| 20 | import math | 21 | import math |
| 21 | import os | 22 | import os |
| 22 | from dataclasses import dataclass | 23 | from dataclasses import dataclass |
| 23 | 24 | ||
| 25 | + | ||
| 24 | _DEFAULT_HOST = "0.0.0.0" | 26 | _DEFAULT_HOST = "0.0.0.0" |
| 25 | _DEFAULT_PORT = 8090 | 27 | _DEFAULT_PORT = 8090 |
| 26 | _DEFAULT_REDIS_URL = "redis://localhost:6379/0" | 28 | _DEFAULT_REDIS_URL = "redis://localhost:6379/0" |
| 27 | _DEFAULT_KEY_PREFIX = "service" | 29 | _DEFAULT_KEY_PREFIX = "service" |
| 28 | _DEFAULT_TITLE = "service" | 30 | _DEFAULT_TITLE = "service" |
| 29 | _DEFAULT_REQUEST_TIMEOUT_SECONDS = 0.0 | 31 | _DEFAULT_REQUEST_TIMEOUT_SECONDS = 0.0 |
| 32 | +_DEFAULT_LOCK_BACKEND = "auto" | ||
| 33 | +_DEFAULT_LOCK_KEY_PREFIX = "service:lock" | ||
| 34 | +_DEFAULT_LOCK_TTL_SECONDS = 30.0 | ||
| 35 | +_DEFAULT_LOCK_WAIT_SECONDS = 0.0 | ||
| 36 | +_DEFAULT_LOCK_RENEW_RATIO = 0.333 | ||
| 37 | +_DEFAULT_LOCK_RELEASE_TIMEOUT_SECONDS = 3.0 | ||
| 38 | +_DEFAULT_DEPLOY_REPLICAS = 1 | ||
| 39 | +_DEFAULT_CACHE_BACKEND = "memory" | ||
| 40 | +_DEFAULT_CACHE_KEY_PREFIX = "service:cache" | ||
| 41 | +_DEFAULT_CACHE_DEFAULT_TTL_SECONDS = 300.0 | ||
| 42 | +_DEFAULT_CACHE_MAX_ENTRIES = 1000 | ||
| 43 | +_DEFAULT_DB_TYPE = "none" | ||
| 44 | +_DEFAULT_DB_PORT = 3306 | ||
| 45 | +_DEFAULT_ETCD_CONNECT_TIMEOUT_SECONDS = 5.0 | ||
| 30 | 46 | ||
| 31 | 47 | ||
| 32 | 48 | ||
| @@ -39,22 +55,261 @@ class ServiceConfig: | |||
| 39 | key_prefix: str = _DEFAULT_KEY_PREFIX | 55 | key_prefix: str = _DEFAULT_KEY_PREFIX |
| 40 | title: str = _DEFAULT_TITLE | 56 | title: str = _DEFAULT_TITLE |
| 41 | request_timeout_seconds: float = _DEFAULT_REQUEST_TIMEOUT_SECONDS | 57 | request_timeout_seconds: float = _DEFAULT_REQUEST_TIMEOUT_SECONDS |
| 58 | + lock_backend: str = _DEFAULT_LOCK_BACKEND | ||
| 59 | + lock_key_prefix: str = _DEFAULT_LOCK_KEY_PREFIX | ||
| 60 | + lock_ttl_seconds: float = _DEFAULT_LOCK_TTL_SECONDS | ||
| 61 | + lock_wait_seconds: float = _DEFAULT_LOCK_WAIT_SECONDS | ||
| 62 | + lock_renew_ratio: float = _DEFAULT_LOCK_RENEW_RATIO | ||
| 63 | + lock_release_timeout_seconds: float = _DEFAULT_LOCK_RELEASE_TIMEOUT_SECONDS | ||
| 64 | + deploy_replicas: int = _DEFAULT_DEPLOY_REPLICAS | ||
| 65 | + etcd_endpoints: tuple[str, ...] = () | ||
| 66 | + etcd_username: str | None = None | ||
| 67 | + etcd_password: str | None = None | ||
| 68 | + etcd_ca_cert: str | None = None | ||
| 69 | + etcd_cert: str | None = None | ||
| 70 | + etcd_key: str | None = None | ||
| 71 | + etcd_connect_timeout_seconds: float = _DEFAULT_ETCD_CONNECT_TIMEOUT_SECONDS | ||
| 72 | + cache_backend: str = _DEFAULT_CACHE_BACKEND | ||
| 73 | + cache_key_prefix: str = _DEFAULT_CACHE_KEY_PREFIX | ||
| 74 | + cache_default_ttl_seconds: float = _DEFAULT_CACHE_DEFAULT_TTL_SECONDS | ||
| 75 | + cache_max_entries: int = _DEFAULT_CACHE_MAX_ENTRIES | ||
| 76 | + db_type: str = _DEFAULT_DB_TYPE | ||
| 77 | + db_host: str | None = None | ||
| 78 | + db_port: int = _DEFAULT_DB_PORT | ||
| 79 | + db_name: str | None = None | ||
| 80 | + db_user: str | None = None | ||
| 81 | + db_password: str | None = None | ||
| 42 | 82 | ||
| 43 | def __post_init__(self) -> None: | 83 | def __post_init__(self) -> None: |
| 44 | - if not math.isfinite(self.request_timeout_seconds) or self.request_timeout_seconds < 0: | 84 | + self._validate_port("port", self.port) |
| 45 | - raise ValueError("request_timeout_seconds must be a finite non-negative number") | 85 | + self._validate_port("db_port", self.db_port) |
| 86 | + self._validate_non_negative( | ||
| 87 | + "request_timeout_seconds", self.request_timeout_seconds | ||
| 88 | + ) | ||
| 89 | + object.__setattr__(self, "lock_backend", str(self.lock_backend).strip().lower()) | ||
| 90 | + object.__setattr__( | ||
| 91 | + self, "cache_backend", str(self.cache_backend).strip().lower() | ||
| 92 | + ) | ||
| 93 | + object.__setattr__(self, "db_type", str(self.db_type).strip().lower()) | ||
| 94 | + self._validate_choice( | ||
| 95 | + "lock_backend", self.lock_backend, {"auto", "etcd", "redis", "memory"} | ||
| 96 | + ) | ||
| 97 | + self._validate_choice( | ||
| 98 | + "cache_backend", | ||
| 99 | + self.cache_backend, | ||
| 100 | + {"memory", "redis", "none"}, | ||
| 101 | + ) | ||
| 102 | + self._validate_choice("db_type", self.db_type, {"mysql", "sqlite", "none"}) | ||
| 103 | + self._validate_positive("lock_ttl_seconds", self.lock_ttl_seconds) | ||
| 104 | + self._validate_non_negative("lock_wait_seconds", self.lock_wait_seconds) | ||
| 105 | + self._validate_positive("lock_renew_ratio", self.lock_renew_ratio) | ||
| 106 | + if float(self.lock_renew_ratio) > 1: | ||
| 107 | + raise ValueError("lock_renew_ratio must be less than or equal to 1") | ||
| 108 | + self._validate_positive( | ||
| 109 | + "lock_release_timeout_seconds", self.lock_release_timeout_seconds | ||
| 110 | + ) | ||
| 111 | + self._validate_positive_int("deploy_replicas", self.deploy_replicas) | ||
| 112 | + self._validate_positive( | ||
| 113 | + "etcd_connect_timeout_seconds", self.etcd_connect_timeout_seconds | ||
| 114 | + ) | ||
| 115 | + self._validate_positive( | ||
| 116 | + "cache_default_ttl_seconds", self.cache_default_ttl_seconds | ||
| 117 | + ) | ||
| 118 | + self._validate_positive_int("cache_max_entries", self.cache_max_entries) | ||
| 119 | + if not self.lock_key_prefix.strip(): | ||
| 120 | + raise ValueError("lock_key_prefix must not be empty") | ||
| 121 | + if not self.cache_key_prefix.strip(): | ||
| 122 | + raise ValueError("cache_key_prefix must not be empty") | ||
| 123 | + if self.etcd_endpoints is None: | ||
| 124 | + object.__setattr__(self, "etcd_endpoints", ()) | ||
| 125 | + elif isinstance(self.etcd_endpoints, str): | ||
| 126 | + object.__setattr__( | ||
| 127 | + self, | ||
| 128 | + "etcd_endpoints", | ||
| 129 | + self._parse_endpoints(self.etcd_endpoints), | ||
| 130 | + ) | ||
| 131 | + else: | ||
| 132 | + values = tuple( | ||
| 133 | + str(value).strip() | ||
| 134 | + for value in self.etcd_endpoints | ||
| 135 | + if str(value).strip() | ||
| 136 | + ) | ||
| 137 | + if values: | ||
| 138 | + self._parse_endpoints(",".join(values)) | ||
| 139 | + object.__setattr__(self, "etcd_endpoints", values) | ||
| 140 | + if (self.etcd_username is None) != (self.etcd_password is None): | ||
| 141 | + raise ValueError( | ||
| 142 | + "etcd_username and etcd_password must be configured together" | ||
| 143 | + ) | ||
| 144 | + if (self.etcd_cert is None) != (self.etcd_key is None): | ||
| 145 | + raise ValueError("etcd_cert and etcd_key must be configured together") | ||
| 146 | + if self.lock_backend == "etcd" and not self.etcd_endpoints: | ||
| 147 | + raise ValueError("etcd_endpoints is required for lock_backend=etcd") | ||
| 148 | + if self.lock_backend == "redis" and not self.redis_url: | ||
| 149 | + raise ValueError("redis_url is required for lock_backend=redis") | ||
| 150 | + if self.cache_backend == "redis" and not self.redis_url: | ||
| 151 | + raise ValueError( | ||
| 152 | + f"redis_url is required for cache_backend={self.cache_backend}" | ||
| 153 | + ) | ||
| 154 | + if self.deploy_replicas > 1 and self.lock_backend == "memory": | ||
| 155 | + raise ValueError( | ||
| 156 | + "memory lock backend cannot be used with multiple replicas" | ||
| 157 | + ) | ||
| 158 | + if self.db_type == "mysql": | ||
| 159 | + missing = [] | ||
| 160 | + required_fields = ( | ||
| 161 | + ("db_host", self.db_host), | ||
| 162 | + ("db_name", self.db_name), | ||
| 163 | + ("db_user", self.db_user), | ||
| 164 | + ) | ||
| 165 | + for name, value in required_fields: | ||
| 166 | + if not value: | ||
| 167 | + missing.append(name) | ||
| 168 | + if missing: | ||
| 169 | + raise ValueError(f"{', '.join(missing)} is required for db_type=mysql") | ||
| 170 | + if self.db_type == "sqlite" and not self.db_name: | ||
| 171 | + raise ValueError("db_name is required for db_type=sqlite") | ||
| 172 | + | ||
| 173 | + | ||
| 174 | + def _validate_port(name: str, value: int) -> None: | ||
| 175 | + if ( | ||
| 176 | + isinstance(value, bool) | ||
| 177 | + or int(value) != value | ||
| 178 | + or not 1 <= int(value) <= 65535 | ||
| 179 | + ): | ||
| 180 | + raise ValueError(f"{name} must be an integer between 1 and 65535") | ||
| 181 | + | ||
| 182 | + | ||
| 183 | + def _validate_positive(name: str, value: float) -> None: | ||
| 184 | + if not math.isfinite(float(value)) or float(value) <= 0: | ||
| 185 | + raise ValueError(f"{name} must be a finite positive number") | ||
| 186 | + | ||
| 187 | + | ||
| 188 | + def _validate_non_negative(name: str, value: float) -> None: | ||
| 189 | + if not math.isfinite(float(value)) or float(value) < 0: | ||
| 190 | + raise ValueError(f"{name} must be a finite non-negative number") | ||
| 191 | + | ||
| 192 | + | ||
| 193 | + def _validate_positive_int(name: str, value: int) -> None: | ||
| 194 | + if isinstance(value, bool) or int(value) != value or int(value) < 1: | ||
| 195 | + raise ValueError(f"{name} must be a positive integer") | ||
| 196 | + | ||
| 197 | + | ||
| 198 | + def _validate_choice(name: str, value: str, choices: set[str]) -> None: | ||
| 199 | + if str(value).strip().lower() not in choices: | ||
| 200 | + choices_text = ", ".join(sorted(choices)) | ||
| 201 | + raise ValueError(f"{name} must be one of {choices_text}") | ||
| 202 | + | ||
| 203 | + | ||
| 204 | + def _parse_endpoints(value: str) -> tuple[str, ...]: | ||
| 205 | + from .context.locks.backends.etcd import parse_etcd_endpoint | ||
| 206 | + | ||
| 207 | + endpoints = tuple(item.strip() for item in value.split(",") if item.strip()) | ||
| 208 | + if not endpoints: | ||
| 209 | + raise ValueError("etcd_endpoints must contain at least one endpoint") | ||
| 210 | + for endpoint in endpoints: | ||
| 211 | + parse_etcd_endpoint(endpoint) | ||
| 212 | + return endpoints | ||
| 46 | 213 | ||
| 47 | 214 | ||
| 48 | def from_env(cls) -> "ServiceConfig": | 215 | def from_env(cls) -> "ServiceConfig": |
| 49 | """从环境变量读取;非法端口立即报错(fail-fast)。""" | 216 | """从环境变量读取;非法端口立即报错(fail-fast)。""" |
| 217 | + endpoints = os.getenv("OPENJIUWEN_SERVICE_ETCD_ENDPOINTS", "") | ||
| 50 | return cls( | 218 | return cls( |
| 51 | host=os.getenv("OPENJIUWEN_SERVICE_HOST", _DEFAULT_HOST), | 219 | host=os.getenv("OPENJIUWEN_SERVICE_HOST", _DEFAULT_HOST), |
| 52 | port=int(os.getenv("OPENJIUWEN_SERVICE_PORT", str(_DEFAULT_PORT))), | 220 | port=int(os.getenv("OPENJIUWEN_SERVICE_PORT", str(_DEFAULT_PORT))), |
| 53 | redis_url=os.getenv("OPENJIUWEN_SERVICE_REDIS_URL", _DEFAULT_REDIS_URL), | 221 | redis_url=os.getenv("OPENJIUWEN_SERVICE_REDIS_URL", _DEFAULT_REDIS_URL), |
| 54 | - key_prefix=os.getenv("OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX", _DEFAULT_KEY_PREFIX), | 222 | + key_prefix=os.getenv( |
| 223 | + "OPENJIUWEN_SERVICE_REDIS_KEY_PREFIX", _DEFAULT_KEY_PREFIX | ||
| 224 | + ), | ||
| 55 | title=os.getenv("OPENJIUWEN_SERVICE_TITLE", _DEFAULT_TITLE), | 225 | title=os.getenv("OPENJIUWEN_SERVICE_TITLE", _DEFAULT_TITLE), |
| 56 | - request_timeout_seconds=float(os.getenv( | 226 | + request_timeout_seconds=float( |
| 57 | - "OPENJIUWEN_SERVICE_REQUEST_TIMEOUT_SECONDS", | 227 | + os.getenv( |
| 58 | - str(_DEFAULT_REQUEST_TIMEOUT_SECONDS), | 228 | + "OPENJIUWEN_SERVICE_REQUEST_TIMEOUT_SECONDS", |
| 59 | - )), | 229 | + str(_DEFAULT_REQUEST_TIMEOUT_SECONDS), |
| 230 | + ) | ||
| 231 | + ), | ||
| 232 | + lock_backend=os.getenv( | ||
| 233 | + "OPENJIUWEN_SERVICE_LOCK_BACKEND", _DEFAULT_LOCK_BACKEND | ||
| 234 | + ).lower(), | ||
| 235 | + lock_key_prefix=os.getenv( | ||
| 236 | + "OPENJIUWEN_SERVICE_LOCK_KEY_PREFIX", _DEFAULT_LOCK_KEY_PREFIX | ||
| 237 | + ), | ||
| 238 | + lock_ttl_seconds=float( | ||
| 239 | + os.getenv( | ||
| 240 | + "OPENJIUWEN_SERVICE_LOCK_TTL_SECONDS", | ||
| 241 | + str(_DEFAULT_LOCK_TTL_SECONDS), | ||
| 242 | + ) | ||
| 243 | + ), | ||
| 244 | + lock_wait_seconds=float( | ||
| 245 | + os.getenv( | ||
| 246 | + "OPENJIUWEN_SERVICE_LOCK_WAIT_SECONDS", | ||
| 247 | + str(_DEFAULT_LOCK_WAIT_SECONDS), | ||
| 248 | + ) | ||
| 249 | + ), | ||
| 250 | + lock_renew_ratio=float( | ||
| 251 | + os.getenv( | ||
| 252 | + "OPENJIUWEN_SERVICE_LOCK_RENEW_RATIO", | ||
| 253 | + str(_DEFAULT_LOCK_RENEW_RATIO), | ||
| 254 | + ) | ||
| 255 | + ), | ||
| 256 | + lock_release_timeout_seconds=float( | ||
| 257 | + os.getenv( | ||
| 258 | + "OPENJIUWEN_SERVICE_LOCK_RELEASE_TIMEOUT_SECONDS", | ||
| 259 | + str(_DEFAULT_LOCK_RELEASE_TIMEOUT_SECONDS), | ||
| 260 | + ) | ||
| 261 | + ), | ||
| 262 | + deploy_replicas=int( | ||
| 263 | + os.getenv( | ||
| 264 | + "OPENJIUWEN_SERVICE_DEPLOY_REPLICAS", str(_DEFAULT_DEPLOY_REPLICAS) | ||
| 265 | + ) | ||
| 266 | + ), | ||
| 267 | + etcd_endpoints=cls._parse_endpoints(endpoints) if endpoints.strip() else (), | ||
| 268 | + etcd_username=os.getenv("OPENJIUWEN_SERVICE_ETCD_USERNAME") or None, | ||
| 269 | + etcd_password=os.getenv("OPENJIUWEN_SERVICE_ETCD_PASSWORD") or None, | ||
| 270 | + etcd_ca_cert=os.getenv("OPENJIUWEN_SERVICE_ETCD_CA_CERT") or None, | ||
| 271 | + etcd_cert=os.getenv("OPENJIUWEN_SERVICE_ETCD_CERT") or None, | ||
| 272 | + etcd_key=os.getenv("OPENJIUWEN_SERVICE_ETCD_KEY") or None, | ||
| 273 | + etcd_connect_timeout_seconds=float( | ||
| 274 | + os.getenv( | ||
| 275 | + "OPENJIUWEN_SERVICE_ETCD_CONNECT_TIMEOUT_SECONDS", | ||
| 276 | + str(_DEFAULT_ETCD_CONNECT_TIMEOUT_SECONDS), | ||
| 277 | + ) | ||
| 278 | + ), | ||
| 279 | + cache_backend=os.getenv( | ||
| 280 | + "OPENJIUWEN_SERVICE_CACHE_BACKEND", _DEFAULT_CACHE_BACKEND | ||
| 281 | + ).lower(), | ||
| 282 | + cache_key_prefix=os.getenv( | ||
| 283 | + "OPENJIUWEN_SERVICE_CACHE_KEY_PREFIX", _DEFAULT_CACHE_KEY_PREFIX | ||
| 284 | + ), | ||
| 285 | + cache_default_ttl_seconds=float( | ||
| 286 | + os.getenv( | ||
| 287 | + "OPENJIUWEN_SERVICE_CACHE_DEFAULT_TTL_SECONDS", | ||
| 288 | + str(_DEFAULT_CACHE_DEFAULT_TTL_SECONDS), | ||
| 289 | + ) | ||
| 290 | + ), | ||
| 291 | + cache_max_entries=int( | ||
| 292 | + os.getenv( | ||
| 293 | + "OPENJIUWEN_SERVICE_CACHE_MAX_ENTRIES", | ||
| 294 | + str(_DEFAULT_CACHE_MAX_ENTRIES), | ||
| 295 | + ) | ||
| 296 | + ), | ||
| 297 | + db_type=os.getenv("OPENJIUWEN_SERVICE_DB_TYPE", _DEFAULT_DB_TYPE).lower(), | ||
| 298 | + db_host=os.getenv("OPENJIUWEN_SERVICE_DB_HOST") or None, | ||
| 299 | + db_port=int(os.getenv("OPENJIUWEN_SERVICE_DB_PORT", str(_DEFAULT_DB_PORT))), | ||
| 300 | + db_name=os.getenv("OPENJIUWEN_SERVICE_DB_NAME") or None, | ||
| 301 | + db_user=os.getenv("OPENJIUWEN_SERVICE_DB_USER") or None, | ||
| 302 | + db_password=os.getenv("OPENJIUWEN_SERVICE_DB_PASSWORD"), | ||
| 60 | ) | 303 | ) |
| 304 | + | ||
| 305 | + | ||
| 306 | + def etcd_configured(self) -> bool: | ||
| 307 | + return bool(self.etcd_endpoints) | ||
| 308 | + | ||
| 309 | + | ||
| 310 | + def redis_configured(self) -> bool: | ||
| 311 | + return bool(self.redis_url) | ||
| 312 | + | ||
| 313 | + | ||
| 314 | + def multi_replica(self) -> bool: | ||
| 315 | + return self.deploy_replicas > 1 | ||
| @@ -2,15 +2,65 @@ | |||
| 2 | # Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | 2 | # Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved |
| 3 | 3 | ||
| 4 | from .audit import AuditEvent, AuditLogger, LoggingAuditLogger, NoopAuditLogger | 4 | from .audit import AuditEvent, AuditLogger, LoggingAuditLogger, NoopAuditLogger |
| 5 | +from .cache import ( | ||
| 6 | + BaseCacheBackend, | ||
| 7 | + Cache, | ||
| 8 | + CacheBackend, | ||
| 9 | + CacheBackendFactory, | ||
| 10 | + CacheMetrics, | ||
| 11 | + CacheSerializer, | ||
| 12 | + JsonCacheSerializer, | ||
| 13 | + MemoryCacheBackend, | ||
| 14 | + RedisCacheBackend, | ||
| 15 | + build_cache_backend, | ||
| 16 | + create_cache_backend, | ||
| 17 | +) | ||
| 18 | +from .locks import ( | ||
| 19 | + EtcdLockBackend, | ||
| 20 | + LeaseState, | ||
| 21 | + LockBackend, | ||
| 22 | + LockBackendFactory, | ||
| 23 | + LockCapabilities, | ||
| 24 | + LockCredential, | ||
| 25 | + LockLease, | ||
| 26 | + LockManager, | ||
| 27 | + MemoryLockBackend, | ||
| 28 | + RedisLockBackend, | ||
| 29 | + build_lock_backend, | ||
| 30 | + create_lock_backend, | ||
| 31 | +) | ||
| 5 | from .request_context import RequestContext, TypedAppContext | 32 | from .request_context import RequestContext, TypedAppContext |
| 6 | from .system_context import SystemContext | 33 | from .system_context import SystemContext |
| 7 | 34 | ||
| 8 | __all__ = [ | 35 | __all__ = [ |
| 9 | "AuditEvent", | 36 | "AuditEvent", |
| 10 | "AuditLogger", | 37 | "AuditLogger", |
| 38 | + "BaseCacheBackend", | ||
| 39 | + "Cache", | ||
| 40 | + "CacheBackend", | ||
| 41 | + "CacheBackendFactory", | ||
| 42 | + "CacheMetrics", | ||
| 43 | + "CacheSerializer", | ||
| 44 | + "EtcdLockBackend", | ||
| 11 | "LoggingAuditLogger", | 45 | "LoggingAuditLogger", |
| 46 | + "LeaseState", | ||
| 47 | + "LockBackend", | ||
| 48 | + "LockBackendFactory", | ||
| 49 | + "LockCapabilities", | ||
| 50 | + "LockCredential", | ||
| 51 | + "LockLease", | ||
| 52 | + "LockManager", | ||
| 53 | + "MemoryLockBackend", | ||
| 54 | + "MemoryCacheBackend", | ||
| 12 | "NoopAuditLogger", | 55 | "NoopAuditLogger", |
| 13 | "RequestContext", | 56 | "RequestContext", |
| 57 | + "RedisLockBackend", | ||
| 58 | + "RedisCacheBackend", | ||
| 14 | "SystemContext", | 59 | "SystemContext", |
| 15 | "TypedAppContext", | 60 | "TypedAppContext", |
| 61 | + "JsonCacheSerializer", | ||
| 62 | + "build_cache_backend", | ||
| 63 | + "build_lock_backend", | ||
| 64 | + "create_cache_backend", | ||
| 65 | + "create_lock_backend", | ||
| 16 | ] | 66 | ] |
| @@ -0,0 +1,28 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +from .base import ( | ||
| 5 | + BaseCacheBackend, | ||
| 6 | + Cache, | ||
| 7 | + CacheBackend, | ||
| 8 | + CacheMetrics, | ||
| 9 | + CacheSerializer, | ||
| 10 | + JsonCacheSerializer, | ||
| 11 | +) | ||
| 12 | +from .factory import CacheBackendFactory, build_cache_backend, create_cache_backend | ||
| 13 | +from .memory import MemoryCacheBackend | ||
| 14 | +from .redis import RedisCacheBackend | ||
| 15 | + | ||
| 16 | +__all__ = [ | ||
| 17 | + "BaseCacheBackend", | ||
| 18 | + "Cache", | ||
| 19 | + "CacheBackend", | ||
| 20 | + "CacheBackendFactory", | ||
| 21 | + "CacheMetrics", | ||
| 22 | + "CacheSerializer", | ||
| 23 | + "JsonCacheSerializer", | ||
| 24 | + "MemoryCacheBackend", | ||
| 25 | + "RedisCacheBackend", | ||
| 26 | + "build_cache_backend", | ||
| 27 | + "create_cache_backend", | ||
| 28 | +] | ||
| @@ -0,0 +1,354 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Cache backend contracts and the request-facing cache client.""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +import json | ||
| 9 | +import math | ||
| 10 | +from abc import ABC, abstractmethod | ||
| 11 | +from dataclasses import dataclass | ||
| 12 | +from typing import Any, NoReturn, Protocol, TypeVar, runtime_checkable | ||
| 13 | + | ||
| 14 | +from pydantic import BaseModel | ||
| 15 | + | ||
| 16 | +from ...errors import CacheUnavailable | ||
| 17 | + | ||
| 18 | +TModel = TypeVar("TModel", bound=BaseModel) | ||
| 19 | + | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +class CacheSerializer(Protocol): | ||
| 23 | + """Serialize JSON-compatible values for cache storage.""" | ||
| 24 | + | ||
| 25 | + def dumps(self, value: Any) -> str: | ||
| 26 | + raise NotImplementedError | ||
| 27 | + | ||
| 28 | + def loads(self, value: str) -> Any: | ||
| 29 | + raise NotImplementedError | ||
| 30 | + | ||
| 31 | + | ||
| 32 | +class JsonCacheSerializer: | ||
| 33 | + """Compact JSON serializer with native Pydantic model support.""" | ||
| 34 | + | ||
| 35 | + | ||
| 36 | + def dumps(value: Any) -> str: | ||
| 37 | + if isinstance(value, BaseModel): | ||
| 38 | + value = value.model_dump(mode="json") | ||
| 39 | + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) | ||
| 40 | + | ||
| 41 | + | ||
| 42 | + def loads(value: str) -> Any: | ||
| 43 | + return json.loads(value) | ||
| 44 | + | ||
| 45 | + | ||
| 46 | + | ||
| 47 | +class CacheMetrics: | ||
| 48 | + """In-process counters exposed by each cache backend.""" | ||
| 49 | + | ||
| 50 | + hits: int = 0 | ||
| 51 | + misses: int = 0 | ||
| 52 | + expirations: int = 0 | ||
| 53 | + evictions: int = 0 | ||
| 54 | + backend_errors: int = 0 | ||
| 55 | + | ||
| 56 | + | ||
| 57 | + def expired(self) -> int: | ||
| 58 | + """Compatibility alias for the number of lazily expired entries.""" | ||
| 59 | + return self.expirations | ||
| 60 | + | ||
| 61 | + | ||
| 62 | + def errors(self) -> int: | ||
| 63 | + """Compatibility alias for backend operation failures.""" | ||
| 64 | + return self.backend_errors | ||
| 65 | + | ||
| 66 | + | ||
| 67 | + | ||
| 68 | +class CacheBackend(Protocol): | ||
| 69 | + """String cache operations implemented by local and shared backends.""" | ||
| 70 | + | ||
| 71 | + metrics: CacheMetrics | ||
| 72 | + | ||
| 73 | + async def get(self, key: str) -> str | None: | ||
| 74 | + raise NotImplementedError | ||
| 75 | + | ||
| 76 | + async def set(self, key: str, value: str, ttl: float | None = None) -> None: | ||
| 77 | + raise NotImplementedError | ||
| 78 | + | ||
| 79 | + async def delete(self, key: str) -> bool: | ||
| 80 | + raise NotImplementedError | ||
| 81 | + | ||
| 82 | + async def exists(self, key: str) -> bool: | ||
| 83 | + raise NotImplementedError | ||
| 84 | + | ||
| 85 | + async def clear_namespace(self) -> int: | ||
| 86 | + raise NotImplementedError | ||
| 87 | + | ||
| 88 | + async def close(self) -> None: | ||
| 89 | + raise NotImplementedError | ||
| 90 | + | ||
| 91 | + | ||
| 92 | +class BaseCacheBackend(ABC): | ||
| 93 | + """Validation, metrics, and error normalization shared by cache backends.""" | ||
| 94 | + | ||
| 95 | + def __init__( | ||
| 96 | + self, | ||
| 97 | + *, | ||
| 98 | + prefix: str, | ||
| 99 | + default_ttl: float | None = 300, | ||
| 100 | + max_value_bytes: int = 1024 * 1024, | ||
| 101 | + ) -> None: | ||
| 102 | + if not isinstance(prefix, str): | ||
| 103 | + raise TypeError("prefix must be a string") | ||
| 104 | + if default_ttl is not None: | ||
| 105 | + default_ttl = self._validate_ttl(default_ttl) | ||
| 106 | + if isinstance(max_value_bytes, bool) or int(max_value_bytes) <= 0: | ||
| 107 | + raise ValueError("max_value_bytes must be a positive integer") | ||
| 108 | + self.prefix = prefix.rstrip(":") | ||
| 109 | + self.default_ttl = default_ttl | ||
| 110 | + self.max_value_bytes = int(max_value_bytes) | ||
| 111 | + self.metrics = CacheMetrics() | ||
| 112 | + self._closed = False | ||
| 113 | + | ||
| 114 | + def format_key(self, key: str) -> str: | ||
| 115 | + key = self._validate_key(key) | ||
| 116 | + return f"{self.prefix}:{key}" if self.prefix else key | ||
| 117 | + | ||
| 118 | + async def get(self, key: str) -> str | None: | ||
| 119 | + full_key = self.format_key(key) | ||
| 120 | + self._ensure_open() | ||
| 121 | + try: | ||
| 122 | + value = await self._get(full_key) | ||
| 123 | + except Exception as exc: # noqa: BLE001 - normalize backend failures | ||
| 124 | + self._raise_unavailable("get", exc) | ||
| 125 | + if value is None: | ||
| 126 | + self.metrics.misses += 1 | ||
| 127 | + else: | ||
| 128 | + self.metrics.hits += 1 | ||
| 129 | + return value | ||
| 130 | + | ||
| 131 | + async def set(self, key: str, value: str, ttl: float | None = None) -> None: | ||
| 132 | + full_key = self.format_key(key) | ||
| 133 | + value = self._validate_value(value) | ||
| 134 | + resolved_ttl = self.default_ttl if ttl is None else self._validate_ttl(ttl) | ||
| 135 | + self._ensure_open() | ||
| 136 | + try: | ||
| 137 | + await self._set(full_key, value, resolved_ttl) | ||
| 138 | + except Exception as exc: # noqa: BLE001 - normalize backend failures | ||
| 139 | + self._raise_unavailable("set", exc) | ||
| 140 | + | ||
| 141 | + async def delete(self, key: str) -> bool: | ||
| 142 | + full_key = self.format_key(key) | ||
| 143 | + self._ensure_open() | ||
| 144 | + try: | ||
| 145 | + return await self._delete(full_key) | ||
| 146 | + except Exception as exc: # noqa: BLE001 - normalize backend failures | ||
| 147 | + self._raise_unavailable("delete", exc) | ||
| 148 | + | ||
| 149 | + async def exists(self, key: str) -> bool: | ||
| 150 | + full_key = self.format_key(key) | ||
| 151 | + self._ensure_open() | ||
| 152 | + try: | ||
| 153 | + return await self._exists(full_key) | ||
| 154 | + except Exception as exc: # noqa: BLE001 - normalize backend failures | ||
| 155 | + self._raise_unavailable("exists", exc) | ||
| 156 | + | ||
| 157 | + async def clear_namespace(self) -> int: | ||
| 158 | + self._ensure_open() | ||
| 159 | + try: | ||
| 160 | + return await self._clear_namespace() | ||
| 161 | + except Exception as exc: # noqa: BLE001 - normalize backend failures | ||
| 162 | + self._raise_unavailable("clear_namespace", exc) | ||
| 163 | + | ||
| 164 | + async def get_json( | ||
| 165 | + self, | ||
| 166 | + key: str, | ||
| 167 | + default: Any = None, | ||
| 168 | + *, | ||
| 169 | + model: type[TModel] | None = None, | ||
| 170 | + ) -> Any | TModel: | ||
| 171 | + """Read and deserialize JSON without creating a request facade.""" | ||
| 172 | + return await Cache(self).get_json(key, default=default, model=model) | ||
| 173 | + | ||
| 174 | + async def set_json( | ||
| 175 | + self, | ||
| 176 | + key: str, | ||
| 177 | + value: Any, | ||
| 178 | + ttl: float | None = None, | ||
| 179 | + ) -> None: | ||
| 180 | + """Serialize JSON or a Pydantic model and store it.""" | ||
| 181 | + await Cache(self).set_json(key, value, ttl=ttl) | ||
| 182 | + | ||
| 183 | + async def get_model( | ||
| 184 | + self, | ||
| 185 | + key: str, | ||
| 186 | + model: type[TModel], | ||
| 187 | + default: TModel | None = None, | ||
| 188 | + ) -> TModel | None: | ||
| 189 | + """Read a cached object and validate it as a Pydantic model.""" | ||
| 190 | + return await Cache(self).get_model(key, model, default=default) | ||
| 191 | + | ||
| 192 | + async def close(self) -> None: | ||
| 193 | + if self._closed: | ||
| 194 | + return | ||
| 195 | + self._closed = True | ||
| 196 | + try: | ||
| 197 | + await self._close() | ||
| 198 | + except Exception as exc: # noqa: BLE001 - normalize backend failures | ||
| 199 | + self._raise_unavailable("close", exc) | ||
| 200 | + | ||
| 201 | + def _validate_value(self, value: str) -> str: | ||
| 202 | + if not isinstance(value, str): | ||
| 203 | + raise TypeError("cache values must be strings") | ||
| 204 | + size = len(value.encode("utf-8")) | ||
| 205 | + if size > self.max_value_bytes: | ||
| 206 | + raise ValueError( | ||
| 207 | + f"cache value is {size} bytes; limit is {self.max_value_bytes} bytes" | ||
| 208 | + ) | ||
| 209 | + return value | ||
| 210 | + | ||
| 211 | + | ||
| 212 | + def _validate_key(key: str) -> str: | ||
| 213 | + if not isinstance(key, str): | ||
| 214 | + raise TypeError("cache key must be a string") | ||
| 215 | + if not key: | ||
| 216 | + raise ValueError("cache key must not be empty") | ||
| 217 | + return key | ||
| 218 | + | ||
| 219 | + | ||
| 220 | + def _validate_ttl(ttl: float) -> float: | ||
| 221 | + ttl = float(ttl) | ||
| 222 | + if not math.isfinite(ttl) or ttl <= 0: | ||
| 223 | + raise ValueError("ttl must be a finite positive number") | ||
| 224 | + return ttl | ||
| 225 | + | ||
| 226 | + def _ensure_open(self) -> None: | ||
| 227 | + if self._closed: | ||
| 228 | + raise CacheUnavailable("cache backend is closed") | ||
| 229 | + | ||
| 230 | + def _raise_unavailable(self, operation: str, exc: Exception) -> NoReturn: | ||
| 231 | + self.metrics.backend_errors += 1 | ||
| 232 | + if isinstance(exc, CacheUnavailable): | ||
| 233 | + raise exc | ||
| 234 | + raise CacheUnavailable(f"cache {operation} failed: {exc}") from exc | ||
| 235 | + | ||
| 236 | + | ||
| 237 | + async def _get(self, key: str) -> str | None: | ||
| 238 | + raise NotImplementedError | ||
| 239 | + | ||
| 240 | + | ||
| 241 | + async def _set(self, key: str, value: str, ttl: float | None) -> None: | ||
| 242 | + raise NotImplementedError | ||
| 243 | + | ||
| 244 | + | ||
| 245 | + async def _delete(self, key: str) -> bool: | ||
| 246 | + raise NotImplementedError | ||
| 247 | + | ||
| 248 | + | ||
| 249 | + async def _exists(self, key: str) -> bool: | ||
| 250 | + raise NotImplementedError | ||
| 251 | + | ||
| 252 | + | ||
| 253 | + async def _clear_namespace(self) -> int: | ||
| 254 | + raise NotImplementedError | ||
| 255 | + | ||
| 256 | + | ||
| 257 | + async def _close(self) -> None: | ||
| 258 | + raise NotImplementedError | ||
| 259 | + | ||
| 260 | + | ||
| 261 | +class Cache: | ||
| 262 | + """Request-scoped JSON facade over a process-scoped cache backend.""" | ||
| 263 | + | ||
| 264 | + def __init__( | ||
| 265 | + self, | ||
| 266 | + backend: CacheBackend, | ||
| 267 | + *, | ||
| 268 | + serializer: CacheSerializer | None = None, | ||
| 269 | + ) -> None: | ||
| 270 | + if not isinstance(backend, CacheBackend): | ||
| 271 | + raise TypeError("backend must implement CacheBackend") | ||
| 272 | + self.backend = backend | ||
| 273 | + self.serializer = serializer or JsonCacheSerializer() | ||
| 274 | + self._closed = False | ||
| 275 | + | ||
| 276 | + | ||
| 277 | + def metrics(self) -> CacheMetrics: | ||
| 278 | + return self.backend.metrics | ||
| 279 | + | ||
| 280 | + async def get(self, key: str) -> str | None: | ||
| 281 | + self._ensure_open() | ||
| 282 | + return await self.backend.get(key) | ||
| 283 | + | ||
| 284 | + async def set(self, key: str, value: str, ttl: float | None = None) -> None: | ||
| 285 | + self._ensure_open() | ||
| 286 | + await self.backend.set(key, value, ttl=ttl) | ||
| 287 | + | ||
| 288 | + async def delete(self, key: str) -> bool: | ||
| 289 | + self._ensure_open() | ||
| 290 | + return await self.backend.delete(key) | ||
| 291 | + | ||
| 292 | + async def exists(self, key: str) -> bool: | ||
| 293 | + self._ensure_open() | ||
| 294 | + return await self.backend.exists(key) | ||
| 295 | + | ||
| 296 | + async def clear_namespace(self) -> int: | ||
| 297 | + self._ensure_open() | ||
| 298 | + return await self.backend.clear_namespace() | ||
| 299 | + | ||
| 300 | + async def get_json( | ||
| 301 | + self, | ||
| 302 | + key: str, | ||
| 303 | + default: Any = None, | ||
| 304 | + *, | ||
| 305 | + model: type[TModel] | None = None, | ||
| 306 | + ) -> Any | TModel: | ||
| 307 | + raw = await self.get(key) | ||
| 308 | + if raw is None: | ||
| 309 | + return default | ||
| 310 | + try: | ||
| 311 | + value = self.serializer.loads(raw) | ||
| 312 | + return model.model_validate(value) if model is not None else value | ||
| 313 | + except Exception as exc: # noqa: BLE001 - expose one cache-facing error | ||
| 314 | + raise CacheUnavailable(f"cache value for {key!r} is invalid") from exc | ||
| 315 | + | ||
| 316 | + async def set_json( | ||
| 317 | + self, | ||
| 318 | + key: str, | ||
| 319 | + value: Any, | ||
| 320 | + ttl: float | None = None, | ||
| 321 | + ) -> None: | ||
| 322 | + try: | ||
| 323 | + serialized = self.serializer.dumps(value) | ||
| 324 | + except Exception as exc: # noqa: BLE001 - expose one cache-facing error | ||
| 325 | + raise CacheUnavailable( | ||
| 326 | + f"cache value for {key!r} is not serializable" | ||
| 327 | + ) from exc | ||
| 328 | + await self.set(key, serialized, ttl=ttl) | ||
| 329 | + | ||
| 330 | + async def get_model( | ||
| 331 | + self, | ||
| 332 | + key: str, | ||
| 333 | + model: type[TModel], | ||
| 334 | + default: TModel | None = None, | ||
| 335 | + ) -> TModel | None: | ||
| 336 | + return await self.get_json(key, default=default, model=model) | ||
| 337 | + | ||
| 338 | + async def close(self) -> None: | ||
| 339 | + """Close this request facade without closing the shared backend.""" | ||
| 340 | + self._closed = True | ||
| 341 | + | ||
| 342 | + def _ensure_open(self) -> None: | ||
| 343 | + if self._closed: | ||
| 344 | + raise CacheUnavailable("request cache client is closed") | ||
| 345 | + | ||
| 346 | + | ||
| 347 | +__all__ = [ | ||
| 348 | + "BaseCacheBackend", | ||
| 349 | + "Cache", | ||
| 350 | + "CacheBackend", | ||
| 351 | + "CacheMetrics", | ||
| 352 | + "CacheSerializer", | ||
| 353 | + "JsonCacheSerializer", | ||
| 354 | +] | ||
| @@ -0,0 +1,71 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Cache backend construction helpers.""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +from typing import Any | ||
| 9 | + | ||
| 10 | +from ...errors import CacheUnavailable | ||
| 11 | +from .base import CacheBackend | ||
| 12 | +from .memory import MemoryCacheBackend | ||
| 13 | +from .redis import RedisCacheBackend | ||
| 14 | + | ||
| 15 | + | ||
| 16 | +def build_cache_backend( | ||
| 17 | + backend: str | Any = "memory", | ||
| 18 | + *, | ||
| 19 | + redis: Any = None, | ||
| 20 | + key_prefix: str = "service:cache", | ||
| 21 | + default_ttl: float | None = 300, | ||
| 22 | + max_entries: int = 1000, | ||
| 23 | + max_value_bytes: int = 1024 * 1024, | ||
| 24 | + owns_redis: bool = False, | ||
| 25 | +) -> CacheBackend | None: | ||
| 26 | + """Build a local or Redis cache backend from an explicit selection.""" | ||
| 27 | + if not isinstance(backend, str): | ||
| 28 | + config = backend | ||
| 29 | + return build_cache_backend( | ||
| 30 | + getattr(config, "cache_backend", "memory"), | ||
| 31 | + redis=redis, | ||
| 32 | + key_prefix=getattr(config, "cache_key_prefix", key_prefix), | ||
| 33 | + default_ttl=getattr(config, "cache_default_ttl_seconds", default_ttl), | ||
| 34 | + max_entries=getattr(config, "cache_max_entries", max_entries), | ||
| 35 | + max_value_bytes=max_value_bytes, | ||
| 36 | + owns_redis=owns_redis, | ||
| 37 | + ) | ||
| 38 | + selected = str(backend or "none").strip().lower() | ||
| 39 | + if selected == "none": | ||
| 40 | + return None | ||
| 41 | + if selected == "memory": | ||
| 42 | + return MemoryCacheBackend( | ||
| 43 | + prefix=key_prefix, | ||
| 44 | + default_ttl=default_ttl, | ||
| 45 | + max_entries=max_entries, | ||
| 46 | + max_value_bytes=max_value_bytes, | ||
| 47 | + ) | ||
| 48 | + if selected == "redis": | ||
| 49 | + if redis is None: | ||
| 50 | + raise CacheUnavailable("Redis cache backend requires a Redis client") | ||
| 51 | + return RedisCacheBackend( | ||
| 52 | + redis, | ||
| 53 | + prefix=key_prefix, | ||
| 54 | + default_ttl=default_ttl, | ||
| 55 | + max_value_bytes=max_value_bytes, | ||
| 56 | + owns_redis=owns_redis, | ||
| 57 | + ) | ||
| 58 | + raise ValueError("cache backend must be one of memory, redis, none") | ||
| 59 | + | ||
| 60 | + | ||
| 61 | +class CacheBackendFactory: | ||
| 62 | + """Object-oriented facade for cache backend construction.""" | ||
| 63 | + | ||
| 64 | + | ||
| 65 | + def build(*args: Any, **kwargs: Any) -> CacheBackend | None: | ||
| 66 | + return build_cache_backend(*args, **kwargs) | ||
| 67 | + | ||
| 68 | + | ||
| 69 | +create_cache_backend = build_cache_backend | ||
| 70 | + | ||
| 71 | +__all__ = ["CacheBackendFactory", "build_cache_backend", "create_cache_backend"] | ||
| @@ -0,0 +1,104 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Bounded process-local LRU cache with lazy TTL expiration.""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +import asyncio | ||
| 9 | +import time | ||
| 10 | +from collections import OrderedDict | ||
| 11 | +from dataclasses import dataclass | ||
| 12 | + | ||
| 13 | +from .base import BaseCacheBackend | ||
| 14 | + | ||
| 15 | + | ||
| 16 | + | ||
| 17 | +class _Entry: | ||
| 18 | + value: str | ||
| 19 | + expires_at: float | None | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +class MemoryCacheBackend(BaseCacheBackend): | ||
| 23 | + """A concurrency-safe local cache for reconstructable hot data.""" | ||
| 24 | + | ||
| 25 | + def __init__( | ||
| 26 | + self, | ||
| 27 | + *, | ||
| 28 | + prefix: str = "service:cache", | ||
| 29 | + default_ttl: float | None = 300, | ||
| 30 | + max_entries: int = 1000, | ||
| 31 | + max_value_bytes: int = 1024 * 1024, | ||
| 32 | + ) -> None: | ||
| 33 | + super().__init__( | ||
| 34 | + prefix=prefix, | ||
| 35 | + default_ttl=default_ttl, | ||
| 36 | + max_value_bytes=max_value_bytes, | ||
| 37 | + ) | ||
| 38 | + if isinstance(max_entries, bool) or int(max_entries) <= 0: | ||
| 39 | + raise ValueError("max_entries must be a positive integer") | ||
| 40 | + self.max_entries = int(max_entries) | ||
| 41 | + self._entries: OrderedDict[str, _Entry] = OrderedDict() | ||
| 42 | + self._lock = asyncio.Lock() | ||
| 43 | + | ||
| 44 | + async def _get(self, key: str) -> str | None: | ||
| 45 | + async with self._lock: | ||
| 46 | + self._ensure_open() | ||
| 47 | + self._purge_expired(time.monotonic()) | ||
| 48 | + entry = self._entries.get(key) | ||
| 49 | + if entry is None: | ||
| 50 | + return None | ||
| 51 | + self._entries.move_to_end(key) | ||
| 52 | + return entry.value | ||
| 53 | + | ||
| 54 | + async def _set(self, key: str, value: str, ttl: float | None) -> None: | ||
| 55 | + async with self._lock: | ||
| 56 | + self._ensure_open() | ||
| 57 | + now = time.monotonic() | ||
| 58 | + self._purge_expired(now) | ||
| 59 | + self._entries.pop(key, None) | ||
| 60 | + while len(self._entries) >= self.max_entries: | ||
| 61 | + self._entries.popitem(last=False) | ||
| 62 | + self.metrics.evictions += 1 | ||
| 63 | + expires_at = None if ttl is None else now + ttl | ||
| 64 | + self._entries[key] = _Entry(value=value, expires_at=expires_at) | ||
| 65 | + | ||
| 66 | + async def _delete(self, key: str) -> bool: | ||
| 67 | + async with self._lock: | ||
| 68 | + self._ensure_open() | ||
| 69 | + self._purge_expired(time.monotonic()) | ||
| 70 | + return self._entries.pop(key, None) is not None | ||
| 71 | + | ||
| 72 | + async def _exists(self, key: str) -> bool: | ||
| 73 | + async with self._lock: | ||
| 74 | + self._ensure_open() | ||
| 75 | + self._purge_expired(time.monotonic()) | ||
| 76 | + return key in self._entries | ||
| 77 | + | ||
| 78 | + async def _clear_namespace(self) -> int: | ||
| 79 | + async with self._lock: | ||
| 80 | + self._ensure_open() | ||
| 81 | + count = len(self._entries) | ||
| 82 | + self._entries.clear() | ||
| 83 | + return count | ||
| 84 | + | ||
| 85 | + async def _close(self) -> None: | ||
| 86 | + async with self._lock: | ||
| 87 | + self._entries.clear() | ||
| 88 | + | ||
| 89 | + async def ping(self) -> bool: | ||
| 90 | + self._ensure_open() | ||
| 91 | + return True | ||
| 92 | + | ||
| 93 | + def _purge_expired(self, now: float) -> None: | ||
| 94 | + expired = [ | ||
| 95 | + key | ||
| 96 | + for key, entry in self._entries.items() | ||
| 97 | + if entry.expires_at is not None and entry.expires_at <= now | ||
| 98 | + ] | ||
| 99 | + for key in expired: | ||
| 100 | + del self._entries[key] | ||
| 101 | + self.metrics.expirations += len(expired) | ||
| 102 | + | ||
| 103 | + | ||
| 104 | +__all__ = ["MemoryCacheBackend"] | ||
| @@ -0,0 +1,93 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Redis-backed shared cache.""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +import math | ||
| 9 | +from typing import Any | ||
| 10 | + | ||
| 11 | +from .base import BaseCacheBackend | ||
| 12 | + | ||
| 13 | + | ||
| 14 | +class RedisCacheBackend(BaseCacheBackend): | ||
| 15 | + """Cross-replica cache using namespaced Redis keys and millisecond TTLs.""" | ||
| 16 | + | ||
| 17 | + def __init__( | ||
| 18 | + self, | ||
| 19 | + redis: Any, | ||
| 20 | + *, | ||
| 21 | + prefix: str = "service:cache", | ||
| 22 | + default_ttl: float | None = 300, | ||
| 23 | + max_value_bytes: int = 1024 * 1024, | ||
| 24 | + owns_redis: bool = False, | ||
| 25 | + scan_count: int = 100, | ||
| 26 | + ) -> None: | ||
| 27 | + if not prefix or not prefix.rstrip(":"): | ||
| 28 | + raise ValueError("Redis cache prefix must not be empty") | ||
| 29 | + super().__init__( | ||
| 30 | + prefix=prefix, | ||
| 31 | + default_ttl=default_ttl, | ||
| 32 | + max_value_bytes=max_value_bytes, | ||
| 33 | + ) | ||
| 34 | + if isinstance(scan_count, bool) or int(scan_count) <= 0: | ||
| 35 | + raise ValueError("scan_count must be a positive integer") | ||
| 36 | + self._redis = redis | ||
| 37 | + self._owns_redis = owns_redis | ||
| 38 | + self.scan_count = int(scan_count) | ||
| 39 | + | ||
| 40 | + async def _get(self, key: str) -> str | None: | ||
| 41 | + value = await self._redis.get(key) | ||
| 42 | + if value is None: | ||
| 43 | + return None | ||
| 44 | + if isinstance(value, str): | ||
| 45 | + return value | ||
| 46 | + return bytes(value).decode("utf-8") | ||
| 47 | + | ||
| 48 | + async def _set(self, key: str, value: str, ttl: float | None) -> None: | ||
| 49 | + if ttl is None: | ||
| 50 | + await self._redis.set(key, value) | ||
| 51 | + return | ||
| 52 | + ttl_ms = max(1, math.ceil(ttl * 1000)) | ||
| 53 | + await self._redis.set(key, value, px=ttl_ms) | ||
| 54 | + | ||
| 55 | + async def _delete(self, key: str) -> bool: | ||
| 56 | + return bool(await self._redis.delete(key)) | ||
| 57 | + | ||
| 58 | + async def _exists(self, key: str) -> bool: | ||
| 59 | + return bool(await self._redis.exists(key)) | ||
| 60 | + | ||
| 61 | + async def _clear_namespace(self) -> int: | ||
| 62 | + cursor: int | bytes = 0 | ||
| 63 | + keys_to_delete: list[Any] = [] | ||
| 64 | + pattern = f"{self.prefix}:*" | ||
| 65 | + while True: | ||
| 66 | + cursor, keys = await self._redis.scan( | ||
| 67 | + cursor=cursor, | ||
| 68 | + match=pattern, | ||
| 69 | + count=self.scan_count, | ||
| 70 | + ) | ||
| 71 | + keys_to_delete.extend(keys) | ||
| 72 | + if int(cursor) == 0: | ||
| 73 | + break | ||
| 74 | + deleted = 0 | ||
| 75 | + for offset in range(0, len(keys_to_delete), self.scan_count): | ||
| 76 | + end = offset + self.scan_count | ||
| 77 | + batch = keys_to_delete[offset:end] | ||
| 78 | + deleted += int(await self._redis.delete(*batch)) | ||
| 79 | + return deleted | ||
| 80 | + | ||
| 81 | + async def _close(self) -> None: | ||
| 82 | + if self._owns_redis: | ||
| 83 | + await self._redis.aclose() | ||
| 84 | + | ||
| 85 | + async def ping(self) -> bool: | ||
| 86 | + self._ensure_open() | ||
| 87 | + try: | ||
| 88 | + return bool(await self._redis.ping()) | ||
| 89 | + except Exception as exc: # noqa: BLE001 - normalize Redis failures | ||
| 90 | + self._raise_unavailable("ping", exc) | ||
| 91 | + | ||
| 92 | + | ||
| 93 | +__all__ = ["RedisCacheBackend"] | ||
| @@ -0,0 +1,23 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +from .base import LockBackend, LockCapabilities, LockCredential | ||
| 5 | +from .backends import EtcdLockBackend, MemoryLockBackend, RedisLockBackend | ||
| 6 | +from .factory import LockBackendFactory, build_lock_backend, create_lock_backend | ||
| 7 | +from .lease import LeaseState, LockLease | ||
| 8 | +from .manager import LockManager | ||
| 9 | + | ||
| 10 | +__all__ = [ | ||
| 11 | + "EtcdLockBackend", | ||
| 12 | + "LeaseState", | ||
| 13 | + "LockBackend", | ||
| 14 | + "LockBackendFactory", | ||
| 15 | + "LockCapabilities", | ||
| 16 | + "LockCredential", | ||
| 17 | + "LockLease", | ||
| 18 | + "LockManager", | ||
| 19 | + "MemoryLockBackend", | ||
| 20 | + "RedisLockBackend", | ||
| 21 | + "build_lock_backend", | ||
| 22 | + "create_lock_backend", | ||
| 23 | +] | ||
| @@ -0,0 +1,17 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Built-in lock backend implementations.""" | ||
| 5 | + | ||
| 6 | +from .etcd import EtcdLockBackend, EtcdEndpoint, create_etcd_client, parse_etcd_endpoint | ||
| 7 | +from .memory import MemoryLockBackend | ||
| 8 | +from .redis import RedisLockBackend | ||
| 9 | + | ||
| 10 | +__all__ = [ | ||
| 11 | + "EtcdEndpoint", | ||
| 12 | + "EtcdLockBackend", | ||
| 13 | + "MemoryLockBackend", | ||
| 14 | + "RedisLockBackend", | ||
| 15 | + "create_etcd_client", | ||
| 16 | + "parse_etcd_endpoint", | ||
| 17 | +] | ||
| @@ -0,0 +1,448 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""etcd v3 lease/CAS lock backend implemented with ``aetcd``.""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +import asyncio | ||
| 9 | +import math | ||
| 10 | +import time | ||
| 11 | +from dataclasses import dataclass | ||
| 12 | +from pathlib import Path | ||
| 13 | +from typing import Any | ||
| 14 | +from urllib.parse import urlparse | ||
| 15 | +from uuid import uuid4 | ||
| 16 | + | ||
| 17 | +from ....errors import InvalidLockLease, LockBackendUnavailable, LockLost | ||
| 18 | +from ..base import LockCapabilities, LockCredential | ||
| 19 | + | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +class EtcdEndpoint: | ||
| 23 | + """Parsed endpoint used by the aetcd client factory.""" | ||
| 24 | + | ||
| 25 | + scheme: str | ||
| 26 | + host: str | ||
| 27 | + port: int | ||
| 28 | + | ||
| 29 | + | ||
| 30 | + def tls(self) -> bool: | ||
| 31 | + return self.scheme in {"https", "grpcs"} | ||
| 32 | + | ||
| 33 | + | ||
| 34 | + | ||
| 35 | +class _OwnedLease: | ||
| 36 | + lease: Any | ||
| 37 | + key: str | ||
| 38 | + token: str | ||
| 39 | + | ||
| 40 | + | ||
| 41 | +def parse_etcd_endpoint(endpoint: str) -> EtcdEndpoint: | ||
| 42 | + value = endpoint.strip() | ||
| 43 | + if not value: | ||
| 44 | + raise ValueError("etcd endpoint must not be empty") | ||
| 45 | + parsed = urlparse(value if "://" in value else f"http://{value}") | ||
| 46 | + if parsed.scheme not in {"http", "https", "grpc", "grpcs"} or not parsed.hostname: | ||
| 47 | + raise ValueError(f"invalid etcd endpoint: {endpoint!r}") | ||
| 48 | + default_port = 2379 | ||
| 49 | + port = parsed.port or default_port | ||
| 50 | + if not 1 <= port <= 65535: | ||
| 51 | + raise ValueError("etcd endpoint port must be between 1 and 65535") | ||
| 52 | + return EtcdEndpoint(parsed.scheme, parsed.hostname, port) | ||
| 53 | + | ||
| 54 | + | ||
| 55 | +def create_etcd_client( | ||
| 56 | + endpoints: str | list[str] | tuple[str, ...], | ||
| 57 | + *, | ||
| 58 | + username: str | None = None, | ||
| 59 | + password: str | None = None, | ||
| 60 | + connect_timeout: float | None = None, | ||
| 61 | + tls_ca_cert: str | None = None, | ||
| 62 | + tls_cert: str | None = None, | ||
| 63 | + tls_key: str | None = None, | ||
| 64 | +) -> Any: | ||
| 65 | + """Create an ``aetcd`` client from endpoint, TLS and authentication settings.""" | ||
| 66 | + import aetcd | ||
| 67 | + | ||
| 68 | + values = endpoints.split(",") if isinstance(endpoints, str) else list(endpoints) | ||
| 69 | + parsed = [parse_etcd_endpoint(value) for value in values] | ||
| 70 | + if not parsed: | ||
| 71 | + raise ValueError("at least one etcd endpoint is required") | ||
| 72 | + if (username is None) != (password is None): | ||
| 73 | + raise ValueError("etcd username and password must be configured together") | ||
| 74 | + if (tls_cert is None) != (tls_key is None): | ||
| 75 | + raise ValueError("etcd client certificate and key must be configured together") | ||
| 76 | + timeout = ( | ||
| 77 | + None if connect_timeout is None else max(1, math.ceil(float(connect_timeout))) | ||
| 78 | + ) | ||
| 79 | + clients = [ | ||
| 80 | + _build_aetcd_client( | ||
| 81 | + aetcd, | ||
| 82 | + endpoint, | ||
| 83 | + username=username, | ||
| 84 | + password=password, | ||
| 85 | + timeout=timeout, | ||
| 86 | + tls_ca_cert=tls_ca_cert, | ||
| 87 | + tls_cert=tls_cert, | ||
| 88 | + tls_key=tls_key, | ||
| 89 | + ) | ||
| 90 | + for endpoint in parsed | ||
| 91 | + ] | ||
| 92 | + return clients[0] if len(clients) == 1 else _EtcdClientPool(clients) | ||
| 93 | + | ||
| 94 | + | ||
| 95 | +def _build_aetcd_client( | ||
| 96 | + aetcd: Any, | ||
| 97 | + endpoint: EtcdEndpoint, | ||
| 98 | + *, | ||
| 99 | + username: str | None, | ||
| 100 | + password: str | None, | ||
| 101 | + timeout: int | None, | ||
| 102 | + tls_ca_cert: str | None, | ||
| 103 | + tls_cert: str | None, | ||
| 104 | + tls_key: str | None, | ||
| 105 | +) -> Any: | ||
| 106 | + kwargs = { | ||
| 107 | + "host": endpoint.host, | ||
| 108 | + "port": endpoint.port, | ||
| 109 | + "username": username, | ||
| 110 | + "password": password, | ||
| 111 | + "timeout": timeout, | ||
| 112 | + } | ||
| 113 | + if not endpoint.tls and not any((tls_ca_cert, tls_cert, tls_key)): | ||
| 114 | + return aetcd.Client(**kwargs) | ||
| 115 | + | ||
| 116 | + import grpc | ||
| 117 | + | ||
| 118 | + root_certificates = Path(tls_ca_cert).read_bytes() if tls_ca_cert else None | ||
| 119 | + certificate_chain = Path(tls_cert).read_bytes() if tls_cert else None | ||
| 120 | + private_key = Path(tls_key).read_bytes() if tls_key else None | ||
| 121 | + credentials = grpc.ssl_channel_credentials( | ||
| 122 | + root_certificates=root_certificates, | ||
| 123 | + private_key=private_key, | ||
| 124 | + certificate_chain=certificate_chain, | ||
| 125 | + ) | ||
| 126 | + | ||
| 127 | + class _SecureAetcdClient(aetcd.Client): | ||
| 128 | + async def connect(self) -> None: | ||
| 129 | + if self._connected.is_set(): | ||
| 130 | + return | ||
| 131 | + if self._is_connecting: | ||
| 132 | + await asyncio.wait_for( | ||
| 133 | + self._connected.wait(), self._connect_wait_timeout | ||
| 134 | + ) | ||
| 135 | + return | ||
| 136 | + try: | ||
| 137 | + self._is_connecting = True | ||
| 138 | + target = f"{self._host}:{self._port}" | ||
| 139 | + self.channel = aetcd.rpc.secure_channel( | ||
| 140 | + target, | ||
| 141 | + credentials, | ||
| 142 | + options=self._options.items(), | ||
| 143 | + ) | ||
| 144 | + if self._username is not None and self._password is not None: | ||
| 145 | + self.auth_stub = aetcd.rpc.AuthStub(self.channel) | ||
| 146 | + request = aetcd.rpc.AuthenticateRequest( | ||
| 147 | + name=self._username, | ||
| 148 | + password=self._password, | ||
| 149 | + ) | ||
| 150 | + response = await self.auth_stub.Authenticate( | ||
| 151 | + request, | ||
| 152 | + timeout=self._timeout, | ||
| 153 | + ) | ||
| 154 | + self.metadata = (("token", response.token),) | ||
| 155 | + self.kvstub = aetcd.rpc.KVStub(self.channel) | ||
| 156 | + self.clusterstub = aetcd.rpc.ClusterStub(self.channel) | ||
| 157 | + self.leasestub = aetcd.rpc.LeaseStub(self.channel) | ||
| 158 | + self.maintenancestub = aetcd.rpc.MaintenanceStub(self.channel) | ||
| 159 | + self._watcher = aetcd.watcher.Watcher( | ||
| 160 | + aetcd.rpc.WatchStub(self.channel), | ||
| 161 | + timeout=self._timeout, | ||
| 162 | + metadata=self.metadata, | ||
| 163 | + ) | ||
| 164 | + self._connected.set() | ||
| 165 | + finally: | ||
| 166 | + self._is_connecting = False | ||
| 167 | + | ||
| 168 | + return _SecureAetcdClient(**kwargs) | ||
| 169 | + | ||
| 170 | + | ||
| 171 | +class _PooledLease: | ||
| 172 | + def __init__(self, client: "_EtcdClientPool", lease_id: int, ttl: int) -> None: | ||
| 173 | + self._client = client | ||
| 174 | + self.id = lease_id | ||
| 175 | + self.ttl = ttl | ||
| 176 | + | ||
| 177 | + async def refresh(self) -> Any: | ||
| 178 | + return await self._client.refresh_lease(self.id) | ||
| 179 | + | ||
| 180 | + async def revoke(self) -> None: | ||
| 181 | + await self._client.revoke_lease(self.id) | ||
| 182 | + | ||
| 183 | + | ||
| 184 | +class _EtcdClientPool: | ||
| 185 | + """Small endpoint failover facade over pinned single-endpoint aetcd clients.""" | ||
| 186 | + | ||
| 187 | + def __init__(self, clients: list[Any]) -> None: | ||
| 188 | + self._clients = clients | ||
| 189 | + self._active = 0 | ||
| 190 | + self.transactions = clients[0].transactions | ||
| 191 | + | ||
| 192 | + async def connect(self) -> None: | ||
| 193 | + await self._call("connect") | ||
| 194 | + | ||
| 195 | + async def status(self) -> Any: | ||
| 196 | + return await self._call("status") | ||
| 197 | + | ||
| 198 | + async def get(self, key: bytes) -> Any: | ||
| 199 | + return await self._call("get", key) | ||
| 200 | + | ||
| 201 | + async def transaction(self, compare: Any, success: Any, failure: Any) -> Any: | ||
| 202 | + return await self._call( | ||
| 203 | + "transaction", | ||
| 204 | + compare=compare, | ||
| 205 | + success=success, | ||
| 206 | + failure=failure, | ||
| 207 | + ) | ||
| 208 | + | ||
| 209 | + async def lease(self, ttl: int) -> _PooledLease: | ||
| 210 | + lease = await self._call("lease", ttl) | ||
| 211 | + return _PooledLease(self, int(lease.id), int(lease.ttl)) | ||
| 212 | + | ||
| 213 | + async def refresh_lease(self, lease_id: int) -> Any: | ||
| 214 | + return await self._call("refresh_lease", lease_id) | ||
| 215 | + | ||
| 216 | + async def revoke_lease(self, lease_id: int) -> None: | ||
| 217 | + await self._call("revoke_lease", lease_id) | ||
| 218 | + | ||
| 219 | + async def close(self) -> None: | ||
| 220 | + await asyncio.gather( | ||
| 221 | + *(client.close() for client in self._clients), | ||
| 222 | + return_exceptions=True, | ||
| 223 | + ) | ||
| 224 | + | ||
| 225 | + async def _call(self, method: str, *args: Any, **kwargs: Any) -> Any: | ||
| 226 | + last_error: Exception | None = None | ||
| 227 | + for offset in range(len(self._clients)): | ||
| 228 | + index = (self._active + offset) % len(self._clients) | ||
| 229 | + try: | ||
| 230 | + result = await getattr(self._clients[index], method)(*args, **kwargs) | ||
| 231 | + self._active = index | ||
| 232 | + return result | ||
| 233 | + except Exception as exc: # noqa: BLE001 - try the next configured member | ||
| 234 | + last_error = exc | ||
| 235 | + if last_error is not None: | ||
| 236 | + raise last_error | ||
| 237 | + raise LockBackendUnavailable("no etcd endpoints are configured") | ||
| 238 | + | ||
| 239 | + | ||
| 240 | +class EtcdLockBackend: | ||
| 241 | + """Distributed lock using an etcd lease and transaction CAS.""" | ||
| 242 | + | ||
| 243 | + capabilities = LockCapabilities(distributed=True, fencing=True) | ||
| 244 | + | ||
| 245 | + def __init__( | ||
| 246 | + self, | ||
| 247 | + client: Any, | ||
| 248 | + *, | ||
| 249 | + prefix: str = "lock", | ||
| 250 | + owns_client: bool = False, | ||
| 251 | + instance_id: str | None = None, | ||
| 252 | + request_id: str | None = None, | ||
| 253 | + ) -> None: | ||
| 254 | + self._client = client | ||
| 255 | + self.prefix = prefix | ||
| 256 | + self._owns_client = owns_client | ||
| 257 | + self.instance_id = instance_id | ||
| 258 | + self.request_id = request_id | ||
| 259 | + self._leases: dict[int, _OwnedLease] = {} | ||
| 260 | + self._lock = asyncio.Lock() | ||
| 261 | + self._closed = False | ||
| 262 | + | ||
| 263 | + def format_key(self, key: str) -> str: | ||
| 264 | + return f"{self.prefix}:{key}" if self.prefix else key | ||
| 265 | + | ||
| 266 | + | ||
| 267 | + def _ttl(ttl: float) -> int: | ||
| 268 | + ttl = float(ttl) | ||
| 269 | + if not math.isfinite(ttl) or ttl <= 0: | ||
| 270 | + raise ValueError("ttl must be a finite positive number") | ||
| 271 | + return max(1, math.ceil(ttl)) | ||
| 272 | + | ||
| 273 | + def _token(self) -> str: | ||
| 274 | + return f"{uuid4().hex}:{self.instance_id or '-'}:{self.request_id or '-'}" | ||
| 275 | + | ||
| 276 | + async def try_acquire(self, key: str, ttl: float) -> LockCredential | None: | ||
| 277 | + self._ensure_open() | ||
| 278 | + full_key = self.format_key(key) | ||
| 279 | + lease = await self._new_lease(ttl) | ||
| 280 | + lease_id = int(lease.id) | ||
| 281 | + token = self._token() | ||
| 282 | + retain_lease = False | ||
| 283 | + try: | ||
| 284 | + try: | ||
| 285 | + succeeded, _ = await self._client.transaction( | ||
| 286 | + compare=[self._client.transactions.create(full_key.encode()) == 0], | ||
| 287 | + success=[ | ||
| 288 | + self._client.transactions.put( | ||
| 289 | + full_key.encode(), | ||
| 290 | + token.encode(), | ||
| 291 | + lease=lease_id, | ||
| 292 | + ) | ||
| 293 | + ], | ||
| 294 | + failure=[], | ||
| 295 | + ) | ||
| 296 | + except Exception as exc: # noqa: BLE001 - normalize transport errors | ||
| 297 | + raise LockBackendUnavailable( | ||
| 298 | + f"etcd lock acquisition failed: {exc}" | ||
| 299 | + ) from exc | ||
| 300 | + if not succeeded: | ||
| 301 | + return None | ||
| 302 | + try: | ||
| 303 | + current = await self._client.get(full_key.encode()) | ||
| 304 | + except Exception as exc: # noqa: BLE001 - normalize transport errors | ||
| 305 | + raise LockBackendUnavailable( | ||
| 306 | + f"etcd lock acquisition failed: {exc}" | ||
| 307 | + ) from exc | ||
| 308 | + if current is None or current.value != token.encode(): | ||
| 309 | + raise LockLost(f"lock {full_key!r} was lost during acquisition") | ||
| 310 | + acquired_at = time.monotonic() | ||
| 311 | + credential = LockCredential( | ||
| 312 | + key=full_key, | ||
| 313 | + token=token, | ||
| 314 | + backend="etcd", | ||
| 315 | + lease_id=lease_id, | ||
| 316 | + fencing_token=int(current.create_revision), | ||
| 317 | + acquired_at=acquired_at, | ||
| 318 | + expires_at=acquired_at + float(ttl), | ||
| 319 | + ) | ||
| 320 | + async with self._lock: | ||
| 321 | + self._leases[lease_id] = _OwnedLease(lease, full_key, token) | ||
| 322 | + retain_lease = True | ||
| 323 | + return credential | ||
| 324 | + finally: | ||
| 325 | + if not retain_lease: | ||
| 326 | + await self._revoke(lease) | ||
| 327 | + | ||
| 328 | + async def renew(self, credential: LockCredential, ttl: float) -> LockCredential: | ||
| 329 | + self._validate_credential(credential) | ||
| 330 | + self._ttl(ttl) | ||
| 331 | + lease = await self._known_lease(credential) | ||
| 332 | + try: | ||
| 333 | + current = await self._client.get(credential.key.encode()) | ||
| 334 | + except Exception as exc: # noqa: BLE001 - normalize transport errors | ||
| 335 | + raise LockBackendUnavailable(f"etcd lock renewal failed: {exc}") from exc | ||
| 336 | + if current is None or current.value != credential.token.encode(): | ||
| 337 | + raise LockLost(f"lock {credential.key!r} is no longer owned") | ||
| 338 | + try: | ||
| 339 | + response = await lease.refresh() | ||
| 340 | + except Exception as exc: # noqa: BLE001 - normalize transport errors | ||
| 341 | + raise LockBackendUnavailable(f"etcd lock renewal failed: {exc}") from exc | ||
| 342 | + if ( | ||
| 343 | + response is None | ||
| 344 | + or getattr(response, "TTL", getattr(response, "ttl", 0)) <= 0 | ||
| 345 | + ): | ||
| 346 | + raise LockLost(f"lock {credential.key!r} lease keepalive failed") | ||
| 347 | + return credential.renewed(ttl) | ||
| 348 | + | ||
| 349 | + async def release(self, credential: LockCredential) -> bool: | ||
| 350 | + self._validate_credential(credential) | ||
| 351 | + lease = await self._known_lease(credential, required=False) | ||
| 352 | + if lease is None: | ||
| 353 | + return False | ||
| 354 | + try: | ||
| 355 | + succeeded, _ = await self._client.transaction( | ||
| 356 | + compare=[ | ||
| 357 | + self._client.transactions.value(credential.key.encode()) | ||
| 358 | + == credential.token.encode() | ||
| 359 | + ], | ||
| 360 | + success=[self._client.transactions.delete(credential.key.encode())], | ||
| 361 | + failure=[], | ||
| 362 | + ) | ||
| 363 | + await self._revoke(lease) | ||
| 364 | + async with self._lock: | ||
| 365 | + self._leases.pop(int(credential.lease_id), None) | ||
| 366 | + return bool(succeeded) | ||
| 367 | + except Exception as exc: # noqa: BLE001 | ||
| 368 | + raise LockBackendUnavailable(f"etcd lock release failed: {exc}") from exc | ||
| 369 | + | ||
| 370 | + async def ping(self) -> bool: | ||
| 371 | + self._ensure_open() | ||
| 372 | + transactions = getattr(self._client, "transactions", None) | ||
| 373 | + if transactions is None or any( | ||
| 374 | + not callable(getattr(transactions, name, None)) | ||
| 375 | + for name in ("create", "value", "put", "delete") | ||
| 376 | + ): | ||
| 377 | + raise LockBackendUnavailable( | ||
| 378 | + "etcd client does not provide transaction operations" | ||
| 379 | + ) | ||
| 380 | + try: | ||
| 381 | + await self._client.status() | ||
| 382 | + return True | ||
| 383 | + except Exception as exc: # noqa: BLE001 | ||
| 384 | + raise LockBackendUnavailable(f"etcd health check failed: {exc}") from exc | ||
| 385 | + | ||
| 386 | + async def close(self) -> None: | ||
| 387 | + if self._closed: | ||
| 388 | + return | ||
| 389 | + self._closed = True | ||
| 390 | + async with self._lock: | ||
| 391 | + leases = tuple(owned.lease for owned in self._leases.values()) | ||
| 392 | + self._leases.clear() | ||
| 393 | + for lease in leases: | ||
| 394 | + try: | ||
| 395 | + await self._revoke(lease) | ||
| 396 | + except Exception: # noqa: BLE001 | ||
| 397 | + pass | ||
| 398 | + if self._owns_client and hasattr(self._client, "close"): | ||
| 399 | + await self._client.close() | ||
| 400 | + | ||
| 401 | + async def _new_lease(self, ttl: float) -> Any: | ||
| 402 | + try: | ||
| 403 | + return await self._client.lease(self._ttl(ttl)) | ||
| 404 | + except Exception as exc: # noqa: BLE001 | ||
| 405 | + raise LockBackendUnavailable(f"etcd lease creation failed: {exc}") from exc | ||
| 406 | + | ||
| 407 | + async def _known_lease( | ||
| 408 | + self, credential: LockCredential, *, required: bool = True | ||
| 409 | + ) -> Any | None: | ||
| 410 | + if credential.lease_id is None: | ||
| 411 | + if required: | ||
| 412 | + raise InvalidLockLease("etcd credential has no lease id") | ||
| 413 | + return None | ||
| 414 | + async with self._lock: | ||
| 415 | + owned = self._leases.get(int(credential.lease_id)) | ||
| 416 | + if owned is not None and ( | ||
| 417 | + owned.key != credential.key or owned.token != credential.token | ||
| 418 | + ): | ||
| 419 | + raise InvalidLockLease("etcd credential does not match its owned lease") | ||
| 420 | + lease = None if owned is None else owned.lease | ||
| 421 | + if lease is None and required: | ||
| 422 | + raise InvalidLockLease("etcd credential is not owned by this backend") | ||
| 423 | + return lease | ||
| 424 | + | ||
| 425 | + async def _revoke(self, lease: Any) -> None: | ||
| 426 | + try: | ||
| 427 | + await lease.revoke() | ||
| 428 | + except AttributeError: | ||
| 429 | + await self._client.revoke_lease(int(lease.id)) | ||
| 430 | + | ||
| 431 | + | ||
| 432 | + def _validate_credential(credential: LockCredential) -> None: | ||
| 433 | + if credential.backend != "etcd": | ||
| 434 | + raise InvalidLockLease( | ||
| 435 | + f"credential backend {credential.backend!r} cannot be used with etcd" | ||
| 436 | + ) | ||
| 437 | + | ||
| 438 | + def _ensure_open(self) -> None: | ||
| 439 | + if self._closed: | ||
| 440 | + raise LockBackendUnavailable("etcd lock backend is closed") | ||
| 441 | + | ||
| 442 | + | ||
| 443 | +__all__ = [ | ||
| 444 | + "EtcdEndpoint", | ||
| 445 | + "EtcdLockBackend", | ||
| 446 | + "create_etcd_client", | ||
| 447 | + "parse_etcd_endpoint", | ||
| 448 | +] | ||
| @@ -0,0 +1,129 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Process-local lock backend.""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +import asyncio | ||
| 9 | +import heapq | ||
| 10 | +import math | ||
| 11 | +import time | ||
| 12 | +from dataclasses import dataclass | ||
| 13 | +from uuid import uuid4 | ||
| 14 | + | ||
| 15 | +from ....errors import InvalidLockLease, LockBackendUnavailable, LockLost | ||
| 16 | +from ..base import LockCapabilities, LockCredential | ||
| 17 | + | ||
| 18 | + | ||
| 19 | + | ||
| 20 | +class _Entry: | ||
| 21 | + token: str | ||
| 22 | + expires_at: float | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +class MemoryLockBackend: | ||
| 26 | + """An in-process, token-checked TTL lock backend. | ||
| 27 | + | ||
| 28 | + Waiting is intentionally absent from this class. ``LockManager`` owns the | ||
| 29 | + retry policy so all lock backends expose the same behavior. | ||
| 30 | + """ | ||
| 31 | + | ||
| 32 | + capabilities = LockCapabilities(distributed=False, fencing=False) | ||
| 33 | + | ||
| 34 | + def __init__(self, *, prefix: str = "lock") -> None: | ||
| 35 | + self.prefix = prefix | ||
| 36 | + self._entries: dict[str, _Entry] = {} | ||
| 37 | + self._expiry_heap: list[tuple[float, str, str]] = [] | ||
| 38 | + self._lock = asyncio.Lock() | ||
| 39 | + self._closed = False | ||
| 40 | + | ||
| 41 | + def format_key(self, key: str) -> str: | ||
| 42 | + return f"{self.prefix}:{key}" if self.prefix else key | ||
| 43 | + | ||
| 44 | + | ||
| 45 | + def _ttl(ttl: float) -> float: | ||
| 46 | + ttl = float(ttl) | ||
| 47 | + if not math.isfinite(ttl) or ttl <= 0: | ||
| 48 | + raise ValueError("ttl must be a finite positive number") | ||
| 49 | + return ttl | ||
| 50 | + | ||
| 51 | + async def try_acquire(self, key: str, ttl: float) -> LockCredential | None: | ||
| 52 | + if self._closed: | ||
| 53 | + raise LockBackendUnavailable("memory lock backend is closed") | ||
| 54 | + ttl = self._ttl(ttl) | ||
| 55 | + full_key = self.format_key(key) | ||
| 56 | + now = time.monotonic() | ||
| 57 | + async with self._lock: | ||
| 58 | + self._purge_expired(now) | ||
| 59 | + if full_key in self._entries: | ||
| 60 | + return None | ||
| 61 | + token = uuid4().hex | ||
| 62 | + expires_at = now + ttl | ||
| 63 | + self._entries[full_key] = _Entry(token, expires_at) | ||
| 64 | + heapq.heappush(self._expiry_heap, (expires_at, full_key, token)) | ||
| 65 | + return LockCredential( | ||
| 66 | + key=full_key, | ||
| 67 | + token=token, | ||
| 68 | + backend="memory", | ||
| 69 | + lease_id=None, | ||
| 70 | + fencing_token=None, | ||
| 71 | + acquired_at=now, | ||
| 72 | + expires_at=expires_at, | ||
| 73 | + ) | ||
| 74 | + | ||
| 75 | + async def renew(self, credential: LockCredential, ttl: float) -> LockCredential: | ||
| 76 | + ttl = self._ttl(ttl) | ||
| 77 | + self._validate_credential(credential) | ||
| 78 | + now = time.monotonic() | ||
| 79 | + async with self._lock: | ||
| 80 | + self._purge_expired(now) | ||
| 81 | + entry = self._entries.get(credential.key) | ||
| 82 | + if entry is None or entry.token != credential.token: | ||
| 83 | + raise LockLost(f"lock {credential.key!r} is no longer owned") | ||
| 84 | + expires_at = now + ttl | ||
| 85 | + entry.expires_at = expires_at | ||
| 86 | + heapq.heappush( | ||
| 87 | + self._expiry_heap, (expires_at, credential.key, credential.token) | ||
| 88 | + ) | ||
| 89 | + return credential.renewed(ttl) | ||
| 90 | + | ||
| 91 | + async def release(self, credential: LockCredential) -> bool: | ||
| 92 | + self._validate_credential(credential) | ||
| 93 | + async with self._lock: | ||
| 94 | + self._purge_expired(time.monotonic()) | ||
| 95 | + entry = self._entries.get(credential.key) | ||
| 96 | + if entry is None or entry.token != credential.token: | ||
| 97 | + return False | ||
| 98 | + del self._entries[credential.key] | ||
| 99 | + return True | ||
| 100 | + | ||
| 101 | + async def ping(self) -> bool: | ||
| 102 | + return not self._closed | ||
| 103 | + | ||
| 104 | + async def close(self) -> None: | ||
| 105 | + async with self._lock: | ||
| 106 | + self._closed = True | ||
| 107 | + self._entries.clear() | ||
| 108 | + self._expiry_heap.clear() | ||
| 109 | + | ||
| 110 | + def _purge_expired(self, now: float) -> None: | ||
| 111 | + while self._expiry_heap and self._expiry_heap[0][0] <= now: | ||
| 112 | + expires_at, key, token = heapq.heappop(self._expiry_heap) | ||
| 113 | + entry = self._entries.get(key) | ||
| 114 | + if ( | ||
| 115 | + entry is not None | ||
| 116 | + and entry.token == token | ||
| 117 | + and entry.expires_at == expires_at | ||
| 118 | + ): | ||
| 119 | + del self._entries[key] | ||
| 120 | + | ||
| 121 | + | ||
| 122 | + def _validate_credential(credential: LockCredential) -> None: | ||
| 123 | + if credential.backend != "memory": | ||
| 124 | + raise InvalidLockLease( | ||
| 125 | + f"credential backend {credential.backend!r} cannot be used with memory" | ||
| 126 | + ) | ||
| 127 | + | ||
| 128 | + | ||
| 129 | +__all__ = ["MemoryLockBackend"] | ||
| @@ -0,0 +1,181 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Redis ``SET NX PX`` lock backend with owner-checked Lua mutations.""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +import json | ||
| 9 | +import math | ||
| 10 | +import time | ||
| 11 | +from typing import Any | ||
| 12 | +from uuid import uuid4 | ||
| 13 | + | ||
| 14 | +from redis.exceptions import ResponseError, WatchError | ||
| 15 | + | ||
| 16 | +from ....errors import InvalidLockLease, LockBackendUnavailable, LockLost | ||
| 17 | +from ..base import LockCapabilities, LockCredential | ||
| 18 | + | ||
| 19 | +_RELEASE_SCRIPT = """ | ||
| 20 | +if redis.call('get', KEYS[1]) == ARGV[1] then | ||
| 21 | + return redis.call('del', KEYS[1]) | ||
| 22 | +end | ||
| 23 | +return 0 | ||
| 24 | +""" | ||
| 25 | +_RENEW_SCRIPT = """ | ||
| 26 | +if redis.call('get', KEYS[1]) == ARGV[1] then | ||
| 27 | + return redis.call('pexpire', KEYS[1], ARGV[2]) | ||
| 28 | +end | ||
| 29 | +return 0 | ||
| 30 | +""" | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +class RedisLockBackend: | ||
| 34 | + """Single-attempt Redis lock operations. | ||
| 35 | + | ||
| 36 | + The stored value is an opaque token containing diagnostic ownership fields. | ||
| 37 | + Lua compares that complete value, so an expired owner can never delete or | ||
| 38 | + renew a subsequent owner's lock. | ||
| 39 | + """ | ||
| 40 | + | ||
| 41 | + capabilities = LockCapabilities(distributed=True, fencing=False) | ||
| 42 | + | ||
| 43 | + def __init__( | ||
| 44 | + self, | ||
| 45 | + redis: Any, | ||
| 46 | + *, | ||
| 47 | + prefix: str = "lock", | ||
| 48 | + instance_id: str | None = None, | ||
| 49 | + request_id: str | None = None, | ||
| 50 | + owns_redis: bool = False, | ||
| 51 | + ) -> None: | ||
| 52 | + self._redis = redis | ||
| 53 | + self.prefix = prefix | ||
| 54 | + self.instance_id = instance_id | ||
| 55 | + self.request_id = request_id | ||
| 56 | + self._owns_redis = owns_redis | ||
| 57 | + self._closed = False | ||
| 58 | + | ||
| 59 | + def format_key(self, key: str) -> str: | ||
| 60 | + return f"{self.prefix}:{key}" if self.prefix else key | ||
| 61 | + | ||
| 62 | + | ||
| 63 | + def _ttl_ms(ttl: float) -> int: | ||
| 64 | + ttl = float(ttl) | ||
| 65 | + if not math.isfinite(ttl) or ttl <= 0: | ||
| 66 | + raise ValueError("ttl must be a finite positive number") | ||
| 67 | + return max(1, int(ttl * 1000)) | ||
| 68 | + | ||
| 69 | + def _token(self) -> str: | ||
| 70 | + return json.dumps( | ||
| 71 | + { | ||
| 72 | + "token": uuid4().hex, | ||
| 73 | + "instance_id": self.instance_id, | ||
| 74 | + "request_id": self.request_id, | ||
| 75 | + }, | ||
| 76 | + separators=(",", ":"), | ||
| 77 | + sort_keys=True, | ||
| 78 | + ) | ||
| 79 | + | ||
| 80 | + async def try_acquire(self, key: str, ttl: float) -> LockCredential | None: | ||
| 81 | + self._ensure_open() | ||
| 82 | + full_key = self.format_key(key) | ||
| 83 | + token = self._token() | ||
| 84 | + ttl_ms = self._ttl_ms(ttl) | ||
| 85 | + acquired_at = time.monotonic() | ||
| 86 | + if await self._redis.set(full_key, token, nx=True, px=ttl_ms): | ||
| 87 | + return LockCredential( | ||
| 88 | + key=full_key, | ||
| 89 | + token=token, | ||
| 90 | + backend="redis", | ||
| 91 | + lease_id=None, | ||
| 92 | + fencing_token=None, | ||
| 93 | + acquired_at=acquired_at, | ||
| 94 | + expires_at=time.monotonic() + float(ttl), | ||
| 95 | + ) | ||
| 96 | + return None | ||
| 97 | + | ||
| 98 | + async def renew(self, credential: LockCredential, ttl: float) -> LockCredential: | ||
| 99 | + self._ensure_open() | ||
| 100 | + self._validate_credential(credential) | ||
| 101 | + result = await self._cas(credential, _RENEW_SCRIPT, self._ttl_ms(ttl)) | ||
| 102 | + if not result: | ||
| 103 | + raise LockLost( | ||
| 104 | + f"lock {credential.key!r} is no longer owned by this credential" | ||
| 105 | + ) | ||
| 106 | + return credential.renewed(float(ttl)) | ||
| 107 | + | ||
| 108 | + async def release(self, credential: LockCredential) -> bool: | ||
| 109 | + self._ensure_open() | ||
| 110 | + self._validate_credential(credential) | ||
| 111 | + return bool(await self._cas(credential, _RELEASE_SCRIPT)) | ||
| 112 | + | ||
| 113 | + async def ping(self) -> bool: | ||
| 114 | + self._ensure_open() | ||
| 115 | + return bool(await self._redis.ping()) | ||
| 116 | + | ||
| 117 | + async def close(self) -> None: | ||
| 118 | + self._closed = True | ||
| 119 | + if self._owns_redis: | ||
| 120 | + await self._redis.aclose() | ||
| 121 | + | ||
| 122 | + def _ensure_open(self) -> None: | ||
| 123 | + if self._closed: | ||
| 124 | + raise LockBackendUnavailable("Redis lock backend is closed") | ||
| 125 | + | ||
| 126 | + | ||
| 127 | + def _validate_credential(credential: LockCredential) -> None: | ||
| 128 | + if credential.backend != "redis": | ||
| 129 | + raise InvalidLockLease( | ||
| 130 | + f"credential backend {credential.backend!r} cannot be used with redis" | ||
| 131 | + ) | ||
| 132 | + | ||
| 133 | + async def _cas(self, credential: LockCredential, script: str, *args: Any) -> int: | ||
| 134 | + try: | ||
| 135 | + return int( | ||
| 136 | + await self._redis.eval( | ||
| 137 | + script, 1, credential.key, credential.token, *args | ||
| 138 | + ) | ||
| 139 | + ) | ||
| 140 | + except ResponseError as exc: | ||
| 141 | + # Older fakeredis builds omit Lua support. Keep the compatibility | ||
| 142 | + # test path while real Redis always takes the atomic Lua branch. | ||
| 143 | + if ( | ||
| 144 | + "unknown command" not in str(exc).lower() | ||
| 145 | + or "eval" not in str(exc).lower() | ||
| 146 | + ): | ||
| 147 | + raise | ||
| 148 | + return int(await self._watch_cas(credential, script, *args)) | ||
| 149 | + | ||
| 150 | + async def _watch_cas( | ||
| 151 | + self, credential: LockCredential, script: str, *args: Any | ||
| 152 | + ) -> int: | ||
| 153 | + pipe = self._redis.pipeline(transaction=True) | ||
| 154 | + try: | ||
| 155 | + await pipe.watch(credential.key) | ||
| 156 | + current = await pipe.get(credential.key) | ||
| 157 | + if _as_bytes(current) != credential.token.encode(): | ||
| 158 | + await pipe.unwatch() | ||
| 159 | + return 0 | ||
| 160 | + pipe.multi() | ||
| 161 | + if script is _RENEW_SCRIPT: | ||
| 162 | + pipe.pexpire(credential.key, args[0]) | ||
| 163 | + else: | ||
| 164 | + pipe.delete(credential.key) | ||
| 165 | + response = await pipe.execute() | ||
| 166 | + return int(response[0]) if response else 0 | ||
| 167 | + except WatchError: | ||
| 168 | + try: | ||
| 169 | + await pipe.unwatch() | ||
| 170 | + except Exception: # noqa: BLE001 | ||
| 171 | + pass | ||
| 172 | + return 0 | ||
| 173 | + | ||
| 174 | + | ||
| 175 | +def _as_bytes(value: Any) -> bytes | None: | ||
| 176 | + if value is None: | ||
| 177 | + return None | ||
| 178 | + return value.encode() if isinstance(value, str) else bytes(value) | ||
| 179 | + | ||
| 180 | + | ||
| 181 | +__all__ = ["RedisLockBackend"] | ||
| @@ -0,0 +1,60 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""锁后端与凭证的稳定契约。""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +import time | ||
| 9 | +from dataclasses import dataclass, replace | ||
| 10 | +from typing import Protocol, runtime_checkable | ||
| 11 | + | ||
| 12 | + | ||
| 13 | + | ||
| 14 | +class LockCapabilities: | ||
| 15 | + """声明锁后端提供的跨副本和 fencing 能力。""" | ||
| 16 | + | ||
| 17 | + distributed: bool | ||
| 18 | + fencing: bool | ||
| 19 | + | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +class LockCredential: | ||
| 23 | + """一次成功获取对应的不可变锁凭证。""" | ||
| 24 | + | ||
| 25 | + key: str | ||
| 26 | + token: str | ||
| 27 | + backend: str | ||
| 28 | + lease_id: str | int | None | ||
| 29 | + fencing_token: int | None | ||
| 30 | + acquired_at: float | ||
| 31 | + expires_at: float | ||
| 32 | + | ||
| 33 | + def renewed( | ||
| 34 | + self, ttl: float, *, lease_id: str | int | None = None | ||
| 35 | + ) -> "LockCredential": | ||
| 36 | + """按单调时钟生成续约后的凭证副本。""" | ||
| 37 | + return replace( | ||
| 38 | + self, | ||
| 39 | + lease_id=self.lease_id if lease_id is None else lease_id, | ||
| 40 | + expires_at=time.monotonic() + ttl, | ||
| 41 | + ) | ||
| 42 | + | ||
| 43 | + | ||
| 44 | + | ||
| 45 | +class LockBackend(Protocol): | ||
| 46 | + """单次原子锁操作;等待和续约循环由 ``LockManager`` 统一实现。""" | ||
| 47 | + | ||
| 48 | + capabilities: LockCapabilities | ||
| 49 | + | ||
| 50 | + async def try_acquire(self, key: str, ttl: float) -> LockCredential | None: | ||
| 51 | + raise NotImplementedError | ||
| 52 | + | ||
| 53 | + async def renew(self, credential: LockCredential, ttl: float) -> LockCredential: | ||
| 54 | + raise NotImplementedError | ||
| 55 | + | ||
| 56 | + async def release(self, credential: LockCredential) -> bool: | ||
| 57 | + raise NotImplementedError | ||
| 58 | + | ||
| 59 | + | ||
| 60 | +__all__ = ["LockBackend", "LockCapabilities", "LockCredential"] | ||
| @@ -0,0 +1,116 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""Lock backend selection used during service startup.""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +from typing import Any | ||
| 9 | + | ||
| 10 | +from ...errors import LockBackendUnavailable | ||
| 11 | +from .base import LockBackend | ||
| 12 | +from .backends.etcd import EtcdLockBackend, create_etcd_client | ||
| 13 | +from .backends.memory import MemoryLockBackend | ||
| 14 | +from .backends.redis import RedisLockBackend | ||
| 15 | + | ||
| 16 | + | ||
| 17 | +def build_lock_backend( | ||
| 18 | + backend: str | Any = "auto", | ||
| 19 | + *, | ||
| 20 | + redis: Any = None, | ||
| 21 | + etcd_client: Any = None, | ||
| 22 | + etcd_endpoints: str | list[str] | tuple[str, ...] | None = None, | ||
| 23 | + etcd_username: str | None = None, | ||
| 24 | + etcd_password: str | None = None, | ||
| 25 | + etcd_connect_timeout: float | None = None, | ||
| 26 | + etcd_ca_cert: str | None = None, | ||
| 27 | + etcd_cert: str | None = None, | ||
| 28 | + etcd_key: str | None = None, | ||
| 29 | + key_prefix: str = "service:lock", | ||
| 30 | + deploy_replicas: int = 1, | ||
| 31 | + instance_id: str | None = None, | ||
| 32 | + request_id: str | None = None, | ||
| 33 | + owns_etcd_client: bool = False, | ||
| 34 | +) -> LockBackend: | ||
| 35 | + """Build one lock backend and fail fast when its prerequisites are absent.""" | ||
| 36 | + if not isinstance(backend, str): | ||
| 37 | + config = backend | ||
| 38 | + return build_lock_backend( | ||
| 39 | + getattr(config, "lock_backend", "auto"), | ||
| 40 | + redis=redis, | ||
| 41 | + etcd_client=etcd_client, | ||
| 42 | + etcd_endpoints=getattr(config, "etcd_endpoints", etcd_endpoints), | ||
| 43 | + etcd_username=getattr(config, "etcd_username", etcd_username), | ||
| 44 | + etcd_password=getattr(config, "etcd_password", etcd_password), | ||
| 45 | + etcd_connect_timeout=getattr( | ||
| 46 | + config, "etcd_connect_timeout_seconds", etcd_connect_timeout | ||
| 47 | + ), | ||
| 48 | + etcd_ca_cert=getattr(config, "etcd_ca_cert", etcd_ca_cert), | ||
| 49 | + etcd_cert=getattr(config, "etcd_cert", etcd_cert), | ||
| 50 | + etcd_key=getattr(config, "etcd_key", etcd_key), | ||
| 51 | + key_prefix=getattr(config, "lock_key_prefix", key_prefix), | ||
| 52 | + deploy_replicas=getattr(config, "deploy_replicas", deploy_replicas), | ||
| 53 | + instance_id=instance_id, | ||
| 54 | + request_id=request_id, | ||
| 55 | + owns_etcd_client=owns_etcd_client, | ||
| 56 | + ) | ||
| 57 | + selected = str(backend or "auto").strip().lower() | ||
| 58 | + if selected not in {"auto", "memory", "redis", "etcd"}: | ||
| 59 | + raise ValueError("lock backend must be one of auto, memory, redis, etcd") | ||
| 60 | + if selected == "auto": | ||
| 61 | + if etcd_client is not None or etcd_endpoints: | ||
| 62 | + selected = "etcd" | ||
| 63 | + elif redis is not None: | ||
| 64 | + selected = "redis" | ||
| 65 | + elif int(deploy_replicas) == 1: | ||
| 66 | + selected = "memory" | ||
| 67 | + else: | ||
| 68 | + raise LockBackendUnavailable( | ||
| 69 | + "multi-replica deployment requires an etcd or Redis lock backend" | ||
| 70 | + ) | ||
| 71 | + if selected == "memory": | ||
| 72 | + return MemoryLockBackend(prefix=key_prefix) | ||
| 73 | + if selected == "redis": | ||
| 74 | + if redis is None: | ||
| 75 | + raise LockBackendUnavailable("Redis lock backend requires a Redis client") | ||
| 76 | + return RedisLockBackend( | ||
| 77 | + redis, | ||
| 78 | + prefix=key_prefix, | ||
| 79 | + instance_id=instance_id, | ||
| 80 | + request_id=request_id, | ||
| 81 | + ) | ||
| 82 | + if etcd_client is None: | ||
| 83 | + if not etcd_endpoints: | ||
| 84 | + raise LockBackendUnavailable( | ||
| 85 | + "etcd lock backend requires a client or endpoint" | ||
| 86 | + ) | ||
| 87 | + etcd_client = create_etcd_client( | ||
| 88 | + etcd_endpoints, | ||
| 89 | + username=etcd_username, | ||
| 90 | + password=etcd_password, | ||
| 91 | + connect_timeout=etcd_connect_timeout, | ||
| 92 | + tls_ca_cert=etcd_ca_cert, | ||
| 93 | + tls_cert=etcd_cert, | ||
| 94 | + tls_key=etcd_key, | ||
| 95 | + ) | ||
| 96 | + owns_etcd_client = True | ||
| 97 | + return EtcdLockBackend( | ||
| 98 | + etcd_client, | ||
| 99 | + prefix=key_prefix, | ||
| 100 | + owns_client=owns_etcd_client, | ||
| 101 | + instance_id=instance_id, | ||
| 102 | + request_id=request_id, | ||
| 103 | + ) | ||
| 104 | + | ||
| 105 | + | ||
| 106 | +class LockBackendFactory: | ||
| 107 | + """Object-oriented facade for integrations that prefer a factory type.""" | ||
| 108 | + | ||
| 109 | + | ||
| 110 | + def build(*args: Any, **kwargs: Any) -> LockBackend: | ||
| 111 | + return build_lock_backend(*args, **kwargs) | ||
| 112 | + | ||
| 113 | + | ||
| 114 | +create_lock_backend = build_lock_backend | ||
| 115 | + | ||
| 116 | +__all__ = ["LockBackendFactory", "build_lock_backend", "create_lock_backend"] | ||
| @@ -0,0 +1,222 @@ | |||
| 1 | +# coding: utf-8 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved | ||
| 3 | + | ||
| 4 | +"""请求范围内的锁租约和自动续约任务。""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +import asyncio | ||
| 9 | +import inspect | ||
| 10 | +import logging | ||
| 11 | +import random | ||
| 12 | +import time | ||
| 13 | +from enum import Enum | ||
| 14 | +from typing import Any, Callable | ||
| 15 | + | ||
| 16 | +from ...errors import InvalidLockLease, LockLost | ||
| 17 | +from .base import LockBackend, LockCredential | ||
| 18 | + | ||
| 19 | +_logger = logging.getLogger("openjiuwen_runtime.service.locks") | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +class LeaseState(str, Enum): | ||
| 23 | + NEW = "NEW" | ||
| 24 | + HELD = "HELD" | ||
| 25 | + LOST = "LOST" | ||
| 26 | + RELEASED = "RELEASED" | ||
| 27 | + | ||
| 28 | + | ||
| 29 | +Callback = Callable[..., Any] | ||
| 30 | + | ||
| 31 | + | ||
| 32 | +class LockLease: | ||
| 33 | + """绑定单次凭证的锁租约。 | ||
| 34 | + | ||
| 35 | + 后端只执行一次原子操作,续约循环、状态转换和请求中断由本类统一管理。 | ||
| 36 | + """ | ||
| 37 | + | ||
| 38 | + def __init__( | ||
| 39 | + self, | ||
| 40 | + backend: LockBackend, | ||
| 41 | + credential: LockCredential, | ||
| 42 | + *, | ||
| 43 | + ttl: float, | ||
| 44 | + auto_renew: bool = True, | ||
| 45 | + renew_ratio: float = 1 / 3, | ||
| 46 | + check_interrupted: Callable[[], None] | None = None, | ||
| 47 | + on_lost: Callback | None = None, | ||
| 48 | + on_release: Callback | None = None, | ||
| 49 | + release_timeout: float = 3.0, | ||
| 50 | + ) -> None: | ||
| 51 | + self.backend = backend | ||
| 52 | + self._credential = credential | ||
| 53 | + self._ttl = ttl | ||
| 54 | + self._auto_renew = auto_renew | ||
| 55 | + self._renew_ratio = renew_ratio | ||
| 56 | + self._check_interrupted = check_interrupted | ||
| 57 | + self._on_lost = on_lost | ||
| 58 | + self._on_release = on_release | ||
| 59 | + self._release_timeout = release_timeout | ||
| 60 | + self._state = LeaseState.HELD | ||
| 61 | + self.lost_event = asyncio.Event() | ||
| 62 | + self._operation_lock = asyncio.Lock() | ||
| 63 | + self._renew_task: asyncio.Task[None] | None = None | ||
| 64 | + self._release_task: asyncio.Task[None] | None = None | ||
| 65 | + self._lost_reason: str | None = None | ||
| 66 | + self._ever_lost = False | ||
| 67 | + if auto_renew: | ||
| 68 | + self._renew_task = asyncio.create_task( | ||
| 69 | + self._renew_loop(), name=f"lock-renew:{credential.key}" | ||
| 70 | + ) | ||
| 71 | + | ||
| 72 | + | ||
| 73 | + def credential(self) -> LockCredential: | ||
| 74 | + return self._credential | ||
| 75 | + | ||
| 76 | + | ||
| 77 | + def key(self) -> str: | ||
| 78 | + return self._credential.key | ||
| 79 | + | ||
| 80 | + | ||
| 81 | + def state(self) -> LeaseState: | ||
| 82 | + return self._state | ||
| 83 | + | ||
| 84 | + | ||
| 85 | + def lost(self) -> bool: | ||
| 86 | + return self._state is LeaseState.LOST | ||
| 87 | + | ||
| 88 | + | ||
| 89 | + def released(self) -> bool: | ||
| 90 | + return self._state is LeaseState.RELEASED | ||
| 91 | + | ||
| 92 | + async def __aenter__(self) -> "LockLease": | ||
| 93 | + self.ensure_valid() | ||
| 94 | + return self | ||
| 95 | + | ||
| 96 | + async def __aexit__(self, exc_type, exc, tb) -> bool: | ||
| 97 | + await self.release() | ||
| 98 | + if self._ever_lost and exc_type is None: | ||
| 99 | + raise LockLost(self._lost_reason or f"lock {self.key!r} was lost") | ||
| 100 | + return False | ||
| 101 | + | ||
| 102 | + def ensure_valid(self) -> None: | ||
| 103 | + """在业务关键边界检查本地租约状态。""" | ||
| 104 | + if self._state is LeaseState.LOST: | ||
| 105 | + raise InvalidLockLease(self._lost_reason or f"lock {self.key!r} was lost") | ||
| 106 | + if self._state is LeaseState.RELEASED: | ||
| 107 | + raise InvalidLockLease(f"lock {self.key!r} has been released") | ||
| 108 | + if self._state is not LeaseState.HELD: | ||
| 109 | + raise InvalidLockLease(f"lock {self.key!r} is not held") | ||
| 110 | + if self._credential.expires_at <= time.monotonic(): | ||
| 111 | + self._mark_lost(f"lock {self.key!r} lease expired") | ||
| 112 | + raise InvalidLockLease(f"lock {self.key!r} lease expired") | ||
| 113 | + | ||
| 114 | + async def renew(self) -> LockCredential: | ||
| 115 | + """续约并替换当前凭证。""" | ||
| 116 | + async with self._operation_lock: | ||
| 117 | + self.ensure_valid() | ||
| 118 | + if self._check_interrupted is not None: | ||
| 119 | + self._check_interrupted() | ||
| 120 | + try: | ||
| 121 | + credential = await self.backend.renew(self._credential, self._ttl) | ||
| 122 | + except asyncio.CancelledError: | ||
| 123 | + raise | ||
| 124 | + except BaseException as exc: # noqa: BLE001 - backend failures lose the lease | ||
| 125 | + self._mark_lost(f"lock {self.key!r} renewal failed: {exc}") | ||
| 126 | + if isinstance(exc, LockLost): | ||
| 127 | + raise | ||
| 128 | + raise LockLost(f"lock {self.key!r} renewal failed") from exc | ||
| 129 | + self._credential = credential | ||
| 130 | + return credential | ||
| 131 | + | ||
| 132 | + async def release(self, *, timeout: float | None = None) -> bool: | ||
| 133 | + """释放租约;重复调用安全且不会重新删除后继持有者的锁。""" | ||
| 134 | + if self._release_task is None: | ||
| 135 | + self._release_task = asyncio.create_task( | ||
| 136 | + self._release_impl(), name=f"lock-release:{self.key}" | ||
| 137 | + ) | ||
| 138 | + task = self._release_task | ||
| 139 | + limit = self._release_timeout if timeout is None else timeout | ||
| 140 | + try: | ||
| 141 | + if limit is not None and limit > 0: | ||
| 142 | + return bool(await asyncio.wait_for(asyncio.shield(task), timeout=limit)) | ||
| 143 | + return bool(await asyncio.shield(task)) | ||
| 144 | + except asyncio.CancelledError: | ||
| 145 | + # 请求取消不能中断已经开始的释放操作。 | ||
| 146 | + try: | ||
| 147 | + await asyncio.shield(task) | ||
| 148 | + except BaseException: # noqa: BLE001 - preserve the cancellation | ||
| 149 | + pass | ||
| 150 | + raise | ||
| 151 | + except asyncio.TimeoutError: | ||
| 152 | + # 后端调用继续在后台完成,避免超时路径留下半释放状态。 | ||
| 153 | + return False | ||
| 154 | + | ||
| 155 | + async def _release_impl(self) -> bool: | ||
| 156 | + async with self._operation_lock: | ||
| 157 | + if self._state is LeaseState.RELEASED: | ||
| 158 | + return True | ||
| 159 | + if ( | ||
| 160 | + self._renew_task is not None | ||
| 161 | + and self._renew_task is not asyncio.current_task() | ||
| 162 | + ): | ||
| 163 | + self._renew_task.cancel() | ||
| 164 | + try: | ||
| 165 | + await self._renew_task | ||
| 166 | + except BaseException: # noqa: BLE001 - release remains best effort | ||
| 167 | + pass | ||
| 168 | + released = False | ||
| 169 | + try: | ||
| 170 | + released = bool(await self.backend.release(self._credential)) | ||
| 171 | + except BaseException: # noqa: BLE001 - cleanup must be idempotent | ||
| 172 | + _logger.exception("lock release failed: key=%s", self.key) | ||
| 173 | + self._state = LeaseState.RELEASED | ||
| 174 | + callback = self._on_release | ||
| 175 | + if callback is not None: | ||
| 176 | + try: | ||
| 177 | + result = callback(self) | ||
| 178 | + if inspect.isawaitable(result): | ||
| 179 | + await result | ||
| 180 | + except BaseException: # noqa: BLE001 | ||
| 181 | + _logger.exception("lock release callback failed: key=%s", self.key) | ||
| 182 | + return released | ||
| 183 | + | ||
| 184 | + async def wait_lost(self) -> None: | ||
| 185 | + await self.lost_event.wait() | ||
| 186 | + | ||
| 187 | + def _mark_lost(self, reason: str) -> None: | ||
| 188 | + if self._state in (LeaseState.LOST, LeaseState.RELEASED): | ||
| 189 | + return | ||
| 190 | + self._state = LeaseState.LOST | ||
| 191 | + self._ever_lost = True | ||
| 192 | + self._lost_reason = reason | ||
| 193 | + self.lost_event.set() | ||
| 194 | + callback = self._on_lost | ||
| 195 | + if callback is not None: | ||
| 196 | + try: | ||
| 197 | + result = callback(reason) | ||
| 198 | + if inspect.isawaitable(result): | ||
| 199 | + asyncio.create_task(result) | ||
| 200 | + except BaseException: # noqa: BLE001 | ||
| 201 | + _logger.exception("lock lost callback failed: key=%s", self.key) | ||
| 202 | + | ||
| 203 | + async def _renew_loop(self) -> None: | ||
| 204 | + interval = max(0.01, self._ttl * self._renew_ratio) | ||
| 205 | + try: | ||
| 206 | + while self._state is LeaseState.HELD: | ||
| 207 | + jitter = random.uniform(0.0, interval * 0.1) | ||
| 208 | + await asyncio.sleep(interval + jitter) | ||
| 209 | + if self._state is not LeaseState.HELD: | ||
| 210 | + return | ||
| 211 | + try: | ||
| 212 | + await self.renew() | ||
| 213 | + except asyncio.CancelledError: | ||
| 214 | + return | ||
| 215 | + except BaseException as exc: # noqa: BLE001 | ||
| 216 | + self._mark_lost(str(exc) or f"lock {self.key!r} renewal failed") | ||
| 217 | + return | ||
| 218 | + except asyncio.CancelledError: | ||
| 219 | + return | ||
| 220 | + | ||
| 221 | + | ||
| 222 | +__all__ = ["LeaseState", "LockLease"] | ||