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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any