Documentation/titan/titan-redis
In this section

titan-redis

official · @omnitron-dev/titan-redis · stable

Redis connection management with clustering, Sentinel, TLS, multiple named instances, pluggable Lua scripts, and the underlying foundation for titan-cache (L2), titan-lock, titan-discovery, titan-ratelimit, and titan-notifications.

pnpm add @omnitron-dev/titan-redis

Quickstart

Single client

import { RedisModule } from '@omnitron-dev/titan-redis';

@Module({
  imports: [
    RedisModule.forRoot({
      config: { host: 'localhost', port: 6379, db: 0 },
    }),
  ],
})
class AppModule {}

Multiple named clients

RedisModule.forRoot({
  clients: [
    { namespace: 'main',   host: 'redis-main',   port: 6379, db: 0 },
    { namespace: 'cache',  host: 'redis-cache',  port: 6379, db: 1 },
    { namespace: 'queue',  host: 'redis-queue',  port: 6379, db: 2 },
  ],
  commonOptions: { maxRetriesPerRequest: 3 },
})

Cluster

RedisModule.forRoot({
  config: {
    cluster: {
      nodes: [
        { host: 'redis-1', port: 6379 },
        { host: 'redis-2', port: 6379 },
        { host: 'redis-3', port: 6379 },
      ],
      maxRedirections: 16,
      retryDelayOnClusterDown: 100,
      scaleReads: 'replica',
    },
  },
})

Sentinel

RedisModule.forRoot({
  config: {
    sentinels:    [{ host: 'sentinel-1', port: 26379 }, { host: 'sentinel-2', port: 26379 }],
    sentinelName: 'mymaster',
    password:     env.REDIS_PASSWORD,
  },
})

TLS

RedisModule.forRoot({
  config: {
    host: 'redis-tls.internal',
    port: 6380,
    tls:  { rejectUnauthorized: true, ca: [fs.readFileSync('./ca.pem', 'utf8')] },
  },
})

Async configuration

RedisModule.forRootAsync({
  useFactory: (config: ConfigService) => ({
    config: {
      host: config.get('redis.host'),
      port: config.get('redis.port'),
    },
  }),
  inject: [ConfigService],
})

RedisModuleOptions

OptionType
configIRedisClientOptions — single-client config
clientsIRedisClientOptions[] — multiple named clients
commonOptionsPartial<IRedisClientOptions> — applied to every client
closeClientboolean — close during module shutdown (default: true)
shutdownTimeoutnumber — QUIT acknowledgement budget in milliseconds (default: 5000; 0 disconnects immediately)
readyLogboolean — log when each client emits 'ready'
errorLogboolean — log on connection errors
loggerILogger — override default logger
onError(error, client) => void — global error callback
onClientCreated(client) => void
onClientDestroyed(namespace) => void
healthCheck{ enabled?, timeout?, interval? }
scriptsArray<{ name, path?, content? }> — Lua scripts to preload
pool{ min?, max?, acquireTimeoutMillis?, idleTimeoutMillis? }
isGlobalboolean

IRedisClientOptions

OptionTypeDefault
namespacestring'default'
hoststring'localhost'
portnumber6379
dbnumber — DB index0
pathstring — Unix socket path (alt. to host/port)—
passwordstring—
usernamestring — Redis 6+ ACL username—
namestring — Connection name visible in CLIENT LIST—
lazyConnectboolean — defer connect to first command—
enableReadyCheckboolean — wait for 'ready' before returningtrue
enableOfflineQueueboolean — buffer commands during disconnecttrue
connectTimeoutnumber (ms)10_000
commandTimeoutnumber (ms)—
keepAlivenumber (ms)—
maxRetriesPerRequestnumber20
retryStrategy(times) => number | nullexponential
clusterIRedisClusterOptions—
sentinelsArray<{ host, port }>—
sentinelNamestring—
tlsIRedisTlsOptions—

RedisService — the high-level API

The injected default service for one-line ops without managing clients.

import { RedisService, REDIS_MANAGER } from '@omnitron-dev/titan-redis';

@Service({ name: 'cart' })
class CartService {
  constructor(private readonly redis: RedisService) {}

  @Public()
  async add(userId: string, item: Item) {
    await this.redis.hset(`cart:${userId}`, item.id, JSON.stringify(item));
  }
}

Connection helpers

MethodPurpose
getClient(namespace?)Get client by namespace
getOrThrow(namespace?)Get client or throw NotFoundError
getOrNil(namespace?)Get client or return null
ping(namespace?)Health check; returns boolean
isReady(namespace?)Synchronous ready check
pipeline(namespace?) / multi(namespace?)Batch pipeline / transaction
createSubscriber(namespace?)Dup client for pub/sub
publish(channel, message, namespace?)Publish to channel

Strings, counters, expiry

await this.redis.set('user:42:tier', 'premium', 3600);   // 3600s TTL
await this.redis.setnx('lock:job', 'taken');             // 1 if set, 0 if exists
await this.redis.incr('orders:today');
await this.redis.expire('cart:42', 600);
const ttl = await this.redis.ttl('cart:42');             // -1 no expiry, -2 not exists

Hashes / sets / lists / sorted sets / streams

RedisService proxies every common ioredis method directly: hget, hset, hgetall, hdel, hexists, hincrby, hlen, hkeys, hvals, hmset, hmget; sadd, srem, smembers, sismember; lpush, rpush, lpop, rpop, lrange, llen, ltrim; zadd, zrem, zrange, zrevrange, zcard, zscore, zincrby, zrangebyscore; xadd, xgroup, xreadgroup, xack, xlen, xrange, xread, xtrim. Each accepts an optional trailing namespace? argument to target a non-default client.

Lua scripts

const sha = await this.redis.loadScript(
  'limit',
  'return redis.call("incr", KEYS[1]) <= tonumber(ARGV[1]) and 1 or 0',
);
const allowed = await this.redis.runScript<number>('limit', ['rate:42'], [100]);

Scripts declared in RedisModule.forRoot({ scripts: [...] }) are preloaded at startup; runScript() re-loads automatically if Redis evicts the SHA (NOSCRIPT error).

RedisManager — low-level API

Injected via @Inject(REDIS_MANAGER) for cases that need the raw ioredis client (advanced commands not in RedisService's typed surface, custom connection management, dynamic client creation).

@Service({ name: 'analytics' })
class AnalyticsService {
  constructor(@Inject(REDIS_MANAGER) private readonly redis: RedisManager) {}

  @Public()
  async raw() {
    const client = this.redis.getClient('analytics');
    return client.call('OBJECT', 'ENCODING', 'user:42');  // arbitrary command
  }
}
MethodPurpose
getClient(namespace?)Public IRedisClient
getInternalClient(namespace?)Internal — full ioredis surface
getClients()All registered clients
hasClient(namespace?)Existence check
createClient(options)Dynamically create a new named client
destroyClient(namespace)Tear down a client
isHealthy(namespace?)Health check
healthCheck()All clients' health + latency
runScript<T>(scriptName, keys, args, namespace?)Execute named Lua script

Decorators

@InjectRedis(namespace?) — parameter

@Service({ name: 'cart' })
class CartService {
  constructor(@InjectRedis() private readonly redis: IRedisClient) {}
}

@Service({ name: 'sessions' })
class SessionsService {
  constructor(@InjectRedis('sessions') private readonly redis: IRedisClient) {}
}

@InjectRedisManager() — parameter

Inject the RedisManager directly.

@RedisCache(options?) — method

@Public()
@RedisCache({ ttl: 60, namespace: 'cache', key: (id) => `user:${id}` })
async findById(id: string) { /* … */ }

Options: ttl (seconds), namespace, key (string or function), keyFn (alias), condition (skip if false), refresh (always re-compute, bypass read).

@RedisLock(options?) — method

Lightweight inline lock for simple cases. For full distributed lock semantics use titan-lock.

@Public()
@RedisLock({ ttl: 10, key: (id) => `invoice:${id}`, retries: 5 })
async charge(id: string) { /* … */ }

@RedisRateLimit(options) — method

@Public()
@RedisRateLimit({ points: 100, duration: 60_000, keyPrefix: 'rl' })
async search(q: string) { /* … */ }

For tier-based / sliding-window strategies use titan-ratelimit.

Tokens

TokenPurpose
REDIS_MANAGERThe RedisManager instance
REDIS_MODULE_OPTIONSResolved options bundle
REDIS_DEFAULT_NAMESPACEDefault namespace constant ('default')
getRedisClientToken(namespace?)Token for a named client

Lifecycle

The module's provider factory calls RedisManager.init() before consumers receive the manager. It initializes non-lazy clients and preloads declared Lua scripts. TitanRedisModule.onStop() calls destroy() after dependent modules have stopped. Applications using the module do not need a second shutdown hook. The manager also exposes onModuleInit() and onModuleDestroy() for hosts that explicitly invoke those compatibility methods.

Shutdown sends QUIT only to a ready client and waits up to shutdownTimeout for its acknowledgement. A client that is lazy, connecting or reconnecting is disconnected immediately, so stopping an application cannot start a new Redis connection. The deadline applies independently to clients closed in parallel. A timeout or failed acknowledgement is logged with its namespace, then the socket and reconnect loop are closed. This bounds shutdown; it does not prove that an unacknowledged command reached the server.

destroyClient(namespace) uses the same close path and removes the registration even when closing fails; the caller receives the failure. destroy() attempts to close every owned client and logs individual failures. With closeClient: false, the caller retains responsibility for connections and must close them explicitly. Set shutdownTimeout: 0 only when immediate disconnect is intended; otherwise use a finite non-negative integer up to 2147483647.

DB-index isolation pattern

When you co-locate multiple subsystems on the same Redis instance, isolate them by DB index per named client instead of sharing one index. A typical layout:

DBExample use
0Default application data
1Cache (L2 for titan-cache)
2Queue / messaging
3Sessions
4Rate-limit buckets

Use named clients with an explicit db per namespace rather than swapping SELECT at runtime — runtime SELECT is per-connection and hard to reason about under connection-pool churn.

Anti-patterns

  • Cross-slot ops in cluster multi(). Cluster mode forbids multi-slot transactions. Pin keys to one slot via {hashtag} or use Lua scripts that touch one key.

  • One client for everything. Pub/sub blocks command pipelines on the subscribing client. Use createSubscriber() for pub/sub and keep the default client for commands.

  • Forgetting password in Sentinel/cluster. Sentinels typically require their own password in addition to the data-plane password.

  • Hot-loop without pipeline. N round-trips for N small writes costs N×RTT. Use pipeline() or multi() for batches.

  • Using @RedisLock for critical multi-step work. It's a convenience wrapper. For Lua-safe ownership and TTL extension, use titan-lock.

See also