Skip to content

Commit 68fca77

Browse files
committed
Bound the workflow-artifact rate limiter's per-run map
createRunCreateRateLimiter closed over a plain Map<string, number[]> keyed by workflow run id, created once per hub process and never pruned — every finished run's entry stayed resident for the rest of the process's uptime. Route it through @corbits/collections' createExpiringMap (CL-7233) with a TTL equal to RATE_WINDOW_MS, so an idle run's entry is reclaimed instead of accumulating forever. The TTL can't let a caller exceed the rate: every allow() call re-sets the entry, refreshing its expiry, so an entry only lapses after a full window with no calls for that run — by which point every timestamp it held has already aged out of the sliding window's own cutoff filter. A shorter TTL could evict an entry (and its still-in-window timestamps) before the window's own filter would have dropped them, resetting a caller's quota early; RATE_WINDOW_MS can't.
1 parent ba2f88f commit 68fca77

3 files changed

Lines changed: 35 additions & 7 deletions

File tree

‎bun.lock‎

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/artifacts-hub/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"dependencies": {
1616
"@corbits/artifact-ui": "workspace:*",
1717
"@corbits/artifacts": "github:corbitsdev/corbits-artifacts#81049ed24a64e927498c7238bda6ffa66b63d2ab",
18+
"@corbits/collections": "workspace:*",
1819
"@corbits/folded-runs": "workspace:*",
1920
"@intx/crypto": "0.3.0",
2021
"@intx/db": "workspace:*",

‎packages/artifacts-hub/src/workflow-routes.ts‎

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
* itself never holds a database handle.
2323
*/
2424
import { type } from "arktype";
25+
import { createExpiringMap } from "@corbits/collections";
2526
import {
2627
ARTIFACT_UPLOAD_POLICY,
2728
anonymousIdentity,
@@ -58,8 +59,8 @@ const MAX_ARTIFACT_CONTENT_CHARS = 64_000;
5859
// A finalized turn can legitimately persist a handful of artifacts in
5960
// one burst; 30/minute per run comfortably covers that while still
6061
// catching a runaway loop before it floods Library storage.
61-
const MAX_CREATES_PER_RUN_PER_MINUTE = 30;
62-
const RATE_WINDOW_MS = 60_000;
62+
export const MAX_CREATES_PER_RUN_PER_MINUTE = 30;
63+
export const RATE_WINDOW_MS = 60_000;
6364

6465
// Same per-file ceiling the tenant Library's own `POST /upload` enforces
6566
// (`MAX_UPLOAD_BYTES`) — one number for "how big a file artifact may be"
@@ -80,24 +81,45 @@ export const MAX_WORKFLOW_BINARY_BYTES = MAX_UPLOAD_BYTES;
8081
* only what it personally handled — a known fail-open gap, not a
8182
* fail-closed one, so it under-limits rather than wrongly rejecting a
8283
* caller a sibling replica hasn't seen yet.
84+
*
85+
* The per-run entry lives in a `createExpiringMap` (CL-7243) rather than
86+
* a plain `Map`, so a run's entry is reclaimed once it goes idle instead
87+
* of staying resident for the rest of the hub process's uptime. The TTL
88+
* is exactly `RATE_WINDOW_MS`: every `allow()` call re-`set`s the entry,
89+
* refreshing its expiry, so an entry can only lapse after a full window
90+
* with no calls for that run — by which point every timestamp it held
91+
* has already aged out of the sliding window's own `cutoff` filter below.
92+
* A shorter TTL could evict an entry (and thus its still-in-window
93+
* timestamps) before the window's filter would have dropped them,
94+
* silently resetting a caller's quota early; this TTL can't.
8395
*/
84-
function createRunCreateRateLimiter(maxPerWindow: number) {
85-
const timestampsByRunId = new Map<string, number[]>();
96+
export function createRunCreateRateLimiter(
97+
maxPerWindow: number,
98+
now: () => number = Date.now,
99+
) {
100+
const timestampsByRunId = createExpiringMap<string, number[]>({
101+
ttlMs: RATE_WINDOW_MS,
102+
now,
103+
});
86104
return {
87105
allow(runId: string): boolean {
88-
const now = Date.now();
89-
const cutoff = now - RATE_WINDOW_MS;
106+
const at = now();
107+
const cutoff = at - RATE_WINDOW_MS;
90108
const recent = (timestampsByRunId.get(runId) ?? []).filter(
91109
(timestamp) => timestamp > cutoff,
92110
);
93111
if (recent.length >= maxPerWindow) {
94112
timestampsByRunId.set(runId, recent);
95113
return false;
96114
}
97-
recent.push(now);
115+
recent.push(at);
98116
timestampsByRunId.set(runId, recent);
99117
return true;
100118
},
119+
/** Live entry count, for tests asserting the map stays bounded. */
120+
get trackedRunCount(): number {
121+
return timestampsByRunId.size;
122+
},
101123
};
102124
}
103125

0 commit comments

Comments
 (0)