feat(sites): cross-process site rate limit via PG token bucket

Migration 0041_rate_limit adds rate_limit_bucket. PgTokenBucket (@umt/db)
refills and takes tokens under SELECT ... FOR UPDATE on the DB clock and
polls with jitter within a wait budget; penalize() carries Retry-After.
Site adapters get createSharedSiteRateLimiter (key site:<id>), shared by
api / worker / worker:agent, with an in-process serial queue in front and
a local fallback when the bucket is unavailable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 21:51:32 +08:00
1 parent 57346d438c
commit 61f585a707
12 files changed
+466 -5

No files matched your search

+78
View File
@@ -93,3 +93,81 @@ export function createSiteRateLimiter(opts: RateLimiterOptions): SiteRateLimiter
},
};
}
/** 跨进程令牌桶端口(@umt/db 的 PgTokenBucket 结构上满足;adapters 不依赖 db 包) */
export interface SharedBucketPort {
acquire(
key: string,
cost: number,
opts: { capacity: number; refillPerSec: number; waitMs?: number; signal?: AbortSignal },
): Promise<{ ok: boolean; waitedMs: number }>;
penalize(key: string, ms: number, spec: { capacity: number; refillPerSec: number }): Promise<void>;
}
export interface SharedRateLimiterOptions extends RateLimiterOptions {
/** 桶 key,站点用 `site:<id>` */
key: string;
bucket: SharedBucketPort;
/** 单轮等待预算,缺省 60s;超出后重新排队(直到 signal 中止) */
waitMs?: number;
/** 共享桶出错(DB 不可达等)时回调;本次退回进程内令牌桶,不让站点请求失败 */
onError?: (err: unknown) => void;
}
/**
* 跨进程站点限流器:进程内先串行排队(同进程并发不去 PG 抢锁),再到共享桶扣令牌;
* 429 Retry-After 同时顺延本地与共享桶。minIntervalMs = 0(不限流)时只走本地。
*/
export function createSharedSiteRateLimiter(opts: SharedRateLimiterOptions): SiteRateLimiter {
const local = createSiteRateLimiter(opts);
const interval = Math.max(0, opts.minIntervalMs);
const spec = { capacity: Math.max(1, opts.burst ?? 1), refillPerSec: interval > 0 ? 1000 / interval : 0 };
const stats = { granted: 0, waitedMs: 0 };
let queue: Promise<void> = Promise.resolve();
const onError = opts.onError ?? (() => {});
const viaLocal = async (signal?: AbortSignal) => {
const before = local.stats.waitedMs;
await local.acquire(signal);
stats.waitedMs += local.stats.waitedMs - before;
};
return {
stats,
tryAcquire() {
if (!local.tryAcquire()) return false;
stats.granted++;
return true;
},
penalize(ms) {
local.penalize(ms);
if (interval > 0) opts.bucket.penalize(opts.key, ms, spec).catch(onError);
},
acquire(signal) {
const run = queue.then(async () => {
if (interval === 0) {
await viaLocal(signal);
} else {
try {
for (;;) {
const r = await opts.bucket.acquire(opts.key, 1, {
...spec,
waitMs: opts.waitMs ?? 60_000,
...(signal ? { signal } : {}),
});
stats.waitedMs += r.waitedMs;
if (r.ok) break;
}
} catch (e) {
if (signal?.aborted) throw e;
onError(e);
await viaLocal(signal);
}
}
stats.granted++;
});
queue = run.catch(() => {});
return run;
},
};
}