Accepted for incremental rollout (DD-007 PR #29).
Firestore reads are a P0 cost driver. The default database is responsible for roughly $3,807/mo net with 10.7B reads/month. DD-007 found repeated reads of the same users/{uid} document on WebSocket startup and other hot paths.
The tempting fix is to cache the full user document. We explicitly do not do that.
Introduce a reusable projection-based Firestore read-through cache under backend/database/ and apply it only to low-risk typed projections first.
Firestore source of truth
↓
typed projection fetcher in database/*
↓
database.firestore_cache.get_or_fetch(...)
↓
Redis shared L2 cache, disabled by default
users/{uid} mixes low-risk preferences with correctness-critical fields:
| Field family | Risk if stale |
|---|---|
| Subscription / entitlement | grants or blocks paid features incorrectly |
| BYOK | accepts removed keys or misroutes provider billing |
| Data protection / migration state | writes data under wrong storage/encryption policy |
| Privacy consent | continues recording/training/syncing after opt-out |
| Account/security state | stale security decisions |
Therefore, this cache stores only allowlisted projections such as language, transcription preferences, and AI profile metadata. It must not be used for whole documents or critical fields without a separate design review and shadow rollout.
Cached projections:
| Projection | Namespace | TTL | Notes |
|---|---|---|---|
| language preference | user_language |
300s | low-risk listen startup setting |
| transcription preferences | user_transcription_prefs |
120s | low-risk startup prefs; includes language |
| AI user profile | user_ai_profile |
300s | read-mostly metadata |
Not cached:
get_user_subscription()/get_user_valid_subscription()- BYOK state
- data protection level
- private cloud sync flag
- recording / training consent
- full
get_user_profile()
Cache reads/writes are disabled by default.
FIRESTORE_CACHE_ENABLED=truePer-namespace override:
FIRESTORE_CACHE_USER_LANGUAGE_ENABLED=true
FIRESTORE_CACHE_USER_TRANSCRIPTION_PREFS_ENABLED=true
FIRESTORE_CACHE_USER_AI_PROFILE_ENABLED=trueEmergency namespace reset:
FIRESTORE_CACHE_GLOBAL_VERSION=2Bumping the global version abandons all existing keys without scanning Redis.
fs:v{global_version}:{namespace}:v{policy_version}:b64:{base64url(entity_id)}
Example for entity ID uid_123:
fs:v1:user_transcription_prefs:v1:b64:dWlkXzEyMw
Keys must include:
- global version
- namespace
- policy version
- base64url-encoded UID / entity ID
Entity IDs are encoded rather than character-replaced so keys are collision-free. For example, a:b and a_b must not map to the same Redis key.
Never include raw names, emails, user text, or secrets in key names.
Redis stores a typed envelope:
{
"v": 1,
"kind": "value",
"created_at": 1781460000.123,
"fresh_until": 1781460120.123,
"payload": {}
}The envelope supports schema version checks, TTL jitter, payload-size metrics, and future stale-while-revalidate support.
Redis is never the source of truth.
- cache disabled → fetch Firestore
- Redis read error → fetch Firestore
- malformed envelope → fetch Firestore
- Redis write error → return Firestore result
- payload too large → do not cache
Setter functions for cached projections must invalidate after successful Firestore writes.
Current invalidation hooks:
| Write path | Invalidates |
|---|---|
set_user_language_preference() |
user_language, user_transcription_prefs |
set_user_transcription_preferences() |
user_transcription_prefs |
set_user_custom_stt_usage() |
user_transcription_prefs |
update_ai_user_profile() |
user_ai_profile |
Prometheus metrics use low-cardinality labels only:
firestore_cache_requests_total{namespace,result}firestore_cache_fetch_seconds{namespace}firestore_cache_payload_bytes{namespace}
Do not add UID, email, route path, or free-form cache keys as labels.
- Deploy with cache disabled.
- Enable
user_languagefor internal users / small percentage. - Monitor cache hit/miss, Redis errors, Firestore reads, listen startup errors.
- Enable
user_transcription_prefs. - Enable
user_ai_profile. - Only consider entitlement/BYOK/data-protection caches after shadow-mode mismatch metrics exist.
Disable all cache reads/writes:
FIRESTORE_CACHE_ENABLED=falseOr abandon all existing keys:
FIRESTORE_CACHE_GLOBAL_VERSION=<new integer>No data migration is required because Firestore remains the source of truth.
deep-dives/DD-007-firestore-read-amplification.mddeep-dives/DD-007-firestore-cache-architecture-proposal.mddeep-dives/DD-007-committee-synthesis.md