Skip to content

Commit a0d3bd6

Browse files
authored
fix(selfhost): bound notification queue coalescing (#3218)
* fix(selfhost): bound notification queue coalescing * test(selfhost): fix a notify-evaluate coalesce-key assertion stale from the digest switch This PR changed jobCoalesceKey's notify-evaluate case to a fixed-length digest of the sorted dedup-key set, but one assertion in the general "coalesces by stable id" suite still expected the old raw-key format, failing CI. Matches the shape assertion already used by the dedicated digest test added elsewhere in this PR. * fix(selfhost): sort notify-evaluate events before chunking, not after jobCoalesceKey already hashes each chunk's OWN sorted dedup-key set, so a chunk's coalesce key was order-independent WITHIN that chunk -- but chunk MEMBERSHIP was still built from notificationEvents' raw arrival order, so a redelivery whose events resolved in a different order could split across a different 100-event boundary and never coalesce with the earlier attempt. Sorting by dedupKey before chunking makes membership a pure function of the detected event set, restoring the "same full set in any order" coalescing guarantee across chunk boundaries too. Added a regression test that reproduces the split with 205 reordered watchers and fails without the fix.
1 parent 69916ca commit a0d3bd6

4 files changed

Lines changed: 99 additions & 22 deletions

File tree

src/queue/processors.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,7 @@ const PR_PUBLIC_SURFACE_ACTIONS = new Set([
480480
]);
481481
const PR_GATE_CLOSED_ACTIONS = new Set(["closed"]);
482482
const ISSUE_PLAN_COOLDOWN_MS = 10 * 60 * 1000;
483+
const NOTIFY_EVALUATE_EVENTS_PER_JOB = 100;
483484

484485
type RequiredStatusContextsLookup = { requiredContexts: Set<string> | null; resolved: boolean };
485486

@@ -511,6 +512,14 @@ function liveFactTokenPart(token: string | undefined): string {
511512
return `token:${token.length}:${(hash >>> 0).toString(16).padStart(8, "0")}`;
512513
}
513514

515+
function chunkNotificationEvents(events: DetectedNotificationEvent[]): DetectedNotificationEvent[][] {
516+
const chunks: DetectedNotificationEvent[][] = [];
517+
for (let start = 0; start < events.length; start += NOTIFY_EVALUATE_EVENTS_PER_JOB) {
518+
chunks.push(events.slice(start, start + NOTIFY_EVALUATE_EVENTS_PER_JOB));
519+
}
520+
return chunks;
521+
}
522+
514523
function githubAdmissionKeyForToken(
515524
env: Env,
516525
installationId: number | null | undefined,
@@ -5602,15 +5611,20 @@ async function processGitHubWebhook(
56025611
},
56035612
});
56045613
}
5605-
// Batched (#selfhost-maintenance-self-pin): every event this ONE webhook delivery detected rides in a
5606-
// single notify-evaluate job instead of one job per event -- the audit trail above still records each
5607-
// event individually, so nothing about observability changes, only how many maintenance-lane rows a
5608-
// multi-watcher issue (or a review event landing alongside issue-watch matches) creates.
5609-
if (notificationEvents.length > 0) {
5614+
// Batched, but bounded (#selfhost-maintenance-self-pin): a popular issue can have thousands of watchers,
5615+
// so keep queue payloads comfortably below backend message limits while still avoiding one row per event.
5616+
// Sorted by dedupKey BEFORE chunking (#3218 review): jobCoalesceKey hashes each chunk's OWN sorted dedup-key
5617+
// set, so it's already order-independent WITHIN a chunk -- but chunk MEMBERSHIP itself was still built from
5618+
// notificationEvents' arrival order, so a redelivery whose events resolved in a different order could split
5619+
// across a different 100-event boundary and never coalesce with the earlier attempt. Sorting first makes
5620+
// chunk membership a pure function of the detected event SET, not its arrival order, restoring the "same
5621+
// full set in any order" coalescing guarantee across chunk boundaries too.
5622+
const notificationEventsForChunking = [...notificationEvents].sort((a, b) => a.dedupKey.localeCompare(b.dedupKey));
5623+
for (const events of chunkNotificationEvents(notificationEventsForChunking)) {
56105624
await env.JOBS.send({
56115625
type: "notify-evaluate",
56125626
requestedBy: "webhook",
5613-
events: notificationEvents,
5627+
events,
56145628
});
56155629
}
56165630

src/selfhost/queue-common.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -996,15 +996,15 @@ export function jobCoalesceKey(payload: string): string | null {
996996
}
997997
case "notify-evaluate": {
998998
// A batched job carries every event from one webhook delivery (#selfhost-maintenance-self-pin) --
999-
// coalescing keys off the FULL sorted set of dedup keys, so a redelivery of the identical batch still
1000-
// coalesces (same events -> same key) while any batch with even one different event gets its own key.
999+
// coalescing keys off a digest of the FULL sorted set of dedup keys, so a redelivery of the identical
1000+
// batch still coalesces without placing an attacker-sized concatenation into the indexed job_key column.
10011001
// If ANY event is missing its dedup key (a malformed payload), the whole batch is left uncoalesced
10021002
// (null) rather than keying off a partial set that could collide with an unrelated batch and silently
10031003
// drop the malformed event's work -- same rule as the other event-id-keyed types above.
10041004
if (!Array.isArray(message.events) || message.events.length === 0) return null;
10051005
const dedupKeys = message.events.map((event) => normalizedId(event?.dedupKey));
10061006
if (dedupKeys.some((dedupKey) => dedupKey === null)) return null;
1007-
return keyOf(type, [...(dedupKeys as string[])].sort().join(","));
1007+
return keyOf(type, stableStringDigest([...(dedupKeys as string[])].sort()));
10081008
}
10091009
case "submit-draft": {
10101010
const draftId = normalizedId(message.draftId);
@@ -1118,6 +1118,10 @@ function normalizedPathScope(value: unknown): string | null {
11181118
return `sha256:${createHash("sha256").update(JSON.stringify(paths)).digest("hex")}`;
11191119
}
11201120

1121+
function stableStringDigest(values: string[]): string {
1122+
return `sha256:${createHash("sha256").update(JSON.stringify(values)).digest("hex")}`;
1123+
}
1124+
11211125
function boolFlag(value: unknown): string {
11221126
return value === true ? "1" : "0";
11231127
}

test/unit/queue.test.ts

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as repositoriesModule from "../../src/db/repositories";
88
import * as repositorySettingsModule from "../../src/settings/repository-settings";
99
import * as sentryModule from "../../src/selfhost/sentry";
1010
import { renderMetrics, resetMetrics } from "../../src/selfhost/metrics";
11+
import { jobCoalesceKey } from "../../src/selfhost/queue-common";
1112
import {
1213
listCollisionEdges,
1314
createAgentRun,
@@ -16946,8 +16947,10 @@ describe("queue processors", () => {
1694616947
const enqueued: Array<{ type: string; events?: Array<{ eventType: string; recipientLogin: string; pullNumber: number }> }> = [];
1694716948
const env = createTestEnv({ JOBS: { async send(message: { type: string }) { enqueued.push(message); } } as unknown as Queue });
1694816949
vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 })); // no .gittensory.yml → empty manifest
16949-
await upsertIssueWatchSubscription(env, { login: "watcher-one", repoFullName: "JSONbored/gittensory" });
16950-
await upsertIssueWatchSubscription(env, { login: "watcher-two", repoFullName: "JSONbored/gittensory" });
16950+
const watcherLogins = Array.from({ length: 205 }, (_, index) => `watcher-${String(index + 1).padStart(3, "0")}`);
16951+
for (const login of watcherLogins) {
16952+
await upsertIssueWatchSubscription(env, { login, repoFullName: "JSONbored/gittensory" });
16953+
}
1695116954
await upsertIssueWatchSubscription(env, { login: "maintainer", repoFullName: "JSONbored/gittensory" }); // the author — should be skipped
1695216955

1695316956
await processJob(env, {
@@ -16962,17 +16965,58 @@ describe("queue processors", () => {
1696216965
},
1696316966
});
1696416967

16965-
// Batched (#selfhost-maintenance-self-pin): both watcher matches from this ONE webhook delivery ride in a
16966-
// SINGLE notify-evaluate job, not one job per watcher -- that fan-out was flooding the self-host maintenance
16967-
// lane with a job per watcher on a popular issue.
16968+
// Batched but bounded (#selfhost-maintenance-self-pin): watcher matches from this ONE webhook delivery ride in
16969+
// chunked notify-evaluate jobs, not one job per watcher and not one unbounded queue payload.
1696816970
const evaluateJobs = enqueued.filter((m): m is { type: "notify-evaluate"; events: Array<{ eventType: string; recipientLogin: string; pullNumber: number }> } => m.type === "notify-evaluate");
16969-
expect(evaluateJobs).toHaveLength(1);
16970-
const watchEvents = evaluateJobs[0]!.events.filter((event) => event.eventType === "issue_watch_match");
16971-
expect(watchEvents.map((event) => event.recipientLogin).sort()).toEqual(["watcher-one", "watcher-two"]); // maintainer (author) skipped
16971+
expect(evaluateJobs.map((job) => job.events).map((events) => events.length)).toEqual([100, 100, 5]);
16972+
const watchEvents = evaluateJobs.flatMap((job) => job.events).filter((event) => event.eventType === "issue_watch_match");
16973+
expect(watchEvents.map((event) => event.recipientLogin).sort()).toEqual(watcherLogins); // maintainer (author) skipped
1697216974
expect(watchEvents.every((event) => event.pullNumber === 91)).toBe(true);
1697316975

16974-
const detected = await env.DB.prepare("select metadata_json from audit_events where event_type = 'notification.event_detected' and target_key = ?").bind("watcher-one").first<{ metadata_json: string }>();
16975-
expect(JSON.parse(detected!.metadata_json)).toMatchObject({ eventType: "issue_watch_match", recipientLogin: "watcher-one", repoFullName: "JSONbored/gittensory" });
16976+
const detected = await env.DB.prepare("select metadata_json from audit_events where event_type = 'notification.event_detected' and target_key = ?").bind("watcher-001").first<{ metadata_json: string }>();
16977+
expect(JSON.parse(detected!.metadata_json)).toMatchObject({ eventType: "issue_watch_match", recipientLogin: "watcher-001", repoFullName: "JSONbored/gittensory" });
16978+
});
16979+
16980+
it("REGRESSION (#3218 review): chunk membership across a >100-watcher batch is order-independent -- the SAME watcher set in a different arrival order still produces the SAME set of chunk coalesce keys", async () => {
16981+
const watcherLogins = Array.from({ length: 205 }, (_, index) => `watcher-${String(index + 1).padStart(3, "0")}`);
16982+
16983+
const enqueueNotifyEvaluateJobs = async (loginOrder: string[]): Promise<Array<{ type: string; events: Array<{ dedupKey: string }> }>> => {
16984+
const enqueued: Array<{ type: string; events?: Array<{ dedupKey: string }> }> = [];
16985+
const env = createTestEnv({ JOBS: { async send(message: { type: string }) { enqueued.push(message); } } as unknown as Queue });
16986+
vi.stubGlobal("fetch", async () => new Response("not found", { status: 404 })); // no .gittensory.yml → empty manifest
16987+
// listIssueWatchersForRepo has no ORDER BY -- insertion order IS read-back order, so inserting in a
16988+
// different order here genuinely reproduces two logically-identical detection passes disagreeing on
16989+
// notificationEvents' arrival order, exactly the redelivery scenario the review is concerned about.
16990+
for (const login of loginOrder) {
16991+
await upsertIssueWatchSubscription(env, { login, repoFullName: "JSONbored/gittensory" });
16992+
}
16993+
await processJob(env, {
16994+
type: "github-webhook",
16995+
deliveryId: `issue-watch-open-${loginOrder[0]}`,
16996+
eventName: "issues",
16997+
payload: {
16998+
action: "opened",
16999+
installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } },
17000+
repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } },
17001+
issue: { number: 91, title: "Add caching to the registry sync", state: "open", user: { login: "maintainer" }, author_association: "OWNER", body: "We should cache the registry fetch." },
17002+
},
17003+
});
17004+
vi.unstubAllGlobals();
17005+
return enqueued.filter((m): m is { type: "notify-evaluate"; events: Array<{ dedupKey: string }> } => m.type === "notify-evaluate");
17006+
};
17007+
17008+
const coalesceKeysFor = (jobs: Array<{ type: string; events: Array<{ dedupKey: string }> }>): Array<string | null> =>
17009+
jobs.map((job) => jobCoalesceKey(JSON.stringify(job))).sort();
17010+
17011+
const forwardJobs = await enqueueNotifyEvaluateJobs(watcherLogins);
17012+
const reversedJobs = await enqueueNotifyEvaluateJobs([...watcherLogins].reverse());
17013+
17014+
// Same chunk SIZES either way (chunking itself is unaffected -- only membership was the risk).
17015+
expect(forwardJobs.map((job) => job.events.length)).toEqual([100, 100, 5]);
17016+
expect(reversedJobs.map((job) => job.events.length)).toEqual([100, 100, 5]);
17017+
// The set of chunk-level coalesce keys must match -- proving a redelivery whose events resolve in a
17018+
// different order still coalesces with the original batch instead of silently re-running as "new" work.
17019+
expect(coalesceKeysFor(reversedJobs)).toEqual(coalesceKeysFor(forwardJobs));
1697617020
});
1697717021

1697817022
it("appends issue-side slop findings to the issue advisory only when slop is opted in (#533)", async () => {

test/unit/selfhost-queue-common.test.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,11 +1052,14 @@ describe("self-host queue common helpers", () => {
10521052
expect(jobCoalesceKey(payload({ type: "run-agent", requestedBy: "github_comment", runId: "run-abc123" }))).toBe("run-agent:run-abc123");
10531053
expect(jobCoalesceKey(payload({ type: "notify-deliver", requestedBy: "notify-evaluate", deliveryId: "del-77" }))).toBe("notify-deliver:del-77");
10541054
expect(jobCoalesceKey(payload({ type: "submit-draft", requestedBy: "api", draftId: "draft-9" }))).toBe("submit-draft:draft-9");
1055+
// notify-evaluate keys off a fixed-length digest of the batch's dedup keys, not the raw keys themselves
1056+
// (#selfhost-maintenance-self-pin, tested for shape/order-independence/collision-avoidance below) --
1057+
// still a stable per-invocation key, so it belongs in this "coalesces by stable id" suite too.
10551058
expect(
10561059
jobCoalesceKey(
10571060
payload({ type: "notify-evaluate", requestedBy: "webhook", events: [{ dedupKey: "review_requested:o/r#3:bob" }] }),
10581061
),
1059-
).toBe("notify-evaluate:review_requested:o/r#3:bob");
1062+
).toMatch(/^notify-evaluate:sha256:[a-f0-9]{64}$/);
10601063
// Two DISTINCT invocations have distinct ids → distinct keys, so they never merge.
10611064
expect(jobCoalesceKey(payload({ type: "run-agent", requestedBy: "github_comment", runId: "run-xyz789" }))).toBe("run-agent:run-xyz789");
10621065
// A payload missing its id → null (uncoalesced), never a shared key that could drop a distinct job.
@@ -1068,7 +1071,7 @@ describe("self-host queue common helpers", () => {
10681071
expect(jobCoalesceKey(payload({ type: "notify-evaluate", requestedBy: "test", events: [{}] }))).toBeNull();
10691072
});
10701073

1071-
it("batches a notify-evaluate job's coalesce key off the FULL sorted set of dedup keys (#selfhost-maintenance-self-pin)", () => {
1074+
it("batches a notify-evaluate job's coalesce key off a fixed-length digest of the FULL sorted set of dedup keys (#selfhost-maintenance-self-pin)", () => {
10721075
// Order-independent: the same two events in either order produce the same key, so a redelivery with the
10731076
// events reordered still coalesces.
10741077
const forward = jobCoalesceKey(
@@ -1085,7 +1088,7 @@ describe("self-host queue common helpers", () => {
10851088
events: [{ dedupKey: "issue_watch_match:o/r#9:alice" }, { dedupKey: "review_requested:o/r#3:bob" }],
10861089
}),
10871090
);
1088-
expect(forward).toBe("notify-evaluate:issue_watch_match:o/r#9:alice,review_requested:o/r#3:bob");
1091+
expect(forward).toMatch(/^notify-evaluate:sha256:[a-f0-9]{64}$/);
10891092
expect(reversed).toBe(forward);
10901093
// A batch with even one different event gets a DIFFERENT key -- never silently merges with an unrelated batch.
10911094
const differentBatch = jobCoalesceKey(
@@ -1096,6 +1099,18 @@ describe("self-host queue common helpers", () => {
10961099
}),
10971100
);
10981101
expect(differentBatch).not.toBe(forward);
1102+
expect(differentBatch).toMatch(/^notify-evaluate:sha256:[a-f0-9]{64}$/);
1103+
const many = jobCoalesceKey(
1104+
payload({
1105+
type: "notify-evaluate",
1106+
requestedBy: "webhook",
1107+
events: Array.from({ length: 5_000 }, (_, index) => ({
1108+
dedupKey: `issue_watch_match:owner/repo#9:watcher-${String(index).padStart(4, "0")}`,
1109+
})),
1110+
}),
1111+
);
1112+
expect(many).toMatch(/^notify-evaluate:sha256:[a-f0-9]{64}$/);
1113+
expect(many!.length).toBe("notify-evaluate:sha256:".length + 64);
10991114
// If ANY event in the batch is missing its dedup key, the whole batch is left uncoalesced (null) rather than
11001115
// key off a partial set that could collide with -- and silently drop the malformed event from -- an
11011116
// unrelated batch.

0 commit comments

Comments
 (0)