omnixys-cache 3.0.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cache/__init__.py +43 -0
- cache/client.py +136 -0
- cache/container.py +56 -0
- cache/delayed_job.py +392 -0
- cache/invalidation.py +52 -0
- cache/keys.py +24 -0
- cache/lock.py +72 -0
- cache/model.py +19 -0
- cache/py.typed +0 -0
- cache/rate_limit.py +42 -0
- cache/serializer.py +27 -0
- cache/stream.py +101 -0
- omnixys_cache-3.0.6.dist-info/METADATA +136 -0
- omnixys_cache-3.0.6.dist-info/RECORD +15 -0
- omnixys_cache-3.0.6.dist-info/WHEEL +4 -0
cache/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from cache.client import CacheClient
|
|
2
|
+
from cache.delayed_job import (
|
|
3
|
+
DelayedJob,
|
|
4
|
+
DelayedJobContext,
|
|
5
|
+
DelayedJobHandlerNotFoundError,
|
|
6
|
+
DelayedJobRegistry,
|
|
7
|
+
DelayedJobSchedule,
|
|
8
|
+
DelayedJobService,
|
|
9
|
+
DelayedJobWorker,
|
|
10
|
+
delayed_job,
|
|
11
|
+
)
|
|
12
|
+
from cache.invalidation import CacheInvalidationService
|
|
13
|
+
from cache.keys import create_key, key
|
|
14
|
+
from cache.lock import CacheLock, LockNotAcquiredError
|
|
15
|
+
from cache.model import CacheKeyDefinition, CacheSerializer
|
|
16
|
+
from cache.rate_limit import RateLimiter, RateLimitResult
|
|
17
|
+
from cache.serializer import JsonCacheSerializer, StrictJsonCacheSerializer
|
|
18
|
+
from cache.stream import StreamMessage, ValkeyStreamService
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"CacheClient",
|
|
22
|
+
"CacheInvalidationService",
|
|
23
|
+
"CacheKeyDefinition",
|
|
24
|
+
"CacheLock",
|
|
25
|
+
"CacheSerializer",
|
|
26
|
+
"DelayedJob",
|
|
27
|
+
"DelayedJobContext",
|
|
28
|
+
"DelayedJobHandlerNotFoundError",
|
|
29
|
+
"DelayedJobRegistry",
|
|
30
|
+
"DelayedJobSchedule",
|
|
31
|
+
"DelayedJobService",
|
|
32
|
+
"DelayedJobWorker",
|
|
33
|
+
"JsonCacheSerializer",
|
|
34
|
+
"LockNotAcquiredError",
|
|
35
|
+
"RateLimitResult",
|
|
36
|
+
"RateLimiter",
|
|
37
|
+
"StreamMessage",
|
|
38
|
+
"StrictJsonCacheSerializer",
|
|
39
|
+
"ValkeyStreamService",
|
|
40
|
+
"create_key",
|
|
41
|
+
"delayed_job",
|
|
42
|
+
"key",
|
|
43
|
+
]
|
cache/client.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from redis.asyncio import Redis
|
|
6
|
+
|
|
7
|
+
from cache.model import CacheKeyDefinition, CacheSerializer
|
|
8
|
+
from cache.serializer import JsonCacheSerializer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CacheClient:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
url: str | None = None,
|
|
15
|
+
key_prefix: str = "",
|
|
16
|
+
password: str | None = None,
|
|
17
|
+
serializer: CacheSerializer | None = None,
|
|
18
|
+
client: Redis | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
if client is not None:
|
|
21
|
+
self._client = client
|
|
22
|
+
else:
|
|
23
|
+
if url is None:
|
|
24
|
+
raise ValueError("Either 'url' or 'client' must be provided")
|
|
25
|
+
self._client = Redis.from_url(
|
|
26
|
+
url,
|
|
27
|
+
password=password or None,
|
|
28
|
+
decode_responses=True,
|
|
29
|
+
health_check_interval=30,
|
|
30
|
+
)
|
|
31
|
+
self._prefix = key_prefix
|
|
32
|
+
self._serializer = serializer or JsonCacheSerializer()
|
|
33
|
+
|
|
34
|
+
async def ping(self) -> bool:
|
|
35
|
+
return bool(await self._client.ping())
|
|
36
|
+
|
|
37
|
+
async def close(self) -> None:
|
|
38
|
+
await self._client.aclose()
|
|
39
|
+
|
|
40
|
+
async def get(self, key: str | CacheKeyDefinition, *parts: str) -> str | None:
|
|
41
|
+
key_str = self._resolve(key, *parts)
|
|
42
|
+
value = await self._client.get(key_str)
|
|
43
|
+
return str(value) if value is not None else None
|
|
44
|
+
|
|
45
|
+
async def set(
|
|
46
|
+
self,
|
|
47
|
+
key: str | CacheKeyDefinition,
|
|
48
|
+
value: str,
|
|
49
|
+
ttl_seconds: int | None = None,
|
|
50
|
+
*parts: str,
|
|
51
|
+
) -> None:
|
|
52
|
+
key_str = self._resolve(key, *parts)
|
|
53
|
+
ttl = self._resolve_ttl(key, ttl_seconds)
|
|
54
|
+
await self._client.set(key_str, value, ex=ttl)
|
|
55
|
+
|
|
56
|
+
async def delete(self, key: str | CacheKeyDefinition, *parts: str) -> int:
|
|
57
|
+
key_str = self._resolve(key, *parts)
|
|
58
|
+
result = await self._client.delete(key_str)
|
|
59
|
+
return int(result)
|
|
60
|
+
|
|
61
|
+
async def exists(self, key: str | CacheKeyDefinition, *parts: str) -> bool:
|
|
62
|
+
key_str = self._resolve(key, *parts)
|
|
63
|
+
return bool(await self._client.exists(key_str))
|
|
64
|
+
|
|
65
|
+
async def expire(self, key: str | CacheKeyDefinition, ttl_seconds: int, *parts: str) -> bool:
|
|
66
|
+
key_str = self._resolve(key, *parts)
|
|
67
|
+
return bool(await self._client.expire(key_str, ttl_seconds))
|
|
68
|
+
|
|
69
|
+
async def ttl(self, key: str | CacheKeyDefinition, *parts: str) -> int:
|
|
70
|
+
key_str = self._resolve(key, *parts)
|
|
71
|
+
result = await self._client.ttl(key_str)
|
|
72
|
+
return int(result)
|
|
73
|
+
|
|
74
|
+
async def increment(self, key: str | CacheKeyDefinition, ttl_seconds: int | None = None, *parts: str) -> int:
|
|
75
|
+
key_str = self._resolve(key, *parts)
|
|
76
|
+
val = await self._client.incr(key_str)
|
|
77
|
+
ttl = self._resolve_ttl(key, ttl_seconds)
|
|
78
|
+
if ttl is not None:
|
|
79
|
+
await self._client.expire(key_str, ttl)
|
|
80
|
+
return int(val)
|
|
81
|
+
|
|
82
|
+
async def get_json(
|
|
83
|
+
self,
|
|
84
|
+
key: str | CacheKeyDefinition,
|
|
85
|
+
*parts: str,
|
|
86
|
+
) -> Any | None:
|
|
87
|
+
value = await self.get(key, *parts)
|
|
88
|
+
if value is None:
|
|
89
|
+
return None
|
|
90
|
+
return self._serializer.deserialize(value)
|
|
91
|
+
|
|
92
|
+
async def set_json(
|
|
93
|
+
self,
|
|
94
|
+
key: str | CacheKeyDefinition,
|
|
95
|
+
value: Any,
|
|
96
|
+
ttl_seconds: int | None = None,
|
|
97
|
+
*parts: str,
|
|
98
|
+
) -> None:
|
|
99
|
+
serialized = self._serializer.serialize(value)
|
|
100
|
+
key_str = self._resolve(key, *parts)
|
|
101
|
+
ttl = self._resolve_ttl(key, ttl_seconds)
|
|
102
|
+
await self._client.set(key_str, serialized, ex=ttl)
|
|
103
|
+
|
|
104
|
+
async def set_if_not_exists(
|
|
105
|
+
self,
|
|
106
|
+
key: str | CacheKeyDefinition,
|
|
107
|
+
value: str,
|
|
108
|
+
ttl_seconds: int | None = None,
|
|
109
|
+
*parts: str,
|
|
110
|
+
) -> bool:
|
|
111
|
+
key_str = self._resolve(key, *parts)
|
|
112
|
+
ttl = self._resolve_ttl(key, ttl_seconds)
|
|
113
|
+
result = await self._client.set(key_str, value, ex=ttl, nx=True)
|
|
114
|
+
return result is not None
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def raw(self) -> Redis:
|
|
118
|
+
"""The underlying redis.asyncio client for advanced low-level access."""
|
|
119
|
+
return self._client
|
|
120
|
+
|
|
121
|
+
async def set_nx_px(self, key: str, value: str, ttl_ms: int) -> bool:
|
|
122
|
+
"""Atomically set a raw key only if absent, expiring in milliseconds."""
|
|
123
|
+
result = await self._client.set(key, value, nx=True, px=ttl_ms)
|
|
124
|
+
return result is not None
|
|
125
|
+
|
|
126
|
+
def _resolve(self, key: str | CacheKeyDefinition, *parts: str) -> str:
|
|
127
|
+
raw = key.key(*parts) if isinstance(key, CacheKeyDefinition) else ":".join((key, *parts)) if parts else key
|
|
128
|
+
return f"{self._prefix}:{raw}" if self._prefix else raw
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _resolve_ttl(key: str | CacheKeyDefinition, ttl_seconds: int | None) -> int | None:
|
|
132
|
+
if ttl_seconds is not None:
|
|
133
|
+
return ttl_seconds
|
|
134
|
+
if isinstance(key, CacheKeyDefinition):
|
|
135
|
+
return key.ttl_seconds
|
|
136
|
+
return None
|
cache/container.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dishka import Provider, Scope, provide
|
|
4
|
+
from redis.asyncio import Redis
|
|
5
|
+
|
|
6
|
+
from cache.client import CacheClient
|
|
7
|
+
from cache.delayed_job import DelayedJobRegistry, DelayedJobService, get_default_registry
|
|
8
|
+
from cache.invalidation import CacheInvalidationService
|
|
9
|
+
from cache.lock import CacheLock
|
|
10
|
+
from cache.rate_limit import RateLimiter
|
|
11
|
+
from cache.serializer import JsonCacheSerializer
|
|
12
|
+
from cache.stream import ValkeyStreamService
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CacheProvider(Provider):
|
|
16
|
+
scope = Scope.APP
|
|
17
|
+
|
|
18
|
+
@provide
|
|
19
|
+
def serializer(self) -> JsonCacheSerializer:
|
|
20
|
+
return JsonCacheSerializer()
|
|
21
|
+
|
|
22
|
+
@provide
|
|
23
|
+
def cache_client(
|
|
24
|
+
self,
|
|
25
|
+
url: str,
|
|
26
|
+
key_prefix: str = "",
|
|
27
|
+
serializer: JsonCacheSerializer | None = None,
|
|
28
|
+
) -> CacheClient:
|
|
29
|
+
return CacheClient(url=url, key_prefix=key_prefix, serializer=serializer)
|
|
30
|
+
|
|
31
|
+
@provide
|
|
32
|
+
def invalidation(self, url: str, channel: str = "omnixys:cache:invalidate") -> CacheInvalidationService:
|
|
33
|
+
return CacheInvalidationService(
|
|
34
|
+
redis=Redis.from_url(url, decode_responses=True),
|
|
35
|
+
channel=channel,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
@provide
|
|
39
|
+
def lock(self, client: CacheClient) -> CacheLock:
|
|
40
|
+
return CacheLock(client)
|
|
41
|
+
|
|
42
|
+
@provide
|
|
43
|
+
def rate_limiter(self, client: CacheClient) -> RateLimiter:
|
|
44
|
+
return RateLimiter(client)
|
|
45
|
+
|
|
46
|
+
@provide
|
|
47
|
+
def stream_service(self, client: CacheClient) -> ValkeyStreamService:
|
|
48
|
+
return ValkeyStreamService(client)
|
|
49
|
+
|
|
50
|
+
@provide
|
|
51
|
+
def delayed_job_service(self, client: CacheClient) -> DelayedJobService:
|
|
52
|
+
return DelayedJobService(client)
|
|
53
|
+
|
|
54
|
+
@provide
|
|
55
|
+
def delayed_job_registry(self) -> DelayedJobRegistry:
|
|
56
|
+
return get_default_registry()
|
cache/delayed_job.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Delayed jobs scheduled on a Redis sorted set with persisted records.
|
|
2
|
+
|
|
3
|
+
A job is scheduled with an absolute execution timestamp. `DelayedJobWorker`
|
|
4
|
+
atomically claims due jobs (ZRANGEBYSCORE + ZREM via a WATCH/MULTI
|
|
5
|
+
transaction) and executes the registered handler through the
|
|
6
|
+
`DelayedJobRegistry`. Failed jobs are retried with a linear backoff until
|
|
7
|
+
`max_retries` is exhausted, after which they are marked as failed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import contextlib
|
|
14
|
+
import json
|
|
15
|
+
import secrets
|
|
16
|
+
from collections.abc import Awaitable, Callable
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from typing import Any, Literal
|
|
20
|
+
|
|
21
|
+
import redis as redis_pkg
|
|
22
|
+
from redis.asyncio import Redis
|
|
23
|
+
|
|
24
|
+
from cache.client import CacheClient
|
|
25
|
+
|
|
26
|
+
QUEUE_KEY = "delayed:jobs:scheduled"
|
|
27
|
+
RECORD_KEY_PREFIX = "delayed:job"
|
|
28
|
+
RECORD_TTL_SECONDS = 7 * 24 * 60 * 60
|
|
29
|
+
|
|
30
|
+
DelayedJobState = Literal["scheduled", "running", "completed", "failed", "canceled"]
|
|
31
|
+
|
|
32
|
+
JobHandler = Callable[[dict[str, Any]], Awaitable[Any]]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def now_ms() -> int:
|
|
36
|
+
return int(datetime.now(UTC).timestamp() * 1000)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class DelayedJobContext:
|
|
41
|
+
request_id: str = "unscoped"
|
|
42
|
+
correlation_id: str = "unscoped"
|
|
43
|
+
trace_id: str | None = None
|
|
44
|
+
actor_id: str | None = None
|
|
45
|
+
tenant_id: str | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(slots=True)
|
|
49
|
+
class DelayedJob:
|
|
50
|
+
id: str
|
|
51
|
+
type: str
|
|
52
|
+
payload: dict[str, Any]
|
|
53
|
+
execute_at_ms: int
|
|
54
|
+
retries: int = 0
|
|
55
|
+
max_retries: int = 3
|
|
56
|
+
retry_delay_ms: int = 1000
|
|
57
|
+
status: DelayedJobState = "scheduled"
|
|
58
|
+
created_at_ms: int = field(default_factory=now_ms)
|
|
59
|
+
updated_at_ms: int = field(default_factory=now_ms)
|
|
60
|
+
last_error: str | None = None
|
|
61
|
+
context: DelayedJobContext | None = None
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict[str, Any]:
|
|
64
|
+
return {
|
|
65
|
+
"id": self.id,
|
|
66
|
+
"type": self.type,
|
|
67
|
+
"payload": self.payload,
|
|
68
|
+
"executeAtMs": self.execute_at_ms,
|
|
69
|
+
"retries": self.retries,
|
|
70
|
+
"maxRetries": self.max_retries,
|
|
71
|
+
"retryDelayMs": self.retry_delay_ms,
|
|
72
|
+
"status": self.status,
|
|
73
|
+
"createdAtMs": self.created_at_ms,
|
|
74
|
+
"updatedAtMs": self.updated_at_ms,
|
|
75
|
+
"lastError": self.last_error,
|
|
76
|
+
"context": dataclass_to_dict(self.context),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_dict(cls, data: dict[str, Any]) -> DelayedJob:
|
|
81
|
+
return cls(
|
|
82
|
+
id=str(data["id"]),
|
|
83
|
+
type=str(data["type"]),
|
|
84
|
+
payload=data.get("payload") or {},
|
|
85
|
+
execute_at_ms=int(data["executeAtMs"]),
|
|
86
|
+
retries=int(data.get("retries", 0)),
|
|
87
|
+
max_retries=int(data.get("maxRetries", 3)),
|
|
88
|
+
retry_delay_ms=int(data.get("retryDelayMs", 1000)),
|
|
89
|
+
status=data.get("status", "scheduled"),
|
|
90
|
+
created_at_ms=int(data.get("createdAtMs", 0)),
|
|
91
|
+
updated_at_ms=int(data.get("updatedAtMs", 0)),
|
|
92
|
+
last_error=data.get("lastError"),
|
|
93
|
+
context=_context_from_dict(data.get("context")),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def dataclass_to_dict(context: DelayedJobContext | None) -> dict[str, str | None] | None:
|
|
98
|
+
if context is None:
|
|
99
|
+
return None
|
|
100
|
+
return {
|
|
101
|
+
"requestId": context.request_id,
|
|
102
|
+
"correlationId": context.correlation_id,
|
|
103
|
+
"traceId": context.trace_id,
|
|
104
|
+
"actorId": context.actor_id,
|
|
105
|
+
"tenantId": context.tenant_id,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _context_from_dict(data: Any) -> DelayedJobContext | None:
|
|
110
|
+
if not isinstance(data, dict):
|
|
111
|
+
return None
|
|
112
|
+
return DelayedJobContext(
|
|
113
|
+
request_id=str(data.get("requestId", "unscoped")),
|
|
114
|
+
correlation_id=str(data.get("correlationId", "unscoped")),
|
|
115
|
+
trace_id=data.get("traceId"),
|
|
116
|
+
actor_id=data.get("actorId"),
|
|
117
|
+
tenant_id=data.get("tenantId"),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True, slots=True)
|
|
122
|
+
class DelayedJobSchedule:
|
|
123
|
+
type: str
|
|
124
|
+
payload: dict[str, Any]
|
|
125
|
+
delay_ms: int
|
|
126
|
+
max_retries: int = 3
|
|
127
|
+
retry_delay_ms: int = 1000
|
|
128
|
+
context: DelayedJobContext | None = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class DelayedJobHandlerNotFoundError(Exception):
|
|
132
|
+
def __init__(self, job_type: str) -> None:
|
|
133
|
+
self.job_type = job_type
|
|
134
|
+
super().__init__(f"No handler registered for delayed job type '{job_type}'")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class DelayedJobRegistry:
|
|
138
|
+
"""Maps delayed-job types to their async handlers."""
|
|
139
|
+
|
|
140
|
+
def __init__(self) -> None:
|
|
141
|
+
self._handlers: dict[str, JobHandler] = {}
|
|
142
|
+
|
|
143
|
+
def register(self, job_type: str, handler: JobHandler) -> None:
|
|
144
|
+
self._handlers[job_type] = handler
|
|
145
|
+
|
|
146
|
+
def execute(self, job_type: str, payload: dict[str, Any]) -> Awaitable[Any]:
|
|
147
|
+
handler = self._handlers.get(job_type)
|
|
148
|
+
if handler is None:
|
|
149
|
+
raise DelayedJobHandlerNotFoundError(job_type)
|
|
150
|
+
return handler(payload)
|
|
151
|
+
|
|
152
|
+
def has(self, job_type: str) -> bool:
|
|
153
|
+
return job_type in self._handlers
|
|
154
|
+
|
|
155
|
+
def handlers(self) -> tuple[str, ...]:
|
|
156
|
+
return tuple(sorted(self._handlers))
|
|
157
|
+
|
|
158
|
+
def __len__(self) -> int:
|
|
159
|
+
return len(self._handlers)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
_default_registry = DelayedJobRegistry()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_default_registry() -> DelayedJobRegistry:
|
|
166
|
+
return _default_registry
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def delayed_job(job_type: str) -> Callable[[JobHandler], JobHandler]:
|
|
170
|
+
"""Decorator that registers an async handler for a delayed-job type."""
|
|
171
|
+
|
|
172
|
+
def decorator(handler: JobHandler) -> JobHandler:
|
|
173
|
+
_default_registry.register(job_type, handler)
|
|
174
|
+
return handler
|
|
175
|
+
|
|
176
|
+
return decorator
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class DelayedJobService:
|
|
180
|
+
def __init__(self, client: CacheClient) -> None:
|
|
181
|
+
self._client = client
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def raw(self) -> Redis:
|
|
185
|
+
return self._client.raw
|
|
186
|
+
|
|
187
|
+
async def schedule(self, schedule: DelayedJobSchedule) -> str:
|
|
188
|
+
"""Schedule a job and return its id."""
|
|
189
|
+
if schedule.delay_ms < 0:
|
|
190
|
+
raise ValueError("Delayed job delay_ms must be non-negative")
|
|
191
|
+
if schedule.max_retries < 0:
|
|
192
|
+
raise ValueError("Delayed job max_retries must be non-negative")
|
|
193
|
+
|
|
194
|
+
now = now_ms()
|
|
195
|
+
job = DelayedJob(
|
|
196
|
+
id=secrets.token_hex(16),
|
|
197
|
+
type=schedule.type,
|
|
198
|
+
payload=schedule.payload,
|
|
199
|
+
execute_at_ms=now + schedule.delay_ms,
|
|
200
|
+
max_retries=schedule.max_retries,
|
|
201
|
+
retry_delay_ms=schedule.retry_delay_ms,
|
|
202
|
+
context=schedule.context,
|
|
203
|
+
)
|
|
204
|
+
await self.persist(job)
|
|
205
|
+
await self.raw.zadd(QUEUE_KEY, {job.id: job.execute_at_ms})
|
|
206
|
+
return job.id
|
|
207
|
+
|
|
208
|
+
async def cancel(self, job_id: str) -> bool:
|
|
209
|
+
job = await self.status(job_id)
|
|
210
|
+
if job is None or job.status in ("completed", "running", "canceled", "failed"):
|
|
211
|
+
return False
|
|
212
|
+
await self.raw.zrem(QUEUE_KEY, job_id)
|
|
213
|
+
job.status = "canceled"
|
|
214
|
+
job.updated_at_ms = now_ms()
|
|
215
|
+
await self.persist(job)
|
|
216
|
+
return True
|
|
217
|
+
|
|
218
|
+
async def retry(self, job_id: str, delay_ms: int = 0) -> bool:
|
|
219
|
+
if delay_ms < 0:
|
|
220
|
+
raise ValueError("Delayed job retry delay must be non-negative")
|
|
221
|
+
job = await self.status(job_id)
|
|
222
|
+
if job is None or job.status == "running":
|
|
223
|
+
return False
|
|
224
|
+
job.status = "scheduled"
|
|
225
|
+
job.execute_at_ms = now_ms() + delay_ms
|
|
226
|
+
job.updated_at_ms = now_ms()
|
|
227
|
+
job.last_error = None
|
|
228
|
+
await self.persist(job)
|
|
229
|
+
await self.raw.zadd(QUEUE_KEY, {job.id: job.execute_at_ms})
|
|
230
|
+
return True
|
|
231
|
+
|
|
232
|
+
async def status(self, job_id: str) -> DelayedJob | None:
|
|
233
|
+
raw = await self.raw.get(self._record_key(job_id))
|
|
234
|
+
if not raw:
|
|
235
|
+
return None
|
|
236
|
+
try:
|
|
237
|
+
return DelayedJob.from_dict(json.loads(raw))
|
|
238
|
+
except (json.JSONDecodeError, KeyError, TypeError, ValueError):
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
async def claim_due(self, count: int = 10) -> list[DelayedJob]:
|
|
242
|
+
"""Atomically claim up to `count` due jobs and mark them running."""
|
|
243
|
+
claimed_ids = await self._claim_due_ids(count)
|
|
244
|
+
jobs: list[DelayedJob] = []
|
|
245
|
+
for raw_id in claimed_ids:
|
|
246
|
+
job = await self.status(str(raw_id))
|
|
247
|
+
if job is None or job.status != "scheduled":
|
|
248
|
+
continue
|
|
249
|
+
job.status = "running"
|
|
250
|
+
job.updated_at_ms = now_ms()
|
|
251
|
+
await self.persist(job)
|
|
252
|
+
jobs.append(job)
|
|
253
|
+
return jobs
|
|
254
|
+
|
|
255
|
+
async def _claim_due_ids(self, count: int) -> list[str]:
|
|
256
|
+
pipe = self.raw.pipeline(transaction=True)
|
|
257
|
+
while True:
|
|
258
|
+
try:
|
|
259
|
+
await pipe.watch(QUEUE_KEY)
|
|
260
|
+
ids = await pipe.zrangebyscore(QUEUE_KEY, "-inf", now_ms(), start=0, num=count)
|
|
261
|
+
if not ids:
|
|
262
|
+
await pipe.unwatch() # type: ignore[no-untyped-call]
|
|
263
|
+
return []
|
|
264
|
+
pipe.multi() # type: ignore[no-untyped-call]
|
|
265
|
+
pipe.zrem(QUEUE_KEY, *ids)
|
|
266
|
+
await pipe.execute()
|
|
267
|
+
return [str(item) for item in ids]
|
|
268
|
+
except redis_pkg.WatchError:
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
async def complete(self, job: DelayedJob) -> None:
|
|
272
|
+
job.status = "completed"
|
|
273
|
+
job.updated_at_ms = now_ms()
|
|
274
|
+
await self.persist(job)
|
|
275
|
+
|
|
276
|
+
async def fail(self, job: DelayedJob, error: Any) -> None:
|
|
277
|
+
job.retries += 1
|
|
278
|
+
job.last_error = str(error) if error else "unknown error"
|
|
279
|
+
job.updated_at_ms = now_ms()
|
|
280
|
+
if job.retries <= job.max_retries:
|
|
281
|
+
job.status = "scheduled"
|
|
282
|
+
job.execute_at_ms = now_ms() + job.retry_delay_ms * job.retries
|
|
283
|
+
await self.persist(job)
|
|
284
|
+
await self.raw.zadd(QUEUE_KEY, {job.id: job.execute_at_ms})
|
|
285
|
+
return
|
|
286
|
+
job.status = "failed"
|
|
287
|
+
await self.persist(job)
|
|
288
|
+
|
|
289
|
+
async def persist(self, job: DelayedJob) -> None:
|
|
290
|
+
await self.raw.set(
|
|
291
|
+
self._record_key(job.id),
|
|
292
|
+
json.dumps(job.to_dict()),
|
|
293
|
+
ex=RECORD_TTL_SECONDS,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def _record_key(self, job_id: str) -> str:
|
|
297
|
+
return f"{RECORD_KEY_PREFIX}:{job_id}"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class DelayedJobWorker:
|
|
301
|
+
"""Background loop that claims and executes due delayed jobs."""
|
|
302
|
+
|
|
303
|
+
def __init__(
|
|
304
|
+
self,
|
|
305
|
+
service: DelayedJobService,
|
|
306
|
+
registry: DelayedJobRegistry | None = None,
|
|
307
|
+
*,
|
|
308
|
+
claim_count: int = 10,
|
|
309
|
+
poll_interval_ms: int = 250,
|
|
310
|
+
on_error: Callable[[str, Any], None] | None = None,
|
|
311
|
+
) -> None:
|
|
312
|
+
self._service = service
|
|
313
|
+
self._registry = registry or _default_registry
|
|
314
|
+
self._claim_count = claim_count
|
|
315
|
+
self._poll_interval_ms = poll_interval_ms
|
|
316
|
+
self._on_error = on_error
|
|
317
|
+
self._running = False
|
|
318
|
+
self._in_flight = 0
|
|
319
|
+
self._task: asyncio.Task[None] | None = None
|
|
320
|
+
|
|
321
|
+
def ready(self) -> bool:
|
|
322
|
+
return self._running
|
|
323
|
+
|
|
324
|
+
def status(self) -> DelayedJobState:
|
|
325
|
+
if not self._running:
|
|
326
|
+
return "running" if self._in_flight > 0 else "canceled"
|
|
327
|
+
return "running" if self._in_flight > 0 else "scheduled"
|
|
328
|
+
|
|
329
|
+
def health(self) -> dict[str, Any]:
|
|
330
|
+
return {"healthy": self._running, "status": self.status()}
|
|
331
|
+
|
|
332
|
+
def diagnostics(self) -> dict[str, Any]:
|
|
333
|
+
return {
|
|
334
|
+
"status": self.status(),
|
|
335
|
+
"inFlight": self._in_flight,
|
|
336
|
+
"handlers": self._registry.handlers(),
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async def start(self) -> None:
|
|
340
|
+
if self._running:
|
|
341
|
+
return
|
|
342
|
+
self._running = True
|
|
343
|
+
self._task = asyncio.create_task(self._run(), name="delayed-job-worker")
|
|
344
|
+
|
|
345
|
+
async def _run(self) -> None:
|
|
346
|
+
while self._running:
|
|
347
|
+
try:
|
|
348
|
+
jobs = await self._service.claim_due(self._claim_count)
|
|
349
|
+
for job in jobs:
|
|
350
|
+
await self._handle(job)
|
|
351
|
+
if not jobs:
|
|
352
|
+
await asyncio.sleep(self._poll_interval_ms / 1000)
|
|
353
|
+
except asyncio.CancelledError:
|
|
354
|
+
break
|
|
355
|
+
except Exception as exc: # noqa: BLE001
|
|
356
|
+
if not self._running:
|
|
357
|
+
break
|
|
358
|
+
if self._on_error is not None:
|
|
359
|
+
self._on_error("delayed_job_worker", exc)
|
|
360
|
+
await asyncio.sleep(1.0)
|
|
361
|
+
|
|
362
|
+
async def _handle(self, job: DelayedJob) -> None:
|
|
363
|
+
self._in_flight += 1
|
|
364
|
+
try:
|
|
365
|
+
await self._registry.execute(job.type, job.payload)
|
|
366
|
+
await self._service.complete(job)
|
|
367
|
+
except Exception as exc: # noqa: BLE001
|
|
368
|
+
await self._service.fail(job, exc)
|
|
369
|
+
if self._on_error is not None:
|
|
370
|
+
self._on_error(job.type, exc)
|
|
371
|
+
finally:
|
|
372
|
+
self._in_flight -= 1
|
|
373
|
+
|
|
374
|
+
async def drain(self, timeout_ms: int = 5000) -> None:
|
|
375
|
+
deadline = now_ms() + timeout_ms
|
|
376
|
+
while self._in_flight > 0:
|
|
377
|
+
if now_ms() >= deadline:
|
|
378
|
+
raise TimeoutError(f"Delayed job drain timed out after {timeout_ms}ms")
|
|
379
|
+
await asyncio.sleep(0.01)
|
|
380
|
+
|
|
381
|
+
async def close(self) -> None:
|
|
382
|
+
if self._task is None and not self._running:
|
|
383
|
+
return
|
|
384
|
+
self._running = False
|
|
385
|
+
if self._task is not None:
|
|
386
|
+
self._task.cancel()
|
|
387
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
388
|
+
await self._task
|
|
389
|
+
self._task = None
|
|
390
|
+
await self.drain()
|
|
391
|
+
|
|
392
|
+
shutdown = close
|
cache/invalidation.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from redis.asyncio import Redis
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CacheInvalidationService:
|
|
12
|
+
def __init__(self, redis: Redis, channel: str = "omnixys:cache:invalidate") -> None:
|
|
13
|
+
self._redis = redis
|
|
14
|
+
self._channel = channel
|
|
15
|
+
self._pubsub = redis.pubsub()
|
|
16
|
+
|
|
17
|
+
async def publish(
|
|
18
|
+
self,
|
|
19
|
+
key: str,
|
|
20
|
+
operation: str = "delete",
|
|
21
|
+
source: str = "",
|
|
22
|
+
request_id: str | None = None,
|
|
23
|
+
correlation_id: str | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
event = {
|
|
26
|
+
"eventId": str(uuid4()),
|
|
27
|
+
"key": key,
|
|
28
|
+
"operation": operation,
|
|
29
|
+
"source": source,
|
|
30
|
+
"occurredAtEpochMs": int(datetime.now(UTC).timestamp() * 1000),
|
|
31
|
+
"requestId": request_id or "",
|
|
32
|
+
"correlationId": correlation_id or "",
|
|
33
|
+
}
|
|
34
|
+
await self._redis.publish(self._channel, json.dumps(event))
|
|
35
|
+
|
|
36
|
+
async def subscribe(self) -> None:
|
|
37
|
+
await self._pubsub.subscribe(self._channel)
|
|
38
|
+
|
|
39
|
+
async def unsubscribe(self) -> None:
|
|
40
|
+
await self._pubsub.unsubscribe(self._channel)
|
|
41
|
+
|
|
42
|
+
async def get_message(self, poll_timeout: float = 1.0) -> dict[str, Any] | None:
|
|
43
|
+
raw = await self._pubsub.get_message(timeout=poll_timeout, ignore_subscribe_messages=True)
|
|
44
|
+
if raw is None or raw["type"] != "message":
|
|
45
|
+
return None
|
|
46
|
+
data = raw.get("data", "")
|
|
47
|
+
if isinstance(data, bytes):
|
|
48
|
+
data = data.decode("utf-8")
|
|
49
|
+
return json.loads(data) if data else None
|
|
50
|
+
|
|
51
|
+
async def close(self) -> None:
|
|
52
|
+
await self._pubsub.close()
|
cache/keys.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Typed cache key definitions and the `create_key` builder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
from cache.model import CacheKeyDefinition
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_key(prefix: str, ttl_seconds: int | None = None) -> CacheKeyDefinition:
|
|
11
|
+
"""Build a `CacheKeyDefinition`, validating the TTL.
|
|
12
|
+
|
|
13
|
+
Raises:
|
|
14
|
+
ValueError: when `ttl_seconds` is not a positive finite number.
|
|
15
|
+
"""
|
|
16
|
+
if ttl_seconds is not None and (not math.isfinite(ttl_seconds) or ttl_seconds <= 0):
|
|
17
|
+
raise ValueError("Cache key ttl_seconds must be a positive finite number")
|
|
18
|
+
return CacheKeyDefinition(prefix=prefix, ttl_seconds=ttl_seconds)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def key(name: str, *parts: str) -> CacheKeyDefinition:
|
|
22
|
+
"""Convenience shorthand for `create_key` with an optional suffix chain."""
|
|
23
|
+
prefix = ":".join((name, *parts)) if parts else name
|
|
24
|
+
return CacheKeyDefinition(prefix=prefix)
|
cache/lock.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Distributed lock support backed by a Redis/Valkey SET NX PX primitive."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
|
|
9
|
+
import redis as redis_pkg
|
|
10
|
+
from redis.asyncio import Redis
|
|
11
|
+
|
|
12
|
+
from cache.client import CacheClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LockNotAcquiredError(Exception):
|
|
16
|
+
def __init__(self, key: str) -> None:
|
|
17
|
+
self.key = key
|
|
18
|
+
super().__init__(f"Could not acquire lock for key '{key}'")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CacheLock:
|
|
22
|
+
"""Non-blocking distributed lock with a safety TTL and token-based release.
|
|
23
|
+
|
|
24
|
+
The lock is released atomically (WATCH + GET + MULTI DEL) only when the
|
|
25
|
+
stored token matches, so a lock acquired by another holder can never be
|
|
26
|
+
released by us.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, client: CacheClient) -> None:
|
|
30
|
+
self._client = client
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def raw(self) -> Redis:
|
|
34
|
+
return self._client.raw
|
|
35
|
+
|
|
36
|
+
async def acquire(self, key: str, ttl_ms: int = 3000) -> str | None:
|
|
37
|
+
"""Try to acquire `key`; returns the release token or None if already held."""
|
|
38
|
+
token = secrets.token_hex(16)
|
|
39
|
+
acquired = await self._client.set_nx_px(key, token, ttl_ms)
|
|
40
|
+
return token if acquired else None
|
|
41
|
+
|
|
42
|
+
async def release(self, key: str, token: str) -> bool:
|
|
43
|
+
"""Release `key` if and only if it is still held by `token`."""
|
|
44
|
+
pipe = self.raw.pipeline(transaction=True)
|
|
45
|
+
while True:
|
|
46
|
+
try:
|
|
47
|
+
await pipe.watch(key)
|
|
48
|
+
stored = await pipe.get(key)
|
|
49
|
+
if stored != token:
|
|
50
|
+
await pipe.unwatch() # type: ignore[no-untyped-call]
|
|
51
|
+
return False
|
|
52
|
+
pipe.multi() # type: ignore[no-untyped-call]
|
|
53
|
+
pipe.delete(key)
|
|
54
|
+
await pipe.execute()
|
|
55
|
+
except redis_pkg.WatchError:
|
|
56
|
+
continue
|
|
57
|
+
else:
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
async def is_locked(self, key: str) -> bool:
|
|
61
|
+
return await self._client.exists(key)
|
|
62
|
+
|
|
63
|
+
@asynccontextmanager
|
|
64
|
+
async def locked(self, key: str, ttl_ms: int = 3000) -> AsyncIterator[None]:
|
|
65
|
+
"""Context manager that acquires `key` and raises if it is already held."""
|
|
66
|
+
token = await self.acquire(key, ttl_ms)
|
|
67
|
+
if token is None:
|
|
68
|
+
raise LockNotAcquiredError(key)
|
|
69
|
+
try:
|
|
70
|
+
yield
|
|
71
|
+
finally:
|
|
72
|
+
await self.release(key, token)
|
cache/model.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Protocol, runtime_checkable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@runtime_checkable
|
|
8
|
+
class CacheSerializer(Protocol):
|
|
9
|
+
def serialize(self, value: Any) -> str: ...
|
|
10
|
+
def deserialize(self, data: str) -> Any: ...
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class CacheKeyDefinition:
|
|
15
|
+
prefix: str
|
|
16
|
+
ttl_seconds: int | None = None
|
|
17
|
+
|
|
18
|
+
def key(self, *parts: str) -> str:
|
|
19
|
+
return ":".join((self.prefix, *parts)) if parts else self.prefix
|
cache/py.typed
ADDED
|
File without changes
|
cache/rate_limit.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Fixed-window rate limiting backed by atomic Redis INCR/EXPIRE."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from cache.client import CacheClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class RateLimitResult:
|
|
12
|
+
allowed: bool
|
|
13
|
+
current: int
|
|
14
|
+
limit: int
|
|
15
|
+
remaining: int
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RateLimiter:
|
|
19
|
+
"""Fixed-window rate limiter.
|
|
20
|
+
|
|
21
|
+
Each `hit` atomically increments the counter for `key` and sets the TTL on
|
|
22
|
+
the first increment of the window. The window is therefore reset after
|
|
23
|
+
`ttl_seconds` pass.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, client: CacheClient) -> None:
|
|
27
|
+
self._client = client
|
|
28
|
+
|
|
29
|
+
async def hit(self, key: str, limit: int, ttl_seconds: int) -> bool:
|
|
30
|
+
"""Record one hit; returns True if still within `limit` for the window."""
|
|
31
|
+
current = await self._client.increment(key, ttl_seconds=ttl_seconds)
|
|
32
|
+
return current <= limit
|
|
33
|
+
|
|
34
|
+
async def check(self, key: str, limit: int, ttl_seconds: int) -> RateLimitResult:
|
|
35
|
+
"""Record one hit and return the full rate-limit result."""
|
|
36
|
+
current = await self._client.increment(key, ttl_seconds=ttl_seconds)
|
|
37
|
+
return RateLimitResult(
|
|
38
|
+
allowed=current <= limit,
|
|
39
|
+
current=current,
|
|
40
|
+
limit=limit,
|
|
41
|
+
remaining=max(limit - current, 0),
|
|
42
|
+
)
|
cache/serializer.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class JsonCacheSerializer:
|
|
8
|
+
def serialize(self, value: Any) -> str:
|
|
9
|
+
return json.dumps(value, default=str)
|
|
10
|
+
|
|
11
|
+
def deserialize(self, data: str) -> Any | None:
|
|
12
|
+
try:
|
|
13
|
+
return json.loads(data)
|
|
14
|
+
except json.JSONDecodeError:
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StrictJsonCacheSerializer:
|
|
19
|
+
def serialize(self, value: Any) -> str:
|
|
20
|
+
return json.dumps(value, default=str)
|
|
21
|
+
|
|
22
|
+
def deserialize(self, data: str) -> Any:
|
|
23
|
+
try:
|
|
24
|
+
return json.loads(data)
|
|
25
|
+
except json.JSONDecodeError as exc:
|
|
26
|
+
msg = f"Failed to deserialize cache value: {exc}"
|
|
27
|
+
raise ValueError(msg) from exc
|
cache/stream.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Redis Streams consumer-group support for reliable work queues."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from redis.asyncio import Redis
|
|
10
|
+
from redis.exceptions import ResponseError
|
|
11
|
+
|
|
12
|
+
from cache.client import CacheClient
|
|
13
|
+
|
|
14
|
+
_DATA_FIELD = "data"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class StreamMessage[T]:
|
|
19
|
+
id: str
|
|
20
|
+
data: T
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ValkeyStreamService:
|
|
24
|
+
"""Stream + consumer-group facade over a shared CacheClient.
|
|
25
|
+
|
|
26
|
+
Messages are stored as JSON in a single `data` field so payloads stay
|
|
27
|
+
transport-neutral.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, client: CacheClient) -> None:
|
|
31
|
+
self._client = client
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def raw(self) -> Redis:
|
|
35
|
+
return self._client.raw
|
|
36
|
+
|
|
37
|
+
async def ensure_group(self, stream: str, group: str) -> None:
|
|
38
|
+
"""Create `group` on `stream` if missing (idempotent)."""
|
|
39
|
+
try:
|
|
40
|
+
await self.raw.xgroup_create(stream, group, id="$", mkstream=True)
|
|
41
|
+
except ResponseError as exc:
|
|
42
|
+
if "BUSYGROUP" in str(exc):
|
|
43
|
+
return
|
|
44
|
+
raise
|
|
45
|
+
|
|
46
|
+
async def enqueue(self, stream: str, payload: Any) -> str:
|
|
47
|
+
"""Append `payload` to `stream`; returns the message id."""
|
|
48
|
+
result = await self.raw.xadd(stream, {_DATA_FIELD: json.dumps(payload)})
|
|
49
|
+
return str(result)
|
|
50
|
+
|
|
51
|
+
async def consume(
|
|
52
|
+
self,
|
|
53
|
+
stream: str,
|
|
54
|
+
group: str,
|
|
55
|
+
consumer: str,
|
|
56
|
+
count: int = 10,
|
|
57
|
+
block_ms: int = 5000,
|
|
58
|
+
) -> list[StreamMessage[Any]]:
|
|
59
|
+
"""Read new (unconsumed) messages from `group` as `consumer`."""
|
|
60
|
+
response = await self.raw.xreadgroup(
|
|
61
|
+
group,
|
|
62
|
+
consumer,
|
|
63
|
+
{stream: ">"},
|
|
64
|
+
count=count,
|
|
65
|
+
block=block_ms,
|
|
66
|
+
)
|
|
67
|
+
return self._flatten(response)
|
|
68
|
+
|
|
69
|
+
async def read_pending(
|
|
70
|
+
self,
|
|
71
|
+
stream: str,
|
|
72
|
+
group: str,
|
|
73
|
+
consumer: str,
|
|
74
|
+
count: int = 10,
|
|
75
|
+
) -> list[StreamMessage[Any]]:
|
|
76
|
+
"""Read already-delivered but unacknowledged messages (id '0')."""
|
|
77
|
+
response = await self.raw.xreadgroup(
|
|
78
|
+
group,
|
|
79
|
+
consumer,
|
|
80
|
+
{stream: "0"},
|
|
81
|
+
count=count,
|
|
82
|
+
)
|
|
83
|
+
return self._flatten(response)
|
|
84
|
+
|
|
85
|
+
async def ack(self, stream: str, group: str, message_id: str) -> None:
|
|
86
|
+
await self.raw.xack(stream, group, message_id)
|
|
87
|
+
|
|
88
|
+
def _flatten(self, response: Any) -> list[StreamMessage[Any]]:
|
|
89
|
+
if not response:
|
|
90
|
+
return []
|
|
91
|
+
messages: list[StreamMessage[Any]] = []
|
|
92
|
+
for entry in response:
|
|
93
|
+
_, entries = entry
|
|
94
|
+
for message_id, fields in entries:
|
|
95
|
+
payload = fields.get(_DATA_FIELD.encode(), fields.get(_DATA_FIELD))
|
|
96
|
+
data: Any = None
|
|
97
|
+
if payload is not None:
|
|
98
|
+
decoded = payload.decode("utf-8") if isinstance(payload, bytes) else payload
|
|
99
|
+
data = json.loads(decoded)
|
|
100
|
+
messages.append(StreamMessage(str(message_id), data))
|
|
101
|
+
return messages
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: omnixys-cache
|
|
3
|
+
Version: 3.0.6
|
|
4
|
+
Summary: Omnixys shared cache package (Redis/Valkey client, serialization, invalidation)
|
|
5
|
+
Project-URL: Homepage, https://github.com/omnixys/omnixys
|
|
6
|
+
Project-URL: Repository, https://github.com/omnixys/omnixys
|
|
7
|
+
Author-email: Omnixys Team <team@omnixys.com>
|
|
8
|
+
License: GPL-3.0-or-later
|
|
9
|
+
Keywords: cache,omnixys,redis,serialization,valkey
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.14
|
|
16
|
+
Requires-Dist: redis>=5.4.0
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# omnixys-cache
|
|
20
|
+
|
|
21
|
+
Omnixys shared cache package — a typed Redis/Valkey facade with JSON serialization, invalidation, distributed locks, rate limiting, streams with consumer groups, and delayed jobs, all on one client.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install omnixys-cache
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- **CacheClient**: async Redis/Valkey facade with typed keys, JSON serialization, TTL handling, atomic `NX`/`PX` ops and low-level `raw` access
|
|
32
|
+
- **Key definitions**: `create_key(prefix, ttl_seconds)` and `key(...)` builders validated at construction time
|
|
33
|
+
- **CacheLock**: distributed mutual-exclusion lock with token-based (WATCH/MULTI) release
|
|
34
|
+
- **RateLimiter**: fixed-window rate limiting with `hit`/`check` and TTL window reset
|
|
35
|
+
- **ValkeyStreamService**: stream + consumer-group queues (`ensureGroup`, `enqueue`, `consume`, `read_pending`, `ack`)
|
|
36
|
+
- **Delayed jobs**: schedule/claim/retry/cancel delayed jobs with a worker loop and decorator-driven registry
|
|
37
|
+
- **Invalidation**: `CacheInvalidationService` with pluggable strategies
|
|
38
|
+
- **Serialization**: JSON serializers (`JsonCacheSerializer`, `StrictJsonCacheSerializer`)
|
|
39
|
+
- **DI**: `CacheProvider` container integration (dishka)
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
### Client and keys
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import asyncio
|
|
47
|
+
from cache import CacheClient, create_key
|
|
48
|
+
|
|
49
|
+
session = create_key("session", ttl_seconds=300)
|
|
50
|
+
cache = CacheClient(url="redis://localhost:6379", key_prefix="myapp")
|
|
51
|
+
|
|
52
|
+
async def main() -> None:
|
|
53
|
+
await cache.set_json(session, {"userId": 42}, parts=("u1",)) # myapp:session:u1
|
|
54
|
+
assert await cache.get_json(session, "u1") == {"userId": 42}
|
|
55
|
+
await cache.expire(session, 60, "u1")
|
|
56
|
+
await cache.close()
|
|
57
|
+
|
|
58
|
+
asyncio.run(main())
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Raw keys work too — pass a plain `str` and optional parts are joined with `:`.
|
|
62
|
+
|
|
63
|
+
### Distributed lock
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from cache import CacheClient, CacheLock
|
|
67
|
+
|
|
68
|
+
async with CacheLock(cache, "deploy:eu1", ttl_ms=30_000, wait_ms=10_000) as lock:
|
|
69
|
+
...
|
|
70
|
+
# or manually:
|
|
71
|
+
lock = CacheLock(cache, "deploy:eu1", ttl_ms=30_000)
|
|
72
|
+
if await lock.acquire():
|
|
73
|
+
try:
|
|
74
|
+
...
|
|
75
|
+
finally:
|
|
76
|
+
await lock.release()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Rate limiting
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from cache import CacheClient, RateLimiter
|
|
83
|
+
|
|
84
|
+
limiter = RateLimiter(cache)
|
|
85
|
+
if not await limiter.check("sms:user:42", limit=5, ttl_seconds=60).allowed:
|
|
86
|
+
raise RuntimeError("rate limit exceeded")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Streams with consumer groups
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from cache import CacheClient, ValkeyStreamService
|
|
93
|
+
|
|
94
|
+
streams = ValkeyStreamService(cache)
|
|
95
|
+
await streams.ensure_group("jobs", "workers")
|
|
96
|
+
|
|
97
|
+
await streams.enqueue("jobs", {"type": "resize", "imageId": "abc"})
|
|
98
|
+
|
|
99
|
+
for message in await streams.consume("jobs", "workers", consumer="w1", block_ms=1_000):
|
|
100
|
+
payload = message.data # {"type": "resize", "imageId": "abc"}
|
|
101
|
+
await streams.ack("jobs", "workers", message.id)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Delayed jobs
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from cache import DelayedJobService, DelayedJobSchedule, DelayedJobWorker, delayed_job
|
|
108
|
+
|
|
109
|
+
@delayed_job("image.resize")
|
|
110
|
+
async def resize(payload: dict) -> None:
|
|
111
|
+
print(payload["imageId"])
|
|
112
|
+
|
|
113
|
+
service = DelayedJobService(cache)
|
|
114
|
+
job_id = await service.schedule(
|
|
115
|
+
DelayedJobSchedule(type="image.resize", payload={"imageId": "abc"}, delay_ms=5_000)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
worker = DelayedJobWorker(service) # uses the default (decorated) registry
|
|
119
|
+
await worker.start()
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Jobs are persisted as records (7-day TTL), scheduled on a sorted set (`delayed:jobs`), claimed atomically via WATCH/MULTI, retried on failure up to `max_retries`, and surfaced through `status()`, `cancel()`, `retry()`, and `claim_due()`.
|
|
123
|
+
|
|
124
|
+
## Testing
|
|
125
|
+
|
|
126
|
+
The package ships behavior-level tests (pytest + fakeredis, no server needed):
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
uv run pytest -q # 47 tests
|
|
130
|
+
uv run ruff check . # lint
|
|
131
|
+
uv run mypy src/ # strict typing
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
GPL-3.0-or-later
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
cache/__init__.py,sha256=IbAYEoI9OMOCmEVmHiILTBq9Xt-PDTXadm8CjtCEEUY,1200
|
|
2
|
+
cache/client.py,sha256=mbviah3eb0eIqChjtSRBObpH3d82adHhDkwGZTg5xcc,4791
|
|
3
|
+
cache/container.py,sha256=Lef5Zv7LTD_0aqhbhWYq3qSyP9RPT9VWL8ZROl5c2aE,1728
|
|
4
|
+
cache/delayed_job.py,sha256=YH5GJ61wrq-A9PqqHYLraDucBNmaN-AxGsKnQSL4wfY,13177
|
|
5
|
+
cache/invalidation.py,sha256=sWdtLO1UmRnjRz0iQ61452EbzaNpPmw5chySqs5AWHQ,1671
|
|
6
|
+
cache/keys.py,sha256=D-dPh41ZSAdQ22NX3iBoFBVDjeIYGQeuLkd3QxvTwwY,873
|
|
7
|
+
cache/lock.py,sha256=hyYGCmyETQteJ-nZjWAATB13-qbJKNDb6eZtcklQUoM,2420
|
|
8
|
+
cache/model.py,sha256=uRPY_dyTxSmXuP-sOJtHGOtVantVGYWlRdSCq03CvpQ,489
|
|
9
|
+
cache/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
cache/rate_limit.py,sha256=Go4YQm5aUVHhZJB9KoS5irt7LL65wkEv0rGDKObonLk,1313
|
|
11
|
+
cache/serializer.py,sha256=85tAcXQ_q-v6XJ_JldmEp_UKKeNaXWUK2eh-ZEtRn98,722
|
|
12
|
+
cache/stream.py,sha256=cSMWRymsrKTk1l7J7ivxWW7Mb7MtPF1eDYArN-3LWiQ,3049
|
|
13
|
+
omnixys_cache-3.0.6.dist-info/METADATA,sha256=dQqMIjq2IQ8a-cX4K6ZnNchAwz8um7QPgamVQ1Z-8Lc,4468
|
|
14
|
+
omnixys_cache-3.0.6.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
15
|
+
omnixys_cache-3.0.6.dist-info/RECORD,,
|