Skip to main content

nanocached-django

Django cache backend for the nanocached Python SDK: django.core.cache.backends.base.BaseCache implemented on nanocached.NanocachedClient, so django.core.cache.caches, @cache_page and friends run against a nanocached cluster.

  • Cache alias ⇄ namespace. Each NanocachedCache instance binds to one nanocached namespace (OPTIONS.NAMESPACE, default "django") — two aliases with different namespaces never collide, even sharing the same node, and cache.clear() is that namespace's CLEAR (one sub-map removal on every node with the entries freed off its cache thread, no key scan, and never the whole store).
  • get/set/delete/has_key/touch map onto the SDK's namespaced get/set/delete, with all of its routing, replication, and retries.
  • add is atomic: it is the wire's put-if-absent, evaluated by the key's primary owner alone, so of several racing add() calls exactly one returns True. touch is get-then-set, not atomic — a write that lands between its read and its re-set is overwritten with the pre-touch value.
  • get_many/set_many use the wire's batched multi-get/multi-set: one round trip per involved node instead of one per key. The values in one batch reply total at most 16 MiB, and a hit past that budget is answered as a miss, so get_many over many large keys can omit a key that exists — get() it on its own rather than taking it for evicted. delete_many remains a concurrent client-side fan-out — the wire has no multi-key delete.
  • incr/decr use the wire's own atomic counter (INCR): atomic on the node that owns the key, unlike touch() above. Matches BaseCache's own contract — raises ValueError if the key doesn't exist — and is exactly as volatile as set(): LRU eviction or this alias's TIMEOUT reclaim a counter the same as any other entry, so it suits rate limiting and approximate counts, never a count that must survive (billing, inventory). See "Counter storage" below for how this changes what's on the wire.
  • Values are pickled (pickle.dumps(..., HIGHEST_PROTOCOL)), the Django convention — anything picklable round-trips, None included (distinguished from a cache miss, which is nanocached's own "no value") — except a plain int (bool excluded), which is stored as INCR's own decimal-ASCII counter grammar instead, so incr/decr can operate on it server-side. See "Counter storage" below.

Install

pip install nanocached-django

Pulls in the nanocached SDK of the same version and Django>=4.2.

Setup

Point CACHES["default"]["BACKEND"] at this class — adding the nanocached-django dependency alone changes nothing:

CACHES = {
    "default": {
        "BACKEND": "nanocached_django.NanocachedCache",
        "LOCATION": ["10.0.0.1:8357", "10.0.0.2:8357"],  # or "host:port"
        "OPTIONS": {
            "NAMESPACE": "django",   # default "django"
            "SECRET": "...",         # optional; passed to connect() as auth_secret
            "TLS": True,             # optional; connect() tls, default False
            "CA": "/path/ca.pem",    # optional; connect() ca, only meaningful with TLS
            "COMPRESS": True,        # optional; connect() compress, default False
            "COMPRESSION_THRESHOLD": 512,     # optional; connect() compression_threshold
            "FIRE_AND_FORGET_REPLICAS": True, # optional; connect() fire_and_forget_replicas
            "READ_REPAIR": True,              # optional; connect() read_repair
            "READ_HEDGE_AFTER": 0.05,         # optional; connect() read_hedge_after (seconds)
            "RECONNECT_COOLDOWN": 1.0,        # optional; connect() reconnect_cooldown (seconds)
        },
        "TIMEOUT": 300,               # Django's own key, honored as usual
        "KEY_PREFIX": "...",
        "VERSION": 1,
    }
}

LOCATION may name a single nanocached node or one or more discovery servers fronting a cluster — same as NanocachedClient.connect(), this backend doesn't need to be told which; it finds out from the server's own handshake. A LOCATION list is one address per entry; a single string may also carry more than one address separated by ,/;.

TLS/CA/COMPRESS/COMPRESSION_THRESHOLD/FIRE_AND_FORGET_REPLICAS/ READ_REPAIR/READ_HEDGE_AFTER/RECONNECT_COOLDOWN are forwarded straight through to the matching NanocachedClient.connect() keyword (tls, ca, compress, compression_threshold, fire_and_forget_replicas, read_repair, read_hedge_after, reconnect_cooldown — see that method's docstring for what each one does) — each is optional and, when the OPTIONS entry omits it, the SDK's own default is what actually takes effect, not some second default chosen by this backend. CA is a filesystem path to a CA bundle and only matters when TLS is also true. READ_HEDGE_AFTER and RECONNECT_COOLDOWN are seconds (float); everything else here is a bool except COMPRESSION_THRESHOLD, an int byte count.

The sync/async bridge

The Python SDK is asyncio-only; Django's cache SPI is sync-first. Each connection configuration owns a dedicated daemon-thread event loop and client, started lazily on first use and shared by every NanocachedCache instance built from the same LOCATION/SECRET/connection OPTIONS (Django builds a fresh instance per thread or ASGI request context, so per-instance loops and sockets would pile up). Every sync call (get/set/...) is dispatched onto it with asyncio.run_coroutine_threadsafe(...).result() — never asyncio.run() per call, which would redo the SDK's connect handshake on every single cache operation instead of reusing one persistent connection, and never requires an ambient running loop either.

Django's own aget/aset/aadd/... come for free: BaseCache wraps every sync method in sync_to_async(..., thread_sensitive=True), so this backend implements no native async path of its own — doing so would just be a second bridge fighting the first for the same event loop.

Lifecycle note

Django connects close_caches() to the request_finished signal unconditionally (django/core/cache/__init__.py), which calls .close() on every cache alias a thread has already touched — after every request. Honoring that literally would re-dial TCP (+ auth, + a discovery refresh in cluster mode) once per request, defeating the persistent connections the client is built around. So, like django-redis, this backend's close() is a no-op by default: the loop thread and connection survive the request cycle.

  • OPTIONS: {"CLOSE_ON_REQUEST": True} opts into real per-request teardown, if short-lived processes matter more than connection reuse. Such an instance keeps its own private loop and client rather than the shared one, so closing it never disturbs other threads.
  • backend.shutdown() always tears down (loop thread stopped, client closed) for every instance sharing it; each reconnects lazily on its next operation. Shared loops are also closed at interpreter exit.

Preforking servers (Gunicorn preload_app, uWSGI)

The bridge is fork-aware: the backend records which process started its loop thread and rebuilds the loop, thread and client on the first cache operation in a fork() child. Without that, a warm-up cache touch in a preloading master (e.g. from AppConfig.ready()) would leave every worker holding a loop whose driving thread died in the fork — and each operation would block forever on run_coroutine_threadsafe(...).result(). The parent's inherited sockets are deliberately left untouched in the child; the parent remains their owner. No configuration is needed — preload_app = True and uWSGI without lazy-apps both just work.

Timeouts

Django's timeout conventions are translated to nanocached's wire TTL (whole seconds; on the wire, unlike Django's own convention, 0 means "no expiry", not "expire now"):

Django timeout wire TTL
not passed (DEFAULT_TIMEOUT) this alias's TIMEOUT setting
None 0 (no expiry — the one case the two conventions agree)
0 or negative nothing is written; set() deletes any existing value instead
positive, sub-second rounds up to 1 (never down to 0, which would mean eternal)
positive, ≥ 1s rounds up to the next whole second

Usage

With CACHES pointed at this backend, use the standard API:

from django.core.cache import cache, caches

cache.set("greeting", "hello", timeout=60)
cache.get("greeting")            # "hello"
cache.get("missing", "default")  # "default"

sessions = caches["sessions"]    # a second alias, its own NAMESPACE

@cache_page and the whole-site CacheMiddleware work unchanged:

from django.views.decorators.cache import cache_page

@cache_page(60, cache="default")
def my_view(request):
    ...

Consistency notes

touch() is get-then-set (see above), not atomic; add() is. get_many/set_many batch by owning node and delete_many fans out per key — in every case a failure partway through leaves whichever keys were already processed changed rather than rolling back. incr/decr are atomic on the node that owns the key, since the wire has a real INCR.

Counter storage

To make incr/decr possible, this backend changed what a plain int value looks like on the wire: instead of the usual pickle round trip, an int (excluding bool) within the signed 64-bit range the wire counter holds is stored as INCR's own decimal-ASCII grammar — the same bytes the wire's atomic counter reads and writes directly. Everything else — str, dict, model instances, None, bool, an int outside that range (which incr could not increment anyway), ... — is still pickled exactly as before, and a pickled value has no size limit of its own beyond the request size limit below. get() tells the two apart by the bytes themselves: a pickle stream always begins with the 0x80 PROTO opcode (Python's pickle protocol 2+, which HIGHEST_PROTOCOL always is), a byte that can never start an ASCII decimal integer, so there is no ambiguity and no extra marker byte on the wire.

This is a compatibility break for a rolling deploy that shares a cache across old and new nanocached-django code, not a nanocached-server concern at all — the node just stores whatever opaque bytes it's given, on any version. The risk is entirely between Django app instances:

  • An old worker's get() unconditionally calls pickle.loads(raw). If a new worker already wrote an int under that key (as raw decimal-ASCII, not pickled), the old worker's get()/get_many() raises (an unpickling error), not just returns a wrong value.
  • A new worker's get() handles both encodings fine, including an int an old worker pickled — that direction round-trips correctly.

So the break is one-directional (old code choking on new-written ints) and only matters while old and new code run concurrently against the same shared keys — a normal rolling deploy window. It resolves itself once the rollout finishes; nothing needs a manual fix-up. If a rolling deploy of this adapter's version must never error on an int key, drain or version-bump that key's namespace instead of upgrading it in place.

Trust boundary / deserialization

Values (other than plain int, see "Counter storage" above) are read back with pickle.loads() — the Django convention, and the same model as Django's own memcached backend. That means anyone who can write to this cache's namespace can execute code in the process that reads it back — any holder of the cluster's auth secret, or, when no secret is configured, any peer that can reach the server at all. This is inherent to pickle.loads() on untrusted bytes, not specific to nanocached.

Mitigate with all of:

  • OPTIONS.TLS + OPTIONS.SECRET, so only trusted processes can write to the namespace at all.
  • Never share a namespace (or its secret) with an untrusted writer — OPTIONS.NAMESPACE is cheap and isolated (see above), so give each trust domain its own alias/namespace rather than reusing one across services with different trust levels.
  • There is no pluggable serializer in this backend today; where writers aren't fully trusted, isolate this cache's namespace to trusted writers only rather than relying on the pickle stream itself for safety.

When the node is unreachable

Every operation fails fast rather than hanging: the SDK bounds each connect and each request by its timeouts. A node that is down makes every cache method raise — the SDK's ConnectionLostError for a connection that was lost, or the operating system's own error (ConnectionRefusedError, socket.gaierror, ...) when no connection could be opened at all. This backend does not catch it, and neither do cache.get() in a view, @cache_page or CacheMiddleware: the request fails. Django's cache-backed session store is the exception: its load() catches any exception and starts an empty session, so an outage there looks like every user being logged out.

Value size limit

A namespace, key and value together must fit in one request: 1 MiB minus a 256-byte header, 1,048,320 bytes. A set/add/set_many entry over that size raises the SDK's ValueError before anything is sent, and nothing is written. The value is measured after pickling (or as the decimal digits of an int), and after compression when OPTIONS.COMPRESS is on.

Requirements

Python 3.11+, Django 4.2+, nanocached (Python SDK) 0.4.8+ (what pyproject.toml pins), a nanocached server 0.4.0 or later — the release that added namespaces, CLEAR, the put-if-absent add() uses, the INCR behind incr/decr, and the batched multi-get/multi-set behind get_many/set_many.

Policy note

Framework adapters are ecosystem-specific and live outside the six-language SDK parity policy: parity applies to the SDK core only. This module exists for Django; other ecosystems get their own idiomatic adapters (Spring Cache, IDistributedCache, cache-manager store, JCache, Keyv) rather than mirrors of this one.

Testing

cd adapters/django
PYTHONPATH=src:../../sdk/python/src python3 -m unittest discover -s tests -v

Tests run against a small in-module MockNode (tests/mock_node.py, a trimmed re-implementation of the SDK's own test double, which is private to the SDK's suite) speaking the wire protocol directly — no server binary needed, and no PyPI install of the SDK either, since the sibling sdk/python sources are put on PYTHONPATH instead.

MIT license.

Metadata

Release files for nanocached-django 0.4.8

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for nanocached-django 0.4.8
File Size Uploaded Provenance
nanocached_django-0.4.8.tar.gz 42.5 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for nanocached-django 0.4.8
File Interpreter ABI Platform
nanocached_django-0.4.8-py3-none-any.whl Python 3 none any Details

Total release size: 66.0 kB

Release files / nanocached_django-0.4.8.tar.gz

Download URL nanocached_django-0.4.8.tar.gz
Size 42.5 kB
Tags Source
SHA-256 checksum
How to use checksums
ebdb660545e893512bae58c7c6824054efd91b069c7e643cdee89b6f830baf99
BLAKE2b-256 checksum
How to use checksums
3bea94517da4ffbf846c8d77dd46b693906e229e464a870472a7808542b197a7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.4 {"installer":{"name":"uv","version":"0.13.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / nanocached_django-0.4.8-py3-none-any.whl

Download URL nanocached_django-0.4.8-py3-none-any.whl
Size 23.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
abec39f49e97dd7420d5fa5d8679b585edef32e4ad00552f1250ce930cbe8c4f
BLAKE2b-256 checksum
How to use checksums
9c023f01b227ddab18f1781ecb41b6bebd80233daef77b574ef9b707ccb93613
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.4 {"installer":{"name":"uv","version":"0.13.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Security for nanocached-django 0.4.8

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page