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

@@ -131,6 +131,19 @@ describe.skipIf(!up)("来源管理与站点工作台 API", () => {
expect(observed.length).toBe(r.newCount);
const site = await json(await h.req(`/sites/${siteId}`));
expect(site.body.data.lastSnapshotAt).not.toBeNull();
// 限流间隔 > 0 时令牌在 PG 跨进程共享(rate_limit_bucket,key = site:<id>);0 = 不限流只走本地
await h.db
.updateTable("site")
.set({ rate_limit: JSON.stringify({ minIntervalMs: 5, concurrency: 5 }) })
.where("id", "=", siteId)
.execute();
expect((await snapshotSite(h.rt, { siteId, kind: "manual" })).status).toBe("ok");
const bucket = await h.db
.selectFrom("rate_limit_bucket")
.selectAll()
.where("key", "=", `site:${siteId}`)
.executeTakeFirst();
expect(bucket).toMatchObject({ capacity: 5, refill_per_sec: 200 });
});
it("工作台列表:快照优先,标注来源与更新时间;筛选与分页", async () => {