cache-sync 0.3.1__tar.gz
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_sync-0.3.1/PKG-INFO +140 -0
- cache_sync-0.3.1/README.md +99 -0
- cache_sync-0.3.1/pyproject.toml +107 -0
- cache_sync-0.3.1/src/cache_sync/__init__.py +80 -0
- cache_sync-0.3.1/src/cache_sync/core.py +256 -0
- cache_sync-0.3.1/src/cache_sync/decorators.py +86 -0
- cache_sync-0.3.1/src/cache_sync/distributed_cache.py +16 -0
- cache_sync-0.3.1/src/cache_sync/invalidation.py +111 -0
- cache_sync-0.3.1/src/cache_sync/providers/__init__.py +1 -0
- cache_sync-0.3.1/src/cache_sync/providers/kafka/__init__.py +7 -0
- cache_sync-0.3.1/src/cache_sync/providers/kafka/invalidation_bus.py +173 -0
- cache_sync-0.3.1/src/cache_sync/providers/postgres/__init__.py +7 -0
- cache_sync-0.3.1/src/cache_sync/providers/postgres/invalidation_bus.py +129 -0
- cache_sync-0.3.1/src/cache_sync/providers/rabbitmq/__init__.py +7 -0
- cache_sync-0.3.1/src/cache_sync/providers/rabbitmq/invalidation_bus.py +168 -0
- cache_sync-0.3.1/src/cache_sync/providers/redis/__init__.py +9 -0
- cache_sync-0.3.1/src/cache_sync/providers/redis/cache.py +52 -0
- cache_sync-0.3.1/src/cache_sync/providers/redis/invalidation_bus.py +181 -0
- cache_sync-0.3.1/src/cache_sync/py.typed +1 -0
- cache_sync-0.3.1/src/cache_sync/serializers.py +83 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: cache-sync
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: Async hybrid Python cache with in-memory L1, distributed L2 providers, pluggable invalidation, stampede protection, and typed decorators.
|
|
5
|
+
Keywords: async,cache,redis,invalidation,stampede-protection
|
|
6
|
+
Author: Peter Cinibulk
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Framework :: AsyncIO
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: redis>=5.0.0 ; extra == 'all'
|
|
20
|
+
Requires-Dist: aio-pika>=9.0.0 ; extra == 'all'
|
|
21
|
+
Requires-Dist: aiokafka>=0.10.0 ; extra == 'all'
|
|
22
|
+
Requires-Dist: asyncpg>=0.29.0 ; extra == 'all'
|
|
23
|
+
Requires-Dist: pydantic>=1.10.0 ; extra == 'all'
|
|
24
|
+
Requires-Dist: aiokafka>=0.10.0 ; extra == 'kafka'
|
|
25
|
+
Requires-Dist: asyncpg>=0.29.0 ; extra == 'postgres'
|
|
26
|
+
Requires-Dist: pydantic>=1.10.0 ; extra == 'pydantic'
|
|
27
|
+
Requires-Dist: aio-pika>=9.0.0 ; extra == 'rabbitmq'
|
|
28
|
+
Requires-Dist: redis>=5.0.0 ; extra == 'redis'
|
|
29
|
+
Requires-Python: >=3.12
|
|
30
|
+
Project-URL: Changelog, https://github.com/petercinibulk/cache-sync/blob/main/CHANGELOG.md
|
|
31
|
+
Project-URL: Documentation, https://petercinibulk.github.io/cache-sync/
|
|
32
|
+
Project-URL: Issues, https://github.com/petercinibulk/cache-sync/issues
|
|
33
|
+
Project-URL: Repository, https://github.com/petercinibulk/cache-sync
|
|
34
|
+
Provides-Extra: all
|
|
35
|
+
Provides-Extra: kafka
|
|
36
|
+
Provides-Extra: postgres
|
|
37
|
+
Provides-Extra: pydantic
|
|
38
|
+
Provides-Extra: rabbitmq
|
|
39
|
+
Provides-Extra: redis
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
|
|
42
|
+
# cache-sync
|
|
43
|
+
|
|
44
|
+
Async hybrid Python cache with in-memory L1 caching, optional Redis L2 caching, pluggable invalidation, stampede protection, fail-safe stale values, and typed decorators.
|
|
45
|
+
|
|
46
|
+
## Features
|
|
47
|
+
|
|
48
|
+
- Async-first API for Python 3.12 and newer.
|
|
49
|
+
- Fast in-process L1 cache with optional Redis-backed L2 storage.
|
|
50
|
+
- Pluggable invalidation buses for Redis Streams, RabbitMQ, Kafka, and PostgreSQL.
|
|
51
|
+
- Request stampede protection with per-key refresh coordination.
|
|
52
|
+
- Fail-safe stale reads for short backend outages.
|
|
53
|
+
- Typed decorators that preserve the wrapped function signature.
|
|
54
|
+
- Serializer choices for JSON, pickle, and Pydantic models.
|
|
55
|
+
|
|
56
|
+
## Documentation
|
|
57
|
+
|
|
58
|
+
The end-user documentation is published at <https://petercinibulk.github.io/cache-sync/> and is built from [`docs/`](docs/index.md) with Zensical.
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
uv add cache-sync
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Install optional providers only when your application uses them:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
uv add "cache-sync[redis]"
|
|
70
|
+
uv add "cache-sync[rabbitmq]"
|
|
71
|
+
uv add "cache-sync[kafka]"
|
|
72
|
+
uv add "cache-sync[postgres]"
|
|
73
|
+
uv add "cache-sync[all]"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
| Extra | Installs | Use when |
|
|
77
|
+
| --- | --- | --- |
|
|
78
|
+
| `redis` | `redis` | You need Redis L2 storage or Redis Streams invalidation. |
|
|
79
|
+
| `rabbitmq` | `aio-pika` | You use RabbitMQ as the invalidation bus. |
|
|
80
|
+
| `kafka` | `aiokafka` | You use Kafka as the invalidation bus. |
|
|
81
|
+
| `postgres` | `asyncpg` | You use PostgreSQL `LISTEN`/`NOTIFY` for invalidation. |
|
|
82
|
+
| `pydantic` | `pydantic` | You want Pydantic model serialization helpers. |
|
|
83
|
+
| `all` | all provider dependencies | You want every optional provider available. |
|
|
84
|
+
|
|
85
|
+
## Quick Start
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from cache_sync import CacheOptions, CacheSync
|
|
89
|
+
|
|
90
|
+
cache = CacheSync(
|
|
91
|
+
options=CacheOptions(
|
|
92
|
+
ttl_seconds=60,
|
|
93
|
+
fail_safe_seconds=300,
|
|
94
|
+
hard_timeout_seconds=5,
|
|
95
|
+
jitter_seconds=5,
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
await cache.start()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@cache.cached(lambda user_id: f"user:{user_id}")
|
|
103
|
+
async def get_user(user_id: str) -> dict[str, str]:
|
|
104
|
+
return {"id": user_id, "name": "Peter"}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
user = await get_user("123")
|
|
108
|
+
await get_user.remove_cached("123")
|
|
109
|
+
await cache.stop()
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Redis L2 Example
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from redis.asyncio import Redis
|
|
116
|
+
|
|
117
|
+
from cache_sync import CacheOptions, CacheSync, RedisDistributedCache
|
|
118
|
+
|
|
119
|
+
redis = Redis.from_url("redis://localhost:6379/0")
|
|
120
|
+
|
|
121
|
+
cache = CacheSync(
|
|
122
|
+
distributed_cache=RedisDistributedCache(redis),
|
|
123
|
+
options=CacheOptions(ttl_seconds=60, fail_safe_seconds=300),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
await cache.start()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@cache.cached(lambda product_id: f"product:{product_id}")
|
|
130
|
+
async def get_product(product_id: str) -> dict[str, str]:
|
|
131
|
+
return {"id": product_id}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
For a complete walkthrough with shared values and cross-instance invalidation, see the [get started tutorial](https://petercinibulk.github.io/cache-sync/tutorials/get-started/).
|
|
135
|
+
|
|
136
|
+
## Project
|
|
137
|
+
|
|
138
|
+
- License: MIT
|
|
139
|
+
- Source: <https://github.com/petercinibulk/cache-sync>
|
|
140
|
+
- Issues: <https://github.com/petercinibulk/cache-sync/issues>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# cache-sync
|
|
2
|
+
|
|
3
|
+
Async hybrid Python cache with in-memory L1 caching, optional Redis L2 caching, pluggable invalidation, stampede protection, fail-safe stale values, and typed decorators.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Async-first API for Python 3.12 and newer.
|
|
8
|
+
- Fast in-process L1 cache with optional Redis-backed L2 storage.
|
|
9
|
+
- Pluggable invalidation buses for Redis Streams, RabbitMQ, Kafka, and PostgreSQL.
|
|
10
|
+
- Request stampede protection with per-key refresh coordination.
|
|
11
|
+
- Fail-safe stale reads for short backend outages.
|
|
12
|
+
- Typed decorators that preserve the wrapped function signature.
|
|
13
|
+
- Serializer choices for JSON, pickle, and Pydantic models.
|
|
14
|
+
|
|
15
|
+
## Documentation
|
|
16
|
+
|
|
17
|
+
The end-user documentation is published at <https://petercinibulk.github.io/cache-sync/> and is built from [`docs/`](docs/index.md) with Zensical.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uv add cache-sync
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Install optional providers only when your application uses them:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv add "cache-sync[redis]"
|
|
29
|
+
uv add "cache-sync[rabbitmq]"
|
|
30
|
+
uv add "cache-sync[kafka]"
|
|
31
|
+
uv add "cache-sync[postgres]"
|
|
32
|
+
uv add "cache-sync[all]"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
| Extra | Installs | Use when |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| `redis` | `redis` | You need Redis L2 storage or Redis Streams invalidation. |
|
|
38
|
+
| `rabbitmq` | `aio-pika` | You use RabbitMQ as the invalidation bus. |
|
|
39
|
+
| `kafka` | `aiokafka` | You use Kafka as the invalidation bus. |
|
|
40
|
+
| `postgres` | `asyncpg` | You use PostgreSQL `LISTEN`/`NOTIFY` for invalidation. |
|
|
41
|
+
| `pydantic` | `pydantic` | You want Pydantic model serialization helpers. |
|
|
42
|
+
| `all` | all provider dependencies | You want every optional provider available. |
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from cache_sync import CacheOptions, CacheSync
|
|
48
|
+
|
|
49
|
+
cache = CacheSync(
|
|
50
|
+
options=CacheOptions(
|
|
51
|
+
ttl_seconds=60,
|
|
52
|
+
fail_safe_seconds=300,
|
|
53
|
+
hard_timeout_seconds=5,
|
|
54
|
+
jitter_seconds=5,
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
await cache.start()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@cache.cached(lambda user_id: f"user:{user_id}")
|
|
62
|
+
async def get_user(user_id: str) -> dict[str, str]:
|
|
63
|
+
return {"id": user_id, "name": "Peter"}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
user = await get_user("123")
|
|
67
|
+
await get_user.remove_cached("123")
|
|
68
|
+
await cache.stop()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Redis L2 Example
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from redis.asyncio import Redis
|
|
75
|
+
|
|
76
|
+
from cache_sync import CacheOptions, CacheSync, RedisDistributedCache
|
|
77
|
+
|
|
78
|
+
redis = Redis.from_url("redis://localhost:6379/0")
|
|
79
|
+
|
|
80
|
+
cache = CacheSync(
|
|
81
|
+
distributed_cache=RedisDistributedCache(redis),
|
|
82
|
+
options=CacheOptions(ttl_seconds=60, fail_safe_seconds=300),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
await cache.start()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@cache.cached(lambda product_id: f"product:{product_id}")
|
|
89
|
+
async def get_product(product_id: str) -> dict[str, str]:
|
|
90
|
+
return {"id": product_id}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
For a complete walkthrough with shared values and cross-instance invalidation, see the [get started tutorial](https://petercinibulk.github.io/cache-sync/tutorials/get-started/).
|
|
94
|
+
|
|
95
|
+
## Project
|
|
96
|
+
|
|
97
|
+
- License: MIT
|
|
98
|
+
- Source: <https://github.com/petercinibulk/cache-sync>
|
|
99
|
+
- Issues: <https://github.com/petercinibulk/cache-sync/issues>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "cache-sync"
|
|
3
|
+
version = "0.3.1"
|
|
4
|
+
description = "Async hybrid Python cache with in-memory L1, distributed L2 providers, pluggable invalidation, stampede protection, and typed decorators."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Peter Cinibulk" }]
|
|
9
|
+
|
|
10
|
+
# Published dependencies.
|
|
11
|
+
dependencies = []
|
|
12
|
+
keywords = ["async", "cache", "redis", "invalidation", "stampede-protection"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Framework :: AsyncIO",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Changelog = "https://github.com/petercinibulk/cache-sync/blob/main/CHANGELOG.md"
|
|
29
|
+
Documentation = "https://petercinibulk.github.io/cache-sync/"
|
|
30
|
+
Issues = "https://github.com/petercinibulk/cache-sync/issues"
|
|
31
|
+
Repository = "https://github.com/petercinibulk/cache-sync"
|
|
32
|
+
|
|
33
|
+
# Published optional dependencies, or "extras".
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
kafka = ["aiokafka>=0.10.0"]
|
|
36
|
+
postgres = ["asyncpg>=0.29.0"]
|
|
37
|
+
pydantic = ["pydantic>=1.10.0"]
|
|
38
|
+
rabbitmq = ["aio-pika>=9.0.0"]
|
|
39
|
+
redis = ["redis>=5.0.0"]
|
|
40
|
+
all = [
|
|
41
|
+
"redis>=5.0.0",
|
|
42
|
+
"aio-pika>=9.0.0",
|
|
43
|
+
"aiokafka>=0.10.0",
|
|
44
|
+
"asyncpg>=0.29.0",
|
|
45
|
+
"pydantic>=1.10.0",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
# Local dependencies for development.
|
|
49
|
+
[dependency-groups]
|
|
50
|
+
redis = ["redis>=5.0.0"]
|
|
51
|
+
rabbitmq = ["aio-pika>=9.0.0"]
|
|
52
|
+
kafka = ["aiokafka>=0.10.0"]
|
|
53
|
+
postgres = ["asyncpg>=0.29.0"]
|
|
54
|
+
pydantic = ["pydantic>=1.10.0"]
|
|
55
|
+
docs = ["zensical>=0.0.45"]
|
|
56
|
+
all = [
|
|
57
|
+
{ include-group = "redis" },
|
|
58
|
+
{ include-group = "rabbitmq" },
|
|
59
|
+
{ include-group = "kafka" },
|
|
60
|
+
{ include-group = "postgres" },
|
|
61
|
+
{ include-group = "pydantic" },
|
|
62
|
+
]
|
|
63
|
+
dev = [
|
|
64
|
+
{ include-group = "all" },
|
|
65
|
+
{ include-group = "docs" },
|
|
66
|
+
"pytest>=8.0.0",
|
|
67
|
+
"pytest-asyncio>=0.23.0",
|
|
68
|
+
"pytest-cov>=7.1.0",
|
|
69
|
+
"ruff>=0.5.0",
|
|
70
|
+
"ty>=0.0.1a8",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
[build-system]
|
|
74
|
+
requires = ["uv_build>=0.11.21,<0.12"]
|
|
75
|
+
build-backend = "uv_build"
|
|
76
|
+
|
|
77
|
+
[tool.uv]
|
|
78
|
+
package = true
|
|
79
|
+
|
|
80
|
+
[tool.ruff]
|
|
81
|
+
line-length = 100
|
|
82
|
+
target-version = "py312"
|
|
83
|
+
src = ["src", "tests"]
|
|
84
|
+
|
|
85
|
+
[tool.ruff.lint]
|
|
86
|
+
select = ["A", "ASYNC", "B", "C4", "E", "F", "I", "N", "PERF", "RUF", "SIM", "UP", "W"]
|
|
87
|
+
ignore = ["B904", "UP046", "UP047"]
|
|
88
|
+
|
|
89
|
+
[tool.ruff.format]
|
|
90
|
+
quote-style = "double"
|
|
91
|
+
indent-style = "space"
|
|
92
|
+
|
|
93
|
+
[tool.pytest.ini_options]
|
|
94
|
+
addopts = [
|
|
95
|
+
"--cov=cache_sync",
|
|
96
|
+
"--cov-report=term-missing",
|
|
97
|
+
]
|
|
98
|
+
asyncio_mode = "auto"
|
|
99
|
+
testpaths = ["tests"]
|
|
100
|
+
|
|
101
|
+
[tool.ty.environment]
|
|
102
|
+
python-version = "3.12"
|
|
103
|
+
python-platform = "all"
|
|
104
|
+
root = ["./src"]
|
|
105
|
+
|
|
106
|
+
[tool.ty.src]
|
|
107
|
+
include = ["src", "tests"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Public API for cache-sync."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from cache_sync.core import CacheOptions, CacheSync
|
|
6
|
+
from cache_sync.decorators import CachedFunction
|
|
7
|
+
from cache_sync.distributed_cache import DistributedCache
|
|
8
|
+
from cache_sync.invalidation import (
|
|
9
|
+
InvalidationBus,
|
|
10
|
+
InvalidationHandler,
|
|
11
|
+
InvalidationMessage,
|
|
12
|
+
InvalidationTransport,
|
|
13
|
+
TransportInvalidationBus,
|
|
14
|
+
)
|
|
15
|
+
from cache_sync.serializers import (
|
|
16
|
+
JsonSerializer,
|
|
17
|
+
PickleSerializer,
|
|
18
|
+
PydanticSerializer,
|
|
19
|
+
Serializer,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from cache_sync.providers.kafka import KafkaInvalidationBus
|
|
24
|
+
from cache_sync.providers.postgres import PostgresNotifyInvalidationBus
|
|
25
|
+
from cache_sync.providers.rabbitmq import RabbitMQInvalidationBus
|
|
26
|
+
from cache_sync.providers.redis import (
|
|
27
|
+
RedisDistributedCache,
|
|
28
|
+
RedisStreamsInvalidationBus,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"CacheOptions",
|
|
33
|
+
"CacheSync",
|
|
34
|
+
"CachedFunction",
|
|
35
|
+
"DistributedCache",
|
|
36
|
+
"InvalidationBus",
|
|
37
|
+
"InvalidationHandler",
|
|
38
|
+
"InvalidationMessage",
|
|
39
|
+
"InvalidationTransport",
|
|
40
|
+
"JsonSerializer",
|
|
41
|
+
"KafkaInvalidationBus",
|
|
42
|
+
"PickleSerializer",
|
|
43
|
+
"PostgresNotifyInvalidationBus",
|
|
44
|
+
"PydanticSerializer",
|
|
45
|
+
"RabbitMQInvalidationBus",
|
|
46
|
+
"RedisDistributedCache",
|
|
47
|
+
"RedisStreamsInvalidationBus",
|
|
48
|
+
"Serializer",
|
|
49
|
+
"TransportInvalidationBus",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def __getattr__(name: str) -> Any:
|
|
54
|
+
if name == "RedisDistributedCache":
|
|
55
|
+
from cache_sync.providers.redis import RedisDistributedCache
|
|
56
|
+
|
|
57
|
+
return RedisDistributedCache
|
|
58
|
+
|
|
59
|
+
if name == "RedisStreamsInvalidationBus":
|
|
60
|
+
from cache_sync.providers.redis import RedisStreamsInvalidationBus
|
|
61
|
+
|
|
62
|
+
return RedisStreamsInvalidationBus
|
|
63
|
+
|
|
64
|
+
if name == "RabbitMQInvalidationBus":
|
|
65
|
+
from cache_sync.providers.rabbitmq import RabbitMQInvalidationBus
|
|
66
|
+
|
|
67
|
+
return RabbitMQInvalidationBus
|
|
68
|
+
|
|
69
|
+
if name == "KafkaInvalidationBus":
|
|
70
|
+
from cache_sync.providers.kafka import KafkaInvalidationBus
|
|
71
|
+
|
|
72
|
+
return KafkaInvalidationBus
|
|
73
|
+
|
|
74
|
+
if name == "PostgresNotifyInvalidationBus":
|
|
75
|
+
from cache_sync.providers.postgres import PostgresNotifyInvalidationBus
|
|
76
|
+
|
|
77
|
+
return PostgresNotifyInvalidationBus
|
|
78
|
+
|
|
79
|
+
msg = f"module {__name__!r} has no attribute {name!r}"
|
|
80
|
+
raise AttributeError(msg)
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import random
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import TYPE_CHECKING, ParamSpec, TypeVar, cast
|
|
9
|
+
|
|
10
|
+
from cache_sync.distributed_cache import DistributedCache
|
|
11
|
+
from cache_sync.invalidation import InvalidationBus
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
P = ParamSpec("P")
|
|
15
|
+
_CACHE_OPTION_DEFAULTS = {
|
|
16
|
+
"ttl_seconds": 60.0,
|
|
17
|
+
"fail_safe_seconds": 300.0,
|
|
18
|
+
"hard_timeout_seconds": 5.0,
|
|
19
|
+
"jitter_seconds": 0.0,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _Unset:
|
|
24
|
+
__slots__ = ()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_UNSET = _Unset()
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from cache_sync.decorators import CachedFunction
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True, init=False)
|
|
34
|
+
class CacheOptions:
|
|
35
|
+
"""Runtime policy for cache freshness, factory timeouts, and TTL jitter."""
|
|
36
|
+
|
|
37
|
+
ttl_seconds: float = 60
|
|
38
|
+
fail_safe_seconds: float = 300
|
|
39
|
+
hard_timeout_seconds: float = 5
|
|
40
|
+
jitter_seconds: float = 0
|
|
41
|
+
_supplied: frozenset[str] = field(
|
|
42
|
+
default_factory=frozenset,
|
|
43
|
+
repr=False,
|
|
44
|
+
compare=False,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
ttl_seconds: float | _Unset = _UNSET,
|
|
50
|
+
fail_safe_seconds: float | _Unset = _UNSET,
|
|
51
|
+
hard_timeout_seconds: float | _Unset = _UNSET,
|
|
52
|
+
jitter_seconds: float | _Unset = _UNSET,
|
|
53
|
+
) -> None:
|
|
54
|
+
values = {
|
|
55
|
+
"ttl_seconds": ttl_seconds,
|
|
56
|
+
"fail_safe_seconds": fail_safe_seconds,
|
|
57
|
+
"hard_timeout_seconds": hard_timeout_seconds,
|
|
58
|
+
"jitter_seconds": jitter_seconds,
|
|
59
|
+
}
|
|
60
|
+
supplied = frozenset(name for name, value in values.items() if value is not _UNSET)
|
|
61
|
+
|
|
62
|
+
for name, default in _CACHE_OPTION_DEFAULTS.items():
|
|
63
|
+
value = values[name]
|
|
64
|
+
object.__setattr__(self, name, default if value is _UNSET else value)
|
|
65
|
+
|
|
66
|
+
object.__setattr__(self, "_supplied", supplied)
|
|
67
|
+
|
|
68
|
+
def merge_over(self, defaults: CacheOptions) -> CacheOptions:
|
|
69
|
+
"""Return this option object's supplied fields over cache defaults."""
|
|
70
|
+
|
|
71
|
+
values = {
|
|
72
|
+
name: getattr(self if name in self._supplied else defaults, name)
|
|
73
|
+
for name in _CACHE_OPTION_DEFAULTS
|
|
74
|
+
}
|
|
75
|
+
return CacheOptions(**values)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(slots=True)
|
|
79
|
+
class CacheEntry:
|
|
80
|
+
"""In-memory cache entry with freshness and fail-safe deadlines."""
|
|
81
|
+
|
|
82
|
+
value: object
|
|
83
|
+
expires_at: float
|
|
84
|
+
fail_safe_until: float
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def is_fresh(self) -> bool:
|
|
88
|
+
return time.monotonic() < self.expires_at
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def is_fail_safe_available(self) -> bool:
|
|
92
|
+
return time.monotonic() < self.fail_safe_until
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class CacheSync:
|
|
96
|
+
"""Async two-level cache with optional distributed storage and invalidation."""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
distributed_cache: DistributedCache | None = None,
|
|
102
|
+
invalidation_bus: InvalidationBus | None = None,
|
|
103
|
+
options: CacheOptions | None = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Create a cache using optional L2 storage and invalidation providers."""
|
|
106
|
+
|
|
107
|
+
self._memory: dict[str, CacheEntry] = {}
|
|
108
|
+
self._locks: dict[str, asyncio.Lock] = {}
|
|
109
|
+
self._distributed_cache = distributed_cache
|
|
110
|
+
self._invalidation_bus = invalidation_bus
|
|
111
|
+
self._options = options or CacheOptions()
|
|
112
|
+
|
|
113
|
+
async def start(self) -> None:
|
|
114
|
+
"""Start the configured invalidation bus, if any."""
|
|
115
|
+
|
|
116
|
+
if self._invalidation_bus is None:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
await self._invalidation_bus.start(
|
|
120
|
+
remove_local=self.remove_local,
|
|
121
|
+
clear_local=self.clear_memory,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
async def stop(self) -> None:
|
|
125
|
+
"""Stop the configured invalidation bus, if any."""
|
|
126
|
+
|
|
127
|
+
if self._invalidation_bus is not None:
|
|
128
|
+
await self._invalidation_bus.stop()
|
|
129
|
+
|
|
130
|
+
def cached(
|
|
131
|
+
self,
|
|
132
|
+
key: str | Callable[..., str] | None = None,
|
|
133
|
+
*,
|
|
134
|
+
options: CacheOptions | None = None,
|
|
135
|
+
) -> Callable[[Callable[P, Awaitable[T]]], CachedFunction[P, T]]:
|
|
136
|
+
"""Decorate an async function using this cache instance."""
|
|
137
|
+
|
|
138
|
+
from cache_sync.decorators import CachedFunction
|
|
139
|
+
|
|
140
|
+
def decorator(func: Callable[P, Awaitable[T]]) -> CachedFunction[P, T]:
|
|
141
|
+
return CachedFunction(self, func, key, options)
|
|
142
|
+
|
|
143
|
+
return decorator
|
|
144
|
+
|
|
145
|
+
async def get_or_set(
|
|
146
|
+
self,
|
|
147
|
+
key: str,
|
|
148
|
+
factory: Callable[[], Awaitable[T]],
|
|
149
|
+
*,
|
|
150
|
+
options: CacheOptions | None = None,
|
|
151
|
+
) -> T:
|
|
152
|
+
"""Return a cached value or compute, store, and return a new value."""
|
|
153
|
+
|
|
154
|
+
opts = self._effective_options(options)
|
|
155
|
+
entry = self._memory.get(key)
|
|
156
|
+
|
|
157
|
+
if entry and entry.is_fresh:
|
|
158
|
+
return cast(T, entry.value)
|
|
159
|
+
|
|
160
|
+
lock = self._locks.setdefault(key, asyncio.Lock())
|
|
161
|
+
|
|
162
|
+
async with lock:
|
|
163
|
+
entry = self._memory.get(key)
|
|
164
|
+
if entry and entry.is_fresh:
|
|
165
|
+
return cast(T, entry.value)
|
|
166
|
+
|
|
167
|
+
if self._distributed_cache is not None:
|
|
168
|
+
cached_value = await self._distributed_cache.get(key)
|
|
169
|
+
if cached_value is not None:
|
|
170
|
+
self._set_memory(key, cached_value, opts)
|
|
171
|
+
return cast(T, cached_value)
|
|
172
|
+
|
|
173
|
+
stale = entry if entry and entry.is_fail_safe_available else None
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
value = await asyncio.wait_for(
|
|
177
|
+
factory(),
|
|
178
|
+
timeout=opts.hard_timeout_seconds,
|
|
179
|
+
)
|
|
180
|
+
await self.set(key, value, options=opts, publish_invalidation=False)
|
|
181
|
+
return value
|
|
182
|
+
except Exception:
|
|
183
|
+
if stale is not None:
|
|
184
|
+
return cast(T, stale.value)
|
|
185
|
+
raise
|
|
186
|
+
|
|
187
|
+
async def set(
|
|
188
|
+
self,
|
|
189
|
+
key: str,
|
|
190
|
+
value: object,
|
|
191
|
+
*,
|
|
192
|
+
options: CacheOptions | None = None,
|
|
193
|
+
publish_invalidation: bool = True,
|
|
194
|
+
) -> None:
|
|
195
|
+
"""Store a value in local memory and optional distributed storage."""
|
|
196
|
+
|
|
197
|
+
opts = self._effective_options(options)
|
|
198
|
+
self._set_memory(key, value, opts)
|
|
199
|
+
|
|
200
|
+
if self._distributed_cache is not None:
|
|
201
|
+
await self._distributed_cache.set(
|
|
202
|
+
key,
|
|
203
|
+
value,
|
|
204
|
+
ttl_seconds=self._ttl_with_jitter(opts),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if publish_invalidation and self._invalidation_bus is not None:
|
|
208
|
+
await self._invalidation_bus.invalidate(key)
|
|
209
|
+
|
|
210
|
+
async def remove(self, key: str) -> None:
|
|
211
|
+
"""Remove a key locally, from distributed storage, and from peer nodes."""
|
|
212
|
+
|
|
213
|
+
self.remove_local(key)
|
|
214
|
+
|
|
215
|
+
if self._distributed_cache is not None:
|
|
216
|
+
await self._distributed_cache.delete(key)
|
|
217
|
+
|
|
218
|
+
if self._invalidation_bus is not None:
|
|
219
|
+
await self._invalidation_bus.invalidate(key)
|
|
220
|
+
|
|
221
|
+
async def clear(self) -> None:
|
|
222
|
+
"""Clear all local entries and publish a clear message to peer nodes."""
|
|
223
|
+
|
|
224
|
+
self.clear_memory()
|
|
225
|
+
|
|
226
|
+
if self._invalidation_bus is not None:
|
|
227
|
+
await self._invalidation_bus.clear()
|
|
228
|
+
|
|
229
|
+
def remove_local(self, key: str) -> None:
|
|
230
|
+
"""Remove a key from only this process's in-memory cache."""
|
|
231
|
+
|
|
232
|
+
self._memory.pop(key, None)
|
|
233
|
+
|
|
234
|
+
def clear_memory(self) -> None:
|
|
235
|
+
"""Clear only this process's in-memory cache."""
|
|
236
|
+
|
|
237
|
+
self._memory.clear()
|
|
238
|
+
|
|
239
|
+
def _effective_options(self, options: CacheOptions | None) -> CacheOptions:
|
|
240
|
+
if options is None:
|
|
241
|
+
return self._options
|
|
242
|
+
return options.merge_over(self._options)
|
|
243
|
+
|
|
244
|
+
def _set_memory(self, key: str, value: object, opts: CacheOptions) -> None:
|
|
245
|
+
ttl = self._ttl_with_jitter(opts)
|
|
246
|
+
now = time.monotonic()
|
|
247
|
+
self._memory[key] = CacheEntry(
|
|
248
|
+
value=value,
|
|
249
|
+
expires_at=now + ttl,
|
|
250
|
+
fail_safe_until=now + ttl + opts.fail_safe_seconds,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def _ttl_with_jitter(self, opts: CacheOptions) -> float:
|
|
254
|
+
if opts.jitter_seconds <= 0:
|
|
255
|
+
return opts.ttl_seconds
|
|
256
|
+
return opts.ttl_seconds + random.uniform(0, opts.jitter_seconds)
|