valve_

Custom stores

The Store interface, for backends other than memory and Redis.

A store keeps rule state, the shared pause, and global leases. valve ships a memory store and a Redis store. Implement Store to add another backend.

import type { Store } from 'valvejs';

Interface

MemberRequiredDescription
kindYesShort name used in messages, such as 'memory' or 'redis'.
acquire(request)YesAtomically checks the pause and every rule, then grants a prefix of costs.
acquireSync?(request)NoSynchronous version. Used on the hot path when present.
pause(id, durationMs)YesExtends the pause to at least durationMs from now. Never shortens it.
resume(id)YesRemoves the pause.
release?(id, leases)GlobalReleases global concurrency slots when jobs settle.
renew?(id, leases, ttlMs)GlobalRenews unexpired slots. Returns false if any was lost. Never revives expired ones.
subscribe?(id, listener)NoNotifies of pause, resume, and release. Returns an unsubscribe function.
close()YesFrees resources owned by the store. Never closes user-provided clients.

release and renew are required for concurrency: { scope: 'global' }.

acquire

interface AcquireRequest {
  readonly id: string;
  readonly rules: readonly NormalizedRule[]; // Empty for a concurrency-only valve.
  readonly costs: readonly number[]; // Head of the queue, in order. Never empty.
  readonly lease?: {
    readonly limit: number;
    readonly ttlMs: number;
    readonly owner: string;
  };
}

interface AcquireResult {
  readonly granted: number; // Jobs granted, counted from the start of costs.
  readonly retryAfterMs: number; // If not all were granted and not paused: ms until the next could be.
  readonly pausedMs: number; // Ms left on an active pause. When above 0, granted is 0.
  readonly leases?: readonly string[]; // One unique token per grant when lease was requested.
  readonly leaseBlocked?: boolean; // With a lease: true if full slots, not a rate rule, denied the first ungranted job.
}

Each NormalizedRule carries algorithm, limit, intervalMs, burst, and a stable key: ${algorithm}:${intervalMs} for unnamed rules or named:${name} for named rules. Named and scoped rules also carry name and scope when configured.

Set leaseBlocked on results for lease requests. Valves poll lease waits at a short interval but wait out rate denials for the full retryAfterMs. A store that omits it is treated as lease-blocked.

Requirements

  • Atomicity. Checking the pause, checking every rule, spending cost, and acquiring leases must happen as one indivisible step across all processes.
  • One clock. All time math must use the store's own clock, never the caller's.
  • Prefix grants. Grant jobs strictly in order. Stop at the first job that does not fit.
  • No partial spends. If a job does not fit one rule, spend nothing for it in the others.
  • Pinned policy. Reject a request with INVALID_OPTIONS when its rules, lease limit, or lease TTL differ from the policy other workers use for the id.
  • Finite retry hints. When more jobs ask for leases than slots are free, return a finite retryAfterMs, such as the lease TTL when no slot is held.

The memory store's source, packages/valve/src/memory-store.ts, is a compact reference implementation. The Redis Lua scripts in packages/valve/src/redis/ show the same logic for a remote backend.

Scoped admission capability

Existing stores can keep implementing acquire for static configurations. Scoped rules require the optional acquireScoped(request) method; creation rejects unsupported stores. Exported ScopedAcquireRequest and ScopedAcquireResult describe this contract:

interface ScopedAcquireResult {
  grantedIndexes: readonly number[];
  pausedMs?: number; // Ms left on an active pause. When above 0, every job is blocked.
  blocked: readonly {
    index: number;
    retryAfterMs: number;
    leaseBlocked?: boolean; // With a lease: true if full slots set the retry hint.
  }[];
  leases?: readonly { index: number; token: string }[];
}

The request contains id, immutable normalized rules, ordered jobs with cost and scopes, and optional lease settings. Indexes are zero-based into jobs. Every job must appear exactly once in grants or denials. Retry hints must be positive safe integers. Report an active shared pause in pausedMs so valves without a subscriber can show it in stats() and emit pause. Global requests need one unique lease token per admitted index, released and renewed through the existing methods.

Validate the complete request before mutation. Use one store clock and one atomic operation to check the shared pause, every applicable rule, and lease capacity. A denial spends nothing; continue evaluating later jobs, including lower-cost jobs. Deduplicate keys and update temporary state after each grant so repeated identities see earlier admissions in the same batch. Commit only final changed states. Pin the complete policy independently of expiring rate state and reject incompatible workers.

Requests contain at most 16 rules and 128 jobs. acquireScopedSync is an optional synchronous optimization with the same semantics. Do not implement scoped admission as repeated calls to acquire: separate reservations cannot enforce all-or-none spending. See Scoped rate limits for identity requirements and operational limits.

On this page