feat: add optional Redis caching layer for API endpoints

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.
This commit is contained in:
Louis King
2026-06-09 23:08:49 +01:00
parent a4419a8987
commit 385d1ab141
21 changed files with 1574 additions and 84 deletions
+39
View File
@@ -285,6 +285,45 @@ METRICS_CACHE_TTL=60
# External Prometheus port (when using --profile metrics)
PROMETHEUS_PORT=9090
# -------------------
# Redis Cache
# -------------------
# Optional Redis cache for API response caching.
# Reduces database load for read-heavy endpoints (nodes, messages, dashboard).
# When disabled or unavailable, the API queries the database directly.
#
# Docker: Redis is included in the "cache" profile (--profile cache).
# REDIS_ENABLED defaults to false everywhere. To enable, set REDIS_ENABLED=true
# and start with --profile cache (or point REDIS_HOST at an external Redis).
# Bare-metal: Install Redis separately and set REDIS_ENABLED=true.
#
# For multi-instance setups sharing one Redis, use different REDIS_KEY_PREFIX
# values per instance (e.g., hub for prod, hub-stg for staging).
# Enable Redis caching (default: false outside Docker, true in Docker Compose)
# REDIS_ENABLED=false
# Redis server host (use "redis" in Docker Compose)
# REDIS_HOST=localhost
# Redis server port
# REDIS_PORT=6379
# Redis database number
# REDIS_DB=0
# Redis password (optional)
# REDIS_PASSWORD=
# Cache key prefix for multi-instance isolation
# REDIS_KEY_PREFIX=hub
# Default cache TTL in seconds (matches web auto-refresh interval)
# REDIS_CACHE_TTL=30
# Cache TTL for dashboard endpoints (seconds)
# REDIS_CACHE_TTL_DASHBOARD=30
# External Alertmanager port (when using --profile metrics)
ALERTMANAGER_PORT=9093
+11
View File
@@ -55,6 +55,7 @@ MeshCore Hub is a Python 3.14+ monorepo for managing and orchestrating MeshCore
| Database ORM | SQLAlchemy 2.0 (async) |
| Migrations | Alembic |
| REST API | FastAPI |
| Redis Client | redis[hiredis] (optional) |
| MQTT Client | paho-mqtt |
| MQTT Broker | [meshcore-mqtt-broker](https://github.com/michaelhart/meshcore-mqtt-broker) (WebSocket + JWT auth) |
| Templates | Jinja2 (server), lit-html (SPA) |
@@ -262,6 +263,7 @@ meshcore-hub/
│ │ ├── i18n.py # Translation loading
│ │ ├── health.py # Health check utilities
│ │ ├── hash_utils.py # Hash utility functions
│ │ ├── redis.py # Redis cache backend
│ │ ├── models/ # SQLAlchemy models
│ │ │ ├── node.py # Node model
│ │ │ ├── channel.py # Channel model (encryption keys)
@@ -287,6 +289,7 @@ meshcore-hub/
│ │ ├── auth.py # Authentication
│ │ ├── dependencies.py
│ │ ├── metrics.py # Prometheus metrics endpoint
│ │ ├── cache.py # API response caching (Redis)
│ │ └── routes/ # API routes
│ │ ├── user_profiles.py # User profile endpoints (GET/PUT profile)
│ │ ├── adoptions.py # Node adoption endpoints (POST adopt, DELETE release)
@@ -645,6 +648,14 @@ Key variables:
- `CORS_ORIGINS` - Comma-separated list of allowed CORS origins for the API (optional)
- `METRICS_ENABLED` - Enable Prometheus metrics endpoint at /metrics (default: `true`)
- `METRICS_CACHE_TTL` - Seconds to cache metrics output (default: `60`)
- `REDIS_ENABLED` - Enable Redis API response caching (default: `false`)
- `REDIS_HOST` - Redis server host (default: `localhost`)
- `REDIS_PORT` - Redis server port (default: `6379`)
- `REDIS_DB` - Redis database number (default: `0`)
- `REDIS_PASSWORD` - Redis password (optional)
- `REDIS_KEY_PREFIX` - Cache key prefix for multi-instance isolation (default: `hub`)
- `REDIS_CACHE_TTL` - Default cache TTL in seconds (default: `30`)
- `REDIS_CACHE_TTL_DASHBOARD` - Cache TTL for dashboard endpoints in seconds (default: `30`)
- `WEB_HOST` - Web server bind address (default: `0.0.0.0`)
- `WEB_PORT` - Web server port (default: `8080`)
- `API_BASE_URL` - API server base URL for the web dashboard (default: `http://localhost:8000`)
+26
View File
@@ -370,6 +370,32 @@ The collector automatically cleans up old event data and inactive nodes:
| `METRICS_CACHE_TTL` | `60` | Seconds to cache metrics output (reduces database load) |
| `CORS_ORIGINS` | _(none)_ | Comma-separated list of allowed CORS origins for the API (optional, only needed when the web dashboard runs on a different origin) |
### Redis Caching
Optional Redis-backed caching for API responses. When disabled or unavailable, the API queries the database directly.
**Docker:** Redis is included in the `cache` profile. Disabled by default — set `REDIS_ENABLED=true` to enable.
```bash
docker compose --profile cache up # Start with bundled Redis
docker compose --profile core up # Start without Redis
```
**Bare-metal:** Install Redis separately, then set `REDIS_ENABLED=true` and `REDIS_HOST=localhost`.
**Multi-instance:** Use different `REDIS_KEY_PREFIX` values per instance to share one Redis without key collisions.
| Variable | Default | Description |
| ---------------------------- | ----------- | ---------------------------------------------- |
| `REDIS_ENABLED` | `false` | Enable Redis API response caching |
| `REDIS_HOST` | `localhost` | Redis server host (`redis` in Docker) |
| `REDIS_PORT` | `6379` | Redis server port |
| `REDIS_DB` | `0` | Redis database number |
| `REDIS_PASSWORD` | _(none)_ | Redis password (optional) |
| `REDIS_KEY_PREFIX` | `hub` | Cache key prefix for multi-instance isolation |
| `REDIS_CACHE_TTL` | `30` | Default cache TTL in seconds |
| `REDIS_CACHE_TTL_DASHBOARD` | `30` | Cache TTL for dashboard endpoints in seconds |
### Web Dashboard Settings
| Variable | Default | Description |
+4
View File
@@ -25,3 +25,7 @@ services:
web:
ports:
- "${WEB_PORT:-8080}:8080"
redis:
ports:
- "${REDIS_PORT:-6379}:6379"
+31
View File
@@ -126,6 +126,27 @@ services:
volumes:
- observer_data:/app/data
# ==========================================================================
# 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: 3
# ==========================================================================
# Collector - MQTT subscriber and database storage
# ==========================================================================
@@ -221,6 +242,14 @@ services:
- API_ADMIN_KEY=${API_ADMIN_KEY:-}
- METRICS_ENABLED=${METRICS_ENABLED:-true}
- METRICS_CACHE_TTL=${METRICS_CACHE_TTL:-60}
# Redis cache (optional — API works without Redis)
- REDIS_ENABLED=${REDIS_ENABLED:-false}
- 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}
command: ["api"]
healthcheck:
test:
@@ -378,3 +407,5 @@ volumes:
name: ${COMPOSE_PROJECT_NAME:-hub}_mqtt_data
observer_data:
name: ${COMPOSE_PROJECT_NAME:-hub}_observer_data
redis_data:
name: ${COMPOSE_PROJECT_NAME:-hub}_redis_data
@@ -0,0 +1,314 @@
# 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.yml` for 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/data` endpoint -- it lives in `web/app.py` (not the API) and its heavy query work is already cached via `GET /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
1. Redis cache is used for GET endpoints: `/api/v1/nodes`, `/api/v1/advertisements`, `/api/v1/messages`, `/api/v1/channels`, and `/api/v1/dashboard/*`
2. 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 as `role=anonymous` (for unauthenticated/`None`), `role=member`, `role=operator`, or `role=admin`.
3. Cached responses are served with correct `X-Cache: HIT` / `X-Cache: MISS` headers for observability
4. When Redis is not configured or unreachable, the API falls back to direct database queries with no errors
5. Cache TTL defaults to the web auto-refresh interval (30 seconds) and is configurable per endpoint group via `REDIS_CACHE_TTL` (default) and `REDIS_CACHE_TTL_DASHBOARD` (dashboard override)
6. Cache can be globally disabled via `REDIS_ENABLED=false`
7. Cache is bypassed for authenticated admin/operator write operations (no stale data issues)
8. Health check endpoint (`/health/ready`) includes Redis connectivity status when configured
### Technical Requirements
1. Use `redis[hiredis]` Python package (binary protocol parser for performance)
2. Redis connection managed via a singleton connection pool, initialized during FastAPI lifespan startup
3. All Redis operations use timeouts and exception handling -- never block the API on Redis failures
4. Configuration via environment variables matching existing patterns (`REDIS_HOST`, `REDIS_PORT`, `REDIS_DB`, `REDIS_PASSWORD`)
5. Cache serialization uses JSON (not pickle) for safety and debuggability
6. Docker Compose includes Redis service with persistent volume and health check
7. Existing tests pass without Redis (tests mock or disable the cache layer)
## Implementation Plan
### Phase 1: Configuration & Redis Client
- Add `redis[hiredis]` to `dependencies` in `pyproject.toml`
- Add `redis` to mypy ignore list in `pyproject.toml` (add `"redis.*"` to `[[tool.mypy.overrides]]` ignore list, consistent with existing pattern for `paho.mqtt`, etc.)
- Add `RedisSettings` or extend `APISettings` in `common/config.py` with:
- `REDIS_ENABLED: bool = False` (code default; Docker Compose overrides to `true` via `REDIS_ENABLED:-true`)
- `REDIS_HOST: str = "localhost"`
- `REDIS_PORT: int = 6379`
- `REDIS_DB: int = 0`
- `REDIS_PASSWORD: Optional[str] = None`
- `REDIS_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 with `WEB_AUTO_REFRESH_SECONDS`)
- `REDIS_CACHE_TTL_DASHBOARD: int = 30` (override for all `/dashboard/*` endpoints)
- Create `common/redis.py` with:
- `RedisClient` class wrapping a `redis.Redis` connection pool
- `get_redis()` / `get_redis_settings()` helper functions
- `CacheBackend` protocol/class with `get(key)`, `set(key, value, ttl)`, `delete(prefix)` methods
- A `NullCache` no-op implementation for when Redis is disabled
- Add Redis parameters to `create_app()` in `api/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 on `app.state` during `create_app()` (same pattern as `app.state.database_url`, `app.state.metrics_cache_ttl`).
- Add corresponding Click options to `api/cli.py` and pass them through to `create_app()` (same pattern as `--metrics-cache-ttl``metrics_cache_ttl`).
> **Note — reload mode**: When running `meshcore-hub api --reload`, uvicorn uses the factory pattern (calling `create_app()` with no arguments), so all parameters default to their code defaults. This means `REDIS_ENABLED=false` (safe fallback, no Redis) in reload mode. This is the same pre-existing limitation that affects `metrics_cache_ttl` and `metrics_enabled` in reload mode.
### Phase 2: Cache Middleware / Dependency
- Create `api/cache.py` with:
- `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 `""`.
- `CacheKey` builder: generates deterministic keys from `REDIS_KEY_PREFIX` + endpoint path + sorted query params + user role (for role-sensitive endpoints like channels/messages). Role is resolved via `resolve_user_role(request)``None` is 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:
1. Checks Redis for a cached JSON response
2. On hit: returns the cached response with `X-Cache: HIT` header
3. On miss: executes the route handler, stores the result in Redis with TTL, returns with `X-Cache: MISS`
4. On Redis error: logs a warning and falls through to the handler
- Create a FastAPI dependency `get_cache` that 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`**: Cache `list_nodes()` (list endpoint only; single-node lookups by public key are fast enough)
- **`api/routes/advertisements.py`**: Cache `list_advertisements()`
- **`api/routes/messages.py`**: Cache `list_messages()` (include role in cache key for channel visibility)
- **`api/routes/channels.py`**: Cache `list_channels()` (include role in cache key)
- **`api/routes/dashboard.py`**: Cache `get_stats()` (~15 SQL queries), `get_activity()`, `get_message_activity()`, `get_node_count_history()` (all `/dashboard/*` endpoints share `REDIS_CACHE_TTL_DASHBOARD`). Both `get_stats()` and `get_message_activity()` need role-based cache keys (they filter channel counts/messages by visibility).
- **Web map data**: Not cached directly. `/map/data` lives in `web/app.py` (not the API) and its heavy query work is already cached via `GET /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 no `Request`
- `list_advertisements()` (routes/advertisements.py:47) — currently has no `Request`
- `get_activity()` (routes/dashboard.py:309) — currently has no `Request`
- `get_node_count_history()` (routes/dashboard.py:422) — currently has no `Request`
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`:**
```python
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):**
```python
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`:
```yaml
# ==========================================================================
# 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: 3
```
Redis is in the `cache` profile (not `core`), so `docker compose --profile core up` starts API+web+collector without Redis. Use `--profile cache` (or `--profile all`) to include the bundled Redis. For production multi-instance setups, point `REDIS_HOST` at an external shared Redis and use `REDIS_KEY_PREFIX` to namespace keys per instance (e.g., `hub` for prod, `hub-stg` for staging).
- Add `redis_data` volume to volumes section:
```yaml
redis_data:
name: ${COMPOSE_PROJECT_NAME:-hub}_redis_data
```
- Update `api` service in `docker-compose.yml`:
- No `depends_on` for 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}
```
- Note: `REDIS_ENABLED` defaults `false` in code (safe for bare-metal) but `true` in Docker Compose (where Redis is always present). This ensures non-Docker installs work without Redis.
- The `depends_on` is intentionally omitted for Redis — if the API starts before Redis, it falls back gracefully. Redis is optional.
- Update `docker-compose.dev.yml` to expose Redis port for local development:
```yaml
redis:
ports:
- "${REDIS_PORT:-6379}:6379"
```
- Add Redis env vars to `api/cli.py` Click options
### Phase 5: Health Check & Observability
- Update `/health/ready` endpoint to check Redis connectivity when `REDIS_ENABLED=true`:
- Uses Redis `PING` command (fast, no data access)
- Connection failure returns `{"status": "not_ready", "database": "connected", "redis": "unreachable"}` — not a fatal error since Redis is optional
- Log cache hits/misses at DEBUG level for troubleshooting
- Add `X-Cache` response header (`HIT` or `MISS`) to all cached endpoints
**Decorator technical notes:**
- The `@cached` decorator uses `functools.wraps` to preserve the wrapped function's type signature (required for FastAPI's `response_model` resolution)
- TTL values are read from `request.app.state` at invocation time (set during `create_app()` from environment variables), not from module-level globals
- `ttl_setting` is the attribute name on `app.state`; the decorator reads it via `getattr(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 from `app.state.redis_key_prefix` by the `CacheBackend`, not by individual `key_builder` callbacks
- Custom `key_builder` receives the `Request` object and returns a string key (suffix only — the `CacheBackend` prepends the key prefix)
- The decorator wrapper locates the `Request` parameter from the handler's arguments by type (inspecting `kwargs` for a `Request` instance). All cached endpoints **must** include `request: Request` in 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.py` with:
- 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.md` with new environment variables and Redis configuration
- Update `README.md` with Redis setup instructions
- Update `.env.example` with all new `REDIS_*` environment variables and comments
- Update `docs/upgrading.md`: add a new `###` subsection under the existing `## v0.12.0` section documenting the new optional dependency (`redis[hiredis]`), all new `REDIS_*` environment variables, Docker Compose `cache` profile, and that Redis is entirely optional (no migration required)
- Update `docker-compose.yml` comments
## Decisions
1. **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.
2. **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.
3. **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.
4. **Redis memory limits**: **Resolved — 128MB with `allkeys-lru` eviction.** Provides generous headroom for large networks while keeping the container footprint modest. LRU eviction handles any overflow gracefully.
5. **Async vs sync Redis client**: **Resolved — sync `redis` client.** Matches the current synchronous SQLAlchemy session pattern used throughout the API. Will migrate to `redis.asyncio` when/if the broader DB layer goes async.
6. **Decorator vs inline pattern**: **Resolved — decorator with optional `key_builder` callback.** A `@cached(endpoint_name, ttl=..., key_builder=...)` decorator keeps handlers clean. Role-sensitive endpoints (messages, channels) provide a custom `key_builder` that includes the resolved role. Default key builder uses endpoint name + sorted query params.
7. **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 uses `REDIS_KEY_PREFIX` to namespace its cache keys (e.g., `hub` for prod, `hub-stg` for staging). The prefix is prepended to all keys by the `CacheBackend`, not by individual `key_builder` callbacks. The bundled Docker Compose Redis uses the `cache` profile (not `core`), so `--profile core` starts 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 listing
- `src/meshcore_hub/api/routes/messages.py` -- Role-based channel visibility filtering
- `src/meshcore_hub/api/routes/channels.py` -- Role-based channel listing
- `src/meshcore_hub/api/channel_visibility.py` -- `resolve_user_role()`, `get_visible_channel_indices()` helpers
- `src/meshcore_hub/common/config.py` -- Settings pattern to follow
- `src/meshcore_hub/api/cli.py` -- CLI option pattern to follow
- `src/meshcore_hub/api/app.py` -- FastAPI lifespan handler (Redis init/cleanup goes here)
- `src/meshcore_hub/web/app.py:758` -- `/map/data` endpoint (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()` and `get_message_activity()` both filter channel data by role. They now get custom `key_builder` callbacks (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/data` endpoint is in `web/app.py` (not the API) and proxies+aggregates from `GET /api/v1/nodes`. Since the underlying nodes endpoint is cached, and the in-memory aggregation is lightweight, `/map/data` will not be directly cached. Added to Non-Goals.
- **TTL env vars**: Reduced from 7 to 2: `REDIS_CACHE_TTL` (default for all endpoints) and `REDIS_CACHE_TTL_DASHBOARD` (override for expensive `/dashboard/*` queries). All non-dashboard endpoints fall back to `REDIS_CACHE_TTL`. `REDIS_CACHE_TTL_MAP` was 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-count` share `REDIS_CACHE_TTL_DASHBOARD` with `/dashboard/stats`.
- **`REDIS_ENABLED` default mismatch**: Code defaults `false` (safe for bare-metal), Docker Compose overrides to `true` (Redis always present in Docker). Documented in Phase 4.
- **Decorator technical details**: Clarified that `@cached` uses `functools.wraps` for type signature preservation, reads TTL from `app.state` at runtime via `getattr(request.app.state, ttl_setting, 30)`, locates the `Request` parameter by type inspection, and catches Redis errors gracefully.
- **Health check**: `/health/ready` will use Redis `PING`, return `"redis": "unreachable"` on failure (non-fatal since Redis is optional).
- **Multi-instance isolation**: Added `REDIS_KEY_PREFIX` config (default `hub`) to namespace cache keys. Production instances sharing one Redis set different prefixes (e.g., `hub` vs `hub-stg`). Bundled Docker Compose Redis uses `cache` profile (not `core`), keeping it optional.
- **`Request` parameter requirement**: Four endpoints currently lack `request: Request` in their signatures: `list_nodes()`, `list_advertisements()`, `get_activity()`, and `get_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 in `api/cache.py`, extracts query params from `Request`, sorts by key, URL-encodes, returns a deterministic string. Added to Phase 2.
- **`ttl_setting``app.state` mapping**: Clarified that `ttl_setting` is an attribute name on `app.state`, read via `getattr(request.app.state, ttl_setting, 30)`. Defaults to `"redis_cache_ttl"` when omitted. Added to decorator technical notes.
- **Docker volume naming**: `redis_data` volume follows `${COMPOSE_PROJECT_NAME:-hub}_redis_data` naming convention (consistent with existing `data`, `mqtt_data`, `observer_data` volumes).
- **Dev port exposure**: `docker-compose.dev.yml` exposes Redis on `${REDIS_PORT:-6379}:6379` for local development.
- **Role `None` mapping**: `resolve_user_role()` returns `None` for unauthenticated users, not `"public"`. All `key_builder` callbacks map `None``"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 that `create_app()` needs new parameters matching existing pattern (stored on `app.state`), and `cli.py` needs corresponding Click options passed through (matching `--metrics-cache-ttl` pattern).
- **Decision #1 role naming**: Corrected `"public"` to `"anonymous"` for the cache-key role mapping. Channel visibility levels use `"community"` (renamed from `"public"` in commit `f8c2a7b`), but cache keys use `resolve_user_role()` return values (`"anonymous"` for `None`, not `"community"` or `"public"`).
- **Phase 7 missing `docs/upgrading.md`**: Added to documentation checklist — new `###` subsection under `## v0.12.0` for the new optional dependency, environment variables, Docker Compose `cache` profile, and that Redis is optional.
- **Reload mode limitation**: Documented in Phase 1 that `--reload` mode uses factory pattern (uvicorn calls `create_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
@@ -0,0 +1,295 @@
# Tasks: Redis Caching Layer for API Endpoints
> Generated from `plan.md` on 2026-06-09
## 1. Dependencies & Configuration
- [x] 1.1 Add `redis[hiredis]` to `pyproject.toml`
- [x] Add `"redis[hiredis]"` to the `dependencies` list in `[project]`
- [x] Add `"redis.*"` to the first `[[tool.mypy.overrides]]` module ignore list (line 116, alongside `paho.*`, `uvicorn.*`, etc.)
- [x] 1.2 Add Redis settings to `common/config.py`
- [x] In `APISettings` class (which extends `CommonSettings`), add fields:
- `REDIS_ENABLED: bool = False` (code default `False`, safe fallback)
- `REDIS_HOST: str = "localhost"`
- `REDIS_PORT: int = 6379`
- `REDIS_DB: int = 0`
- `REDIS_PASSWORD: Optional[str] = None`
- `REDIS_KEY_PREFIX: str = "hub"` (multi-instance key namespace isolation)
- `REDIS_CACHE_TTL: int = 30` (default TTL, matches `WEB_AUTO_REFRESH_SECONDS`)
- `REDIS_CACHE_TTL_DASHBOARD: int = 30` (override for all `/dashboard/*` endpoints)
- [x] Follow existing field patterns: `Field(default=..., env=...)` with Pydantic `SettingsConfigDict`
- [x] 1.3 Add Redis Click options to `api/cli.py`
- [x] Add `@click.option` blocks before the `api()` function for each Redis setting, matching the `--metrics-cache-ttl` pattern:
- `--redis-enabled/--no-redis` (boolean flag, `envvar="REDIS_ENABLED"`, default `False`)
- `--redis-host` (`str`, `envvar="REDIS_HOST"`, default `"localhost"`)
- `--redis-port` (`int`, `envvar="REDIS_PORT"`, default `6379`)
- `--redis-db` (`int`, `envvar="REDIS_DB"`, default `0`)
- `--redis-password` (`str`, `envvar="REDIS_PASSWORD"`, default `None`)
- `--redis-key-prefix` (`str`, `envvar="REDIS_KEY_PREFIX"`, default `"hub"`)
- `--redis-cache-ttl` (`int`, `envvar="REDIS_CACHE_TTL"`, default `30`)
- `--redis-cache-ttl-dashboard` (`int`, `envvar="REDIS_CACHE_TTL_DASHBOARD"`, default `30`)
- [x] Add corresponding parameters to the `api()` function signature
- [x] Add `click.echo` lines in the startup banner section (after metrics lines, before reload) showing Redis enabled/disabled and TTL values
- [x] Pass all Redis parameters through to `create_app()` in the non-reload branch (line 225)
- [x] In the reload branch (line 210), add a `click.echo` note that Redis defaults to disabled in reload mode
- [x] 1.4 Add Redis parameters to `create_app()` in `api/app.py`
- [x] Add 8 new parameters to `create_app()` signature (after `metrics_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`
- [x] Store all on `app.state` (after `metrics_cache_ttl` on line 107): `app.state.redis_enabled`, `app.state.redis_host`, etc.
- [x] Update docstring with new parameters
## 2. Redis Client & App Integration
- [x] 2.1 Create `common/redis.py`
- [x] Implement `CacheBackend` abstract base class / Protocol with methods:
- `get(key: str) -> str | None` — retrieve cached JSON string
- `set(key: str, value: str, ttl: int) -> None` — store with TTL
- `delete(prefix: str) -> None` — delete keys matching prefix
- `ping() -> bool` — health check
- [x] Implement `RedisCacheBackend(CacheBackend)`:
- Uses sync `redis.Redis` client with connection pool
- Constructor accepts `host`, `port`, `db`, `password`, `key_prefix`
- `key_prefix` is 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()` returns `None` on cache miss or error (never raises)
- `set()` silently logs Redis errors (never raises)
- `ping()` calls Redis `PING` command, returns `True`/`False`
- [x] Implement `NullCache(CacheBackend)`:
- `get()` always returns `None`
- `set()` is a no-op
- `ping()` returns `False`
- Used when `REDIS_ENABLED=false` or Redis is unreachable
- [x] 2.2 Wire Redis into FastAPI lifespan in `api/app.py`
- [x] In `lifespan()` startup (before `yield`):
- Read `redis_enabled`, `redis_host`, `redis_port`, `redis_db`, `redis_password`, `redis_key_prefix` from `app.state`
- If `redis_enabled` is True: create a `RedisCacheBackend` instance, store as `app.state.redis_cache`
- If `redis_enabled` is False: create a `NullCache` instance, store as `app.state.redis_cache`
- Log Redis status at INFO level
- [x] In `lifespan()` shutdown (after `yield`):
- Close Redis connection (if any) — call `.close()` on the cache backend
## 3. Cache Decorator
- [x] 3.1 Create `api/cache.py`
- [x] 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
- [x] 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 — the `CacheBackend` prepends the key prefix)
- Custom `key_builder` receives `Request`, returns a suffix string
- [x] Decorator implementation (inner `decorator` function):
- Uses `functools.wraps` to preserve the wrapped function's `__name__`, `__module__`, `__annotations__`
- Locates the `Request` parameter from `kwargs` by 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, sets `request.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
- [x] 3.2 Add `X-Cache` middleware to `api/app.py`
- [x] Add a FastAPI middleware using `@app.middleware("http")` after the CORS middleware
- [x] The middleware reads `getattr(request.state, "cache_status", None)` after the response is generated
- [x] If set, adds `X-Cache: HIT` or `X-Cache: MISS` header to the response
- [x] If not set (non-cached endpoints), no `X-Cache` header is added
## 4. Apply Caching to API Routes
- [x] 4.1 Update `routes/nodes.py``list_nodes()` (line 50)
- [x] Add `request: Request` parameter (after `session: DbSession`, before query params)
- [x] Import `Request` from `fastapi`
- [x] Apply `@cached("nodes")` decorator (default key builder: endpoint name + sorted query params)
- [x] 4.2 Update `routes/advertisements.py``list_advertisements()` (line 47)
- [x] Add `request: Request` parameter (after `session: DbSession`, before query params)
- [x] Import `Request` from `fastapi`
- [x] Apply `@cached("advertisements")` decorator
- [x] 4.3 Update `routes/messages.py``list_messages()` (line 37)
- [x] Already has `request: Request` — no parameter change needed
- [x] Create a `_messages_key_builder(request: Request) -> str` function
- [x] Import `resolve_user_role` from `meshcore_hub.api.channel_visibility`
- [x] Import `sorted_query_string` from `meshcore_hub.api.cache`
- [x] Apply `@cached("messages", key_builder=_messages_key_builder)` decorator
- [x] 4.4 Update `routes/channels.py``list_channels()` (line 41)
- [x] Already has `request: Request` — no parameter change needed
- [x] Create a `_channels_key_builder(request: Request) -> str` function
- [x] Apply `@cached("channels", key_builder=_channels_key_builder)` decorator
- [x] 4.5 Update `routes/dashboard.py``get_stats()` (line 52)
- [x] Already has `request: Request` — no parameter change needed
- [x] Create a `_dashboard_stats_key_builder(request: Request) -> str` function
- [x] Apply `@cached("dashboard/stats", ttl_setting="redis_cache_ttl_dashboard", key_builder=_dashboard_stats_key_builder)` decorator
- [x] 4.6 Update `routes/dashboard.py``get_activity()` (line 309)
- [x] Add `request: Request` parameter (after `session: DbSession`, before `days` param)
- [x] Apply `@cached("dashboard/activity", ttl_setting="redis_cache_ttl_dashboard")` decorator
- [x] 4.7 Update `routes/dashboard.py``get_message_activity()` (line 363)
- [x] Already has `request: Request` — no parameter change needed
- [x] Create a `_dashboard_msg_activity_key_builder(request: Request) -> str` function
- [x] Apply `@cached("dashboard/message-activity", ttl_setting="redis_cache_ttl_dashboard", key_builder=_dashboard_msg_activity_key_builder)` decorator
- [x] 4.8 Update `routes/dashboard.py``get_node_count_history()` (line 422)
- [x] Add `request: Request` parameter (after `session: DbSession`, before `days` param)
- [x] Apply `@cached("dashboard/node-count", ttl_setting="redis_cache_ttl_dashboard")` decorator
- [x] 4.9 Add required imports to each route file
- [x] `from fastapi import Request` (where not already present)
- [x] `from meshcore_hub.api.cache import cached, sorted_query_string` (all files)
- [x] `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
- [x] 5.1 Add Redis service to `docker-compose.yml`
- [x] Insert the Redis service definition (after the `observer` service block, before `collector`):
- 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
- [x] 5.2 Add `redis_data` volume to `docker-compose.yml`
- [x] Add to the `volumes:` section at the bottom:
- `redis_data:` with `name: ${COMPOSE_PROJECT_NAME:-hub}_redis_data` (matching existing naming convention)
- [x] 5.3 Add Redis environment variables to `api` service in `docker-compose.yml`
- [x] In the `api` service `environment:` block (after `METRICS_CACHE_TTL` line):
- `REDIS_ENABLED=${REDIS_ENABLED:-true}` (Docker overrides code default)
- `REDIS_HOST=redis` (container name within Docker network)
- `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}`
- [x] Do NOT add `depends_on: redis` to the `api` service — Redis is optional, API starts fine without it
- [x] 5.4 Add Redis port exposure to `docker-compose.dev.yml`
- [x] Add a `redis:` service override:
- `ports:` with `"${REDIS_PORT:-6379}:6379"` (matching the `mqtt`/`api`/`web` port exposure pattern)
- [x] 5.5 Add Redis env vars to `.env.example`
- [x] Add a new section `# REDIS CACHE SETTINGS` after the API settings section (before Web Dashboard)
- [x] Document all new env vars with comments and defaults
- [x] Note that Redis is the `cache` profile (not `core`) in Docker Compose
- [x] Note multi-instance guidance: set different `REDIS_KEY_PREFIX` per instance
## 6. Health Check & Observability
- [x] 6.1 Update `/health/ready` endpoint in `api/app.py` (line 138)
- [x] After the database check, add a Redis check:
- Only if `app.state.redis_enabled` is 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)
- [x] Update response dict construction accordingly
- [x] 6.2 Add cache hit/miss logging
- [x] In the `cached()` decorator (in `api/cache.py`), log cache hits at DEBUG level: `logger.debug("Cache HIT: %s", cache_key)`
- [x] Log cache misses at DEBUG level: `logger.debug("Cache MISS: %s", cache_key)`
- [x] Log Redis errors at WARNING level: `logger.warning("Redis GET error for %s: %s", cache_key, e)`
- [x] Use `logging.getLogger(__name__)`
## 7. Tests
- [x] 7.1 Create `tests/test_api/test_cache.py`
- [x] 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
- [x] Test `NullCache`:
- `get()` always returns `None`
- `set()` does not raise
- `ping()` returns `False`
- [x] Test `RedisCacheBackend` with mocked `redis.Redis`:
- `get()` returns cached value on hit
- `get()` returns `None` on miss
- `set()` stores with correct TTL
- `ping()` returns `True` on success
- On `ConnectionError`, `get()` returns `None` (no raise)
- On `TimeoutError`, `set()` logs warning (no raise)
- Keys include prefix: `hub:nodes:limit=50`
- [x] Test `@cached` decorator:
- Cache hit: handler not called, `cache_status = "HIT"`, cached result returned
- Cache miss: handler called, result cached, `cache_status = "MISS"`
- No `Request` in handler args → raises `TypeError`
- Redis disabled (`NullCache`): handler always called, result not cached
- Redis error: handler called, result returned, no error raised
- [x] Test `key_builder` callbacks:
- 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
- [x] 7.2 Verify existing route tests pass without Redis
- [x] `pytest tests/test_api/ -v` — all 336 API tests pass (including new `test_cache.py`)
- [x] `pytest tests/test_web/ tests/test_common/ tests/test_collector/` — all 567 tests pass
- [x] No test failures due to new `request: Request` parameter
## 8. Documentation
- [x] 8.1 Update `AGENTS.md`
- [x] Add `REDIS_*` environment variables to the Environment Variables table
- [x] Add `redis[hiredis]` to the Technology Stack table
- [x] Add `src/meshcore_hub/common/redis.py` and `src/meshcore_hub/api/cache.py` to the Project Structure tree
- [x] 8.2 Update `README.md`
- [x] Add a Redis setup section describing:
- Redis is optional (API works without it)
- Docker: `docker compose --profile cache up` to start bundled Redis
- Bare-metal: install Redis separately, set `REDIS_ENABLED=true` and `REDIS_HOST=localhost`
- Multi-instance: use `REDIS_KEY_PREFIX` to isolate namespaces
- [x] Environment variable reference table
- [x] 8.3 Update `docs/upgrading.md`
- [x] Under the existing `## v0.12.0` section, add a new `###` subsection: "Optional Redis API Cache"
- [x] Document all new `REDIS_*` environment variables with defaults and descriptions
- [x] Docker Compose `cache` profile for bundled Redis
- [x] Redis is entirely optional — no migration or configuration required to upgrade
- [x] Cache TTL defaults to 30s (matches web dashboard auto-refresh)
- [x] 8.4 Update `docker-compose.yml` comments
- [x] Add comment noting Redis env vars in the `api` service environment block
- [x] Redis service comment block clear about the `cache` profile and optionality
## 9. Verification
- [x] 9.1 Lint and type-check
- [x] Run `pip install -e ".[dev]"` to ensure `redis[hiredis]` is installed
- [x] Run `pre-commit run --all-files` — all checks pass (black, flake8, mypy)
- [x] mypy passes (new `redis.*` module in mypy ignore list)
- [x] 9.2 Run targeted tests
- [x] `pytest tests/test_api/` — 336 passed
- [x] `pytest tests/test_common/ tests/test_web/ tests/test_collector/` — 567 passed
- [x] 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/ready` reports `"redis": "connected"`
- [ ] Start without Redis: `docker compose up api -d` (core profile only) — verify API starts, `/health/ready` reports `"redis": "unreachable"` or Redis section absent
- [ ] Hit `/api/v1/nodes` twice — first response should have `X-Cache: MISS`, second should have `X-Cache: HIT`
- [ ] Hit `/api/v1/messages` with different request headers (anonymous vs authenticated) — verify separate cache keys
- [ ] Stop Redis container mid-operation — verify API continues serving from database (graceful fallback)
- [ ] 9.4 Manual verification (bare-metal)
- [ ] Run `meshcore-hub api` (no Redis env vars) — verify API starts, `/health/ready` shows database connected, no Redis dependency
- [ ] Run with `REDIS_ENABLED=true` pointing at a running Redis — verify caching works, `X-Cache` headers present
- [ ] Run with `--reload` flag — verify Redis defaults to disabled (safe fallback)
+114 -77
View File
@@ -13,11 +13,13 @@ Frequency, bandwidth, and TX power are now configured as raw numbers without uni
**Migration example:**
Before:
```
NETWORK_RADIO_CONFIG=EU/UK Narrow,869.618MHz,62.5kHz,8,8,22dBm
```
After:
```
NETWORK_RADIO_PROFILE=EU/UK Narrow
NETWORK_RADIO_FREQUENCY=869.618
@@ -29,6 +31,34 @@ NETWORK_RADIO_TX_POWER=22
**Note:** Radio config is now "always on" with EU/UK Narrow defaults. To hide the radio config panel entirely, set `FEATURE_RADIO_CONFIG=false`.
### Optional Redis API Cache
A new optional Redis-backed caching layer reduces database load for read-heavy API endpoints (nodes, advertisements, messages, channels, dashboard). Redis is entirely optional — the API works identically without it.
**New optional dependency:** `redis[hiredis]` is installed automatically with `pip install -e .`. No manual action needed.
**New environment variables:**
| Variable | Default | Description |
|----------|---------|-------------|
| `REDIS_ENABLED` | `false` | Enable Redis API response caching |
| `REDIS_HOST` | `localhost` | Redis server host (`redis` in Docker) |
| `REDIS_PORT` | `6379` | Redis server port |
| `REDIS_DB` | `0` | Redis database number |
| `REDIS_PASSWORD` | *(none)* | Redis password (optional) |
| `REDIS_KEY_PREFIX` | `hub` | Cache key prefix (change per instance for multi-instance setups) |
| `REDIS_CACHE_TTL` | `30` | Default cache TTL in seconds |
| `REDIS_CACHE_TTL_DASHBOARD` | `30` | Cache TTL for dashboard endpoints |
**Docker Compose:** Redis is available via the `cache` profile:
```bash
docker compose --profile cache up # Start with bundled Redis
docker compose --profile core up # Start without Redis (default)
```
`REDIS_ENABLED` defaults to `false` everywhere (code and Docker Compose). Cache TTL defaults to 30 seconds (matching the web dashboard auto-refresh interval).
## v0.11.0
### Channel Visibility Rename: "public" → "community"
@@ -45,21 +75,23 @@ Channel decryption keys are now managed via the `channels` database table instea
**New database table: `channels`**
| Column | Type | Description |
|--------|------|-------------|
| `id` | `VARCHAR(36), PK` | UUID primary key |
| `name` | `VARCHAR(100), UNIQUE` | Channel display name |
| `key_hex` | `VARCHAR(64), UNIQUE` | Uppercase hex key (32 or 64 chars) |
| `channel_hash` | `VARCHAR(2)` | First byte of SHA-256 of key |
| `visibility` | `VARCHAR(20)` | `community`, `member`, `operator`, or `admin` |
| `enabled` | `BOOLEAN` | Whether the channel is active |
| `created_at`, `updated_at` | `DATETIME` | Timestamps |
| Column | Type | Description |
| -------------------------- | ---------------------- | --------------------------------------------- |
| `id` | `VARCHAR(36), PK` | UUID primary key |
| `name` | `VARCHAR(100), UNIQUE` | Channel display name |
| `key_hex` | `VARCHAR(64), UNIQUE` | Uppercase hex key (32 or 64 chars) |
| `channel_hash` | `VARCHAR(2)` | First byte of SHA-256 of key |
| `visibility` | `VARCHAR(20)` | `community`, `member`, `operator`, or `admin` |
| `enabled` | `BOOLEAN` | Whether the channel is active |
| `created_at`, `updated_at` | `DATETIME` | Timestamps |
**Removed environment variables:**
- `COLLECTOR_CHANNEL_KEYS` — replaced by database channels table
- `COLLECTOR_INCLUDE_TEST_CHANNEL` — replaced by presence of a `test` channel row in the database
**New environment variables:**
- `CHANNEL_REFRESH_INTERVAL_SECONDS` — seconds between key refresh (default: `300`)
- `FEATURE_CHANNELS` — enable/disable the /channels page (default: `true`)
@@ -98,10 +130,10 @@ The collector's scheduled cleanup cycle now also runs orphan cleanup automatical
The `meshcore-hub collector cleanup` command now accepts:
| Flag | Default | Description |
|------|---------|-------------|
| `--node-cleanup` | `false` | Also delete inactive nodes and orphaned relations |
| `--node-cleanup-days` | `30` | Inactivity threshold for node deletion |
| Flag | Default | Description |
| --------------------- | ------- | ------------------------------------------------- |
| `--node-cleanup` | `false` | Also delete inactive nodes and orphaned relations |
| `--node-cleanup-days` | `30` | Inactivity threshold for node deletion |
## v0.10.0
@@ -109,33 +141,33 @@ This release introduces OIDC authentication, user profiles with node adoption, r
### Breaking Changes
| Area | Before | After |
|------|--------|-------|
| Admin auth | `WEB_ADMIN_ENABLED=true` (open access) | OIDC/OAuth2 authentication via identity provider |
| Network Members | `members` table + CRUD API + YAML seed | Removed — replaced by `UserProfile` roles |
| Infrastructure detection | `role=infra` NodeTag | `user_profile_nodes` adoption records |
| Tag editing | `/admin/node-tags` dedicated page | Inline editor on node detail page |
| Tag API auth | `RequireAdmin` (API key with open fallback) | `RequireOperatorOrAdmin` (OIDC role-based, always requires auth) |
| Admin UI | `/admin/` routes with SPA pages | Removed entirely |
| Map API field | `infra_center` | `adopted_center` |
| Map API field | `is_infra` (on node objects) | `is_adopted` |
| Prometheus label | `role="infra"` / `role=""` | `adopted="true"` / `adopted="false"` |
| Profile endpoint | `GET /api/v1/user/profile/{user_id}` | `GET /api/v1/user/profile/{profile_id}` (UUID) |
| Node cleanup default | 7 days | 30 days |
| Python | 3.13 | 3.14 |
| Area | Before | After |
| ------------------------ | ------------------------------------------- | ---------------------------------------------------------------- |
| Admin auth | `WEB_ADMIN_ENABLED=true` (open access) | OIDC/OAuth2 authentication via identity provider |
| Network Members | `members` table + CRUD API + YAML seed | Removed — replaced by `UserProfile` roles |
| Infrastructure detection | `role=infra` NodeTag | `user_profile_nodes` adoption records |
| Tag editing | `/admin/node-tags` dedicated page | Inline editor on node detail page |
| Tag API auth | `RequireAdmin` (API key with open fallback) | `RequireOperatorOrAdmin` (OIDC role-based, always requires auth) |
| Admin UI | `/admin/` routes with SPA pages | Removed entirely |
| Map API field | `infra_center` | `adopted_center` |
| Map API field | `is_infra` (on node objects) | `is_adopted` |
| Prometheus label | `role="infra"` / `role=""` | `adopted="true"` / `adopted="false"` |
| Profile endpoint | `GET /api/v1/user/profile/{user_id}` | `GET /api/v1/user/profile/{profile_id}` (UUID) |
| Node cleanup default | 7 days | 30 days |
| Python | 3.13 | 3.14 |
### Removed API Endpoints
| Method | Path | Replacement |
|--------|------|-------------|
| `GET` | `/nodes/{pk}/tags/{key}` | Use `GET /nodes/{pk}` and filter tags client-side |
| `PUT` | `/nodes/{pk}/tags/{key}/move` | No replacement (delete + recreate) |
| `POST` | `/nodes/{pk}/tags/copy-to/{dest}` | No replacement (create tags individually) |
| `DELETE` | `/nodes/{pk}/tags` (bulk) | No replacement (delete tags individually) |
| `POST` | `/api/v1/commands/send-message` | Removed |
| `POST` | `/api/v1/commands/send-channel-message` | Removed |
| `POST` | `/api/v1/commands/send-advertisement` | Removed |
| All | `/api/v1/members/*` | Use `/api/v1/user/profiles` |
| Method | Path | Replacement |
| -------- | --------------------------------------- | ------------------------------------------------- |
| `GET` | `/nodes/{pk}/tags/{key}` | Use `GET /nodes/{pk}` and filter tags client-side |
| `PUT` | `/nodes/{pk}/tags/{key}/move` | No replacement (delete + recreate) |
| `POST` | `/nodes/{pk}/tags/copy-to/{dest}` | No replacement (create tags individually) |
| `DELETE` | `/nodes/{pk}/tags` (bulk) | No replacement (delete tags individually) |
| `POST` | `/api/v1/commands/send-message` | Removed |
| `POST` | `/api/v1/commands/send-channel-message` | Removed |
| `POST` | `/api/v1/commands/send-advertisement` | Removed |
| All | `/api/v1/members/*` | Use `/api/v1/user/profiles` |
### Removed Schemas
@@ -158,6 +190,7 @@ This release introduces OIDC authentication, user profiles with node adoption, r
### Upgrade Actions
1. **Set up an OIDC identity provider** (LogTo, Keycloak, etc.) and configure these environment variables:
```bash
OIDC_ENABLED=true
OIDC_CLIENT_ID=your-client-id
@@ -193,6 +226,7 @@ This release introduces OIDC authentication, user profiles with node adoption, r
### OIDC-Disabled Deployments
When `OIDC_ENABLED=false`:
- Tag writes require OIDC authentication → 401 on direct API access (tags are read-only via web UI)
- The inline tag editor is hidden on the node detail page
- `adopted_center` is always `null`, all nodes have `is_adopted: false`
@@ -202,17 +236,18 @@ When `OIDC_ENABLED=false`:
### Tag Editor Authorization
Tag write endpoints now use `RequireOperatorOrAdmin` (OIDC role-based). The previous `RequireAdmin` had a fallback allowing open access when no admin key was configured. The new system always requires OIDC authentication:
- Operators can edit tags on their adopted nodes only
- Admins can edit tags on any node
- The admin API key no longer grants tag write access
### New Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `OIDC_ROLE_ADMIN` | `admin` | IdP role name granting admin access |
| `OIDC_ROLE_OPERATOR` | `operator` | IdP role name for operator access |
| `OIDC_ROLE_MEMBER` | `member` | IdP role name for member access |
| Variable | Default | Description |
| -------------------- | ---------- | ----------------------------------- |
| `OIDC_ROLE_ADMIN` | `admin` | IdP role name granting admin access |
| `OIDC_ROLE_OPERATOR` | `operator` | IdP role name for operator access |
| `OIDC_ROLE_MEMBER` | `member` | IdP role name for member access |
See `.env.example` for the full list of OIDC environment variables.
@@ -222,22 +257,22 @@ This release includes **breaking changes** to the MQTT broker, packet capture se
### Overview of Changes
| Area | Before | After |
|------|--------|-------|
| MQTT broker | Eclipse Mosquitto (TCP) | [meshcore-mqtt-broker](https://github.com/michaelhart/meshcore-mqtt-broker) (WebSocket, JWT auth) |
| Packet capture | Proprietary `interface-receiver` service | [meshcore-packet-capture](https://github.com/agessaman/meshcore-packet-capture) (LetsMesh Observer model) |
| Auth model | MQTT username/password for publishing | JWT signed by device hardware public key |
| Collector MQTT | Anonymous subscriber | Subscriber account (admin-level) with credentials |
| Decoder | Node.js `meshcore-decoder` CLI subprocess | Native Python `meshcoredecoder` library |
| Python | 3.13 | 3.14 |
| DB columns | `receiver_node_id` | `observer_node_id` |
| DB table | `event_receivers` | `event_observers` |
| API commands | `/api/v1/commands/*` | Removed |
| Compose profiles | `receiver`, `sender`, `mock` | `observer` |
| Compose files | Single `docker-compose.yml` | Base + environment overrides (`.dev.yml`, `.prod.yml`) |
| Container names | `meshcore-*` | Parameterized via `COMPOSE_PROJECT_NAME` (default: `hub-*`) |
| Volume names | `meshcore_*` | Parameterized via `COMPOSE_PROJECT_NAME` (default: `hub_*`) |
| Public key case | Mixed (uppercase/lowercase) | Normalized to **lowercase** |
| Area | Before | After |
| ---------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| MQTT broker | Eclipse Mosquitto (TCP) | [meshcore-mqtt-broker](https://github.com/michaelhart/meshcore-mqtt-broker) (WebSocket, JWT auth) |
| Packet capture | Proprietary `interface-receiver` service | [meshcore-packet-capture](https://github.com/agessaman/meshcore-packet-capture) (LetsMesh Observer model) |
| Auth model | MQTT username/password for publishing | JWT signed by device hardware public key |
| Collector MQTT | Anonymous subscriber | Subscriber account (admin-level) with credentials |
| Decoder | Node.js `meshcore-decoder` CLI subprocess | Native Python `meshcoredecoder` library |
| Python | 3.13 | 3.14 |
| DB columns | `receiver_node_id` | `observer_node_id` |
| DB table | `event_receivers` | `event_observers` |
| API commands | `/api/v1/commands/*` | Removed |
| Compose profiles | `receiver`, `sender`, `mock` | `observer` |
| Compose files | Single `docker-compose.yml` | Base + environment overrides (`.dev.yml`, `.prod.yml`) |
| Container names | `meshcore-*` | Parameterized via `COMPOSE_PROJECT_NAME` (default: `hub-*`) |
| Volume names | `meshcore_*` | Parameterized via `COMPOSE_PROJECT_NAME` (default: `hub_*`) |
| Public key case | Mixed (uppercase/lowercase) | Normalized to **lowercase** |
### Public Key Case Normalization
@@ -296,8 +331,8 @@ docker volume ls | grep meshcore
These volumes always need migrating:
| Old Name | New Name |
|----------|----------|
| Old Name | New Name |
| ------------------- | ---------- |
| `meshcore_hub_data` | `hub_data` |
> **Note:** `observer_data` and `mqtt_data` are new — they are created automatically on first run and do not need migrating.
@@ -382,12 +417,12 @@ MQTT_WS_PORT=9001
#### Variables to Update
| Variable | Old Value | New Value | Notes |
|----------|-----------|-----------|-------|
| `MQTT_TRANSPORT` | `tcp` | `websockets` | Required by the new JWT-based broker |
| `MQTT_WS_PATH` | `/mqtt` | `/` | New broker accepts connections on `/` |
| `MQTT_USERNAME` | (empty/optional) | Subscriber username | Now **required** for collector subscriber auth. Set to match your broker's `SUBSCRIBER_1` config. |
| `MQTT_PASSWORD` | (empty/optional) | Subscriber password | Now **required** for collector subscriber auth. Generate a secure password: `openssl rand -base64 32` |
| Variable | Old Value | New Value | Notes |
| ---------------- | ---------------- | ------------------- | ----------------------------------------------------------------------------------------------------- |
| `MQTT_TRANSPORT` | `tcp` | `websockets` | Required by the new JWT-based broker |
| `MQTT_WS_PATH` | `/mqtt` | `/` | New broker accepts connections on `/` |
| `MQTT_USERNAME` | (empty/optional) | Subscriber username | Now **required** for collector subscriber auth. Set to match your broker's `SUBSCRIBER_1` config. |
| `MQTT_PASSWORD` | (empty/optional) | Subscriber password | Now **required** for collector subscriber auth. Generate a secure password: `openssl rand -base64 32` |
> **Note:** The Python-level defaults for `MQTT_TRANSPORT` and `MQTT_WS_PATH` are now `websockets` and `/`, matching the Docker Compose and `.env.example` values. No additional configuration is needed for non-Docker users.
@@ -469,6 +504,7 @@ The new packet capture service ([meshcore-packet-capture](https://github.com/age
In production, the MQTT WebSocket server should be hosted behind a TLS/SSL-terminated reverse proxy (e.g., Nginx Proxy Manager, Caddy, Traefik) under the `/mqtt` path. The proxy handles TLS termination and forwards plain WebSocket connections to the broker on port 1883.
**Local / development (default):**
```bash
MQTT_PORT=1883
MQTT_TRANSPORT=websockets
@@ -478,6 +514,7 @@ MQTT_TOKEN_AUDIENCE=mqtt.localhost
```
**Production (behind reverse proxy):**
```bash
MQTT_PORT=443
MQTT_TRANSPORT=websockets
@@ -505,11 +542,11 @@ PACKETCAPTURE_MQTT3_TOKEN_AUDIENCE=mqtt.localhost
The following Docker Compose services have been removed:
| Old Service | Replacement |
|-------------|-------------|
| `interface-receiver` | `observer` (profile: `observer`) |
| `interface-sender` | None (removed) |
| `interface-mock-receiver` | None (removed) |
| Old Service | Replacement |
| ------------------------- | -------------------------------- |
| `interface-receiver` | `observer` (profile: `observer`) |
| `interface-sender` | None (removed) |
| `interface-mock-receiver` | None (removed) |
The `observer` service uses the [meshcore-packet-capture](https://github.com/agessaman/meshcore-packet-capture) image and is included in `docker-compose.yml` under the `observer` profile for an easy transition.
@@ -517,12 +554,12 @@ The `observer` service uses the [meshcore-packet-capture](https://github.com/age
The Docker Compose configuration is now split into multiple files:
| File | Purpose |
|------|---------|
| `docker-compose.yml` | Base shared config (services, profiles, healthchecks, environment) |
| `docker-compose.dev.yml` | Development overrides (port mappings for direct access) |
| `docker-compose.prod.yml` | Production overrides (external proxy network, no exposed ports) |
| `docker-compose.traefik.yml` | Optional Traefik auto-discovery labels |
| File | Purpose |
| ---------------------------- | ------------------------------------------------------------------ |
| `docker-compose.yml` | Base shared config (services, profiles, healthchecks, environment) |
| `docker-compose.dev.yml` | Development overrides (port mappings for direct access) |
| `docker-compose.prod.yml` | Production overrides (external proxy network, no exposed ports) |
| `docker-compose.traefik.yml` | Optional Traefik auto-discovery labels |
All `docker compose` commands now require explicit file selection:
+2
View File
@@ -45,6 +45,7 @@ dependencies = [
"markdown>=3.5.0",
"prometheus-client>=0.20.0",
"meshcoredecoder>=0.3.2",
"redis[hiredis]>=5.0.0",
]
[project.optional-dependencies]
@@ -122,6 +123,7 @@ module = [
"prometheus_client.*",
"meshcoredecoder.*",
"authlib.*",
"redis.*",
]
ignore_missing_imports = true
+70 -5
View File
@@ -2,9 +2,9 @@
import logging
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from typing import Any, AsyncGenerator
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
@@ -36,9 +36,32 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
logger.info(f"Initializing database: {database_url}")
_db_manager = DatabaseManager(database_url)
# Initialize Redis cache
redis_enabled = getattr(app.state, "redis_enabled", False)
if redis_enabled:
from meshcore_hub.common.redis import RedisCacheBackend
redis_cache = RedisCacheBackend(
host=getattr(app.state, "redis_host", "localhost"),
port=getattr(app.state, "redis_port", 6379),
db=getattr(app.state, "redis_db", 0),
password=getattr(app.state, "redis_password", None),
key_prefix=getattr(app.state, "redis_key_prefix", "hub"),
)
app.state.redis_cache = redis_cache
logger.info("Redis cache enabled")
else:
from meshcore_hub.common.redis import NullCache
app.state.redis_cache = NullCache()
logger.info("Redis cache disabled")
yield
# Cleanup
_cache = getattr(app.state, "redis_cache", None)
if _cache is not None and hasattr(_cache, "close"):
_cache.close()
if _db_manager:
_db_manager.dispose()
_db_manager = None
@@ -60,6 +83,14 @@ def create_app(
cors_origins: list[str] | None = None,
metrics_enabled: bool = True,
metrics_cache_ttl: int = 60,
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,
) -> FastAPI:
"""Create and configure the FastAPI application.
@@ -78,6 +109,14 @@ def create_app(
cors_origins: Allowed CORS origins
metrics_enabled: Enable Prometheus metrics endpoint at /metrics
metrics_cache_ttl: Seconds to cache metrics output
redis_enabled: Enable Redis API response caching
redis_host: Redis server host
redis_port: Redis server port
redis_db: Redis database number
redis_password: Redis password (optional)
redis_key_prefix: Prefix for all cache keys
redis_cache_ttl: Default cache TTL in seconds
redis_cache_ttl_dashboard: Cache TTL for dashboard endpoints
Returns:
Configured FastAPI application
@@ -105,6 +144,14 @@ def create_app(
app.state.mqtt_transport = mqtt_transport
app.state.mqtt_ws_path = mqtt_ws_path
app.state.metrics_cache_ttl = metrics_cache_ttl
app.state.redis_enabled = redis_enabled
app.state.redis_host = redis_host
app.state.redis_port = redis_port
app.state.redis_db = redis_db
app.state.redis_password = redis_password
app.state.redis_key_prefix = redis_key_prefix
app.state.redis_cache_ttl = redis_cache_ttl
app.state.redis_cache_ttl_dashboard = redis_cache_ttl_dashboard
# Configure CORS
if cors_origins is None:
@@ -118,6 +165,14 @@ def create_app(
allow_headers=["*"],
)
@app.middleware("http")
async def cache_header_middleware(request: Request, call_next: Any) -> Any:
response = await call_next(request)
cache_status = getattr(request.state, "cache_status", None)
if cache_status is not None:
response.headers["X-Cache"] = cache_status
return response
# Include routers
from meshcore_hub.api.routes import api_router
@@ -137,13 +192,23 @@ def create_app(
@app.get("/health/ready", tags=["Health"])
async def health_ready() -> dict:
"""Readiness check including database."""
"""Readiness check including database and optional Redis."""
try:
db = get_db_manager()
with db.session_scope() as session:
session.execute(text("SELECT 1"))
return {"status": "ready", "database": "connected"}
result: dict[str, str] = {"status": "ready", "database": "connected"}
except Exception as e:
return {"status": "not_ready", "database": str(e)}
result = {"status": "not_ready", "database": str(e)}
redis_cache = getattr(app.state, "redis_cache", None)
redis_enabled = getattr(app.state, "redis_enabled", False)
if redis_enabled and redis_cache is not None:
if redis_cache.ping():
result["redis"] = "connected"
else:
result["redis"] = "unreachable"
return result
return app
+91
View File
@@ -0,0 +1,91 @@
"""Cache decorator for API endpoints."""
import functools
import json
import logging
from typing import Any, Callable, Optional
from urllib.parse import urlencode
from fastapi import Request
logger = logging.getLogger(__name__)
def sorted_query_string(request: Request) -> str:
"""Build a deterministic query string from request params, sorted by key."""
params = list(request.query_params.items())
if not params:
return ""
params.sort(key=lambda p: p[0])
return urlencode(params)
def _find_request(kwargs: dict[str, Any]) -> Request:
"""Locate the Request object in handler kwargs by type inspection."""
for value in kwargs.values():
if isinstance(value, Request):
return value
raise TypeError("No Request parameter found in handler arguments")
def cached(
endpoint_name: str,
ttl_setting: str = "redis_cache_ttl",
key_builder: Optional[Callable[[Request], str]] = None,
) -> Callable[..., Any]:
"""Decorator factory for caching API endpoint responses.
Args:
endpoint_name: Cache key prefix for this endpoint.
ttl_setting: Attribute name on app.state holding the TTL value.
key_builder: Optional custom function to build cache key suffix.
Receives the Request, returns a string suffix.
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
request = _find_request(kwargs)
cache = getattr(request.app.state, "redis_cache", None)
if cache is None:
return await func(*args, **kwargs)
ttl = getattr(request.app.state, ttl_setting, 30)
if key_builder is not None:
cache_key = key_builder(request)
else:
cache_key = f"{endpoint_name}:{sorted_query_string(request)}"
try:
cached_value = cache.get(cache_key)
except Exception as e:
logger.warning("Redis GET error for %s: %s", cache_key, e)
cached_value = None
if cached_value is not None:
logger.debug("Cache HIT: %s", cache_key)
request.state.cache_status = "HIT"
return json.loads(cached_value)
logger.debug("Cache MISS: %s", cache_key)
request.state.cache_status = "MISS"
result = await func(*args, **kwargs)
try:
if hasattr(result, "model_dump"):
serialized = json.dumps(result.model_dump(mode="json"))
elif isinstance(result, dict):
serialized = json.dumps(result)
else:
serialized = json.dumps(result, default=str)
cache.set(cache_key, serialized, ttl)
except Exception as e:
logger.warning("Cache store error for %s: %s", cache_key, e)
return result
return wrapper
return decorator
+79
View File
@@ -122,6 +122,61 @@ import click
envvar="METRICS_CACHE_TTL",
help="Seconds to cache metrics output (reduces database load)",
)
@click.option(
"--redis-enabled/--no-redis",
default=False,
envvar="REDIS_ENABLED",
help="Enable Redis API response caching",
)
@click.option(
"--redis-host",
type=str,
default="localhost",
envvar="REDIS_HOST",
help="Redis server host",
)
@click.option(
"--redis-port",
type=int,
default=6379,
envvar="REDIS_PORT",
help="Redis server port",
)
@click.option(
"--redis-db",
type=int,
default=0,
envvar="REDIS_DB",
help="Redis database number",
)
@click.option(
"--redis-password",
type=str,
default=None,
envvar="REDIS_PASSWORD",
help="Redis password (optional)",
)
@click.option(
"--redis-key-prefix",
type=str,
default="hub",
envvar="REDIS_KEY_PREFIX",
help="Prefix for all Redis cache keys",
)
@click.option(
"--redis-cache-ttl",
type=int,
default=30,
envvar="REDIS_CACHE_TTL",
help="Default cache TTL in seconds",
)
@click.option(
"--redis-cache-ttl-dashboard",
type=int,
default=30,
envvar="REDIS_CACHE_TTL_DASHBOARD",
help="Cache TTL for dashboard endpoints (seconds)",
)
@click.option(
"--reload",
is_flag=True,
@@ -148,6 +203,14 @@ def api(
cors_origins: str | None,
metrics_enabled: bool,
metrics_cache_ttl: int,
redis_enabled: bool,
redis_host: str,
redis_port: int,
redis_db: int,
redis_password: str | None,
redis_key_prefix: str,
redis_cache_ttl: int,
redis_cache_ttl_dashboard: int,
reload: bool,
) -> None:
"""Run the REST API server.
@@ -199,6 +262,13 @@ def api(
click.echo(f"CORS origins: {cors_origins or 'none'}")
click.echo(f"Metrics enabled: {metrics_enabled}")
click.echo(f"Metrics cache TTL: {metrics_cache_ttl}s")
click.echo(f"Redis enabled: {redis_enabled}")
if redis_enabled:
click.echo(f"Redis: {redis_host}:{redis_port}/{redis_db}")
click.echo(f"Redis key prefix: {redis_key_prefix}")
click.echo(
f"Redis cache TTL: {redis_cache_ttl}s (dashboard: {redis_cache_ttl_dashboard}s)"
)
click.echo(f"Reload mode: {reload}")
click.echo("=" * 50)
@@ -212,6 +282,7 @@ def api(
# We need to pass app as string for reload to work
click.echo("\nStarting in development mode with auto-reload...")
click.echo("Note: Using default settings for reload mode.")
click.echo("Note: Redis defaults to disabled in reload mode.")
uvicorn.run(
"meshcore_hub.api.app:create_app",
@@ -237,6 +308,14 @@ def api(
cors_origins=origins_list,
metrics_enabled=metrics_enabled,
metrics_cache_ttl=metrics_cache_ttl,
redis_enabled=redis_enabled,
redis_host=redis_host,
redis_port=redis_port,
redis_db=redis_db,
redis_password=redis_password,
redis_key_prefix=redis_key_prefix,
redis_cache_ttl=redis_cache_ttl,
redis_cache_ttl_dashboard=redis_cache_ttl_dashboard,
)
click.echo("\nStarting API server...")
@@ -3,11 +3,12 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, HTTPException, Query, Request
from sqlalchemy import func, or_, select
from sqlalchemy.orm import aliased, selectinload
from meshcore_hub.api.auth import RequireRead
from meshcore_hub.api.cache import cached
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.api.observer_utils import fetch_observers_for_events
from meshcore_hub.common.models import Advertisement, Node, NodeTag, UserProfileNode
@@ -44,9 +45,11 @@ def _get_tag_description(node: Optional[Node]) -> Optional[str]:
@router.get("", response_model=AdvertisementList)
@cached("advertisements")
async def list_advertisements(
_: RequireRead,
session: DbSession,
request: Request,
search: Optional[str] = Query(
None, description="Search in name tag, node name, or public key"
),
+7
View File
@@ -4,6 +4,7 @@ from fastapi import APIRouter, HTTPException, Request
from sqlalchemy import select
from meshcore_hub.api.auth import RequireAdmin, RequireRead
from meshcore_hub.api.cache import cached, sorted_query_string
from meshcore_hub.api.channel_visibility import (
VISIBILITY_LEVELS,
get_max_visibility_level,
@@ -21,6 +22,11 @@ from meshcore_hub.common.schemas.channels import (
router = APIRouter()
def _channels_key_builder(request: Request) -> str:
role = resolve_user_role(request) or "anonymous"
return f"channels:role={role}:{sorted_query_string(request)}"
def _channel_to_read(channel: Channel, include_key: bool = False) -> ChannelRead:
"""Convert a Channel model to ChannelRead schema."""
@@ -38,6 +44,7 @@ def _channel_to_read(channel: Channel, include_key: bool = False) -> ChannelRead
@router.get("", response_model=ChannelList)
@cached("channels", key_builder=_channels_key_builder)
async def list_channels(
_: RequireRead,
session: DbSession,
+25
View File
@@ -7,6 +7,7 @@ from sqlalchemy import func, or_, select
from sqlalchemy.sql.elements import ColumnElement
from meshcore_hub.api.auth import RequireRead
from meshcore_hub.api.cache import cached, sorted_query_string
from meshcore_hub.api.channel_visibility import (
get_max_visibility_level,
get_visible_channel_indices,
@@ -35,6 +36,16 @@ router = APIRouter()
_FLOOD_ROUTE_TYPES = {"flood", "transport_flood"}
def _dashboard_stats_key_builder(request: Request) -> str:
role = resolve_user_role(request) or "anonymous"
return f"dashboard/stats:role={role}:{sorted_query_string(request)}"
def _dashboard_msg_activity_key_builder(request: Request) -> str:
role = resolve_user_role(request) or "anonymous"
return f"dashboard/message-activity:role={role}:{sorted_query_string(request)}"
def _flood_only_filter(
ad_model: type[Advertisement],
) -> ColumnElement[bool]:
@@ -49,6 +60,11 @@ def _flood_only_filter(
@router.get("/stats", response_model=DashboardStats)
@cached(
"dashboard/stats",
ttl_setting="redis_cache_ttl_dashboard",
key_builder=_dashboard_stats_key_builder,
)
async def get_stats(
_: RequireRead,
session: DbSession,
@@ -306,9 +322,11 @@ async def get_stats(
@router.get("/activity", response_model=DailyActivity)
@cached("dashboard/activity", ttl_setting="redis_cache_ttl_dashboard")
async def get_activity(
_: RequireRead,
session: DbSession,
request: Request,
days: int = 30,
) -> DailyActivity:
"""Get daily advertisement activity for the specified period.
@@ -360,6 +378,11 @@ async def get_activity(
@router.get("/message-activity", response_model=MessageActivity)
@cached(
"dashboard/message-activity",
ttl_setting="redis_cache_ttl_dashboard",
key_builder=_dashboard_msg_activity_key_builder,
)
async def get_message_activity(
_: RequireRead,
session: DbSession,
@@ -419,9 +442,11 @@ async def get_message_activity(
@router.get("/node-count", response_model=NodeCountHistory)
@cached("dashboard/node-count", ttl_setting="redis_cache_ttl_dashboard")
async def get_node_count_history(
_: RequireRead,
session: DbSession,
request: Request,
days: int = 30,
) -> NodeCountHistory:
"""Get cumulative node count over time.
+7
View File
@@ -8,6 +8,7 @@ from sqlalchemy import func, or_, select
from sqlalchemy.orm import aliased, selectinload
from meshcore_hub.api.auth import RequireRead
from meshcore_hub.api.cache import cached, sorted_query_string
from meshcore_hub.api.channel_visibility import (
get_max_visibility_level,
get_visible_channel_indices,
@@ -23,6 +24,11 @@ router = APIRouter()
VALID_MSG_SORT_COLUMNS = {"time", "type", "from", "message"}
def _messages_key_builder(request: Request) -> str:
role = resolve_user_role(request) or "anonymous"
return f"messages:role={role}:{sorted_query_string(request)}"
def _get_tag_name(node: Optional[Node]) -> Optional[str]:
"""Extract name tag from a node's tags."""
if not node or not node.tags:
@@ -34,6 +40,7 @@ def _get_tag_name(node: Optional[Node]) -> Optional[str]:
@router.get("", response_model=MessageList)
@cached("messages", key_builder=_messages_key_builder)
async def list_messages(
_: RequireRead,
session: DbSession,
+4 -1
View File
@@ -2,11 +2,12 @@
from typing import Optional
from fastapi import APIRouter, HTTPException, Path, Query
from fastapi import APIRouter, HTTPException, Path, Query, Request
from sqlalchemy import func, or_, select
from sqlalchemy.orm import selectinload
from meshcore_hub.api.auth import RequireRead
from meshcore_hub.api.cache import cached
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.common.models import (
Advertisement,
@@ -47,9 +48,11 @@ VALID_NODE_SORT_COLUMNS = {"name", "public_key", "last_seen"}
@router.get("", response_model=NodeList)
@cached("nodes")
async def list_nodes(
_: RequireRead,
session: DbSession,
request: Request,
search: Optional[str] = Query(
None, description="Search in name tag, node name, or public key"
),
@@ -8,6 +8,7 @@ from sqlalchemy import func, or_, select
from sqlalchemy.orm import selectinload
from meshcore_hub.api.auth import RequireRead, RequireUserOwner, X_USER_ID_HEADER
from meshcore_hub.api.cache import cached
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.api.profile_utils import get_or_create_profile
from meshcore_hub.common.config import get_web_settings
@@ -51,9 +52,11 @@ def _build_adopted_nodes(profile: UserProfile) -> list[AdoptedNodeRead]:
@router.get("/profiles", response_model=UserProfileList)
@cached("profiles")
async def list_profiles(
_: RequireRead,
session: DbSession,
request: Request,
exclude_test: bool = Query(
default=True, description="Exclude test users from results"
),
+23
View File
@@ -208,6 +208,29 @@ class APISettings(CommonSettings):
default=None, description="Admin API key (full access)"
)
# Redis cache
redis_enabled: bool = Field(
default=False, description="Enable Redis API response caching"
)
redis_host: str = Field(default="localhost", description="Redis server host")
redis_port: int = Field(default=6379, description="Redis server port")
redis_db: int = Field(default=0, description="Redis database number")
redis_password: Optional[str] = Field(
default=None, description="Redis password (optional)"
)
redis_key_prefix: str = Field(
default="hub",
description="Prefix for all cache keys (isolates multi-instance setups)",
)
redis_cache_ttl: int = Field(
default=30,
description="Default cache TTL in seconds",
)
redis_cache_ttl_dashboard: int = Field(
default=30,
description="Cache TTL for dashboard endpoints (seconds)",
)
@property
def effective_database_url(self) -> str:
"""Get the effective database URL, using default if not set."""
+112
View File
@@ -0,0 +1,112 @@
"""Redis cache backend for API response caching."""
import logging
from typing import Optional
logger = logging.getLogger(__name__)
class CacheBackend:
"""Abstract base class for cache backends."""
def get(self, key: str) -> Optional[str]:
raise NotImplementedError
def set(self, key: str, value: str, ttl: int) -> None:
raise NotImplementedError
def delete(self, prefix: str) -> None:
raise NotImplementedError
def ping(self) -> bool:
raise NotImplementedError
class NullCache(CacheBackend):
"""No-op cache backend used when Redis is disabled."""
def get(self, key: str) -> Optional[str]:
return None
def set(self, key: str, value: str, ttl: int) -> None:
pass
def delete(self, prefix: str) -> None:
pass
def ping(self) -> bool:
return False
class RedisCacheBackend(CacheBackend):
"""Redis-backed cache using a connection pool."""
def __init__(
self,
host: str = "localhost",
port: int = 6379,
db: int = 0,
password: Optional[str] = None,
key_prefix: str = "hub",
) -> None:
import redis
self._prefix = key_prefix
self._client = redis.Redis(
host=host,
port=port,
db=db,
password=password,
socket_timeout=2,
socket_connect_timeout=2,
retry_on_timeout=False,
)
def _full_key(self, key: str) -> str:
return f"{self._prefix}:{key}"
def get(self, key: str) -> Optional[str]:
try:
full_key = self._full_key(key)
value = self._client.get(full_key)
if value is not None:
return value.decode("utf-8") if isinstance(value, bytes) else value
return None
except Exception as e:
logger.warning("Redis GET error for %s: %s", key, e)
return None
def set(self, key: str, value: str, ttl: int) -> None:
try:
full_key = self._full_key(key)
self._client.setex(full_key, ttl, value)
except Exception as e:
logger.warning("Redis SET error for %s: %s", key, e)
def delete(self, prefix: str) -> None:
try:
full_prefix = self._full_key(prefix)
cursor = 0
while True:
cursor, keys = self._client.scan(
cursor, match=f"{full_prefix}*", count=100
)
if keys:
self._client.delete(*keys)
if cursor == 0:
break
except Exception as e:
logger.warning("Redis DELETE error for prefix %s: %s", prefix, e)
def ping(self) -> bool:
try:
result = self._client.ping()
return bool(result)
except Exception:
return False
def close(self) -> None:
try:
self._client.close()
except Exception:
pass
+313
View File
@@ -0,0 +1,313 @@
"""Tests for API cache layer."""
import json
from unittest.mock import MagicMock, patch
import pytest
from fastapi import FastAPI, Request
from meshcore_hub.api.cache import cached, sorted_query_string
from meshcore_hub.common.redis import NullCache, RedisCacheBackend
class TestSortedQueryString:
def test_empty_query_params(self):
scope = {"type": "http", "query_string": b"", "headers": []}
request = Request(scope)
assert sorted_query_string(request) == ""
def test_single_param(self):
scope = {"type": "http", "query_string": b"limit=50", "headers": []}
request = Request(scope)
assert sorted_query_string(request) == "limit=50"
def test_multiple_params_sorted(self):
scope = {
"type": "http",
"query_string": b"offset=0&limit=50",
"headers": [],
}
request = Request(scope)
result = sorted_query_string(request)
assert result == "limit=50&offset=0"
def test_url_encoded_special_chars(self):
scope = {
"type": "http",
"query_string": b"search=foo+bar",
"headers": [],
}
request = Request(scope)
result = sorted_query_string(request)
assert "search=" in result
assert "foo" in result
class TestNullCache:
def test_get_returns_none(self):
cache = NullCache()
assert cache.get("any_key") is None
def test_set_does_not_raise(self):
cache = NullCache()
cache.set("key", "value", 30)
def test_ping_returns_false(self):
cache = NullCache()
assert cache.ping() is False
def test_delete_does_not_raise(self):
cache = NullCache()
cache.delete("prefix")
class TestRedisCacheBackend:
def test_get_returns_cached_value(self):
with patch("redis.Redis") as mock_redis_cls:
mock_client = MagicMock()
mock_redis_cls.return_value = mock_client
mock_client.get.return_value = b'{"items": []}'
backend = RedisCacheBackend(key_prefix="hub")
result = backend.get("nodes:limit=50")
assert result == '{"items": []}'
mock_client.get.assert_called_once_with("hub:nodes:limit=50")
def test_get_returns_none_on_miss(self):
with patch("redis.Redis") as mock_redis_cls:
mock_client = MagicMock()
mock_redis_cls.return_value = mock_client
mock_client.get.return_value = None
backend = RedisCacheBackend(key_prefix="hub")
assert backend.get("nodes:limit=50") is None
def test_set_stores_with_ttl(self):
with patch("redis.Redis") as mock_redis_cls:
mock_client = MagicMock()
mock_redis_cls.return_value = mock_client
backend = RedisCacheBackend(key_prefix="hub")
backend.set("nodes:limit=50", '{"items": []}', 30)
mock_client.setex.assert_called_once_with(
"hub:nodes:limit=50", 30, '{"items": []}'
)
def test_ping_returns_true(self):
with patch("redis.Redis") as mock_redis_cls:
mock_client = MagicMock()
mock_redis_cls.return_value = mock_client
mock_client.ping.return_value = True
backend = RedisCacheBackend(key_prefix="hub")
assert backend.ping() is True
def test_ping_returns_false_on_error(self):
with patch("redis.Redis") as mock_redis_cls:
mock_client = MagicMock()
mock_redis_cls.return_value = mock_client
mock_client.ping.side_effect = Exception("connection refused")
backend = RedisCacheBackend(key_prefix="hub")
assert backend.ping() is False
def test_get_returns_none_on_connection_error(self):
with patch("redis.Redis") as mock_redis_cls:
mock_client = MagicMock()
mock_redis_cls.return_value = mock_client
mock_client.get.side_effect = Exception("connection error")
backend = RedisCacheBackend(key_prefix="hub")
assert backend.get("any_key") is None
def test_set_logs_warning_on_error(self):
with patch("redis.Redis") as mock_redis_cls:
mock_client = MagicMock()
mock_redis_cls.return_value = mock_client
mock_client.setex.side_effect = Exception("timeout")
backend = RedisCacheBackend(key_prefix="hub")
backend.set("key", "value", 30)
# Should not raise
def test_key_prefix_prepended(self):
with patch("redis.Redis") as mock_redis_cls:
mock_client = MagicMock()
mock_redis_cls.return_value = mock_client
mock_client.get.return_value = None
backend = RedisCacheBackend(key_prefix="hub-stg")
backend.get("nodes:")
mock_client.get.assert_called_once_with("hub-stg:nodes:")
class TestCachedDecorator:
async def test_cache_hit_returns_cached_data(self):
app = FastAPI()
mock_cache = MagicMock()
mock_cache.get.return_value = json.dumps({"items": [], "total": 0})
app.state.redis_cache = mock_cache
app.state.redis_cache_ttl = 30
@cached("nodes")
async def handler(request: Request):
return {"items": ["should not appear"], "total": 1}
scope = {
"type": "http",
"query_string": b"limit=50",
"headers": [],
"app": app,
}
from starlette.datastructures import State
request = Request(scope)
request._state = State()
result = await handler(request=request)
assert result == {"items": [], "total": 0}
assert request.state.cache_status == "HIT"
async def test_cache_miss_calls_handler(self):
app = FastAPI()
mock_cache = MagicMock()
mock_cache.get.return_value = None
app.state.redis_cache = mock_cache
app.state.redis_cache_ttl = 30
@cached("nodes")
async def handler(request: Request):
return {"items": ["real"], "total": 1}
scope = {
"type": "http",
"query_string": b"limit=50",
"headers": [],
"app": app,
}
from starlette.datastructures import State
request = Request(scope)
request._state = State()
result = await handler(request=request)
assert result == {"items": ["real"], "total": 1}
assert request.state.cache_status == "MISS"
mock_cache.set.assert_called_once()
async def test_null_cache_always_calls_handler(self):
app = FastAPI()
app.state.redis_cache = NullCache()
app.state.redis_cache_ttl = 30
call_count = 0
@cached("nodes")
async def handler(request: Request):
nonlocal call_count
call_count += 1
return {"items": [], "total": 0}
scope = {
"type": "http",
"query_string": b"limit=50",
"headers": [],
"app": app,
}
from starlette.datastructures import State
request = Request(scope)
request._state = State()
await handler(request=request)
await handler(request=request)
assert call_count == 2
async def test_redis_error_falls_through(self):
app = FastAPI()
mock_cache = MagicMock()
mock_cache.get.side_effect = Exception("redis down")
app.state.redis_cache = mock_cache
app.state.redis_cache_ttl = 30
@cached("nodes")
async def handler(request: Request):
return {"items": ["fallback"], "total": 1}
scope = {
"type": "http",
"query_string": b"",
"headers": [],
"app": app,
}
from starlette.datastructures import State
request = Request(scope)
request._state = State()
result = await handler(request=request)
assert result == {"items": ["fallback"], "total": 1}
async def test_no_request_raises_error(self):
@cached("nodes")
async def handler():
return {"items": []}
with pytest.raises(TypeError, match="No Request"):
await handler()
async def test_custom_key_builder(self):
app = FastAPI()
mock_cache = MagicMock()
mock_cache.get.return_value = None
app.state.redis_cache = mock_cache
app.state.redis_cache_ttl = 30
def role_key_builder(request: Request) -> str:
role = request.headers.get("x-user-roles", "anonymous")
return f"messages:role={role}:"
@cached("messages", key_builder=role_key_builder)
async def handler(request: Request):
return {"items": []}
scope = {
"type": "http",
"query_string": b"",
"headers": [(b"x-user-roles", b"admin")],
"app": app,
}
from starlette.datastructures import State
request = Request(scope)
request._state = State()
await handler(request=request)
mock_cache.get.assert_called_once_with("messages:role=admin:")
async def test_dashboard_ttl_override(self):
app = FastAPI()
mock_cache = MagicMock()
mock_cache.get.return_value = None
app.state.redis_cache = mock_cache
app.state.redis_cache_ttl = 30
app.state.redis_cache_ttl_dashboard = 60
@cached("dashboard/stats", ttl_setting="redis_cache_ttl_dashboard")
async def handler(request: Request):
return {"total_nodes": 10}
scope = {
"type": "http",
"query_string": b"",
"headers": [],
"app": app,
}
from starlette.datastructures import State
request = Request(scope)
request._state = State()
await handler(request=request)
call_args = mock_cache.set.call_args
assert call_args[0][2] == 60 # TTL should be 60, not 30