Skip to content

Webapp env vars *_REDIS_READER_HOST / *_REDIS_READER_PORT are declared but never consumed #3643

@brentshulman-silkline

Description

@brentshulman-silkline

Summary

The webapp's env schema in apps/webapp/app/env.server.ts declares per-subsystem Redis reader env vars (CACHE_REDIS_READER_HOST, RATE_LIMIT_REDIS_READER_HOST, REALTIME_STREAMS_REDIS_READER_HOST, PUBSUB_REDIS_READER_HOST, ALERT_RATE_LIMITER_REDIS_READER_HOST, TASK_META_CACHE_REDIS_READER_HOST), each of which falls back to the global REDIS_READER_HOST / REDIS_READER_PORT. The self-hosting docs at docs/self-hosting/env/webapp.mdx document REDIS_READER_HOST as the way to use a Redis read replica.

However, no subsystem actually reads env.*_REDIS_READER_HOST — every consumer instantiates a single ioredis client off the primary *_REDIS_HOST/*_REDIS_PORT. Setting REDIS_READER_HOST on a self-hosted webapp is a complete no-op today.

Evidence

Repo-wide grep for any consumer of the reader-host names (excluding the schema declaration file):

$ grep -rn 'CACHE_REDIS_READER_HOST\|RATE_LIMIT_REDIS_READER_HOST\|REALTIME_STREAMS_REDIS_READER_HOST\|PUBSUB_REDIS_READER_HOST\|ALERT_RATE_LIMITER_REDIS_READER_HOST\|TASK_META_CACHE_REDIS_READER_HOST\|readerHost\|reader_host' apps/webapp internal-packages
apps/webapp/app/env.server.ts: …  # declarations only

Only one file matches — env.server.ts itself.

Example consumer (apps/webapp/app/services/taskIdentifierCache.server.ts:42-60):

function initializeRedis(): Redis | undefined {
  const host = env.CACHE_REDIS_HOST;
  if (!host) return undefined;
  return new Redis({
    connectionName: "taskIdentifierCache",
    host,
    port: env.CACHE_REDIS_PORT,
    username: env.CACHE_REDIS_USERNAME,
    password: env.CACHE_REDIS_PASSWORD,
    // … no CACHE_REDIS_READER_HOST anywhere
  });
}

The same single-client pattern is used in every other *_REDIS_HOST consumer: apiRateLimit.server.ts, realtimeClientGlobal.server.ts, services/realtime/v1StreamsGlobal.server.ts, tracePubSub.server.ts, projectPubSub.server.ts, magicLinkRateLimiter.server.ts, etc.

Compare with DATABASE_READ_REPLICA_URL, which is properly wired in apps/webapp/app/db.server.ts:249 — it constructs a second PrismaClient exposed as $replica. Prisma read splits work as documented; Redis ones don't.

Impact

Self-hosters who provision an ElastiCache reader endpoint (or any Redis replica) and set REDIS_READER_HOST per the docs see no traffic on the replica. We confirmed this on our production deploy: after binding REDIS_READER_HOST for ~24 h on 8 webapp pods, the replica node held a flat ~6 connections (cluster-internal replication only) and engine CPU was unchanged vs the primary. The Aurora half of the same change — DATABASE_READ_REPLICA_URL — worked as advertised and absorbed ~44 sustained connections plus ~3% CPU.

Suggested fix

Two reasonable shapes:

  1. Per-subsystem twin client. Wherever env.<NAME>_REDIS_HOST is consumed today, also accept env.<NAME>_REDIS_READER_HOST/PORT, and when present, construct a second ioredis client. Provide a small splitRedis({ writer, reader }) helper that routes read commands (GET/HGET/SMEMBERS/HGETALL/ZRANGE/EXISTS/etc.) to the reader and writes to the writer.

  2. Drop the orphan declarations. If a Redis read split isn't on the roadmap, remove the *_REDIS_READER_HOST/*_REDIS_READER_PORT entries from env.server.ts and the REDIS_READER_HOST row from docs/self-hosting/env/webapp.mdx so users don't waste deploy cycles wiring them up.

Happy to put up a PR for (1) if you'd like — would be useful to know if there's an intended design (e.g. is the eventual goal cluster-mode + read-from-replicas, or twin-client?).

Versions

Reproduced against main (current HEAD); also present in the v4.4.x release we run in prod.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions