nanocached-django 0.4.4__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.
- nanocached_django-0.4.4/LICENSE +21 -0
- nanocached_django-0.4.4/PKG-INFO +291 -0
- nanocached_django-0.4.4/README.md +269 -0
- nanocached_django-0.4.4/pyproject.toml +34 -0
- nanocached_django-0.4.4/setup.cfg +4 -0
- nanocached_django-0.4.4/src/nanocached_django/__init__.py +15 -0
- nanocached_django-0.4.4/src/nanocached_django/backend.py +731 -0
- nanocached_django-0.4.4/src/nanocached_django.egg-info/PKG-INFO +291 -0
- nanocached_django-0.4.4/src/nanocached_django.egg-info/SOURCES.txt +18 -0
- nanocached_django-0.4.4/src/nanocached_django.egg-info/dependency_links.txt +1 -0
- nanocached_django-0.4.4/src/nanocached_django.egg-info/requires.txt +2 -0
- nanocached_django-0.4.4/src/nanocached_django.egg-info/top_level.txt +1 -0
- nanocached_django-0.4.4/tests/test_auth.py +30 -0
- nanocached_django-0.4.4/tests/test_cache_page.py +60 -0
- nanocached_django-0.4.4/tests/test_close.py +252 -0
- nanocached_django-0.4.4/tests/test_connect_options.py +123 -0
- nanocached_django-0.4.4/tests/test_isolation.py +47 -0
- nanocached_django-0.4.4/tests/test_partial_failure_retry.py +194 -0
- nanocached_django-0.4.4/tests/test_round_trip.py +291 -0
- nanocached_django-0.4.4/tests/test_timeouts.py +86 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Takashi Yamashina
|
|
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,291 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nanocached-django
|
|
3
|
+
Version: 0.4.4
|
|
4
|
+
Summary: Django cache backend for nanocached, a tiny distributed cache with client-side replication
|
|
5
|
+
Author: Takashi Yamashina
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://nanocached.org
|
|
8
|
+
Project-URL: Repository, https://github.com/nanocached/nanocached
|
|
9
|
+
Project-URL: Issues, https://github.com/nanocached/nanocached/issues
|
|
10
|
+
Keywords: nanocached,cache,django,distributed-cache
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: Django
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: nanocached>=0.4.4
|
|
20
|
+
Requires-Dist: Django>=4.2
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# nanocached-django
|
|
24
|
+
|
|
25
|
+
Django cache backend for the [nanocached](https://github.com/nanocached/nanocached)
|
|
26
|
+
Python SDK: `django.core.cache.backends.base.BaseCache` implemented on
|
|
27
|
+
`nanocached.NanocachedClient`, so `django.core.cache.caches`, `@cache_page`
|
|
28
|
+
and friends run against a nanocached cluster.
|
|
29
|
+
|
|
30
|
+
- **Cache alias ⇄ namespace.** Each `NanocachedCache` instance binds to
|
|
31
|
+
one nanocached namespace (`OPTIONS.NAMESPACE`, default `"django"`) — two
|
|
32
|
+
aliases with different namespaces never collide, even sharing the same
|
|
33
|
+
node, and `cache.clear()` is that namespace's `CLEAR` (an O(1) sub-map
|
|
34
|
+
drop on every node, no key scan, and never the whole store).
|
|
35
|
+
- **`get`/`set`/`delete`/`has_key`/`touch`** map onto the SDK's namespaced
|
|
36
|
+
get/set/delete, with all of its routing, replication, and retries.
|
|
37
|
+
- **`add`** and **`touch`** are get-then-set, not atomic — the wire has no
|
|
38
|
+
compare-and-set. Two racing callers can both observe "absent" and both
|
|
39
|
+
write; the later write wins. Same trade-off the Spring adapter's
|
|
40
|
+
`putIfAbsent` documents.
|
|
41
|
+
- **`get_many`/`set_many`** use the wire's batched multi-get/multi-set
|
|
42
|
+
(issues #150/#152): one round trip per involved node instead of one per
|
|
43
|
+
key. **`delete_many`** remains a concurrent client-side fan-out — the
|
|
44
|
+
wire has no multi-key delete.
|
|
45
|
+
- **`incr`/`decr`** use the wire's own atomic counter (`INCR`, issue
|
|
46
|
+
#129): atomic on the node that owns the key, unlike `add()`/`touch()`
|
|
47
|
+
above. Matches `BaseCache`'s own contract — raises `ValueError` if the
|
|
48
|
+
key doesn't exist — and is exactly as volatile as `set()`: LRU
|
|
49
|
+
eviction or this alias's `TIMEOUT` reclaim a counter the same as any
|
|
50
|
+
other entry, so it suits rate limiting and approximate counts, never a
|
|
51
|
+
count that must survive (billing, inventory). See "Counter storage"
|
|
52
|
+
below for how this changes what's on the wire.
|
|
53
|
+
- **Values are pickled** (`pickle.dumps(..., HIGHEST_PROTOCOL)`), the
|
|
54
|
+
Django convention — anything picklable round-trips, `None` included
|
|
55
|
+
(distinguished from a cache miss, which is nanocached's own "no value")
|
|
56
|
+
— **except a plain `int`** (`bool` excluded), which is stored as
|
|
57
|
+
`INCR`'s own decimal-ASCII counter grammar instead, so `incr`/`decr`
|
|
58
|
+
can operate on it server-side. See "Counter storage" below.
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
pip install nanocached-django
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Pulls in the `nanocached` SDK of the same version and `Django>=4.2`.
|
|
67
|
+
|
|
68
|
+
## Setup
|
|
69
|
+
|
|
70
|
+
Point `CACHES["default"]["BACKEND"]` at this class — adding the
|
|
71
|
+
`nanocached-django` dependency alone changes nothing:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
CACHES = {
|
|
75
|
+
"default": {
|
|
76
|
+
"BACKEND": "nanocached_django.NanocachedCache",
|
|
77
|
+
"LOCATION": ["10.0.0.1:8357", "10.0.0.2:8357"], # or "host:port"
|
|
78
|
+
"OPTIONS": {
|
|
79
|
+
"NAMESPACE": "django", # default "django"
|
|
80
|
+
"SECRET": "...", # optional; passed to connect() as auth_secret
|
|
81
|
+
"TLS": True, # optional; connect() tls, default False
|
|
82
|
+
"CA": "/path/ca.pem", # optional; connect() ca, only meaningful with TLS
|
|
83
|
+
"COMPRESS": True, # optional; connect() compress, default False
|
|
84
|
+
"COMPRESSION_THRESHOLD": 512, # optional; connect() compression_threshold
|
|
85
|
+
"FIRE_AND_FORGET_REPLICAS": True, # optional; connect() fire_and_forget_replicas
|
|
86
|
+
"READ_REPAIR": True, # optional; connect() read_repair
|
|
87
|
+
"READ_HEDGE_AFTER": 0.05, # optional; connect() read_hedge_after (seconds)
|
|
88
|
+
"RECONNECT_COOLDOWN": 1.0, # optional; connect() reconnect_cooldown (seconds)
|
|
89
|
+
},
|
|
90
|
+
"TIMEOUT": 300, # Django's own key, honored as usual
|
|
91
|
+
"KEY_PREFIX": "...",
|
|
92
|
+
"VERSION": 1,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`LOCATION` may name a single nanocached node or one or more discovery
|
|
98
|
+
servers fronting a cluster — same as `NanocachedClient.connect()`, this
|
|
99
|
+
backend doesn't need to be told which; it finds out from the server's own
|
|
100
|
+
handshake. A `LOCATION` list is one address per entry; a single string may
|
|
101
|
+
also carry more than one address separated by `,`/`;`.
|
|
102
|
+
|
|
103
|
+
`TLS`/`CA`/`COMPRESS`/`COMPRESSION_THRESHOLD`/`FIRE_AND_FORGET_REPLICAS`/
|
|
104
|
+
`READ_REPAIR`/`READ_HEDGE_AFTER`/`RECONNECT_COOLDOWN` are forwarded
|
|
105
|
+
straight through to the matching `NanocachedClient.connect()` keyword
|
|
106
|
+
(`tls`, `ca`, `compress`, `compression_threshold`,
|
|
107
|
+
`fire_and_forget_replicas`, `read_repair`, `read_hedge_after`,
|
|
108
|
+
`reconnect_cooldown` — see that method's docstring for what each one
|
|
109
|
+
does) — each is optional and, when the OPTIONS entry omits it, the SDK's
|
|
110
|
+
own default is what actually takes effect, not some second default
|
|
111
|
+
chosen by this backend. `CA` is a filesystem path to a CA bundle and only
|
|
112
|
+
matters when `TLS` is also true. `READ_HEDGE_AFTER` and
|
|
113
|
+
`RECONNECT_COOLDOWN` are seconds (`float`); everything else here is a
|
|
114
|
+
`bool` except `COMPRESSION_THRESHOLD`, an `int` byte count.
|
|
115
|
+
|
|
116
|
+
## The sync/async bridge
|
|
117
|
+
|
|
118
|
+
The Python SDK is asyncio-only; Django's cache SPI is sync-first. Each
|
|
119
|
+
`NanocachedCache` instance owns a dedicated daemon-thread event loop,
|
|
120
|
+
started lazily on first use, and every sync call (`get`/`set`/...) is
|
|
121
|
+
dispatched onto it with `asyncio.run_coroutine_threadsafe(...).result()` —
|
|
122
|
+
never `asyncio.run()` per call, which would redo the SDK's connect
|
|
123
|
+
handshake on every single cache operation instead of reusing one
|
|
124
|
+
persistent connection, and never requires an ambient running loop either.
|
|
125
|
+
|
|
126
|
+
Django's own `aget`/`aset`/`aadd`/... come for free: `BaseCache` wraps
|
|
127
|
+
every sync method in `sync_to_async(..., thread_sensitive=True)`, so this
|
|
128
|
+
backend implements no native async path of its own — doing so would just
|
|
129
|
+
be a second bridge fighting the first for the same event loop.
|
|
130
|
+
|
|
131
|
+
### Lifecycle note
|
|
132
|
+
|
|
133
|
+
Django connects `close_caches()` to the `request_finished` signal
|
|
134
|
+
unconditionally (`django/core/cache/__init__.py`), which calls `.close()`
|
|
135
|
+
on every cache alias a thread has already touched — after **every**
|
|
136
|
+
request. Honoring that literally would re-dial TCP (+ auth, + a discovery
|
|
137
|
+
refresh in cluster mode) once per request, defeating the persistent
|
|
138
|
+
connections the client is built around. So, like django-redis, this
|
|
139
|
+
backend's `close()` is a **no-op by default**: the loop thread and
|
|
140
|
+
connection survive the request cycle.
|
|
141
|
+
|
|
142
|
+
- `OPTIONS: {"CLOSE_ON_REQUEST": True}` opts into real per-request
|
|
143
|
+
teardown, if short-lived processes matter more than connection reuse.
|
|
144
|
+
- `backend.shutdown()` always tears down (loop thread stopped, client
|
|
145
|
+
closed); the next operation lazily reconnects. Use it at process exit
|
|
146
|
+
or in tests.
|
|
147
|
+
|
|
148
|
+
### Preforking servers (Gunicorn `preload_app`, uWSGI)
|
|
149
|
+
|
|
150
|
+
The bridge is fork-aware (issue #393): the backend records which process
|
|
151
|
+
started its loop thread and rebuilds the loop, thread and client on the
|
|
152
|
+
first cache operation in a `fork()` child. Without that, a warm-up cache
|
|
153
|
+
touch in a preloading master (e.g. from `AppConfig.ready()`) would leave
|
|
154
|
+
every worker holding a loop whose driving thread died in the fork —
|
|
155
|
+
and each operation would block forever on
|
|
156
|
+
`run_coroutine_threadsafe(...).result()`. The parent's inherited sockets
|
|
157
|
+
are deliberately left untouched in the child; the parent remains their
|
|
158
|
+
owner. No configuration is needed — `preload_app = True` and uWSGI
|
|
159
|
+
without `lazy-apps` both just work.
|
|
160
|
+
|
|
161
|
+
## Timeouts
|
|
162
|
+
|
|
163
|
+
Django's `timeout` conventions are translated to nanocached's wire TTL
|
|
164
|
+
(whole seconds; on the wire, unlike Django's own convention, `0` means
|
|
165
|
+
"no expiry", not "expire now"):
|
|
166
|
+
|
|
167
|
+
| Django `timeout` | wire TTL |
|
|
168
|
+
| -------------------------------- | -------------------------------------- |
|
|
169
|
+
| not passed (`DEFAULT_TIMEOUT`) | this alias's `TIMEOUT` setting |
|
|
170
|
+
| `None` | `0` (no expiry — the one case the two conventions agree) |
|
|
171
|
+
| `0` or negative | nothing is written; `set()` deletes any existing value instead |
|
|
172
|
+
| positive, sub-second | rounds **up** to `1` (never down to `0`, which would mean eternal) |
|
|
173
|
+
| positive, ≥ 1s | rounds up to the next whole second |
|
|
174
|
+
|
|
175
|
+
## Usage
|
|
176
|
+
|
|
177
|
+
With `CACHES` pointed at this backend, use the standard API:
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
from django.core.cache import cache, caches
|
|
181
|
+
|
|
182
|
+
cache.set("greeting", "hello", timeout=60)
|
|
183
|
+
cache.get("greeting") # "hello"
|
|
184
|
+
cache.get("missing", "default") # "default"
|
|
185
|
+
|
|
186
|
+
sessions = caches["sessions"] # a second alias, its own NAMESPACE
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`@cache_page` and the whole-site `CacheMiddleware` work unchanged:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from django.views.decorators.cache import cache_page
|
|
193
|
+
|
|
194
|
+
@cache_page(60, cache="default")
|
|
195
|
+
def my_view(request):
|
|
196
|
+
...
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Consistency notes
|
|
200
|
+
|
|
201
|
+
`add()` and `touch()` are get-then-set (see above), not atomic.
|
|
202
|
+
`get_many`/`set_many` batch by owning node (issues #150/#152) and
|
|
203
|
+
`delete_many` fans out per key — in every case a failure partway through
|
|
204
|
+
leaves whichever keys were already processed changed rather than rolling
|
|
205
|
+
back. `incr`/`decr` (issue #129) are atomic on the node that owns the
|
|
206
|
+
key, since the wire has a real `INCR`.
|
|
207
|
+
|
|
208
|
+
## Counter storage
|
|
209
|
+
|
|
210
|
+
To make `incr`/`decr` possible, this backend changed what a plain `int`
|
|
211
|
+
value looks like on the wire: instead of the usual pickle round trip, an
|
|
212
|
+
`int` (excluding `bool`) is stored as `INCR`'s own decimal-ASCII grammar
|
|
213
|
+
— the same bytes the wire's atomic counter reads and writes directly.
|
|
214
|
+
Everything else — `str`, `dict`, model instances, `None`, `bool`, ... —
|
|
215
|
+
is still pickled exactly as before. `get()` tells the two apart by the
|
|
216
|
+
bytes themselves: a pickle stream always begins with the `0x80` PROTO
|
|
217
|
+
opcode (Python's pickle protocol 2+, which `HIGHEST_PROTOCOL` always is),
|
|
218
|
+
a byte that can never start an ASCII decimal integer, so there is no
|
|
219
|
+
ambiguity and no extra marker byte on the wire.
|
|
220
|
+
|
|
221
|
+
**This is a compatibility break for a rolling deploy that shares a cache
|
|
222
|
+
across old and new `nanocached-django` code**, not a nanocached-server
|
|
223
|
+
concern at all — the node just stores whatever opaque bytes it's given,
|
|
224
|
+
on any version. The risk is entirely between Django app instances:
|
|
225
|
+
|
|
226
|
+
- An **old** worker's `get()` unconditionally calls `pickle.loads(raw)`.
|
|
227
|
+
If a **new** worker already wrote an `int` under that key (as raw
|
|
228
|
+
decimal-ASCII, not pickled), the old worker's `get()`/`get_many()`
|
|
229
|
+
**raises** (an unpickling error), not just returns a wrong value.
|
|
230
|
+
- A **new** worker's `get()` handles both encodings fine, including an
|
|
231
|
+
`int` an old worker pickled — that direction round-trips correctly.
|
|
232
|
+
|
|
233
|
+
So the break is one-directional (old code choking on new-written ints)
|
|
234
|
+
and only matters while old and new code run concurrently against the
|
|
235
|
+
same shared keys — a normal rolling deploy window. It resolves itself
|
|
236
|
+
once the rollout finishes; nothing needs a manual fix-up. If a rolling
|
|
237
|
+
deploy of this adapter's version must never error on an `int` key, drain
|
|
238
|
+
or version-bump that key's namespace instead of upgrading it in place.
|
|
239
|
+
|
|
240
|
+
## Trust boundary / deserialization
|
|
241
|
+
|
|
242
|
+
Values (other than plain `int`, see "Counter storage" above) are read
|
|
243
|
+
back with `pickle.loads()` — the Django convention, and the same model as
|
|
244
|
+
Django's own memcached backend. That means **anyone who can write to this
|
|
245
|
+
cache's namespace can execute code in the process that reads it back** —
|
|
246
|
+
any holder of the cluster's auth secret, or, when no secret is
|
|
247
|
+
configured, any peer that can reach the server at all. This is inherent
|
|
248
|
+
to `pickle.loads()` on untrusted bytes, not specific to nanocached.
|
|
249
|
+
|
|
250
|
+
Mitigate with all of:
|
|
251
|
+
|
|
252
|
+
- **`OPTIONS.TLS` + `OPTIONS.SECRET`**, so only trusted processes can
|
|
253
|
+
write to the namespace at all.
|
|
254
|
+
- **Never share a namespace (or its secret) with an untrusted writer** —
|
|
255
|
+
`OPTIONS.NAMESPACE` is cheap and isolated (see above), so give each
|
|
256
|
+
trust domain its own alias/namespace rather than reusing one across
|
|
257
|
+
services with different trust levels.
|
|
258
|
+
- There is no pluggable serializer in this backend today; where writers
|
|
259
|
+
aren't fully trusted, isolate this cache's namespace to trusted writers
|
|
260
|
+
only rather than relying on the pickle stream itself for safety.
|
|
261
|
+
|
|
262
|
+
## Requirements
|
|
263
|
+
|
|
264
|
+
Python 3.11+, Django 4.2+, `nanocached` (Python SDK) 0.3+, a nanocached
|
|
265
|
+
server ≥ the release that ships namespaces and CLEAR (issues #105/#106).
|
|
266
|
+
|
|
267
|
+
## Policy note
|
|
268
|
+
|
|
269
|
+
Framework adapters are ecosystem-specific and live **outside** the
|
|
270
|
+
six-language SDK parity policy ([#25]): parity applies to the SDK core
|
|
271
|
+
only. This module exists for Django; other ecosystems get their own
|
|
272
|
+
idiomatic adapters (Spring Cache, `IDistributedCache`, cache-manager
|
|
273
|
+
store, [JCache](../jcache), [Keyv](../keyv)) rather than mirrors of this
|
|
274
|
+
one.
|
|
275
|
+
|
|
276
|
+
[#25]: https://github.com/nanocached/nanocached/issues/25
|
|
277
|
+
|
|
278
|
+
## Testing
|
|
279
|
+
|
|
280
|
+
```sh
|
|
281
|
+
cd adapters/django
|
|
282
|
+
PYTHONPATH=src:../../sdk/python/src python3 -m unittest discover -s tests -v
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Tests run against a small in-module `MockNode` (`tests/mock_node.py`, a
|
|
286
|
+
trimmed re-implementation of the SDK's own test double, which is private
|
|
287
|
+
to the SDK's suite) speaking the wire protocol directly — no server
|
|
288
|
+
binary needed, and no PyPI install of the SDK either, since the sibling
|
|
289
|
+
`sdk/python` sources are put on `PYTHONPATH` instead.
|
|
290
|
+
|
|
291
|
+
MIT license.
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# nanocached-django
|
|
2
|
+
|
|
3
|
+
Django cache backend for the [nanocached](https://github.com/nanocached/nanocached)
|
|
4
|
+
Python SDK: `django.core.cache.backends.base.BaseCache` implemented on
|
|
5
|
+
`nanocached.NanocachedClient`, so `django.core.cache.caches`, `@cache_page`
|
|
6
|
+
and friends run against a nanocached cluster.
|
|
7
|
+
|
|
8
|
+
- **Cache alias ⇄ namespace.** Each `NanocachedCache` instance binds to
|
|
9
|
+
one nanocached namespace (`OPTIONS.NAMESPACE`, default `"django"`) — two
|
|
10
|
+
aliases with different namespaces never collide, even sharing the same
|
|
11
|
+
node, and `cache.clear()` is that namespace's `CLEAR` (an O(1) sub-map
|
|
12
|
+
drop on every node, no key scan, and never the whole store).
|
|
13
|
+
- **`get`/`set`/`delete`/`has_key`/`touch`** map onto the SDK's namespaced
|
|
14
|
+
get/set/delete, with all of its routing, replication, and retries.
|
|
15
|
+
- **`add`** and **`touch`** are get-then-set, not atomic — the wire has no
|
|
16
|
+
compare-and-set. Two racing callers can both observe "absent" and both
|
|
17
|
+
write; the later write wins. Same trade-off the Spring adapter's
|
|
18
|
+
`putIfAbsent` documents.
|
|
19
|
+
- **`get_many`/`set_many`** use the wire's batched multi-get/multi-set
|
|
20
|
+
(issues #150/#152): one round trip per involved node instead of one per
|
|
21
|
+
key. **`delete_many`** remains a concurrent client-side fan-out — the
|
|
22
|
+
wire has no multi-key delete.
|
|
23
|
+
- **`incr`/`decr`** use the wire's own atomic counter (`INCR`, issue
|
|
24
|
+
#129): atomic on the node that owns the key, unlike `add()`/`touch()`
|
|
25
|
+
above. Matches `BaseCache`'s own contract — raises `ValueError` if the
|
|
26
|
+
key doesn't exist — and is exactly as volatile as `set()`: LRU
|
|
27
|
+
eviction or this alias's `TIMEOUT` reclaim a counter the same as any
|
|
28
|
+
other entry, so it suits rate limiting and approximate counts, never a
|
|
29
|
+
count that must survive (billing, inventory). See "Counter storage"
|
|
30
|
+
below for how this changes what's on the wire.
|
|
31
|
+
- **Values are pickled** (`pickle.dumps(..., HIGHEST_PROTOCOL)`), the
|
|
32
|
+
Django convention — anything picklable round-trips, `None` included
|
|
33
|
+
(distinguished from a cache miss, which is nanocached's own "no value")
|
|
34
|
+
— **except a plain `int`** (`bool` excluded), which is stored as
|
|
35
|
+
`INCR`'s own decimal-ASCII counter grammar instead, so `incr`/`decr`
|
|
36
|
+
can operate on it server-side. See "Counter storage" below.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
pip install nanocached-django
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Pulls in the `nanocached` SDK of the same version and `Django>=4.2`.
|
|
45
|
+
|
|
46
|
+
## Setup
|
|
47
|
+
|
|
48
|
+
Point `CACHES["default"]["BACKEND"]` at this class — adding the
|
|
49
|
+
`nanocached-django` dependency alone changes nothing:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
CACHES = {
|
|
53
|
+
"default": {
|
|
54
|
+
"BACKEND": "nanocached_django.NanocachedCache",
|
|
55
|
+
"LOCATION": ["10.0.0.1:8357", "10.0.0.2:8357"], # or "host:port"
|
|
56
|
+
"OPTIONS": {
|
|
57
|
+
"NAMESPACE": "django", # default "django"
|
|
58
|
+
"SECRET": "...", # optional; passed to connect() as auth_secret
|
|
59
|
+
"TLS": True, # optional; connect() tls, default False
|
|
60
|
+
"CA": "/path/ca.pem", # optional; connect() ca, only meaningful with TLS
|
|
61
|
+
"COMPRESS": True, # optional; connect() compress, default False
|
|
62
|
+
"COMPRESSION_THRESHOLD": 512, # optional; connect() compression_threshold
|
|
63
|
+
"FIRE_AND_FORGET_REPLICAS": True, # optional; connect() fire_and_forget_replicas
|
|
64
|
+
"READ_REPAIR": True, # optional; connect() read_repair
|
|
65
|
+
"READ_HEDGE_AFTER": 0.05, # optional; connect() read_hedge_after (seconds)
|
|
66
|
+
"RECONNECT_COOLDOWN": 1.0, # optional; connect() reconnect_cooldown (seconds)
|
|
67
|
+
},
|
|
68
|
+
"TIMEOUT": 300, # Django's own key, honored as usual
|
|
69
|
+
"KEY_PREFIX": "...",
|
|
70
|
+
"VERSION": 1,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`LOCATION` may name a single nanocached node or one or more discovery
|
|
76
|
+
servers fronting a cluster — same as `NanocachedClient.connect()`, this
|
|
77
|
+
backend doesn't need to be told which; it finds out from the server's own
|
|
78
|
+
handshake. A `LOCATION` list is one address per entry; a single string may
|
|
79
|
+
also carry more than one address separated by `,`/`;`.
|
|
80
|
+
|
|
81
|
+
`TLS`/`CA`/`COMPRESS`/`COMPRESSION_THRESHOLD`/`FIRE_AND_FORGET_REPLICAS`/
|
|
82
|
+
`READ_REPAIR`/`READ_HEDGE_AFTER`/`RECONNECT_COOLDOWN` are forwarded
|
|
83
|
+
straight through to the matching `NanocachedClient.connect()` keyword
|
|
84
|
+
(`tls`, `ca`, `compress`, `compression_threshold`,
|
|
85
|
+
`fire_and_forget_replicas`, `read_repair`, `read_hedge_after`,
|
|
86
|
+
`reconnect_cooldown` — see that method's docstring for what each one
|
|
87
|
+
does) — each is optional and, when the OPTIONS entry omits it, the SDK's
|
|
88
|
+
own default is what actually takes effect, not some second default
|
|
89
|
+
chosen by this backend. `CA` is a filesystem path to a CA bundle and only
|
|
90
|
+
matters when `TLS` is also true. `READ_HEDGE_AFTER` and
|
|
91
|
+
`RECONNECT_COOLDOWN` are seconds (`float`); everything else here is a
|
|
92
|
+
`bool` except `COMPRESSION_THRESHOLD`, an `int` byte count.
|
|
93
|
+
|
|
94
|
+
## The sync/async bridge
|
|
95
|
+
|
|
96
|
+
The Python SDK is asyncio-only; Django's cache SPI is sync-first. Each
|
|
97
|
+
`NanocachedCache` instance owns a dedicated daemon-thread event loop,
|
|
98
|
+
started lazily on first use, and every sync call (`get`/`set`/...) is
|
|
99
|
+
dispatched onto it with `asyncio.run_coroutine_threadsafe(...).result()` —
|
|
100
|
+
never `asyncio.run()` per call, which would redo the SDK's connect
|
|
101
|
+
handshake on every single cache operation instead of reusing one
|
|
102
|
+
persistent connection, and never requires an ambient running loop either.
|
|
103
|
+
|
|
104
|
+
Django's own `aget`/`aset`/`aadd`/... come for free: `BaseCache` wraps
|
|
105
|
+
every sync method in `sync_to_async(..., thread_sensitive=True)`, so this
|
|
106
|
+
backend implements no native async path of its own — doing so would just
|
|
107
|
+
be a second bridge fighting the first for the same event loop.
|
|
108
|
+
|
|
109
|
+
### Lifecycle note
|
|
110
|
+
|
|
111
|
+
Django connects `close_caches()` to the `request_finished` signal
|
|
112
|
+
unconditionally (`django/core/cache/__init__.py`), which calls `.close()`
|
|
113
|
+
on every cache alias a thread has already touched — after **every**
|
|
114
|
+
request. Honoring that literally would re-dial TCP (+ auth, + a discovery
|
|
115
|
+
refresh in cluster mode) once per request, defeating the persistent
|
|
116
|
+
connections the client is built around. So, like django-redis, this
|
|
117
|
+
backend's `close()` is a **no-op by default**: the loop thread and
|
|
118
|
+
connection survive the request cycle.
|
|
119
|
+
|
|
120
|
+
- `OPTIONS: {"CLOSE_ON_REQUEST": True}` opts into real per-request
|
|
121
|
+
teardown, if short-lived processes matter more than connection reuse.
|
|
122
|
+
- `backend.shutdown()` always tears down (loop thread stopped, client
|
|
123
|
+
closed); the next operation lazily reconnects. Use it at process exit
|
|
124
|
+
or in tests.
|
|
125
|
+
|
|
126
|
+
### Preforking servers (Gunicorn `preload_app`, uWSGI)
|
|
127
|
+
|
|
128
|
+
The bridge is fork-aware (issue #393): the backend records which process
|
|
129
|
+
started its loop thread and rebuilds the loop, thread and client on the
|
|
130
|
+
first cache operation in a `fork()` child. Without that, a warm-up cache
|
|
131
|
+
touch in a preloading master (e.g. from `AppConfig.ready()`) would leave
|
|
132
|
+
every worker holding a loop whose driving thread died in the fork —
|
|
133
|
+
and each operation would block forever on
|
|
134
|
+
`run_coroutine_threadsafe(...).result()`. The parent's inherited sockets
|
|
135
|
+
are deliberately left untouched in the child; the parent remains their
|
|
136
|
+
owner. No configuration is needed — `preload_app = True` and uWSGI
|
|
137
|
+
without `lazy-apps` both just work.
|
|
138
|
+
|
|
139
|
+
## Timeouts
|
|
140
|
+
|
|
141
|
+
Django's `timeout` conventions are translated to nanocached's wire TTL
|
|
142
|
+
(whole seconds; on the wire, unlike Django's own convention, `0` means
|
|
143
|
+
"no expiry", not "expire now"):
|
|
144
|
+
|
|
145
|
+
| Django `timeout` | wire TTL |
|
|
146
|
+
| -------------------------------- | -------------------------------------- |
|
|
147
|
+
| not passed (`DEFAULT_TIMEOUT`) | this alias's `TIMEOUT` setting |
|
|
148
|
+
| `None` | `0` (no expiry — the one case the two conventions agree) |
|
|
149
|
+
| `0` or negative | nothing is written; `set()` deletes any existing value instead |
|
|
150
|
+
| positive, sub-second | rounds **up** to `1` (never down to `0`, which would mean eternal) |
|
|
151
|
+
| positive, ≥ 1s | rounds up to the next whole second |
|
|
152
|
+
|
|
153
|
+
## Usage
|
|
154
|
+
|
|
155
|
+
With `CACHES` pointed at this backend, use the standard API:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from django.core.cache import cache, caches
|
|
159
|
+
|
|
160
|
+
cache.set("greeting", "hello", timeout=60)
|
|
161
|
+
cache.get("greeting") # "hello"
|
|
162
|
+
cache.get("missing", "default") # "default"
|
|
163
|
+
|
|
164
|
+
sessions = caches["sessions"] # a second alias, its own NAMESPACE
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`@cache_page` and the whole-site `CacheMiddleware` work unchanged:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from django.views.decorators.cache import cache_page
|
|
171
|
+
|
|
172
|
+
@cache_page(60, cache="default")
|
|
173
|
+
def my_view(request):
|
|
174
|
+
...
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Consistency notes
|
|
178
|
+
|
|
179
|
+
`add()` and `touch()` are get-then-set (see above), not atomic.
|
|
180
|
+
`get_many`/`set_many` batch by owning node (issues #150/#152) and
|
|
181
|
+
`delete_many` fans out per key — in every case a failure partway through
|
|
182
|
+
leaves whichever keys were already processed changed rather than rolling
|
|
183
|
+
back. `incr`/`decr` (issue #129) are atomic on the node that owns the
|
|
184
|
+
key, since the wire has a real `INCR`.
|
|
185
|
+
|
|
186
|
+
## Counter storage
|
|
187
|
+
|
|
188
|
+
To make `incr`/`decr` possible, this backend changed what a plain `int`
|
|
189
|
+
value looks like on the wire: instead of the usual pickle round trip, an
|
|
190
|
+
`int` (excluding `bool`) is stored as `INCR`'s own decimal-ASCII grammar
|
|
191
|
+
— the same bytes the wire's atomic counter reads and writes directly.
|
|
192
|
+
Everything else — `str`, `dict`, model instances, `None`, `bool`, ... —
|
|
193
|
+
is still pickled exactly as before. `get()` tells the two apart by the
|
|
194
|
+
bytes themselves: a pickle stream always begins with the `0x80` PROTO
|
|
195
|
+
opcode (Python's pickle protocol 2+, which `HIGHEST_PROTOCOL` always is),
|
|
196
|
+
a byte that can never start an ASCII decimal integer, so there is no
|
|
197
|
+
ambiguity and no extra marker byte on the wire.
|
|
198
|
+
|
|
199
|
+
**This is a compatibility break for a rolling deploy that shares a cache
|
|
200
|
+
across old and new `nanocached-django` code**, not a nanocached-server
|
|
201
|
+
concern at all — the node just stores whatever opaque bytes it's given,
|
|
202
|
+
on any version. The risk is entirely between Django app instances:
|
|
203
|
+
|
|
204
|
+
- An **old** worker's `get()` unconditionally calls `pickle.loads(raw)`.
|
|
205
|
+
If a **new** worker already wrote an `int` under that key (as raw
|
|
206
|
+
decimal-ASCII, not pickled), the old worker's `get()`/`get_many()`
|
|
207
|
+
**raises** (an unpickling error), not just returns a wrong value.
|
|
208
|
+
- A **new** worker's `get()` handles both encodings fine, including an
|
|
209
|
+
`int` an old worker pickled — that direction round-trips correctly.
|
|
210
|
+
|
|
211
|
+
So the break is one-directional (old code choking on new-written ints)
|
|
212
|
+
and only matters while old and new code run concurrently against the
|
|
213
|
+
same shared keys — a normal rolling deploy window. It resolves itself
|
|
214
|
+
once the rollout finishes; nothing needs a manual fix-up. If a rolling
|
|
215
|
+
deploy of this adapter's version must never error on an `int` key, drain
|
|
216
|
+
or version-bump that key's namespace instead of upgrading it in place.
|
|
217
|
+
|
|
218
|
+
## Trust boundary / deserialization
|
|
219
|
+
|
|
220
|
+
Values (other than plain `int`, see "Counter storage" above) are read
|
|
221
|
+
back with `pickle.loads()` — the Django convention, and the same model as
|
|
222
|
+
Django's own memcached backend. That means **anyone who can write to this
|
|
223
|
+
cache's namespace can execute code in the process that reads it back** —
|
|
224
|
+
any holder of the cluster's auth secret, or, when no secret is
|
|
225
|
+
configured, any peer that can reach the server at all. This is inherent
|
|
226
|
+
to `pickle.loads()` on untrusted bytes, not specific to nanocached.
|
|
227
|
+
|
|
228
|
+
Mitigate with all of:
|
|
229
|
+
|
|
230
|
+
- **`OPTIONS.TLS` + `OPTIONS.SECRET`**, so only trusted processes can
|
|
231
|
+
write to the namespace at all.
|
|
232
|
+
- **Never share a namespace (or its secret) with an untrusted writer** —
|
|
233
|
+
`OPTIONS.NAMESPACE` is cheap and isolated (see above), so give each
|
|
234
|
+
trust domain its own alias/namespace rather than reusing one across
|
|
235
|
+
services with different trust levels.
|
|
236
|
+
- There is no pluggable serializer in this backend today; where writers
|
|
237
|
+
aren't fully trusted, isolate this cache's namespace to trusted writers
|
|
238
|
+
only rather than relying on the pickle stream itself for safety.
|
|
239
|
+
|
|
240
|
+
## Requirements
|
|
241
|
+
|
|
242
|
+
Python 3.11+, Django 4.2+, `nanocached` (Python SDK) 0.3+, a nanocached
|
|
243
|
+
server ≥ the release that ships namespaces and CLEAR (issues #105/#106).
|
|
244
|
+
|
|
245
|
+
## Policy note
|
|
246
|
+
|
|
247
|
+
Framework adapters are ecosystem-specific and live **outside** the
|
|
248
|
+
six-language SDK parity policy ([#25]): parity applies to the SDK core
|
|
249
|
+
only. This module exists for Django; other ecosystems get their own
|
|
250
|
+
idiomatic adapters (Spring Cache, `IDistributedCache`, cache-manager
|
|
251
|
+
store, [JCache](../jcache), [Keyv](../keyv)) rather than mirrors of this
|
|
252
|
+
one.
|
|
253
|
+
|
|
254
|
+
[#25]: https://github.com/nanocached/nanocached/issues/25
|
|
255
|
+
|
|
256
|
+
## Testing
|
|
257
|
+
|
|
258
|
+
```sh
|
|
259
|
+
cd adapters/django
|
|
260
|
+
PYTHONPATH=src:../../sdk/python/src python3 -m unittest discover -s tests -v
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Tests run against a small in-module `MockNode` (`tests/mock_node.py`, a
|
|
264
|
+
trimmed re-implementation of the SDK's own test double, which is private
|
|
265
|
+
to the SDK's suite) speaking the wire protocol directly — no server
|
|
266
|
+
binary needed, and no PyPI install of the SDK either, since the sibling
|
|
267
|
+
`sdk/python` sources are put on `PYTHONPATH` instead.
|
|
268
|
+
|
|
269
|
+
MIT license.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nanocached-django"
|
|
7
|
+
version = "0.4.4"
|
|
8
|
+
description = "Django cache backend for nanocached, a tiny distributed cache with client-side replication"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "Takashi Yamashina" }]
|
|
13
|
+
keywords = ["nanocached", "cache", "django", "distributed-cache"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Framework :: Django",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Software Development :: Libraries",
|
|
20
|
+
]
|
|
21
|
+
# Not part of the six-language SDK parity policy (#25) — this is an
|
|
22
|
+
# ecosystem-specific adapter on top of the Python SDK core, like the
|
|
23
|
+
# Spring adapter is on top of the Java SDK. Released at the project's one
|
|
24
|
+
# shared version (server, SDKs and adapters move together), pinned to the
|
|
25
|
+
# SDK release of the same cycle.
|
|
26
|
+
dependencies = ["nanocached>=0.4.4", "Django>=4.2"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://nanocached.org"
|
|
30
|
+
Repository = "https://github.com/nanocached/nanocached"
|
|
31
|
+
Issues = "https://github.com/nanocached/nanocached/issues"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Django cache backend for nanocached (issue #108).
|
|
2
|
+
|
|
3
|
+
See https://github.com/nanocached/nanocached for the server and protocol,
|
|
4
|
+
and this package's README for setup — adding the dependency alone changes
|
|
5
|
+
nothing; ``CACHES["default"]["BACKEND"]`` has to name ``NanocachedCache``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .backend import NanocachedCache
|
|
9
|
+
|
|
10
|
+
__all__ = ["NanocachedCache"]
|
|
11
|
+
|
|
12
|
+
# Independent of the SDK core's own version (nanocached/__init__.py) —
|
|
13
|
+
# this adapter is versioned and released on its own, like the Spring
|
|
14
|
+
# adapter is on top of the Java SDK (issue #108 spec, "Policy note").
|
|
15
|
+
__version__ = "0.1.0"
|