Skip to content

Commit 2c06711

Browse files
fix(queue): time-bound queue-health history for the 30-day trend window (#10020)
Read queue-health snapshots through listRecentSignalSnapshotsForTargets with the same trendSince window as totals, sized by QUEUE_TREND_SNAPSHOT_LIMIT.
1 parent 0bc00ab commit 2c06711

5 files changed

Lines changed: 166 additions & 5 deletions

File tree

src/db/repositories.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6190,7 +6190,10 @@ export async function listRecentSignalSnapshotsForTargets(
61906190
): Promise<Map<string, SignalSnapshotRecord[]>> {
61916191
const result = new Map<string, SignalSnapshotRecord[]>();
61926192
if (targetKeys.length === 0) return result;
6193-
const perTargetLimit = Math.max(1, Math.min(maxPerTarget, 100));
6193+
// #10020 / #9699: allow caps large enough for multi-week daily snapshot series (e.g. QUEUE_TREND_SNAPSHOT_LIMIT
6194+
// = 140). The previous hard 100 matched listSignalSnapshots' latest-row backstop and re-truncated time-bounded
6195+
// history that needed more than 100 in-window rows.
6196+
const perTargetLimit = Math.max(1, Math.min(maxPerTarget, 500));
61946197
// The time bound (#9699) is applied INSIDE the windowed subquery so row_number() ranks over the in-window
61956198
// set, not the whole table — otherwise a repo with many recent snapshots could rank its cap entirely within
61966199
// the last few days and never surface the older weeks the trend card needs. maxPerTarget stays the backstop.

src/queue/signal-snapshot.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,18 @@ import {
1515
listRecentMergedPullRequests,
1616
listRepoGithubTotalsSnapshotHistory,
1717
listRepoLabels,
18+
listRecentSignalSnapshotsForTargets,
1819
listRepositories,
19-
listSignalSnapshots,
2020
persistSignalSnapshot,
2121
replaceCollisionEdges,
2222
upsertRepoQueueTrendSnapshot,
2323
} from "../db/repositories";
2424
import { computeRepoOutcomePatterns, REPO_OUTCOME_PATTERNS_SIGNAL } from "../services/repo-outcome-patterns";
25-
import { buildQueueTrendReport, QUEUE_TREND_HISTORY_DAYS } from "../services/queue-trends";
25+
import {
26+
buildQueueTrendReport,
27+
QUEUE_TREND_HISTORY_DAYS,
28+
QUEUE_TREND_SNAPSHOT_LIMIT,
29+
} from "../services/queue-trends";
2630
import {
2731
buildCollisionEdges,
2832
buildCollisionReport,
@@ -119,7 +123,15 @@ async function generateSignalSnapshotForRepo(
119123
sinceIso: trendSince,
120124
limit: 120,
121125
}),
122-
listSignalSnapshots(env, "queue-health", repo.fullName),
126+
// #10020: same time window as the totals half — listSignalSnapshots' hard 100-row cap otherwise keeps
127+
// only ~25 days at 4 rows/day and the 30-day trend window never resolves a queue-health baseline.
128+
listRecentSignalSnapshotsForTargets(
129+
env,
130+
"queue-health",
131+
[repo.fullName],
132+
QUEUE_TREND_SNAPSHOT_LIMIT,
133+
trendSince,
134+
).then((byTarget) => byTarget.get(repo.fullName) ?? []),
123135
]);
124136
const collisions = buildCollisionReport(
125137
repo.fullName,

src/services/queue-trends.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { nowIso } from "../utils/json";
44

55
const QUEUE_TREND_WINDOWS_DAYS = [7, 14, 30] as const;
66
export const QUEUE_TREND_HISTORY_DAYS = 35;
7+
/** Per-repo row backstop for the queue-health history read (#10020). Sized for ≥4 snapshots/day across
8+
* `QUEUE_TREND_HISTORY_DAYS` so the time bound (`trendSince`) is the primary constraint. */
9+
export const QUEUE_TREND_SNAPSHOT_LIMIT = QUEUE_TREND_HISTORY_DAYS * 4;
710

811
export type QueueTrendWindow = {
912
windowDays: 7 | 14 | 30;

test/unit/queue-trends.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,20 @@ import { afterEach, describe, expect, it, vi } from "vitest";
22
import * as repositoriesModule from "../../src/db/repositories";
33
import { getRepoQueueTrendSnapshot, persistRepoGithubTotalsSnapshot, persistSignalSnapshot, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories";
44
import { generateSignalSnapshots } from "../../src/queue/processors";
5-
import { buildQueueTrendReport, buildUnavailableQueueTrendReport, type QueueTrendReport } from "../../src/services/queue-trends";
5+
import { buildQueueTrendReport, buildUnavailableQueueTrendReport, QUEUE_TREND_HISTORY_DAYS, QUEUE_TREND_SNAPSHOT_LIMIT, type QueueTrendReport } from "../../src/services/queue-trends";
66
import type { RepoGithubTotalsSnapshotRecord } from "../../src/types";
77
import { createTestEnv } from "../helpers/d1";
88

99
describe("queue trend windows", () => {
1010
afterEach(() => {
1111
vi.restoreAllMocks();
1212
});
13+
14+
it("keeps QUEUE_TREND_SNAPSHOT_LIMIT sized for four rows/day across the history window (#10020)", () => {
15+
expect(QUEUE_TREND_HISTORY_DAYS).toBeGreaterThanOrEqual(30);
16+
expect(QUEUE_TREND_SNAPSHOT_LIMIT).toBeGreaterThanOrEqual(QUEUE_TREND_HISTORY_DAYS * 4);
17+
});
18+
1319
it("builds deterministic 7/14/30-day queue pressure and review velocity windows", () => {
1420
const report = buildQueueTrendReport({
1521
repoFullName: "owner/repo",
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
getRepoQueueTrendSnapshot,
4+
persistRepoGithubTotalsSnapshot,
5+
persistSignalSnapshot,
6+
upsertPullRequestFromGitHub,
7+
upsertRepositoryFromGitHub,
8+
} from "../../src/db/repositories";
9+
import { generateSignalSnapshots } from "../../src/queue/processors";
10+
import type { QueueTrendReport } from "../../src/services/queue-trends";
11+
import type { RepoGithubTotalsSnapshotRecord } from "../../src/types";
12+
import { createTestEnv } from "../helpers/d1";
13+
14+
const REPO = "owner/trend-history";
15+
const FIXTURE_NOW_MS = Date.parse("2026-07-31T12:00:00.000Z");
16+
17+
describe("signal-snapshot queue-trend history window (#10020)", () => {
18+
beforeEach(() => {
19+
vi.useFakeTimers({ now: FIXTURE_NOW_MS });
20+
});
21+
22+
afterEach(() => {
23+
vi.useRealTimers();
24+
});
25+
26+
it("REGRESSION: time-bounded queue-health history lets the 30-day window resolve duplicate and stale deltas", async () => {
27+
const env = createTestEnv();
28+
await upsertRepositoryFromGitHub(
29+
env,
30+
{ name: "trend-history", full_name: REPO, private: false, owner: { login: "owner" }, default_branch: "main" },
31+
801,
32+
);
33+
await env.DB.prepare("update repositories set is_registered = 1 where full_name = ?").bind(REPO).run();
34+
await upsertPullRequestFromGitHub(env, REPO, {
35+
number: 1,
36+
title: "Open fix",
37+
state: "open",
38+
user: { login: "miner" },
39+
author_association: "NONE",
40+
labels: [],
41+
body: "Fixes #1",
42+
created_at: atDaysAgo(40),
43+
updated_at: atDaysAgo(0),
44+
});
45+
46+
// 130 queue-health rows across ~33 days at four/day — under the old listSignalSnapshots(limit 100)
47+
// only ~25 days remained and the 30-day baseline stayed null.
48+
let id = 0;
49+
for (let day = 32; day >= 0; day -= 1) {
50+
for (let slot = 0; slot < 4; slot += 1) {
51+
if (id >= 130) break;
52+
const daysAgo = day + slot / 4;
53+
await persistSignalSnapshot(env, {
54+
id: `qh-${id}`,
55+
signalType: "queue-health",
56+
targetKey: REPO,
57+
repoFullName: REPO,
58+
generatedAt: atDaysAgo(daysAgo),
59+
payload: {
60+
signals: {
61+
openPullRequests: 10 + Math.floor(daysAgo),
62+
stalePullRequests: 1 + Math.floor(daysAgo / 10),
63+
collisionClusters: 1 + Math.floor((32 - day) / 8),
64+
},
65+
},
66+
});
67+
id += 1;
68+
}
69+
}
70+
71+
for (const daysAgo of [33, 30, 14, 7, 0]) {
72+
await persistRepoGithubTotalsSnapshot(env, totals(daysAgo, {
73+
openIssues: 10 + daysAgo,
74+
openPrs: 4 + Math.floor(daysAgo / 5),
75+
merged: 20 - Math.floor(daysAgo / 3),
76+
closed: 5,
77+
}));
78+
}
79+
80+
await generateSignalSnapshots(env, REPO);
81+
82+
const snapshot = await getRepoQueueTrendSnapshot(env, REPO);
83+
const report = snapshot?.payload as unknown as QueueTrendReport;
84+
const window30 = report?.windows.find((window) => window.windowDays === 30);
85+
expect(window30).toMatchObject({
86+
status: "ready",
87+
duplicateTrend: expect.any(Number),
88+
stalePullRequestRateDelta: expect.any(Number),
89+
});
90+
expect(window30?.duplicateTrend).not.toBeNull();
91+
expect(window30?.stalePullRequestRateDelta).not.toBeNull();
92+
});
93+
94+
it("a repo with no queue-health history still persists a trend (map-miss ?? [] arm) with unavailable windows when totals are missing", async () => {
95+
const env = createTestEnv();
96+
await upsertRepositoryFromGitHub(
97+
env,
98+
{ name: "empty-history", full_name: "owner/empty-history", private: false, owner: { login: "owner" }, default_branch: "main" },
99+
802,
100+
);
101+
102+
await generateSignalSnapshots(env, "owner/empty-history");
103+
104+
const snapshot = await getRepoQueueTrendSnapshot(env, "owner/empty-history");
105+
const report = snapshot?.payload as unknown as QueueTrendReport;
106+
expect(report).toMatchObject({
107+
status: "unavailable",
108+
windows: [
109+
expect.objectContaining({ windowDays: 7, status: "unavailable" }),
110+
expect.objectContaining({ windowDays: 14, status: "unavailable" }),
111+
expect.objectContaining({ windowDays: 30, status: "unavailable" }),
112+
],
113+
});
114+
});
115+
});
116+
117+
function totals(
118+
daysAgo: number,
119+
values: { openIssues: number; openPrs: number; merged: number; closed: number },
120+
): RepoGithubTotalsSnapshotRecord {
121+
return {
122+
id: `totals-${daysAgo}-${REPO}`,
123+
repoFullName: REPO,
124+
openIssuesTotal: values.openIssues,
125+
openPullRequestsTotal: values.openPrs,
126+
mergedPullRequestsTotal: values.merged,
127+
closedUnmergedPullRequestsTotal: values.closed,
128+
labelsTotal: 0,
129+
sourceKind: "test",
130+
fetchedAt: atDaysAgo(daysAgo),
131+
payload: {},
132+
};
133+
}
134+
135+
function atDaysAgo(daysAgo: number): string {
136+
return new Date(FIXTURE_NOW_MS - daysAgo * 24 * 60 * 60 * 1000).toISOString();
137+
}

0 commit comments

Comments
 (0)