Add Redis-backed response caching for read-heavy API endpoints (nodes, advertisements, messages, channels, dashboard, profiles) with configurable TTL, key prefix isolation, and graceful fallback when Redis is unavailable. New files: - common/redis.py: CacheBackend, NullCache, RedisCacheBackend - api/cache.py: @cached decorator, sorted_query_string helper - tests/test_api/test_cache.py: 23 unit tests Changes: - pyproject.toml: add redis[hiredis] dependency - common/config.py: 8 Redis settings on APISettings - api/cli.py: Redis Click options + startup banner - api/app.py: Redis lifespan init/cleanup, X-Cache middleware, health check - 6 route files: apply @cached decorator to list endpoints - docker-compose.yml: Redis service (cache profile), env vars - docker-compose.dev.yml: Redis port exposure - .env.example, README.md, AGENTS.md, docs/upgrading.md: documentation Redis is disabled by default (REDIS_ENABLED=false). Enable with --profile cache and REDIS_ENABLED=true.
25 KiB
Redis Caching Layer for API Endpoints
Summary
Add a Redis-backed caching layer to the MeshCore Hub API to reduce database load and improve response times for read-heavy endpoints (Nodes, Messages, Advertisements, Channels, Map, Dashboard). The cache TTL should be short enough (defaulting to match the web dashboard's 30-second auto-refresh interval) to preserve near-real-time updates. Redis will be added as an optional service to the Docker Compose stack, configurable via environment variables, with graceful fallback to no-cache operation when Redis is unavailable.
Background & Motivation
The MeshCore Hub API currently executes every read query directly against SQLite on every request. The web dashboard's SPA auto-refreshes list pages every 30 seconds (WEB_AUTO_REFRESH_SECONDS), meaning each active browser session generates repeated identical queries. As the network grows (more nodes, more advertisements, more messages), these queries become increasingly expensive -- particularly the Nodes list with its multi-join filters (tags, adoptions, observers), the Dashboard stats endpoint (which runs ~15 separate SQL queries), and the Map data endpoint.
The metrics endpoint (api/metrics.py:30) already implements an in-process TTL cache pattern, but it uses a module-level dict -- this doesn't scale across API worker processes or support cache invalidation. A Redis-backed approach provides:
- Shared cache across workers: Multiple uvicorn workers or API containers share the same cache
- Configurable TTL per endpoint group: Dashboard stats can cache longer than node lists
- Graceful degradation: If Redis is down, requests fall through to the database (no errors)
- Foundation for future scaling: Enables horizontal API scaling behind a load balancer
Current Query Load Profile
| Endpoint | Query Complexity | Typical Call Frequency |
|---|---|---|
GET /api/v1/dashboard/stats |
~15 SQL queries (counts, joins, subqueries) | Every 30s per browser |
GET /api/v1/nodes |
Multi-join with tag/adoption/observer filters | Every 30s per browser |
GET /api/v1/advertisements |
Aliased joins, observer lookups | Every 30s per browser |
GET /api/v1/messages |
Channel visibility filtering, observer lookups | Every 30s per browser |
GET /api/v1/channels |
Simple select with visibility filter | Every 30s per browser |
Goals
- Reduce database query load by caching API responses in Redis
- Keep cache TTL aligned with the web auto-refresh interval (default 30s) for near-real-time UX
- Make Redis fully optional -- the API works identically without Redis (no errors, no performance regression)
- Support per-endpoint-group TTL configuration via environment variables
- Add Redis service to
docker-compose.ymlfor out-of-the-box Docker deployments - Support both synchronous and async Redis clients for future async migration
Non-Goals
- Caching write endpoints (POST, PUT, DELETE) -- these always hit the database directly
- Caching authenticated/per-user responses (e.g., user-specific channel visibility) -- cache only public responses or cache per-role
- Cache warming or pre-computation -- rely on natural request patterns to populate cache
- Replacing SQLite with Redis for data storage -- Redis is cache-only
- Client-side caching (browser Cache-Control headers) -- this is already handled by the web middleware for static assets
- Caching the Prometheus metrics endpoint -- it already has its own TTL cache pattern
- Caching the web
/map/dataendpoint -- it lives inweb/app.py(not the API) and its heavy query work is already cached viaGET /api/v1/nodes - Full cache invalidation on write events (e.g., invalidate node cache when a new node is seen by the collector) -- future enhancement
Requirements
Functional Requirements
- Redis cache is used for GET endpoints:
/api/v1/nodes,/api/v1/advertisements,/api/v1/messages,/api/v1/channels, and/api/v1/dashboard/* - Cache keys include the full request query string to differentiate filtered/paginated responses (e.g.,
nodes:?limit=50&offset=0&sort=last_seen). For role-sensitive endpoints (messages, channels, dashboard stats, message-activity), the resolved user role is included in the cache key asrole=anonymous(for unauthenticated/None),role=member,role=operator, orrole=admin. - Cached responses are served with correct
X-Cache: HIT/X-Cache: MISSheaders for observability - When Redis is not configured or unreachable, the API falls back to direct database queries with no errors
- Cache TTL defaults to the web auto-refresh interval (30 seconds) and is configurable per endpoint group via
REDIS_CACHE_TTL(default) andREDIS_CACHE_TTL_DASHBOARD(dashboard override) - Cache can be globally disabled via
REDIS_ENABLED=false - Cache is bypassed for authenticated admin/operator write operations (no stale data issues)
- Health check endpoint (
/health/ready) includes Redis connectivity status when configured
Technical Requirements
- Use
redis[hiredis]Python package (binary protocol parser for performance) - Redis connection managed via a singleton connection pool, initialized during FastAPI lifespan startup
- All Redis operations use timeouts and exception handling -- never block the API on Redis failures
- Configuration via environment variables matching existing patterns (
REDIS_HOST,REDIS_PORT,REDIS_DB,REDIS_PASSWORD) - Cache serialization uses JSON (not pickle) for safety and debuggability
- Docker Compose includes Redis service with persistent volume and health check
- Existing tests pass without Redis (tests mock or disable the cache layer)
Implementation Plan
Phase 1: Configuration & Redis Client
- Add
redis[hiredis]todependenciesinpyproject.toml - Add
redisto mypy ignore list inpyproject.toml(add"redis.*"to[[tool.mypy.overrides]]ignore list, consistent with existing pattern forpaho.mqtt, etc.) - Add
RedisSettingsor extendAPISettingsincommon/config.pywith:REDIS_ENABLED: bool = False(code default; Docker Compose overrides totrueviaREDIS_ENABLED:-true)REDIS_HOST: str = "localhost"REDIS_PORT: int = 6379REDIS_DB: int = 0REDIS_PASSWORD: Optional[str] = NoneREDIS_KEY_PREFIX: str = "hub"(prefix for all cache keys, enables multiple Hub instances to share one Redis without key collisions)REDIS_CACHE_TTL: int = 30(default cache TTL in seconds, aligned withWEB_AUTO_REFRESH_SECONDS)REDIS_CACHE_TTL_DASHBOARD: int = 30(override for all/dashboard/*endpoints)
- Create
common/redis.pywith:RedisClientclass wrapping aredis.Redisconnection poolget_redis()/get_redis_settings()helper functionsCacheBackendprotocol/class withget(key),set(key, value, ttl),delete(prefix)methods- A
NullCacheno-op implementation for when Redis is disabled
- Add Redis parameters to
create_app()inapi/app.py(matching existing parameter pattern):redis_enabled,redis_host,redis_port,redis_db,redis_password,redis_key_prefix,redis_cache_ttl,redis_cache_ttl_dashboard. Store them onapp.stateduringcreate_app()(same pattern asapp.state.database_url,app.state.metrics_cache_ttl). - Add corresponding Click options to
api/cli.pyand pass them through tocreate_app()(same pattern as--metrics-cache-ttl→metrics_cache_ttl).
Note — reload mode: When running
meshcore-hub api --reload, uvicorn uses the factory pattern (callingcreate_app()with no arguments), so all parameters default to their code defaults. This meansREDIS_ENABLED=false(safe fallback, no Redis) in reload mode. This is the same pre-existing limitation that affectsmetrics_cache_ttlandmetrics_enabledin reload mode.
Phase 2: Cache Middleware / Dependency
- Create
api/cache.pywith:sorted_query_string(request: Request) -> str: extracts query params from the Request, sorts them by key, URL-encodes each, and returns a deterministic string (e.g.,limit=50&offset=0). Empty query string returns"".CacheKeybuilder: generates deterministic keys fromREDIS_KEY_PREFIX+ endpoint path + sorted query params + user role (for role-sensitive endpoints like channels/messages). Role is resolved viaresolve_user_role(request)—Noneis mapped to"anonymous"in the key. Example keys:hub:nodes:limit=50&offset=0,hub:messages:role=anonymous:limit=50,hub:messages:role=admin:limit=50,hub-stg:dashboard:stats:role=member:cached_response()helper/decorator that:- Checks Redis for a cached JSON response
- On hit: returns the cached response with
X-Cache: HITheader - On miss: executes the route handler, stores the result in Redis with TTL, returns with
X-Cache: MISS - On Redis error: logs a warning and falls through to the handler
- Create a FastAPI dependency
get_cachethat provides the cache backend (either Redis or NullCache) - Initialize Redis connection in the FastAPI lifespan handler (
api/app.py) - Add Redis disposal to the lifespan shutdown
Phase 3: Apply Caching to API Routes
Apply the cache to read endpoints. The approach wraps the route handler logic rather than using middleware, to ensure cache keys include query parameters:
api/routes/nodes.py: Cachelist_nodes()(list endpoint only; single-node lookups by public key are fast enough)api/routes/advertisements.py: Cachelist_advertisements()api/routes/messages.py: Cachelist_messages()(include role in cache key for channel visibility)api/routes/channels.py: Cachelist_channels()(include role in cache key)api/routes/dashboard.py: Cacheget_stats()(~15 SQL queries),get_activity(),get_message_activity(),get_node_count_history()(all/dashboard/*endpoints shareREDIS_CACHE_TTL_DASHBOARD). Bothget_stats()andget_message_activity()need role-based cache keys (they filter channel counts/messages by visibility).- Web map data: Not cached directly.
/map/datalives inweb/app.py(not the API) and its heavy query work is already cached viaGET /api/v1/nodes. The in-memory aggregation step is lightweight.
Note: The @cached decorator requires request: Request in the handler's signature to read TTL settings and build cache keys. The following endpoints must gain this parameter:
list_nodes()(routes/nodes.py:50) — currently has noRequestlist_advertisements()(routes/advertisements.py:47) — currently has noRequestget_activity()(routes/dashboard.py:309) — currently has noRequestget_node_count_history()(routes/dashboard.py:422) — currently has noRequest
All other cached endpoints (list_messages, list_channels, get_stats, get_message_activity) already include request: Request.
Implementation pattern using the decorator:
Default (query-string key), uses REDIS_CACHE_TTL:
from meshcore_hub.api.cache import cached
@cached("nodes")
@router.get("", response_model=NodeList)
async def list_nodes(...) -> NodeList:
# existing logic unchanged
With dashboard TTL override and custom key builder (role-sensitive):
def dashboard_key_builder(request: Request) -> str:
role = resolve_user_role(request) or "anonymous"
return f"dashboard:stats:role={role}:{sorted_query_string(request)}"
# produces e.g. "dashboard:stats:role=anonymous:" (unauthenticated, no query params)
# produces e.g. "dashboard:stats:role=admin:" (admin user, no query params)
@cached("dashboard/stats", ttl_setting="redis_cache_ttl_dashboard", key_builder=dashboard_key_builder)
@router.get("/stats", response_model=DashboardStats)
async def get_stats(...) -> DashboardStats:
# existing logic unchanged
def messages_key_builder(request: Request) -> str:
role = resolve_user_role(request) or "anonymous"
return f"messages:role={role}:{sorted_query_string(request)}"
@cached("messages", key_builder=messages_key_builder)
@router.get("", response_model=MessageList)
async def list_messages(...) -> MessageList:
# existing logic unchanged
ttl_setting is optional — when omitted, the decorator uses redis_cache_ttl (the default). Only dashboard endpoints need the explicit redis_cache_ttl_dashboard override.
Phase 4: Docker Compose & Environment Variables
- Add Redis service to
docker-compose.yml:Redis is in the# ========================================================================== # Redis Cache - Optional shared cache for API response caching # Use --profile cache to start with the bundled Redis, or point # REDIS_HOST at an external Redis instance for multi-instance setups. # ========================================================================== redis: image: redis:7-alpine container_name: ${COMPOSE_PROJECT_NAME:-hub}-redis profiles: - all - cache restart: unless-stopped command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru volumes: - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 3cacheprofile (notcore), sodocker compose --profile core upstarts API+web+collector without Redis. Use--profile cache(or--profile all) to include the bundled Redis. For production multi-instance setups, pointREDIS_HOSTat an external shared Redis and useREDIS_KEY_PREFIXto namespace keys per instance (e.g.,hubfor prod,hub-stgfor staging). - Add
redis_datavolume to volumes section:redis_data: name: ${COMPOSE_PROJECT_NAME:-hub}_redis_data - Update
apiservice indocker-compose.yml:- No
depends_onfor Redis — the API must start and work fine without Redis (graceful fallback) - Add environment variables:
- REDIS_ENABLED=${REDIS_ENABLED:-true} - REDIS_HOST=redis - REDIS_PORT=6379 - REDIS_PASSWORD=${REDIS_PASSWORD:-} - REDIS_KEY_PREFIX=${REDIS_KEY_PREFIX:-hub} - REDIS_CACHE_TTL=${REDIS_CACHE_TTL:-30} - REDIS_CACHE_TTL_DASHBOARD=${REDIS_CACHE_TTL_DASHBOARD:-30}
- No
- Note:
REDIS_ENABLEDdefaultsfalsein code (safe for bare-metal) buttruein Docker Compose (where Redis is always present). This ensures non-Docker installs work without Redis. - The
depends_onis intentionally omitted for Redis — if the API starts before Redis, it falls back gracefully. Redis is optional. - Update
docker-compose.dev.ymlto expose Redis port for local development:redis: ports: - "${REDIS_PORT:-6379}:6379" - Add Redis env vars to
api/cli.pyClick options
Phase 5: Health Check & Observability
- Update
/health/readyendpoint to check Redis connectivity whenREDIS_ENABLED=true:- Uses Redis
PINGcommand (fast, no data access) - Connection failure returns
{"status": "not_ready", "database": "connected", "redis": "unreachable"}— not a fatal error since Redis is optional
- Uses Redis
- Log cache hits/misses at DEBUG level for troubleshooting
- Add
X-Cacheresponse header (HITorMISS) to all cached endpoints
Decorator technical notes:
- The
@cacheddecorator usesfunctools.wrapsto preserve the wrapped function's type signature (required for FastAPI'sresponse_modelresolution) - TTL values are read from
request.app.stateat invocation time (set duringcreate_app()from environment variables), not from module-level globals ttl_settingis the attribute name onapp.state; the decorator reads it viagetattr(request.app.state, ttl_setting, 30). When omitted, defaults to"redis_cache_ttl"- Default key builder:
"{redis_key_prefix}:{endpoint_name}:{sorted_urlencoded_query_params}"(e.g.,hub:nodes:limit=50&offset=0). The{redis_key_prefix}component is read fromapp.state.redis_key_prefixby theCacheBackend, not by individualkey_buildercallbacks - Custom
key_builderreceives theRequestobject and returns a string key (suffix only — theCacheBackendprepends the key prefix) - The decorator wrapper locates the
Requestparameter from the handler's arguments by type (inspectingkwargsfor aRequestinstance). All cached endpoints must includerequest: Requestin their function signature - Redis errors (ConnectionError, TimeoutError) are caught and logged at WARNING level; the handler executes normally without cache
Phase 6: Tests
- Add
tests/test_api/test_cache.pywith:- Test cache key generation (deterministic, includes query params)
- Test cache hit/miss flow with mocked Redis
- Test fallback when Redis raises connection errors
- Test NullCache is used when
REDIS_ENABLED=false - Test TTL is applied correctly
- Update existing route tests to work with cache dependency (mock or disable)
- Add integration test with a real Redis instance (optional, CI-only)
Phase 7: Documentation
- Update
AGENTS.mdwith new environment variables and Redis configuration - Update
README.mdwith Redis setup instructions - Update
.env.examplewith all newREDIS_*environment variables and comments - Update
docs/upgrading.md: add a new###subsection under the existing## v0.12.0section documenting the new optional dependency (redis[hiredis]), all newREDIS_*environment variables, Docker Composecacheprofile, and that Redis is entirely optional (no migration required) - Update
docker-compose.ymlcomments
Decisions
-
Per-role caching for channel-visibility endpoints: Resolved — include resolved role in cache key. Messages, channels, and role-sensitive dashboard endpoints incorporate the user's role into the cache key using
resolve_user_role()return values:"admin","operator","member", or"anonymous"(for unauthenticated/None). Note that"anonymous"is the cache-key role — channel visibility levels use"community"(formerly"public"), but the cache key uses the resolved role, not the visibility level name. This prevents leaking restricted channel data to unauthenticated users while still caching for all role levels. The number of roles is small (4 max), so the cache multiplier is minimal. -
Cache invalidation on write: Resolved — skip for now. Rely on the 30s TTL for stale data self-correction. The web dashboard auto-refreshes every 30s anyway, so the UX impact is negligible. Active invalidation can be added as a future enhancement if needed.
-
Pagination cache effectiveness: Resolved — cache per query string. Each unique combination of query parameters gets its own cache entry. No full-result-set slicing. Page 1 gets the highest hit rate, which matches real traffic patterns.
-
Redis memory limits: Resolved — 128MB with
allkeys-lrueviction. Provides generous headroom for large networks while keeping the container footprint modest. LRU eviction handles any overflow gracefully. -
Async vs sync Redis client: Resolved — sync
redisclient. Matches the current synchronous SQLAlchemy session pattern used throughout the API. Will migrate toredis.asynciowhen/if the broader DB layer goes async. -
Decorator vs inline pattern: Resolved — decorator with optional
key_buildercallback. A@cached(endpoint_name, ttl=..., key_builder=...)decorator keeps handlers clean. Role-sensitive endpoints (messages, channels) provide a customkey_builderthat includes the resolved role. Default key builder uses endpoint name + sorted query params. -
Multi-instance key isolation: Resolved — configurable key prefix via
REDIS_KEY_PREFIX. Production hosts multiple Hub instances (prod, staging) that may share a single Redis. Each instance usesREDIS_KEY_PREFIXto namespace its cache keys (e.g.,hubfor prod,hub-stgfor staging). The prefix is prepended to all keys by theCacheBackend, not by individualkey_buildercallbacks. The bundled Docker Compose Redis uses thecacheprofile (notcore), so--profile corestarts without Redis.
References
docs/plans/20260505-1735-caching-bundling/plan.md-- Prior caching/bundling plan (focused on static asset caching, not API response caching)src/meshcore_hub/api/metrics.py-- Existing in-process TTL cache pattern (module-level dict)src/meshcore_hub/api/routes/dashboard.py-- Most expensive endpoint (~15 SQL queries per request)src/meshcore_hub/api/routes/nodes.py-- Complex multi-join node listingsrc/meshcore_hub/api/routes/messages.py-- Role-based channel visibility filteringsrc/meshcore_hub/api/routes/channels.py-- Role-based channel listingsrc/meshcore_hub/api/channel_visibility.py--resolve_user_role(),get_visible_channel_indices()helperssrc/meshcore_hub/common/config.py-- Settings pattern to followsrc/meshcore_hub/api/cli.py-- CLI option pattern to followsrc/meshcore_hub/api/app.py-- FastAPI lifespan handler (Redis init/cleanup goes here)src/meshcore_hub/web/app.py:758--/map/dataendpoint (web app, not API -- not cached by this plan)
Review
Status: Approved with Changes
Reviewed: 2026-06-09 (two passes — 18 resolutions total)
Resolutions
- Dashboard role sensitivity:
get_stats()andget_message_activity()both filter channel data by role. They now get customkey_buildercallbacks (like messages/channels) to include the resolved role in cache keys. This prevents leaking restricted channel counts/stats to community users. - Map/data endpoint: The
/map/dataendpoint is inweb/app.py(not the API) and proxies+aggregates fromGET /api/v1/nodes. Since the underlying nodes endpoint is cached, and the in-memory aggregation is lightweight,/map/datawill not be directly cached. Added to Non-Goals. - TTL env vars: Reduced from 7 to 2:
REDIS_CACHE_TTL(default for all endpoints) andREDIS_CACHE_TTL_DASHBOARD(override for expensive/dashboard/*queries). All non-dashboard endpoints fall back toREDIS_CACHE_TTL.REDIS_CACHE_TTL_MAPwas removed — it will be added alongside the future map-cache feature, not as dead config now. - Dashboard sub-endpoints:
/dashboard/activity,/dashboard/message-activity, and/dashboard/node-countshareREDIS_CACHE_TTL_DASHBOARDwith/dashboard/stats. REDIS_ENABLEDdefault mismatch: Code defaultsfalse(safe for bare-metal), Docker Compose overrides totrue(Redis always present in Docker). Documented in Phase 4.- Decorator technical details: Clarified that
@cachedusesfunctools.wrapsfor type signature preservation, reads TTL fromapp.stateat runtime viagetattr(request.app.state, ttl_setting, 30), locates theRequestparameter by type inspection, and catches Redis errors gracefully. - Health check:
/health/readywill use RedisPING, return"redis": "unreachable"on failure (non-fatal since Redis is optional). - Multi-instance isolation: Added
REDIS_KEY_PREFIXconfig (defaulthub) to namespace cache keys. Production instances sharing one Redis set different prefixes (e.g.,hubvshub-stg). Bundled Docker Compose Redis usescacheprofile (notcore), keeping it optional. Requestparameter requirement: Four endpoints currently lackrequest: Requestin their signatures:list_nodes(),list_advertisements(),get_activity(), andget_node_count_history(). These must gain the parameter for the decorator to access TTL settings and build cache keys. Noted in Phase 3.sorted_query_string()helper: Specified — lives inapi/cache.py, extracts query params fromRequest, sorts by key, URL-encodes, returns a deterministic string. Added to Phase 2.ttl_setting→app.statemapping: Clarified thatttl_settingis an attribute name onapp.state, read viagetattr(request.app.state, ttl_setting, 30). Defaults to"redis_cache_ttl"when omitted. Added to decorator technical notes.- Docker volume naming:
redis_datavolume follows${COMPOSE_PROJECT_NAME:-hub}_redis_datanaming convention (consistent with existingdata,mqtt_data,observer_datavolumes). - Dev port exposure:
docker-compose.dev.ymlexposes Redis on${REDIS_PORT:-6379}:6379for local development. - Role
Nonemapping:resolve_user_role()returnsNonefor unauthenticated users, not"public". Allkey_buildercallbacks mapNone→"anonymous"(e.g.,role=anonymous). Updated FR #2, Phase 2 key builder, and Phase 3 examples. create_app()and CLI expansion: Added explicit notes to Phase 1 thatcreate_app()needs new parameters matching existing pattern (stored onapp.state), andcli.pyneeds corresponding Click options passed through (matching--metrics-cache-ttlpattern).- Decision #1 role naming: Corrected
"public"to"anonymous"for the cache-key role mapping. Channel visibility levels use"community"(renamed from"public"in commitf8c2a7b), but cache keys useresolve_user_role()return values ("anonymous"forNone, not"community"or"public"). - Phase 7 missing
docs/upgrading.md: Added to documentation checklist — new###subsection under## v0.12.0for the new optional dependency, environment variables, Docker Composecacheprofile, and that Redis is optional. - Reload mode limitation: Documented in Phase 1 that
--reloadmode uses factory pattern (uvicorn callscreate_app()with no args), so Redis settings default to code defaults (REDIS_ENABLED=false). Same pre-existing limitation as metrics. Noted in Phase 1.
Remaining Action Items
- None