已合并
feat(service): 增加锁、缓存与资源装配能力 #413
feat(service): 增加锁、缓存与资源装配能力 #413
已合并
m0u55e创建于 8月7日
共 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+@app.handle("users/create", request_model=CreateUserInput)
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+@app.handle("users/get", request_model=UserIdInput)
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+@app.handle("users/update", request_model=UpdateUserInput)
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+@app.handle("users/remove", request_model=UserIdInput)
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+@app.handle("locks/manual", request_model=ManualLockInput)
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+@app.stream("chat", request_model=ChatInput)
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 @@
9from .envelope import Envelope, Metadata, ResponseEnvelope, StreamChunk9from .envelope import Envelope, Metadata, ResponseEnvelope, StreamChunk
10from .config import ServiceConfig10from .config import ServiceConfig
11from .errors import (11from .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 (
24from .context import (28from .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)
33from .context.primitives.idempotency import idempotency_guard69from .context.primitives.idempotency import idempotency_guard
34from .routing.handlers import (70from .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 # context108 # 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 # handlers148 # 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+ 
18from __future__ import annotations19from __future__ import annotations
19 20 
20import math21import math
21import os22import os
22from dataclasses import dataclass23from dataclasses import dataclass
23 24 
25+ 
24_DEFAULT_HOST = "0.0.0.0"26_DEFAULT_HOST = "0.0.0.0"
25_DEFAULT_PORT = 809027_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.031_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@dataclass(frozen=True)48@dataclass(frozen=True)
@@ -39,22 +55,261 @@ class ServiceConfig:
39 key_prefix: str = _DEFAULT_KEY_PREFIX55 key_prefix: str = _DEFAULT_KEY_PREFIX
40 title: str = _DEFAULT_TITLE56 title: str = _DEFAULT_TITLE
41 request_timeout_seconds: float = _DEFAULT_REQUEST_TIMEOUT_SECONDS57 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+ @staticmethod
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+ @staticmethod
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+ @staticmethod
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+ @staticmethod
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+ @staticmethod
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+ @staticmethod
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 @classmethod214 @classmethod
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+ @property
306+ def etcd_configured(self) -> bool:
307+ return bool(self.etcd_endpoints)
308+ 
309+ @property
310+ def redis_configured(self) -> bool:
311+ return bool(self.redis_url)
312+ 
313+ @property
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 reserved2# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3 3 
4from .audit import AuditEvent, AuditLogger, LoggingAuditLogger, NoopAuditLogger4from .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+)
5from .request_context import RequestContext, TypedAppContext32from .request_context import RequestContext, TypedAppContext
6from .system_context import SystemContext33from .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+@runtime_checkable
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+ @staticmethod
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+ @staticmethod
42+ def loads(value: str) -> Any:
43+ return json.loads(value)
44+ 
45+ 
46+@dataclass(slots=True)
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+ @property
57+ def expired(self) -> int:
58+ """Compatibility alias for the number of lazily expired entries."""
59+ return self.expirations
60+ 
61+ @property
62+ def errors(self) -> int:
63+ """Compatibility alias for backend operation failures."""
64+ return self.backend_errors
65+ 
66+ 
67+@runtime_checkable
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+ @staticmethod
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+ @staticmethod
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+ @abstractmethod
237+ async def _get(self, key: str) -> str | None:
238+ raise NotImplementedError
239+ 
240+ @abstractmethod
241+ async def _set(self, key: str, value: str, ttl: float | None) -> None:
242+ raise NotImplementedError
243+ 
244+ @abstractmethod
245+ async def _delete(self, key: str) -> bool:
246+ raise NotImplementedError
247+ 
248+ @abstractmethod
249+ async def _exists(self, key: str) -> bool:
250+ raise NotImplementedError
251+ 
252+ @abstractmethod
253+ async def _clear_namespace(self) -> int:
254+ raise NotImplementedError
255+ 
256+ @abstractmethod
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+ @property
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+ @staticmethod
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+@dataclass(slots=True)
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+@dataclass(frozen=True, slots=True)
22+class EtcdEndpoint:
23+ """Parsed endpoint used by the aetcd client factory."""
24+ 
25+ scheme: str
26+ host: str
27+ port: int
28+ 
29+ @property
30+ def tls(self) -> bool:
31+ return self.scheme in {"https", "grpcs"}
32+ 
33+ 
34+@dataclass(slots=True)
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+ @staticmethod
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+ @staticmethod
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+@dataclass(slots=True)
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+ @staticmethod
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+ @staticmethod
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+ @staticmethod
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+ @staticmethod
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+@dataclass(frozen=True, slots=True)
14+class LockCapabilities:
15+ """声明锁后端提供的跨副本和 fencing 能力。"""
16+ 
17+ distributed: bool
18+ fencing: bool
19+ 
20+ 
21+@dataclass(frozen=True, slots=True)
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+@runtime_checkable
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+ @staticmethod
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+ @property
73+ def credential(self) -> LockCredential:
74+ return self._credential
75+ 
76+ @property
77+ def key(self) -> str:
78+ return self._credential.key
79+ 
80+ @property
81+ def state(self) -> LeaseState:
82+ return self._state
83+ 
84+ @property
85+ def lost(self) -> bool:
86+ return self._state is LeaseState.LOST
87+ 
88+ @property
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"]