Files
rose-ash/shared/browser/app/redis_cacher.py
giles f42042ccb7
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
Monorepo: consolidate 7 repos into one
Combines shared, blog, market, cart, events, federation, and account
into a single repository. Eliminates submodule sync, sibling model
copying at build time, and per-app CI orchestration.

Changes:
- Remove per-app .git, .gitmodules, .gitea, submodule shared/ dirs
- Remove stale sibling model copies from each app
- Update all 6 Dockerfiles for monorepo build context (root = .)
- Add build directives to docker-compose.yml
- Add single .gitea/workflows/ci.yml with change detection
- Add .dockerignore for monorepo build context
- Create __init__.py for federation and account (cross-app imports)
2026-02-24 19:44:17 +00:00

347 lines
9.9 KiB
Python

from __future__ import annotations
from functools import wraps
from typing import Optional, Literal
import asyncio
from quart import (
Quart,
request,
Response,
g,
current_app,
)
from redis import asyncio as aioredis
Scope = Literal["user", "global", "anon"]
TagScope = Literal["all", "user"] # for clear_cache
# ---------------------------------------------------------------------------
# Redis setup
# ---------------------------------------------------------------------------
def register(app: Quart) -> None:
@app.before_serving
async def setup_redis() -> None:
if app.config["REDIS_URL"] and app.config["REDIS_URL"] != 'no':
app.redis = aioredis.Redis.from_url(
app.config["REDIS_URL"],
encoding="utf-8",
decode_responses=False, # store bytes
)
else:
app.redis = False
@app.after_serving
async def close_redis() -> None:
if app.redis:
await app.redis.close()
# optional: await app.redis.connection_pool.disconnect()
def get_redis():
return current_app.redis
# ---------------------------------------------------------------------------
# Key helpers
# ---------------------------------------------------------------------------
def get_user_id() -> str:
"""
Returns a string id or 'anon'.
Adjust based on your auth system.
"""
user = getattr(g, "user", None)
if user:
return str(user.id)
return "anon"
def make_cache_key(cache_user_id: str) -> str:
"""
Build a cache key for this (user/global/anon) + path + query + HTMX status.
HTMX requests and normal requests get different cache keys because they
return different content (partials vs full pages).
Keys are namespaced by app name (from CACHE_APP_PREFIX) to avoid
collisions between apps that may share the same paths.
"""
app_prefix = current_app.config.get("CACHE_APP_PREFIX", "app")
path = request.path
qs = request.query_string.decode() if request.query_string else ""
# Check if this is an HTMX request
is_htmx = request.headers.get("HX-Request", "").lower() == "true"
htmx_suffix = ":htmx" if is_htmx else ""
if qs:
return f"cache:{app_prefix}:page:{cache_user_id}:{path}?{qs}{htmx_suffix}"
else:
return f"cache:{app_prefix}:page:{cache_user_id}:{path}{htmx_suffix}"
def user_set_key(user_id: str) -> str:
"""
Redis set that tracks all cache keys for a given user id.
Only used when scope='user'.
"""
return f"cache:user:{user_id}"
def tag_set_key(tag: str) -> str:
"""
Redis set that tracks all cache keys associated with a tag
(across all scopes/users).
"""
return f"cache:tag:{tag}"
# ---------------------------------------------------------------------------
# Invalidation helpers
# ---------------------------------------------------------------------------
async def invalidate_user_cache(user_id: str) -> None:
"""
Delete all cached pages for a specific user (scope='user' caches).
"""
r = get_redis()
if r:
s_key = user_set_key(user_id)
keys = await r.smembers(s_key) # set of bytes
if keys:
await r.delete(*keys)
await r.delete(s_key)
async def invalidate_tag_cache(tag: str) -> None:
"""
Delete all cached pages associated with this tag, for all users/scopes.
"""
r = get_redis()
if r:
t_key = tag_set_key(tag)
keys = await r.smembers(t_key) # set of bytes
if keys:
await r.delete(*keys)
await r.delete(t_key)
async def invalidate_tag_cache_for_user(tag: str, cache_uid: str) -> None:
r = get_redis()
if not r:
return
t_key = tag_set_key(tag)
keys = await r.smembers(t_key) # set of bytes
if not keys:
return
prefix = f"cache:page:{cache_uid}:".encode("utf-8")
# Filter keys belonging to this cache_uid only
to_delete = [k for k in keys if k.startswith(prefix)]
if not to_delete:
return
# Delete those page entries
await r.delete(*to_delete)
# Remove them from the tag set (leave other users' keys intact)
await r.srem(t_key, *to_delete)
async def invalidate_tag_cache_for_current_user(tag: str) -> None:
"""
Convenience helper: delete tag cache for the current user_id (scope='user').
"""
uid = get_user_id()
await invalidate_tag_cache_for_user(tag, uid)
# ---------------------------------------------------------------------------
# Cache decorator for GET
# ---------------------------------------------------------------------------
def cache_page(
ttl: int = 0,
tag: Optional[str] = None,
scope: Scope = "user",
):
"""
Cache GET responses in Redis.
ttl:
Seconds to keep the cache. 0 = no expiry.
tag:
Optional tag name used for bulk invalidation via invalidate_tag_cache().
scope:
"user" → cache per-user (includes 'anon'), tracked in cache:user:{id}
"global" → single cache shared by everyone (no per-user tracking)
"anon" → cache only for anonymous users; logged-in users bypass cache
"""
def decorator(view):
@wraps(view)
async def wrapper(*args, **kwargs):
r = get_redis()
if not r or request.method != "GET":
return await view(*args, **kwargs)
uid = get_user_id()
# Decide who the cache key is keyed on
if scope == "global":
cache_uid = "global"
elif scope == "anon":
# Only cache for anonymous users
if uid != "anon":
return await view(*args, **kwargs)
cache_uid = "anon"
else: # scope == "user"
cache_uid = uid
key = make_cache_key(cache_uid)
cached = await r.hgetall(key)
if cached:
body = cached[b"body"]
status = int(cached[b"status"].decode())
content_type = cached.get(b"content_type", b"text/html").decode()
return Response(body, status=status, content_type=content_type)
# Not cached, call the view
resp = await view(*args, **kwargs)
# Normalise: if the view returned a string/bytes, wrap it
if not isinstance(resp, Response):
resp = Response(resp, content_type="text/html")
# Only cache successful responses
if resp.status_code == 200:
body = await resp.get_data() # bytes
pipe = r.pipeline()
pipe.hset(
key,
mapping={
"body": body,
"status": str(resp.status_code),
"content_type": resp.content_type or "text/html",
},
)
if ttl:
pipe.expire(key, ttl)
# Track per-user keys only when scope='user'
if scope == "user":
pipe.sadd(user_set_key(cache_uid), key)
# Track per-tag keys (all scopes)
if tag:
pipe.sadd(tag_set_key(tag), key)
await pipe.execute()
resp.set_data(body)
return resp
return wrapper
return decorator
# ---------------------------------------------------------------------------
# Clear cache decorator for POST (or any method)
# ---------------------------------------------------------------------------
def clear_cache(
*,
tag: Optional[str] = None,
tag_scope: TagScope = "all",
clear_user: bool = False,
):
"""
Decorator for routes that should clear cache after they run.
Use on POST/PUT/PATCH/DELETE handlers.
Params:
tag:
If set, will clear caches for this tag.
tag_scope:
"all" → invalidate_tag_cache(tag) (all users/scopes)
"user" → invalidate_tag_cache_for_current_user(tag)
clear_user:
If True, also run invalidate_user_cache(current_user_id).
Typical usage:
@bp.post("/posts/<slug>/edit")
@clear_cache(tag="post.post_detail", tag_scope="all")
async def edit_post(slug):
...
@bp.post("/prefs")
@clear_cache(tag="dashboard", tag_scope="user", clear_user=True)
async def update_prefs():
...
"""
def decorator(view):
@wraps(view)
async def wrapper(*args, **kwargs):
# Run the view first
resp = await view(*args, **kwargs)
if get_redis():
# Only clear cache if the view succeeded (2xx)
status = getattr(resp, "status_code", None)
if status is None:
# Non-Response return (string, dict) -> treat as success
success = True
else:
success = 200 <= status < 300
if not success:
return resp
# Perform invalidations
tasks = []
if clear_user:
uid = get_user_id()
tasks.append(invalidate_user_cache(uid))
if tag:
if tag_scope == "all":
tasks.append(invalidate_tag_cache(tag))
else: # tag_scope == "user"
tasks.append(invalidate_tag_cache_for_current_user(tag))
if tasks:
# Run them concurrently
await asyncio.gather(*tasks)
return resp
return wrapper
return decorator
async def clear_all_cache(prefix: str = "cache:") -> None:
r = get_redis()
if not r:
return
cursor = 0
pattern = f"{prefix}*"
while True:
cursor, keys = await r.scan(cursor=cursor, match=pattern, count=500)
if keys:
await r.delete(*keys)
if cursor == 0:
break