Fix remaining cache issues: index TTL, sub-index cleanup, top-level imports

- RedisCache.put: add pipe.expire() on index sets matching entry TTL,
  prevents orphaned index entries when cache values expire
- Broad purge: delete_indexes_by_prefix() cleans per-param sub-indexes
  (mizan:idx:ctx:k=v) that previously leaked as dead sets
- Move cache imports to top of executor.py (were inline in view functions)
- Update KNOWN_ISSUES.md — all 16 issues now resolved or documented

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 23:09:22 -04:00
parent b06a65e133
commit 7daec1c2e2
3 changed files with 25 additions and 5 deletions

View File

@@ -172,6 +172,9 @@ def cache_purge(
keys_to_delete = backend.get_index(index_key)
backend.delete_index(index_key)
# Clean up per-param sub-indexes (e.g., mizan:idx:user:user_id=5)
backend.delete_indexes_by_prefix(f"mizan:idx:{context}:")
if keys_to_delete:
return backend.delete_many(list(keys_to_delete))
return 0

View File

@@ -20,6 +20,7 @@ class CacheBackend(Protocol):
def get_index(self, index_key: str) -> set[str]: ...
def remove_from_index(self, index_key: str, members: set[str]) -> None: ...
def delete_index(self, index_key: str) -> None: ...
def delete_indexes_by_prefix(self, prefix: str) -> None: ...
def clear(self) -> None: ...
@@ -65,6 +66,11 @@ class MemoryCache:
def delete_index(self, index_key: str) -> None:
self._indexes.pop(index_key, None)
def delete_indexes_by_prefix(self, prefix: str) -> None:
to_delete = [k for k in self._indexes if k.startswith(prefix)]
for k in to_delete:
del self._indexes[k]
def clear(self) -> None:
self._store.clear()
self._indexes.clear()
@@ -116,6 +122,7 @@ class RedisCache:
pipe.set(prefixed_key, value, ex=self._ttl)
for idx in indexes:
pipe.sadd(self._key(idx), key)
pipe.expire(self._key(idx), self._ttl)
pipe.execute()
def delete_many(self, keys: list[str]) -> int:
@@ -135,6 +142,19 @@ class RedisCache:
def delete_index(self, index_key: str) -> None:
self._client.delete(self._key(index_key))
def delete_indexes_by_prefix(self, prefix: str) -> None:
pattern = f"{self._prefix}{prefix}*"
cursor = 0
while True:
cursor, keys = self._client.scan(cursor, match=pattern, count=100)
if keys:
pipe = self._client.pipeline()
for key in keys:
pipe.unlink(key)
pipe.execute()
if cursor == 0:
break
def clear(self) -> None:
pattern = f"{self._prefix}*"
cursor = 0

View File

@@ -27,7 +27,9 @@ from django.http import HttpRequest, HttpResponse, JsonResponse
from django.views.decorators.csrf import csrf_protect
from pydantic import BaseModel, ValidationError
from mizan.cache import get_cache, cache_get, cache_put, cache_purge
from mizan.setup.registry import get_function, get_context_groups
from mizan.setup.settings import get_settings
if TYPE_CHECKING:
pass
@@ -662,8 +664,6 @@ def function_call_view(request: HttpRequest) -> JsonResponse:
response["X-Mizan-Invalidate"] = _format_invalidate_header(invalidate_contexts)
# Purge origin-side cache for invalidated contexts
import logging
from mizan.cache import get_cache, cache_purge
_cache_log = logging.getLogger("mizan.cache")
cache = get_cache()
if cache is not None:
@@ -776,9 +776,6 @@ def context_fetch_view(request: HttpRequest, context_name: str) -> JsonResponse:
params = request.GET.dict()
# Origin-side cache lookup
import logging
from mizan.cache import get_cache, cache_get, cache_put
from mizan.setup.settings import get_settings
_cache_log = logging.getLogger("mizan.cache")
cache = get_cache()
cache_settings = get_settings()