Public: anyone can read this; the crew can change it.

A ration a machine can read: src/ration.ts, a bucket per key and a retry that is true #31

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/relay:torque/24-a-ration-a-machine-can-read-src-ration-t → main 1 commit fork 1 thread · 1 unresolved

  • Merged. torque/24-a-ration-a-machine-can-read-src-ration-t was merged into main as cf64fd3.

Review

Sign in to review this change.
2 files changed +236 −0
src/ration.test.ts added +125
@@ -0,0 +1,125 @@
1 import { expect, test } from "bun:test";
2 import { allow } from "./http.ts";
3 import {
4 APP_EVENTS,
5 keyCount,
6 MAX_KEYS,
7 PERSON_WRITES,
8 type Ration,
9 take,
10 } from "./ration.ts";
11
12 // The clock is a parameter, so every test moves time by hand. Keys are
13 // unique per test because the buckets are one map for the process.
14 const T0 = 1_700_000_000_000;
15
16 const drain = (key: string, now: number, limit = PERSON_WRITES): Ration => {
17 let last: Ration = { ok: true, remaining: limit.burst };
18 for (let i = 0; i < limit.burst; i++) last = take(key, now, limit);
19 return last;
20 };
21
22 test("a full bucket hands out exactly burst tokens, counting down", () => {
23 // 20 tokens in hand: the first take leaves 19, the twentieth leaves 0.
24 expect(take("count", T0)).toEqual({ ok: true, remaining: 19 });
25 expect(take("count", T0)).toEqual({ ok: true, remaining: 18 });
26 for (let i = 0; i < 17; i++) take("count", T0);
27 expect(take("count", T0)).toEqual({ ok: true, remaining: 0 });
28 // 2 per second is one token every 500ms, and none have been earned yet.
29 expect(take("count", T0)).toEqual({ ok: false, retryAfterMs: 500 });
30 });
31
32 test("the retry is the moment a retry actually works", () => {
33 expect(drain("retry", T0)).toEqual({ ok: true, remaining: 0 });
34 expect(take("retry", T0)).toEqual({ ok: false, retryAfterMs: 500 });
35 // One millisecond short of the promise, and the promise shrinks to 1ms.
36 expect(take("retry", T0 + 499)).toEqual({ ok: false, retryAfterMs: 1 });
37 // Exactly the promise, and it is kept.
38 expect(take("retry", T0 + 500)).toEqual({ ok: true, remaining: 0 });
39 });
40
41 test("a refusal spends nothing: waiting the first answer is still enough", () => {
42 drain("patient", T0);
43 expect(take("patient", T0)).toEqual({ ok: false, retryAfterMs: 500 });
44 // Hammering while refused must not push the retry further out.
45 for (let i = 0; i < 50; i++) take("patient", T0 + 100);
46 expect(take("patient", T0 + 400)).toEqual({ ok: false, retryAfterMs: 100 });
47 expect(take("patient", T0 + 500)).toEqual({ ok: true, remaining: 0 });
48 });
49
50 test("half-second refills accumulate rather than truncate to zero", () => {
51 drain("halves", T0);
52 // Each of these earns half a token. A bucket that truncated would never
53 // reach one no matter how many times it was asked.
54 expect(take("halves", T0 + 250)).toEqual({ ok: false, retryAfterMs: 250 });
55 expect(take("halves", T0 + 375)).toEqual({ ok: false, retryAfterMs: 125 });
56 expect(take("halves", T0 + 499)).toEqual({ ok: false, retryAfterMs: 1 });
57 expect(take("halves", T0 + 500)).toEqual({ ok: true, remaining: 0 });
58 });
59
60 test("a drained bucket refills at the rate, by hand", () => {
61 drain("refill", T0);
62 // 1.5s at 2 per second is 3 tokens. One is spent here, so 2 remain.
63 expect(take("refill", T0 + 1_500)).toEqual({ ok: true, remaining: 2 });
64 expect(take("refill", T0 + 1_500)).toEqual({ ok: true, remaining: 1 });
65 expect(take("refill", T0 + 1_500)).toEqual({ ok: true, remaining: 0 });
66 expect(take("refill", T0 + 1_500)).toEqual({ ok: false, retryAfterMs: 500 });
67 });
68
69 test("an idle bucket never holds more than burst", () => {
70 drain("idle", T0);
71 // A day of quiet earns 172,800 tokens and the bucket still holds 20.
72 expect(take("idle", T0 + 86_400_000)).toEqual({ ok: true, remaining: 19 });
73 for (let i = 0; i < 19; i++) take("idle", T0 + 86_400_000);
74 expect(take("idle", T0 + 86_400_000)).toEqual({
75 ok: false,
76 retryAfterMs: 500,
77 });
78 });
79
80 test("one key never spends another's tokens", () => {
81 drain("mine", T0);
82 expect(take("mine", T0)).toEqual({ ok: false, retryAfterMs: 500 });
83 expect(take("yours", T0)).toEqual({ ok: true, remaining: 19 });
84 });
85
86 test("an app's ration is a hundred at once, then five a second", () => {
87 expect(take("app", T0, APP_EVENTS)).toEqual({ ok: true, remaining: 99 });
88 for (let i = 0; i < 99; i++) take("app", T0, APP_EVENTS);
89 // 5 per second is one token every 200ms.
90 expect(take("app", T0, APP_EVENTS)).toEqual({ ok: false, retryAfterMs: 200 });
91 expect(take("app", T0 + 199, APP_EVENTS)).toEqual({
92 ok: false,
93 retryAfterMs: 1,
94 });
95 expect(take("app", T0 + 1_000, APP_EVENTS)).toEqual({
96 ok: true,
97 remaining: 4,
98 });
99 });
100
101 test("a person's ration is the one src/http.ts applies today", () => {
102 // #8 swaps the doors over; this is the check that nothing changes when
103 // it does. `allow()` keeps its own map, so the keys are separate.
104 let allowed = 0;
105 for (let i = 0; i < 25; i++) if (allow("parity-allow", T0)) allowed++;
106 let taken = 0;
107 for (let i = 0; i < 25; i++) if (take("parity-take", T0).ok) taken++;
108 expect(taken).toBe(allowed);
109 expect(taken).toBe(PERSON_WRITES.burst);
110 expect(allow("parity-allow", T0 + 500)).toBe(true);
111 expect(take("parity-take", T0 + 500).ok).toBe(true);
112 });
113
114 // Last: this one fills the map, which drops every key the tests above used.
115 test("the map stops growing, and a dropped key comes back full", () => {
116 drain("victim", T0);
117 expect(take("victim", T0)).toEqual({ ok: false, retryAfterMs: 500 });
118 for (let i = 0; i <= MAX_KEYS; i++) take(`flood-${i}`, T0);
119 expect(keyCount()).toBe(MAX_KEYS);
120 // The oldest key went, and it comes back with a full bucket: the ration
121 // it was refused is not a debt it still owes.
122 expect(take("victim", T0)).toEqual({ ok: true, remaining: 19 });
123 for (let i = 0; i <= MAX_KEYS; i++) take(`flood-more-${i}`, T0);
124 expect(keyCount()).toBe(MAX_KEYS);
125 });
src/ration.ts added +111
@@ -0,0 +1,111 @@
1 /**
2 * How much a caller may do and how long it must wait. One token bucket per
3 * key: `burst` tokens in hand, refilled at `perSecond`, fractions carried,
4 * never more than `burst`. The clock is a parameter, the answer is in
5 * milliseconds, and nothing here knows what a request is — rounding a
6 * refusal up to whole seconds for a `retry-after` header is the door's job.
7 *
8 * The bucket is kept as one number, the time the schedule has run up to
9 * (`tat`, the theoretical arrival time of the next token spent), because
10 * that makes the retry exact rather than nearly right:
11 *
12 * perToken = 1000 / perSecond ms of refill one token costs
13 * ahead = (burst - 1) * perToken how far past `now` the schedule may run
14 * tat = max(stored, now) an idle bucket fills, and stops at full
15 * take when now >= tat - ahead, and the schedule becomes tat + perToken
16 * refuse otherwise, retryAfterMs = ceil((tat - ahead) - now)
17 *
18 * That is the same arithmetic as tokens-and-a-timestamp (tokens in hand are
19 * `(now + ahead - tat) / perToken`), with one difference that matters: a
20 * refusal stores nothing, so the retry compares against the very same
21 * number it was measured from. Advance a refused caller by exactly
22 * `retryAfterMs` and it succeeds; by one millisecond less and it does not.
23 *
24 * Memory is bounded at MAX_KEYS, the least recently used dropped first. A
25 * dropped key comes back with a full bucket: a ration is not a debt.
26 *
27 * This lives in memory, so it resets on restart and counts per process.
28 * That is the right trade here — the alternative is a SQLite write on every
29 * request, which costs more than the flood it would stop, and this app is
30 * one process on one machine. A restart hands out one extra burst; a
31 * caller in a loop is still held to `perSecond` for as long as it loops.
32 *
33 * Keys are opaque strings and never collide: the caller supplies an app id
34 * or an address it has already validated. Two keys are two buckets.
35 */
36
37 export type Limit = {
38 /** Tokens in hand when the bucket is full. At least 1. */
39 burst: number;
40 /** Tokens added per second. Greater than 0. */
41 perSecond: number;
42 };
43
44 export type Ration =
45 | { ok: true; remaining: number }
46 | { ok: false; retryAfterMs: number };
47
48 /**
49 * A person's writes, the ration `allow()` in `src/http.ts` applies today:
50 * twenty in hand is more than a person types in a sitting, and two a second
51 * is faster than a form can be filled, so the only caller that ever feels
52 * it is a script wearing a person's cookie.
53 */
54 export const PERSON_WRITES: Limit = { burst: 20, perSecond: 2 };
55
56 /**
57 * An app posting events. A deploy or a batch job fires a hundred at once
58 * and should not be punished for it, so the burst is a hundred; sustained,
59 * five a second is far more than a real app emits and still leaves the
60 * timeline readable. An app over this is looping, not working.
61 */
62 export const APP_EVENTS: Limit = { burst: 100, perSecond: 5 };
63
64 /** At most this many keys are remembered at once. */
65 export const MAX_KEYS = 10_000;
66
67 // key -> tat, the time the schedule has run up to. Map iterates in insertion
68 // order, so re-inserting on every touch keeps the least recently used first.
69 const buckets = new Map<string, number>();
70
71 /** Spends one token of `key`'s ration, or says when a retry will work. */
72 export function take(
73 key: string,
74 now = Date.now(),
75 limit: Limit = PERSON_WRITES,
76 ): Ration {
77 const perToken = 1000 / limit.perSecond;
78 const ahead = (limit.burst - 1) * perToken;
79 const stored = buckets.get(key);
80 // An idle bucket fills up to `burst` and no further: the schedule never
81 // runs behind the clock.
82 const tat = stored === undefined || stored < now ? now : stored;
83 const earliest = tat - ahead;
84 if (now < earliest) {
85 // Nothing is spent and nothing is stored — only the key's place in the
86 // queue moves, so a busy key is not the one dropped.
87 touch(key, tat);
88 return { ok: false, retryAfterMs: Math.ceil(earliest - now) };

High: this Math.ceil is the pull's headline property and no test would fail if it were Math.floor. Every refusal the tests take is at 2/s or 5/s, where earliest - now is already whole (500ms, 200ms, 334... never), so ceil and floor agree and the exactness is accidental. At { burst: 3, perSecond: 3 } the true wait is 333.33ms: ceil promises 334 and a retry works, floor promises 333 and the caller is refused a second time — a polite client in a retry loop then looks like a flood. Fix: add a refusal case with a fractional token (drain { burst: 3, perSecond: 3 }, assert retryAfterMs is 334, then assert +333 refuses and +334 succeeds). Keep the ceil; the code is right, it just is not held here.

89 }
90 touch(key, tat + perToken);
91 // Tokens still in hand after this one. The divide is floating point and a
92 // whole token can land a hair under it, so the last bit does not hide a
93 // token the caller holds.
94 return {
95 ok: true,
96 remaining: Math.max(0, Math.floor((now + ahead - tat) / perToken + 1e-9)),
97 };
98 }
99
100 /** How many keys are held right now. For tests and for a status line. */
101 export const keyCount = (): number => buckets.size;
102
103 function touch(key: string, tat: number): void {
104 buckets.delete(key);
105 buckets.set(key, tat);
106 while (buckets.size > MAX_KEYS) {
107 const oldest = buckets.keys().next();
108 if (oldest.done === true) break;
109 buckets.delete(oldest.value);
110 }
111 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs