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
| Member | Required | Description |
|---|---|---|
kind | Yes | Short name used in messages, such as 'memory' or 'redis'. |
acquire(request) | Yes | Atomically checks the pause and every rule, then grants a prefix of costs. |
acquireSync?(request) | No | Synchronous version. Used on the hot path when present. |
pause(id, durationMs) | Yes | Extends the pause to at least durationMs from now. Never shortens it. |
resume(id) | Yes | Removes the pause. |
release?(id, leases) | Global | Releases global concurrency slots when jobs settle. |
renew?(id, leases, ttlMs) | Global | Renews unexpired slots. Returns false if any was lost. Never revives expired ones. |
subscribe?(id, listener) | No | Notifies of pause, resume, and release. Returns an unsubscribe function. |
close() | Yes | Frees 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_OPTIONSwhen 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.