Redis Caching System
Overview
CONA uses Redis as an in-memory caching layer to significantly improve application performance by reducing database query latency. Redis stores frequently accessed data in memory, providing sub-millisecond access times for cached data.Why Redis?
Performance Benefits
- Query Latency Reduction: Reduces database query time from ~100ms to ~5ms
- Workflow Throughput: Improves Temporal workflow performance by reducing I/O bottlenecks
- User Experience: Faster page loads and real-time data updates
- Cost Efficiency: Reduces database load, allowing for smaller database instances
Use Cases in CONA
- Chart of Accounts Caching: Frequently accessed account data and lists
- Organization Settings: User preferences and configuration data
- Posting Matrix Rules: Complex accounting rules that are expensive to compute
- Integration Data: Cached results from external API calls
- Session Data: User authentication and session information
Architecture
- Why Redis is used - Performance benefits and use cases
- Architecture overview - How Redis fits into the system
- Configuration details - Production and development setup
- Implementation patterns - Code examples and best practices
- Cache version management - Version-prefixed keys; generations expire via TTL (no full-cache flush)
- Monitoring integration - How to connect with Redis Insights
- Troubleshooting guide - Common issues and solutions
- Security considerations - Access control and data protection
- Cost optimization - Current costs and optimization strategies
Redis Configuration
Production Environment
Provider: Redis Cloud Configuration:- Database:
13661105 - Subscription:
2958056 - Region: Frankfurt (fra)
- Memory: 250MB (Dataset: 125MB, Total: 250MB)
- High Availability: Single zone
- Connections: 30 concurrent connections
- Persistence: RDB snapshots every 6 hours
- Network Cap: 100GB/month
rediss://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}
GUI to connect - Redis Insight
Development Environment
Local Redis Instance: Local instances is running in docker container. To spin up the instances runpnpm run redis:start:dev
Environment Variables
Required Variables:REDIS_HOST- Redis server hostnameREDIS_PORT- Redis server portREDIS_PASSWORD- Redis authentication passwordREDIS_TLS- Set to"true"for TLS connections (production)REDIS_ENV- Environment identifier for cache key prefixing (defaults to no prefix in production)
REDIS_ENABLED- Set to"false"to disable Redis (all cache operations will be no-ops)REDIS_CACHE_VERSION- Runtime override for the shared cache generation. Normally leave it unset and letDEFAULT_CACHE_VERSIONgovern (see below)- Falls back to
DEFAULT_CACHE_VERSION("1") when unset or blank - If set at all, it must be identical across webapp, temporal-workers, and portal — never derived from a per-service deploy identity
- Every cache key is stored as
v{version}:{key}. Changing the version rolls to a new generation; the old one is left to expire via TTL (noFLUSHDB) - Does not use
VERCEL_GIT_COMMIT_SHA(that previously caused cross-serviceFLUSHDBwars — see CONA-1017 / CONA-1090)
- Falls back to
Cache Version Management
Every cache entry is stored under a version-prefixed key —v{version}:{key} — where the version comes from the shared REDIS_CACHE_VERSION (default "1"). Bumping the version rolls all cache reads and writes onto a fresh keyspace; the previous generation is simply never read again and expires via its own TTLs. There is no FLUSHDB and no connect-time version check — independently deployed services (webapp on Vercel, temporal-workers on Fly.io, portal) never touch each other’s data, and ordinary deploys never invalidate the cache.
This replaced an earlier design where a version mismatch triggered a global FLUSHDB on connect. Because the version was derived per-service (VERCEL_GIT_COMMIT_SHA on webapp vs "1" on workers), the shared database was flushed back and forth on every deploy and restart — the CONA-1090 “FLUSHDB war”.
How It Works
- Key prefixing:
getFromCache/setInCache/deleteFromCache/deleteFromCacheByPatternresolve every logical key throughtoStorageKey(), which prependsv{version}:(and theREDIS_ENVnamespace in non-production). - Version bump: changing the version changes the prefix, so subsequent reads miss the old generation and re-populate under the new one.
- Old generation expiry: orphaned
v{old}:…keys are never read again and expire naturally via their TTLs — no active deletion, no blocking flush. - Non-cache keys are never versioned: counters, locks, rate limits and JTIs (via the
atomic.tshelpers) use raw keys and are unaffected by a version bump.
Version Sources (Priority Order)
- Runtime override (
REDIS_CACHE_VERSION): wins when set. Use it only to roll the generation without a deploy, and set the same string on every service — or on none. DEFAULT_CACHE_VERSION(packages/core/src/redis/index.ts, currently"1"): the normal mechanism. It ships with the code, so every service that does not pin the env var moves to the new generation on the same deploy.
VERCEL_GIT_COMMIT_SHA is intentionally ignored — per-deploy SHAs differ between services and previously caused the webapp and workers to flush each other’s cache.
Careful: the env var wins over the constant. If a service pinsREDIS_CACHE_VERSION, bumpingDEFAULT_CACHE_VERSIONwill not move that service, and you get services split across generations — the class of bug CONA-1090 fixed. Keep it unset everywhere unless you are deliberately using it.
Configuration Examples
Production / Development (default):When the Cache Rolls
DEFAULT_CACHE_VERSIONbump: the deploy carrying it moves every service that does not pin the env var onto a new generation; the old one expires via TTL.- Runtime override change: setting or changing
REDIS_CACHE_VERSION(same value on all services) does the same without a deploy. - Ordinary deploys: nothing happens — the cache persists across deploys by design.
- Shape change without a bump: old-shaped entries stay reachable under the current prefix until their TTLs expire, so bump the version whenever a cached payload’s shape changes incompatibly.
Cost of a bump
Because the old generation is not deleted, both generations coexist in memory until the old keys’ TTLs expire (most CONA caches use TTLs ≤ 1 hour; the longest are 24 h / 7 d). On the 250 MB production instance this transient overlap is acceptable given how rarely the version changes. Normally you just wait it out.Note: the previous design emitted aredis.cache_flushAxiom event on each connect-time flush. That flush no longer exists, so no newredis.cache_flushevents are emitted; historical events remain queryable.
Guards against a split generation
Two checks run at startup so a repeat of CONA-1090 is visible immediately instead of after weeks. A deploy-derived version is rejected. IfREDIS_CACHE_VERSION looks like a commit SHA, or matches a variable the platform sets per deploy (VERCEL_GIT_COMMIT_SHA, VERCEL_DEPLOYMENT_ID, FLY_MACHINE_ID, GITHUB_SHA, …), it is ignored and the service uses DEFAULT_CACHE_VERSION instead. A misconfigured service rejoins the shared generation rather than quietly getting a private cache. It logs redis.cache_version_rejected.
A version made only of digits — 20260727 — is not rejected. Only the presence of a letter a–f distinguishes a SHA from a deliberate date-style version.
Every process publishes the generation it resolved. All records live in one hash, cache_generation:{REDIS_ENV}, with a field per process ({service}:{instance}) holding {generation}|{writtenAt}. Each process rewrites its field every 10 minutes and compares the hash. Agreement logs redis.cache_generation; disagreement logs redis.cache_generation_mismatch at error level naming each instance and its generation. Alert on the mismatch event.
All of these — along with redis.env_missing, redis.cache_version_rejected and redis.cache_generation_failed — go through the logger installed by configureRedisObservability, not the module-level consoleLogger. Only the configured one reaches the service’s Axiom dataset; using the console logger would leave the events in stdout and the alert would never fire.
One hash instead of one key per process specifically to avoid SCAN: matching cache_generation:* still walks the whole keyspace, which on production is roughly 1,800 round trips per report at COUNT 100, on every process start and every refresh. That is the cost this ticket exists to reduce, so the check must not reintroduce it. HGETALL is one command.
Redis cannot expire individual hash fields portably, so freshness is carried in the value: a process that has missed three refreshes is treated as gone and its field is deleted on the next read. That keeps the hash small as instances are replaced, and stops a stopped process from faking a disagreement.
That cleanup deletes a field only if it still holds the value the reporter classified as stale, via a small Lua compare-and-delete. HGETALL and HDEL are separate commands, so a process that had been silent long enough to look dead — a frozen serverless instance, or a worker whose event loop stalled — can refresh in between and have its brand-new value deleted from a stale snapshot. It would then be absent from comparisons until its next refresh, and a rollout could report agreement while that process was on a different generation.
The record is keyed by process, not by service, and refreshed rather than written once. Both matter for it to work at all:
RedisServicehas only three values and the portal reports itself aswebapp, so a service-only key let the portal overwrite the webapp’s record — and let a starting instance overwrite a draining one during the rollout the check is meant to watch.- A worker that stays up for days would otherwise lose its record and vanish from the comparison. A webapp deployed later would then find only itself and report agreement — missing the long-running-worker-versus-new-webapp case, which is the shape CONA-1090 actually took.
Cache stamps — invalidating without deleting
Deleting keys only clears the caller’s own generation, so during a rollout an older process can keep serving data it should have dropped. It also costs a full keyspace scan per pattern. Cache stamps fix both. A stamp is a token that changes when source data changes. Put it in the key and a mutation stops deleting anything — every reader builds a new key, misses, and refetches:keyPrefix observability reads the segment before the first colon, and the pattern deletes still running in phase 2 are anchored at the front, so appending keeps both working.
Old and new builds compute the same key, because the stamp is shared data rather than per-process config. They cannot disagree.
Stamp keys carry the environment but not the generation. cacheStampKey() produces cache_stamp:{REDIS_ENV}:{scope}. The split is deliberate:
- No
v{version}:prefix, so every build reads the same stamp. That is what closes the rollout gap; writing a stamp throughsetInCachewould give it the generation prefix and bring the problem straight back. - Namespaced by environment, because environments share one Redis instance. Without it, a staging save would invalidate production’s caches for any organization id present in both databases.
null when Redis is down — callers should bypass the cache rather than invent a stamp, because two processes inventing different ones is the split this removes.
Rollout (CONA-1096)
Changing the key format is itself a rollout, so it goes in phases. Skipping straight to phase 2 would recreate the very stale window this removes.
Phase 1 must be deployed everywhere before phase 2 ships. That is what makes phase 2 safe: an older process still deletes its own keys, a newer one invalidates via the stamp, and because phase 1 taught the older process to bump that stamp, both are correct at the same time. Phase 3 waits until nothing reads unstamped keys.
Phase 2 covers every reader of the
postingMatrix scope — fetchPostingMatrixRules, fetchGlDimensions, getGlDimensions, getGlDimensionsWithoutAccounts. The other posting-matrix caches (posting_matrices_grouped, posting_matrix:{org}:{id}) are UI-facing, are not in the scope, and keep their existing deletes.
Two things worth knowing while phase 2 is live:
- Orphaned entries are left to expire. An invalidated key is not deleted, just never addressed again. It occupies memory for up to its TTL (1 h for these), so expect a modest bump in key count that flattens out.
- Each read costs one extra round trip for the stamp.
createAccountingImpactalready memoizes rule fetches per document, so this is a handful of extraEVALs per document, not per rule evaluation. - A failed stamp bump still invalidates. The list deletes are exact unstamped keys. If
bumpCacheStampreturnsnullwhile those deletes succeed, the helpers retire the stamp (clearCacheStamp) and clean up the stamped list keys that were stored under it. Retiring is required: deleting the list entry alone leaves the stamp current, so an in-flight miss holding it can rewrite that same key after the delete and serve the pre-save snapshot for the remaining TTL.
Implementation
Import Redis Functions
Redis Client Initialization
Redis is automatically initialized on application startup via thestartRedis() function. This function:
- Connection Management: Handles Redis connection with automatic retry logic
- Error Handling: Gracefully handles connection failures and returns status
- Version Checking: Automatically checks and updates cache version on connection
- Reconnection: Implements exponential backoff retry strategy (4 attempts max)
- Return Value: Returns a
RedisStartResultobject with connection status
- Initial delay: 4 seconds
- Max attempts: 4
- Backoff: Geometric progression (4s, 8s, 16s, 32s)
- Total timeout: ~65 seconds (sum of all delays + 5s buffer)
Core Cache Functions
All cache functions include:- Graceful Degradation: Return
nullor no-op if Redis is unavailable - Environment Prefixing: Automatically prefix keys with
REDIS_ENVin non-production - Error Handling: Log warnings but don’t throw errors (allows app to continue)
toStorageKey() prepends the cache generation v{version}: everywhere, plus the
REDIS_ENV namespace outside production. Callers always pass the logical key:
REDIS_ENV is unset the namespace falls back to unknown: — deliberately not
production, so an unconfigured service gets its own isolated keyspace rather than
writing into production’s. It also shares nothing with correctly-configured services,
so startRedis() logs a redis.env_missing error when it is absent. Every deployed
service (webapp, temporal-workers, portal) must set it.
Cache Key Conventions
{domain}:{type}:{organizationId}:{identifier}
Bulk Cache Invalidation Functions
CONA provides specialized functions for invalidating related caches:Usage Patterns
1. Cache-Aside Pattern
2. Write-Through Pattern
3. Cache Invalidation Strategies
Monitoring & Debugging
Redis Insights Integration
Production Redis Cloud Dashboard: https://cloud.redis.io/#/databases/13661105/subscription/2958056/view-bdb/configuration Key Metrics to Monitor:- Memory Usage: Track current / 250MB — check Redis Cloud dashboard
- Hit Rate: Target >90% cache hit rate
- Connection Count: Monitor concurrent connections
- Key Count: Track number of cached keys
- Operations per Second: Monitor Redis performance
- Network Usage: Monitor against the 100 GB/month cap — use the
redis.usage_rollupAxiom queries above for per-service and per-key-family breakdowns - Cache Version: The active generation is the
v{version}:prefix on cache keys (REDIS_CACHE_VERSION, default"1") - Cache Flushes: The app no longer flushes on connect;
redis.cache_flushevents exist only as historical records of the retired design
Redis CLI Access
Application Logging
The Redis implementation includes comprehensive logging:- Connection Events: Logs when Redis connects, reconnects, or fails (
redis.connection→ Axiom) - Cache Generation: Cache keys are prefixed with the shared
v{version}:generation; there is no connect-time version check or flush to log - Aggregate Usage: Emits
redis.usage_rollupevents periodically (see Axiom Observability section) - Error Handling: Logs warnings when cache operations fail (allows graceful degradation)
"Redis connection established successfully"
redis.usage_rollup) over free-text log lines — they are always present regardless of log level filtering.
Axiom Observability
Every Redis operation emits structured telemetry to Axiom. This data is available now in the live datasets and can be queried immediately to investigate performance, cache efficiency, and network usage — without any code changes. Four event types are written under thefields.event key:
redis.call events are emitted at debug level. In long-running Temporal workers, the pino logger typically runs at info, so per-call debug events are dropped there. redis.usage_rollup fills that gap — it aggregates the same data and emits at info so it always reaches Axiom.
Setup
CallconfigureRedisObservability() once at process startup, after startRedis(), to wire the correct service identity and logger into all subsequent events. Without this call the events fall back to consoleLogger and never reach Axiom.
The state is stored on
globalThis via Symbol.for("cona.redis.observability") so it is shared across all Next.js bundles in the same Node process — the instrumentation file and route handlers always see the same configuration.
Shutting down (Temporal workers only): Call flushRedisRollup() before the final pino drain so the last partial window is shipped to Axiom before process.exit() kills the transport:
redis.call Event Schema
Emitted by observeRedisCall() which wraps every Redis command. Each field maps directly to an Axiom column under fields.*.
Key prefix extraction strips everything after the first colon so dynamic segments (org IDs, user tokens, etc.) are never exposed in logs:
redis.connection Event Schema
Emitted by attachRedisConnectionLifecycleListeners() on every ioredis lifecycle transition.
redis.usage_rollup Event Schema
Emitted at info level by the periodic rollup timer and by flushRedisRollup() at shutdown. Each event covers one (service, caller, operation, keyPrefix) bucket for a single time window.
redis.cache_flush Event Schema (retired)
No longer emitted. The connect-time FLUSHDB was removed in CONA-1090 — the cache version now lives in the key prefix (v{version}:). This schema is retained only to interpret historical events already in Axiom.
Privacy Guarantees
The observability layer is designed so that raw Redis keys, values, tokens, session IDs, and any PII are never written to Axiom. Only the following are logged:- Byte counts —
requestBytes,responseBytes,totalBytes - Normalised key prefix — the static first segment of the key, not the full key
- Error messages — stripped of key names and raw values
Example Axiom Queries
Use thecona_webapp dataset for webapp events. For Temporal worker events use cona_temporal-workers (note: redis.call debug events are dropped at the worker’s pino level — use redis.usage_rollup events from that dataset instead).
Cache hit rate over time
Best Practices
1. TTL (Time To Live) Strategy
2. Cache Key Design
- Hierarchical: Use colons to separate levels (
domain:type:org:id) - Consistent: Follow the same pattern across all cache keys
- Descriptive: Make keys self-documenting
- Environment-aware: Automatically prefixed with
REDIS_ENVin non-production
3. Error Handling
All cache functions are designed to fail gracefully:4. Memory Management
- Set appropriate TTLs to prevent memory bloat
- Use bulk invalidation functions for related caches
- Monitor memory usage regularly in Redis Insights
- Cache version management rolls to a new key generation when
REDIS_CACHE_VERSIONis bumped; the old generation expires via TTL (no flush)
5. Cache Version Management
- Shared across services: Default
"1", or the sameREDIS_CACHE_VERSIONon webapp, workers, and portal - Roll a generation: Bump
REDIS_CACHE_VERSIONto the same new value in every service when cached payload shapes change; reads/writes move to the newv{version}:prefix and the old generation expires via TTL (no flush) - No per-deploy auto-flush: Ordinary deploys leave Redis intact — do not rely on
VERCEL_GIT_COMMIT_SHA - Development: Uses default version or set manually for testing
- Monitor Axiom: Watch
redis.usage_rollupfor hit ratios and network usage after a version bump
Troubleshooting
Common Issues
-
Connection Timeouts
- Check Redis Cloud status
- Verify network connectivity
- Check connection pool settings
- Review retry logs (max 4 attempts with exponential backoff)
-
Memory Issues
- Monitor memory usage in Redis Insights
- Check for memory leaks in key patterns
- Adjust TTL values
- Use bulk invalidation functions
-
Performance Issues
- Check cache hit rates
- Monitor Redis CPU usage
- Verify key patterns are efficient
- After a version bump, expect a brief cold-cache window while the new generation populates
-
Data Inconsistency
- Ensure proper cache invalidation
- Check for race conditions
- Verify write-through patterns
- Confirm
REDIS_CACHE_VERSIONmatches across all services (a mismatch routes services to different generations)
-
Stale Cache After Deploy / Restart
- Ordinary deploys keep the cache — bump the shared
REDIS_CACHE_VERSIONon all services to roll to a fresh generation when a cached payload’s shape changed - Verify
REDIS_CACHE_VERSIONis identical across webapp, temporal-workers, and portal (or unset everywhere so all use"1") — a mismatch sends services to different generations - The old generation expires via TTL — wait it out. Never
FLUSHDBthis shared database; if you must reclaim memory early, delete the retired generation by prefix (see Cost of a bump)
- Ordinary deploys keep the cache — bump the shared
Debug Commands
Security
Access Control
- Password Protection: All Redis instances use strong passwords
- TLS Encryption: Production Redis uses TLS for secure connections
- Network Isolation: Redis Cloud provides network-level security
- Environment Separation: Development and production use separate instances
- Key Prefixing: Non-production environments use
REDIS_ENVprefix to prevent cross-contamination
Data Protection
- No Sensitive Data: Never cache passwords, tokens, or PII
- TTL Enforcement: All cached data has appropriate expiration
- Encryption: Sensitive cached data should be encrypted before storage
- Version Management: Bumping the shared version rolls cached data to a new generation; stale entries expire via TTL
Cost Optimization
Current Costs
- Redis Cloud: ~$10/month for 250MB memory (estimated based on usage)
- Performance Gain: Reduces database load, allowing smaller DB instances
- ROI: Significant performance improvement for minimal cost
Optimization Strategies
- Right-size Memory: Monitor usage and adjust memory allocation
- Efficient TTLs: Set appropriate expiration times
- Key Compression: Use shorter, efficient key names
- Pattern Cleanup: Regular cleanup of unused keys
- Version Management: Shared
REDIS_CACHE_VERSIONbumps clear stale data when payload shapes change (ordinary deploys do not)
Future Enhancements
Planned Features
- Cache Warming: Pre-populate cache with frequently accessed data
- Distributed Caching: Support for multiple Redis instances
- Cache Analytics: Detailed hit/miss ratio tracking
- Automatic Scaling: Dynamic memory allocation based on usage
- Cache Versioning: Enhanced version management with metadata tracking
Integrations
- Temporal Workflows: Cache workflow state and results
- API Rate Limiting: Use Redis for rate limiting external APIs
- Session Management: Store user sessions in Redis
- Real-time Features: Use Redis for pub/sub messaging