fastapi-alcazaba 0.1.0__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.
- fastapi_alcazaba/__init__.py +125 -0
- fastapi_alcazaba/backends/__init__.py +42 -0
- fastapi_alcazaba/backends/_redis_common.py +138 -0
- fastapi_alcazaba/backends/dynamodb.py +92 -0
- fastapi_alcazaba/backends/glide.py +117 -0
- fastapi_alcazaba/backends/inmemory.py +95 -0
- fastapi_alcazaba/backends/memcached.py +49 -0
- fastapi_alcazaba/backends/redis.py +15 -0
- fastapi_alcazaba/backends/tiered.py +233 -0
- fastapi_alcazaba/backends/valkey.py +17 -0
- fastapi_alcazaba/cache.py +300 -0
- fastapi_alcazaba/coder.py +191 -0
- fastapi_alcazaba/compression.py +91 -0
- fastapi_alcazaba/decorator.py +771 -0
- fastapi_alcazaba/encryption.py +88 -0
- fastapi_alcazaba/errors.py +19 -0
- fastapi_alcazaba/integrity.py +76 -0
- fastapi_alcazaba/key_builder.py +27 -0
- fastapi_alcazaba/metrics.py +66 -0
- fastapi_alcazaba/py.typed +0 -0
- fastapi_alcazaba/resilience.py +108 -0
- fastapi_alcazaba/tracing.py +51 -0
- fastapi_alcazaba/types.py +65 -0
- fastapi_alcazaba-0.1.0.dist-info/METADATA +552 -0
- fastapi_alcazaba-0.1.0.dist-info/RECORD +28 -0
- fastapi_alcazaba-0.1.0.dist-info/WHEEL +4 -0
- fastapi_alcazaba-0.1.0.dist-info/licenses/LICENSE +201 -0
- fastapi_alcazaba-0.1.0.dist-info/licenses/NOTICE +8 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
from typing import Any, ClassVar
|
|
3
|
+
|
|
4
|
+
from fastapi_alcazaba.cache import (
|
|
5
|
+
DEFAULT_VARY_HEADERS,
|
|
6
|
+
Cache,
|
|
7
|
+
CacheOptions,
|
|
8
|
+
get_cache,
|
|
9
|
+
normalise_headers,
|
|
10
|
+
warn_unsigned,
|
|
11
|
+
)
|
|
12
|
+
from fastapi_alcazaba.coder import Coder, JsonCoder
|
|
13
|
+
from fastapi_alcazaba.errors import (
|
|
14
|
+
BackendUnavailable,
|
|
15
|
+
UnsignedEntriesWarning,
|
|
16
|
+
UnstableCacheKeyWarning,
|
|
17
|
+
)
|
|
18
|
+
from fastapi_alcazaba.key_builder import default_key_builder
|
|
19
|
+
from fastapi_alcazaba.metrics import Metrics
|
|
20
|
+
from fastapi_alcazaba.resilience import BackendGuard
|
|
21
|
+
from fastapi_alcazaba.types import Backend, KeyBuilder
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
__version__ = version("fastapi-alcazaba")
|
|
25
|
+
except PackageNotFoundError: # running from a source checkout that isn't installed
|
|
26
|
+
__version__ = "0.0.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"DEFAULT_VARY_HEADERS",
|
|
30
|
+
"Backend",
|
|
31
|
+
"BackendUnavailable",
|
|
32
|
+
"Cache",
|
|
33
|
+
"CacheOptions",
|
|
34
|
+
"Coder",
|
|
35
|
+
"FastAPICache",
|
|
36
|
+
"JsonCoder",
|
|
37
|
+
"KeyBuilder",
|
|
38
|
+
"UnsignedEntriesWarning",
|
|
39
|
+
"UnstableCacheKeyWarning",
|
|
40
|
+
"default_key_builder",
|
|
41
|
+
"get_cache",
|
|
42
|
+
"normalise_headers",
|
|
43
|
+
"warn_unsigned",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FastAPICache:
|
|
48
|
+
"""The global cache that `@cache` uses: a Cache configured once with `init()`. The API of
|
|
49
|
+
fastapi-cache2; for several caches, or one per app, create Cache instances instead."""
|
|
50
|
+
|
|
51
|
+
_instance: ClassVar[Cache | None] = None
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def init(cls, backend: Backend, *args: Any, **kwargs: Any) -> None:
|
|
55
|
+
"""Configure the global cache, with the arguments of `Cache`; later calls are ignored
|
|
56
|
+
until `reset()`."""
|
|
57
|
+
if cls._instance is None:
|
|
58
|
+
cls._instance = Cache(backend, *args, **kwargs)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def reset(cls) -> None:
|
|
62
|
+
cls._instance = None
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def instance(cls) -> Cache:
|
|
66
|
+
if cls._instance is None:
|
|
67
|
+
raise RuntimeError(
|
|
68
|
+
"FastAPICache.init() hasn't been called (or decorate with a Cache's cached())"
|
|
69
|
+
)
|
|
70
|
+
return cls._instance
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def get_backend(cls) -> Backend:
|
|
74
|
+
return cls.instance().backend
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def get_prefix(cls) -> str:
|
|
78
|
+
return cls.instance().prefix
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def get_expire(cls) -> int | None:
|
|
82
|
+
return cls._instance.expire if cls._instance is not None else None
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def get_coder(cls) -> type[Coder]:
|
|
86
|
+
return cls.instance().coder
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def get_key_builder(cls) -> KeyBuilder:
|
|
90
|
+
return cls.instance().key_builder
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def get_cache_status_header(cls) -> str:
|
|
94
|
+
return cls.instance().cache_status_header
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def get_enable(cls) -> bool:
|
|
98
|
+
return cls._instance.enable if cls._instance is not None else True
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def get_options(cls) -> CacheOptions:
|
|
102
|
+
return cls._instance.options if cls._instance is not None else CacheOptions()
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def get_guard(cls) -> BackendGuard:
|
|
106
|
+
return cls.instance().guard
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def get_metrics(cls) -> Metrics:
|
|
110
|
+
return cls._instance.metrics if cls._instance is not None else Metrics(enabled=False)
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def tag_key(cls, tag: str) -> str:
|
|
114
|
+
return cls.instance().tag_key(tag)
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
async def invalidate_tags(cls, *tags: str) -> int:
|
|
118
|
+
"""Delete every entry stored with any of `tags` (see `@cache(tags=...)`)."""
|
|
119
|
+
return await cls.instance().invalidate_tags(*tags)
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
async def clear(cls, namespace: str | None = None, key: str | None = None) -> int:
|
|
123
|
+
"""Delete the entry `key` (a full cache key), or every entry of `namespace`, or of the
|
|
124
|
+
whole prefix when neither is given."""
|
|
125
|
+
return await cls.instance().clear(namespace, key)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from fastapi_alcazaba.backends import inmemory, tiered
|
|
2
|
+
from fastapi_alcazaba.types import Backend
|
|
3
|
+
|
|
4
|
+
__all__ = ["Backend", "inmemory", "tiered"]
|
|
5
|
+
|
|
6
|
+
# import each backend in turn and add to __all__. This syntax
|
|
7
|
+
# is explicitly supported by type checkers, while more dynamic
|
|
8
|
+
# syntax would not be recognised.
|
|
9
|
+
try:
|
|
10
|
+
from fastapi_alcazaba.backends import dynamodb
|
|
11
|
+
except ImportError:
|
|
12
|
+
pass
|
|
13
|
+
else:
|
|
14
|
+
__all__ += ["dynamodb"]
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from fastapi_alcazaba.backends import memcached
|
|
18
|
+
except ImportError:
|
|
19
|
+
pass
|
|
20
|
+
else:
|
|
21
|
+
__all__ += ["memcached"]
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from fastapi_alcazaba.backends import redis
|
|
25
|
+
except ImportError:
|
|
26
|
+
pass
|
|
27
|
+
else:
|
|
28
|
+
__all__ += ["redis"]
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from fastapi_alcazaba.backends import valkey
|
|
32
|
+
except ImportError:
|
|
33
|
+
pass
|
|
34
|
+
else:
|
|
35
|
+
__all__ += ["valkey"]
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
from fastapi_alcazaba.backends import glide
|
|
39
|
+
except ImportError:
|
|
40
|
+
pass
|
|
41
|
+
else:
|
|
42
|
+
__all__ += ["glide"]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Shared implementation of the Redis and Valkey backends: their clients have the same API."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import secrets
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi_alcazaba.types import Backend
|
|
9
|
+
|
|
10
|
+
_GLOB_SPECIAL = re.compile(r"([\\*?\[\]])")
|
|
11
|
+
|
|
12
|
+
# Keys and values always travel as KEYS/ARGV, never inside the script source.
|
|
13
|
+
RELEASE_LOCK_SCRIPT = """
|
|
14
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
15
|
+
return redis.call('DEL', KEYS[1])
|
|
16
|
+
end
|
|
17
|
+
return 0
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
# KEYS[1]: tag set; ARGV[1]: entry key; ARGV[2]: its expiry in seconds, 0 for none. The set lives as
|
|
21
|
+
# long as its longest-lived entry: it stays persistent once an entry without expiry joins.
|
|
22
|
+
ADD_TAG_SCRIPT = """
|
|
23
|
+
local existed = redis.call('EXISTS', KEYS[1]) == 1
|
|
24
|
+
redis.call('SADD', KEYS[1], ARGV[1])
|
|
25
|
+
local expire = tonumber(ARGV[2])
|
|
26
|
+
if expire == 0 then
|
|
27
|
+
redis.call('PERSIST', KEYS[1])
|
|
28
|
+
elseif not existed then
|
|
29
|
+
redis.call('EXPIRE', KEYS[1], expire)
|
|
30
|
+
else
|
|
31
|
+
local current = redis.call('TTL', KEYS[1])
|
|
32
|
+
if current ~= -1 and current < expire then
|
|
33
|
+
redis.call('EXPIRE', KEYS[1], expire)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
return 1
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def escape_glob(text: str) -> str:
|
|
41
|
+
"""Escape the metacharacters of a SCAN MATCH pattern, so that a namespace like "a*" or "a[b]"
|
|
42
|
+
only matches itself."""
|
|
43
|
+
return _GLOB_SPECIAL.sub(r"\\\1", text)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def pending_tag_key(tag: str) -> str:
|
|
47
|
+
"""Where a tag set is moved while it's invalidated. Same hash slot as the tag in a cluster:
|
|
48
|
+
the tag keys carry a {hash tag}."""
|
|
49
|
+
return f"{tag}:invalidating:{secrets.token_hex(8)}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_missing_key_error(exc: Exception) -> bool:
|
|
53
|
+
return "no such key" in str(exc).lower()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RedisLikeBackend(Backend):
|
|
57
|
+
"""Backend for redis-py style clients (standalone or cluster).
|
|
58
|
+
|
|
59
|
+
`clear(namespace=...)` walks the namespace with SCAN and deletes in batches with UNLINK.
|
|
60
|
+
fastapi-cache2 ran a Lua script with KEYS, which blocks the server while it walks every key,
|
|
61
|
+
and interpolated the namespace into the script source: a namespace coming from user input
|
|
62
|
+
could run arbitrary commands (e.g. "x') redis.call('FLUSHALL') --").
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
supports_locks = True
|
|
66
|
+
supports_tags = True
|
|
67
|
+
|
|
68
|
+
def __init__(self, client: Any, *, is_cluster: bool, scan_count: int = 500) -> None:
|
|
69
|
+
if scan_count < 1:
|
|
70
|
+
raise ValueError("scan_count must be at least 1")
|
|
71
|
+
self._client = client
|
|
72
|
+
self.is_cluster = is_cluster
|
|
73
|
+
self.scan_count = scan_count
|
|
74
|
+
|
|
75
|
+
async def get_with_ttl(self, key: str) -> tuple[int, bytes | None]:
|
|
76
|
+
async with self._client.pipeline(transaction=not self.is_cluster) as pipe:
|
|
77
|
+
ttl, value = await pipe.ttl(key).get(key).execute()
|
|
78
|
+
if value is None:
|
|
79
|
+
return 0, None
|
|
80
|
+
return int(ttl), value
|
|
81
|
+
|
|
82
|
+
async def get(self, key: str) -> bytes | None:
|
|
83
|
+
value: bytes | None = await self._client.get(key)
|
|
84
|
+
return value
|
|
85
|
+
|
|
86
|
+
async def set(self, key: str, value: bytes, expire: int | None = None) -> None:
|
|
87
|
+
await self._client.set(key, value, ex=expire or None)
|
|
88
|
+
|
|
89
|
+
async def clear(self, namespace: str | None = None, key: str | None = None) -> int:
|
|
90
|
+
if namespace:
|
|
91
|
+
return await self._delete_matching(f"{escape_glob(namespace)}:*")
|
|
92
|
+
if key:
|
|
93
|
+
return int(await self._client.delete(key))
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
async def acquire_lock(self, key: str, token: str, ttl: float) -> bool:
|
|
97
|
+
return bool(await self._client.set(key, token, nx=True, px=max(1, int(ttl * 1000))))
|
|
98
|
+
|
|
99
|
+
async def release_lock(self, key: str, token: str) -> None:
|
|
100
|
+
await self._client.eval(RELEASE_LOCK_SCRIPT, 1, key, token)
|
|
101
|
+
|
|
102
|
+
async def add_tags(self, key: str, tags: Sequence[str], expire: int | None) -> None:
|
|
103
|
+
for tag in tags:
|
|
104
|
+
await self._client.eval(ADD_TAG_SCRIPT, 1, tag, key, str(expire or 0))
|
|
105
|
+
|
|
106
|
+
async def invalidate_tags(self, tags: Sequence[str]) -> int:
|
|
107
|
+
deleted = 0
|
|
108
|
+
for tag in tags:
|
|
109
|
+
# Moving the set first means that entries tagged while this runs land in a new set and
|
|
110
|
+
# aren't lost; they belong to the next invalidation.
|
|
111
|
+
pending = pending_tag_key(tag)
|
|
112
|
+
try:
|
|
113
|
+
await self._client.rename(tag, pending)
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
if not is_missing_key_error(exc):
|
|
116
|
+
raise
|
|
117
|
+
continue
|
|
118
|
+
members = list(await self._client.smembers(pending))
|
|
119
|
+
if members:
|
|
120
|
+
deleted += await self._unlink(members)
|
|
121
|
+
await self._client.unlink(pending)
|
|
122
|
+
return deleted
|
|
123
|
+
|
|
124
|
+
async def _delete_matching(self, pattern: str) -> int:
|
|
125
|
+
deleted = 0
|
|
126
|
+
batch: list[bytes] = []
|
|
127
|
+
async for found in self._client.scan_iter(match=pattern, count=self.scan_count):
|
|
128
|
+
batch.append(found)
|
|
129
|
+
if len(batch) >= self.scan_count:
|
|
130
|
+
deleted += await self._unlink(batch)
|
|
131
|
+
batch = []
|
|
132
|
+
if batch:
|
|
133
|
+
deleted += await self._unlink(batch)
|
|
134
|
+
return deleted
|
|
135
|
+
|
|
136
|
+
async def _unlink(self, keys: list[bytes]) -> int:
|
|
137
|
+
# The cluster clients of redis-py and valkey-py split a multi-key UNLINK by hash slot.
|
|
138
|
+
return int(await self._client.unlink(*keys))
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from aiobotocore.client import AioBaseClient
|
|
5
|
+
from aiobotocore.session import AioSession, get_session
|
|
6
|
+
|
|
7
|
+
from fastapi_alcazaba.types import Backend
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from types_aiobotocore_dynamodb import DynamoDBClient
|
|
11
|
+
else:
|
|
12
|
+
DynamoDBClient = AioBaseClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DynamoBackend(Backend):
|
|
16
|
+
"""
|
|
17
|
+
Amazon DynamoDB backend provider
|
|
18
|
+
|
|
19
|
+
This backend requires an existing table within your AWS environment to be passed during
|
|
20
|
+
backend init. If ttl is going to be used, this needs to be manually enabled on the table
|
|
21
|
+
using the `ttl` key. Dynamo will take care of deleting outdated objects, but this is not
|
|
22
|
+
instant so don't be alarmed when they linger around for a bit.
|
|
23
|
+
|
|
24
|
+
As with all AWS clients, credentials will be taken from the environment. Check the AWS SDK
|
|
25
|
+
for more information.
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
>> dynamodb = DynamoBackend(table_name="your-cache", region="eu-west-1")
|
|
29
|
+
>> await dynamodb.init()
|
|
30
|
+
>> FastAPICache.init(dynamodb)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self, table_name: str, region: str | None = None, *, endpoint_url: str | None = None
|
|
35
|
+
) -> None:
|
|
36
|
+
"""`endpoint_url` points to DynamoDB Local, LocalStack or a VPC endpoint."""
|
|
37
|
+
self.session: AioSession = get_session()
|
|
38
|
+
self.table_name = table_name
|
|
39
|
+
self.region = region
|
|
40
|
+
self.endpoint_url = endpoint_url
|
|
41
|
+
self.client: DynamoDBClient | None = None
|
|
42
|
+
|
|
43
|
+
async def init(self) -> None:
|
|
44
|
+
self.client = await self.session.create_client(
|
|
45
|
+
"dynamodb", region_name=self.region, endpoint_url=self.endpoint_url
|
|
46
|
+
).__aenter__()
|
|
47
|
+
|
|
48
|
+
async def close(self) -> None:
|
|
49
|
+
if self.client is not None:
|
|
50
|
+
await self.client.__aexit__(None, None, None)
|
|
51
|
+
self.client = None
|
|
52
|
+
|
|
53
|
+
def _get_client(self) -> DynamoDBClient:
|
|
54
|
+
if self.client is None:
|
|
55
|
+
raise RuntimeError("DynamoBackend.init() must be awaited first")
|
|
56
|
+
return self.client
|
|
57
|
+
|
|
58
|
+
async def get_with_ttl(self, key: str) -> tuple[int, bytes | None]:
|
|
59
|
+
response = await self._get_client().get_item(
|
|
60
|
+
TableName=self.table_name, Key={"key": {"S": key}}
|
|
61
|
+
)
|
|
62
|
+
item = response.get("Item")
|
|
63
|
+
if item is None:
|
|
64
|
+
return 0, None
|
|
65
|
+
value = item.get("value", {}).get("B")
|
|
66
|
+
ttl = item.get("ttl", {}).get("N")
|
|
67
|
+
if value is None:
|
|
68
|
+
return 0, None
|
|
69
|
+
if not ttl:
|
|
70
|
+
return -1, value
|
|
71
|
+
# DynamoDB deletes expired items eventually, not right away: check it ourselves.
|
|
72
|
+
remaining = int(ttl) - int(time.time())
|
|
73
|
+
return (remaining, value) if remaining > 0 else (0, None)
|
|
74
|
+
|
|
75
|
+
async def get(self, key: str) -> bytes | None:
|
|
76
|
+
return (await self.get_with_ttl(key))[1]
|
|
77
|
+
|
|
78
|
+
async def set(self, key: str, value: bytes, expire: int | None = None) -> None:
|
|
79
|
+
item: dict[str, dict[str, str | bytes]] = {"key": {"S": key}, "value": {"B": value}}
|
|
80
|
+
if expire:
|
|
81
|
+
item["ttl"] = {"N": str(int(time.time()) + expire)}
|
|
82
|
+
await self._get_client().put_item(TableName=self.table_name, Item=item)
|
|
83
|
+
|
|
84
|
+
async def clear(self, namespace: str | None = None, key: str | None = None) -> int:
|
|
85
|
+
if namespace:
|
|
86
|
+
raise NotImplementedError("DynamoDB can't delete a namespace without a table scan")
|
|
87
|
+
if key:
|
|
88
|
+
response = await self._get_client().delete_item(
|
|
89
|
+
TableName=self.table_name, Key={"key": {"S": key}}, ReturnValues="ALL_OLD"
|
|
90
|
+
)
|
|
91
|
+
return 1 if response.get("Attributes") else 0
|
|
92
|
+
return 0
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
|
|
3
|
+
from glide import (
|
|
4
|
+
Batch,
|
|
5
|
+
ClusterBatch,
|
|
6
|
+
ClusterScanCursor,
|
|
7
|
+
ConditionalChange,
|
|
8
|
+
ExpirySet,
|
|
9
|
+
ExpiryType,
|
|
10
|
+
GlideClient,
|
|
11
|
+
GlideClusterClient,
|
|
12
|
+
Script,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from fastapi_alcazaba.backends._redis_common import (
|
|
16
|
+
ADD_TAG_SCRIPT,
|
|
17
|
+
RELEASE_LOCK_SCRIPT,
|
|
18
|
+
escape_glob,
|
|
19
|
+
is_missing_key_error,
|
|
20
|
+
pending_tag_key,
|
|
21
|
+
)
|
|
22
|
+
from fastapi_alcazaba.types import Backend
|
|
23
|
+
|
|
24
|
+
_RELEASE_LOCK = Script(RELEASE_LOCK_SCRIPT) # type: ignore[no-untyped-call]
|
|
25
|
+
_ADD_TAG = Script(ADD_TAG_SCRIPT) # type: ignore[no-untyped-call]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GlideBackend(Backend):
|
|
29
|
+
"""Valkey (or Redis) through Valkey GLIDE, the official client with a Rust core:
|
|
30
|
+
`GlideClient` or `GlideClusterClient`, created with `await GlideClient.create(config)`.
|
|
31
|
+
|
|
32
|
+
Same behaviour as RedisBackend: SCAN + UNLINK to clear a namespace, distributed locks and tags.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
supports_locks = True
|
|
36
|
+
supports_tags = True
|
|
37
|
+
|
|
38
|
+
def __init__(self, client: GlideClient | GlideClusterClient, *, scan_count: int = 500) -> None:
|
|
39
|
+
if scan_count < 1:
|
|
40
|
+
raise ValueError("scan_count must be at least 1")
|
|
41
|
+
self.client = client
|
|
42
|
+
self.scan_count = scan_count
|
|
43
|
+
self._is_cluster = isinstance(client, GlideClusterClient)
|
|
44
|
+
|
|
45
|
+
async def get_with_ttl(self, key: str) -> tuple[int, bytes | None]:
|
|
46
|
+
# TTL and GET in one round trip (a non-atomic batch, i.e. a pipeline).
|
|
47
|
+
batch = ClusterBatch(is_atomic=False) if self._is_cluster else Batch(is_atomic=False)
|
|
48
|
+
batch.ttl(key)
|
|
49
|
+
batch.get(key)
|
|
50
|
+
replies = await self.client.exec(batch, raise_on_error=True)
|
|
51
|
+
ttl, value = replies if replies is not None else (0, None)
|
|
52
|
+
if value is None:
|
|
53
|
+
return 0, None
|
|
54
|
+
return int(ttl), value
|
|
55
|
+
|
|
56
|
+
async def get(self, key: str) -> bytes | None:
|
|
57
|
+
return await self.client.get(key)
|
|
58
|
+
|
|
59
|
+
async def set(self, key: str, value: bytes, expire: int | None = None) -> None:
|
|
60
|
+
expiry = ExpirySet(ExpiryType.SEC, expire) if expire else None
|
|
61
|
+
await self.client.set(key, value, expiry=expiry)
|
|
62
|
+
|
|
63
|
+
async def clear(self, namespace: str | None = None, key: str | None = None) -> int:
|
|
64
|
+
if namespace:
|
|
65
|
+
return await self._delete_matching(f"{escape_glob(namespace)}:*")
|
|
66
|
+
if key:
|
|
67
|
+
return await self.client.delete([key])
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
async def acquire_lock(self, key: str, token: str, ttl: float) -> bool:
|
|
71
|
+
result = await self.client.set(
|
|
72
|
+
key,
|
|
73
|
+
token,
|
|
74
|
+
conditional_set=ConditionalChange.ONLY_IF_DOES_NOT_EXIST,
|
|
75
|
+
expiry=ExpirySet(ExpiryType.MILLSEC, max(1, int(ttl * 1000))),
|
|
76
|
+
)
|
|
77
|
+
return result is not None
|
|
78
|
+
|
|
79
|
+
async def release_lock(self, key: str, token: str) -> None:
|
|
80
|
+
await self.client.invoke_script(_RELEASE_LOCK, keys=[key], args=[token])
|
|
81
|
+
|
|
82
|
+
async def add_tags(self, key: str, tags: Sequence[str], expire: int | None) -> None:
|
|
83
|
+
for tag in tags:
|
|
84
|
+
await self.client.invoke_script(_ADD_TAG, keys=[tag], args=[key, str(expire or 0)])
|
|
85
|
+
|
|
86
|
+
async def invalidate_tags(self, tags: Sequence[str]) -> int:
|
|
87
|
+
deleted = 0
|
|
88
|
+
for tag in tags:
|
|
89
|
+
pending = pending_tag_key(tag)
|
|
90
|
+
try:
|
|
91
|
+
await self.client.rename(tag, pending)
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
if not is_missing_key_error(exc):
|
|
94
|
+
raise
|
|
95
|
+
continue
|
|
96
|
+
members = list(await self.client.smembers(pending))
|
|
97
|
+
if members:
|
|
98
|
+
deleted += await self.client.unlink(list(members))
|
|
99
|
+
await self.client.unlink([pending])
|
|
100
|
+
return deleted
|
|
101
|
+
|
|
102
|
+
async def _delete_matching(self, pattern: str) -> int:
|
|
103
|
+
deleted = 0
|
|
104
|
+
if isinstance(self.client, GlideClusterClient):
|
|
105
|
+
cursor = ClusterScanCursor() # type: ignore[no-untyped-call]
|
|
106
|
+
while not cursor.is_finished():
|
|
107
|
+
cursor, keys = await self.client.scan(cursor, match=pattern, count=self.scan_count) # type: ignore[assignment]
|
|
108
|
+
if keys:
|
|
109
|
+
deleted += await self.client.unlink(keys)
|
|
110
|
+
return deleted
|
|
111
|
+
position: bytes = b"0"
|
|
112
|
+
while True:
|
|
113
|
+
position, keys = await self.client.scan(position, match=pattern, count=self.scan_count) # type: ignore[assignment]
|
|
114
|
+
if keys:
|
|
115
|
+
deleted += await self.client.unlink(keys)
|
|
116
|
+
if position == b"0":
|
|
117
|
+
return deleted
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import time
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from fastapi_alcazaba.types import Backend
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class Value:
|
|
12
|
+
data: bytes
|
|
13
|
+
expires_at: float | None # time.monotonic(); None: no expiry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InMemoryBackend(Backend):
|
|
17
|
+
"""Cache in the memory of the process: for development, tests and single-process apps.
|
|
18
|
+
|
|
19
|
+
Each instance has its own store, bounded to `max_size` entries (the least recently used are
|
|
20
|
+
evicted). fastapi-cache2 shared one dict between all instances and never evicted, so varying
|
|
21
|
+
a query string grew it until the process ran out of memory.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
supports_tags = True
|
|
25
|
+
|
|
26
|
+
def __init__(self, max_size: int | None = 10_000) -> None:
|
|
27
|
+
if max_size is not None and max_size < 1:
|
|
28
|
+
raise ValueError("max_size must be at least 1 (or None for no limit)")
|
|
29
|
+
self.max_size = max_size
|
|
30
|
+
self._store: OrderedDict[str, Value] = OrderedDict()
|
|
31
|
+
self._tags: dict[str, set[str]] = {}
|
|
32
|
+
self._tags_of: dict[str, set[str]] = {} # entry key -> its tags
|
|
33
|
+
|
|
34
|
+
def _delete(self, key: str) -> bool:
|
|
35
|
+
if self._store.pop(key, None) is None:
|
|
36
|
+
return False
|
|
37
|
+
for tag in self._tags_of.pop(key, ()):
|
|
38
|
+
members = self._tags.get(tag)
|
|
39
|
+
if members is not None:
|
|
40
|
+
members.discard(key)
|
|
41
|
+
if not members:
|
|
42
|
+
del self._tags[tag]
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
def _get(self, key: str) -> Value | None:
|
|
46
|
+
value = self._store.get(key)
|
|
47
|
+
if value is None:
|
|
48
|
+
return None
|
|
49
|
+
if value.expires_at is not None and value.expires_at <= time.monotonic():
|
|
50
|
+
self._delete(key)
|
|
51
|
+
return None
|
|
52
|
+
self._store.move_to_end(key)
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
async def get_with_ttl(self, key: str) -> tuple[int, bytes | None]:
|
|
56
|
+
value = self._get(key)
|
|
57
|
+
if value is None:
|
|
58
|
+
return 0, None
|
|
59
|
+
if value.expires_at is None:
|
|
60
|
+
return -1, value.data
|
|
61
|
+
return max(1, math.ceil(value.expires_at - time.monotonic())), value.data
|
|
62
|
+
|
|
63
|
+
async def get(self, key: str) -> bytes | None:
|
|
64
|
+
value = self._get(key)
|
|
65
|
+
return None if value is None else value.data
|
|
66
|
+
|
|
67
|
+
async def set(self, key: str, value: bytes, expire: int | None = None) -> None:
|
|
68
|
+
expires_at = time.monotonic() + expire if expire else None
|
|
69
|
+
self._store[key] = Value(value, expires_at)
|
|
70
|
+
self._store.move_to_end(key)
|
|
71
|
+
if self.max_size is not None:
|
|
72
|
+
while len(self._store) > self.max_size:
|
|
73
|
+
self._delete(next(iter(self._store)))
|
|
74
|
+
|
|
75
|
+
async def clear(self, namespace: str | None = None, key: str | None = None) -> int:
|
|
76
|
+
if namespace:
|
|
77
|
+
prefix = f"{namespace}:"
|
|
78
|
+
matching = [k for k in self._store if k.startswith(prefix)]
|
|
79
|
+
for k in matching:
|
|
80
|
+
self._delete(k)
|
|
81
|
+
return len(matching)
|
|
82
|
+
if key:
|
|
83
|
+
return int(self._delete(key))
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
async def add_tags(self, key: str, tags: Sequence[str], expire: int | None) -> None:
|
|
87
|
+
if key not in self._store:
|
|
88
|
+
return
|
|
89
|
+
for tag in tags:
|
|
90
|
+
self._tags.setdefault(tag, set()).add(key)
|
|
91
|
+
self._tags_of.setdefault(key, set()).update(tags)
|
|
92
|
+
|
|
93
|
+
async def invalidate_tags(self, tags: Sequence[str]) -> int:
|
|
94
|
+
keys = set().union(*(self._tags.get(tag, set()) for tag in tags))
|
|
95
|
+
return sum(self._delete(key) for key in keys)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from aiomcache import Client
|
|
5
|
+
|
|
6
|
+
from fastapi_alcazaba.types import Backend
|
|
7
|
+
|
|
8
|
+
# Beyond 30 days memcached reads exptime as a Unix timestamp, not as seconds from now.
|
|
9
|
+
_MAX_RELATIVE_EXPTIME = 30 * 24 * 3600
|
|
10
|
+
_MAX_KEY_LENGTH = 250
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def memcached_key(key: str) -> bytes:
|
|
14
|
+
"""memcached keys are at most 250 bytes of printable ASCII without spaces: anything else is
|
|
15
|
+
replaced by its SHA-256 (the keys Alcazaba builds always fit)."""
|
|
16
|
+
raw = key.encode()
|
|
17
|
+
if len(raw) <= _MAX_KEY_LENGTH and all(33 <= byte <= 126 for byte in raw):
|
|
18
|
+
return raw
|
|
19
|
+
return b"fastapi_alcazaba:sha256:" + hashlib.sha256(raw).hexdigest().encode()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MemcachedBackend(Backend):
|
|
23
|
+
def __init__(self, mcache: Client) -> None:
|
|
24
|
+
self.mcache = mcache
|
|
25
|
+
|
|
26
|
+
async def get_with_ttl(self, key: str) -> tuple[int, bytes | None]:
|
|
27
|
+
value = await self.get(key)
|
|
28
|
+
# memcached can't tell the remaining time to live (fastapi-cache2 always said 3600).
|
|
29
|
+
return (0, None) if value is None else (-1, value)
|
|
30
|
+
|
|
31
|
+
async def get(self, key: str) -> bytes | None:
|
|
32
|
+
value: bytes | None = await self.mcache.get(memcached_key(key))
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
async def set(self, key: str, value: bytes, expire: int | None = None) -> None:
|
|
36
|
+
exptime = expire or 0
|
|
37
|
+
if exptime > _MAX_RELATIVE_EXPTIME:
|
|
38
|
+
exptime += int(time.time())
|
|
39
|
+
await self.mcache.set(memcached_key(key), value, exptime=exptime)
|
|
40
|
+
|
|
41
|
+
async def clear(self, namespace: str | None = None, key: str | None = None) -> int:
|
|
42
|
+
if namespace:
|
|
43
|
+
# flush_all (upstream PR #477) would also wipe every other application's data.
|
|
44
|
+
raise NotImplementedError(
|
|
45
|
+
"memcached can't list the keys of a namespace; delete them one by one with `key`"
|
|
46
|
+
)
|
|
47
|
+
if key:
|
|
48
|
+
return int(await self.mcache.delete(memcached_key(key)))
|
|
49
|
+
return 0
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from redis.asyncio.client import Redis
|
|
2
|
+
from redis.asyncio.cluster import RedisCluster
|
|
3
|
+
|
|
4
|
+
from fastapi_alcazaba.backends._redis_common import RedisLikeBackend
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RedisBackend(RedisLikeBackend):
|
|
8
|
+
"""Redis, or any server speaking its protocol (Valkey, KeyDB, Dragonfly...), through redis-py.
|
|
9
|
+
|
|
10
|
+
Create the client without `decode_responses=True`: cached values are bytes.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, redis: Redis | RedisCluster, *, scan_count: int = 500) -> None:
|
|
14
|
+
super().__init__(redis, is_cluster=isinstance(redis, RedisCluster), scan_count=scan_count)
|
|
15
|
+
self.redis = redis
|