valve_

Guarantees

The exact bounds valve provides, and the cases where they do not apply.

All bounds describe the total cost of jobs started, measured on the store's clock at atomic admission. They do not describe when jobs finish, when requests reach the provider, or how the provider counts.

Rate bounds

RuleBound
GCRA, default burst: 1At most limit units per interval, spaced interval / limit apart.
GCRA with burstAt most limit + burst - 1 units in any half-open window of length interval.
Fixed windowAt most limit units in each aligned [k × interval, (k + 1) × interval) window.
Several rulesEach rule holds independently. The store checks and spends them atomically.

Fixed windows can admit up to 2 × limit units near an adjacent-window boundary.

Timing

Timers can fire late. Under GCRA with burst: 1, the next start follows the actual previous start, so small delays add up over a saturated queue. valve never catches up by starting jobs closer together than the rule allows.

With local concurrency, rate waits add no random jitter. For asynchronous stores, the scheduler subtracts elapsed acquisition time from the store's suggested delay. Every wakeup still asks the store, and another worker may have spent the budget first.

A start is a grant from the store. A delayed callback or network write can happen later, so request arrival times at the provider can bunch together.

Shared pauses

  • After a pause is written, the store grants no jobs until it expires.
  • A grant issued before the pause can still start.
  • A pause only extends an existing pause. resume() deletes it.

Each process caches a known pause to avoid extra store calls. Without a subscriber, another process's resume() is noticed on the next store call, which may be at the end of the cached pause. With a Redis subscriber, pause, resume, and slot release wake waiting processes immediately. Notifications are hints only; admission is always checked atomically.

observe() writes a pause in the background and returns immediately. A failed write emits store-error and is not retried. Use await valve.pause(...) when you need confirmation that the store recorded the pause.

Grants and cancellation

A job cancelled while its acquire is in flight does not run, but its grant is spent and not reused. A timed-out Redis call can also spend budget after valve has given up on it. Both cases reduce throughput. Neither raises the rate under onStoreError: 'wait' or 'throw'.

A Redis timeout does not cancel the client command. An offline queue can replay it after reconnection, creating an abandoned lease or applying a late pause, resume, renewal, or release. Lease TTLs start at server execution; renewal never resurrects expired ownership, and release only removes the supplied tokens. Disable client offline queues to reject commands issued while disconnected. Commands already sent still have an unknown outcome, with no exactly-once execution guarantee. See Timeouts and reconnection for client settings and operation effects.

Ordering

Inside one valve, higher priorities start first and equal priorities keep call order. Fetch retries keep their original position. A batch already sent to the store can receive grants before a newly arrived higher-priority job.

Across processes, there is no ordering or fairness guarantee.

Concurrency

Concurrency counts jobs, not cost.

  • A number, or { limit, scope: 'local' }, limits one valve instance.
  • { limit, scope: 'global', leaseTtl } limits every valve sharing the id, using leases acquired atomically with the rate rules.

Leases renew every leaseTtl / 3 while a job runs and release when it settles. A crashed worker's leases expire after the TTL. If renewal fails or a lease expires, the job's signal aborts when the event loop can process the loss. Its promise rejects with LEASE_LOST when the callback settles. Once lease loss is detected, a late renewal response does not restore the job's ownership, even if Redis applied the renewal.

Leases bound ownership, not external side effects. A process paused for longer than its TTL can resume after its slot was reassigned and perform synchronous effects before abort handling runs. Jobs must honor the abort signal, but a rejection cannot undo an effect. If you need a strict bound during process stalls or network partitions, validate fencing tokens in the external system. Valve does not guarantee exactly-once side effects.

Without a subscriber, global-slot waiters poll at most every 50ms plus jitter. With one, they recheck at least once a second. Waits caused by rate rules follow the store's retry hint.

Store failures

onStoreErrorBehavior while the store is down
'wait' (default)Jobs stay queued. valve retries with backoff until each job's maxWait.
'throw'Affected jobs reject with STORE_UNAVAILABLE.
'open'Jobs start without the shared rate rules. Rate bounds do not hold. Rejected with global concurrency.

Store state loss

Rate bounds hold only while the store keeps its state. If Redis loses valve's keys (a flush, a restart without persistence, eviction, or failover to a replica that missed recent writes), the rate budget starts fresh and the shared pause ends. The provider's window has not reset, so valve can admit more than its limit before that window ends.

valve cannot detect lost rate history. A Redis that has lost keys answers normally, so no onStoreError mode applies. Lost global leases are detected at the next renewal, which rejects their jobs with LEASE_LOST. Until then, new jobs can take the freed slots. See Durability for settings that reduce exposure. When exceeding an external quota is costly, rely on the provider's own enforcement as the hard limit.

Process failures

Queued jobs live in memory and are lost when the process exits. Use a durable job queue when work must survive a restart, and call valve from its workers.

On this page