Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -520,3 +520,49 @@ It has following configuration options:
- `timeoutInMsecs?: number` - if set, Redis operations will automatically fail after specified execution threshold in milliseconds is exceeded. Next data source in the sequence will be used instead.
- `separator?: number` - What text should be used between different parts of the key prefix. Default is `':'`
- `ttlLeftBeforeRefreshInMsecs?: number` - if set within a Loader or GroupLoader, when remaining ttl is equal or lower to specified value, loading will be started in background, and all caches will be updated. It is recommended to set this value for heavy loaded system, to prevent requests from stalling while cache refresh is happening.

## Redis connection safety

### Automatic READONLY reconnection

When using `createNotificationPair` or `createGroupNotificationPair` with `RedisOptions` (rather than pre-instantiated Redis clients), the library automatically enriches the Redis configuration with a `reconnectOnError` handler that triggers reconnection when a `READONLY` error is detected. This addresses a common issue during **blue-green deployments** or managed Redis failovers, where the current master is demoted to a replica and starts rejecting write commands with `READONLY` errors.

If you provide your own `reconnectOnError` in the `RedisOptions`, it will be preserved and the default handler will not be applied.

### Using `enrichRedisConfig` for your own connections

When creating Redis instances manually (e.g. for `RedisCache`), you can use the exported `enrichRedisConfig` utility to apply the same safety logic:

```ts
import Redis from 'ioredis'
import { enrichRedisConfig, RedisCache } from 'layered-loader'

const redisOptions = {
host: 'localhost',
port: 6379,
password: 'sOmE_sEcUrE_pAsS',
}

const redis = new Redis(enrichRedisConfig(redisOptions))

const cache = new RedisCache<string>(redis, {
json: true,
ttlInMsecs: 1000 * 60 * 10,
})
```

### Cloud-optimized configuration

For managed Redis cluster services (AWS ElastiCache, GCP Memorystore, etc.), use `enrichRedisConfigOptimizedForCloud` instead. It accepts `ClusterOptions` and, in addition to the `READONLY` reconnection handler (set via `redisOptions`), forces IPv4 DNS resolution so that after a failover the DNS record resolves to the new master instead of using a cached or stale address:

```ts
import Redis from 'ioredis'
import { enrichRedisConfigOptimizedForCloud } from 'layered-loader'

const cluster = new Redis.Cluster(
[{ host: 'my-cluster.cache.amazonaws.com', port: 6379 }],
enrichRedisConfigOptimizedForCloud({})
)
```

Both `enrichRedisConfig` and `enrichRedisConfigOptimizedForCloud` preserve any user-provided `reconnectOnError` or `dnsLookup` handlers.
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export { RedisGroupCache } from './lib/redis/RedisGroupCache'
export { AbstractNotificationConsumer } from './lib/notifications/AbstractNotificationConsumer'
export { createNotificationPair } from './lib/redis/RedisNotificationFactory'
export { createGroupNotificationPair } from './lib/redis/RedisGroupNotificationFactory'
export { enrichRedisConfig, enrichRedisConfigOptimizedForCloud } from './lib/redis/enrichRedisConfig'
export { RedisNotificationConsumer } from './lib/redis/RedisNotificationConsumer'
export { RedisNotificationPublisher } from './lib/redis/RedisNotificationPublisher'
export { RedisGroupNotificationConsumer } from './lib/redis/RedisGroupNotificationConsumer'
Expand Down
7 changes: 4 additions & 3 deletions lib/redis/RedisGroupNotificationFactory.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { randomUUID } from 'node:crypto'
import {Redis} from "ioredis";
import { RedisGroupNotificationConsumer } from './RedisGroupNotificationConsumer'
import { RedisGroupNotificationPublisher } from './RedisGroupNotificationPublisher'
import { enrichRedisConfig } from './enrichRedisConfig'
import {isClient, RedisNotificationConfig} from './RedisNotificationFactory'
import {Redis} from "ioredis";

export function createGroupNotificationPair<T>(config: RedisNotificationConfig) {
const resolvedConsumer = isClient(config.consumerRedis) ? config.consumerRedis : new Redis(config.consumerRedis)
const resolvedPublisher = isClient(config.publisherRedis) ? config.publisherRedis : new Redis(config.publisherRedis)
const resolvedConsumer = isClient(config.consumerRedis) ? config.consumerRedis : new Redis(enrichRedisConfig(config.consumerRedis))
const resolvedPublisher = isClient(config.publisherRedis) ? config.publisherRedis : new Redis(enrichRedisConfig(config.publisherRedis))

const serverUuid = randomUUID()
if (resolvedPublisher === resolvedConsumer) {
Expand Down
5 changes: 3 additions & 2 deletions lib/redis/RedisNotificationFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {Redis, RedisOptions} from 'ioredis'
import type { PublisherErrorHandler } from '../notifications/NotificationPublisher'
import { RedisNotificationConsumer } from './RedisNotificationConsumer'
import { RedisNotificationPublisher } from './RedisNotificationPublisher'
import { enrichRedisConfig } from './enrichRedisConfig'

export type RedisNotificationConfig = {
channel: string
Expand All @@ -16,8 +17,8 @@ export function isClient(maybeClient: unknown): maybeClient is Redis {
}

export function createNotificationPair<T>(config: RedisNotificationConfig) {
const resolvedConsumer = isClient(config.consumerRedis) ? config.consumerRedis : new Redis(config.consumerRedis)
const resolvedPublisher = isClient(config.publisherRedis) ? config.publisherRedis : new Redis(config.publisherRedis)
const resolvedConsumer = isClient(config.consumerRedis) ? config.consumerRedis : new Redis(enrichRedisConfig(config.consumerRedis))
const resolvedPublisher = isClient(config.publisherRedis) ? config.publisherRedis : new Redis(enrichRedisConfig(config.publisherRedis))

const serverUuid = randomUUID()
if (resolvedConsumer === resolvedPublisher) {
Expand Down
95 changes: 95 additions & 0 deletions lib/redis/enrichRedisConfig.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import type { ClusterOptions, RedisOptions } from 'ioredis'
import { enrichRedisConfig, enrichRedisConfigOptimizedForCloud } from './enrichRedisConfig'

describe('enrichRedisConfig', () => {
it('adds default reconnectOnError handler', () => {
const config: RedisOptions = { host: 'localhost', port: 6379 }
const result = enrichRedisConfig(config)

expect(result.reconnectOnError).toBeDefined()
expect(result.host).toBe('localhost')
expect(result.port).toBe(6379)
})

it('default reconnectOnError returns true for READONLY errors', () => {
const result = enrichRedisConfig({})
expect(result.reconnectOnError!(new Error('READONLY You can\'t write against a read only replica.'))).toBe(true)
})

it('default reconnectOnError returns false for other errors', () => {
const result = enrichRedisConfig({})
expect(result.reconnectOnError!(new Error('Connection refused'))).toBe(false)
})

it('preserves user-provided reconnectOnError', () => {
const customHandler = (_err: Error) => false
const result = enrichRedisConfig({ reconnectOnError: customHandler })
expect(result.reconnectOnError).toBe(customHandler)
})

it('preserves other config options', () => {
const config: RedisOptions = { host: 'redis.example.com', port: 6380, password: 'secret', db: 2 }
const result = enrichRedisConfig(config)

expect(result.host).toBe('redis.example.com')
expect(result.port).toBe(6380)
expect(result.password).toBe('secret')
expect(result.db).toBe(2)
})
})

describe('enrichRedisConfigOptimizedForCloud', () => {
it('adds default dnsLookup and reconnectOnError', () => {
const config: ClusterOptions = {}
const result = enrichRedisConfigOptimizedForCloud(config)

expect(result.dnsLookup).toBeDefined()
expect(result.redisOptions?.reconnectOnError).toBeDefined()
})

it('default dnsLookup resolves using IPv4', () => {
const result = enrichRedisConfigOptimizedForCloud({})
expect(typeof result.dnsLookup).toBe('function')

// Invoke to cover the function body; we don't assert the DNS result
result.dnsLookup!('localhost', () => {})
})

it('default reconnectOnError in redisOptions returns true for READONLY errors', () => {
const result = enrichRedisConfigOptimizedForCloud({})
const handler = result.redisOptions?.reconnectOnError as (err: Error) => boolean
expect(handler(new Error('READONLY'))).toBe(true)
})

it('default reconnectOnError in redisOptions returns false for other errors', () => {
const result = enrichRedisConfigOptimizedForCloud({})
const handler = result.redisOptions?.reconnectOnError as (err: Error) => boolean
expect(handler(new Error('Connection refused'))).toBe(false)
})

it('preserves user-provided dnsLookup', () => {
const customLookup: ClusterOptions['dnsLookup'] = (_hostname, _callback) => {}
const result = enrichRedisConfigOptimizedForCloud({ dnsLookup: customLookup })
expect(result.dnsLookup).toBe(customLookup)
})

it('preserves user-provided reconnectOnError in redisOptions', () => {
const customHandler = (_err: Error) => false
const result = enrichRedisConfigOptimizedForCloud({
redisOptions: { reconnectOnError: customHandler },
})
expect(result.redisOptions?.reconnectOnError).toBe(customHandler)
})

it('preserves other cluster and redis options', () => {
const config: ClusterOptions = {
maxRedirections: 5,
redisOptions: { password: 'secret' },
}
const result = enrichRedisConfigOptimizedForCloud(config)

expect(result.maxRedirections).toBe(5)
expect(result.redisOptions?.password).toBe('secret')
})
})
25 changes: 25 additions & 0 deletions lib/redis/enrichRedisConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { lookup } from 'node:dns'
import type { ClusterOptions, RedisOptions } from 'ioredis'

const defaultReconnectOnError = (err: Error): boolean => {
if (err.message.includes('READONLY')) return true
return false
}

const cloudDnsLookup: ClusterOptions['dnsLookup'] = (hostname, callback) => {
lookup(hostname, { family: 4 }, callback)
}

export const enrichRedisConfig = (config: RedisOptions): RedisOptions => ({
...config,
reconnectOnError: config.reconnectOnError ?? defaultReconnectOnError,
})

export const enrichRedisConfigOptimizedForCloud = (config: ClusterOptions): ClusterOptions => ({
...config,
redisOptions: {
...config.redisOptions,
reconnectOnError: config.redisOptions?.reconnectOnError ?? defaultReconnectOnError,
},
dnsLookup: config.dnsLookup ?? cloudDnsLookup,
})
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,16 @@
],
"homepage": "https://github.com/kibertoad/layered-loader",
"dependencies": {
"ioredis": "^5.8.2",
"ioredis": "^5.10.0",
"toad-cache": "^3.7.0"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/node": "^22.15.29",
"@vitest/coverage-v8": "^4.0.15",
"@types/node": "^22.19.15",
"@vitest/coverage-v8": "^4.1.0",
"del-cli": "^7.0.0",
"rfdc": "^1.4.1",
"vitest": "^4.0.15",
"vitest": "^4.1.0",
"typescript": "^5.9.3"
},
"files": ["README.md", "LICENSE", "dist/*"]
Expand Down
Loading