Share a concurrency limit
Cap running jobs across every worker with renewable leases, and wake queues through Redis pub/sub.
Some providers limit concurrent requests, not just request rate. A numeric
concurrency only limits one process. The object form with
scope: 'global' limits every process that shares the valve id.
Before you begin
Set up a shared Redis store as described in Share a quota across workers.
Configure a global limit
import { Redis } from 'ioredis';
import { createValve } from 'valvejs';
import { redisStore } from 'valvejs/redis';
const client = new Redis(process.env.REDIS_URL!);
const subscriber = client.duplicate();
const store = redisStore({ client, subscriber });
const reports = createValve({
id: 'reports-api:key-7',
rate: { limit: 20, interval: '1s' },
concurrency: { limit: 8, scope: 'global', leaseTtl: '30s' },
store,
});
await reports.run(({ signal }) =>
fetch('https://reports.example.com/v1/export', { signal }),
);Use the same limit and leaseTtl in every worker that shares the id.
Redis and a shared memoryStore() reject mismatched rate rules or global
concurrency settings with INVALID_OPTIONS. To change them, follow
Changing a shared quota.
The memory store forgets an id's settings once its rate and lease state expire.
How leases work
- A job acquires a lease in the same atomic step that spends its rate budget.
- While the job runs, valve renews the lease every
leaseTtl / 3. - When the job settles, valve releases the lease.
- If a worker crashes, its leases expire after
leaseTtl.
leaseTtl defaults to '30s', with a minimum of 30ms.
Honor the abort signal
If renewal fails or a lease expires, valve aborts the job's signal. After the
callback settles, the job rejects with LEASE_LOST. Pass the signal to your
HTTP client so the request stops when ownership is lost. wrapFetch does this
for you.
Leases bound ownership, not side effects
An event loop stall, a network partition, or a callback that ignores the signal can let work continue after its lease expired and was reassigned. If you need a strict bound on external effects, use fencing tokens in the external system.
Global concurrency rejects onStoreError: 'open', because running without
the store would bypass lease ownership.
Wake queues with a subscriber
Without a subscriber, a process notices shared changes on its next store call, and waiters for a global slot poll at most every 50ms. With a subscriber, pause, resume, and slot release publish a notification. Waiting valves recheck the store after a short random delay, so one release does not send every waiter to the store at once. A valve that keeps losing slots to others waits up to 50ms before it rechecks. Waits caused by rate rules follow the store's retry hint in both cases.
- The subscriber must be a separate connection. Passing the command client is rejected.
- With node-redis, call
connect()on the duplicate before passing it in. - Notifications are hints. Redis remains the source of truth. Global-slot waiters still recheck at least once a second, so a missed message delays a wake by about a second at most.
// node-redis
const subscriber = await client.duplicate().connect();
const store = redisStore({ client, subscriber });Shut down in order
valve.close() detaches the valve's listener. store.close() removes the
store's subscriptions. Neither closes the connections you supplied.
await reports.close({ drain: true });
await store.close();
await subscriber.quit();
await client.quit();A shared memoryStore() instance supports global concurrency and
notifications too, for valves in the same process.