Skip to main content

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

  1. Chart of Accounts Caching: Frequently accessed account data and lists
  2. Organization Settings: User preferences and configuration data
  3. Posting Matrix Rules: Complex accounting rules that are expensive to compute
  4. Integration Data: Cached results from external API calls
  5. Session Data: User authentication and session information

Architecture

This documentation provides comprehensive coverage of Redis usage in CONA, including:
  1. Why Redis is used - Performance benefits and use cases
  2. Architecture overview - How Redis fits into the system
  3. Configuration details - Production and development setup
  4. Implementation patterns - Code examples and best practices
  5. Cache version management - Version-prefixed keys; generations expire via TTL (no full-cache flush)
  6. Monitoring integration - How to connect with Redis Insights
  7. Troubleshooting guide - Common issues and solutions
  8. Security considerations - Access control and data protection
  9. 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
Connection Details:
Connection string - 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 run pnpm run redis:start:dev

Environment Variables

Required Variables:
  • REDIS_HOST - Redis server hostname
  • REDIS_PORT - Redis server port
  • REDIS_PASSWORD - Redis authentication password
  • REDIS_TLS - Set to "true" for TLS connections (production)
  • REDIS_ENV - Environment identifier for cache key prefixing (defaults to no prefix in production)
Optional Variables:
  • 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 let DEFAULT_CACHE_VERSION govern (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 (no FLUSHDB)
    • Does not use VERCEL_GIT_COMMIT_SHA (that previously caused cross-service FLUSHDB wars — see CONA-1017 / CONA-1090)

Cache Version Management

Every cache entry is stored under a version-prefixed keyv{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

  1. Key prefixing: getFromCache / setInCache / deleteFromCache / deleteFromCacheByPattern resolve every logical key through toStorageKey(), which prepends v{version}: (and the REDIS_ENV namespace in non-production).
  2. Version bump: changing the version changes the prefix, so subsequent reads miss the old generation and re-populate under the new one.
  3. Old generation expiry: orphaned v{old}:… keys are never read again and expire naturally via their TTLs — no active deletion, no blocking flush.
  4. Non-cache keys are never versioned: counters, locks, rate limits and JTIs (via the atomic.ts helpers) use raw keys and are unaffected by a version bump.

Version Sources (Priority Order)

  1. 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.
  2. 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 pins REDIS_CACHE_VERSION, bumping DEFAULT_CACHE_VERSION will 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):
Roll to a new generation (when a cached payload’s shape changes):

When the Cache Rolls

  • DEFAULT_CACHE_VERSION bump: 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.
Do not reclaim that memory with FLUSHDB. This database is shared, and the flush is not limited to the old generation: it wipes every service’s current cache, and also the deliberately unversioned atomic.ts keys — portal PDF JTI replay protection, Xentral rate limiter windows and cooldowns, and locks. That reintroduces exactly the cross-service disruption CONA-1090 removed, and it resets replay protection.If you must reclaim it early, delete only the retired generation by its prefix:
The prefix scopes this to one generation of one environment: other environments carry their own {REDIS_ENV}: prefix, and unversioned atomic keys never match.
Note: the previous design emitted a redis.cache_flush Axiom event on each connect-time flush. That flush no longer exists, so no new redis.cache_flush events 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. If REDIS_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:
  • RedisService has only three values and the portal reports itself as webapp, 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:
It goes on the end: 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 through setInCache would 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.
Read the stamp before the query, not after. If a save lands while the query is in flight, the write goes to the pre-save key and no reader ever asks for it — the stale rows are stranded. Re-reading the stamp at write time does the opposite: rows fetched before the save get written under the stamp readers are now using, and that is stale data served as fresh. A stamp only has to be a value that has never been used before. It is not a counter and is not ordered — an earlier draft compared timestamps, which made correctness depend on three separately hosted services agreeing about the clock. Each stamp is a short time prefix plus 48 random bits, so two saves in the same millisecond produce different stamps, a host whose clock lags produces a usable one, and a stamp recreated after eviction cannot repeat a value that a surviving cache entry is still stored under. Both helpers return 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. createAccountingImpact already memoizes rule fetches per document, so this is a handful of extra EVALs per document, not per rule evaluation.
  • A failed stamp bump still invalidates. The list deletes are exact unstamped keys. If bumpCacheStamp returns null while 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 the startRedis() 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 RedisStartResult object with connection status
Connection Retry Strategy:
  • 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 null or no-op if Redis is unavailable
  • Environment Prefixing: Automatically prefix keys with REDIS_ENV in non-production
  • Error Handling: Log warnings but don’t throw errors (allows app to continue)
Physical Key Prefixing: toStorageKey() prepends the cache generation v{version}: everywhere, plus the REDIS_ENV namespace outside production. Callers always pass the logical key:
If 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

Key Structure: {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_rollup Axiom 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_flush events 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_rollup events periodically (see Axiom Observability section)
  • Error Handling: Logs warnings when cache operations fail (allows graceful degradation)
Check application logs for messages like:
  • "Redis connection established successfully"
In Axiom, prefer querying the structured events (e.g. 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 the fields.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

Call configureRedisObservability() 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.
Options: 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 countsrequestBytes, 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 the cona_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
Slowest Redis operations (p95 duration)
Network bytes sent to Redis per key family
Recent connection lifecycle events (detect reconnect storms)
All Redis errors in the last hour
Volume of SET operations by TTL bucket
Aggregate network usage per service (rollup — works for both webapp and workers)
Top bandwidth consumers by key family (rollup)
Rollup over the Temporal workers dataset
Cache flush history (when and how many keys were wiped)

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_ENV in 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_VERSION is bumped; the old generation expires via TTL (no flush)

5. Cache Version Management

  • Shared across services: Default "1", or the same REDIS_CACHE_VERSION on webapp, workers, and portal
  • Roll a generation: Bump REDIS_CACHE_VERSION to the same new value in every service when cached payload shapes change; reads/writes move to the new v{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_rollup for hit ratios and network usage after a version bump

Troubleshooting

Common Issues

  1. Connection Timeouts
    • Check Redis Cloud status
    • Verify network connectivity
    • Check connection pool settings
    • Review retry logs (max 4 attempts with exponential backoff)
  2. Memory Issues
    • Monitor memory usage in Redis Insights
    • Check for memory leaks in key patterns
    • Adjust TTL values
    • Use bulk invalidation functions
  3. 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
  4. Data Inconsistency
    • Ensure proper cache invalidation
    • Check for race conditions
    • Verify write-through patterns
    • Confirm REDIS_CACHE_VERSION matches across all services (a mismatch routes services to different generations)
  5. Stale Cache After Deploy / Restart
    • Ordinary deploys keep the cache — bump the shared REDIS_CACHE_VERSION on all services to roll to a fresh generation when a cached payload’s shape changed
    • Verify REDIS_CACHE_VERSION is 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 FLUSHDB this shared database; if you must reclaim memory early, delete the retired generation by prefix (see Cost of a bump)

Debug Commands

FLUSHDB / FLUSHALL are deliberately absent. The database is shared by webapp, temporal-workers and portal, so a flush wipes every service’s cache plus the unversioned atomic keys (JTI replay protection, rate limiter windows, locks). To clear a retired generation, delete it by prefix — see Cost of a bump.

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_ENV prefix 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

  1. Right-size Memory: Monitor usage and adjust memory allocation
  2. Efficient TTLs: Set appropriate expiration times
  3. Key Compression: Use shorter, efficient key names
  4. Pattern Cleanup: Regular cleanup of unused keys
  5. Version Management: Shared REDIS_CACHE_VERSION bumps clear stale data when payload shapes change (ordinary deploys do not)

Future Enhancements

Planned Features

  1. Cache Warming: Pre-populate cache with frequently accessed data
  2. Distributed Caching: Support for multiple Redis instances
  3. Cache Analytics: Detailed hit/miss ratio tracking
  4. Automatic Scaling: Dynamic memory allocation based on usage
  5. Cache Versioning: Enhanced version management with metadata tracking

Integrations

  1. Temporal Workflows: Cache workflow state and results
  2. API Rate Limiting: Use Redis for rate limiting external APIs
  3. Session Management: Store user sessions in Redis
  4. Real-time Features: Use Redis for pub/sub messaging