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.
17 KiB
Tasks: Redis Caching Layer for API Endpoints
Generated from
plan.mdon 2026-06-09
1. Dependencies & Configuration
-
1.1 Add
redis[hiredis]topyproject.toml- Add
"redis[hiredis]"to thedependencieslist in[project] - Add
"redis.*"to the first[[tool.mypy.overrides]]module ignore list (line 116, alongsidepaho.*,uvicorn.*, etc.)
- Add
-
1.2 Add Redis settings to
common/config.py- In
APISettingsclass (which extendsCommonSettings), add fields:REDIS_ENABLED: bool = False(code defaultFalse, safe fallback)REDIS_HOST: str = "localhost"REDIS_PORT: int = 6379REDIS_DB: int = 0REDIS_PASSWORD: Optional[str] = NoneREDIS_KEY_PREFIX: str = "hub"(multi-instance key namespace isolation)REDIS_CACHE_TTL: int = 30(default TTL, matchesWEB_AUTO_REFRESH_SECONDS)REDIS_CACHE_TTL_DASHBOARD: int = 30(override for all/dashboard/*endpoints)
- Follow existing field patterns:
Field(default=..., env=...)with PydanticSettingsConfigDict
- In
-
1.3 Add Redis Click options to
api/cli.py- Add
@click.optionblocks before theapi()function for each Redis setting, matching the--metrics-cache-ttlpattern:--redis-enabled/--no-redis(boolean flag,envvar="REDIS_ENABLED", defaultFalse)--redis-host(str,envvar="REDIS_HOST", default"localhost")--redis-port(int,envvar="REDIS_PORT", default6379)--redis-db(int,envvar="REDIS_DB", default0)--redis-password(str,envvar="REDIS_PASSWORD", defaultNone)--redis-key-prefix(str,envvar="REDIS_KEY_PREFIX", default"hub")--redis-cache-ttl(int,envvar="REDIS_CACHE_TTL", default30)--redis-cache-ttl-dashboard(int,envvar="REDIS_CACHE_TTL_DASHBOARD", default30)
- Add corresponding parameters to the
api()function signature - Add
click.echolines in the startup banner section (after metrics lines, before reload) showing Redis enabled/disabled and TTL values - Pass all Redis parameters through to
create_app()in the non-reload branch (line 225) - In the reload branch (line 210), add a
click.echonote that Redis defaults to disabled in reload mode
- Add
-
1.4 Add Redis parameters to
create_app()inapi/app.py- Add 8 new parameters to
create_app()signature (aftermetrics_cache_ttl):redis_enabled: bool = False,redis_host: str = "localhost",redis_port: int = 6379,redis_db: int = 0,redis_password: str | None = None,redis_key_prefix: str = "hub",redis_cache_ttl: int = 30,redis_cache_ttl_dashboard: int = 30 - Store all on
app.state(aftermetrics_cache_ttlon line 107):app.state.redis_enabled,app.state.redis_host, etc. - Update docstring with new parameters
- Add 8 new parameters to
2. Redis Client & App Integration
-
2.1 Create
common/redis.py- Implement
CacheBackendabstract base class / Protocol with methods:get(key: str) -> str | None— retrieve cached JSON stringset(key: str, value: str, ttl: int) -> None— store with TTLdelete(prefix: str) -> None— delete keys matching prefixping() -> bool— health check
- Implement
RedisCacheBackend(CacheBackend):- Uses sync
redis.Redisclient with connection pool - Constructor accepts
host,port,db,password,key_prefix key_prefixis prepended to all keys internally (e.g.,{prefix}:{suffix})- All Redis operations use timeouts and exception handling — catch
redis.ConnectionError,redis.TimeoutError, log at WARNING level get()returnsNoneon cache miss or error (never raises)set()silently logs Redis errors (never raises)ping()calls RedisPINGcommand, returnsTrue/False
- Uses sync
- Implement
NullCache(CacheBackend):get()always returnsNoneset()is a no-opping()returnsFalse- Used when
REDIS_ENABLED=falseor Redis is unreachable
- Implement
-
2.2 Wire Redis into FastAPI lifespan in
api/app.py- In
lifespan()startup (beforeyield):- Read
redis_enabled,redis_host,redis_port,redis_db,redis_password,redis_key_prefixfromapp.state - If
redis_enabledis True: create aRedisCacheBackendinstance, store asapp.state.redis_cache - If
redis_enabledis False: create aNullCacheinstance, store asapp.state.redis_cache - Log Redis status at INFO level
- Read
- In
lifespan()shutdown (afteryield):- Close Redis connection (if any) — call
.close()on the cache backend
- Close Redis connection (if any) — call
- In
3. Cache Decorator
-
3.1 Create
api/cache.py-
Implement
sorted_query_string(request: Request) -> str:- Extract query params from
request.query_params - Sort by key alphabetically
- URL-encode each key-value pair
- Join with
&, return the string (e.g.,"limit=50&offset=0&sort=last_seen") - Return
""for empty query params
- Extract query params from
-
Implement
cached()decorator factory:- Signature:
cached(endpoint_name: str, ttl_setting: str = "redis_cache_ttl", key_builder: Callable[[Request], str] | None = None) - Default
key_builder:f"{endpoint_name}:{sorted_query_string(request)}"(suffix only — theCacheBackendprepends the key prefix) - Custom
key_builderreceivesRequest, returns a suffix string
- Signature:
-
Decorator implementation (inner
decoratorfunction):- Uses
functools.wrapsto preserve the wrapped function's__name__,__module__,__annotations__ - Locates the
Requestparameter fromkwargsby type inspection - Reads the cache TTL from
app.state:ttl = getattr(request.app.state, ttl_setting, 30) - Builds cache key using
key_builder(request)(suffix) — the full key is built by the cache backend - Tries
cache.get(cache_key)— on cache hit: deserializes JSON, setsrequest.state.cache_status = "HIT", returns cached result - On cache miss: calls handler, serializes result, stores in cache, sets
request.state.cache_status = "MISS" - Catches Redis errors: logs WARNING, falls through to handler
- Caches serialization errors: logs WARNING, returns handler result
- Uses
-
-
3.2 Add
X-Cachemiddleware toapi/app.py- Add a FastAPI middleware using
@app.middleware("http")after the CORS middleware - The middleware reads
getattr(request.state, "cache_status", None)after the response is generated - If set, adds
X-Cache: HITorX-Cache: MISSheader to the response - If not set (non-cached endpoints), no
X-Cacheheader is added
- Add a FastAPI middleware using
4. Apply Caching to API Routes
-
4.1 Update
routes/nodes.py—list_nodes()(line 50)- Add
request: Requestparameter (aftersession: DbSession, before query params) - Import
Requestfromfastapi - Apply
@cached("nodes")decorator (default key builder: endpoint name + sorted query params)
- Add
-
4.2 Update
routes/advertisements.py—list_advertisements()(line 47)- Add
request: Requestparameter (aftersession: DbSession, before query params) - Import
Requestfromfastapi - Apply
@cached("advertisements")decorator
- Add
-
4.3 Update
routes/messages.py—list_messages()(line 37)- Already has
request: Request— no parameter change needed - Create a
_messages_key_builder(request: Request) -> strfunction - Import
resolve_user_rolefrommeshcore_hub.api.channel_visibility - Import
sorted_query_stringfrommeshcore_hub.api.cache - Apply
@cached("messages", key_builder=_messages_key_builder)decorator
- Already has
-
4.4 Update
routes/channels.py—list_channels()(line 41)- Already has
request: Request— no parameter change needed - Create a
_channels_key_builder(request: Request) -> strfunction - Apply
@cached("channels", key_builder=_channels_key_builder)decorator
- Already has
-
4.5 Update
routes/dashboard.py—get_stats()(line 52)- Already has
request: Request— no parameter change needed - Create a
_dashboard_stats_key_builder(request: Request) -> strfunction - Apply
@cached("dashboard/stats", ttl_setting="redis_cache_ttl_dashboard", key_builder=_dashboard_stats_key_builder)decorator
- Already has
-
4.6 Update
routes/dashboard.py—get_activity()(line 309)- Add
request: Requestparameter (aftersession: DbSession, beforedaysparam) - Apply
@cached("dashboard/activity", ttl_setting="redis_cache_ttl_dashboard")decorator
- Add
-
4.7 Update
routes/dashboard.py—get_message_activity()(line 363)- Already has
request: Request— no parameter change needed - Create a
_dashboard_msg_activity_key_builder(request: Request) -> strfunction - Apply
@cached("dashboard/message-activity", ttl_setting="redis_cache_ttl_dashboard", key_builder=_dashboard_msg_activity_key_builder)decorator
- Already has
-
4.8 Update
routes/dashboard.py—get_node_count_history()(line 422)- Add
request: Requestparameter (aftersession: DbSession, beforedaysparam) - Apply
@cached("dashboard/node-count", ttl_setting="redis_cache_ttl_dashboard")decorator
- Add
-
4.9 Add required imports to each route file
from fastapi import Request(where not already present)from meshcore_hub.api.cache import cached, sorted_query_string(all files)from meshcore_hub.api.channel_visibility import resolve_user_role(messages.py, channels.py, dashboard.py — where key_builder uses it)
5. Docker Compose & Environment Variables
-
5.1 Add Redis service to
docker-compose.yml- Insert the Redis service definition (after the
observerservice block, beforecollector):- 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 - Volume:
redis_data:/data - Healthcheck:
test: ["CMD", "redis-cli", "ping"], interval 10s, timeout 5s, retries 3 - Follow existing service block formatting (comments, spacing)
- Add descriptive comment block above service definition
- Image:
- Insert the Redis service definition (after the
-
5.2 Add
redis_datavolume todocker-compose.yml- Add to the
volumes:section at the bottom:redis_data:withname: ${COMPOSE_PROJECT_NAME:-hub}_redis_data(matching existing naming convention)
- Add to the
-
5.3 Add Redis environment variables to
apiservice indocker-compose.yml- In the
apiserviceenvironment:block (afterMETRICS_CACHE_TTLline):REDIS_ENABLED=${REDIS_ENABLED:-true}(Docker overrides code default)REDIS_HOST=redis(container name within Docker network)REDIS_PORT=6379REDIS_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}
- Do NOT add
depends_on: redisto theapiservice — Redis is optional, API starts fine without it
- In the
-
5.4 Add Redis port exposure to
docker-compose.dev.yml- Add a
redis:service override:ports:with"${REDIS_PORT:-6379}:6379"(matching themqtt/api/webport exposure pattern)
- Add a
-
5.5 Add Redis env vars to
.env.example- Add a new section
# REDIS CACHE SETTINGSafter the API settings section (before Web Dashboard) - Document all new env vars with comments and defaults
- Note that Redis is the
cacheprofile (notcore) in Docker Compose - Note multi-instance guidance: set different
REDIS_KEY_PREFIXper instance
- Add a new section
6. Health Check & Observability
-
6.1 Update
/health/readyendpoint inapi/app.py(line 138)- After the database check, add a Redis check:
- Only if
app.state.redis_enabledis True - Call
app.state.redis_cache.ping() - On success: include
"redis": "connected"in the response - On failure: include
"redis": "unreachable"— do NOT mark the overall status as"not_ready"(Redis is optional)
- Only if
- Update response dict construction accordingly
- After the database check, add a Redis check:
-
6.2 Add cache hit/miss logging
- In the
cached()decorator (inapi/cache.py), log cache hits at DEBUG level:logger.debug("Cache HIT: %s", cache_key) - Log cache misses at DEBUG level:
logger.debug("Cache MISS: %s", cache_key) - Log Redis errors at WARNING level:
logger.warning("Redis GET error for %s: %s", cache_key, e) - Use
logging.getLogger(__name__)
- In the
7. Tests
-
7.1 Create
tests/test_api/test_cache.py-
Test
sorted_query_string():- Empty query string returns
"" - Single param:
?limit=50→"limit=50" - Multiple params unsorted:
?offset=0&limit=50→"limit=50&offset=0"(sorted) - URL-encoded special chars:
?search=foo+bar→ properly encoded
- Empty query string returns
-
Test
NullCache:get()always returnsNoneset()does not raiseping()returnsFalse
-
Test
RedisCacheBackendwith mockedredis.Redis:get()returns cached value on hitget()returnsNoneon missset()stores with correct TTLping()returnsTrueon success- On
ConnectionError,get()returnsNone(no raise) - On
TimeoutError,set()logs warning (no raise) - Keys include prefix:
hub:nodes:limit=50
-
Test
@cacheddecorator:- Cache hit: handler not called,
cache_status = "HIT", cached result returned - Cache miss: handler called, result cached,
cache_status = "MISS" - No
Requestin handler args → raisesTypeError - Redis disabled (
NullCache): handler always called, result not cached - Redis error: handler called, result returned, no error raised
- Cache hit: handler not called,
-
Test
key_buildercallbacks:- Default builder produces correct suffix from endpoint name + sorted query params
- Custom builder with role header includes
role=admin - Dashboard key builders use TTL override
-
-
7.2 Verify existing route tests pass without Redis
pytest tests/test_api/ -v— all 336 API tests pass (including newtest_cache.py)pytest tests/test_web/ tests/test_common/ tests/test_collector/— all 567 tests pass- No test failures due to new
request: Requestparameter
8. Documentation
-
8.1 Update
AGENTS.md- Add
REDIS_*environment variables to the Environment Variables table - Add
redis[hiredis]to the Technology Stack table - Add
src/meshcore_hub/common/redis.pyandsrc/meshcore_hub/api/cache.pyto the Project Structure tree
- Add
-
8.2 Update
README.md- Add a Redis setup section describing:
- Redis is optional (API works without it)
- Docker:
docker compose --profile cache upto start bundled Redis - Bare-metal: install Redis separately, set
REDIS_ENABLED=trueandREDIS_HOST=localhost - Multi-instance: use
REDIS_KEY_PREFIXto isolate namespaces
- Environment variable reference table
- Add a Redis setup section describing:
-
8.3 Update
docs/upgrading.md- Under the existing
## v0.12.0section, add a new###subsection: "Optional Redis API Cache" - Document all new
REDIS_*environment variables with defaults and descriptions - Docker Compose
cacheprofile for bundled Redis - Redis is entirely optional — no migration or configuration required to upgrade
- Cache TTL defaults to 30s (matches web dashboard auto-refresh)
- Under the existing
-
8.4 Update
docker-compose.ymlcomments- Add comment noting Redis env vars in the
apiservice environment block - Redis service comment block clear about the
cacheprofile and optionality
- Add comment noting Redis env vars in the
9. Verification
-
9.1 Lint and type-check
- Run
pip install -e ".[dev]"to ensureredis[hiredis]is installed - Run
pre-commit run --all-files— all checks pass (black, flake8, mypy) - mypy passes (new
redis.*module in mypy ignore list)
- Run
-
9.2 Run targeted tests
pytest tests/test_api/— 336 passedpytest tests/test_common/ tests/test_web/ tests/test_collector/— 567 passed- Full suite: 903 passed, 22 skipped
-
9.3 Manual verification (Docker)
- Start with Redis:
docker compose --profile cache up api -d— verify API starts,/health/readyreports"redis": "connected" - Start without Redis:
docker compose up api -d(core profile only) — verify API starts,/health/readyreports"redis": "unreachable"or Redis section absent - Hit
/api/v1/nodestwice — first response should haveX-Cache: MISS, second should haveX-Cache: HIT - Hit
/api/v1/messageswith different request headers (anonymous vs authenticated) — verify separate cache keys - Stop Redis container mid-operation — verify API continues serving from database (graceful fallback)
- Start with Redis:
-
9.4 Manual verification (bare-metal)
- Run
meshcore-hub api(no Redis env vars) — verify API starts,/health/readyshows database connected, no Redis dependency - Run with
REDIS_ENABLED=truepointing at a running Redis — verify caching works,X-Cacheheaders present - Run with
--reloadflag — verify Redis defaults to disabled (safe fallback)
- Run