Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 219 additions & 0 deletions packages/gittensory-engine/src/governor/write-rate-limit.ts
Original file line number Diff line number Diff line change
@@ -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<LocalRateLimitConfig> = Object.freeze({
limit: 1_000_000,
windowMs: 60_000,
});

export type WriteRateLimitPolicies = {
/** Per actionClass global ceiling across all repos. */
global: Readonly<Record<string, LocalRateLimitConfig>>;
/** Per actionClass per-repo ceiling. */
perRepo: Readonly<Record<string, LocalRateLimitConfig>>;
/** Jitter backoff base when a write is rate-limited. */
backoffBaseMs: number;
};

export const DEFAULT_WRITE_RATE_LIMIT_POLICIES: Readonly<WriteRateLimitPolicies> = 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<string, LocalRateBucket>;
perRepo: Record<string, LocalRateBucket>;
};

/** Burst-attempt counter keyed by `${actionClass}:${repo}` for jittered backoff growth. */
export type WriteRateLimitBackoffStore = Record<string, number>;

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,
},
};
}
1 change: 1 addition & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
});
30 changes: 30 additions & 0 deletions packages/gittensory-miner/lib/governor-write-rate-limit.d.ts
Original file line number Diff line number Diff line change
@@ -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;
64 changes: 64 additions & 0 deletions packages/gittensory-miner/lib/governor-write-rate-limit.js
Original file line number Diff line number Diff line change
@@ -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,
};
}
2 changes: 1 addition & 1 deletion packages/gittensory-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "*"
Expand Down
Loading