@mark1russell7/service 0.5.6 → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/build/index.d.ts +2 -2
- package/build/index.js +2 -2
- package/build/limiter-DCDdzVwe.d.ts +45 -0
- package/build/limiter-pkBZUp-t.js +402 -0
- package/build/limiter-pkBZUp-t.js.map +1 -0
- package/build/limiter.d.ts +2 -1
- package/build/limiter.js +2 -2
- package/package.json +2 -2
- package/build/limiter-wv0uXDk9.js +0 -126
- package/build/limiter-wv0uXDk9.js.map +0 -1
package/README.md
CHANGED
|
@@ -74,7 +74,9 @@ The format of a description is `mark1russell7.service/1`.
|
|
|
74
74
|
|
|
75
75
|
## The machines
|
|
76
76
|
|
|
77
|
-
The machines of the runtime are `mark1russell7.machine/1` documents, which `@mark1russell7/algebra` reads. They are the gate, the latch (a debounce), the window, the poller, the exit manager and the watchdog. `drainMachine(description)` gives the drain of a declaration, with one state for each phase.
|
|
77
|
+
The machines of the runtime are `mark1russell7.machine/1` documents, which `@mark1russell7/algebra` reads. They are the gate, the latch (a debounce), the window, the poller, the exit manager and the watchdog. `drainMachine(description)` gives the drain of a declaration, with one state for each phase. The keyed limiter has its own documents.
|
|
78
|
+
|
|
79
|
+
Each machine gives its transitions to `run({ record })` or `start({ record })`. `replay(doc, events)` gives each transition that the document does not permit. It is the replay of `@mark1russell7/algebra`, which reads a machine of any name.
|
|
78
80
|
|
|
79
81
|
The tests replay the real runtime against each document. They also compare each timer of the documents, from the algebra, with the time that the runtime keeps.
|
|
80
82
|
|
|
@@ -318,6 +320,8 @@ A keyed limiter gives each key its own limit, for example each tenant or each pr
|
|
|
318
320
|
| `idleSpan(policy)`, `longestRetryAfter(policy)`, `retryAfterSeconds(retryAfter)` | The times of a policy, and `Retry-After` in whole seconds |
|
|
319
321
|
| `limiterProblems(name, spec)` | The problems of a spec that `describe()` and `keyedLimiter` refuse |
|
|
320
322
|
| `LIMITER_RULES` | The rules O29 and O30 of the law, as data. Their identifiers are provisional. |
|
|
323
|
+
| `TOKEN_BUCKET_MACHINE`, `SLIDING_WINDOW_MACHINE`, `limiterMachine(policy)` | The machine of one key of each policy, as a `mark1russell7.machine/1` document |
|
|
324
|
+
| `limiterStateOf(rule, state, now)` | The state of a key of a store, as its document names it: `new`, `open` or `limited` |
|
|
321
325
|
|
|
322
326
|
- **No defaults.** A spec has a key, a policy, a store and a bound on its keys. `describe()` refuses a spec without its rate, its burst, its limit, its window, its store, the failure policy of a shared store or its bound. The error names the part.
|
|
323
327
|
- **The key.** `key` tells what the key of each take is, for example `tenant`. The caller gives each key to `take(key)`.
|
|
@@ -343,6 +347,11 @@ A keyed limiter gives each key its own limit, for example each tenant or each pr
|
|
|
343
347
|
- **O30.** The least delay of a caller before a retry (`retry_delay@caller`) is at least the longest `Retry-After` of the policy. Otherwise a caller that does not read the header sends again too early.
|
|
344
348
|
- **A closed failure.** The `retryAfter` of the cause `store` is the longest `retryAfter` of the policy. Thus O29 and O30 apply to it too.
|
|
345
349
|
- **The symbols.** `retry_after@limiter` is the longest `Retry-After` of the policy in whole seconds: one interval of the bucket, or one window. `retry_after_at_cap@limiter` is the longest at the cap, `idleSpan` in whole seconds.
|
|
350
|
+
- **The machine of a key.** Each policy has a document of one key, which `@mark1russell7/algebra` reads. A key is `new` (the store holds no count, or a count equal to a new key), `open` (its next take is permitted) or `limited` (its next take is refused).
|
|
351
|
+
- **The timers of a token bucket.** `token` fires at most `1 / rate` after the take that used the last token. That time is the longest `Retry-After`. `full` fires from one to `burst` intervals after a permitted take: the bucket is full, and the key is new again.
|
|
352
|
+
- **The timers of a sliding window.** `oldest` fires at most one window after the take that filled the window. `last` fires one window after the last permitted take, and the key is new again.
|
|
353
|
+
- **The other transitions.** A refused take leaves a limited key limited. A new key at the cap stays new, with the cause `cap`. A failure of a shared store leaves each state as it is.
|
|
354
|
+
- **The replay.** The tests drive the runtime on a fake clock and replay each take and each change of state against the document. The scenarios reach each transition. The times of the runtime are inside the bounds of the timers that the algebra gives.
|
|
346
355
|
|
|
347
356
|
```ts
|
|
348
357
|
import { keyedLimiter, memoryStore, realClock } from "@mark1russell7/service";
|
package/build/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { S as retryAfterSeconds, _ as freeAt, a as LimitRule, b as limiterProble
|
|
|
5
5
|
import { $ as failSpan, A as LifecycleEvent, B as Gate, C as ProbeAnswer, Ct as Operation, D as Outcome, E as CheckFn, F as CooperativeFloor, G as Step, H as Phase, I as Coupling, J as Poll, K as Transport, L as Dependency, M as Recorder, N as Check, O as Result, P as Controller, Q as asWindow, R as ExitCodes, S as HealthVerdict, St as Idempotency, T as StartOptions, U as RepeatSignal, V as MessageTrigger, W as ServiceDescription, X as Timing, Y as Span, Z as asDebounce, _ as RequestOptions, _t as isExitSignal, a as PhaseContext, at as EXIT_SIGNALS, b as CheckVerdict, bt as nodeProcess, c as STAGES, ct as ExitTrigger, d as TransportImpl, dt as PROCESS_TRIGGERS, et as heartbeatDetection, f as cooperativeAbortAt, ft as ProcessHost, g as RequestFn, gt as isExitCode, h as RequestEvent, ht as embeddedProcess, i as LifecycleState, it as RouteRequest, j as MachineName, l as ShutdownOptions, lt as HostStream, m as RequestEnd, mt as ProcessTrigger, n as HealthServer, nt as OperationOf, o as PhaseWork, ot as EmbeddedProcess, p as stageRank, pt as ProcessPort, q as FailsBy, r as Lifecycle, rt as RoutePattern, s as RunOptions, st as ExitSignal, t as DependencyHandle, tt as recoverSpan, u as Stage, ut as MessageShape, v as RequestStream, vt as isMessageShape, w as ProbeKind, wt as mayReissue, x as Health, xt as processPortOf, y as RequestWork, yt as matchesMessage, z as FORMAT } from "./lifecycle-Chu9Pri2.js";
|
|
6
6
|
import { a as WatchdogSpec, c as Profile, d as kubernetes, f as localDev, i as Watchdog, l as daemon, n as ServiceBuilder, o as watchdog, r as service, s as PodTiming, t as ControllerBuilder, u as ephemeral } from "./builder-DyDB7zM9.js";
|
|
7
7
|
import { a as keyedLimiter, i as TakeRequest, n as LimiterStore, o as memoryStore, r as MemoryStore, t as KeyedLimiter } from "./store-DzTyosou.js";
|
|
8
|
-
import "./limiter.js";
|
|
8
|
+
import { a as limiterStateOf, i as limiterMachine, n as SLIDING_WINDOW_MACHINE, r as TOKEN_BUCKET_MACHINE, t as LimiterState } from "./limiter-DCDdzVwe.js";
|
|
9
9
|
import { Extent, Quantity } from "@mark1russell7/quantity";
|
|
10
10
|
import { replay } from "@mark1russell7/algebra/replay";
|
|
11
11
|
import { Machine } from "@mark1russell7/algebra";
|
|
@@ -431,5 +431,5 @@ export declare const drainMachine: (description: ServiceDescription) => Machine;
|
|
|
431
431
|
/** These are the static machines of the runtime, by the name that their events carry. */
|
|
432
432
|
export declare const MACHINES: Readonly<Record<Exclude<MachineName, "drain" | "client">, Machine>>;
|
|
433
433
|
//#endregion
|
|
434
|
-
export { type Allowed, type Backoff, type Binding, type Bound, type Cancel, type Check, type CheckFn, type CheckVerdict, type Clock, type Controller, type ControllerBuilder, type CooperativeFloor, type Coupling, type Decision, type DelayBand, type Dependency, type DependencyHandle, EXIT_SIGNALS, type EmbeddedProcess, type Evaluated, type ExitCodes, type ExitSignal, type ExitTrigger, FORMAT, type FailsBy, type Finding, type Gate, type Health, type HealthServer, type HealthVerdict, type Heartbeat, type HeartbeatOptions, type HostStream, type Idempotency, type Inequality, type IterationContext, type Jitter, type JobAction, type JobContext, type JobFailure, type JobSettle, type JobSource, type KeyState, type KeyedLimiter, type Lifecycle, type LifecycleEvent, type LifecycleState, type LimitPolicy, type LimitRule, type Limiter, type LimiterEviction, type LimiterSpec, type LimiterStore, type LimiterStoreSpec, type MachineName, type MemoryStore, type MessageShape, type MessageTrigger, type Operation, type OperationOf, type Outcome, PROCESS_TRIGGERS, type Phase, type PhaseContext, type PhaseWork, type PlatformClockOptions, type PodTiming, type Poll, type ProbeAnswer, type ProbeKind, type ProcessHost, type ProcessPort, type ProcessTrigger, type Profile, type QueueDrain, type QueueDrainOptions, type QueueKnobs, type Recorder, type RedeliveryPolicy, type RefusalCause, type Refused, type RepeatSignal, type RequestEnd, type RequestEvent, type RequestFn, type RequestOptions, type RequestStream, type RequestWork, type RequeueCause, type Result, type RoutePattern, type RouteRequest, type Rule, type RunOptions, STAGES, type Scenario, type ServiceBuilder, type ServiceDescription, type ShutdownOptions, type Side, type SlidingWindow, type Span, type Stage, type StartOptions, type Step, type StoreFailure, type TakeRequest, type TakeStep, type Term, type Timing, type TokenBucket, type Transport, type TransportImpl, type UnitRequest, type Verdict, type Watchdog, type WatchdogSpec, type WorkerKnobs, type WorkerLoop, type WorkerLoopOptions, asDebounce, asWindow, backoffBand, backoffDelay, backoffProblems, cooperativeAbortAt, daemon, decide, detectionTime, embeddedProcess, ephemeral, failSpan, freeAt, heartbeat, heartbeatDetection, idleSpan, isExitCode, isExitSignal, isMessageShape, keyedLimiter, kubernetes, limitRule, limiterProblems, localDev, longestRetryAfter, matchesMessage, mayReissue, memoryStore, nodeProcess, platformClock, processPortOf, realClock, recoverSpan, replay, retryAfterSeconds, service, stageRank, watchdog };
|
|
434
|
+
export { type Allowed, type Backoff, type Binding, type Bound, type Cancel, type Check, type CheckFn, type CheckVerdict, type Clock, type Controller, type ControllerBuilder, type CooperativeFloor, type Coupling, type Decision, type DelayBand, type Dependency, type DependencyHandle, EXIT_SIGNALS, type EmbeddedProcess, type Evaluated, type ExitCodes, type ExitSignal, type ExitTrigger, FORMAT, type FailsBy, type Finding, type Gate, type Health, type HealthServer, type HealthVerdict, type Heartbeat, type HeartbeatOptions, type HostStream, type Idempotency, type Inequality, type IterationContext, type Jitter, type JobAction, type JobContext, type JobFailure, type JobSettle, type JobSource, type KeyState, type KeyedLimiter, type Lifecycle, type LifecycleEvent, type LifecycleState, type LimitPolicy, type LimitRule, type Limiter, type LimiterEviction, type LimiterSpec, type LimiterState, type LimiterStore, type LimiterStoreSpec, type MachineName, type MemoryStore, type MessageShape, type MessageTrigger, type Operation, type OperationOf, type Outcome, PROCESS_TRIGGERS, type Phase, type PhaseContext, type PhaseWork, type PlatformClockOptions, type PodTiming, type Poll, type ProbeAnswer, type ProbeKind, type ProcessHost, type ProcessPort, type ProcessTrigger, type Profile, type QueueDrain, type QueueDrainOptions, type QueueKnobs, type Recorder, type RedeliveryPolicy, type RefusalCause, type Refused, type RepeatSignal, type RequestEnd, type RequestEvent, type RequestFn, type RequestOptions, type RequestStream, type RequestWork, type RequeueCause, type Result, type RoutePattern, type RouteRequest, type Rule, type RunOptions, SLIDING_WINDOW_MACHINE, STAGES, type Scenario, type ServiceBuilder, type ServiceDescription, type ShutdownOptions, type Side, type SlidingWindow, type Span, type Stage, type StartOptions, type Step, type StoreFailure, TOKEN_BUCKET_MACHINE, type TakeRequest, type TakeStep, type Term, type Timing, type TokenBucket, type Transport, type TransportImpl, type UnitRequest, type Verdict, type Watchdog, type WatchdogSpec, type WorkerKnobs, type WorkerLoop, type WorkerLoopOptions, asDebounce, asWindow, backoffBand, backoffDelay, backoffProblems, cooperativeAbortAt, daemon, decide, detectionTime, embeddedProcess, ephemeral, failSpan, freeAt, heartbeat, heartbeatDetection, idleSpan, isExitCode, isExitSignal, isMessageShape, keyedLimiter, kubernetes, limitRule, limiterMachine, limiterProblems, limiterStateOf, localDev, longestRetryAfter, matchesMessage, mayReissue, memoryStore, nodeProcess, platformClock, processPortOf, realClock, recoverSpan, replay, retryAfterSeconds, service, stageRank, watchdog };
|
|
435
435
|
//# sourceMappingURL=index.d.ts.map
|
package/build/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { n as operationProblems, t as mayReissue } from "./operation-DL5RxFds.js
|
|
|
5
5
|
import { a as limiterProblems, i as limitRule, n as freeAt, o as longestRetryAfter, r as idleSpan, s as retryAfterSeconds, t as decide } from "./policy-CgS4y94U.js";
|
|
6
6
|
import { a as lateHolds, l as takeOutside, n as countOf, r as millisecondsOf, s as ownStream, t as boundedMillisecondsOf } from "./knobs-D2ClI68I.js";
|
|
7
7
|
import { backoffBand, backoffDelay, backoffProblems } from "./backoff.js";
|
|
8
|
-
import { n as memoryStore, t as
|
|
8
|
+
import { a as keyedLimiter, i as limiterStateOf, n as TOKEN_BUCKET_MACHINE, o as memoryStore, r as limiterMachine, t as SLIDING_WINDOW_MACHINE } from "./limiter-pkBZUp-t.js";
|
|
9
9
|
import { convertExact, convertible, quantity, rationals, valueIn } from "@mark1russell7/quantity";
|
|
10
10
|
import { replay } from "@mark1russell7/algebra/replay";
|
|
11
11
|
//#region src/description.ts
|
|
@@ -3389,6 +3389,6 @@ const MACHINES = {
|
|
|
3389
3389
|
watchdog: WATCHDOG_MACHINE
|
|
3390
3390
|
};
|
|
3391
3391
|
//#endregion
|
|
3392
|
-
export { DECLARATION_RULE, EXIT_MACHINE, EXIT_SIGNALS, FORMAT, GATE_MACHINE, LATCH_MACHINE, LIMITER_RULES, MACHINES, POLLER_MACHINE, PROCESS_TRIGGERS, SERVICE_RULES, STAGES, WATCHDOG_MACHINE, WINDOW_MACHINE, asDebounce, asWindow, backoffBand, backoffDelay, backoffProblems, capKnob, cooperativeAbortAt, daemon, decide, detectionTime, drainMachine, embeddedProcess, ephemeral, evaluate, failSpan, freeAt, heartbeat, heartbeatDetection, idleSpan, inequalities, isExitCode, isExitSignal, isMessageShape, keyedLimiter, kubernetes, limitRule, limiterProblems, lint, localDev, longestRetryAfter, matchesMessage, mayReissue, memoryStore, nodeProcess, platformClock, processPortOf, queueDrain, realClock, recoverSpan, redeliverOnce, replay, retryAfterSeconds, service, serviceBindings, stageRank, symbolsOf, verdictOf, watchdog, workerLoop };
|
|
3392
|
+
export { DECLARATION_RULE, EXIT_MACHINE, EXIT_SIGNALS, FORMAT, GATE_MACHINE, LATCH_MACHINE, LIMITER_RULES, MACHINES, POLLER_MACHINE, PROCESS_TRIGGERS, SERVICE_RULES, SLIDING_WINDOW_MACHINE, STAGES, TOKEN_BUCKET_MACHINE, WATCHDOG_MACHINE, WINDOW_MACHINE, asDebounce, asWindow, backoffBand, backoffDelay, backoffProblems, capKnob, cooperativeAbortAt, daemon, decide, detectionTime, drainMachine, embeddedProcess, ephemeral, evaluate, failSpan, freeAt, heartbeat, heartbeatDetection, idleSpan, inequalities, isExitCode, isExitSignal, isMessageShape, keyedLimiter, kubernetes, limitRule, limiterMachine, limiterProblems, limiterStateOf, lint, localDev, longestRetryAfter, matchesMessage, mayReissue, memoryStore, nodeProcess, platformClock, processPortOf, queueDrain, realClock, recoverSpan, redeliverOnce, replay, retryAfterSeconds, service, serviceBindings, stageRank, symbolsOf, verdictOf, watchdog, workerLoop };
|
|
3393
3393
|
|
|
3394
3394
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { a as LimitRule, i as LimitPolicy, r as KeyState } from "./policy-G4QJ1ZZq.js";
|
|
2
|
+
import "./store-DzTyosou.js";
|
|
3
|
+
import { Machine } from "@mark1russell7/algebra";
|
|
4
|
+
//#region src/limiter/machine.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* A limiter state is the state of one key of a keyed limiter, as its machine document names it:
|
|
7
|
+
* - `new`: the store holds no state for the key, or a state that is equal to the state of a new key.
|
|
8
|
+
* - `open`: the key holds a count, and its next take is permitted.
|
|
9
|
+
* - `limited`: the next take of the key is refused with the cause `limit`, until a unit is back.
|
|
10
|
+
*/
|
|
11
|
+
type LimiterState = "new" | "open" | "limited";
|
|
12
|
+
/**
|
|
13
|
+
* A token bucket of one key (GCRA), as a `mark1russell7.machine/1` document. The bucket holds
|
|
14
|
+
* `burst` tokens, and it gets one token back in each interval, `1 / rate`. The store keeps one time
|
|
15
|
+
* for the key: when its bucket is full again.
|
|
16
|
+
* - A take is permitted when, with the take, the bucket is full again in at most `burst` intervals.
|
|
17
|
+
* - A refused take waits until one token is back: at most one interval. That interval is the
|
|
18
|
+
* longest `Retry-After` of the policy (`retry_after@limiter` of the law).
|
|
19
|
+
* - After `burst / rate` with no take, the bucket is full, and the key is equal to a new key. That
|
|
20
|
+
* span is the idle span, and the longest wait of a new key at the cap of keys
|
|
21
|
+
* (`retry_after_at_cap@limiter`).
|
|
22
|
+
*/
|
|
23
|
+
declare const TOKEN_BUCKET_MACHINE: Machine;
|
|
24
|
+
/**
|
|
25
|
+
* A sliding window of one key, as a `mark1russell7.machine/1` document. The store keeps the time of
|
|
26
|
+
* each permitted take of the key in the window, and each take counts for one window from its time.
|
|
27
|
+
* - A take is permitted when fewer than `limit` takes are in the window.
|
|
28
|
+
* - A refused take waits until the oldest take leaves the window: at most one window. That window is
|
|
29
|
+
* the longest `Retry-After` of the policy (`retry_after@limiter` of the law).
|
|
30
|
+
* - One window after the last take, no take is in the window, and the key is equal to a new key.
|
|
31
|
+
* That window is the idle span, and the longest wait of a new key at the cap of keys
|
|
32
|
+
* (`retry_after_at_cap@limiter`).
|
|
33
|
+
*/
|
|
34
|
+
declare const SLIDING_WINDOW_MACHINE: Machine;
|
|
35
|
+
/** This function gives the machine document of the policy of a limiter. */
|
|
36
|
+
declare const limiterMachine: (policy: LimitPolicy) => Machine;
|
|
37
|
+
/**
|
|
38
|
+
* This function gives the state of a key of a store as its machine document names it, at the time
|
|
39
|
+
* `now` in milliseconds. `state` is what the store keeps for the key, or `undefined` when it keeps
|
|
40
|
+
* nothing. A key equal to a new key is `new`, and a key whose next take `decide` refuses is `limited`.
|
|
41
|
+
*/
|
|
42
|
+
declare const limiterStateOf: (rule: LimitRule, state: KeyState | undefined, now: number) => LimiterState;
|
|
43
|
+
//#endregion
|
|
44
|
+
export { limiterStateOf as a, limiterMachine as i, SLIDING_WINDOW_MACHINE as n, TOKEN_BUCKET_MACHINE as r, LimiterState as t };
|
|
45
|
+
//# sourceMappingURL=limiter-DCDdzVwe.d.ts.map
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { a as limiterProblems, i as limitRule, n as freeAt, o as longestRetryAfter, t as decide } from "./policy-CgS4y94U.js";
|
|
2
|
+
import { convertExact, rationals } from "@mark1russell7/quantity";
|
|
3
|
+
//#region src/limiter/store.ts
|
|
4
|
+
const ms = (value) => ({
|
|
5
|
+
value,
|
|
6
|
+
unit: "ms"
|
|
7
|
+
});
|
|
8
|
+
/**
|
|
9
|
+
* This function makes the store of one replica. It reads time only through the clock. Thus a step
|
|
10
|
+
* of the wall clock does not move a count, and a fake clock gives exact decisions in a test.
|
|
11
|
+
* - The eviction is exact. A key whose bucket is full again, or whose window has no take, is equal
|
|
12
|
+
* to a new key. The store removes it at its next take, or at a sweep.
|
|
13
|
+
* - The count of keys has a bound. At the cap, a take of a new key sweeps the table, but only when
|
|
14
|
+
* a held key can be free. Each sweep reads each key one time. When no key is free, the store
|
|
15
|
+
* refuses the new key with the cause `cap`. Its `retryAfter` is at most the time until the
|
|
16
|
+
* earliest held key is free, and that time is at most `idleSpan` of the policy.
|
|
17
|
+
*/
|
|
18
|
+
const memoryStore = (clock) => {
|
|
19
|
+
const tables = /* @__PURE__ */ new Map();
|
|
20
|
+
const sweep = (table, rule, now) => {
|
|
21
|
+
let next = Number.POSITIVE_INFINITY;
|
|
22
|
+
for (const [key, state] of table.states) {
|
|
23
|
+
const free = freeAt(rule, state);
|
|
24
|
+
if (free <= now) table.states.delete(key);
|
|
25
|
+
else next = Math.min(next, free);
|
|
26
|
+
}
|
|
27
|
+
table.next = next;
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
kind: "local",
|
|
31
|
+
take({ limiter, key, rule, maxKeys }) {
|
|
32
|
+
const now = clock.now();
|
|
33
|
+
let table = tables.get(limiter);
|
|
34
|
+
if (table === void 0) {
|
|
35
|
+
table = {
|
|
36
|
+
states: /* @__PURE__ */ new Map(),
|
|
37
|
+
next: Number.POSITIVE_INFINITY
|
|
38
|
+
};
|
|
39
|
+
tables.set(limiter, table);
|
|
40
|
+
}
|
|
41
|
+
let state = table.states.get(key);
|
|
42
|
+
if (state !== void 0 && freeAt(rule, state) <= now) {
|
|
43
|
+
table.states.delete(key);
|
|
44
|
+
state = void 0;
|
|
45
|
+
}
|
|
46
|
+
if (state === void 0 && table.states.size >= maxKeys) {
|
|
47
|
+
if (table.next <= now) sweep(table, rule, now);
|
|
48
|
+
if (table.states.size >= maxKeys) return {
|
|
49
|
+
allowed: false,
|
|
50
|
+
retryAfter: ms(table.next - now),
|
|
51
|
+
cause: "cap"
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const step = decide(rule, state, now);
|
|
55
|
+
if (state === void 0) {
|
|
56
|
+
table.states.set(key, step.state);
|
|
57
|
+
table.next = Math.min(table.next, freeAt(rule, step.state));
|
|
58
|
+
} else table.states.set(key, step.state);
|
|
59
|
+
return step.decision;
|
|
60
|
+
},
|
|
61
|
+
size(limiter) {
|
|
62
|
+
return tables.get(limiter)?.states.size ?? 0;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
/** This function gives the text of a failure. */
|
|
67
|
+
const failureOf = (error) => error instanceof Error ? error.message : String(error);
|
|
68
|
+
/**
|
|
69
|
+
* This function makes the runtime of a limiter from its spec and its store. It refuses a spec with a
|
|
70
|
+
* problem, because the limiter has no defaults. It also refuses a store of the wrong kind: a spec
|
|
71
|
+
* with a shared store needs a store that the replicas share.
|
|
72
|
+
* - A failure of a shared store gives the decision of `onStoreFailure`. With `open`, the take is
|
|
73
|
+
* permitted with `remaining` 0. With `closed`, it is refused with the cause `store`.
|
|
74
|
+
* - The `retryAfter` of the cause `store` is the longest `retryAfter` of the policy.
|
|
75
|
+
* - A failure of a local store rejects the take, because a local store has no `onStoreFailure`.
|
|
76
|
+
*/
|
|
77
|
+
const keyedLimiter = (name, spec, store) => {
|
|
78
|
+
const problems = limiterProblems(name, spec);
|
|
79
|
+
if (problems.length > 0) throw new Error(problems.join("; "));
|
|
80
|
+
if (store.kind !== spec.store.kind) throw new Error(`limiter ${name} declares a ${spec.store.kind} store, but the store that it got is ${store.kind}`);
|
|
81
|
+
const rule = limitRule(spec.policy);
|
|
82
|
+
const maxKeys = rationals.toNumber(convertExact(rationals.fromNumber(spec.evict.maxKeys.value), spec.evict.maxKeys.unit, "count"));
|
|
83
|
+
const onStoreFailure = spec.store.kind === "shared" ? spec.store.onStoreFailure : void 0;
|
|
84
|
+
const closedAfter = longestRetryAfter(spec.policy);
|
|
85
|
+
const take = async (key) => {
|
|
86
|
+
const request = {
|
|
87
|
+
limiter: name,
|
|
88
|
+
key,
|
|
89
|
+
rule,
|
|
90
|
+
maxKeys
|
|
91
|
+
};
|
|
92
|
+
if (onStoreFailure === void 0) return store.take(request);
|
|
93
|
+
try {
|
|
94
|
+
return await store.take(request);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
const failure = failureOf(error);
|
|
97
|
+
if (onStoreFailure === "open") return {
|
|
98
|
+
allowed: true,
|
|
99
|
+
remaining: 0,
|
|
100
|
+
failure
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
allowed: false,
|
|
104
|
+
retryAfter: { ...closedAfter },
|
|
105
|
+
cause: "store",
|
|
106
|
+
failure
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
return {
|
|
111
|
+
name,
|
|
112
|
+
spec: {
|
|
113
|
+
key: spec.key,
|
|
114
|
+
policy: { ...spec.policy },
|
|
115
|
+
store: { ...spec.store },
|
|
116
|
+
evict: { maxKeys: { ...spec.evict.maxKeys } }
|
|
117
|
+
},
|
|
118
|
+
rule,
|
|
119
|
+
maxKeys,
|
|
120
|
+
take
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region src/limiter/machine.ts
|
|
125
|
+
const t = (id, from, on, to, extra = {}) => ({
|
|
126
|
+
id,
|
|
127
|
+
from,
|
|
128
|
+
on,
|
|
129
|
+
to,
|
|
130
|
+
...extra
|
|
131
|
+
});
|
|
132
|
+
const count = (key) => ({
|
|
133
|
+
key,
|
|
134
|
+
scope: "limiter",
|
|
135
|
+
unit: "count",
|
|
136
|
+
default: null
|
|
137
|
+
});
|
|
138
|
+
/** These are the states of a key that a store can hold. A store fails in each of them. */
|
|
139
|
+
const HELD = [
|
|
140
|
+
"new",
|
|
141
|
+
"open",
|
|
142
|
+
"limited"
|
|
143
|
+
];
|
|
144
|
+
/** These are the parts of each document of a limiter that do not change with its policy. */
|
|
145
|
+
const COMMON = {
|
|
146
|
+
format: "mark1russell7.machine/1",
|
|
147
|
+
layer: "self",
|
|
148
|
+
version: "0.1.0",
|
|
149
|
+
sources: [
|
|
150
|
+
{
|
|
151
|
+
ref: "packages/service/src/limiter (the runtime implements this document: decide, memoryStore, keyedLimiter)",
|
|
152
|
+
rung: "implemented"
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
ref: "RFC 6585 §4: 429 Too Many Requests, which can carry Retry-After",
|
|
156
|
+
rung: "normative"
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
ref: "RFC 9110 §10.2.3: Retry-After, as delay-seconds or an HTTP-date",
|
|
160
|
+
rung: "normative"
|
|
161
|
+
}
|
|
162
|
+
],
|
|
163
|
+
conditions: {
|
|
164
|
+
"store.local": "the counts are in the memory of one replica: a limit L over N replicas is L / N on each replica, and that sum holds only on average",
|
|
165
|
+
"store.shared": "the counts are in a store that the replicas share: one atomic step on the clock of the store decides each take"
|
|
166
|
+
},
|
|
167
|
+
states: {
|
|
168
|
+
new: {
|
|
169
|
+
initial: true,
|
|
170
|
+
note: "the store holds no state for the key, or a state equal to a new key. The store can remove the key"
|
|
171
|
+
},
|
|
172
|
+
open: { note: "the key holds a count, and its next take is permitted" },
|
|
173
|
+
limited: { note: "the next take is refused with the cause limit, until a unit is back" }
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
/** These are the events of each document of a limiter. */
|
|
177
|
+
const EVENTS = {
|
|
178
|
+
take: {
|
|
179
|
+
kind: "app",
|
|
180
|
+
note: "one call of take(key): one request of the key. A permitted take uses one unit"
|
|
181
|
+
},
|
|
182
|
+
"unit.back": {
|
|
183
|
+
kind: "timer",
|
|
184
|
+
note: "the next take of a limited key is permitted again"
|
|
185
|
+
},
|
|
186
|
+
idle: {
|
|
187
|
+
kind: "timer",
|
|
188
|
+
note: "the key is equal to a new key. The store can remove it, and no decision changes"
|
|
189
|
+
},
|
|
190
|
+
"store.fail": {
|
|
191
|
+
kind: "local",
|
|
192
|
+
note: "a shared store fails the take. The state of the key does not change"
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
/** These are the knobs of each document of a limiter that do not change with its policy. */
|
|
196
|
+
const KNOBS = {
|
|
197
|
+
max_keys: count("limiters.<name>.evict.maxKeys"),
|
|
198
|
+
on_store_failure: {
|
|
199
|
+
key: "limiters.<name>.store.onStoreFailure",
|
|
200
|
+
scope: "limiter",
|
|
201
|
+
unit: null,
|
|
202
|
+
default: null
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
/**
|
|
206
|
+
* These are the transitions of each document of a limiter that do not change with its policy. They
|
|
207
|
+
* are the refusal of a limited key, a new key at the cap of keys, and a failure of a shared store.
|
|
208
|
+
*/
|
|
209
|
+
const COMMON_TRANSITIONS = [
|
|
210
|
+
t("refuse", "limited", "take", "limited", {
|
|
211
|
+
emits: ["429 limit"],
|
|
212
|
+
note: "Retry-After is the time until the next take is permitted, in whole seconds, rounded up. The count of the key does not change"
|
|
213
|
+
}),
|
|
214
|
+
t("cap", "new", "take", "new", {
|
|
215
|
+
guard: "the store holds max_keys keys for the limiter, and no held key is equal to a new key",
|
|
216
|
+
emits: ["429 cap"],
|
|
217
|
+
note: "Retry-After is the time until the earliest held key is free: at most the idle span. A held key keeps its count"
|
|
218
|
+
}),
|
|
219
|
+
...HELD.map((state) => t(`store-failed-${state}`, state, "store.fail", state, {
|
|
220
|
+
guard: "the store is shared",
|
|
221
|
+
emits: ["allow, or 429 store"],
|
|
222
|
+
note: "on_store_failure open lets the take in, with remaining 0. closed refuses it with the cause store, and its Retry-After is the longest of the policy"
|
|
223
|
+
}))
|
|
224
|
+
];
|
|
225
|
+
/**
|
|
226
|
+
* A token bucket of one key (GCRA), as a `mark1russell7.machine/1` document. The bucket holds
|
|
227
|
+
* `burst` tokens, and it gets one token back in each interval, `1 / rate`. The store keeps one time
|
|
228
|
+
* for the key: when its bucket is full again.
|
|
229
|
+
* - A take is permitted when, with the take, the bucket is full again in at most `burst` intervals.
|
|
230
|
+
* - A refused take waits until one token is back: at most one interval. That interval is the
|
|
231
|
+
* longest `Retry-After` of the policy (`retry_after@limiter` of the law).
|
|
232
|
+
* - After `burst / rate` with no take, the bucket is full, and the key is equal to a new key. That
|
|
233
|
+
* span is the idle span, and the longest wait of a new key at the cap of keys
|
|
234
|
+
* (`retry_after_at_cap@limiter`).
|
|
235
|
+
*/
|
|
236
|
+
const TOKEN_BUCKET_MACHINE = {
|
|
237
|
+
...COMMON,
|
|
238
|
+
id: "service.machine.limiter.token-bucket",
|
|
239
|
+
title: "A token bucket of one key of the keyed limiter",
|
|
240
|
+
events: EVENTS,
|
|
241
|
+
knobs: {
|
|
242
|
+
rate: {
|
|
243
|
+
key: "limiters.<name>.policy.rate",
|
|
244
|
+
scope: "limiter",
|
|
245
|
+
unit: "count/s",
|
|
246
|
+
default: null
|
|
247
|
+
},
|
|
248
|
+
burst: count("limiters.<name>.policy.burst"),
|
|
249
|
+
...KNOBS
|
|
250
|
+
},
|
|
251
|
+
timers: {
|
|
252
|
+
token: {
|
|
253
|
+
duration: {
|
|
254
|
+
expr: "1 / rate",
|
|
255
|
+
unit: "s"
|
|
256
|
+
},
|
|
257
|
+
early: {
|
|
258
|
+
expr: "1 / rate",
|
|
259
|
+
unit: "s"
|
|
260
|
+
},
|
|
261
|
+
arms: ["enter:limited"],
|
|
262
|
+
cancels: ["idle"],
|
|
263
|
+
fires: "unit.back",
|
|
264
|
+
note: "one token comes back at most one interval after the take that used the last token. The Retry-After of each refusal is the time to this firing"
|
|
265
|
+
},
|
|
266
|
+
full: {
|
|
267
|
+
duration: {
|
|
268
|
+
expr: "burst / rate",
|
|
269
|
+
unit: "s"
|
|
270
|
+
},
|
|
271
|
+
early: {
|
|
272
|
+
expr: "(burst - 1) / rate",
|
|
273
|
+
unit: "s"
|
|
274
|
+
},
|
|
275
|
+
arms: ["take"],
|
|
276
|
+
fires: "idle",
|
|
277
|
+
note: "the bucket is full again from one to burst intervals after a permitted take. A refused take does not move it"
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
transitions: [
|
|
281
|
+
t("first", "new", "take", "open", {
|
|
282
|
+
guard: "burst > 1",
|
|
283
|
+
emits: ["allow"]
|
|
284
|
+
}),
|
|
285
|
+
t("first-last", "new", "take", "limited", {
|
|
286
|
+
guard: "burst = 1",
|
|
287
|
+
emits: ["allow"]
|
|
288
|
+
}),
|
|
289
|
+
t("take", "open", "take", "open", {
|
|
290
|
+
guard: "a token remains after the take",
|
|
291
|
+
emits: ["allow"]
|
|
292
|
+
}),
|
|
293
|
+
t("take-last", "open", "take", "limited", {
|
|
294
|
+
guard: "the take uses the last token",
|
|
295
|
+
emits: ["allow"]
|
|
296
|
+
}),
|
|
297
|
+
...COMMON_TRANSITIONS,
|
|
298
|
+
t("token-back", "limited", "unit.back", "open", {
|
|
299
|
+
timer: "token",
|
|
300
|
+
guard: "burst > 1"
|
|
301
|
+
}),
|
|
302
|
+
t("full", "open", "idle", "new", { timer: "full" }),
|
|
303
|
+
t("full-at-once", "limited", "idle", "new", {
|
|
304
|
+
timer: "full",
|
|
305
|
+
guard: "burst = 1: the one token is back when the bucket is full"
|
|
306
|
+
})
|
|
307
|
+
]
|
|
308
|
+
};
|
|
309
|
+
/**
|
|
310
|
+
* A sliding window of one key, as a `mark1russell7.machine/1` document. The store keeps the time of
|
|
311
|
+
* each permitted take of the key in the window, and each take counts for one window from its time.
|
|
312
|
+
* - A take is permitted when fewer than `limit` takes are in the window.
|
|
313
|
+
* - A refused take waits until the oldest take leaves the window: at most one window. That window is
|
|
314
|
+
* the longest `Retry-After` of the policy (`retry_after@limiter` of the law).
|
|
315
|
+
* - One window after the last take, no take is in the window, and the key is equal to a new key.
|
|
316
|
+
* That window is the idle span, and the longest wait of a new key at the cap of keys
|
|
317
|
+
* (`retry_after_at_cap@limiter`).
|
|
318
|
+
*/
|
|
319
|
+
const SLIDING_WINDOW_MACHINE = {
|
|
320
|
+
...COMMON,
|
|
321
|
+
id: "service.machine.limiter.sliding-window",
|
|
322
|
+
title: "A sliding window of one key of the keyed limiter",
|
|
323
|
+
events: EVENTS,
|
|
324
|
+
knobs: {
|
|
325
|
+
limit: count("limiters.<name>.policy.limit"),
|
|
326
|
+
window: {
|
|
327
|
+
key: "limiters.<name>.policy.window",
|
|
328
|
+
scope: "limiter",
|
|
329
|
+
unit: "s",
|
|
330
|
+
default: null
|
|
331
|
+
},
|
|
332
|
+
...KNOBS
|
|
333
|
+
},
|
|
334
|
+
timers: {
|
|
335
|
+
oldest: {
|
|
336
|
+
duration: {
|
|
337
|
+
knob: "window",
|
|
338
|
+
unit: "s"
|
|
339
|
+
},
|
|
340
|
+
early: {
|
|
341
|
+
knob: "window",
|
|
342
|
+
unit: "s"
|
|
343
|
+
},
|
|
344
|
+
arms: ["enter:limited"],
|
|
345
|
+
cancels: ["idle"],
|
|
346
|
+
fires: "unit.back",
|
|
347
|
+
note: "the oldest take leaves the window at most one window after the take that filled it. The Retry-After of each refusal is the time to this firing"
|
|
348
|
+
},
|
|
349
|
+
last: {
|
|
350
|
+
duration: {
|
|
351
|
+
knob: "window",
|
|
352
|
+
unit: "s"
|
|
353
|
+
},
|
|
354
|
+
arms: ["take"],
|
|
355
|
+
fires: "idle",
|
|
356
|
+
note: "the last permitted take leaves the window one window after its time. A refused take does not move it"
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
transitions: [
|
|
360
|
+
t("first", "new", "take", "open", {
|
|
361
|
+
guard: "limit > 1",
|
|
362
|
+
emits: ["allow"]
|
|
363
|
+
}),
|
|
364
|
+
t("first-last", "new", "take", "limited", {
|
|
365
|
+
guard: "limit = 1",
|
|
366
|
+
emits: ["allow"]
|
|
367
|
+
}),
|
|
368
|
+
t("take", "open", "take", "open", {
|
|
369
|
+
guard: "fewer than limit - 1 takes are in the window before the take",
|
|
370
|
+
emits: ["allow"]
|
|
371
|
+
}),
|
|
372
|
+
t("take-last", "open", "take", "limited", {
|
|
373
|
+
guard: "the take is the limit-th take in the window",
|
|
374
|
+
emits: ["allow"]
|
|
375
|
+
}),
|
|
376
|
+
...COMMON_TRANSITIONS,
|
|
377
|
+
t("oldest-leaves", "limited", "unit.back", "open", {
|
|
378
|
+
timer: "oldest",
|
|
379
|
+
guard: "a later take stays in the window"
|
|
380
|
+
}),
|
|
381
|
+
t("last-leaves", "open", "idle", "new", { timer: "last" }),
|
|
382
|
+
t("all-leave", "limited", "idle", "new", {
|
|
383
|
+
timer: "last",
|
|
384
|
+
guard: "each take of the window has one time: limit = 1, or each take came at once"
|
|
385
|
+
})
|
|
386
|
+
]
|
|
387
|
+
};
|
|
388
|
+
/** This function gives the machine document of the policy of a limiter. */
|
|
389
|
+
const limiterMachine = (policy) => policy.type === "token-bucket" ? TOKEN_BUCKET_MACHINE : SLIDING_WINDOW_MACHINE;
|
|
390
|
+
/**
|
|
391
|
+
* This function gives the state of a key of a store as its machine document names it, at the time
|
|
392
|
+
* `now` in milliseconds. `state` is what the store keeps for the key, or `undefined` when it keeps
|
|
393
|
+
* nothing. A key equal to a new key is `new`, and a key whose next take `decide` refuses is `limited`.
|
|
394
|
+
*/
|
|
395
|
+
const limiterStateOf = (rule, state, now) => {
|
|
396
|
+
if (state === void 0 || freeAt(rule, state) <= now) return "new";
|
|
397
|
+
return decide(rule, state, now).decision.allowed ? "open" : "limited";
|
|
398
|
+
};
|
|
399
|
+
//#endregion
|
|
400
|
+
export { keyedLimiter as a, limiterStateOf as i, TOKEN_BUCKET_MACHINE as n, memoryStore as o, limiterMachine as r, SLIDING_WINDOW_MACHINE as t };
|
|
401
|
+
|
|
402
|
+
//# sourceMappingURL=limiter-pkBZUp-t.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"limiter-pkBZUp-t.js","names":["r"],"sources":["../src/limiter/store.ts","../src/limiter/machine.ts"],"sourcesContent":["import type { Quantity } from \"@mark1russell7/quantity\";\nimport { convertExact, rationals as r } from \"@mark1russell7/quantity\";\nimport type { Clock } from \"../clock.ts\";\nimport type { Decision, KeyState, LimiterSpec, LimitRule } from \"./policy.ts\";\nimport { decide, freeAt, limitRule, limiterProblems, longestRetryAfter } from \"./policy.ts\";\n\n/** A take request is one take of one key, with the rule and the cap of its limiter in numbers. */\nexport interface TakeRequest {\n /** This name is the limiter. A store keeps the keys of each limiter apart. */\n readonly limiter: string;\n readonly key: string;\n readonly rule: LimitRule;\n /** This count is the most keys that the store holds for the limiter. */\n readonly maxKeys: number;\n}\n\n/**\n * A limiter store keeps the state of each key, and it decides each take in one atomic step, on its\n * own clock. Thus two takes of one key do not read the same state at any time. A store in this\n * process uses `decide`. A store that the replicas share does the same arithmetic in one step of its\n * own, for example one script of Redis that reads `TIME`.\n */\nexport interface LimiterStore {\n /** This kind tells where the counts are: in the memory of this replica, or in a store that the replicas share. */\n readonly kind: \"local\" | \"shared\";\n /**\n * This method decides one take. It removes a key only when its state is equal to the state of a\n * new key. It holds at most `maxKeys` keys for each limiter. A failure of a shared store rejects\n * the take, and the limiter then applies the `onStoreFailure` of its spec.\n */\n take(request: TakeRequest): Decision | Promise<Decision>;\n}\n\n/** A memory store is the store of one replica, on the injected monotonic clock. */\nexport interface MemoryStore extends LimiterStore {\n readonly kind: \"local\";\n /** This method gives the count of keys that the store holds for a limiter. */\n size(limiter: string): number;\n}\n\n/** A table is the keys of one limiter in a memory store. */\ninterface Table {\n readonly states: Map<string, KeyState>;\n /**\n * This time is at or before the earliest time at which a held key is equal to a new key. An\n * insert lowers it, and a sweep sets it again. A take of a held key only moves the time of that\n * key later, thus the bound stays true.\n */\n next: number;\n}\n\nconst ms = (value: number): Quantity => ({ value, unit: \"ms\" });\n\n/**\n * This function makes the store of one replica. It reads time only through the clock. Thus a step\n * of the wall clock does not move a count, and a fake clock gives exact decisions in a test.\n * - The eviction is exact. A key whose bucket is full again, or whose window has no take, is equal\n * to a new key. The store removes it at its next take, or at a sweep.\n * - The count of keys has a bound. At the cap, a take of a new key sweeps the table, but only when\n * a held key can be free. Each sweep reads each key one time. When no key is free, the store\n * refuses the new key with the cause `cap`. Its `retryAfter` is at most the time until the\n * earliest held key is free, and that time is at most `idleSpan` of the policy.\n */\nexport const memoryStore = (clock: Clock): MemoryStore => {\n const tables = new Map<string, Table>();\n const sweep = (table: Table, rule: LimitRule, now: number): void => {\n let next = Number.POSITIVE_INFINITY;\n for (const [key, state] of table.states) {\n const free = freeAt(rule, state);\n if (free <= now) table.states.delete(key);\n else next = Math.min(next, free);\n }\n table.next = next;\n };\n return {\n kind: \"local\",\n take({ limiter, key, rule, maxKeys }) {\n const now = clock.now();\n let table = tables.get(limiter);\n if (table === undefined) {\n table = { states: new Map(), next: Number.POSITIVE_INFINITY };\n tables.set(limiter, table);\n }\n let state = table.states.get(key);\n // a key that is equal to a new key goes: the decision is the same, and it frees its slot\n if (state !== undefined && freeAt(rule, state) <= now) {\n table.states.delete(key);\n state = undefined;\n }\n if (state === undefined && table.states.size >= maxKeys) {\n if (table.next <= now) sweep(table, rule, now);\n if (table.states.size >= maxKeys) return { allowed: false, retryAfter: ms(table.next - now), cause: \"cap\" };\n }\n const step = decide(rule, state, now);\n if (state === undefined) {\n // a new key is never refused by its policy: a full bucket, or an empty window\n table.states.set(key, step.state);\n table.next = Math.min(table.next, freeAt(rule, step.state));\n } else table.states.set(key, step.state);\n return step.decision;\n },\n size(limiter) {\n return tables.get(limiter)?.states.size ?? 0;\n },\n };\n};\n\n/** A keyed limiter is the runtime of one declared limiter: its spec, its rule in numbers, and its store. */\nexport interface KeyedLimiter {\n readonly name: string;\n /** This spec is the declaration, the same data that `svc.limiter(name, spec)` takes and `describe()` shows. */\n readonly spec: LimiterSpec;\n readonly rule: LimitRule;\n /** This count is the most keys that the store holds for the limiter. */\n readonly maxKeys: number;\n /**\n * This method takes one unit for a key, and gives the decision of the store. When a shared store\n * fails, it gives the decision of `onStoreFailure`, with the failure as text.\n */\n take(key: string): Promise<Decision>;\n}\n\n/** This function gives the text of a failure. */\nconst failureOf = (error: unknown): string => (error instanceof Error ? error.message : String(error));\n\n/**\n * This function makes the runtime of a limiter from its spec and its store. It refuses a spec with a\n * problem, because the limiter has no defaults. It also refuses a store of the wrong kind: a spec\n * with a shared store needs a store that the replicas share.\n * - A failure of a shared store gives the decision of `onStoreFailure`. With `open`, the take is\n * permitted with `remaining` 0. With `closed`, it is refused with the cause `store`.\n * - The `retryAfter` of the cause `store` is the longest `retryAfter` of the policy.\n * - A failure of a local store rejects the take, because a local store has no `onStoreFailure`.\n */\nexport const keyedLimiter = (name: string, spec: LimiterSpec, store: LimiterStore): KeyedLimiter => {\n const problems = limiterProblems(name, spec);\n if (problems.length > 0) throw new Error(problems.join(\"; \"));\n if (store.kind !== spec.store.kind)\n throw new Error(`limiter ${name} declares a ${spec.store.kind} store, but the store that it got is ${store.kind}`);\n const rule = limitRule(spec.policy);\n const maxKeys = r.toNumber(convertExact(r.fromNumber(spec.evict.maxKeys.value), spec.evict.maxKeys.unit, \"count\"));\n const onStoreFailure = spec.store.kind === \"shared\" ? spec.store.onStoreFailure : undefined;\n const closedAfter = longestRetryAfter(spec.policy);\n const take = async (key: string): Promise<Decision> => {\n const request: TakeRequest = { limiter: name, key, rule, maxKeys };\n if (onStoreFailure === undefined) return store.take(request);\n try {\n return await store.take(request);\n } catch (error) {\n const failure = failureOf(error);\n if (onStoreFailure === \"open\") return { allowed: true, remaining: 0, failure };\n return { allowed: false, retryAfter: { ...closedAfter }, cause: \"store\", failure };\n }\n };\n return {\n name,\n spec: {\n key: spec.key,\n policy: { ...spec.policy },\n store: { ...spec.store },\n evict: { maxKeys: { ...spec.evict.maxKeys } },\n },\n rule,\n maxKeys,\n take,\n };\n};\n","import type { Machine, TransitionDef } from \"@mark1russell7/algebra\";\nimport type { KeyState, LimitPolicy, LimitRule } from \"./policy.ts\";\nimport { decide, freeAt } from \"./policy.ts\";\n\nconst t = (id: string, from: string | readonly string[], on: string | readonly string[], to: string, extra: Partial<TransitionDef> = {}): TransitionDef => ({\n id,\n from,\n on,\n to,\n ...extra,\n});\nconst count = (key: string) => ({ key, scope: \"limiter\", unit: \"count\", default: null }) as const;\n\n/**\n * A limiter state is the state of one key of a keyed limiter, as its machine document names it:\n * - `new`: the store holds no state for the key, or a state that is equal to the state of a new key.\n * - `open`: the key holds a count, and its next take is permitted.\n * - `limited`: the next take of the key is refused with the cause `limit`, until a unit is back.\n */\nexport type LimiterState = \"new\" | \"open\" | \"limited\";\n\n/** These are the states of a key that a store can hold. A store fails in each of them. */\nconst HELD = [\"new\", \"open\", \"limited\"] as const;\n\n/** These are the parts of each document of a limiter that do not change with its policy. */\nconst COMMON = {\n format: \"mark1russell7.machine/1\",\n layer: \"self\",\n version: \"0.1.0\",\n sources: [\n { ref: \"packages/service/src/limiter (the runtime implements this document: decide, memoryStore, keyedLimiter)\", rung: \"implemented\" },\n { ref: \"RFC 6585 §4: 429 Too Many Requests, which can carry Retry-After\", rung: \"normative\" },\n { ref: \"RFC 9110 §10.2.3: Retry-After, as delay-seconds or an HTTP-date\", rung: \"normative\" },\n ],\n conditions: {\n \"store.local\": \"the counts are in the memory of one replica: a limit L over N replicas is L / N on each replica, and that sum holds only on average\",\n \"store.shared\": \"the counts are in a store that the replicas share: one atomic step on the clock of the store decides each take\",\n },\n states: {\n new: { initial: true, note: \"the store holds no state for the key, or a state equal to a new key. The store can remove the key\" },\n open: { note: \"the key holds a count, and its next take is permitted\" },\n limited: { note: \"the next take is refused with the cause limit, until a unit is back\" },\n },\n} as const;\n\n/** These are the events of each document of a limiter. */\nconst EVENTS = {\n take: { kind: \"app\", note: \"one call of take(key): one request of the key. A permitted take uses one unit\" },\n \"unit.back\": { kind: \"timer\", note: \"the next take of a limited key is permitted again\" },\n idle: { kind: \"timer\", note: \"the key is equal to a new key. The store can remove it, and no decision changes\" },\n \"store.fail\": { kind: \"local\", note: \"a shared store fails the take. The state of the key does not change\" },\n} as const;\n\n/** These are the knobs of each document of a limiter that do not change with its policy. */\nconst KNOBS = {\n max_keys: count(\"limiters.<name>.evict.maxKeys\"),\n on_store_failure: { key: \"limiters.<name>.store.onStoreFailure\", scope: \"limiter\", unit: null, default: null },\n} as const;\n\n/**\n * These are the transitions of each document of a limiter that do not change with its policy. They\n * are the refusal of a limited key, a new key at the cap of keys, and a failure of a shared store.\n */\nconst COMMON_TRANSITIONS: readonly TransitionDef[] = [\n t(\"refuse\", \"limited\", \"take\", \"limited\", {\n emits: [\"429 limit\"],\n note: \"Retry-After is the time until the next take is permitted, in whole seconds, rounded up. The count of the key does not change\",\n }),\n t(\"cap\", \"new\", \"take\", \"new\", {\n guard: \"the store holds max_keys keys for the limiter, and no held key is equal to a new key\",\n emits: [\"429 cap\"],\n note: \"Retry-After is the time until the earliest held key is free: at most the idle span. A held key keeps its count\",\n }),\n ...HELD.map((state) =>\n t(`store-failed-${state}`, state, \"store.fail\", state, {\n guard: \"the store is shared\",\n emits: [\"allow, or 429 store\"],\n note: \"on_store_failure open lets the take in, with remaining 0. closed refuses it with the cause store, and its Retry-After is the longest of the policy\",\n }),\n ),\n];\n\n/**\n * A token bucket of one key (GCRA), as a `mark1russell7.machine/1` document. The bucket holds\n * `burst` tokens, and it gets one token back in each interval, `1 / rate`. The store keeps one time\n * for the key: when its bucket is full again.\n * - A take is permitted when, with the take, the bucket is full again in at most `burst` intervals.\n * - A refused take waits until one token is back: at most one interval. That interval is the\n * longest `Retry-After` of the policy (`retry_after@limiter` of the law).\n * - After `burst / rate` with no take, the bucket is full, and the key is equal to a new key. That\n * span is the idle span, and the longest wait of a new key at the cap of keys\n * (`retry_after_at_cap@limiter`).\n */\nexport const TOKEN_BUCKET_MACHINE: Machine = {\n ...COMMON,\n id: \"service.machine.limiter.token-bucket\",\n title: \"A token bucket of one key of the keyed limiter\",\n events: EVENTS,\n knobs: {\n rate: { key: \"limiters.<name>.policy.rate\", scope: \"limiter\", unit: \"count/s\", default: null },\n burst: count(\"limiters.<name>.policy.burst\"),\n ...KNOBS,\n },\n timers: {\n token: {\n duration: { expr: \"1 / rate\", unit: \"s\" },\n early: { expr: \"1 / rate\", unit: \"s\" },\n arms: [\"enter:limited\"],\n cancels: [\"idle\"],\n fires: \"unit.back\",\n note: \"one token comes back at most one interval after the take that used the last token. The Retry-After of each refusal is the time to this firing\",\n },\n full: {\n duration: { expr: \"burst / rate\", unit: \"s\" },\n early: { expr: \"(burst - 1) / rate\", unit: \"s\" },\n arms: [\"take\"],\n fires: \"idle\",\n note: \"the bucket is full again from one to burst intervals after a permitted take. A refused take does not move it\",\n },\n },\n transitions: [\n t(\"first\", \"new\", \"take\", \"open\", { guard: \"burst > 1\", emits: [\"allow\"] }),\n t(\"first-last\", \"new\", \"take\", \"limited\", { guard: \"burst = 1\", emits: [\"allow\"] }),\n t(\"take\", \"open\", \"take\", \"open\", { guard: \"a token remains after the take\", emits: [\"allow\"] }),\n t(\"take-last\", \"open\", \"take\", \"limited\", { guard: \"the take uses the last token\", emits: [\"allow\"] }),\n ...COMMON_TRANSITIONS,\n t(\"token-back\", \"limited\", \"unit.back\", \"open\", { timer: \"token\", guard: \"burst > 1\" }),\n t(\"full\", \"open\", \"idle\", \"new\", { timer: \"full\" }),\n t(\"full-at-once\", \"limited\", \"idle\", \"new\", {\n timer: \"full\",\n guard: \"burst = 1: the one token is back when the bucket is full\",\n }),\n ],\n};\n\n/**\n * A sliding window of one key, as a `mark1russell7.machine/1` document. The store keeps the time of\n * each permitted take of the key in the window, and each take counts for one window from its time.\n * - A take is permitted when fewer than `limit` takes are in the window.\n * - A refused take waits until the oldest take leaves the window: at most one window. That window is\n * the longest `Retry-After` of the policy (`retry_after@limiter` of the law).\n * - One window after the last take, no take is in the window, and the key is equal to a new key.\n * That window is the idle span, and the longest wait of a new key at the cap of keys\n * (`retry_after_at_cap@limiter`).\n */\nexport const SLIDING_WINDOW_MACHINE: Machine = {\n ...COMMON,\n id: \"service.machine.limiter.sliding-window\",\n title: \"A sliding window of one key of the keyed limiter\",\n events: EVENTS,\n knobs: {\n limit: count(\"limiters.<name>.policy.limit\"),\n window: { key: \"limiters.<name>.policy.window\", scope: \"limiter\", unit: \"s\", default: null },\n ...KNOBS,\n },\n timers: {\n oldest: {\n duration: { knob: \"window\", unit: \"s\" },\n early: { knob: \"window\", unit: \"s\" },\n arms: [\"enter:limited\"],\n cancels: [\"idle\"],\n fires: \"unit.back\",\n note: \"the oldest take leaves the window at most one window after the take that filled it. The Retry-After of each refusal is the time to this firing\",\n },\n last: {\n duration: { knob: \"window\", unit: \"s\" },\n arms: [\"take\"],\n fires: \"idle\",\n note: \"the last permitted take leaves the window one window after its time. A refused take does not move it\",\n },\n },\n transitions: [\n t(\"first\", \"new\", \"take\", \"open\", { guard: \"limit > 1\", emits: [\"allow\"] }),\n t(\"first-last\", \"new\", \"take\", \"limited\", { guard: \"limit = 1\", emits: [\"allow\"] }),\n t(\"take\", \"open\", \"take\", \"open\", { guard: \"fewer than limit - 1 takes are in the window before the take\", emits: [\"allow\"] }),\n t(\"take-last\", \"open\", \"take\", \"limited\", { guard: \"the take is the limit-th take in the window\", emits: [\"allow\"] }),\n ...COMMON_TRANSITIONS,\n t(\"oldest-leaves\", \"limited\", \"unit.back\", \"open\", { timer: \"oldest\", guard: \"a later take stays in the window\" }),\n t(\"last-leaves\", \"open\", \"idle\", \"new\", { timer: \"last\" }),\n t(\"all-leave\", \"limited\", \"idle\", \"new\", {\n timer: \"last\",\n guard: \"each take of the window has one time: limit = 1, or each take came at once\",\n }),\n ],\n};\n\n/** This function gives the machine document of the policy of a limiter. */\nexport const limiterMachine = (policy: LimitPolicy): Machine =>\n policy.type === \"token-bucket\" ? TOKEN_BUCKET_MACHINE : SLIDING_WINDOW_MACHINE;\n\n/**\n * This function gives the state of a key of a store as its machine document names it, at the time\n * `now` in milliseconds. `state` is what the store keeps for the key, or `undefined` when it keeps\n * nothing. A key equal to a new key is `new`, and a key whose next take `decide` refuses is `limited`.\n */\nexport const limiterStateOf = (rule: LimitRule, state: KeyState | undefined, now: number): LimiterState => {\n if (state === undefined || freeAt(rule, state) <= now) return \"new\";\n return decide(rule, state, now).decision.allowed ? \"open\" : \"limited\";\n};\n"],"mappings":"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"}
|
package/build/limiter.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import { S as retryAfterSeconds, _ as freeAt, a as LimitRule, b as limiterProblems, c as LimiterSpec, d as Refused, f as SlidingWindow, g as decide, h as TokenBucket, i as LimitPolicy, l as LimiterStoreSpec, m as TakeStep, n as Decision, o as Limiter, p as StoreFailure, r as KeyState, s as LimiterEviction, t as Allowed, u as RefusalCause, v as idleSpan, x as longestRetryAfter, y as limitRule } from "./policy-G4QJ1ZZq.js";
|
|
2
2
|
import { a as keyedLimiter, i as TakeRequest, n as LimiterStore, o as memoryStore, r as MemoryStore, t as KeyedLimiter } from "./store-DzTyosou.js";
|
|
3
|
-
|
|
3
|
+
import { a as limiterStateOf, i as limiterMachine, n as SLIDING_WINDOW_MACHINE, r as TOKEN_BUCKET_MACHINE, t as LimiterState } from "./limiter-DCDdzVwe.js";
|
|
4
|
+
export { type Allowed, type Decision, type KeyState, type KeyedLimiter, type LimitPolicy, type LimitRule, type Limiter, type LimiterEviction, type LimiterSpec, type LimiterState, type LimiterStore, type LimiterStoreSpec, type MemoryStore, type RefusalCause, type Refused, SLIDING_WINDOW_MACHINE, type SlidingWindow, type StoreFailure, TOKEN_BUCKET_MACHINE, type TakeRequest, type TakeStep, type TokenBucket, decide, freeAt, idleSpan, keyedLimiter, limitRule, limiterMachine, limiterProblems, limiterStateOf, longestRetryAfter, memoryStore, retryAfterSeconds };
|
package/build/limiter.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { a as limiterProblems, i as limitRule, n as freeAt, o as longestRetryAfter, r as idleSpan, s as retryAfterSeconds, t as decide } from "./policy-CgS4y94U.js";
|
|
2
|
-
import { n as memoryStore, t as
|
|
3
|
-
export { decide, freeAt, idleSpan, keyedLimiter, limitRule, limiterProblems, longestRetryAfter, memoryStore, retryAfterSeconds };
|
|
2
|
+
import { a as keyedLimiter, i as limiterStateOf, n as TOKEN_BUCKET_MACHINE, o as memoryStore, r as limiterMachine, t as SLIDING_WINDOW_MACHINE } from "./limiter-pkBZUp-t.js";
|
|
3
|
+
export { SLIDING_WINDOW_MACHINE, TOKEN_BUCKET_MACHINE, decide, freeAt, idleSpan, keyedLimiter, limitRule, limiterMachine, limiterProblems, limiterStateOf, longestRetryAfter, memoryStore, retryAfterSeconds };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package",
|
|
3
3
|
"name": "@mark1russell7/service",
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.7",
|
|
5
5
|
"description": "A service declared as data before it starts, and its runtime: checks on jittered loops, a latch for each policy, probes from the cache, the startup in its sequence, and a staged, capped drain. The timer-ordering law checks the declaration. Its clock, heartbeat and backoff also work alone, in a browser too.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"service",
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
},
|
|
91
91
|
"devDependencies": {
|
|
92
92
|
"@mark1russell7/cue": "github:mark1russell7/cue",
|
|
93
|
-
"@mark1russell7/harness": "^0.11.
|
|
93
|
+
"@mark1russell7/harness": "^0.11.21",
|
|
94
94
|
"@types/node": "^26.6.4",
|
|
95
95
|
"ioredis": "6.0.0",
|
|
96
96
|
"testcontainers": "12.2.0",
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import { a as limiterProblems, i as limitRule, n as freeAt, o as longestRetryAfter, t as decide } from "./policy-CgS4y94U.js";
|
|
2
|
-
import { convertExact, rationals } from "@mark1russell7/quantity";
|
|
3
|
-
//#region src/limiter/store.ts
|
|
4
|
-
const ms = (value) => ({
|
|
5
|
-
value,
|
|
6
|
-
unit: "ms"
|
|
7
|
-
});
|
|
8
|
-
/**
|
|
9
|
-
* This function makes the store of one replica. It reads time only through the clock. Thus a step
|
|
10
|
-
* of the wall clock does not move a count, and a fake clock gives exact decisions in a test.
|
|
11
|
-
* - The eviction is exact. A key whose bucket is full again, or whose window has no take, is equal
|
|
12
|
-
* to a new key. The store removes it at its next take, or at a sweep.
|
|
13
|
-
* - The count of keys has a bound. At the cap, a take of a new key sweeps the table, but only when
|
|
14
|
-
* a held key can be free. Each sweep reads each key one time. When no key is free, the store
|
|
15
|
-
* refuses the new key with the cause `cap`. Its `retryAfter` is at most the time until the
|
|
16
|
-
* earliest held key is free, and that time is at most `idleSpan` of the policy.
|
|
17
|
-
*/
|
|
18
|
-
const memoryStore = (clock) => {
|
|
19
|
-
const tables = /* @__PURE__ */ new Map();
|
|
20
|
-
const sweep = (table, rule, now) => {
|
|
21
|
-
let next = Number.POSITIVE_INFINITY;
|
|
22
|
-
for (const [key, state] of table.states) {
|
|
23
|
-
const free = freeAt(rule, state);
|
|
24
|
-
if (free <= now) table.states.delete(key);
|
|
25
|
-
else next = Math.min(next, free);
|
|
26
|
-
}
|
|
27
|
-
table.next = next;
|
|
28
|
-
};
|
|
29
|
-
return {
|
|
30
|
-
kind: "local",
|
|
31
|
-
take({ limiter, key, rule, maxKeys }) {
|
|
32
|
-
const now = clock.now();
|
|
33
|
-
let table = tables.get(limiter);
|
|
34
|
-
if (table === void 0) {
|
|
35
|
-
table = {
|
|
36
|
-
states: /* @__PURE__ */ new Map(),
|
|
37
|
-
next: Number.POSITIVE_INFINITY
|
|
38
|
-
};
|
|
39
|
-
tables.set(limiter, table);
|
|
40
|
-
}
|
|
41
|
-
let state = table.states.get(key);
|
|
42
|
-
if (state !== void 0 && freeAt(rule, state) <= now) {
|
|
43
|
-
table.states.delete(key);
|
|
44
|
-
state = void 0;
|
|
45
|
-
}
|
|
46
|
-
if (state === void 0 && table.states.size >= maxKeys) {
|
|
47
|
-
if (table.next <= now) sweep(table, rule, now);
|
|
48
|
-
if (table.states.size >= maxKeys) return {
|
|
49
|
-
allowed: false,
|
|
50
|
-
retryAfter: ms(table.next - now),
|
|
51
|
-
cause: "cap"
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
const step = decide(rule, state, now);
|
|
55
|
-
if (state === void 0) {
|
|
56
|
-
table.states.set(key, step.state);
|
|
57
|
-
table.next = Math.min(table.next, freeAt(rule, step.state));
|
|
58
|
-
} else table.states.set(key, step.state);
|
|
59
|
-
return step.decision;
|
|
60
|
-
},
|
|
61
|
-
size(limiter) {
|
|
62
|
-
return tables.get(limiter)?.states.size ?? 0;
|
|
63
|
-
}
|
|
64
|
-
};
|
|
65
|
-
};
|
|
66
|
-
/** This function gives the text of a failure. */
|
|
67
|
-
const failureOf = (error) => error instanceof Error ? error.message : String(error);
|
|
68
|
-
/**
|
|
69
|
-
* This function makes the runtime of a limiter from its spec and its store. It refuses a spec with a
|
|
70
|
-
* problem, because the limiter has no defaults. It also refuses a store of the wrong kind: a spec
|
|
71
|
-
* with a shared store needs a store that the replicas share.
|
|
72
|
-
* - A failure of a shared store gives the decision of `onStoreFailure`. With `open`, the take is
|
|
73
|
-
* permitted with `remaining` 0. With `closed`, it is refused with the cause `store`.
|
|
74
|
-
* - The `retryAfter` of the cause `store` is the longest `retryAfter` of the policy.
|
|
75
|
-
* - A failure of a local store rejects the take, because a local store has no `onStoreFailure`.
|
|
76
|
-
*/
|
|
77
|
-
const keyedLimiter = (name, spec, store) => {
|
|
78
|
-
const problems = limiterProblems(name, spec);
|
|
79
|
-
if (problems.length > 0) throw new Error(problems.join("; "));
|
|
80
|
-
if (store.kind !== spec.store.kind) throw new Error(`limiter ${name} declares a ${spec.store.kind} store, but the store that it got is ${store.kind}`);
|
|
81
|
-
const rule = limitRule(spec.policy);
|
|
82
|
-
const maxKeys = rationals.toNumber(convertExact(rationals.fromNumber(spec.evict.maxKeys.value), spec.evict.maxKeys.unit, "count"));
|
|
83
|
-
const onStoreFailure = spec.store.kind === "shared" ? spec.store.onStoreFailure : void 0;
|
|
84
|
-
const closedAfter = longestRetryAfter(spec.policy);
|
|
85
|
-
const take = async (key) => {
|
|
86
|
-
const request = {
|
|
87
|
-
limiter: name,
|
|
88
|
-
key,
|
|
89
|
-
rule,
|
|
90
|
-
maxKeys
|
|
91
|
-
};
|
|
92
|
-
if (onStoreFailure === void 0) return store.take(request);
|
|
93
|
-
try {
|
|
94
|
-
return await store.take(request);
|
|
95
|
-
} catch (error) {
|
|
96
|
-
const failure = failureOf(error);
|
|
97
|
-
if (onStoreFailure === "open") return {
|
|
98
|
-
allowed: true,
|
|
99
|
-
remaining: 0,
|
|
100
|
-
failure
|
|
101
|
-
};
|
|
102
|
-
return {
|
|
103
|
-
allowed: false,
|
|
104
|
-
retryAfter: { ...closedAfter },
|
|
105
|
-
cause: "store",
|
|
106
|
-
failure
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
};
|
|
110
|
-
return {
|
|
111
|
-
name,
|
|
112
|
-
spec: {
|
|
113
|
-
key: spec.key,
|
|
114
|
-
policy: { ...spec.policy },
|
|
115
|
-
store: { ...spec.store },
|
|
116
|
-
evict: { maxKeys: { ...spec.evict.maxKeys } }
|
|
117
|
-
},
|
|
118
|
-
rule,
|
|
119
|
-
maxKeys,
|
|
120
|
-
take
|
|
121
|
-
};
|
|
122
|
-
};
|
|
123
|
-
//#endregion
|
|
124
|
-
export { memoryStore as n, keyedLimiter as t };
|
|
125
|
-
|
|
126
|
-
//# sourceMappingURL=limiter-wv0uXDk9.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"limiter-wv0uXDk9.js","names":["r"],"sources":["../src/limiter/store.ts"],"sourcesContent":["import type { Quantity } from \"@mark1russell7/quantity\";\nimport { convertExact, rationals as r } from \"@mark1russell7/quantity\";\nimport type { Clock } from \"../clock.ts\";\nimport type { Decision, KeyState, LimiterSpec, LimitRule } from \"./policy.ts\";\nimport { decide, freeAt, limitRule, limiterProblems, longestRetryAfter } from \"./policy.ts\";\n\n/** A take request is one take of one key, with the rule and the cap of its limiter in numbers. */\nexport interface TakeRequest {\n /** This name is the limiter. A store keeps the keys of each limiter apart. */\n readonly limiter: string;\n readonly key: string;\n readonly rule: LimitRule;\n /** This count is the most keys that the store holds for the limiter. */\n readonly maxKeys: number;\n}\n\n/**\n * A limiter store keeps the state of each key, and it decides each take in one atomic step, on its\n * own clock. Thus two takes of one key do not read the same state at any time. A store in this\n * process uses `decide`. A store that the replicas share does the same arithmetic in one step of its\n * own, for example one script of Redis that reads `TIME`.\n */\nexport interface LimiterStore {\n /** This kind tells where the counts are: in the memory of this replica, or in a store that the replicas share. */\n readonly kind: \"local\" | \"shared\";\n /**\n * This method decides one take. It removes a key only when its state is equal to the state of a\n * new key. It holds at most `maxKeys` keys for each limiter. A failure of a shared store rejects\n * the take, and the limiter then applies the `onStoreFailure` of its spec.\n */\n take(request: TakeRequest): Decision | Promise<Decision>;\n}\n\n/** A memory store is the store of one replica, on the injected monotonic clock. */\nexport interface MemoryStore extends LimiterStore {\n readonly kind: \"local\";\n /** This method gives the count of keys that the store holds for a limiter. */\n size(limiter: string): number;\n}\n\n/** A table is the keys of one limiter in a memory store. */\ninterface Table {\n readonly states: Map<string, KeyState>;\n /**\n * This time is at or before the earliest time at which a held key is equal to a new key. An\n * insert lowers it, and a sweep sets it again. A take of a held key only moves the time of that\n * key later, thus the bound stays true.\n */\n next: number;\n}\n\nconst ms = (value: number): Quantity => ({ value, unit: \"ms\" });\n\n/**\n * This function makes the store of one replica. It reads time only through the clock. Thus a step\n * of the wall clock does not move a count, and a fake clock gives exact decisions in a test.\n * - The eviction is exact. A key whose bucket is full again, or whose window has no take, is equal\n * to a new key. The store removes it at its next take, or at a sweep.\n * - The count of keys has a bound. At the cap, a take of a new key sweeps the table, but only when\n * a held key can be free. Each sweep reads each key one time. When no key is free, the store\n * refuses the new key with the cause `cap`. Its `retryAfter` is at most the time until the\n * earliest held key is free, and that time is at most `idleSpan` of the policy.\n */\nexport const memoryStore = (clock: Clock): MemoryStore => {\n const tables = new Map<string, Table>();\n const sweep = (table: Table, rule: LimitRule, now: number): void => {\n let next = Number.POSITIVE_INFINITY;\n for (const [key, state] of table.states) {\n const free = freeAt(rule, state);\n if (free <= now) table.states.delete(key);\n else next = Math.min(next, free);\n }\n table.next = next;\n };\n return {\n kind: \"local\",\n take({ limiter, key, rule, maxKeys }) {\n const now = clock.now();\n let table = tables.get(limiter);\n if (table === undefined) {\n table = { states: new Map(), next: Number.POSITIVE_INFINITY };\n tables.set(limiter, table);\n }\n let state = table.states.get(key);\n // a key that is equal to a new key goes: the decision is the same, and it frees its slot\n if (state !== undefined && freeAt(rule, state) <= now) {\n table.states.delete(key);\n state = undefined;\n }\n if (state === undefined && table.states.size >= maxKeys) {\n if (table.next <= now) sweep(table, rule, now);\n if (table.states.size >= maxKeys) return { allowed: false, retryAfter: ms(table.next - now), cause: \"cap\" };\n }\n const step = decide(rule, state, now);\n if (state === undefined) {\n // a new key is never refused by its policy: a full bucket, or an empty window\n table.states.set(key, step.state);\n table.next = Math.min(table.next, freeAt(rule, step.state));\n } else table.states.set(key, step.state);\n return step.decision;\n },\n size(limiter) {\n return tables.get(limiter)?.states.size ?? 0;\n },\n };\n};\n\n/** A keyed limiter is the runtime of one declared limiter: its spec, its rule in numbers, and its store. */\nexport interface KeyedLimiter {\n readonly name: string;\n /** This spec is the declaration, the same data that `svc.limiter(name, spec)` takes and `describe()` shows. */\n readonly spec: LimiterSpec;\n readonly rule: LimitRule;\n /** This count is the most keys that the store holds for the limiter. */\n readonly maxKeys: number;\n /**\n * This method takes one unit for a key, and gives the decision of the store. When a shared store\n * fails, it gives the decision of `onStoreFailure`, with the failure as text.\n */\n take(key: string): Promise<Decision>;\n}\n\n/** This function gives the text of a failure. */\nconst failureOf = (error: unknown): string => (error instanceof Error ? error.message : String(error));\n\n/**\n * This function makes the runtime of a limiter from its spec and its store. It refuses a spec with a\n * problem, because the limiter has no defaults. It also refuses a store of the wrong kind: a spec\n * with a shared store needs a store that the replicas share.\n * - A failure of a shared store gives the decision of `onStoreFailure`. With `open`, the take is\n * permitted with `remaining` 0. With `closed`, it is refused with the cause `store`.\n * - The `retryAfter` of the cause `store` is the longest `retryAfter` of the policy.\n * - A failure of a local store rejects the take, because a local store has no `onStoreFailure`.\n */\nexport const keyedLimiter = (name: string, spec: LimiterSpec, store: LimiterStore): KeyedLimiter => {\n const problems = limiterProblems(name, spec);\n if (problems.length > 0) throw new Error(problems.join(\"; \"));\n if (store.kind !== spec.store.kind)\n throw new Error(`limiter ${name} declares a ${spec.store.kind} store, but the store that it got is ${store.kind}`);\n const rule = limitRule(spec.policy);\n const maxKeys = r.toNumber(convertExact(r.fromNumber(spec.evict.maxKeys.value), spec.evict.maxKeys.unit, \"count\"));\n const onStoreFailure = spec.store.kind === \"shared\" ? spec.store.onStoreFailure : undefined;\n const closedAfter = longestRetryAfter(spec.policy);\n const take = async (key: string): Promise<Decision> => {\n const request: TakeRequest = { limiter: name, key, rule, maxKeys };\n if (onStoreFailure === undefined) return store.take(request);\n try {\n return await store.take(request);\n } catch (error) {\n const failure = failureOf(error);\n if (onStoreFailure === \"open\") return { allowed: true, remaining: 0, failure };\n return { allowed: false, retryAfter: { ...closedAfter }, cause: \"store\", failure };\n }\n };\n return {\n name,\n spec: {\n key: spec.key,\n policy: { ...spec.policy },\n store: { ...spec.store },\n evict: { maxKeys: { ...spec.evict.maxKeys } },\n },\n rule,\n maxKeys,\n take,\n };\n};\n"],"mappings":"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"}
|