Skip to content

Commit 4bcfe15

Browse files
authored
feat: stream-synchronized local cache (SyncCache) (#39)
* feat: stream-synchronized local cache (SyncCache) Add SyncCache backend that keeps a local in-memory dict synchronized across pods via Redis Streams. Reads are purely local (zero network latency); writes update local dict and broadcast via XADD. A daemon thread consumes the stream via XREAD BLOCK to apply remote changes. Suitable for read-heavy, write-light workloads (config, feature flags). * refactor: non-blocking publish, remove stream replay, fix pod isolation - Make _publish() non-blocking via ThreadPoolExecutor(max_workers=1) so writes return instantly without waiting for Redis round-trip - Remove stream replay on startup (cache fills naturally from usage) - Add _STORAGE_KEY option for test pod isolation - Add _flush_publishes() helper for deterministic cross-instance tests * fix: consumer thread self-healing, poison message skip, health tracking - _ensure_consumer checks is_alive() and auto-restarts dead threads instead of silently falling out of sync once _initialized=True - Advance _last_id before _apply_message so corrupt messages are skipped, not retried forever (poison message livelock) - Track _last_read_time; expose consumer_alive, last_read_age_seconds, last_stream_id, pod_id in info()["sync"] for ops visibility - Document eventual consistency race conditions on add/incr * feat: write-through to transport, atomic add/incr/decr via Redis - All mutations (set, delete, delete_many, touch, clear) write-through to the transport cache (non-blocking, best-effort) so Redis stays in sync with local state - add() routes through transport.add() (Redis SET NX) for cross-pod atomicity — only one pod succeeds when racing on the same key - incr()/decr() route through transport.incr() (Redis INCR) for atomic cross-pod increments — no more lost updates - _flush_publishes() acts as a fence before atomic ops to ensure pending write-throughs have reached the transport - Fix shutdown() order: stop consumer thread before executor - Add __del__ to prevent thread/executor leaks on GC - Rename key → made_key throughout to preserve user key for transport * refactor: SyncCache extends LocMemCache, admin improvements - SyncCache now inherits from LocMemCache instead of BaseCache, reusing all local storage logic (OrderedDict, expiry, culling, locking) - Remove custom make_key — Django's default produces the same format - Set _cachex_support to "cachex" (full admin support) - Add _cachex_location for custom admin display ("stream:cache:sync [transport: ...]") - Cache.location model property checks _cachex_location attribute before falling back to LOCATION config key - Cache list view defaults to settings definition order instead of alphabetical; clicking "Name" column sorts explicitly - Add SyncCache + dedicated transport to full example * feat: configurable stream replay on startup (REPLAY option) New OPTIONS["REPLAY"] (default 0) replays the last N stream entries via XREVRANGE on startup to warm the local cache. A restarting pod no longer starts with an empty cache — it picks up recent state from the stream before the consumer thread begins live consumption. * refactor: drop add/incr/decr support, remove write-through add(), incr(), and decr() now raise NotSupportedError — their atomic semantics (check-and-set, atomic increment) are incompatible with eventual consistency. Users needing these should use the transport cache directly. This removes all write-through infrastructure (no more Redis writes on set/delete/touch/clear) making SyncCache a pure local-first cache with stream-only synchronization. get_or_set() is overridden to use set() instead of add(). The remaining API (get, set, get_many, set_many, delete, delete_many, touch, clear, get_or_set) is fully eventually consistent and covers the needs of packages like django-cachalot. * fix: stale comment, recreate executor after shutdown reuse
1 parent 3e03089 commit 4bcfe15

File tree

8 files changed

+1401
-11
lines changed

8 files changed

+1401
-11
lines changed

django_cachex/admin/models.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,15 @@ def backend_short(self) -> str:
6060

6161
@property
6262
def location(self) -> str:
63-
"""Get the cache location."""
63+
"""Get the cache location.
64+
65+
Checks for a ``_cachex_location`` attribute on the cache instance
66+
first (used by backends like SyncCache that have no ``LOCATION``
67+
setting), then falls back to the ``LOCATION`` config key.
68+
"""
69+
cache = self._get_cache()
70+
if cache is not None and hasattr(cache, "_cachex_location"):
71+
return cache._cachex_location
6472
loc = self.config.get("LOCATION", "")
6573
if isinstance(loc, list):
6674
return ", ".join(str(item) for item in loc)

django_cachex/admin/queryset.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,18 @@ def filter(self, *args: Any, **kwargs: Any) -> CacheQuerySet:
9292
return clone
9393

9494
def order_by(self, *fields: str) -> CacheQuerySet:
95-
"""Sort by name/pk. Unknown fields are ignored."""
95+
"""Sort by name when explicitly requested via column header click.
96+
97+
By default, caches are returned in settings definition order
98+
(the insertion order of ``settings.CACHES``). Django's ChangeList
99+
appends a ``-pk`` fallback which is intentionally ignored here so
100+
that definition order is preserved unless the user clicks the
101+
name column header.
102+
"""
96103
clone = self._clone()
97104
for field in fields:
98105
bare = field.lstrip("-")
99-
if bare in ("name", "pk"):
106+
if bare == "name":
100107
clone._data.sort(key=lambda c: c.name, reverse=field.startswith("-"))
101108
break
102109
return clone
@@ -164,7 +171,7 @@ class CacheAdminMixin:
164171
list_display_links: ClassVar[Any] = ["name"]
165172
list_filter: ClassVar[Any] = [SupportLevelFilter]
166173
search_fields: ClassVar[Any] = ["name"]
167-
ordering: ClassVar[Any] = ["name"]
174+
ordering: ClassVar[Any] = []
168175
actions: ClassVar[Any] = ["flush_selected"]
169176
list_per_page: ClassVar[int] = 100
170177

django_cachex/cache/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
RedisSentinelCache,
1919
ValkeySentinelCache,
2020
)
21+
from django_cachex.cache.sync import SyncCache
2122
from django_cachex.cache.tiered import TieredCache
2223

2324
__all__ = [
@@ -27,6 +28,7 @@
2728
"RedisCache",
2829
"RedisClusterCache",
2930
"RedisSentinelCache",
31+
"SyncCache",
3032
"TieredCache",
3133
"ValkeyCache",
3234
"ValkeyClusterCache",

0 commit comments

Comments
 (0)