dialcache 0.25.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dialcache-0.25.0/.gitignore +25 -0
- dialcache-0.25.0/API-DESIGN.md +28 -0
- dialcache-0.25.0/LICENSE +21 -0
- dialcache-0.25.0/PKG-INFO +256 -0
- dialcache-0.25.0/README.md +229 -0
- dialcache-0.25.0/dialcache/__init__.py +38 -0
- dialcache-0.25.0/dialcache/cache.py +1185 -0
- dialcache-0.25.0/dialcache/clock.py +53 -0
- dialcache-0.25.0/dialcache/config.py +296 -0
- dialcache-0.25.0/dialcache/context.py +133 -0
- dialcache-0.25.0/dialcache/errors.py +46 -0
- dialcache-0.25.0/dialcache/key.py +149 -0
- dialcache-0.25.0/dialcache/local.py +77 -0
- dialcache-0.25.0/dialcache/metrics.py +39 -0
- dialcache-0.25.0/dialcache/protocol.py +301 -0
- dialcache-0.25.0/dialcache/py.typed +0 -0
- dialcache-0.25.0/dialcache/redis.py +213 -0
- dialcache-0.25.0/dialcache/serializer.py +55 -0
- dialcache-0.25.0/pyproject.toml +54 -0
- dialcache-0.25.0/tests/formal/__init__.py +1 -0
- dialcache-0.25.0/tests/formal/coordinator.py +123 -0
- dialcache-0.25.0/tests/formal/driver.py +540 -0
- dialcache-0.25.0/tests/formal/executor.py +140 -0
- dialcache-0.25.0/tests/formal/scenarios.py +42 -0
- dialcache-0.25.0/tests/formal/schema.py +144 -0
- dialcache-0.25.0/tests/formal/simple_drivers.py +170 -0
- dialcache-0.25.0/tests/formal/witness.py +109 -0
- dialcache-0.25.0/tests/run_conformance.py +309 -0
- dialcache-0.25.0/tests/run_integration.py +75 -0
- dialcache-0.25.0/tests/test_cache.py +456 -0
- dialcache-0.25.0/tests/test_callback_cancellation.py +509 -0
- dialcache-0.25.0/tests/test_cluster_read_routing.py +174 -0
- dialcache-0.25.0/tests/test_config.py +142 -0
- dialcache-0.25.0/tests/test_conformance.py +195 -0
- dialcache-0.25.0/tests/test_context.py +81 -0
- dialcache-0.25.0/tests/test_dependency_cancellation.py +352 -0
- dialcache-0.25.0/tests/test_docs_examples.py +122 -0
- dialcache-0.25.0/tests/test_engine_review.py +87 -0
- dialcache-0.25.0/tests/test_integration_acceptance.py +153 -0
- dialcache-0.25.0/tests/test_local.py +64 -0
- dialcache-0.25.0/tests/test_observer_recovery_boundaries.py +210 -0
- dialcache-0.25.0/tests/test_protocol_native.py +298 -0
- dialcache-0.25.0/tests/test_protocol_vectors.py +226 -0
- dialcache-0.25.0/tests/test_redis_adapter.py +123 -0
- dialcache-0.25.0/tests/test_redis_integration.py +202 -0
- dialcache-0.25.0/tests/test_review_regressions.py +506 -0
- dialcache-0.25.0/tests/test_shadow_deadline_retention.py +51 -0
- dialcache-0.25.0/tests/test_validation_environment.py +132 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
coverage/
|
|
4
|
+
/go/coverage-*.out
|
|
5
|
+
.formal-traces/
|
|
6
|
+
docs/.vitepress/cache/
|
|
7
|
+
docs/public/reference/
|
|
8
|
+
docs/generated/
|
|
9
|
+
*.tgz
|
|
10
|
+
.env
|
|
11
|
+
.env.*
|
|
12
|
+
!.env.example
|
|
13
|
+
.DS_Store
|
|
14
|
+
.idea/
|
|
15
|
+
.vscode/
|
|
16
|
+
*.log
|
|
17
|
+
rust/target/
|
|
18
|
+
python/.venv/
|
|
19
|
+
__pycache__/
|
|
20
|
+
*.py[cod]
|
|
21
|
+
.pytest_cache/
|
|
22
|
+
.ruff_cache/
|
|
23
|
+
*.egg-info/
|
|
24
|
+
python/build/
|
|
25
|
+
python/dist/
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Python API design and gcache lineage
|
|
2
|
+
|
|
3
|
+
The Python binding was designed after reviewing [rungalileo/gcache at a688049](https://github.com/rungalileo/gcache/tree/a68804986782cb0b7e8b7dc6bfc34c718c177189), including `src/gcache/gcache.py`, `config.py`, `proto_serializer.py`, and the context, layer wrappers, local cache, Redis cache, and event-loop thread implementations under `_internal/`.
|
|
4
|
+
|
|
5
|
+
DialCache's [Quint specification](../formal/SPEC.md) and TypeScript implementation define portable behavior. Gcache supplies useful Python interface ideas; its implementation is not a compatible DialCache backend.
|
|
6
|
+
|
|
7
|
+
| Gcache interface or behavior | Python DialCache decision |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `with cache.enable(enabled=True)` | Retained, with per-instance context variables and explicit outer-scope lifetime. Also supports `async with` and `disable()`. |
|
|
10
|
+
| `@cache.cached(key_type=..., id_arg=...)` | Retained. `id_arg` can be a parameter name or `(name, adapter)` pair. Signature binding includes defaults. |
|
|
11
|
+
| `arg_adapters`, `ignore_args`, inferred use case | Retained; inferred names include module and qualified function name. Argument names use the portable UTF-16 ordering and scalar normalization. |
|
|
12
|
+
| Direct `aget(key, fallback)` | Retained as a structured-key convenience; `get_or_load` is the primary inline-loader API. |
|
|
13
|
+
| `ainvalidate` | Retained as an alias for `invalidate_remote`; missing Redis and failed mutations raise. |
|
|
14
|
+
| `GCacheKeyConfig` and per-use-case provider | `Policy` / `DialCacheKeyConfig` use sparse per-leaf inheritance and deterministic per-key cohorts. `Policy.enabled(ttl_sec)` is available. |
|
|
15
|
+
| Async `Serializer.dump/load` | Retained; synchronous implementations are also accepted. Default JSON supports the portable top-level `UNDEFINED` value. |
|
|
16
|
+
| Synchronous wrapper and background event-loop pool | The binding is asyncio based. Every cached wrapper is awaitable; synchronous loaders execute on the caller's event loop. One cache belongs to one event loop. No implicit threads or client factories are created. |
|
|
17
|
+
| Singleton, global namespace, global metrics | Instances own their namespace, request scopes, local capacity, flights and observer. The application owns its Redis client. |
|
|
18
|
+
| Pickle / JSON / protobuf envelope choice | DialCache always uses the portable version-1 frame and compression wrapper. A custom serializer can produce text or binary payloads, including protobuf. There is no pickle fallback or gcache envelope compatibility. |
|
|
19
|
+
| `aput`, `adelete`, `aflushall` and their synchronous counterparts | These are not part of the existing DialCache public contract and are not added by this port. Tracked invalidation is the explicit maintenance API. |
|
|
20
|
+
| Random sampling and per-use-case local TTL cache | Replaced by DialCache's deterministic key cohorts and a bounded per-instance LRU, with expiry captured at each insertion. |
|
|
21
|
+
|
|
22
|
+
Disabled calls bypass key selection, argument adaptation, policy resolution, deadlines and coalescing. Redis reads acquire the value and watermark atomically from a primary; writes use one native `SET` of a complete frame. None of these rules are inherited from gcache's implementation.
|
|
23
|
+
|
|
24
|
+
Method decorators omit `self` from inferred key arguments by default. An explicit
|
|
25
|
+
`arg_adapters={"self": ...}` includes the adapted instance identity, for example
|
|
26
|
+
its tenant ID. `ignore_args` takes precedence over argument adapters.
|
|
27
|
+
|
|
28
|
+
The Python API is a binding of the existing behavior, not a migration that reads existing gcache keys or envelopes. Applications sharing entries across ports must use the same namespace, entity identity, use case, argument order and payload schema.
|
dialcache-0.25.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Galileo Technologies Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dialcache
|
|
3
|
+
Version: 0.25.0
|
|
4
|
+
Summary: Explicitly enabled async caching with runtime policies, coalescing, and tracked Redis invalidation.
|
|
5
|
+
Project-URL: Repository, https://github.com/lan17/DialCache
|
|
6
|
+
Project-URL: Documentation, https://github.com/lan17/DialCache/tree/main/python
|
|
7
|
+
Author: Lev Neiman
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Requires-Dist: zstandard<1,>=0.23
|
|
19
|
+
Provides-Extra: redis
|
|
20
|
+
Requires-Dist: redis<7,>=5; extra == 'redis'
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: coverage<8,>=7.11; extra == 'test'
|
|
23
|
+
Requires-Dist: jsonschema<5,>=4.23; extra == 'test'
|
|
24
|
+
Requires-Dist: pytest-asyncio<2,>=0.24; extra == 'test'
|
|
25
|
+
Requires-Dist: pytest<10,>=8; extra == 'test'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# DialCache for Python
|
|
29
|
+
|
|
30
|
+
An asyncio port of DialCache for Python 3.11 and later. Each use case declares
|
|
31
|
+
its identity and policy; an enabled request scope opts into request memoization,
|
|
32
|
+
local storage, Redis, and concurrent request sharing. The behavioral contract is
|
|
33
|
+
the repository's [portable specification](https://github.com/lan17/DialCache/blob/main/formal/SPEC.md).
|
|
34
|
+
|
|
35
|
+
Once the first PyPI release is available, install it with:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
python3 -m pip install 'dialcache[redis]'
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Until that release is published, install from the root of a repository checkout:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
python3 -m pip install './python[redis]'
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
For local-only caching, omit the `redis` extra. Zstandard is included for the
|
|
48
|
+
portable Redis payload format. The application owns the asyncio event loop and
|
|
49
|
+
any Redis client connections.
|
|
50
|
+
|
|
51
|
+
## A cached function
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from dialcache import DialCache, Policy
|
|
55
|
+
|
|
56
|
+
cache = DialCache(namespace="my-service")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@cache.cached(
|
|
60
|
+
use_case="user-profile",
|
|
61
|
+
key_type="user",
|
|
62
|
+
id_arg="user_id",
|
|
63
|
+
default_config=Policy(ttl_sec={"local": 5}, request_local=True),
|
|
64
|
+
)
|
|
65
|
+
async def get_profile(user_id: str) -> dict:
|
|
66
|
+
return await database.fetch_profile(user_id)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def handle_request(user_id: str) -> dict:
|
|
70
|
+
async with cache.enable():
|
|
71
|
+
first = await get_profile(user_id)
|
|
72
|
+
second = await get_profile(user_id) # Same request memo.
|
|
73
|
+
return second
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Calls outside `enable()` go directly to the source. They do not construct cache
|
|
77
|
+
keys, resolve policy, share concurrent work, or apply a DialCache source
|
|
78
|
+
deadline. Both `with cache.enable():` and `async with cache.enable():` are valid;
|
|
79
|
+
the wrapped function is always awaitable. Synchronous loaders are accepted and
|
|
80
|
+
run on the event loop, so use async loaders for blocking I/O.
|
|
81
|
+
|
|
82
|
+
Nested enabled scopes share the live outer request memo. A nested
|
|
83
|
+
`cache.disable()` temporarily bypasses caching without deleting that memo.
|
|
84
|
+
Closing the outer scope clears the memo and prevents late publication. Async
|
|
85
|
+
tasks that inherited a scope use pass-through behavior for calls made after
|
|
86
|
+
that scope closes. Cache instances keep independent contexts.
|
|
87
|
+
|
|
88
|
+
## Policies and runtime changes
|
|
89
|
+
|
|
90
|
+
No layer is enabled by default. A positive TTL enables that shared layer, with
|
|
91
|
+
a default rollout percentage of 100. Request memoization defaults to false;
|
|
92
|
+
concurrent same-key sharing defaults to true.
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
policy = Policy(
|
|
96
|
+
ttl_sec={"local": 5, "remote": 60},
|
|
97
|
+
ramp={"remote": 25},
|
|
98
|
+
request_local=True,
|
|
99
|
+
coalesce=True,
|
|
100
|
+
remote_read_timeout_ms=50,
|
|
101
|
+
stale_on_error_max_age_sec=120,
|
|
102
|
+
)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
TTLs are integer seconds from 1 through 31,536,000. Rollout percentages are
|
|
106
|
+
finite numbers from 0 through 100. Sampling is stable per exact key and layer,
|
|
107
|
+
using the same cohort algorithm as the TypeScript, Go, and Rust ports.
|
|
108
|
+
|
|
109
|
+
Pass a synchronous or asynchronous `policy_provider` to `DialCache` to resolve
|
|
110
|
+
runtime settings once per enabled invocation. It receives the structured key
|
|
111
|
+
and returns a `Policy`, a mapping, or `None`:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
async def policy_provider(key):
|
|
115
|
+
if key.use_case == "user-profile":
|
|
116
|
+
return {"ramp": {"remote": 50}}
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
cache = DialCache(policy_provider=policy_provider)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Runtime replies are sparse: an omitted field inherits the operation default.
|
|
124
|
+
A whole reply of `None` inherits the complete operation policy. An explicit
|
|
125
|
+
`None` leaf is malformed and cannot silently inherit a valid setting. Python
|
|
126
|
+
snake_case names and the shared corpus's camelCase mapping names are accepted.
|
|
127
|
+
Policy objects snapshot their input maps so later mutation cannot alter an
|
|
128
|
+
already admitted invocation.
|
|
129
|
+
|
|
130
|
+
`Policy.disabled()` explicitly disables inherited request memoization, local
|
|
131
|
+
and remote serving, recovery, and shadow work. It does not cancel work that
|
|
132
|
+
was already admitted or disable explicit invalidation. `Policy.enabled(ttl)`
|
|
133
|
+
enables local and remote TTLs; it does not opt into request memoization.
|
|
134
|
+
|
|
135
|
+
Invalid static defaults raise `ConfigError` at registration. At runtime,
|
|
136
|
+
invalid TTLs or ramps disable their own layer; malformed boolean switches,
|
|
137
|
+
read deadlines, containers, or provider failures bypass caching for that
|
|
138
|
+
enabled invocation. Optional recovery and shadow failures leave ordinary
|
|
139
|
+
serving available.
|
|
140
|
+
|
|
141
|
+
## Redis and tracked invalidation
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from redis.asyncio import Redis
|
|
145
|
+
from dialcache import DialCache, Policy
|
|
146
|
+
from dialcache.redis import RedisAdapter
|
|
147
|
+
|
|
148
|
+
client = Redis.from_url(
|
|
149
|
+
"redis://localhost:6379",
|
|
150
|
+
decode_responses=False,
|
|
151
|
+
socket_connect_timeout=0.5,
|
|
152
|
+
socket_timeout=0.5,
|
|
153
|
+
)
|
|
154
|
+
cache = DialCache(redis=RedisAdapter(client))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@cache.cached(
|
|
158
|
+
use_case="user-profile",
|
|
159
|
+
key_type="user",
|
|
160
|
+
id_arg="user_id",
|
|
161
|
+
track_for_invalidation=True,
|
|
162
|
+
default_config=Policy(ttl_sec={"remote": 60}),
|
|
163
|
+
)
|
|
164
|
+
async def get_profile(user_id):
|
|
165
|
+
return await database.fetch_profile(user_id)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def update_profile(user_id, changes):
|
|
169
|
+
await database.update_profile(user_id, changes)
|
|
170
|
+
await cache.invalidate_remote("user", user_id)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The adapter borrows a `redis.asyncio.Redis` or `RedisCluster` client; close it
|
|
174
|
+
with `await client.aclose()` when your application shuts down. Configure
|
|
175
|
+
finite connection, socket, and retry budgets on the client. Tracked reads
|
|
176
|
+
atomically read the value and watermark from a primary. For tracked Cluster
|
|
177
|
+
reads, use a dedicated client constructed with primary-only defaults:
|
|
178
|
+
`read_from_replicas=False`, `load_balancing_strategy=None` where supported, and no custom
|
|
179
|
+
connection hook. Keep its configuration and connection mode unchanged while
|
|
180
|
+
borrowed. Do not repurpose a previously `READONLY` pool by resetting flags;
|
|
181
|
+
create a new primary-only client. Unsafe tracked reads raise `RedisProtocolError`
|
|
182
|
+
at the adapter boundary and ordinary cache calls fail open to the source.
|
|
183
|
+
Replica-enabled clients remain usable for untracked reads and maintenance.
|
|
184
|
+
Keys for one tracked entity share a Redis Cluster hash tag.
|
|
185
|
+
|
|
186
|
+
Each write stores a complete version-1 frame using one native `SET`. A tracked
|
|
187
|
+
frame is readable only if its writer timestamp is strictly greater than the
|
|
188
|
+
invalidation watermark. Value writes never create or extend watermarks.
|
|
189
|
+
Tracked physical value TTLs are capped at one hour. Invalidation raises on
|
|
190
|
+
mutation failure; ordinary cache plumbing fails open to the source.
|
|
191
|
+
|
|
192
|
+
Local storage is process-local. Remote invalidation does not synchronously
|
|
193
|
+
clear already warmed local entries or request memos on any instance. Choose
|
|
194
|
+
local TTLs with that explicit consistency limit in mind.
|
|
195
|
+
|
|
196
|
+
## Deadlines, recovery, and observability
|
|
197
|
+
|
|
198
|
+
The default source deadline is 60,000 ms for enabled calls. The default Redis
|
|
199
|
+
read deadline is 50 ms and can be overridden by operation or runtime policy.
|
|
200
|
+
Deadline budgets are integer milliseconds from 1 through 2,147,483,647; an
|
|
201
|
+
explicit `fallback_timeout_ms=None` disables the source deadline. Timing uses
|
|
202
|
+
the monotonic clock, while Redis frames and invalidation use wall time.
|
|
203
|
+
|
|
204
|
+
Deadline expiration stops the caller's wait. It cannot retract a source
|
|
205
|
+
operation or a Redis command that already started. Late results cannot
|
|
206
|
+
publish through an expired source execution. Caller cancellation likewise
|
|
207
|
+
must not cancel another caller's shared execution.
|
|
208
|
+
|
|
209
|
+
Stale recovery is optional and requires a maximum age strictly greater than
|
|
210
|
+
the remote TTL. A valid candidate is retained from the original remote read;
|
|
211
|
+
an eligible source rejection can use it only before the exclusive maximum
|
|
212
|
+
age. The default recovery predicate admits DialCache's own
|
|
213
|
+
`FallbackTimeoutError`. Recovered values may memoize in still-open request
|
|
214
|
+
scopes; recovery does not refresh Redis or local storage.
|
|
215
|
+
|
|
216
|
+
Pass a synchronous `metrics` callback or an object with `observe(event)` to
|
|
217
|
+
receive the backend-neutral diagnostic event dictionaries. Their label names
|
|
218
|
+
match the shared contract, including `cacheNamespace`, `useCase`, `keyType`,
|
|
219
|
+
and `layer`. Observer failures do not alter cache results. Local capacity
|
|
220
|
+
defaults to 10,000 entries; zero capacity disables storage while preserving
|
|
221
|
+
eligible concurrent sharing.
|
|
222
|
+
|
|
223
|
+
## Relationship to gcache
|
|
224
|
+
|
|
225
|
+
The Python API takes inspiration from [Galileo gcache](https://github.com/rungalileo/gcache):
|
|
226
|
+
decorated functions, argument-based identity, explicit context managers, and
|
|
227
|
+
pluggable serializers. DialCache follows its own portable
|
|
228
|
+
specification for behavior and wire compatibility.
|
|
229
|
+
The [API design notes](https://github.com/lan17/DialCache/blob/main/python/API-DESIGN.md) record the source-reviewed gcache revision
|
|
230
|
+
and the native API choices made for this port.
|
|
231
|
+
|
|
232
|
+
This binding exposes awaitable operations. It does not introduce a global
|
|
233
|
+
singleton, implicitly run synchronous I/O in a thread pool, serialize with
|
|
234
|
+
pickle, take ownership of Redis connections, or change the rollout cohort
|
|
235
|
+
randomly. Direct `put`, `delete`, and `flush` cache APIs from gcache are outside
|
|
236
|
+
DialCache's portable contract; writes come from successful source loads and
|
|
237
|
+
entity-level invalidation is explicit.
|
|
238
|
+
|
|
239
|
+
## Development and conformance
|
|
240
|
+
|
|
241
|
+
From the repository root:
|
|
242
|
+
|
|
243
|
+
```sh
|
|
244
|
+
python3 -m venv python/.venv
|
|
245
|
+
python/.venv/bin/python -m pip install -e './python[test,redis]'
|
|
246
|
+
python/.venv/bin/python -m pytest python/tests
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The native tests cover Python API behavior, policy validation, scope lifetime,
|
|
250
|
+
local expiry, cancellation, and wire boundaries. Shared replay runs the real
|
|
251
|
+
Python API through the repository's Node coordinator. Its inputs and expected
|
|
252
|
+
observations come from the same Quint-generated histories used by the other
|
|
253
|
+
ports; Node is a development dependency, not a runtime dependency of the
|
|
254
|
+
Python library. See [the porting guide](https://github.com/lan17/DialCache/blob/main/formal/PORTING.md) for the completion
|
|
255
|
+
and settlement requirements and [the feature map](https://github.com/lan17/DialCache/blob/main/formal/FEATURE-COVERAGE.md)
|
|
256
|
+
for portable behavior versus native adapter obligations.
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# DialCache for Python
|
|
2
|
+
|
|
3
|
+
An asyncio port of DialCache for Python 3.11 and later. Each use case declares
|
|
4
|
+
its identity and policy; an enabled request scope opts into request memoization,
|
|
5
|
+
local storage, Redis, and concurrent request sharing. The behavioral contract is
|
|
6
|
+
the repository's [portable specification](https://github.com/lan17/DialCache/blob/main/formal/SPEC.md).
|
|
7
|
+
|
|
8
|
+
Once the first PyPI release is available, install it with:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
python3 -m pip install 'dialcache[redis]'
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Until that release is published, install from the root of a repository checkout:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
python3 -m pip install './python[redis]'
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
For local-only caching, omit the `redis` extra. Zstandard is included for the
|
|
21
|
+
portable Redis payload format. The application owns the asyncio event loop and
|
|
22
|
+
any Redis client connections.
|
|
23
|
+
|
|
24
|
+
## A cached function
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from dialcache import DialCache, Policy
|
|
28
|
+
|
|
29
|
+
cache = DialCache(namespace="my-service")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@cache.cached(
|
|
33
|
+
use_case="user-profile",
|
|
34
|
+
key_type="user",
|
|
35
|
+
id_arg="user_id",
|
|
36
|
+
default_config=Policy(ttl_sec={"local": 5}, request_local=True),
|
|
37
|
+
)
|
|
38
|
+
async def get_profile(user_id: str) -> dict:
|
|
39
|
+
return await database.fetch_profile(user_id)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def handle_request(user_id: str) -> dict:
|
|
43
|
+
async with cache.enable():
|
|
44
|
+
first = await get_profile(user_id)
|
|
45
|
+
second = await get_profile(user_id) # Same request memo.
|
|
46
|
+
return second
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Calls outside `enable()` go directly to the source. They do not construct cache
|
|
50
|
+
keys, resolve policy, share concurrent work, or apply a DialCache source
|
|
51
|
+
deadline. Both `with cache.enable():` and `async with cache.enable():` are valid;
|
|
52
|
+
the wrapped function is always awaitable. Synchronous loaders are accepted and
|
|
53
|
+
run on the event loop, so use async loaders for blocking I/O.
|
|
54
|
+
|
|
55
|
+
Nested enabled scopes share the live outer request memo. A nested
|
|
56
|
+
`cache.disable()` temporarily bypasses caching without deleting that memo.
|
|
57
|
+
Closing the outer scope clears the memo and prevents late publication. Async
|
|
58
|
+
tasks that inherited a scope use pass-through behavior for calls made after
|
|
59
|
+
that scope closes. Cache instances keep independent contexts.
|
|
60
|
+
|
|
61
|
+
## Policies and runtime changes
|
|
62
|
+
|
|
63
|
+
No layer is enabled by default. A positive TTL enables that shared layer, with
|
|
64
|
+
a default rollout percentage of 100. Request memoization defaults to false;
|
|
65
|
+
concurrent same-key sharing defaults to true.
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
policy = Policy(
|
|
69
|
+
ttl_sec={"local": 5, "remote": 60},
|
|
70
|
+
ramp={"remote": 25},
|
|
71
|
+
request_local=True,
|
|
72
|
+
coalesce=True,
|
|
73
|
+
remote_read_timeout_ms=50,
|
|
74
|
+
stale_on_error_max_age_sec=120,
|
|
75
|
+
)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
TTLs are integer seconds from 1 through 31,536,000. Rollout percentages are
|
|
79
|
+
finite numbers from 0 through 100. Sampling is stable per exact key and layer,
|
|
80
|
+
using the same cohort algorithm as the TypeScript, Go, and Rust ports.
|
|
81
|
+
|
|
82
|
+
Pass a synchronous or asynchronous `policy_provider` to `DialCache` to resolve
|
|
83
|
+
runtime settings once per enabled invocation. It receives the structured key
|
|
84
|
+
and returns a `Policy`, a mapping, or `None`:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
async def policy_provider(key):
|
|
88
|
+
if key.use_case == "user-profile":
|
|
89
|
+
return {"ramp": {"remote": 50}}
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
cache = DialCache(policy_provider=policy_provider)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Runtime replies are sparse: an omitted field inherits the operation default.
|
|
97
|
+
A whole reply of `None` inherits the complete operation policy. An explicit
|
|
98
|
+
`None` leaf is malformed and cannot silently inherit a valid setting. Python
|
|
99
|
+
snake_case names and the shared corpus's camelCase mapping names are accepted.
|
|
100
|
+
Policy objects snapshot their input maps so later mutation cannot alter an
|
|
101
|
+
already admitted invocation.
|
|
102
|
+
|
|
103
|
+
`Policy.disabled()` explicitly disables inherited request memoization, local
|
|
104
|
+
and remote serving, recovery, and shadow work. It does not cancel work that
|
|
105
|
+
was already admitted or disable explicit invalidation. `Policy.enabled(ttl)`
|
|
106
|
+
enables local and remote TTLs; it does not opt into request memoization.
|
|
107
|
+
|
|
108
|
+
Invalid static defaults raise `ConfigError` at registration. At runtime,
|
|
109
|
+
invalid TTLs or ramps disable their own layer; malformed boolean switches,
|
|
110
|
+
read deadlines, containers, or provider failures bypass caching for that
|
|
111
|
+
enabled invocation. Optional recovery and shadow failures leave ordinary
|
|
112
|
+
serving available.
|
|
113
|
+
|
|
114
|
+
## Redis and tracked invalidation
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from redis.asyncio import Redis
|
|
118
|
+
from dialcache import DialCache, Policy
|
|
119
|
+
from dialcache.redis import RedisAdapter
|
|
120
|
+
|
|
121
|
+
client = Redis.from_url(
|
|
122
|
+
"redis://localhost:6379",
|
|
123
|
+
decode_responses=False,
|
|
124
|
+
socket_connect_timeout=0.5,
|
|
125
|
+
socket_timeout=0.5,
|
|
126
|
+
)
|
|
127
|
+
cache = DialCache(redis=RedisAdapter(client))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@cache.cached(
|
|
131
|
+
use_case="user-profile",
|
|
132
|
+
key_type="user",
|
|
133
|
+
id_arg="user_id",
|
|
134
|
+
track_for_invalidation=True,
|
|
135
|
+
default_config=Policy(ttl_sec={"remote": 60}),
|
|
136
|
+
)
|
|
137
|
+
async def get_profile(user_id):
|
|
138
|
+
return await database.fetch_profile(user_id)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def update_profile(user_id, changes):
|
|
142
|
+
await database.update_profile(user_id, changes)
|
|
143
|
+
await cache.invalidate_remote("user", user_id)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The adapter borrows a `redis.asyncio.Redis` or `RedisCluster` client; close it
|
|
147
|
+
with `await client.aclose()` when your application shuts down. Configure
|
|
148
|
+
finite connection, socket, and retry budgets on the client. Tracked reads
|
|
149
|
+
atomically read the value and watermark from a primary. For tracked Cluster
|
|
150
|
+
reads, use a dedicated client constructed with primary-only defaults:
|
|
151
|
+
`read_from_replicas=False`, `load_balancing_strategy=None` where supported, and no custom
|
|
152
|
+
connection hook. Keep its configuration and connection mode unchanged while
|
|
153
|
+
borrowed. Do not repurpose a previously `READONLY` pool by resetting flags;
|
|
154
|
+
create a new primary-only client. Unsafe tracked reads raise `RedisProtocolError`
|
|
155
|
+
at the adapter boundary and ordinary cache calls fail open to the source.
|
|
156
|
+
Replica-enabled clients remain usable for untracked reads and maintenance.
|
|
157
|
+
Keys for one tracked entity share a Redis Cluster hash tag.
|
|
158
|
+
|
|
159
|
+
Each write stores a complete version-1 frame using one native `SET`. A tracked
|
|
160
|
+
frame is readable only if its writer timestamp is strictly greater than the
|
|
161
|
+
invalidation watermark. Value writes never create or extend watermarks.
|
|
162
|
+
Tracked physical value TTLs are capped at one hour. Invalidation raises on
|
|
163
|
+
mutation failure; ordinary cache plumbing fails open to the source.
|
|
164
|
+
|
|
165
|
+
Local storage is process-local. Remote invalidation does not synchronously
|
|
166
|
+
clear already warmed local entries or request memos on any instance. Choose
|
|
167
|
+
local TTLs with that explicit consistency limit in mind.
|
|
168
|
+
|
|
169
|
+
## Deadlines, recovery, and observability
|
|
170
|
+
|
|
171
|
+
The default source deadline is 60,000 ms for enabled calls. The default Redis
|
|
172
|
+
read deadline is 50 ms and can be overridden by operation or runtime policy.
|
|
173
|
+
Deadline budgets are integer milliseconds from 1 through 2,147,483,647; an
|
|
174
|
+
explicit `fallback_timeout_ms=None` disables the source deadline. Timing uses
|
|
175
|
+
the monotonic clock, while Redis frames and invalidation use wall time.
|
|
176
|
+
|
|
177
|
+
Deadline expiration stops the caller's wait. It cannot retract a source
|
|
178
|
+
operation or a Redis command that already started. Late results cannot
|
|
179
|
+
publish through an expired source execution. Caller cancellation likewise
|
|
180
|
+
must not cancel another caller's shared execution.
|
|
181
|
+
|
|
182
|
+
Stale recovery is optional and requires a maximum age strictly greater than
|
|
183
|
+
the remote TTL. A valid candidate is retained from the original remote read;
|
|
184
|
+
an eligible source rejection can use it only before the exclusive maximum
|
|
185
|
+
age. The default recovery predicate admits DialCache's own
|
|
186
|
+
`FallbackTimeoutError`. Recovered values may memoize in still-open request
|
|
187
|
+
scopes; recovery does not refresh Redis or local storage.
|
|
188
|
+
|
|
189
|
+
Pass a synchronous `metrics` callback or an object with `observe(event)` to
|
|
190
|
+
receive the backend-neutral diagnostic event dictionaries. Their label names
|
|
191
|
+
match the shared contract, including `cacheNamespace`, `useCase`, `keyType`,
|
|
192
|
+
and `layer`. Observer failures do not alter cache results. Local capacity
|
|
193
|
+
defaults to 10,000 entries; zero capacity disables storage while preserving
|
|
194
|
+
eligible concurrent sharing.
|
|
195
|
+
|
|
196
|
+
## Relationship to gcache
|
|
197
|
+
|
|
198
|
+
The Python API takes inspiration from [Galileo gcache](https://github.com/rungalileo/gcache):
|
|
199
|
+
decorated functions, argument-based identity, explicit context managers, and
|
|
200
|
+
pluggable serializers. DialCache follows its own portable
|
|
201
|
+
specification for behavior and wire compatibility.
|
|
202
|
+
The [API design notes](https://github.com/lan17/DialCache/blob/main/python/API-DESIGN.md) record the source-reviewed gcache revision
|
|
203
|
+
and the native API choices made for this port.
|
|
204
|
+
|
|
205
|
+
This binding exposes awaitable operations. It does not introduce a global
|
|
206
|
+
singleton, implicitly run synchronous I/O in a thread pool, serialize with
|
|
207
|
+
pickle, take ownership of Redis connections, or change the rollout cohort
|
|
208
|
+
randomly. Direct `put`, `delete`, and `flush` cache APIs from gcache are outside
|
|
209
|
+
DialCache's portable contract; writes come from successful source loads and
|
|
210
|
+
entity-level invalidation is explicit.
|
|
211
|
+
|
|
212
|
+
## Development and conformance
|
|
213
|
+
|
|
214
|
+
From the repository root:
|
|
215
|
+
|
|
216
|
+
```sh
|
|
217
|
+
python3 -m venv python/.venv
|
|
218
|
+
python/.venv/bin/python -m pip install -e './python[test,redis]'
|
|
219
|
+
python/.venv/bin/python -m pytest python/tests
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The native tests cover Python API behavior, policy validation, scope lifetime,
|
|
223
|
+
local expiry, cancellation, and wire boundaries. Shared replay runs the real
|
|
224
|
+
Python API through the repository's Node coordinator. Its inputs and expected
|
|
225
|
+
observations come from the same Quint-generated histories used by the other
|
|
226
|
+
ports; Node is a development dependency, not a runtime dependency of the
|
|
227
|
+
Python library. See [the porting guide](https://github.com/lan17/DialCache/blob/main/formal/PORTING.md) for the completion
|
|
228
|
+
and settlement requirements and [the feature map](https://github.com/lan17/DialCache/blob/main/formal/FEATURE-COVERAGE.md)
|
|
229
|
+
for portable behavior versus native adapter obligations.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""DialCache: explicit scopes, layered caching, and portable Redis frames."""
|
|
2
|
+
|
|
3
|
+
from .cache import DialCache
|
|
4
|
+
from .config import UNSET, CacheLayer, DialCacheKeyConfig, KeyConfig, Policy
|
|
5
|
+
from .errors import (
|
|
6
|
+
ConfigError,
|
|
7
|
+
DialCacheError,
|
|
8
|
+
FallbackTimeoutError,
|
|
9
|
+
MissingRemoteError,
|
|
10
|
+
RedisReadTimeoutError,
|
|
11
|
+
RemoteReadTimeoutError,
|
|
12
|
+
UseCaseIsAlreadyRegisteredError,
|
|
13
|
+
UseCaseNameIsReservedError,
|
|
14
|
+
)
|
|
15
|
+
from .key import Key, normalize_args
|
|
16
|
+
from .serializer import UNDEFINED, JsonSerializer, Serializer
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"DialCache",
|
|
20
|
+
"Policy",
|
|
21
|
+
"KeyConfig",
|
|
22
|
+
"DialCacheKeyConfig",
|
|
23
|
+
"CacheLayer",
|
|
24
|
+
"Key",
|
|
25
|
+
"normalize_args",
|
|
26
|
+
"Serializer",
|
|
27
|
+
"JsonSerializer",
|
|
28
|
+
"UNDEFINED",
|
|
29
|
+
"UNSET",
|
|
30
|
+
"DialCacheError",
|
|
31
|
+
"ConfigError",
|
|
32
|
+
"FallbackTimeoutError",
|
|
33
|
+
"RemoteReadTimeoutError",
|
|
34
|
+
"RedisReadTimeoutError",
|
|
35
|
+
"MissingRemoteError",
|
|
36
|
+
"UseCaseIsAlreadyRegisteredError",
|
|
37
|
+
"UseCaseNameIsReservedError",
|
|
38
|
+
]
|