From 2c06711c3d271a0d6ed97739f50dadfbcb655954 Mon Sep 17 00:00:00 2001 From: andriypolanski Date: Fri, 31 Jul 2026 14:59:10 +0000 Subject: [PATCH] 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. --- src/db/repositories.ts | 5 +- src/queue/signal-snapshot.ts | 18 ++- src/services/queue-trends.ts | 3 + test/unit/queue-trends.test.ts | 8 +- ...ignal-snapshot-queue-trend-history.test.ts | 137 ++++++++++++++++++ 5 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 test/unit/signal-snapshot-queue-trend-history.test.ts diff --git a/src/db/repositories.ts b/src/db/repositories.ts index e12740dbe0..dac76652b8 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -6190,7 +6190,10 @@ export async function listRecentSignalSnapshotsForTargets( ): Promise> { const result = new Map(); if (targetKeys.length === 0) return result; - const perTargetLimit = Math.max(1, Math.min(maxPerTarget, 100)); + // #10020 / #9699: allow caps large enough for multi-week daily snapshot series (e.g. QUEUE_TREND_SNAPSHOT_LIMIT + // = 140). The previous hard 100 matched listSignalSnapshots' latest-row backstop and re-truncated time-bounded + // history that needed more than 100 in-window rows. + const perTargetLimit = Math.max(1, Math.min(maxPerTarget, 500)); // The time bound (#9699) is applied INSIDE the windowed subquery so row_number() ranks over the in-window // set, not the whole table — otherwise a repo with many recent snapshots could rank its cap entirely within // the last few days and never surface the older weeks the trend card needs. maxPerTarget stays the backstop. diff --git a/src/queue/signal-snapshot.ts b/src/queue/signal-snapshot.ts index f0ad112012..f70d072f4c 100644 --- a/src/queue/signal-snapshot.ts +++ b/src/queue/signal-snapshot.ts @@ -15,14 +15,18 @@ import { listRecentMergedPullRequests, listRepoGithubTotalsSnapshotHistory, listRepoLabels, + listRecentSignalSnapshotsForTargets, listRepositories, - listSignalSnapshots, persistSignalSnapshot, replaceCollisionEdges, upsertRepoQueueTrendSnapshot, } from "../db/repositories"; import { computeRepoOutcomePatterns, REPO_OUTCOME_PATTERNS_SIGNAL } from "../services/repo-outcome-patterns"; -import { buildQueueTrendReport, QUEUE_TREND_HISTORY_DAYS } from "../services/queue-trends"; +import { + buildQueueTrendReport, + QUEUE_TREND_HISTORY_DAYS, + QUEUE_TREND_SNAPSHOT_LIMIT, +} from "../services/queue-trends"; import { buildCollisionEdges, buildCollisionReport, @@ -119,7 +123,15 @@ async function generateSignalSnapshotForRepo( sinceIso: trendSince, limit: 120, }), - listSignalSnapshots(env, "queue-health", repo.fullName), + // #10020: same time window as the totals half — listSignalSnapshots' hard 100-row cap otherwise keeps + // only ~25 days at 4 rows/day and the 30-day trend window never resolves a queue-health baseline. + listRecentSignalSnapshotsForTargets( + env, + "queue-health", + [repo.fullName], + QUEUE_TREND_SNAPSHOT_LIMIT, + trendSince, + ).then((byTarget) => byTarget.get(repo.fullName) ?? []), ]); const collisions = buildCollisionReport( repo.fullName, diff --git a/src/services/queue-trends.ts b/src/services/queue-trends.ts index cea3116a39..8028f41eb2 100644 --- a/src/services/queue-trends.ts +++ b/src/services/queue-trends.ts @@ -4,6 +4,9 @@ import { nowIso } from "../utils/json"; const QUEUE_TREND_WINDOWS_DAYS = [7, 14, 30] as const; export const QUEUE_TREND_HISTORY_DAYS = 35; +/** Per-repo row backstop for the queue-health history read (#10020). Sized for ≥4 snapshots/day across + * `QUEUE_TREND_HISTORY_DAYS` so the time bound (`trendSince`) is the primary constraint. */ +export const QUEUE_TREND_SNAPSHOT_LIMIT = QUEUE_TREND_HISTORY_DAYS * 4; export type QueueTrendWindow = { windowDays: 7 | 14 | 30; diff --git a/test/unit/queue-trends.test.ts b/test/unit/queue-trends.test.ts index 974b4c3acd..6df3c7768a 100644 --- a/test/unit/queue-trends.test.ts +++ b/test/unit/queue-trends.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import * as repositoriesModule from "../../src/db/repositories"; import { getRepoQueueTrendSnapshot, persistRepoGithubTotalsSnapshot, persistSignalSnapshot, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories"; import { generateSignalSnapshots } from "../../src/queue/processors"; -import { buildQueueTrendReport, buildUnavailableQueueTrendReport, type QueueTrendReport } from "../../src/services/queue-trends"; +import { buildQueueTrendReport, buildUnavailableQueueTrendReport, QUEUE_TREND_HISTORY_DAYS, QUEUE_TREND_SNAPSHOT_LIMIT, type QueueTrendReport } from "../../src/services/queue-trends"; import type { RepoGithubTotalsSnapshotRecord } from "../../src/types"; import { createTestEnv } from "../helpers/d1"; @@ -10,6 +10,12 @@ describe("queue trend windows", () => { afterEach(() => { vi.restoreAllMocks(); }); + + it("keeps QUEUE_TREND_SNAPSHOT_LIMIT sized for four rows/day across the history window (#10020)", () => { + expect(QUEUE_TREND_HISTORY_DAYS).toBeGreaterThanOrEqual(30); + expect(QUEUE_TREND_SNAPSHOT_LIMIT).toBeGreaterThanOrEqual(QUEUE_TREND_HISTORY_DAYS * 4); + }); + it("builds deterministic 7/14/30-day queue pressure and review velocity windows", () => { const report = buildQueueTrendReport({ repoFullName: "owner/repo", diff --git a/test/unit/signal-snapshot-queue-trend-history.test.ts b/test/unit/signal-snapshot-queue-trend-history.test.ts new file mode 100644 index 0000000000..6c4f002e5f --- /dev/null +++ b/test/unit/signal-snapshot-queue-trend-history.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getRepoQueueTrendSnapshot, + persistRepoGithubTotalsSnapshot, + persistSignalSnapshot, + upsertPullRequestFromGitHub, + upsertRepositoryFromGitHub, +} from "../../src/db/repositories"; +import { generateSignalSnapshots } from "../../src/queue/processors"; +import type { QueueTrendReport } from "../../src/services/queue-trends"; +import type { RepoGithubTotalsSnapshotRecord } from "../../src/types"; +import { createTestEnv } from "../helpers/d1"; + +const REPO = "owner/trend-history"; +const FIXTURE_NOW_MS = Date.parse("2026-07-31T12:00:00.000Z"); + +describe("signal-snapshot queue-trend history window (#10020)", () => { + beforeEach(() => { + vi.useFakeTimers({ now: FIXTURE_NOW_MS }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("REGRESSION: time-bounded queue-health history lets the 30-day window resolve duplicate and stale deltas", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub( + env, + { name: "trend-history", full_name: REPO, private: false, owner: { login: "owner" }, default_branch: "main" }, + 801, + ); + await env.DB.prepare("update repositories set is_registered = 1 where full_name = ?").bind(REPO).run(); + await upsertPullRequestFromGitHub(env, REPO, { + number: 1, + title: "Open fix", + state: "open", + user: { login: "miner" }, + author_association: "NONE", + labels: [], + body: "Fixes #1", + created_at: atDaysAgo(40), + updated_at: atDaysAgo(0), + }); + + // 130 queue-health rows across ~33 days at four/day — under the old listSignalSnapshots(limit 100) + // only ~25 days remained and the 30-day baseline stayed null. + let id = 0; + for (let day = 32; day >= 0; day -= 1) { + for (let slot = 0; slot < 4; slot += 1) { + if (id >= 130) break; + const daysAgo = day + slot / 4; + await persistSignalSnapshot(env, { + id: `qh-${id}`, + signalType: "queue-health", + targetKey: REPO, + repoFullName: REPO, + generatedAt: atDaysAgo(daysAgo), + payload: { + signals: { + openPullRequests: 10 + Math.floor(daysAgo), + stalePullRequests: 1 + Math.floor(daysAgo / 10), + collisionClusters: 1 + Math.floor((32 - day) / 8), + }, + }, + }); + id += 1; + } + } + + for (const daysAgo of [33, 30, 14, 7, 0]) { + await persistRepoGithubTotalsSnapshot(env, totals(daysAgo, { + openIssues: 10 + daysAgo, + openPrs: 4 + Math.floor(daysAgo / 5), + merged: 20 - Math.floor(daysAgo / 3), + closed: 5, + })); + } + + await generateSignalSnapshots(env, REPO); + + const snapshot = await getRepoQueueTrendSnapshot(env, REPO); + const report = snapshot?.payload as unknown as QueueTrendReport; + const window30 = report?.windows.find((window) => window.windowDays === 30); + expect(window30).toMatchObject({ + status: "ready", + duplicateTrend: expect.any(Number), + stalePullRequestRateDelta: expect.any(Number), + }); + expect(window30?.duplicateTrend).not.toBeNull(); + expect(window30?.stalePullRequestRateDelta).not.toBeNull(); + }); + + it("a repo with no queue-health history still persists a trend (map-miss ?? [] arm) with unavailable windows when totals are missing", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub( + env, + { name: "empty-history", full_name: "owner/empty-history", private: false, owner: { login: "owner" }, default_branch: "main" }, + 802, + ); + + await generateSignalSnapshots(env, "owner/empty-history"); + + const snapshot = await getRepoQueueTrendSnapshot(env, "owner/empty-history"); + const report = snapshot?.payload as unknown as QueueTrendReport; + expect(report).toMatchObject({ + status: "unavailable", + windows: [ + expect.objectContaining({ windowDays: 7, status: "unavailable" }), + expect.objectContaining({ windowDays: 14, status: "unavailable" }), + expect.objectContaining({ windowDays: 30, status: "unavailable" }), + ], + }); + }); +}); + +function totals( + daysAgo: number, + values: { openIssues: number; openPrs: number; merged: number; closed: number }, +): RepoGithubTotalsSnapshotRecord { + return { + id: `totals-${daysAgo}-${REPO}`, + repoFullName: REPO, + openIssuesTotal: values.openIssues, + openPullRequestsTotal: values.openPrs, + mergedPullRequestsTotal: values.merged, + closedUnmergedPullRequestsTotal: values.closed, + labelsTotal: 0, + sourceKind: "test", + fetchedAt: atDaysAgo(daysAgo), + payload: {}, + }; +} + +function atDaysAgo(daysAgo: number): string { + return new Date(FIXTURE_NOW_MS - daysAgo * 24 * 60 * 60 * 1000).toISOString(); +}