doeff-core-effects 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.
- doeff_core_effects/__init__.py +42 -0
- doeff_core_effects/cache.py +172 -0
- doeff_core_effects/cache_effects.py +116 -0
- doeff_core_effects/cache_handlers.py +222 -0
- doeff_core_effects/cache_policy.py +102 -0
- doeff_core_effects/effects.py +124 -0
- doeff_core_effects/handlers.py +563 -0
- doeff_core_effects/memo_effects.py +105 -0
- doeff_core_effects/memo_handlers.py +331 -0
- doeff_core_effects/memo_policy.py +97 -0
- doeff_core_effects/scheduler.py +640 -0
- doeff_core_effects/storage/__init__.py +80 -0
- doeff_core_effects/storage/memory.py +91 -0
- doeff_core_effects/storage/sqlite.py +166 -0
- doeff_core_effects-0.1.0.dist-info/METADATA +6 -0
- doeff_core_effects-0.1.0.dist-info/RECORD +17 -0
- doeff_core_effects-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
doeff-core-effects — reference implementation of effects and handlers.
|
|
3
|
+
|
|
4
|
+
This package provides:
|
|
5
|
+
- Core effects: Ask, Get, Put, Tell
|
|
6
|
+
- Core handlers: reader, state, writer
|
|
7
|
+
- Scheduler: Spawn, Wait, Gather, Race, Cancel, Promise, ExternalPromise, Semaphore
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from doeff_core_effects.effects import (
|
|
11
|
+
Ask, Get, Put, Tell, Try, Slog, WriterTellEffect,
|
|
12
|
+
Local, Listen, Await, slog,
|
|
13
|
+
)
|
|
14
|
+
from doeff_core_effects.handlers import (
|
|
15
|
+
reader, state, writer, try_handler, slog_handler,
|
|
16
|
+
local_handler, listen_handler, await_handler,
|
|
17
|
+
lazy_ask, env_var_ask,
|
|
18
|
+
)
|
|
19
|
+
from doeff_core_effects.scheduler import (
|
|
20
|
+
Cancel,
|
|
21
|
+
CreateExternalPromise,
|
|
22
|
+
CreatePromise,
|
|
23
|
+
CreateSemaphore,
|
|
24
|
+
AcquireSemaphore,
|
|
25
|
+
ReleaseSemaphore,
|
|
26
|
+
CompletePromise,
|
|
27
|
+
ExternalPromise,
|
|
28
|
+
FailPromise,
|
|
29
|
+
Future,
|
|
30
|
+
Gather,
|
|
31
|
+
PRIORITY_HIGH,
|
|
32
|
+
PRIORITY_IDLE,
|
|
33
|
+
PRIORITY_NORMAL,
|
|
34
|
+
Promise,
|
|
35
|
+
Race,
|
|
36
|
+
Semaphore,
|
|
37
|
+
Spawn,
|
|
38
|
+
Task,
|
|
39
|
+
TaskCancelledError,
|
|
40
|
+
Wait,
|
|
41
|
+
scheduled,
|
|
42
|
+
)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Cache decorator for doeff programs using cache effects.
|
|
2
|
+
|
|
3
|
+
The @cache decorator memoizes @do function results via CacheGet/CachePut effects.
|
|
4
|
+
Requires a cache handler (cache_handler, in_memory_cache_handler, or sqlite_cache_handler)
|
|
5
|
+
to be installed via WithHandler.
|
|
6
|
+
|
|
7
|
+
NOTE: The full @cache decorator with execution context-based call site attribution
|
|
8
|
+
is not yet ported. This module provides the core cache helpers and a simplified
|
|
9
|
+
@cache decorator.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import tempfile
|
|
14
|
+
from collections.abc import Callable, Mapping
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, TypeVar
|
|
17
|
+
|
|
18
|
+
from frozendict import frozendict as FrozenDict
|
|
19
|
+
|
|
20
|
+
from doeff import UnhandledEffect, do
|
|
21
|
+
from doeff_core_effects.memo_effects import MemoExists, MemoGet, MemoPut
|
|
22
|
+
from doeff_core_effects.memo_policy import Lifecycle, MemoPolicy, ensure_memo_policy
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T")
|
|
25
|
+
|
|
26
|
+
CACHE_PATH_ENV_KEY = "DOEFF_CACHE_PATH"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def persistent_cache_path() -> Path:
|
|
30
|
+
"""Return the persistent cache path from env or default."""
|
|
31
|
+
env_path = os.environ.get(CACHE_PATH_ENV_KEY)
|
|
32
|
+
if env_path:
|
|
33
|
+
return Path(env_path)
|
|
34
|
+
return Path(tempfile.gettempdir()) / "doeff_cache"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def clear_persistent_cache() -> None:
|
|
38
|
+
"""Clear the persistent cache directory."""
|
|
39
|
+
path = persistent_cache_path()
|
|
40
|
+
if path.exists():
|
|
41
|
+
import shutil
|
|
42
|
+
shutil.rmtree(path)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _function_identifier(target: Any) -> str:
|
|
46
|
+
"""Return a descriptive identifier for a callable (module.qualname)."""
|
|
47
|
+
module = getattr(target, "__module__", None)
|
|
48
|
+
qualname = getattr(target, "__qualname__", None)
|
|
49
|
+
name = getattr(target, "__name__", None)
|
|
50
|
+
|
|
51
|
+
parts: list[str] = []
|
|
52
|
+
if module:
|
|
53
|
+
parts.append(module)
|
|
54
|
+
if qualname:
|
|
55
|
+
parts.append(qualname)
|
|
56
|
+
elif name:
|
|
57
|
+
parts.append(name)
|
|
58
|
+
|
|
59
|
+
if parts:
|
|
60
|
+
return ".".join(parts)
|
|
61
|
+
|
|
62
|
+
func_attr = getattr(target, "func", None)
|
|
63
|
+
if func_attr is not None and func_attr is not target:
|
|
64
|
+
inner = _function_identifier(func_attr)
|
|
65
|
+
cls = target.__class__
|
|
66
|
+
return f"{cls.__module__}.{cls.__qualname__}({inner})"
|
|
67
|
+
|
|
68
|
+
cls = target.__class__
|
|
69
|
+
return f"{cls.__module__}.{cls.__qualname__}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def default_cache_key(func_name: str, args: tuple, kwargs: FrozenDict) -> tuple:
|
|
73
|
+
"""Default cache key: (func_name, args, FrozenDict(kwargs))."""
|
|
74
|
+
return (func_name, args, kwargs)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cache(
|
|
78
|
+
ttl: float | None = None,
|
|
79
|
+
key_func: Callable | None = None,
|
|
80
|
+
*,
|
|
81
|
+
lifecycle: Lifecycle | str | None = None,
|
|
82
|
+
metadata: Mapping[str, Any] | None = None,
|
|
83
|
+
policy: MemoPolicy | Mapping[str, Any] | None = None,
|
|
84
|
+
) -> Callable:
|
|
85
|
+
"""Cache decorator that uses MemoGet/MemoPut effects for memoization.
|
|
86
|
+
|
|
87
|
+
The cache key defaults to ``(func_name, args, FrozenDict(kwargs))`` where
|
|
88
|
+
``func_name`` is the fully qualified module path of the wrapped callable.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
ttl: Expiry in seconds. None means no expiry.
|
|
92
|
+
key_func: Optional key builder. Receives (func_name, args, FrozenDict(kwargs)).
|
|
93
|
+
lifecycle: Lifecycle hint for custom handlers.
|
|
94
|
+
metadata: Extra metadata for custom handlers.
|
|
95
|
+
policy: Full MemoPolicy (overrides ttl/lifecycle/metadata).
|
|
96
|
+
"""
|
|
97
|
+
memo_policy = ensure_memo_policy(
|
|
98
|
+
ttl=ttl,
|
|
99
|
+
lifecycle=lifecycle,
|
|
100
|
+
metadata=metadata,
|
|
101
|
+
policy=policy,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def decorator(func: Callable) -> Callable:
|
|
105
|
+
func_name = _function_identifier(func)
|
|
106
|
+
|
|
107
|
+
@do
|
|
108
|
+
def cached_fn(*args, **kwargs):
|
|
109
|
+
frozen_kwargs = FrozenDict(kwargs) if kwargs else FrozenDict()
|
|
110
|
+
cache_key_obj = (
|
|
111
|
+
key_func(func_name, args, frozen_kwargs)
|
|
112
|
+
if key_func
|
|
113
|
+
else (func_name, args, frozen_kwargs)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Memo handler is optional. If absent (UnhandledEffect on MemoExists/Get)
|
|
117
|
+
# or storage misses (KeyError), fall through to compute. The
|
|
118
|
+
# yield func(...) below is NOT wrapped: its failures must propagate.
|
|
119
|
+
try:
|
|
120
|
+
if (yield MemoExists(cache_key_obj)):
|
|
121
|
+
try:
|
|
122
|
+
cached_value = yield MemoGet(cache_key_obj)
|
|
123
|
+
return cached_value
|
|
124
|
+
except KeyError:
|
|
125
|
+
pass
|
|
126
|
+
except UnhandledEffect:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
# Memo miss — run the function
|
|
130
|
+
result = yield func(*args, **kwargs)
|
|
131
|
+
|
|
132
|
+
# Best-effort store: if no memo handler is installed, swallow. The
|
|
133
|
+
# compute result returned above is authoritative.
|
|
134
|
+
try:
|
|
135
|
+
yield MemoPut(cache_key_obj, result, policy=memo_policy)
|
|
136
|
+
except UnhandledEffect:
|
|
137
|
+
pass
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
# Preserve metadata
|
|
141
|
+
for attr in ("__name__", "__qualname__", "__doc__", "__module__"):
|
|
142
|
+
val = getattr(func, attr, None)
|
|
143
|
+
if val is not None:
|
|
144
|
+
try:
|
|
145
|
+
setattr(cached_fn, attr, val)
|
|
146
|
+
except (AttributeError, TypeError):
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
return cached_fn
|
|
150
|
+
|
|
151
|
+
return decorator
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# Convenience presets
|
|
155
|
+
cache_1min = cache(ttl=60)
|
|
156
|
+
cache_5min = cache(ttl=300)
|
|
157
|
+
cache_1hour = cache(ttl=3600)
|
|
158
|
+
cache_forever = cache(ttl=None)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
__all__ = [
|
|
162
|
+
"CACHE_PATH_ENV_KEY",
|
|
163
|
+
"FrozenDict",
|
|
164
|
+
"cache",
|
|
165
|
+
"cache_1hour",
|
|
166
|
+
"cache_1min",
|
|
167
|
+
"cache_5min",
|
|
168
|
+
"cache_forever",
|
|
169
|
+
"clear_persistent_cache",
|
|
170
|
+
"default_cache_key",
|
|
171
|
+
"persistent_cache_path",
|
|
172
|
+
]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Cache effects for memoization."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from doeff_core_effects.cache_policy import (
|
|
7
|
+
CacheLifecycle,
|
|
8
|
+
CachePolicy,
|
|
9
|
+
CacheStorage,
|
|
10
|
+
ensure_cache_policy,
|
|
11
|
+
)
|
|
12
|
+
from doeff_vm import EffectBase
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CacheGetEffect(EffectBase):
|
|
16
|
+
"""Requests the cached value for the key."""
|
|
17
|
+
def __init__(self, key):
|
|
18
|
+
super().__init__()
|
|
19
|
+
self.key = key
|
|
20
|
+
|
|
21
|
+
def __repr__(self):
|
|
22
|
+
return f"CacheGet({self.key!r})"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CachePutEffect(EffectBase):
|
|
26
|
+
"""Persists the value under the key."""
|
|
27
|
+
def __init__(self, key, value, policy):
|
|
28
|
+
super().__init__()
|
|
29
|
+
if not isinstance(policy, CachePolicy):
|
|
30
|
+
raise TypeError(f"policy must be CachePolicy, got {type(policy).__name__}")
|
|
31
|
+
self.key = key
|
|
32
|
+
self.value = value
|
|
33
|
+
self.policy = policy
|
|
34
|
+
|
|
35
|
+
def __repr__(self):
|
|
36
|
+
return f"CachePut({self.key!r}, ...)"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CacheDeleteEffect(EffectBase):
|
|
40
|
+
"""Deletes the value under the key."""
|
|
41
|
+
def __init__(self, key):
|
|
42
|
+
super().__init__()
|
|
43
|
+
self.key = key
|
|
44
|
+
|
|
45
|
+
def __repr__(self):
|
|
46
|
+
return f"CacheDelete({self.key!r})"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CacheExistsEffect(EffectBase):
|
|
50
|
+
"""Checks if a key exists in the cache."""
|
|
51
|
+
def __init__(self, key):
|
|
52
|
+
super().__init__()
|
|
53
|
+
self.key = key
|
|
54
|
+
|
|
55
|
+
def __repr__(self):
|
|
56
|
+
return f"CacheExists({self.key!r})"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Convenience constructors (lowercase)
|
|
60
|
+
|
|
61
|
+
def cache_get(key: Any) -> CacheGetEffect:
|
|
62
|
+
return CacheGetEffect(key=key)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cache_put(
|
|
66
|
+
key: Any,
|
|
67
|
+
value: Any,
|
|
68
|
+
ttl: float | None = None,
|
|
69
|
+
*,
|
|
70
|
+
lifecycle: CacheLifecycle | str | None = None,
|
|
71
|
+
storage: CacheStorage | str | None = None,
|
|
72
|
+
metadata: Mapping[str, Any] | None = None,
|
|
73
|
+
policy: CachePolicy | Mapping[str, Any] | None = None,
|
|
74
|
+
) -> CachePutEffect:
|
|
75
|
+
cache_policy = ensure_cache_policy(
|
|
76
|
+
ttl=ttl,
|
|
77
|
+
lifecycle=lifecycle,
|
|
78
|
+
storage=storage,
|
|
79
|
+
metadata=metadata,
|
|
80
|
+
policy=policy,
|
|
81
|
+
)
|
|
82
|
+
return CachePutEffect(key=key, value=value, policy=cache_policy)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cache_delete(key: Any) -> CacheDeleteEffect:
|
|
86
|
+
return CacheDeleteEffect(key=key)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cache_exists(key: Any) -> CacheExistsEffect:
|
|
90
|
+
return CacheExistsEffect(key=key)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Capitalized aliases
|
|
94
|
+
CacheGet = cache_get
|
|
95
|
+
CachePut = cache_put
|
|
96
|
+
CacheDelete = cache_delete
|
|
97
|
+
CacheExists = cache_exists
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = [
|
|
101
|
+
"CacheDelete",
|
|
102
|
+
"CacheDeleteEffect",
|
|
103
|
+
"CacheExists",
|
|
104
|
+
"CacheExistsEffect",
|
|
105
|
+
"CacheGet",
|
|
106
|
+
"CacheGetEffect",
|
|
107
|
+
"CacheLifecycle",
|
|
108
|
+
"CachePolicy",
|
|
109
|
+
"CachePut",
|
|
110
|
+
"CachePutEffect",
|
|
111
|
+
"CacheStorage",
|
|
112
|
+
"cache_delete",
|
|
113
|
+
"cache_exists",
|
|
114
|
+
"cache_get",
|
|
115
|
+
"cache_put",
|
|
116
|
+
]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Cache effect handlers and memoization helpers."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Callable, Mapping, Set
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, TypeAlias
|
|
9
|
+
|
|
10
|
+
from doeff import do
|
|
11
|
+
from doeff.program import Resume, Pass
|
|
12
|
+
|
|
13
|
+
from doeff_core_effects.cache_effects import (
|
|
14
|
+
CacheExistsEffect,
|
|
15
|
+
CacheGetEffect,
|
|
16
|
+
CachePutEffect,
|
|
17
|
+
CacheGet,
|
|
18
|
+
CachePut,
|
|
19
|
+
CacheExists,
|
|
20
|
+
)
|
|
21
|
+
from doeff_core_effects.effects import Try
|
|
22
|
+
from doeff_core_effects.storage import DurableStorage, InMemoryStorage, SQLiteStorage
|
|
23
|
+
|
|
24
|
+
MemoKeyFn: TypeAlias = Callable[[object], str]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _dumps(value: object) -> bytes:
|
|
28
|
+
try:
|
|
29
|
+
import cloudpickle as serializer
|
|
30
|
+
except ModuleNotFoundError:
|
|
31
|
+
import pickle as serializer
|
|
32
|
+
|
|
33
|
+
return serializer.dumps(value)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _normalize_for_hash(value: object) -> object:
|
|
37
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
38
|
+
return {
|
|
39
|
+
"__type__": f"{type(value).__module__}.{type(value).__qualname__}",
|
|
40
|
+
**{
|
|
41
|
+
field.name: _normalize_for_hash(getattr(value, field.name))
|
|
42
|
+
for field in dataclasses.fields(value)
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
elif isinstance(value, Mapping):
|
|
46
|
+
return {
|
|
47
|
+
str(key): _normalize_for_hash(item)
|
|
48
|
+
for key, item in sorted(value.items(), key=lambda item: str(item[0]))
|
|
49
|
+
}
|
|
50
|
+
elif isinstance(value, tuple):
|
|
51
|
+
return {"__tuple__": [_normalize_for_hash(item) for item in value]}
|
|
52
|
+
elif isinstance(value, list):
|
|
53
|
+
return [_normalize_for_hash(item) for item in value]
|
|
54
|
+
elif isinstance(value, Set) and not isinstance(value, (str, bytes, bytearray)):
|
|
55
|
+
normalized = [_normalize_for_hash(item) for item in value]
|
|
56
|
+
return {
|
|
57
|
+
"__set__": sorted(normalized, key=lambda item: json.dumps(item, sort_keys=True))
|
|
58
|
+
}
|
|
59
|
+
elif isinstance(value, Path):
|
|
60
|
+
return {"__path__": str(value)}
|
|
61
|
+
elif isinstance(value, bytes):
|
|
62
|
+
return {"__bytes__": value.hex()}
|
|
63
|
+
elif isinstance(value, type):
|
|
64
|
+
return f"{value.__module__}.{value.__qualname__}"
|
|
65
|
+
elif hasattr(value, "__dict__") and not isinstance(value, type):
|
|
66
|
+
# Generic object with __dict__ (e.g. EffectBase subclasses)
|
|
67
|
+
return {
|
|
68
|
+
"__type__": f"{type(value).__module__}.{type(value).__qualname__}",
|
|
69
|
+
**{
|
|
70
|
+
k: _normalize_for_hash(v)
|
|
71
|
+
for k, v in sorted(value.__dict__.items())
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _storage_key(key: object) -> str:
|
|
78
|
+
if isinstance(key, str):
|
|
79
|
+
return key
|
|
80
|
+
return f"sha256:{content_address(key)}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _persist_value(
|
|
84
|
+
storage: DurableStorage,
|
|
85
|
+
storage_key: str,
|
|
86
|
+
*,
|
|
87
|
+
original_key: object,
|
|
88
|
+
value: object,
|
|
89
|
+
) -> None:
|
|
90
|
+
try:
|
|
91
|
+
storage.put(storage_key, value)
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
raise RuntimeError(
|
|
94
|
+
f"Cache handler could not persist value for key {original_key!r}: {exc}"
|
|
95
|
+
) from exc
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def content_address(effect: object) -> str:
|
|
99
|
+
"""Return a SHA-256 content address for an effect payload."""
|
|
100
|
+
try:
|
|
101
|
+
payload = _dumps(effect)
|
|
102
|
+
except Exception:
|
|
103
|
+
payload = json.dumps(_normalize_for_hash(effect), sort_keys=True, default=repr).encode(
|
|
104
|
+
"utf-8"
|
|
105
|
+
)
|
|
106
|
+
return hashlib.sha256(payload).hexdigest()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cache_handler(storage: DurableStorage):
|
|
110
|
+
"""Interpret CacheGet/CachePut/CacheExists against a pluggable storage backend.
|
|
111
|
+
|
|
112
|
+
Storage methods return Programs (DoExpr). The handler yields them to get values.
|
|
113
|
+
This makes the handler transparent to sync (Pure) and async (Await) storage alike.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
@do
|
|
117
|
+
def handler(effect, k):
|
|
118
|
+
if not isinstance(effect, (CacheGetEffect, CacheExistsEffect, CachePutEffect)):
|
|
119
|
+
yield Pass(effect, k)
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
key = _storage_key(effect.key)
|
|
123
|
+
|
|
124
|
+
if isinstance(effect, CacheExistsEffect):
|
|
125
|
+
exists = yield storage.exists(key)
|
|
126
|
+
result = yield Resume(k, exists)
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
if isinstance(effect, CacheGetEffect):
|
|
130
|
+
value = yield storage.get(key)
|
|
131
|
+
if value is None:
|
|
132
|
+
exists = yield storage.exists(key)
|
|
133
|
+
if not exists:
|
|
134
|
+
from doeff.program import ResumeThrow
|
|
135
|
+
return (yield ResumeThrow(k, KeyError(effect.key)))
|
|
136
|
+
result = yield Resume(k, value)
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
# CachePutEffect
|
|
140
|
+
yield storage.put(key, effect.value)
|
|
141
|
+
result = yield Resume(k, None)
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
return handler
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def in_memory_cache_handler():
|
|
148
|
+
"""Return a cache handler backed by in-memory storage."""
|
|
149
|
+
return cache_handler(InMemoryStorage())
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def sqlite_cache_handler(db_path: str | Path):
|
|
153
|
+
"""Return a cache handler backed by SQLite storage."""
|
|
154
|
+
return cache_handler(SQLiteStorage(db_path))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def make_memo_rewriter(
|
|
158
|
+
effect_type: type,
|
|
159
|
+
key_fn: MemoKeyFn = content_address,
|
|
160
|
+
):
|
|
161
|
+
"""Create a handler that memoizes effects of the given type through cache.
|
|
162
|
+
|
|
163
|
+
On cache hit: Resume with cached value (outer handler not called).
|
|
164
|
+
On cache miss: re-perform effect → outer handler handles it → store result in cache.
|
|
165
|
+
"""
|
|
166
|
+
from doeff_core_effects.effects import WriterTellEffect as Slog
|
|
167
|
+
|
|
168
|
+
@do
|
|
169
|
+
def handler(effect, k):
|
|
170
|
+
if not isinstance(effect, effect_type):
|
|
171
|
+
yield Pass(effect, k)
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
key = key_fn(effect)
|
|
175
|
+
yield Slog(f"[memo] checking {effect_type.__name__} key={key[:16]}…")
|
|
176
|
+
|
|
177
|
+
# Check cache — narrow try/except to CacheGet only.
|
|
178
|
+
# Resume(k) must NOT be inside try/except: if the resumed body raises
|
|
179
|
+
# KeyError, it must propagate, not fall through to the MISS path.
|
|
180
|
+
_MISS = object() # sentinel
|
|
181
|
+
if (yield CacheExists(key)):
|
|
182
|
+
try:
|
|
183
|
+
cached = yield CacheGet(key)
|
|
184
|
+
except KeyError:
|
|
185
|
+
cached = _MISS # race: evicted between Exists and Get
|
|
186
|
+
|
|
187
|
+
if cached is not _MISS:
|
|
188
|
+
yield Slog(f"[memo] HIT {effect_type.__name__} key={key[:16]}…")
|
|
189
|
+
result = yield Resume(k, cached)
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
# Cache miss — "delegate" by re-performing the effect.
|
|
193
|
+
yield Slog(f"[memo] MISS {effect_type.__name__} key={key[:16]}… → delegating")
|
|
194
|
+
delegated = yield effect
|
|
195
|
+
|
|
196
|
+
# Store in cache
|
|
197
|
+
yield CachePut(key, delegated)
|
|
198
|
+
yield Slog(f"[memo] STORED {effect_type.__name__} key={key[:16]}…")
|
|
199
|
+
|
|
200
|
+
# Resume body with result
|
|
201
|
+
result = yield Resume(k, delegated)
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
return handler
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def memo_rewriters(
|
|
208
|
+
*effect_types: type,
|
|
209
|
+
key_fn: MemoKeyFn = content_address,
|
|
210
|
+
) -> list:
|
|
211
|
+
"""Create memo rewriter handlers for each provided effect type."""
|
|
212
|
+
return [make_memo_rewriter(et, key_fn=key_fn) for et in effect_types]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
__all__ = [
|
|
216
|
+
"cache_handler",
|
|
217
|
+
"content_address",
|
|
218
|
+
"in_memory_cache_handler",
|
|
219
|
+
"make_memo_rewriter",
|
|
220
|
+
"memo_rewriters",
|
|
221
|
+
"sqlite_cache_handler",
|
|
222
|
+
]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Cache policy types shared between cache effects and handlers."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CacheLifecycle(str, Enum):
|
|
11
|
+
"""Lifecycle hint describing how long cached data should live."""
|
|
12
|
+
|
|
13
|
+
TRANSIENT = "transient"
|
|
14
|
+
SESSION = "session"
|
|
15
|
+
PERSISTENT = "persistent"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CacheStorage(str, Enum):
|
|
19
|
+
"""Preferred storage target for cached entries."""
|
|
20
|
+
|
|
21
|
+
MEMORY = "memory"
|
|
22
|
+
DISK = "disk"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class CachePolicy:
|
|
27
|
+
"""Policy metadata attached to cache entries."""
|
|
28
|
+
|
|
29
|
+
ttl: float | None = None
|
|
30
|
+
lifecycle: CacheLifecycle = CacheLifecycle.TRANSIENT
|
|
31
|
+
storage: CacheStorage | None = None
|
|
32
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
def resolved_storage(self) -> CacheStorage:
|
|
35
|
+
"""Determine the effective storage based on hints."""
|
|
36
|
+
if self.storage is not None:
|
|
37
|
+
return self.storage
|
|
38
|
+
if self.lifecycle is CacheLifecycle.PERSISTENT:
|
|
39
|
+
return CacheStorage.DISK
|
|
40
|
+
return CacheStorage.MEMORY
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ensure_cache_policy(
|
|
44
|
+
*,
|
|
45
|
+
ttl: float | None = None,
|
|
46
|
+
lifecycle: CacheLifecycle | str | None = None,
|
|
47
|
+
storage: CacheStorage | str | None = None,
|
|
48
|
+
metadata: Mapping[str, Any] | None = None,
|
|
49
|
+
policy: CachePolicy | Mapping[str, Any] | None = None,
|
|
50
|
+
) -> CachePolicy:
|
|
51
|
+
"""Normalize parameters into a CachePolicy instance."""
|
|
52
|
+
|
|
53
|
+
if policy is not None:
|
|
54
|
+
if any(param is not None for param in (ttl, lifecycle, storage, metadata)):
|
|
55
|
+
raise ValueError("When policy is provided, other cache policy parameters must be omitted")
|
|
56
|
+
if isinstance(policy, CachePolicy):
|
|
57
|
+
return policy
|
|
58
|
+
if isinstance(policy, Mapping):
|
|
59
|
+
return ensure_cache_policy(**policy) # type: ignore[misc]
|
|
60
|
+
raise TypeError("policy must be CachePolicy or mapping")
|
|
61
|
+
|
|
62
|
+
lifecycle_value = _normalize_lifecycle(lifecycle)
|
|
63
|
+
storage_value = _normalize_storage(storage)
|
|
64
|
+
metadata_dict: Mapping[str, Any]
|
|
65
|
+
if metadata is None:
|
|
66
|
+
metadata_dict = {}
|
|
67
|
+
elif isinstance(metadata, Mapping):
|
|
68
|
+
metadata_dict = dict(metadata)
|
|
69
|
+
else:
|
|
70
|
+
raise TypeError("metadata must be mapping if provided")
|
|
71
|
+
|
|
72
|
+
return CachePolicy(
|
|
73
|
+
ttl=ttl,
|
|
74
|
+
lifecycle=lifecycle_value,
|
|
75
|
+
storage=storage_value,
|
|
76
|
+
metadata=metadata_dict,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _normalize_lifecycle(value: CacheLifecycle | str | None) -> CacheLifecycle:
|
|
81
|
+
if value is None:
|
|
82
|
+
return CacheLifecycle.TRANSIENT
|
|
83
|
+
if isinstance(value, CacheLifecycle):
|
|
84
|
+
return value
|
|
85
|
+
return CacheLifecycle(value)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _normalize_storage(value: CacheStorage | str | None) -> CacheStorage | None:
|
|
89
|
+
if value is None:
|
|
90
|
+
return None
|
|
91
|
+
if isinstance(value, CacheStorage):
|
|
92
|
+
return value
|
|
93
|
+
return CacheStorage(value)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
__all__ = [
|
|
98
|
+
"CacheLifecycle",
|
|
99
|
+
"CachePolicy",
|
|
100
|
+
"CacheStorage",
|
|
101
|
+
"ensure_cache_policy",
|
|
102
|
+
]
|