From 639feaa1059dc02689f386f95bda5b9c4e2064a8 Mon Sep 17 00:00:00 2001 From: andriypolandki Date: Sat, 11 Jul 2026 00:11:03 -0700 Subject: [PATCH] feat(miner-governor): wire rate-limit + jittered backoff into live write enforcement (#2344) --- .../src/governor/write-rate-limit.ts | 219 ++++++++++++++ packages/gittensory-engine/src/index.ts | 1 + .../test/write-rate-limit-enforcement.test.ts | 49 +++ .../lib/governor-write-rate-limit.d.ts | 30 ++ .../lib/governor-write-rate-limit.js | 64 ++++ packages/gittensory-miner/package.json | 2 +- test/unit/governor-write-rate-limit.test.ts | 279 ++++++++++++++++++ .../miner-governor-write-rate-limit.test.ts | 94 ++++++ 8 files changed, 737 insertions(+), 1 deletion(-) create mode 100644 packages/gittensory-engine/src/governor/write-rate-limit.ts create mode 100644 packages/gittensory-engine/test/write-rate-limit-enforcement.test.ts create mode 100644 packages/gittensory-miner/lib/governor-write-rate-limit.d.ts create mode 100644 packages/gittensory-miner/lib/governor-write-rate-limit.js create mode 100644 test/unit/governor-write-rate-limit.test.ts create mode 100644 test/unit/miner-governor-write-rate-limit.test.ts diff --git a/packages/gittensory-engine/src/governor/write-rate-limit.ts b/packages/gittensory-engine/src/governor/write-rate-limit.ts new file mode 100644 index 0000000000..62fea91a28 --- /dev/null +++ b/packages/gittensory-engine/src/governor/write-rate-limit.ts @@ -0,0 +1,219 @@ +// Governor write-rate-limit enforcement (#2344): composes the pure `evaluateLocalRateLimit` calculator with +// global + per-repo buckets and jittered retry scheduling for the local Governor chokepoint. Maintains bucket +// math only — callers own persistence/scheduling; this module returns updated in-memory state snapshots. + +import type { GovernorLedgerEvent } from "../governor-ledger.js"; +import { + evaluateLocalRateLimit, + jitteredBackoffMs, + type LocalRateBucket, + type LocalRateLimitConfig, + type LocalRateLimitDecision, +} from "./rate-limit.js"; + +/** Conservative jitter base when a write is over-limit (not hard-coded at call sites). */ +export const DEFAULT_WRITE_RATE_LIMIT_BACKOFF_BASE_MS = 1_000; + +const PERMISSIVE_CONFIG: Readonly = Object.freeze({ + limit: 1_000_000, + windowMs: 60_000, +}); + +export type WriteRateLimitPolicies = { + /** Per actionClass global ceiling across all repos. */ + global: Readonly>; + /** Per actionClass per-repo ceiling. */ + perRepo: Readonly>; + /** Jitter backoff base when a write is rate-limited. */ + backoffBaseMs: number; +}; + +export const DEFAULT_WRITE_RATE_LIMIT_POLICIES: Readonly = Object.freeze({ + global: Object.freeze({ + open_pr: Object.freeze({ limit: 30, windowMs: 60_000 }), + comment: Object.freeze({ limit: 60, windowMs: 60_000 }), + }), + perRepo: Object.freeze({ + open_pr: Object.freeze({ limit: 3, windowMs: 60_000 }), + comment: Object.freeze({ limit: 10, windowMs: 60_000 }), + }), + backoffBaseMs: DEFAULT_WRITE_RATE_LIMIT_BACKOFF_BASE_MS, +}); + +export type WriteRateLimitBucketStore = { + global: Record; + perRepo: Record; +}; + +/** Burst-attempt counter keyed by `${actionClass}:${repo}` for jittered backoff growth. */ +export type WriteRateLimitBackoffStore = Record; + +export type WriteRateLimitBlockedBy = "global" | "per_repo"; + +export type WriteRateLimitVerdict = { + allowed: boolean; + blockedBy: WriteRateLimitBlockedBy | null; + global: LocalRateLimitDecision; + perRepo: LocalRateLimitDecision; + /** When blocked: milliseconds until the caller should retry (window wait ∪ jittered backoff). */ + retryAfterMs: number; + backoffAttempt: number; + reason: string; +}; + +export function writeRateLimitRepoKey(actionClass: string, repoFullName: string): string { + return `${actionClass.trim()}:${repoFullName.trim().toLowerCase()}`; +} + +function policyFor( + policies: WriteRateLimitPolicies, + actionClass: string, + scope: "global" | "perRepo", +): LocalRateLimitConfig { + const table = scope === "global" ? policies.global : policies.perRepo; + return table[actionClass] ?? PERMISSIVE_CONFIG; +} + +function emptyBucket(nowMs: number): LocalRateBucket { + return { count: 0, windowStartMs: nowMs }; +} + +function incrementBucket( + bucket: LocalRateBucket, + config: LocalRateLimitConfig, + nowMs: number, +): LocalRateBucket { + const windowMs = Number.isFinite(config.windowMs) ? Math.max(0, Math.floor(config.windowMs)) : 0; + const windowStartMs = Number.isFinite(bucket.windowStartMs) ? bucket.windowStartMs : nowMs; + const windowElapsed = nowMs - windowStartMs >= windowMs; + const effectiveCount = windowElapsed ? 0 : Math.max(0, Math.floor(bucket.count)); + return { + count: effectiveCount + 1, + windowStartMs: windowElapsed ? nowMs : windowStartMs, + }; +} + +/** + * Consult global and per-repo rolling-window buckets before a governor write. Both must permit the event; + * a repo under its own limit can still be blocked by the global ceiling. + */ +export function evaluateWriteRateLimit(input: { + actionClass: string; + repoFullName: string; + buckets: WriteRateLimitBucketStore; + backoffAttempts: WriteRateLimitBackoffStore; + policies?: WriteRateLimitPolicies; + nowMs: number; + randomFn?: () => number; +}): WriteRateLimitVerdict { + const policies = input.policies ?? DEFAULT_WRITE_RATE_LIMIT_POLICIES; + const randomFn = input.randomFn ?? (() => 0.5); + const nowMs = Number.isFinite(input.nowMs) ? input.nowMs : 0; + const repoKey = writeRateLimitRepoKey(input.actionClass, input.repoFullName); + const backoffAttempt = input.backoffAttempts[repoKey] ?? 0; + + const globalBucket = input.buckets.global[input.actionClass] ?? emptyBucket(nowMs); + const perRepoBucket = input.buckets.perRepo[repoKey] ?? emptyBucket(nowMs); + const globalConfig = policyFor(policies, input.actionClass, "global"); + const perRepoConfig = policyFor(policies, input.actionClass, "perRepo"); + + const global = evaluateLocalRateLimit(globalBucket, globalConfig, nowMs); + const perRepo = evaluateLocalRateLimit(perRepoBucket, perRepoConfig, nowMs); + + if (global.allowed && perRepo.allowed) { + return { + allowed: true, + blockedBy: null, + global, + perRepo, + retryAfterMs: 0, + backoffAttempt, + reason: "under_limit", + }; + } + + const blockedBy: WriteRateLimitBlockedBy = global.allowed ? "per_repo" : "global"; + const windowWait = Math.max(global.retryAfterMs, perRepo.retryAfterMs); + const jitterWait = jitteredBackoffMs(policies.backoffBaseMs, backoffAttempt, randomFn); + return { + allowed: false, + blockedBy, + global, + perRepo, + retryAfterMs: Math.max(windowWait, jitterWait), + backoffAttempt, + reason: blockedBy === "global" ? "global_rate_limit" : "per_repo_rate_limit", + }; +} + +/** Record a permitted write against both bucket scopes. */ +export function recordWriteRateLimitAllowed( + buckets: WriteRateLimitBucketStore, + actionClass: string, + repoFullName: string, + nowMs: number, + policies: WriteRateLimitPolicies = DEFAULT_WRITE_RATE_LIMIT_POLICIES, +): WriteRateLimitBucketStore { + const repoKey = writeRateLimitRepoKey(actionClass, repoFullName); + const globalConfig = policyFor(policies, actionClass, "global"); + const perRepoConfig = policyFor(policies, actionClass, "perRepo"); + const globalBucket = buckets.global[actionClass] ?? emptyBucket(nowMs); + const perRepoBucket = buckets.perRepo[repoKey] ?? emptyBucket(nowMs); + return { + global: { + ...buckets.global, + [actionClass]: incrementBucket(globalBucket, globalConfig, nowMs), + }, + perRepo: { + ...buckets.perRepo, + [repoKey]: incrementBucket(perRepoBucket, perRepoConfig, nowMs), + }, + }; +} + +/** Bump the jitter backoff attempt after a throttled write (does not mutate rate buckets). */ +export function recordWriteRateLimitDenied( + backoffAttempts: WriteRateLimitBackoffStore, + actionClass: string, + repoFullName: string, +): WriteRateLimitBackoffStore { + const key = writeRateLimitRepoKey(actionClass, repoFullName); + return { ...backoffAttempts, [key]: (backoffAttempts[key] ?? 0) + 1 }; +} + +/** Clear backoff attempts after a successful write. */ +export function clearWriteRateLimitBackoff( + backoffAttempts: WriteRateLimitBackoffStore, + actionClass: string, + repoFullName: string, +): WriteRateLimitBackoffStore { + const key = writeRateLimitRepoKey(actionClass, repoFullName); + if (!(key in backoffAttempts)) return backoffAttempts; + const next = { ...backoffAttempts }; + delete next[key]; + return next; +} + +/** Governor-ledger row for a write-rate-limit decision (#2344 deliverable). */ +export function buildWriteRateLimitGovernorLedgerEvent( + repoFullName: string, + actionClass: string, + verdict: WriteRateLimitVerdict, +): GovernorLedgerEvent { + return { + eventType: verdict.allowed ? "allowed" : "throttled", + repoFullName, + actionClass, + decision: verdict.allowed ? "allow" : "throttle", + reason: verdict.reason, + payload: verdict.allowed + ? {} + : { + blockedBy: verdict.blockedBy, + retryAfterMs: verdict.retryAfterMs, + backoffAttempt: verdict.backoffAttempt, + globalResetAtMs: verdict.global.resetAtMs, + perRepoResetAtMs: verdict.perRepo.resetAtMs, + }, + }; +} diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index ff790fdb80..5710f2fed7 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -147,6 +147,7 @@ export * from "./governor/rate-limit.js"; export * from "./governor/budget-cap.js"; export * from "./governor/self-plagiarism.js"; export * from "./governor/reputation-throttle.js"; +export * from "./governor/write-rate-limit.js"; export { GOVERNOR_LEDGER_EVENT_TYPES, normalizeGovernorLedgerEvent, diff --git a/packages/gittensory-engine/test/write-rate-limit-enforcement.test.ts b/packages/gittensory-engine/test/write-rate-limit-enforcement.test.ts new file mode 100644 index 0000000000..1131fbea57 --- /dev/null +++ b/packages/gittensory-engine/test/write-rate-limit-enforcement.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + buildWriteRateLimitGovernorLedgerEvent, + evaluateWriteRateLimit, + recordWriteRateLimitAllowed, +} from "../dist/index.js"; + +test("barrel: the public entrypoint re-exports write-rate-limit enforcement (#2344)", () => { + assert.equal(typeof evaluateWriteRateLimit, "function"); + assert.equal(typeof recordWriteRateLimitAllowed, "function"); + assert.equal(typeof buildWriteRateLimitGovernorLedgerEvent, "function"); +}); + +test("evaluateWriteRateLimit: global and per-repo buckets both gate a write", () => { + const policies = { + global: { open_pr: { limit: 1, windowMs: 60_000 } }, + perRepo: { open_pr: { limit: 3, windowMs: 60_000 } }, + backoffBaseMs: 50, + }; + const allowed = evaluateWriteRateLimit({ + actionClass: "open_pr", + repoFullName: "acme/widgets", + buckets: { global: {}, perRepo: {} }, + backoffAttempts: {}, + policies, + nowMs: 1_000, + }); + assert.equal(allowed.allowed, true); + + const buckets = recordWriteRateLimitAllowed( + { global: {}, perRepo: {} }, + "open_pr", + "acme/widgets", + 1_000, + policies, + ); + const blocked = evaluateWriteRateLimit({ + actionClass: "open_pr", + repoFullName: "acme/widgets", + buckets, + backoffAttempts: {}, + policies, + nowMs: 1_100, + }); + assert.equal(blocked.allowed, false); + assert.equal(blocked.blockedBy, "global"); +}); diff --git a/packages/gittensory-miner/lib/governor-write-rate-limit.d.ts b/packages/gittensory-miner/lib/governor-write-rate-limit.d.ts new file mode 100644 index 0000000000..b4b683acbc --- /dev/null +++ b/packages/gittensory-miner/lib/governor-write-rate-limit.d.ts @@ -0,0 +1,30 @@ +import type { + WriteRateLimitBackoffStore, + WriteRateLimitBucketStore, + WriteRateLimitPolicies, + WriteRateLimitVerdict, +} from "@jsonbored/gittensory-engine"; +import type { AppendGovernorEventInput, GovernorLedgerEntry } from "./governor-ledger.js"; + +export type EvaluateWriteRateLimitGateInput = { + actionClass: string; + repoFullName: string; + buckets: WriteRateLimitBucketStore; + backoffAttempts: WriteRateLimitBackoffStore; + nowMs: number; + policies?: WriteRateLimitPolicies; + randomFn?: () => number; +}; + +export type EvaluateWriteRateLimitGateResult = { + verdict: WriteRateLimitVerdict; + recorded: GovernorLedgerEntry; + buckets: WriteRateLimitBucketStore; + backoffAttempts: WriteRateLimitBackoffStore; + retryAtMs: number | null; +}; + +export function evaluateWriteRateLimitGate( + input: EvaluateWriteRateLimitGateInput, + options?: { append?: (event: AppendGovernorEventInput) => GovernorLedgerEntry }, +): EvaluateWriteRateLimitGateResult; diff --git a/packages/gittensory-miner/lib/governor-write-rate-limit.js b/packages/gittensory-miner/lib/governor-write-rate-limit.js new file mode 100644 index 0000000000..8e70f78dd0 --- /dev/null +++ b/packages/gittensory-miner/lib/governor-write-rate-limit.js @@ -0,0 +1,64 @@ +// Governor write-rate-limit gate (#2344). Consults global + per-repo buckets before a write action, schedules +// jittered retries on throttle, and records outcomes to the append-only governor ledger. + +import { + buildWriteRateLimitGovernorLedgerEvent, + clearWriteRateLimitBackoff, + evaluateWriteRateLimit, + recordWriteRateLimitAllowed, + recordWriteRateLimitDenied, +} from "@jsonbored/gittensory-engine"; +import { appendGovernorEvent } from "./governor-ledger.js"; + +/** + * Evaluate write-rate limits for a governor write action and persist the decision. + * + * @param {object} input + * @param {string} input.actionClass governor write class (e.g. open_pr, comment) + * @param {string} input.repoFullName target repo + * @param {import("@jsonbored/gittensory-engine").WriteRateLimitBucketStore} input.buckets + * @param {import("@jsonbored/gittensory-engine").WriteRateLimitBackoffStore} input.backoffAttempts + * @param {number} input.nowMs clock reading in epoch ms + * @param {import("@jsonbored/gittensory-engine").WriteRateLimitPolicies} [input.policies] + * @param {() => number} [input.randomFn] injected jitter source (defaults to mid-band draw) + * @param {{ append?: typeof appendGovernorEvent }} [options] + */ +export function evaluateWriteRateLimitGate(input, options = {}) { + const append = options.append ?? appendGovernorEvent; + const verdict = evaluateWriteRateLimit(input); + const recorded = append( + buildWriteRateLimitGovernorLedgerEvent(input.repoFullName, input.actionClass, verdict), + ); + + if (verdict.allowed) { + return { + verdict, + recorded, + buckets: recordWriteRateLimitAllowed( + input.buckets, + input.actionClass, + input.repoFullName, + input.nowMs, + input.policies, + ), + backoffAttempts: clearWriteRateLimitBackoff( + input.backoffAttempts, + input.actionClass, + input.repoFullName, + ), + retryAtMs: null, + }; + } + + return { + verdict, + recorded, + buckets: input.buckets, + backoffAttempts: recordWriteRateLimitDenied( + input.backoffAttempts, + input.actionClass, + input.repoFullName, + ), + retryAtMs: input.nowMs + verdict.retryAfterMs, + }; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 09c9c6adae..410e6f8ad1 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -32,7 +32,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/version.js && node --check lib/local-store.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/worktree-allocator.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/event-ledger-cli.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-adjudication.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-open-pr.js && node --check lib/attempt-log.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/laptop-init.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/calibration-types.js && node --check lib/calibration.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/version.js && node --check lib/local-store.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/worktree-allocator.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/event-ledger-cli.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-adjudication.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-open-pr.js && node --check lib/governor-write-rate-limit.js && node --check lib/attempt-log.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/laptop-init.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/calibration-types.js && node --check lib/calibration.js" }, "dependencies": { "@jsonbored/gittensory-engine": "*" diff --git a/test/unit/governor-write-rate-limit.test.ts b/test/unit/governor-write-rate-limit.test.ts new file mode 100644 index 0000000000..15332dd33e --- /dev/null +++ b/test/unit/governor-write-rate-limit.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it } from "vitest"; +import { + buildWriteRateLimitGovernorLedgerEvent, + clearWriteRateLimitBackoff, + evaluateWriteRateLimit, + recordWriteRateLimitAllowed, + recordWriteRateLimitDenied, + writeRateLimitRepoKey, + type WriteRateLimitBackoffStore, + type WriteRateLimitBucketStore, + type WriteRateLimitPolicies, +} from "../../packages/gittensory-engine/src/governor/write-rate-limit"; + +const ACTION = "open_pr"; +const REPO_A = "acme/repo-a"; +const REPO_B = "acme/repo-b"; + +const tightPolicies: WriteRateLimitPolicies = { + global: { [ACTION]: { limit: 2, windowMs: 10_000 } }, + perRepo: { [ACTION]: { limit: 2, windowMs: 10_000 } }, + backoffBaseMs: 100, +}; + +function emptyState(nowMs: number): { + buckets: WriteRateLimitBucketStore; + backoffAttempts: WriteRateLimitBackoffStore; +} { + return { + buckets: { global: {}, perRepo: {} }, + backoffAttempts: {}, + }; +} + +function attemptWrite( + state: { buckets: WriteRateLimitBucketStore; backoffAttempts: WriteRateLimitBackoffStore }, + repoFullName: string, + nowMs: number, + policies: WriteRateLimitPolicies = tightPolicies, + randomFn: () => number = () => 0.5, +) { + const verdict = evaluateWriteRateLimit({ + actionClass: ACTION, + repoFullName, + buckets: state.buckets, + backoffAttempts: state.backoffAttempts, + policies, + nowMs, + randomFn, + }); + if (verdict.allowed) { + return { + verdict, + buckets: recordWriteRateLimitAllowed(state.buckets, ACTION, repoFullName, nowMs, policies), + backoffAttempts: clearWriteRateLimitBackoff(state.backoffAttempts, ACTION, repoFullName), + }; + } + return { + verdict, + buckets: state.buckets, + backoffAttempts: recordWriteRateLimitDenied(state.backoffAttempts, ACTION, repoFullName), + }; +} + +describe("evaluateWriteRateLimit (#2344)", () => { + it("allows a write when both global and per-repo buckets are under their limits", () => { + const verdict = evaluateWriteRateLimit({ + actionClass: ACTION, + repoFullName: REPO_A, + buckets: { global: {}, perRepo: {} }, + backoffAttempts: {}, + policies: tightPolicies, + nowMs: 1_000, + }); + expect(verdict.allowed).toBe(true); + expect(verdict.reason).toBe("under_limit"); + expect(verdict.retryAfterMs).toBe(0); + }); + + it("throttles a burst past the per-repo limit and increments backoff attempts", () => { + let state = emptyState(1_000); + const outcomes: boolean[] = []; + + for (let i = 0; i < 4; i++) { + const result = attemptWrite(state, REPO_A, 1_000 + i, tightPolicies, () => 0.5); + state = result; + outcomes.push(result.verdict.allowed); + } + + expect(outcomes).toEqual([true, true, false, false]); + expect(state.backoffAttempts[`${ACTION}:acme/repo-a`]).toBe(2); + }); + + it("uses increasing jitter when backoff attempts grow on repeated denials", () => { + const blockedBuckets: WriteRateLimitBucketStore = { + global: { [ACTION]: { count: 2, windowStartMs: 0 } }, + perRepo: { [`${ACTION}:acme/repo-a`]: { count: 2, windowStartMs: 0 } }, + }; + const baseInput = { + actionClass: ACTION, + repoFullName: REPO_A, + buckets: blockedBuckets, + policies: { ...tightPolicies, backoffBaseMs: 500 }, + nowMs: 9_990, + randomFn: () => 0.5, + }; + + const first = evaluateWriteRateLimit({ ...baseInput, backoffAttempts: {} }); + const second = evaluateWriteRateLimit({ + ...baseInput, + backoffAttempts: { [`${ACTION}:acme/repo-a`]: 1 }, + }); + const third = evaluateWriteRateLimit({ + ...baseInput, + backoffAttempts: { [`${ACTION}:acme/repo-a`]: 2 }, + }); + + expect(first.allowed).toBe(false); + expect(second.retryAfterMs).toBeGreaterThan(first.retryAfterMs); + expect(third.retryAfterMs).toBeGreaterThan(second.retryAfterMs); + }); + + it("blocks on the global ceiling even when every individual repo bucket is under its own limit", () => { + let state = emptyState(1_000); + expect(attemptWrite(state, REPO_A, 1_000).verdict.allowed).toBe(true); + state = attemptWrite(state, REPO_A, 1_100); + expect(attemptWrite(state, REPO_B, 1_200).verdict.allowed).toBe(true); + state = attemptWrite(state, REPO_B, 1_300); + const blocked = attemptWrite(state, REPO_A, 1_400); + expect(blocked.verdict.allowed).toBe(false); + expect(blocked.verdict.blockedBy).toBe("global"); + expect(blocked.verdict.reason).toBe("global_rate_limit"); + }); + + it("resets buckets after the rolling window elapses", () => { + let state = emptyState(0); + state = attemptWrite(state, REPO_A, 0); + state = attemptWrite(state, REPO_A, 100); + const blocked = attemptWrite(state, REPO_A, 200); + expect(blocked.verdict.allowed).toBe(false); + + const afterWindow = attemptWrite(state, REPO_A, 10_500); + expect(afterWindow.verdict.allowed).toBe(true); + expect(afterWindow.verdict.reason).toBe("under_limit"); + }); + + it("buildWriteRateLimitGovernorLedgerEvent records throttle metadata for retries", () => { + const verdict = evaluateWriteRateLimit({ + actionClass: ACTION, + repoFullName: REPO_A, + buckets: { + global: { [ACTION]: { count: 2, windowStartMs: 0 } }, + perRepo: { [`${ACTION}:acme/repo-a`]: { count: 0, windowStartMs: 0 } }, + }, + backoffAttempts: { [`${ACTION}:acme/repo-a`]: 1 }, + policies: tightPolicies, + nowMs: 100, + randomFn: () => 0.5, + }); + expect(verdict.allowed).toBe(false); + const event = buildWriteRateLimitGovernorLedgerEvent(REPO_A, ACTION, verdict); + expect(event).toMatchObject({ + eventType: "throttled", + actionClass: ACTION, + decision: "throttle", + reason: "global_rate_limit", + payload: { + blockedBy: "global", + backoffAttempt: 1, + }, + }); + expect(event.payload?.retryAfterMs).toBeGreaterThan(0); + }); + + it("buildWriteRateLimitGovernorLedgerEvent records an empty payload on allow", () => { + const verdict = evaluateWriteRateLimit({ + actionClass: ACTION, + repoFullName: REPO_A, + buckets: { global: {}, perRepo: {} }, + backoffAttempts: {}, + nowMs: 1, + }); + const event = buildWriteRateLimitGovernorLedgerEvent(REPO_A, ACTION, verdict); + expect(event).toMatchObject({ + eventType: "allowed", + decision: "allow", + reason: "under_limit", + payload: {}, + }); + }); + + it("blocks on the per-repo bucket when the global bucket still has capacity", () => { + const policies: WriteRateLimitPolicies = { + global: { [ACTION]: { limit: 10, windowMs: 10_000 } }, + perRepo: { [ACTION]: { limit: 1, windowMs: 10_000 } }, + backoffBaseMs: 100, + }; + let state = emptyState(0); + state = attemptWrite(state, REPO_A, 0, policies); + const blocked = attemptWrite(state, REPO_A, 1, policies); + expect(blocked.verdict.allowed).toBe(false); + expect(blocked.verdict.blockedBy).toBe("per_repo"); + expect(blocked.verdict.reason).toBe("per_repo_rate_limit"); + }); + + it("uses default policies and repo keys when callers omit optional config", () => { + const key = writeRateLimitRepoKey(" open_pr ", " Acme/Repo-A "); + expect(key).toBe("open_pr:acme/repo-a"); + + const verdict = evaluateWriteRateLimit({ + actionClass: "unknown_action", + repoFullName: REPO_A, + buckets: { global: {}, perRepo: {} }, + backoffAttempts: {}, + nowMs: 1_000, + }); + expect(verdict.allowed).toBe(true); + + const buckets = recordWriteRateLimitAllowed( + { global: {}, perRepo: {} }, + ACTION, + REPO_A, + 1_000, + ); + expect(buckets.global[ACTION]?.count).toBe(1); + }); + + it("recordWriteRateLimitDenied increments and clearWriteRateLimitBackoff removes backoff keys", () => { + expect(clearWriteRateLimitBackoff({}, ACTION, REPO_A)).toEqual({}); + + const denied = recordWriteRateLimitDenied({}, ACTION, REPO_A); + expect(denied[`${ACTION}:acme/repo-a`]).toBe(1); + expect(clearWriteRateLimitBackoff(denied, ACTION, REPO_A)).toEqual({}); + }); + + it("falls back to the default jitter draw when randomFn is omitted on a throttle", () => { + const verdict = evaluateWriteRateLimit({ + actionClass: ACTION, + repoFullName: REPO_A, + buckets: { + global: { [ACTION]: { count: 2, windowStartMs: 0 } }, + perRepo: { [`${ACTION}:acme/repo-a`]: { count: 2, windowStartMs: 0 } }, + }, + backoffAttempts: {}, + policies: tightPolicies, + nowMs: 9_990, + }); + expect(verdict.allowed).toBe(false); + expect(verdict.retryAfterMs).toBeGreaterThanOrEqual(100); + }); + + it("normalizes non-finite clock and bucket inputs when advancing counters", () => { + const verdict = evaluateWriteRateLimit({ + actionClass: ACTION, + repoFullName: REPO_A, + buckets: { global: {}, perRepo: {} }, + backoffAttempts: {}, + policies: tightPolicies, + nowMs: Number.NaN, + }); + expect(verdict.allowed).toBe(true); + + const buckets = recordWriteRateLimitAllowed( + { + global: { [ACTION]: { count: 1, windowStartMs: Number.NaN } }, + perRepo: { [`${ACTION}:acme/repo-a`]: { count: 1, windowStartMs: 0 } }, + }, + ACTION, + REPO_A, + 500, + { + global: { [ACTION]: { limit: 5, windowMs: Number.NaN } }, + perRepo: { [ACTION]: { limit: 5, windowMs: 10_000 } }, + backoffBaseMs: 100, + }, + ); + expect(buckets.global[ACTION]?.count).toBe(1); + expect(buckets.perRepo[`${ACTION}:acme/repo-a`]?.count).toBe(2); + }); +}); diff --git a/test/unit/miner-governor-write-rate-limit.test.ts b/test/unit/miner-governor-write-rate-limit.test.ts new file mode 100644 index 0000000000..7fec5854b2 --- /dev/null +++ b/test/unit/miner-governor-write-rate-limit.test.ts @@ -0,0 +1,94 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@jsonbored/gittensory-engine", async () => { + return import("../../packages/gittensory-engine/src/index"); +}); + +import { evaluateWriteRateLimitGate } from "../../packages/gittensory-miner/lib/governor-write-rate-limit.js"; +import { initGovernorLedger } from "../../packages/gittensory-miner/lib/governor-ledger.js"; + +const roots: string[] = []; +const ledgers: Array<{ close(): void }> = []; + +afterEach(() => { + for (const ledger of ledgers.splice(0)) ledger.close(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("evaluateWriteRateLimitGate (#2344)", () => { + it("records an allowed write to the governor ledger and advances both buckets", () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-governor-write-rate-limit-")); + roots.push(root); + const ledger = initGovernorLedger(join(root, "governor-ledger.sqlite3")); + ledgers.push(ledger); + + const { verdict, recorded, buckets, retryAtMs } = evaluateWriteRateLimitGate( + { + actionClass: "open_pr", + repoFullName: "acme/repo-a", + buckets: { global: {}, perRepo: {} }, + backoffAttempts: {}, + nowMs: 1_000, + policies: { + global: { open_pr: { limit: 5, windowMs: 60_000 } }, + perRepo: { open_pr: { limit: 2, windowMs: 60_000 } }, + backoffBaseMs: 100, + }, + }, + { append: (event) => ledger.appendGovernorEvent(event) }, + ); + + expect(verdict.allowed).toBe(true); + expect(retryAtMs).toBeNull(); + expect(buckets.global.open_pr?.count).toBe(1); + expect(recorded.eventType).toBe("allowed"); + expect(recorded.actionClass).toBe("open_pr"); + }); + + it("schedules a jittered retry and records a throttled denial without advancing buckets", () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-governor-write-rate-limit-deny-")); + roots.push(root); + const ledger = initGovernorLedger(join(root, "governor-ledger.sqlite3")); + ledgers.push(ledger); + + const policies = { + global: { open_pr: { limit: 1, windowMs: 60_000 } }, + perRepo: { open_pr: { limit: 5, windowMs: 60_000 } }, + backoffBaseMs: 200, + }; + const first = evaluateWriteRateLimitGate( + { + actionClass: "open_pr", + repoFullName: "acme/repo-a", + buckets: { global: {}, perRepo: {} }, + backoffAttempts: {}, + nowMs: 5_000, + policies, + randomFn: () => 0.5, + }, + { append: (event) => ledger.appendGovernorEvent(event) }, + ); + const denied = evaluateWriteRateLimitGate( + { + actionClass: "open_pr", + repoFullName: "acme/repo-a", + buckets: first.buckets, + backoffAttempts: first.backoffAttempts, + nowMs: 5_100, + policies, + randomFn: () => 0.5, + }, + { append: (event) => ledger.appendGovernorEvent(event) }, + ); + + expect(denied.verdict.allowed).toBe(false); + expect(denied.retryAtMs).toBe(5_100 + denied.verdict.retryAfterMs); + expect(denied.verdict.retryAfterMs).toBeGreaterThanOrEqual(200); + expect(denied.buckets).toEqual(first.buckets); + expect(denied.recorded.eventType).toBe("throttled"); + expect(denied.recorded.payload).toMatchObject({ blockedBy: "global" }); + }); +});