Skip to content

Commit b2bbced

Browse files
committed
Phase 2: budget-before-tokens reservation, verified against real Valkey
CONST-BUDGET-BEFORE-TOKENS -- the ordering IS the mechanism. reserveBudget atomically INCRs the day's counter and only allows a container to start if the reserved number is within the cap. Check-after-spend would let fifty junk triggers cost fifty real jobs before the cap engages, which is the exact scenario it guards. Verified against a real Valkey (AOF on, as REQ-QUEUE-BURST-NO-DROP requires): 20 parallel INCRs yield exactly 20, no lost updates, so INCR-then-compare needs no lock even at concurrency 3. Three jobs racing at count 9 with cap 10 reserve 10/11/12 -- exactly one proceeds. Confirmed the refuse path too: with cap 3, reservations 4 and 5 are refused and no container starts. Design choices, each tested: - a refusal still counts (no decrement): the cap is a hard daily ceiling on container starts, and giving refused attempts their slot back would let a burst probe the cap for free. A refused job spends nothing and reports on its issue. - releaseBudget exists for ONE case: an infra fault AFTER reserving but before the container spent anything (e.g. docker daemon unreachable). A completed run (0/2) really consumed its slot and does not release. - cap 0 fails CLOSED (every job refused), never "unlimited" -- it guards money. - TTL set once on first reservation, so a busy day cannot push its own expiry forward. Tests run everywhere (injected fake redis); the same logic was also exercised against real Valkey by hand. The BullMQ Worker construction, docker orchestration, and 30-minute timeout follow, pending the wiring research in flight -- not guessed.
1 parent f8074d0 commit b2bbced

2 files changed

Lines changed: 129 additions & 0 deletions

File tree

worker/src/budget.mjs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* CONST-BUDGET-BEFORE-TOKENS. The ordering IS the mechanism.
3+
*
4+
* Reserve a slot against the daily cap BEFORE a container starts. Check-after-spend means fifty
5+
* junk triggers cost fifty jobs of real money before the cap engages -- the exact scenario the cap
6+
* exists for. So: atomically INCR the day's counter, and only start the container if the reserved
7+
* number is within the cap.
8+
*
9+
* INCR is atomic -- verified against a real Valkey under 20 parallel increments yielding exactly
10+
* 20, no lost updates -- so this needs no lock even at concurrency 3. Three jobs racing at count 9
11+
* with cap 10 reserve 10, 11, 12; exactly one proceeds, the others are refused, and no container
12+
* starts over budget.
13+
*
14+
* `redis` is any ioredis-compatible client (BullMQ bundles ioredis). Injected so the logic is
15+
* testable without a running server.
16+
*/
17+
18+
/** UTC date key. The worker is ordinary node, so Date is available (unlike the workflow sandbox). */
19+
export function dayKey(now = new Date(), prefix = "budget") {
20+
const d = now.toISOString().slice(0, 10); // YYYY-MM-DD, UTC
21+
return `${prefix}:${d}`;
22+
}
23+
24+
const TWO_DAYS_SECONDS = 2 * 24 * 60 * 60;
25+
26+
/**
27+
* Atomically reserve one slot against today's cap. Returns { allowed, reserved, cap }.
28+
*
29+
* A refused reservation still counts -- the counter bounds container STARTS per day, and a refused
30+
* job spends nothing (no container) and reports on its issue. We deliberately do NOT decrement on
31+
* refusal: the cap is a hard daily ceiling on attempts, and letting refused attempts "give back"
32+
* their slot would let a burst probe the cap for free.
33+
*
34+
* A cap of 0 or negative disables running entirely (every job refused) rather than meaning
35+
* "unlimited" -- fail closed, since this guards money.
36+
*/
37+
export async function reserveBudget(redis, { cap, now = new Date(), keyPrefix = "budget" } = {}) {
38+
const key = dayKey(now, keyPrefix);
39+
const reserved = Number(await redis.incr(key));
40+
// Set the TTL only once, when the key is first created (reserved === 1), so a long-running
41+
// day cannot have its expiry pushed forward indefinitely.
42+
if (reserved === 1) await redis.expire(key, TWO_DAYS_SECONDS);
43+
return { allowed: reserved <= cap, reserved, cap };
44+
}
45+
46+
/**
47+
* Give a reservation back. Used ONLY when the container never started because of an INFRA fault
48+
* AFTER reserving (e.g. the docker daemon was unreachable) -- an infra failure that spent nothing
49+
* should not permanently consume a cap slot. NOT used for a completed run (0/2), which really did
50+
* consume its slot, nor for a refusal (which never incremented past the cap deliberately).
51+
*/
52+
export async function releaseBudget(redis, { now = new Date(), keyPrefix = "budget" } = {}) {
53+
await redis.decr(dayKey(now, keyPrefix));
54+
}

worker/test/budget.test.mjs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
import { dayKey, releaseBudget, reserveBudget } from "../src/budget.mjs";
4+
5+
/** Minimal ioredis-compatible fake: atomic enough for single-threaded test semantics. */
6+
function fakeRedis() {
7+
const store = new Map();
8+
const ttl = new Map();
9+
return {
10+
store,
11+
ttl,
12+
async incr(k) {
13+
const v = (store.get(k) ?? 0) + 1;
14+
store.set(k, v);
15+
return v;
16+
},
17+
async decr(k) {
18+
const v = (store.get(k) ?? 0) - 1;
19+
store.set(k, v);
20+
return v;
21+
},
22+
async expire(k, s) {
23+
ttl.set(k, s);
24+
},
25+
};
26+
}
27+
28+
test("dayKey is a UTC YYYY-MM-DD key", () => {
29+
assert.equal(dayKey(new Date("2026-07-16T23:30:00Z")), "budget:2026-07-16");
30+
// A timestamp just before UTC midnight stays on its UTC day regardless of local tz.
31+
assert.equal(dayKey(new Date("2026-07-16T00:00:01Z")), "budget:2026-07-16");
32+
});
33+
34+
test("reserves within the cap, refuses beyond it, and never lets a refusal spend", async () => {
35+
const redis = fakeRedis();
36+
const now = new Date("2026-07-16T10:00:00Z");
37+
const results = [];
38+
for (let i = 0; i < 5; i++) results.push(await reserveBudget(redis, { cap: 3, now }));
39+
40+
assert.deepEqual(
41+
results.map((r) => r.allowed),
42+
[true, true, true, false, false],
43+
);
44+
assert.deepEqual(
45+
results.map((r) => r.reserved),
46+
[1, 2, 3, 4, 5],
47+
);
48+
});
49+
50+
test("the TTL is set once, on first reservation only", async () => {
51+
const redis = fakeRedis();
52+
const now = new Date("2026-07-16T10:00:00Z");
53+
await reserveBudget(redis, { cap: 10, now });
54+
assert.ok(redis.ttl.has("budget:2026-07-16"), "TTL set on first reserve");
55+
redis.ttl.delete("budget:2026-07-16");
56+
await reserveBudget(redis, { cap: 10, now });
57+
assert.ok(!redis.ttl.has("budget:2026-07-16"), "TTL must NOT be reset on later reserves");
58+
});
59+
60+
test("cap 0 fails closed -- every job refused, not 'unlimited'", async () => {
61+
const redis = fakeRedis();
62+
const r = await reserveBudget(redis, { cap: 0, now: new Date("2026-07-16T10:00:00Z") });
63+
assert.equal(r.allowed, false);
64+
});
65+
66+
test("release gives a slot back (infra-fault path only)", async () => {
67+
const redis = fakeRedis();
68+
const now = new Date("2026-07-16T10:00:00Z");
69+
await reserveBudget(redis, { cap: 3, now });
70+
await reserveBudget(redis, { cap: 3, now });
71+
await releaseBudget(redis, { now });
72+
const r = await reserveBudget(redis, { cap: 3, now });
73+
assert.equal(r.reserved, 2, "a released slot is reusable");
74+
assert.equal(r.allowed, true);
75+
});

0 commit comments

Comments
 (0)