Skip to main content

titan-redis

Official@omnitron-dev/titan-redis

Maintained by the Omnitron team. Independent npm package.

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 on onDestroy (default: true)
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

RedisManager implements:

  • async onModuleInit() — connect every non-lazy client; preload Lua scripts.
  • async onModuleDestroy() — close every client gracefully (skipped if closeClient: false).

The framework wires these into the Titan application lifecycle — clients connect before your services' onInit and close after every service's onStop.

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