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,102 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""Redis cache backend (optional — requires ``redis`` package)."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from cloud_dog_cache.models import CacheEntry
|
|
13
|
+
from cloud_dog_cache.stats import CacheStats
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RedisCacheBackend:
|
|
17
|
+
"""Redis-backed cache with TTL and tag-based invalidation.
|
|
18
|
+
|
|
19
|
+
Requires the ``redis`` package: ``pip install redis``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, *, redis_url: str, key_prefix: str = "cdc:") -> None:
|
|
23
|
+
"""Initialise with Redis connection URL."""
|
|
24
|
+
import redis.asyncio as aioredis
|
|
25
|
+
|
|
26
|
+
self._client = aioredis.from_url(redis_url, decode_responses=True)
|
|
27
|
+
self._prefix = key_prefix
|
|
28
|
+
self._hits = 0
|
|
29
|
+
self._misses = 0
|
|
30
|
+
self._evictions = 0
|
|
31
|
+
|
|
32
|
+
def _key(self, key: str) -> str:
|
|
33
|
+
return f"{self._prefix}{key}"
|
|
34
|
+
|
|
35
|
+
def _tag_key(self, tag: str) -> str:
|
|
36
|
+
return f"{self._prefix}tag:{tag}"
|
|
37
|
+
|
|
38
|
+
async def get(self, key: str) -> Optional[CacheEntry]:
|
|
39
|
+
"""Retrieve a cached entry from Redis."""
|
|
40
|
+
raw = await self._client.get(self._key(key))
|
|
41
|
+
if raw is None:
|
|
42
|
+
self._misses += 1
|
|
43
|
+
return None
|
|
44
|
+
data = json.loads(raw)
|
|
45
|
+
self._hits += 1
|
|
46
|
+
return CacheEntry(
|
|
47
|
+
key=key,
|
|
48
|
+
value=data["value"],
|
|
49
|
+
created_at=datetime.fromisoformat(data["created_at"]),
|
|
50
|
+
tags=tuple(data.get("tags", ())),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
async def set(self, key: str, entry: CacheEntry, ttl: int) -> None:
|
|
54
|
+
"""Store a cache entry in Redis with TTL."""
|
|
55
|
+
data = {
|
|
56
|
+
"value": entry.value,
|
|
57
|
+
"created_at": entry.created_at.isoformat(),
|
|
58
|
+
"tags": list(entry.tags),
|
|
59
|
+
}
|
|
60
|
+
pipe = self._client.pipeline()
|
|
61
|
+
pipe.setex(self._key(key), ttl, json.dumps(data, default=str))
|
|
62
|
+
for tag in entry.tags:
|
|
63
|
+
pipe.sadd(self._tag_key(tag), key)
|
|
64
|
+
pipe.expire(self._tag_key(tag), ttl)
|
|
65
|
+
await pipe.execute()
|
|
66
|
+
|
|
67
|
+
async def delete(self, key: str) -> None:
|
|
68
|
+
"""Remove a single entry from Redis."""
|
|
69
|
+
await self._client.delete(self._key(key))
|
|
70
|
+
|
|
71
|
+
async def flush(self) -> None:
|
|
72
|
+
"""Remove all entries with the configured prefix."""
|
|
73
|
+
keys = []
|
|
74
|
+
async for key in self._client.scan_iter(match=f"{self._prefix}*"):
|
|
75
|
+
keys.append(key)
|
|
76
|
+
if keys:
|
|
77
|
+
await self._client.delete(*keys)
|
|
78
|
+
|
|
79
|
+
async def stats(self) -> CacheStats:
|
|
80
|
+
"""Return statistics (approximate for Redis)."""
|
|
81
|
+
count = 0
|
|
82
|
+
async for _ in self._client.scan_iter(match=f"{self._prefix}*"):
|
|
83
|
+
count += 1
|
|
84
|
+
return CacheStats(
|
|
85
|
+
hits=self._hits,
|
|
86
|
+
misses=self._misses,
|
|
87
|
+
entries=count,
|
|
88
|
+
evictions=self._evictions,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
async def flush_by_tag(self, tag: str) -> int:
|
|
92
|
+
"""Remove all entries tagged with the given tag."""
|
|
93
|
+
members = await self._client.smembers(self._tag_key(tag))
|
|
94
|
+
removed = 0
|
|
95
|
+
if members:
|
|
96
|
+
pipe = self._client.pipeline()
|
|
97
|
+
for member in members:
|
|
98
|
+
pipe.delete(self._key(member))
|
|
99
|
+
await pipe.execute()
|
|
100
|
+
removed = len(members)
|
|
101
|
+
await self._client.delete(self._tag_key(tag))
|
|
102
|
+
return removed
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""@cached decorator for transparent function result caching."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import functools
|
|
9
|
+
import inspect
|
|
10
|
+
from typing import Any, Callable, Sequence
|
|
11
|
+
|
|
12
|
+
from cloud_dog_cache.keys import cache_key
|
|
13
|
+
from cloud_dog_cache.manager import get_cache_manager
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cached(
|
|
17
|
+
*,
|
|
18
|
+
ttl: int = 3600,
|
|
19
|
+
invalidate_on: Sequence[str] = (),
|
|
20
|
+
key_params: Sequence[str] | None = None,
|
|
21
|
+
context_hash_param: str = "",
|
|
22
|
+
model_config_hash_param: str = "",
|
|
23
|
+
prompt_hash_param: str = "",
|
|
24
|
+
) -> Callable:
|
|
25
|
+
"""Decorator that caches async function results.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
ttl: Time-to-live in seconds for cached entries.
|
|
29
|
+
invalidate_on: Event names that trigger invalidation of this function's cache.
|
|
30
|
+
key_params: Explicit parameter names to include in the cache key.
|
|
31
|
+
If None, all parameters are included.
|
|
32
|
+
context_hash_param: Name of the parameter containing the context hash.
|
|
33
|
+
model_config_hash_param: Name of the parameter containing the model config hash.
|
|
34
|
+
prompt_hash_param: Name of the parameter containing the prompt hash.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A decorator that wraps async functions with caching logic.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def decorator(fn: Callable) -> Callable:
|
|
41
|
+
fn_name = f"{fn.__module__}.{fn.__qualname__}"
|
|
42
|
+
|
|
43
|
+
@functools.wraps(fn)
|
|
44
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
45
|
+
manager = get_cache_manager()
|
|
46
|
+
if manager is None or not manager.enabled:
|
|
47
|
+
return await fn(*args, **kwargs)
|
|
48
|
+
|
|
49
|
+
# Build parameter dict from function signature
|
|
50
|
+
sig = inspect.signature(fn)
|
|
51
|
+
bound = sig.bind(*args, **kwargs)
|
|
52
|
+
bound.apply_defaults()
|
|
53
|
+
all_params = dict(bound.arguments)
|
|
54
|
+
|
|
55
|
+
# Select key params
|
|
56
|
+
if key_params is not None:
|
|
57
|
+
params = {k: all_params[k] for k in key_params if k in all_params}
|
|
58
|
+
else:
|
|
59
|
+
params = all_params
|
|
60
|
+
|
|
61
|
+
# Extract hash params
|
|
62
|
+
ctx_hash = (
|
|
63
|
+
str(params.pop(context_hash_param, "")) if context_hash_param else ""
|
|
64
|
+
)
|
|
65
|
+
model_hash = (
|
|
66
|
+
str(params.pop(model_config_hash_param, ""))
|
|
67
|
+
if model_config_hash_param
|
|
68
|
+
else ""
|
|
69
|
+
)
|
|
70
|
+
p_hash = str(params.pop(prompt_hash_param, "")) if prompt_hash_param else ""
|
|
71
|
+
|
|
72
|
+
key = cache_key(
|
|
73
|
+
fn_name,
|
|
74
|
+
params=params,
|
|
75
|
+
context_hash=ctx_hash,
|
|
76
|
+
model_config_hash=model_hash,
|
|
77
|
+
prompt_hash=p_hash,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Check cache
|
|
81
|
+
cached_value = await manager.get(key)
|
|
82
|
+
if cached_value is not None:
|
|
83
|
+
return cached_value
|
|
84
|
+
|
|
85
|
+
# Execute and cache
|
|
86
|
+
result = await fn(*args, **kwargs)
|
|
87
|
+
tags = tuple(invalidate_on)
|
|
88
|
+
await manager.set(key, result, ttl=ttl, tags=tags)
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
wrapper.__cache_function_name__ = fn_name
|
|
92
|
+
return wrapper
|
|
93
|
+
|
|
94
|
+
return decorator
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""Event-based cache invalidation hooks."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
# Well-known invalidation event names.
|
|
13
|
+
CONTEXT_REBUILD = "context_rebuild"
|
|
14
|
+
CONFIG_CHANGE = "config_change"
|
|
15
|
+
PROMPT_CHANGE = "prompt_change"
|
|
16
|
+
MANUAL_FLUSH = "manual_flush"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def invalidate_event(event_name: str) -> int:
|
|
20
|
+
"""Fire an invalidation event, flushing cache entries tagged with *event_name*.
|
|
21
|
+
|
|
22
|
+
Returns the number of entries removed.
|
|
23
|
+
"""
|
|
24
|
+
from cloud_dog_cache.manager import get_cache_manager
|
|
25
|
+
|
|
26
|
+
manager = get_cache_manager()
|
|
27
|
+
if manager is None:
|
|
28
|
+
return 0
|
|
29
|
+
removed = await manager.backend.flush_by_tag(event_name)
|
|
30
|
+
logger.info("cache_invalidation", extra={"event": event_name, "removed": removed})
|
|
31
|
+
return removed
|
cloud_dog_cache/keys.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""Cache key generation and hashing."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _stable_json(obj: Any) -> str:
|
|
14
|
+
"""Produce a deterministic JSON representation for hashing."""
|
|
15
|
+
if isinstance(obj, dict):
|
|
16
|
+
return json.dumps(
|
|
17
|
+
{k: _stable_json(v) for k, v in sorted(obj.items())}, sort_keys=True
|
|
18
|
+
)
|
|
19
|
+
if isinstance(obj, (list, tuple)):
|
|
20
|
+
return json.dumps([_stable_json(v) for v in obj])
|
|
21
|
+
if isinstance(obj, set):
|
|
22
|
+
return json.dumps(sorted(str(v) for v in obj))
|
|
23
|
+
return json.dumps(obj, default=str)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cache_key(
|
|
27
|
+
function_name: str,
|
|
28
|
+
*,
|
|
29
|
+
params: dict[str, Any] | None = None,
|
|
30
|
+
context_hash: str = "",
|
|
31
|
+
model_config_hash: str = "",
|
|
32
|
+
prompt_hash: str = "",
|
|
33
|
+
) -> str:
|
|
34
|
+
"""Generate a deterministic cache key from function identity and input hashes.
|
|
35
|
+
|
|
36
|
+
The key includes the function name, sorted input parameters, and optional
|
|
37
|
+
hashes for context state, model configuration, and prompt templates. Any
|
|
38
|
+
change in these components produces a different key, ensuring stale results
|
|
39
|
+
are never served.
|
|
40
|
+
"""
|
|
41
|
+
parts = [
|
|
42
|
+
function_name,
|
|
43
|
+
_stable_json(params or {}),
|
|
44
|
+
context_hash,
|
|
45
|
+
model_config_hash,
|
|
46
|
+
prompt_hash,
|
|
47
|
+
]
|
|
48
|
+
combined = "|".join(parts)
|
|
49
|
+
return hashlib.sha256(combined.encode("utf-8")).hexdigest()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def hash_text(text: str) -> str:
|
|
53
|
+
"""Return a stable SHA-256 hex digest of the given text."""
|
|
54
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def hash_config(config: dict[str, Any]) -> str:
|
|
58
|
+
"""Return a stable hash of a configuration dict."""
|
|
59
|
+
return hashlib.sha256(_stable_json(config).encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""CacheManager — orchestrates cache backends, logging, and statistics."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from cloud_dog_cache.backends.memory import MemoryCacheBackend
|
|
12
|
+
from cloud_dog_cache.models import CacheConfig, CacheEntry
|
|
13
|
+
from cloud_dog_cache.stats import CacheStats
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_MANAGER: CacheManager | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CacheManager:
|
|
21
|
+
"""Central cache orchestrator.
|
|
22
|
+
|
|
23
|
+
Wraps a pluggable backend with logging, config-driven enable/disable,
|
|
24
|
+
and statistics collection.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, config: CacheConfig | None = None) -> None:
|
|
28
|
+
"""Initialise with configuration."""
|
|
29
|
+
self.config = config or CacheConfig()
|
|
30
|
+
self.backend = self._build_backend()
|
|
31
|
+
|
|
32
|
+
def _build_backend(self) -> Any:
|
|
33
|
+
"""Instantiate the configured backend."""
|
|
34
|
+
if self.config.backend == "redis" and self.config.redis_url:
|
|
35
|
+
from cloud_dog_cache.backends.redis import RedisCacheBackend
|
|
36
|
+
|
|
37
|
+
return RedisCacheBackend(redis_url=self.config.redis_url)
|
|
38
|
+
return MemoryCacheBackend(max_entries=self.config.max_entries)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def enabled(self) -> bool:
|
|
42
|
+
"""Return whether caching is active."""
|
|
43
|
+
return self.config.enabled
|
|
44
|
+
|
|
45
|
+
async def get(self, key: str) -> Optional[Any]:
|
|
46
|
+
"""Retrieve a cached value. Return None on miss or when disabled."""
|
|
47
|
+
if not self.enabled:
|
|
48
|
+
return None
|
|
49
|
+
entry = await self.backend.get(key)
|
|
50
|
+
if entry is None:
|
|
51
|
+
logger.debug("cache_miss", extra={"key": key[:16]})
|
|
52
|
+
return None
|
|
53
|
+
logger.debug("cache_hit", extra={"key": key[:16]})
|
|
54
|
+
return entry.value
|
|
55
|
+
|
|
56
|
+
async def set(
|
|
57
|
+
self,
|
|
58
|
+
key: str,
|
|
59
|
+
value: Any,
|
|
60
|
+
*,
|
|
61
|
+
ttl: int | None = None,
|
|
62
|
+
tags: tuple[str, ...] = (),
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Store a value in the cache."""
|
|
65
|
+
if not self.enabled:
|
|
66
|
+
return
|
|
67
|
+
effective_ttl = ttl if ttl is not None else self.config.ttl_seconds
|
|
68
|
+
entry = CacheEntry(key=key, value=value, tags=tags)
|
|
69
|
+
await self.backend.set(key, entry, effective_ttl)
|
|
70
|
+
logger.debug("cache_set", extra={"key": key[:16], "ttl": effective_ttl})
|
|
71
|
+
|
|
72
|
+
async def delete(self, key: str) -> None:
|
|
73
|
+
"""Remove a single cache entry."""
|
|
74
|
+
await self.backend.delete(key)
|
|
75
|
+
|
|
76
|
+
async def flush(self) -> None:
|
|
77
|
+
"""Remove all cache entries."""
|
|
78
|
+
await self.backend.flush()
|
|
79
|
+
logger.info("cache_flushed")
|
|
80
|
+
|
|
81
|
+
async def stats(self) -> CacheStats:
|
|
82
|
+
"""Return current cache statistics."""
|
|
83
|
+
return await self.backend.stats()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def init_cache(config: CacheConfig | None = None) -> CacheManager:
|
|
87
|
+
"""Initialise the global cache manager singleton."""
|
|
88
|
+
global _MANAGER
|
|
89
|
+
_MANAGER = CacheManager(config)
|
|
90
|
+
return _MANAGER
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_cache_manager() -> CacheManager | None:
|
|
94
|
+
"""Return the global cache manager, or None if not initialised."""
|
|
95
|
+
return _MANAGER
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""Agent memory primitives — scoped namespaces, hierarchical TTL, tenant isolation.
|
|
5
|
+
|
|
6
|
+
W28B-305: Distributed memory on cloud_dog_cache. No new memory package.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import enum
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import Any, Callable, Optional, Sequence
|
|
17
|
+
|
|
18
|
+
from cloud_dog_cache.manager import CacheManager, get_cache_manager
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MemoryScope(enum.Enum):
|
|
22
|
+
"""Hierarchical memory scopes with default TTL ordering.
|
|
23
|
+
|
|
24
|
+
REQUEST < SESSION < USER_PROFILE < GLOBAL
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
REQUEST = "request"
|
|
28
|
+
SESSION = "session"
|
|
29
|
+
USER_PROFILE = "user_profile"
|
|
30
|
+
GLOBAL = "global"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Default TTL per scope (seconds). Callers can override.
|
|
34
|
+
DEFAULT_SCOPE_TTL: dict[MemoryScope, int] = {
|
|
35
|
+
MemoryScope.REQUEST: 300, # 5 minutes
|
|
36
|
+
MemoryScope.SESSION: 3600, # 1 hour
|
|
37
|
+
MemoryScope.USER_PROFILE: 86400, # 24 hours
|
|
38
|
+
MemoryScope.GLOBAL: 604800, # 7 days
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class MemoryNamespace:
|
|
44
|
+
"""A scoped, tenant-isolated namespace for agent memory.
|
|
45
|
+
|
|
46
|
+
Keys are prefixed with ``mem:{tenant}:{scope}:{namespace}:`` to ensure
|
|
47
|
+
isolation across tenants, scopes, and logical namespaces.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
tenant_id: str
|
|
51
|
+
scope: MemoryScope
|
|
52
|
+
namespace: str = ""
|
|
53
|
+
|
|
54
|
+
def _prefix(self) -> str:
|
|
55
|
+
parts = ["mem", self.tenant_id, self.scope.value]
|
|
56
|
+
if self.namespace:
|
|
57
|
+
parts.append(self.namespace)
|
|
58
|
+
return ":".join(parts) + ":"
|
|
59
|
+
|
|
60
|
+
def full_key(self, key: str) -> str:
|
|
61
|
+
"""Return the fully-qualified cache key."""
|
|
62
|
+
return f"{self._prefix()}{key}"
|
|
63
|
+
|
|
64
|
+
def tag(self) -> str:
|
|
65
|
+
"""Return the invalidation tag for this namespace."""
|
|
66
|
+
return self._prefix().rstrip(":")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class MemoryEntry:
|
|
71
|
+
"""A single memory entry with scope metadata."""
|
|
72
|
+
|
|
73
|
+
key: str
|
|
74
|
+
value: Any
|
|
75
|
+
scope: MemoryScope
|
|
76
|
+
tenant_id: str
|
|
77
|
+
namespace: str = ""
|
|
78
|
+
created_at: float = field(default_factory=time.time)
|
|
79
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class MemoryStore:
|
|
83
|
+
"""Agent memory store backed by CacheManager.
|
|
84
|
+
|
|
85
|
+
Provides scoped get/set/list/clear operations with tenant isolation
|
|
86
|
+
and hierarchical TTL defaults.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
tenant_id: str,
|
|
92
|
+
*,
|
|
93
|
+
manager: CacheManager | None = None,
|
|
94
|
+
scope_ttl: dict[MemoryScope, int] | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
self._tenant_id = tenant_id
|
|
97
|
+
self._manager = manager
|
|
98
|
+
self._scope_ttl = dict(DEFAULT_SCOPE_TTL)
|
|
99
|
+
if scope_ttl:
|
|
100
|
+
self._scope_ttl.update(scope_ttl)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def manager(self) -> CacheManager:
|
|
104
|
+
if self._manager is not None:
|
|
105
|
+
return self._manager
|
|
106
|
+
m = get_cache_manager()
|
|
107
|
+
if m is None:
|
|
108
|
+
raise RuntimeError("CacheManager not initialised; call init_cache() first")
|
|
109
|
+
return m
|
|
110
|
+
|
|
111
|
+
def _ns(self, scope: MemoryScope, namespace: str = "") -> MemoryNamespace:
|
|
112
|
+
return MemoryNamespace(
|
|
113
|
+
tenant_id=self._tenant_id,
|
|
114
|
+
scope=scope,
|
|
115
|
+
namespace=namespace,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def _ttl(self, scope: MemoryScope, ttl: int | None = None) -> int:
|
|
119
|
+
if ttl is not None:
|
|
120
|
+
return ttl
|
|
121
|
+
return self._scope_ttl.get(scope, 3600)
|
|
122
|
+
|
|
123
|
+
async def get(
|
|
124
|
+
self,
|
|
125
|
+
key: str,
|
|
126
|
+
scope: MemoryScope = MemoryScope.SESSION,
|
|
127
|
+
namespace: str = "",
|
|
128
|
+
) -> Any | None:
|
|
129
|
+
"""Retrieve a memory value by key and scope."""
|
|
130
|
+
ns = self._ns(scope, namespace)
|
|
131
|
+
return await self.manager.get(ns.full_key(key))
|
|
132
|
+
|
|
133
|
+
async def set(
|
|
134
|
+
self,
|
|
135
|
+
key: str,
|
|
136
|
+
value: Any,
|
|
137
|
+
scope: MemoryScope = MemoryScope.SESSION,
|
|
138
|
+
namespace: str = "",
|
|
139
|
+
ttl: int | None = None,
|
|
140
|
+
metadata: dict[str, Any] | None = None,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Store a memory value with scope-appropriate TTL."""
|
|
143
|
+
ns = self._ns(scope, namespace)
|
|
144
|
+
effective_ttl = self._ttl(scope, ttl)
|
|
145
|
+
# Wrap value with metadata for richer retrieval
|
|
146
|
+
wrapped = {
|
|
147
|
+
"v": value,
|
|
148
|
+
"scope": scope.value,
|
|
149
|
+
"tenant": self._tenant_id,
|
|
150
|
+
"ns": namespace,
|
|
151
|
+
"ts": time.time(),
|
|
152
|
+
}
|
|
153
|
+
if metadata:
|
|
154
|
+
wrapped["meta"] = metadata
|
|
155
|
+
await self.manager.set(
|
|
156
|
+
ns.full_key(key),
|
|
157
|
+
wrapped,
|
|
158
|
+
ttl=effective_ttl,
|
|
159
|
+
tags=(ns.tag(),),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
async def delete(
|
|
163
|
+
self,
|
|
164
|
+
key: str,
|
|
165
|
+
scope: MemoryScope = MemoryScope.SESSION,
|
|
166
|
+
namespace: str = "",
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Remove a single memory entry."""
|
|
169
|
+
ns = self._ns(scope, namespace)
|
|
170
|
+
await self.manager.delete(ns.full_key(key))
|
|
171
|
+
|
|
172
|
+
async def clear_scope(
|
|
173
|
+
self,
|
|
174
|
+
scope: MemoryScope,
|
|
175
|
+
namespace: str = "",
|
|
176
|
+
) -> int:
|
|
177
|
+
"""Clear all entries in a scope/namespace. Returns count removed."""
|
|
178
|
+
ns = self._ns(scope, namespace)
|
|
179
|
+
return await self.manager.backend.flush_by_tag(ns.tag())
|
|
180
|
+
|
|
181
|
+
async def clear_tenant(self) -> int:
|
|
182
|
+
"""Clear all entries for this tenant across all scopes."""
|
|
183
|
+
total = 0
|
|
184
|
+
for scope in MemoryScope:
|
|
185
|
+
total += await self.clear_scope(scope)
|
|
186
|
+
return total
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class VectorSearchAdapter:
|
|
190
|
+
"""Optional adapter for semantic search over memory entries.
|
|
191
|
+
|
|
192
|
+
This is a protocol/interface — concrete implementations connect to
|
|
193
|
+
index-retriever, Qdrant, ChromaDB, etc.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
async def index(self, key: str, text: str, metadata: dict[str, Any] | None = None) -> None:
|
|
197
|
+
"""Index a memory entry for semantic search."""
|
|
198
|
+
raise NotImplementedError
|
|
199
|
+
|
|
200
|
+
async def search(self, query: str, *, top_k: int = 5, filter: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
201
|
+
"""Search memory entries by semantic similarity."""
|
|
202
|
+
raise NotImplementedError
|
|
203
|
+
|
|
204
|
+
async def delete(self, key: str) -> None:
|
|
205
|
+
"""Remove a memory entry from the search index."""
|
|
206
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""Cache entry and configuration models."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class CacheEntry:
|
|
15
|
+
"""A single cached result with metadata."""
|
|
16
|
+
|
|
17
|
+
key: str
|
|
18
|
+
value: Any
|
|
19
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
20
|
+
expires_at: datetime | None = None
|
|
21
|
+
hit_count: int = 0
|
|
22
|
+
tags: tuple[str, ...] = ()
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def expired(self) -> bool:
|
|
26
|
+
"""Return True if this entry has passed its TTL."""
|
|
27
|
+
if self.expires_at is None:
|
|
28
|
+
return False
|
|
29
|
+
return datetime.now(timezone.utc) >= self.expires_at
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class CacheConfig:
|
|
34
|
+
"""Runtime cache configuration."""
|
|
35
|
+
|
|
36
|
+
enabled: bool = True
|
|
37
|
+
backend: str = "memory"
|
|
38
|
+
ttl_seconds: int = 3600
|
|
39
|
+
max_entries: int = 1000
|
|
40
|
+
max_memory_mb: int = 256
|
|
41
|
+
redis_url: str = ""
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
"""Runtime initialisation helper — reads cache config from cloud_dog_config."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from cloud_dog_cache.manager import CacheManager, init_cache
|
|
11
|
+
from cloud_dog_cache.models import CacheConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def init_cache_from_config(config: Any = None) -> CacheManager:
|
|
15
|
+
"""Initialise the global cache manager from cloud_dog_config.
|
|
16
|
+
|
|
17
|
+
Reads ``cache.enabled``, ``cache.backend``, ``cache.ttl_seconds``,
|
|
18
|
+
``cache.max_entries``, ``cache.redis_url`` from the platform config.
|
|
19
|
+
"""
|
|
20
|
+
if config is None:
|
|
21
|
+
try:
|
|
22
|
+
from cloud_dog_config import get_config
|
|
23
|
+
|
|
24
|
+
config = get_config
|
|
25
|
+
except ImportError:
|
|
26
|
+
config = lambda key, default=None: default # noqa: E731
|
|
27
|
+
|
|
28
|
+
def _get(key: str, default: Any = None) -> Any:
|
|
29
|
+
if callable(config):
|
|
30
|
+
return config(key) or default
|
|
31
|
+
return getattr(config, "get", lambda k, d=None: d)(key, default)
|
|
32
|
+
|
|
33
|
+
cache_config = CacheConfig(
|
|
34
|
+
enabled=_to_bool(_get("cache.enabled", True)),
|
|
35
|
+
backend=str(_get("cache.backend", "memory") or "memory"),
|
|
36
|
+
ttl_seconds=_to_int(_get("cache.ttl_seconds", 3600)),
|
|
37
|
+
max_entries=_to_int(_get("cache.max_entries", 1000)),
|
|
38
|
+
redis_url=str(_get("cache.redis_url", "") or ""),
|
|
39
|
+
)
|
|
40
|
+
return init_cache(cache_config)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _to_bool(value: Any) -> bool:
|
|
44
|
+
"""Coerce a config value to bool."""
|
|
45
|
+
if isinstance(value, bool):
|
|
46
|
+
return value
|
|
47
|
+
if value is None:
|
|
48
|
+
return True
|
|
49
|
+
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _to_int(value: Any) -> int:
|
|
53
|
+
"""Coerce a config value to int."""
|
|
54
|
+
try:
|
|
55
|
+
return int(value)
|
|
56
|
+
except (TypeError, ValueError):
|
|
57
|
+
return 0
|