valve_

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.

OptionTypeDefaultDescription
clientioredis or node-redis clientNoneA connected client. Cluster clients work too.
adapterRedisEvalAdapterNoneFor any other client. See Bring your own client.
prefixstring'valve'Key prefix. Must not contain { or }.
timeoutDuration'2s'Maximum time for one store call before it counts as a store error.
subscriberioredis or node-redis clientNoneA 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

ClientVersionsNotes
ioredis5Redis and Cluster instances.
node-redis4 and 5Call 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 operationPossible effect after reconnection
acquireSpends rate budget without starting the abandoned job. A global lease can occupy a slot until its TTL expires, measured from server execution.
pauseWrites or extends a pause after the caller saw an error.
resumeRemoves a pause after the caller saw an error.
renewExtends an existing unexpired token. It cannot resurrect expired ownership or renew a replacement token.
releaseRemoves 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:

  • FLUSHDB or FLUSHALL.
  • 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 as allkeys-* policies.
  • Failover. Replication is asynchronous, so a promoted replica can miss admissions the old primary acknowledged.

To reduce exposure:

  • Set maxmemory-policy noeviction on the instance holding valve's keys. When memory runs out, writes then fail with OOM, and valve treats the failure as a store error handled by onStoreError.
  • Keep memory headroom, and alert on used_memory, evicted_keys, and OOM errors.
  • Enable persistence (AOF with appendfsync everysec loses at most about one second of writes).
  • Monitor failovers and replication lag. WAIT does 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 a NOSCRIPT error, for example after a server restart, valve falls back to EVAL.
  • All time math uses the server's TIME command, 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}:config pins 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 as maxmemory-policy noeviction. An allkeys-* 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.

On this page