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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"