cloud-dog-cache 0.2.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.
- cloud_dog_cache/__init__.py +84 -0
- cloud_dog_cache/access.py +386 -0
- cloud_dog_cache/api.py +37 -0
- cloud_dog_cache/backends/__init__.py +9 -0
- cloud_dog_cache/backends/base.py +39 -0
- cloud_dog_cache/backends/memory.py +91 -0
- cloud_dog_cache/backends/redis.py +102 -0
- cloud_dog_cache/decorator.py +94 -0
- cloud_dog_cache/invalidation.py +31 -0
- cloud_dog_cache/keys.py +59 -0
- cloud_dog_cache/manager.py +95 -0
- cloud_dog_cache/memory.py +206 -0
- cloud_dog_cache/models.py +41 -0
- cloud_dog_cache/runtime.py +57 -0
- cloud_dog_cache/stats.py +36 -0
- cloud_dog_cache-0.2.0.dist-info/METADATA +16 -0
- cloud_dog_cache-0.2.0.dist-info/RECORD +20 -0
- cloud_dog_cache-0.2.0.dist-info/WHEEL +4 -0
- cloud_dog_cache-0.2.0.dist-info/licenses/LICENCE +13 -0
- cloud_dog_cache-0.2.0.dist-info/licenses/NOTICE +24 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""cloud_dog_cache — Platform caching package for LLM and tool call results.
|
|
16
|
+
|
|
17
|
+
Provides a transparent caching layer with configurable TTL, pluggable backends
|
|
18
|
+
(in-memory LRU, Redis), and event-based invalidation for context rebuilds,
|
|
19
|
+
config changes, and prompt template changes.
|
|
20
|
+
|
|
21
|
+
Usage::
|
|
22
|
+
|
|
23
|
+
from cloud_dog_cache import cached, CacheManager, get_cache_manager
|
|
24
|
+
|
|
25
|
+
@cached(ttl=3600, invalidate_on=["context_rebuild"])
|
|
26
|
+
async def generate_sql(query: str, context_hash: str, model: str) -> str:
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
# Manual access
|
|
30
|
+
manager = get_cache_manager()
|
|
31
|
+
await manager.flush()
|
|
32
|
+
stats = await manager.stats()
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
__version__ = "0.3.0"
|
|
36
|
+
|
|
37
|
+
from cloud_dog_cache.manager import CacheManager, get_cache_manager, init_cache
|
|
38
|
+
from cloud_dog_cache.decorator import cached
|
|
39
|
+
from cloud_dog_cache.keys import cache_key, hash_text, hash_config
|
|
40
|
+
from cloud_dog_cache.stats import CacheStats
|
|
41
|
+
from cloud_dog_cache.models import CacheConfig, CacheEntry
|
|
42
|
+
from cloud_dog_cache.invalidation import invalidate_event
|
|
43
|
+
from cloud_dog_cache.runtime import init_cache_from_config
|
|
44
|
+
from cloud_dog_cache.api import create_cache_router
|
|
45
|
+
from cloud_dog_cache.access import (
|
|
46
|
+
AccessUrlConfig,
|
|
47
|
+
AccessUrlEntry,
|
|
48
|
+
URLAddressableCache,
|
|
49
|
+
create_access_url_router,
|
|
50
|
+
)
|
|
51
|
+
from cloud_dog_cache.memory import (
|
|
52
|
+
MemoryScope,
|
|
53
|
+
MemoryNamespace,
|
|
54
|
+
MemoryEntry,
|
|
55
|
+
MemoryStore,
|
|
56
|
+
VectorSearchAdapter,
|
|
57
|
+
DEFAULT_SCOPE_TTL,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"AccessUrlConfig",
|
|
62
|
+
"AccessUrlEntry",
|
|
63
|
+
"CacheConfig",
|
|
64
|
+
"CacheEntry",
|
|
65
|
+
"CacheManager",
|
|
66
|
+
"CacheStats",
|
|
67
|
+
"DEFAULT_SCOPE_TTL",
|
|
68
|
+
"MemoryEntry",
|
|
69
|
+
"MemoryNamespace",
|
|
70
|
+
"MemoryScope",
|
|
71
|
+
"MemoryStore",
|
|
72
|
+
"URLAddressableCache",
|
|
73
|
+
"VectorSearchAdapter",
|
|
74
|
+
"cached",
|
|
75
|
+
"cache_key",
|
|
76
|
+
"create_access_url_router",
|
|
77
|
+
"create_cache_router",
|
|
78
|
+
"get_cache_manager",
|
|
79
|
+
"hash_config",
|
|
80
|
+
"hash_text",
|
|
81
|
+
"init_cache",
|
|
82
|
+
"init_cache_from_config",
|
|
83
|
+
"invalidate_event",
|
|
84
|
+
]
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""URL-addressable cache extension for cloud_dog_cache.
|
|
5
|
+
|
|
6
|
+
This module provides an additive layer on top of :class:`CacheManager` that
|
|
7
|
+
exposes cached entries via a URL — either an unsigned key path (relying on
|
|
8
|
+
upstream auth) or an HMAC-signed URL with embedded expiry (for tokenless
|
|
9
|
+
downstream consumers such as image renderers, e-mail bodies, or remote
|
|
10
|
+
notification clients).
|
|
11
|
+
|
|
12
|
+
It is the platform replacement for the bespoke ``access_url`` semantic
|
|
13
|
+
historically implemented by ``ImageCacheManager`` in
|
|
14
|
+
``notification-agent-mcp-server`` (see W28A-#A16-REPORT-2026-05-04 §2.B).
|
|
15
|
+
|
|
16
|
+
Key design properties:
|
|
17
|
+
|
|
18
|
+
* **Additive.** Pure new module. ``CacheManager`` is unchanged. Existing
|
|
19
|
+
``cached``/``init_cache``/``get_cache_manager`` consumers see no behaviour
|
|
20
|
+
change.
|
|
21
|
+
* **TTL is independent.** The cache content TTL (how long the value lives in
|
|
22
|
+
the cache backend) is separate from the URL TTL (how long a signed URL is
|
|
23
|
+
valid). Callers can hold a long-lived cache entry but mint short-lived
|
|
24
|
+
access URLs against it.
|
|
25
|
+
* **Two URL modes.** Unsigned URLs are simple ``{base}/cache/access/{key}``
|
|
26
|
+
paths — the upstream caller is responsible for auth. Signed URLs append
|
|
27
|
+
``?expires=<unix_ts>&sig=<hex>`` and are HMAC-SHA256 verifiable without
|
|
28
|
+
any per-request server lookup beyond the cache itself.
|
|
29
|
+
* **No new dependencies.** Uses only ``hmac``/``hashlib`` from stdlib.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import hashlib
|
|
35
|
+
import hmac
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from datetime import datetime, timedelta, timezone
|
|
38
|
+
from typing import Any, Optional
|
|
39
|
+
from urllib.parse import parse_qs, quote, urlsplit
|
|
40
|
+
|
|
41
|
+
from cloud_dog_cache.manager import CacheManager
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Configuration & data models
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class AccessUrlConfig:
|
|
51
|
+
"""Runtime configuration for the URL-addressable cache layer.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
base_url: Public base URL where the cache router is mounted, e.g.
|
|
55
|
+
``https://notificationagent0.cloud-dog.net``. The access path
|
|
56
|
+
``/cache/access/{key}`` is appended.
|
|
57
|
+
signing_secret: Secret used for HMAC-SHA256 signing. When empty,
|
|
58
|
+
``signed=True`` calls raise ``ValueError`` — services MUST
|
|
59
|
+
provide a secret to mint signed URLs. Reading the secret from
|
|
60
|
+
Vault and passing it here is the responsibility of the consuming
|
|
61
|
+
service.
|
|
62
|
+
default_url_ttl_seconds: Default validity window for signed URLs
|
|
63
|
+
when caller does not specify ``url_ttl``.
|
|
64
|
+
access_path_prefix: Path prefix appended to ``base_url`` to form
|
|
65
|
+
the access URL. Defaults to ``"/cache/access"``.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
base_url: str = ""
|
|
69
|
+
signing_secret: str = ""
|
|
70
|
+
default_url_ttl_seconds: int = 300
|
|
71
|
+
access_path_prefix: str = "/cache/access"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class AccessUrlEntry:
|
|
76
|
+
"""A cache entry plus its mint URL metadata.
|
|
77
|
+
|
|
78
|
+
Returned by :meth:`URLAddressableCache.set_with_url`. Mirrors the shape
|
|
79
|
+
historically returned by ``ImageCacheManager.cache_image``:
|
|
80
|
+
``{cache_key, access_url, ...}``.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
key: str
|
|
84
|
+
url: str
|
|
85
|
+
url_expires_at: Optional[datetime] = None
|
|
86
|
+
signed: bool = False
|
|
87
|
+
cache_ttl: Optional[int] = None
|
|
88
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> dict[str, Any]:
|
|
91
|
+
"""Plain-dict view for JSON responses."""
|
|
92
|
+
return {
|
|
93
|
+
"cache_key": self.key,
|
|
94
|
+
"access_url": self.url,
|
|
95
|
+
"url_expires_at": (
|
|
96
|
+
self.url_expires_at.isoformat() if self.url_expires_at else None
|
|
97
|
+
),
|
|
98
|
+
"signed": self.signed,
|
|
99
|
+
"cache_ttl_seconds": self.cache_ttl,
|
|
100
|
+
"metadata": dict(self.metadata),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# URLAddressableCache
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class URLAddressableCache:
|
|
110
|
+
"""Wraps a :class:`CacheManager` with URL-addressable access semantics.
|
|
111
|
+
|
|
112
|
+
This class is the migration target for ``ImageCacheManager``: it stores
|
|
113
|
+
a value in the cache and returns an externally-fetchable URL alongside
|
|
114
|
+
the cache key.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
manager: CacheManager,
|
|
120
|
+
config: AccessUrlConfig | None = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Initialise with a cache manager and access-URL configuration.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
manager: An existing :class:`CacheManager` instance. The cache
|
|
126
|
+
content lives here unchanged.
|
|
127
|
+
config: URL-addressable configuration. If ``None``, a default
|
|
128
|
+
with empty ``base_url`` and empty signing secret is used —
|
|
129
|
+
the caller can still mint **relative** unsigned URLs but
|
|
130
|
+
signed URLs will require an explicit secret.
|
|
131
|
+
"""
|
|
132
|
+
self._manager = manager
|
|
133
|
+
self._config = config or AccessUrlConfig()
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
# URL minting
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def build_url(
|
|
140
|
+
self,
|
|
141
|
+
key: str,
|
|
142
|
+
*,
|
|
143
|
+
url_ttl: int | None = None,
|
|
144
|
+
signed: bool = False,
|
|
145
|
+
) -> tuple[str, Optional[datetime]]:
|
|
146
|
+
"""Build an access URL for an existing cache key.
|
|
147
|
+
|
|
148
|
+
This does NOT touch the cache backend — it only constructs the URL.
|
|
149
|
+
Useful when the caller has already populated the cache via
|
|
150
|
+
:meth:`CacheManager.set` and now wants an access URL for an
|
|
151
|
+
existing key.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
key: The cache key already present in the manager.
|
|
155
|
+
url_ttl: URL validity window in seconds. Only meaningful when
|
|
156
|
+
``signed=True``. Defaults to ``config.default_url_ttl_seconds``.
|
|
157
|
+
signed: When ``True``, append ``expires`` and ``sig`` query
|
|
158
|
+
parameters (HMAC-SHA256). When ``False``, return a bare
|
|
159
|
+
``{base}/{prefix}/{key}`` URL — upstream auth is required.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
``(url, url_expires_at)``. ``url_expires_at`` is ``None`` for
|
|
163
|
+
unsigned URLs.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
ValueError: ``signed=True`` requested but no signing secret in
|
|
167
|
+
config.
|
|
168
|
+
"""
|
|
169
|
+
path = f"{self._config.access_path_prefix.rstrip('/')}/{quote(key, safe='')}"
|
|
170
|
+
base = self._config.base_url.rstrip("/")
|
|
171
|
+
if signed:
|
|
172
|
+
if not self._config.signing_secret:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
"signed=True requested but AccessUrlConfig.signing_secret is empty"
|
|
175
|
+
)
|
|
176
|
+
ttl = (
|
|
177
|
+
url_ttl
|
|
178
|
+
if url_ttl is not None
|
|
179
|
+
else self._config.default_url_ttl_seconds
|
|
180
|
+
)
|
|
181
|
+
expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl)
|
|
182
|
+
expires_ts = int(expires_at.timestamp())
|
|
183
|
+
sig = self._sign(key, expires_ts)
|
|
184
|
+
return (
|
|
185
|
+
f"{base}{path}?expires={expires_ts}&sig={sig}",
|
|
186
|
+
expires_at,
|
|
187
|
+
)
|
|
188
|
+
return f"{base}{path}", None
|
|
189
|
+
|
|
190
|
+
async def set_with_url(
|
|
191
|
+
self,
|
|
192
|
+
key: str,
|
|
193
|
+
value: Any,
|
|
194
|
+
*,
|
|
195
|
+
ttl: int | None = None,
|
|
196
|
+
url_ttl: int | None = None,
|
|
197
|
+
signed: bool = False,
|
|
198
|
+
tags: tuple[str, ...] = (),
|
|
199
|
+
metadata: dict[str, Any] | None = None,
|
|
200
|
+
) -> AccessUrlEntry:
|
|
201
|
+
"""Store a value AND mint an access URL for it.
|
|
202
|
+
|
|
203
|
+
This is the canonical replacement for ``ImageCacheManager.cache_image``.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
key: Cache key (typically a content hash — see
|
|
207
|
+
:func:`cloud_dog_cache.keys.hash_text`).
|
|
208
|
+
value: Cached value. Any type that the underlying backend can
|
|
209
|
+
serialise (bytes, str, dict, etc.).
|
|
210
|
+
ttl: Cache content TTL in seconds. ``None`` uses the manager's
|
|
211
|
+
configured default.
|
|
212
|
+
url_ttl: URL validity TTL in seconds (signed URLs only).
|
|
213
|
+
signed: Whether to mint a signed URL.
|
|
214
|
+
tags: Invalidation tags applied to the cache entry.
|
|
215
|
+
metadata: Free-form metadata attached to the returned
|
|
216
|
+
:class:`AccessUrlEntry` (e.g. content-type, original URI).
|
|
217
|
+
NOT stored in the cache itself — this is a convenience for
|
|
218
|
+
the caller's response shaping.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
:class:`AccessUrlEntry` with key, URL, expiry, and metadata.
|
|
222
|
+
"""
|
|
223
|
+
await self._manager.set(key, value, ttl=ttl, tags=tags)
|
|
224
|
+
url, expires_at = self.build_url(key, url_ttl=url_ttl, signed=signed)
|
|
225
|
+
return AccessUrlEntry(
|
|
226
|
+
key=key,
|
|
227
|
+
url=url,
|
|
228
|
+
url_expires_at=expires_at,
|
|
229
|
+
signed=signed,
|
|
230
|
+
cache_ttl=ttl,
|
|
231
|
+
metadata=dict(metadata or {}),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# ------------------------------------------------------------------
|
|
235
|
+
# URL verification & retrieval
|
|
236
|
+
# ------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
def verify_url(self, url: str) -> Optional[tuple[str, Optional[datetime]]]:
|
|
239
|
+
"""Verify a signed or unsigned URL and return ``(key, expires_at)``.
|
|
240
|
+
|
|
241
|
+
For unsigned URLs (no ``sig`` query param), this just extracts the
|
|
242
|
+
key from the path. For signed URLs, this validates the HMAC and the
|
|
243
|
+
expiry timestamp.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
``(key, expires_at)`` on success, ``None`` on any failure
|
|
247
|
+
(bad signature, expired, malformed path).
|
|
248
|
+
"""
|
|
249
|
+
try:
|
|
250
|
+
parts = urlsplit(url)
|
|
251
|
+
except ValueError:
|
|
252
|
+
return None
|
|
253
|
+
prefix = self._config.access_path_prefix.rstrip("/")
|
|
254
|
+
path = parts.path
|
|
255
|
+
if prefix and not path.startswith(prefix + "/"):
|
|
256
|
+
return None
|
|
257
|
+
key_segment = path[len(prefix) + 1 :] if prefix else path.lstrip("/")
|
|
258
|
+
if not key_segment:
|
|
259
|
+
return None
|
|
260
|
+
# urllib.parse.unquote — matches the quote() in build_url
|
|
261
|
+
from urllib.parse import unquote
|
|
262
|
+
|
|
263
|
+
key = unquote(key_segment)
|
|
264
|
+
query = parse_qs(parts.query)
|
|
265
|
+
sig_values = query.get("sig", [])
|
|
266
|
+
if not sig_values:
|
|
267
|
+
return (key, None)
|
|
268
|
+
# Signed URL — validate
|
|
269
|
+
expires_values = query.get("expires", [])
|
|
270
|
+
if not expires_values:
|
|
271
|
+
return None
|
|
272
|
+
try:
|
|
273
|
+
expires_ts = int(expires_values[0])
|
|
274
|
+
except (TypeError, ValueError):
|
|
275
|
+
return None
|
|
276
|
+
if not self._config.signing_secret:
|
|
277
|
+
return None
|
|
278
|
+
expected = self._sign(key, expires_ts)
|
|
279
|
+
if not hmac.compare_digest(expected, sig_values[0]):
|
|
280
|
+
return None
|
|
281
|
+
expires_at = datetime.fromtimestamp(expires_ts, tz=timezone.utc)
|
|
282
|
+
if datetime.now(timezone.utc) >= expires_at:
|
|
283
|
+
return None
|
|
284
|
+
return (key, expires_at)
|
|
285
|
+
|
|
286
|
+
async def get_by_url(self, url: str) -> Optional[Any]:
|
|
287
|
+
"""Resolve a URL to its cached value.
|
|
288
|
+
|
|
289
|
+
Returns ``None`` if the URL fails verification, has expired, or the
|
|
290
|
+
cache key has been evicted/expired from the underlying manager.
|
|
291
|
+
"""
|
|
292
|
+
verified = self.verify_url(url)
|
|
293
|
+
if verified is None:
|
|
294
|
+
return None
|
|
295
|
+
key, _ = verified
|
|
296
|
+
return await self._manager.get(key)
|
|
297
|
+
|
|
298
|
+
# ------------------------------------------------------------------
|
|
299
|
+
# Internals
|
|
300
|
+
# ------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
def _sign(self, key: str, expires_ts: int) -> str:
|
|
303
|
+
"""HMAC-SHA256 over ``"{key}|{expires_ts}"``."""
|
|
304
|
+
msg = f"{key}|{expires_ts}".encode("utf-8")
|
|
305
|
+
secret = self._config.signing_secret.encode("utf-8")
|
|
306
|
+
return hmac.new(secret, msg, hashlib.sha256).hexdigest()
|
|
307
|
+
|
|
308
|
+
@property
|
|
309
|
+
def manager(self) -> CacheManager:
|
|
310
|
+
"""The underlying :class:`CacheManager`."""
|
|
311
|
+
return self._manager
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def config(self) -> AccessUrlConfig:
|
|
315
|
+
"""The active :class:`AccessUrlConfig`."""
|
|
316
|
+
return self._config
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# ---------------------------------------------------------------------------
|
|
320
|
+
# FastAPI router
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def create_access_url_router(
|
|
325
|
+
cache: URLAddressableCache,
|
|
326
|
+
*,
|
|
327
|
+
media_type_resolver: Optional[Any] = None,
|
|
328
|
+
) -> Any:
|
|
329
|
+
"""Create a FastAPI router exposing ``GET {access_path_prefix}/{key}``.
|
|
330
|
+
|
|
331
|
+
The router validates the URL (signature + expiry when present), looks
|
|
332
|
+
up the cache entry, and returns the value. ``bytes``/``bytearray``
|
|
333
|
+
values are returned as a binary ``Response`` (with optional
|
|
334
|
+
``media_type_resolver(key, value) -> str``); other values are returned
|
|
335
|
+
as JSON.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
cache: The :class:`URLAddressableCache` to serve from.
|
|
339
|
+
media_type_resolver: Optional callable receiving ``(key, value)``
|
|
340
|
+
and returning a content-type string for binary responses.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
A FastAPI ``APIRouter``.
|
|
344
|
+
"""
|
|
345
|
+
from fastapi import APIRouter, HTTPException, Request, Response
|
|
346
|
+
|
|
347
|
+
prefix = cache.config.access_path_prefix.rstrip("/")
|
|
348
|
+
router = APIRouter(tags=["cache-access"])
|
|
349
|
+
|
|
350
|
+
@router.get(prefix + "/{key:path}")
|
|
351
|
+
async def access(key: str, request: Request) -> Response:
|
|
352
|
+
# Reconstruct the URL exactly as a downstream consumer would have
|
|
353
|
+
# received it, then verify against the live config.
|
|
354
|
+
full_url = (
|
|
355
|
+
f"{cache.config.base_url.rstrip('/')}{prefix}/{quote(key, safe='')}"
|
|
356
|
+
)
|
|
357
|
+
if request.url.query:
|
|
358
|
+
full_url = f"{full_url}?{request.url.query}"
|
|
359
|
+
verified = cache.verify_url(full_url)
|
|
360
|
+
if verified is None:
|
|
361
|
+
raise HTTPException(status_code=403, detail="invalid or expired url")
|
|
362
|
+
verified_key, _ = verified
|
|
363
|
+
value = await cache.manager.get(verified_key)
|
|
364
|
+
if value is None:
|
|
365
|
+
raise HTTPException(status_code=404, detail="cache miss")
|
|
366
|
+
if isinstance(value, (bytes, bytearray)):
|
|
367
|
+
content_type = (
|
|
368
|
+
media_type_resolver(verified_key, value)
|
|
369
|
+
if media_type_resolver is not None
|
|
370
|
+
else "application/octet-stream"
|
|
371
|
+
)
|
|
372
|
+
return Response(content=bytes(value), media_type=content_type)
|
|
373
|
+
# JSON fall-through
|
|
374
|
+
from fastapi.responses import JSONResponse
|
|
375
|
+
|
|
376
|
+
return JSONResponse(content=value)
|
|
377
|
+
|
|
378
|
+
return router
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
__all__ = [
|
|
382
|
+
"AccessUrlConfig",
|
|
383
|
+
"AccessUrlEntry",
|
|
384
|
+
"URLAddressableCache",
|
|
385
|
+
"create_access_url_router",
|
|
386
|
+
]
|
cloud_dog_cache/api.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""FastAPI router for cache statistics and management endpoints."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from cloud_dog_cache.manager import get_cache_manager
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_cache_router() -> Any:
|
|
14
|
+
"""Create a FastAPI APIRouter with /cache/stats and /cache/flush endpoints."""
|
|
15
|
+
from fastapi import APIRouter, HTTPException
|
|
16
|
+
|
|
17
|
+
router = APIRouter(prefix="/cache", tags=["cache"])
|
|
18
|
+
|
|
19
|
+
@router.get("/stats")
|
|
20
|
+
async def cache_stats() -> dict[str, object]:
|
|
21
|
+
"""Return current cache statistics."""
|
|
22
|
+
manager = get_cache_manager()
|
|
23
|
+
if manager is None:
|
|
24
|
+
return {"enabled": False, "stats": {}}
|
|
25
|
+
stats = await manager.stats()
|
|
26
|
+
return {"enabled": manager.enabled, "stats": stats.to_dict()}
|
|
27
|
+
|
|
28
|
+
@router.post("/flush")
|
|
29
|
+
async def cache_flush() -> dict[str, str]:
|
|
30
|
+
"""Flush all cache entries."""
|
|
31
|
+
manager = get_cache_manager()
|
|
32
|
+
if manager is None:
|
|
33
|
+
raise HTTPException(status_code=503, detail="Cache not initialised")
|
|
34
|
+
await manager.flush()
|
|
35
|
+
return {"status": "flushed"}
|
|
36
|
+
|
|
37
|
+
return router
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""Cache backend implementations."""
|
|
5
|
+
|
|
6
|
+
from cloud_dog_cache.backends.base import CacheBackend
|
|
7
|
+
from cloud_dog_cache.backends.memory import MemoryCacheBackend
|
|
8
|
+
|
|
9
|
+
__all__ = ["CacheBackend", "MemoryCacheBackend"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""Abstract cache backend protocol."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Optional, Protocol
|
|
9
|
+
|
|
10
|
+
from cloud_dog_cache.models import CacheEntry
|
|
11
|
+
from cloud_dog_cache.stats import CacheStats
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CacheBackend(Protocol):
|
|
15
|
+
"""Protocol for pluggable cache storage backends."""
|
|
16
|
+
|
|
17
|
+
async def get(self, key: str) -> Optional[CacheEntry]:
|
|
18
|
+
"""Retrieve a cache entry by key. Return None if missing or expired."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
async def set(self, key: str, entry: CacheEntry, ttl: int) -> None:
|
|
22
|
+
"""Store a cache entry with the given TTL in seconds."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
async def delete(self, key: str) -> None:
|
|
26
|
+
"""Remove a single entry by key."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
async def flush(self) -> None:
|
|
30
|
+
"""Remove all entries from the cache."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
async def stats(self) -> CacheStats:
|
|
34
|
+
"""Return current cache statistics."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
async def flush_by_tag(self, tag: str) -> int:
|
|
38
|
+
"""Remove all entries matching the given tag. Return count removed."""
|
|
39
|
+
...
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""In-memory LRU cache backend with TTL support."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
from datetime import datetime, timedelta, timezone
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from cloud_dog_cache.models import CacheEntry
|
|
15
|
+
from cloud_dog_cache.stats import CacheStats
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MemoryCacheBackend:
|
|
19
|
+
"""Thread-safe in-memory LRU cache with per-entry TTL and tag-based invalidation."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, *, max_entries: int = 1000) -> None:
|
|
22
|
+
"""Initialise with the given maximum entry count."""
|
|
23
|
+
self._max_entries = max(1, max_entries)
|
|
24
|
+
self._store: OrderedDict[str, CacheEntry] = OrderedDict()
|
|
25
|
+
self._lock = threading.Lock()
|
|
26
|
+
self._hits = 0
|
|
27
|
+
self._misses = 0
|
|
28
|
+
self._evictions = 0
|
|
29
|
+
|
|
30
|
+
async def get(self, key: str) -> Optional[CacheEntry]:
|
|
31
|
+
"""Retrieve an entry, promoting it in LRU order."""
|
|
32
|
+
with self._lock:
|
|
33
|
+
entry = self._store.get(key)
|
|
34
|
+
if entry is None:
|
|
35
|
+
self._misses += 1
|
|
36
|
+
return None
|
|
37
|
+
if entry.expired:
|
|
38
|
+
del self._store[key]
|
|
39
|
+
self._misses += 1
|
|
40
|
+
return None
|
|
41
|
+
self._store.move_to_end(key)
|
|
42
|
+
self._hits += 1
|
|
43
|
+
return entry
|
|
44
|
+
|
|
45
|
+
async def set(self, key: str, entry: CacheEntry, ttl: int) -> None:
|
|
46
|
+
"""Store an entry with TTL. Evict oldest if at capacity."""
|
|
47
|
+
expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl)
|
|
48
|
+
stored = CacheEntry(
|
|
49
|
+
key=entry.key,
|
|
50
|
+
value=entry.value,
|
|
51
|
+
created_at=entry.created_at,
|
|
52
|
+
expires_at=expires_at,
|
|
53
|
+
tags=entry.tags,
|
|
54
|
+
)
|
|
55
|
+
with self._lock:
|
|
56
|
+
if key in self._store:
|
|
57
|
+
del self._store[key]
|
|
58
|
+
while len(self._store) >= self._max_entries:
|
|
59
|
+
self._store.popitem(last=False)
|
|
60
|
+
self._evictions += 1
|
|
61
|
+
self._store[key] = stored
|
|
62
|
+
|
|
63
|
+
async def delete(self, key: str) -> None:
|
|
64
|
+
"""Remove a single entry."""
|
|
65
|
+
with self._lock:
|
|
66
|
+
self._store.pop(key, None)
|
|
67
|
+
|
|
68
|
+
async def flush(self) -> None:
|
|
69
|
+
"""Remove all entries."""
|
|
70
|
+
with self._lock:
|
|
71
|
+
self._store.clear()
|
|
72
|
+
|
|
73
|
+
async def stats(self) -> CacheStats:
|
|
74
|
+
"""Return current statistics snapshot."""
|
|
75
|
+
with self._lock:
|
|
76
|
+
mem = sum(sys.getsizeof(e.value) for e in self._store.values())
|
|
77
|
+
return CacheStats(
|
|
78
|
+
hits=self._hits,
|
|
79
|
+
misses=self._misses,
|
|
80
|
+
entries=len(self._store),
|
|
81
|
+
evictions=self._evictions,
|
|
82
|
+
memory_bytes=mem,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
async def flush_by_tag(self, tag: str) -> int:
|
|
86
|
+
"""Remove all entries with the given tag."""
|
|
87
|
+
with self._lock:
|
|
88
|
+
to_remove = [k for k, v in self._store.items() if tag in v.tags]
|
|
89
|
+
for k in to_remove:
|
|
90
|
+
del self._store[k]
|
|
91
|
+
return len(to_remove)
|