Redis store
redisStore options, supported clients, adapters, key layout, and lifecycle.
import { redisStore } from 'valvejs/redis';
const store = redisStore({ client: redis });Works with Redis 6.2 or later and Valkey 7.2 or later. CI runs the integration suite against Redis 6.2, 7.4, and 8, and Valkey 7.2 and 8.
Options
Pass exactly one of client or adapter.
| Option | Type | Default | Description |
|---|---|---|---|
client | ioredis or node-redis client | None | A connected client. Cluster clients work too. |
adapter | RedisEvalAdapter | None | For any other client. See Bring your own client. |
prefix | string | 'valve' | Key prefix. Must not contain { or }. |
timeout | Duration | '2s' | Maximum time for one store call before it counts as a store error. |
subscriber | ioredis or node-redis client | None | A separate, connected client used for pub/sub wakeups. |
Invalid options throw ValveError with code INVALID_OPTIONS, including a
subscriber that is the same object as client.
Supported clients
| Client | Versions | Notes |
|---|---|---|
| ioredis | 5 | Redis and Cluster instances. |
| node-redis | 4 and 5 | Call connect() before passing the client or the subscriber. |
Clients are matched by shape, not imported. valve never opens, closes, or reconfigures them.
Timeouts and reconnection
timeout bounds how long valve waits for a store operation and must be at
least 1ms. It does not cancel or remove the underlying client command. A timeout has an unknown outcome:
Redis may already have executed the script, or the client may send it after
reconnecting. Successful reconnection does not make the earlier call successful.
Disable offline queuing on the command client to reject commands issued while disconnected. For ioredis, also disable automatic resending of unfulfilled commands and bound request retries:
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!, {
lazyConnect: true,
enableOfflineQueue: false,
autoResendUnfulfilledCommands: false,
maxRetriesPerRequest: 1,
retryStrategy: (attempt) => Math.min(attempt * 100, 1000),
});
redis.on('error', (error) => console.error('Redis connection error', error));
await redis.connect(); // Wait for readiness before passing the client to valve.
const store = redisStore({ client: redis, timeout: '2s' });For node-redis v4/v5:
import { createClient } from 'redis';
const redis = createClient({
url: process.env.REDIS_URL!,
disableOfflineQueue: true,
socket: {
reconnectStrategy: (attempt) => Math.min((attempt + 1) * 100, 1000),
},
});
redis.on('error', (error) => console.error('Redis connection error', error));
await redis.connect();
const store = redisStore({ client: redis, timeout: '2s' });These examples cap reconnect delays while allowing recovery. Choose an outage
retry budget for your application, and set valve's maxWait to bound how long
jobs wait under onStoreError: 'wait'. Client retry settings and valve's timeout
are separate controls. See the ioredis reconnect and offline queue documentation
and node-redis client configuration.
Disabling offline queues prevents replay of commands issued while disconnected; it does not cancel commands already sent to Redis, roll back scripts, or provide exactly-once execution. valve does not change your client's settings.
| Timed-out operation | Possible effect after reconnection |
|---|---|
acquire | Spends rate budget without starting the abandoned job. A global lease can occupy a slot until its TTL expires, measured from server execution. |
pause | Writes or extends a pause after the caller saw an error. |
resume | Removes a pause after the caller saw an error. |
renew | Extends an existing unexpired token. It cannot resurrect expired ownership or renew a replacement token. |
release | Removes only the supplied tokens. An expired old token cannot release its replacement. |
With wait and throw, late acquire responses do not run the abandoned callback;
Redis still enforces the rate rules. With open, a job can run before Redis
recovers, so shared rate bounds do not hold. Global concurrency rejects open.
Queued pause/resume commands preserve order in the tested single-client offline FIFO case. There is no ordering guarantee across clients, failovers, or retries. After a control-operation timeout, let pending commands settle or discard the original connection before reapplying the desired pause or resume through a healthy connection. Otherwise an older queued command can overwrite that correction later. The error alone does not establish which state Redis holds. See Guarantees.
Durability
Rate history, shared pauses, and global leases live in Redis. If those keys
disappear, the next acquire sees a fresh budget and returns no error. Processes
that have not already seen a pause stop honoring it. Jobs holding lost leases
reject with LEASE_LOST at their next renewal, and new jobs can take those
slots before then. Keys can disappear after:
FLUSHDBorFLUSHALL.- A restart with persistence disabled, or a crash inside the persistence loss window.
- Eviction under memory pressure. Every valve key has a TTL, so
volatile-*policies can evict it as well asallkeys-*policies. - Failover. Replication is asynchronous, so a promoted replica can miss admissions the old primary acknowledged.
To reduce exposure:
- Set
maxmemory-policy noevictionon the instance holding valve's keys. When memory runs out, writes then fail withOOM, and valve treats the failure as a store error handled byonStoreError. - Keep memory headroom, and alert on
used_memory,evicted_keys, and OOM errors. - Enable persistence
(AOF with
appendfsync everysecloses at most about one second of writes). - Monitor failovers and replication
lag.
WAITdoes not make failover lossless.
These measures make loss less likely. They cannot restore lost history. If exceeding an external quota is costly, treat the provider's own enforcement as the hard limit. See Store state loss and Redis eviction.
Bring your own client
Implement two methods. Errors whose message contains NOSCRIPT must be
rethrown unchanged, so valve can reload its scripts.
import type { RedisEvalAdapter } from 'valvejs/redis';
const adapter: RedisEvalAdapter = {
evalsha: (sha, keys, args) =>
client.evalsha(sha, keys.length, ...keys, ...args),
eval: (script, keys, args) =>
client.eval(script, keys.length, ...keys, ...args),
};
const store = redisStore({ adapter });How it talks to Redis
- Each operation is a single Lua script, so checks and updates are atomic.
- Scripts load lazily with
EVALSHA. After aNOSCRIPTerror, for example after a server restart, valve falls back toEVAL. - All time math uses the server's
TIMEcommand, so client clock skew does not matter. - Keys have the form
PREFIX:{ID}:.... The hash tag keeps one valve's keys in one cluster slot. - GCRA keys expire when their stored theoretical arrival time is reached; static fixed-window keys expire after twice their interval, and scoped-path fixed-window keys expire at the end of their window. Lease keys expire with their last lease.
PREFIX:{ID}:configpins a versioned fingerprint of normalized rate and global concurrency settings. It has no TTL and is checked in the admission script on every call, without an additional round trip. Reliable shared quotas and mismatch detection require a non-evicting deployment, such asmaxmemory-policy noeviction. Anallkeys-*policy can evict this key and allow a different configuration to be pinned; eviction of rate or lease keys can also reset active budget state.
When Redis reports an incompatible admission, it rejects with INVALID_OPTIONS
rather than a transient store error. Jobs reject under every onStoreError
policy, including 'open'. An outage or timeout can hide this response; 'open'
then runs jobs without shared admission checks.
Rule order, equivalent duration syntax, local settings, and lease owners do not
cause mismatches. See Configuration drift and quota migrations
before changing rules, resetting the config key, or upgrading older workers.
Scoped acquisition uses the same hash tag and configuration key. Unscoped rules
use PREFIX:{ID}:r:KEY; scoped rules use
PREFIX:{ID}:s:named:NAME:ENCODED_IDENTITY. Names and dimensions participate in
the fingerprint. Each scoped request uses one bounded Lua execution with sparse
grants. See Scoped rate limits for key
cardinality, identity handling, and Cluster hot-slot tradeoffs.
Lifecycle
await valve.close(); // 1. Stop the valves.
await store.close(); // 2. Remove the store's subscriptions.
await subscriber.quit(); // 3. Close your connections.
await redis.quit();store.close() unsubscribes its listeners. It never disconnects either client.