Skip to content

Commit d9ae6d7

Browse files
feat: implement in-memory token caching for GitHub requests and enhance job completion handling
1 parent 7578742 commit d9ae6d7

7 files changed

Lines changed: 229 additions & 55 deletions

File tree

src/server/core/github.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,9 +196,22 @@ export class GitHubClient {
196196
private readonly tracker?: { incrementSubrequests(count?: number): void },
197197
) {}
198198

199+
// In-memory token cache scoped to this client instance (i.e. one Worker invocation). Without it,
200+
// every GitHub request re-read the token from KV -- a wasted subrequest per call. A finalize or
201+
// review invocation makes many GitHub calls, so that repeated KV read pushed the invocation toward
202+
// the Workers-Free 50-subrequest cap (finalize could tip over it right before posting the review).
203+
private memoToken: InstallationTokenCacheRecord | null = null;
204+
199205
async getInstallationToken(): Promise<string> {
206+
// Reuse the in-memory token while it's comfortably unexpired (invocations are < ~120s; tokens
207+
// last ~1h, so this holds for the whole invocation) -- no KV read, no network call.
208+
if (this.memoToken?.token && new Date(this.memoToken.expiresAt).getTime() > Date.now() + 60_000) {
209+
return this.memoToken.token;
210+
}
211+
200212
const cached = await readCachedInstallationToken(this.env, this.installationId, this.tracker);
201213
if (cached?.token) {
214+
this.memoToken = cached;
202215
return cached.token;
203216
}
204217

@@ -229,10 +242,12 @@ export class GitHubClient {
229242
}
230243

231244
const data = (await response.json()) as { token: string; expires_at: string };
232-
await writeCachedInstallationToken(this.env, this.installationId, {
245+
const record: InstallationTokenCacheRecord = {
233246
token: data.token,
234247
expiresAt: data.expires_at,
235-
}, this.tracker);
248+
};
249+
await writeCachedInstallationToken(this.env, this.installationId, record, this.tracker);
250+
this.memoToken = record;
236251

237252
return data.token;
238253
});

src/server/core/job-recovery.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,17 +41,34 @@ export async function completeTerminalCheckRuns(env: AppBindings) {
4141

4242
try {
4343
const github = new GitHubService(env, job.installation_id);
44-
const checkRunPresentation = {
45-
superseded: { conclusion: 'neutral' as const, title: 'Review superseded', summary: 'Superseded by a newer commit or job.' },
46-
cancelled: { conclusion: 'cancelled' as const, title: 'Review stopped', summary: 'Stopped by user.' },
47-
failed: { conclusion: 'failure' as const, title: 'Review failed', summary: 'Review failed.' },
48-
};
49-
const presentation = checkRunPresentation[job.status as keyof typeof checkRunPresentation] ?? checkRunPresentation.failed;
44+
45+
let conclusion: 'success' | 'neutral' | 'failure' | 'cancelled';
46+
let title: string;
47+
let summary: string;
48+
if (job.status === 'done') {
49+
// A completed review whose inline check-run update didn't land (e.g. finalize ran out of
50+
// subrequest budget). Reconstruct the same conclusion finalize would have posted.
51+
const partial = (job.error_msg ?? '').startsWith('Partial review');
52+
conclusion = partial ? 'failure' : (job.verdict === 'approve' ? 'success' : 'neutral');
53+
title = partial ? 'Review partially failed' : (job.verdict === 'approve' ? 'LGTM' : 'Comments posted');
54+
summary = job.error_msg ?? `${job.comment_count ?? 0} inline comments across ${job.file_count ?? 0} files.`;
55+
} else {
56+
const checkRunPresentation = {
57+
superseded: { conclusion: 'neutral' as const, title: 'Review superseded', summary: 'Superseded by a newer commit or job.' },
58+
cancelled: { conclusion: 'cancelled' as const, title: 'Review stopped', summary: 'Stopped by user.' },
59+
failed: { conclusion: 'failure' as const, title: 'Review failed', summary: 'Review failed.' },
60+
};
61+
const presentation = checkRunPresentation[job.status as keyof typeof checkRunPresentation] ?? checkRunPresentation.failed;
62+
conclusion = presentation.conclusion;
63+
title = presentation.title;
64+
summary = job.error_msg ?? presentation.summary;
65+
}
66+
5067
await github.updateCheckRun(job.owner, job.repo, job.check_run_id, {
5168
status: 'completed',
52-
conclusion: presentation.conclusion,
53-
title: presentation.title,
54-
summary: job.error_msg ?? presentation.summary,
69+
conclusion,
70+
title,
71+
summary,
5572
});
5673
await markJobCheckRunCompleted(env, job.id);
5774
} catch (error) {

src/server/core/review.ts

Lines changed: 57 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { isSupportedGitHubWebhookEvent, type GitHubWebhookEventName, type GitHub
33
import { defaultRepoConfig, normalizeModelId, type ParsedReviewComment, type RepoConfig, type ReviewJobMessage } from '@shared/schema';
44
import { isTimeoutMessage, matchesAnyTransientSubstring } from '@shared/transient-errors';
55
import type { AppBindings } from '@server/env';
6-
import { bulkInheritFileReviews, getFileReviewsForJobs, recordRetryableFileReviewFailure, upsertFileReview } from '@server/db/file-reviews';
6+
import { bulkInheritFileReviews, bulkMarkFilesFailed, getFileReviewsForJobs, recordRetryableFileReviewFailure, upsertFileReview } from '@server/db/file-reviews';
77
import { getResolvedModelConfig } from '@server/db/model-configs';
88
import {
99
claimJobLease,
@@ -1227,16 +1227,17 @@ async function runFinalizePhase(
12271227

12281228
if (missingFiles.length > 0) {
12291229
logger.warn(`Job ${job.id} reached finalize phase with ${missingFiles.length} missing file reviews. Forcing them to failed state.`);
1230-
for (const file of missingFiles) {
1231-
await persistFailedFileReview(env, job.id, {
1232-
filePath: file.path,
1233-
modelUsed: config.model?.main ?? 'unconfigured',
1234-
diffLineCount: file.lineCount,
1235-
durationMs: 0,
1236-
errorMessage: 'Review could not be completed after repeated infrastructure limits',
1237-
});
1238-
}
1239-
1230+
// Batch the backfill into one INSERT. Doing it per-file (a transaction each) scales the
1231+
// subrequest cost with the number of missing files and, on a large/growing PR, exhausts the
1232+
// per-invocation budget right before the review is posted (finalize can't safely hibernate --
1233+
// it posts the GitHub review -- so it must stay within one invocation's budget).
1234+
await bulkMarkFilesFailed(
1235+
env,
1236+
job.id,
1237+
missingFiles.map((file) => ({ filePath: file.path, diffLineCount: file.lineCount })),
1238+
{ modelUsed: config.model?.main ?? 'unconfigured', errorMessage: 'Review could not be completed after repeated infrastructure limits' },
1239+
);
1240+
12401241
// Refresh reviews list after inserting the missing ones
12411242
reviews = await getFileReviewsForJobs(env, [job.id]);
12421243
} else {
@@ -1305,34 +1306,6 @@ async function runFinalizePhase(
13051306
})),
13061307
});
13071308

1308-
if (config.review.labels !== false) {
1309-
const labels = config.review.labels;
1310-
const labelMap = {
1311-
comment: { name: labels.p1, color: 'f79009' },
1312-
approve: { name: labels.p2, color: '027a48' },
1313-
} as const;
1314-
const label = labelMap[verdictSummary.verdict];
1315-
1316-
await github.removeIssueLabelsIfPresent(
1317-
job.owner,
1318-
job.repo,
1319-
job.prNumber,
1320-
[labels.p1, labels.p2, labels.p3].filter(possibleLabel => possibleLabel !== label.name),
1321-
);
1322-
1323-
await github.ensureLabel(job.owner, job.repo, label.name, label.color);
1324-
await github.addIssueLabels(job.owner, job.repo, job.prNumber, [label.name]);
1325-
}
1326-
1327-
if (job.checkRunId) {
1328-
await github.updateCheckRun(job.owner, job.repo, job.checkRunId, {
1329-
status: 'completed',
1330-
conclusion: hasFailures ? 'failure' : (verdictSummary.verdict === 'approve' ? 'success' : 'neutral'),
1331-
title: hasFailures ? 'Review partially failed' : (verdictSummary.verdict === 'approve' ? 'LGTM' : 'Comments posted'),
1332-
summary: `${finalComments.length} inline comments across ${files.length} files.${hasFailures ? ` ${failedFileCount} file${failedFileCount === 1 ? '' : 's'} could not be reviewed after repeated provider outages.` : ''}`,
1333-
});
1334-
}
1335-
13361309
const fileInputTokens = reviews.reduce((sum, review) => sum + (review.input_tokens ?? 0), 0);
13371310
const fileOutputTokens = reviews.reduce((sum, review) => sum + (review.output_tokens ?? 0), 0);
13381311

@@ -1345,6 +1318,10 @@ async function runFinalizePhase(
13451318
const partialErrorMessage = hasFailures
13461319
? `Partial review: ${failedFileCount} of ${files.length} file${files.length === 1 ? '' : 's'} could not be reviewed after repeated model/provider outages.`
13471320
: null;
1321+
// Record the posted review and mark the job done IMMEDIATELY after createReview -- before the
1322+
// best-effort cosmetics below. The review is already on GitHub at this point; if the remaining
1323+
// label/check-run calls exhaust this invocation's subrequest budget (large PR), we must not leave
1324+
// the job stranded as 'failed' with review_id null. This is the critical, must-not-lose write.
13481325
await completeJob(env, job.id, {
13491326
verdict: verdictSummary.verdict,
13501327
fileCount: files.length,
@@ -1358,6 +1335,47 @@ async function runFinalizePhase(
13581335
});
13591336
logger.info(`Review job completed: ${job.owner}/${job.repo} PR #${job.prNumber}`);
13601337

1338+
// Cosmetics: labels and the check-run conclusion. Best-effort -- the review is posted and the job
1339+
// is already 'done', so a failure here (e.g. subrequest budget spent, GitHub blip) must not fail
1340+
// the job. completeTerminalCheckRuns / a re-run can reconcile a check run left un-updated.
1341+
try {
1342+
// Check-run conclusion first: it drives the PR's status badge, so it matters more than labels
1343+
// if the budget only allows one of them.
1344+
if (job.checkRunId) {
1345+
await github.updateCheckRun(job.owner, job.repo, job.checkRunId, {
1346+
status: 'completed',
1347+
conclusion: hasFailures ? 'failure' : (verdictSummary.verdict === 'approve' ? 'success' : 'neutral'),
1348+
title: hasFailures ? 'Review partially failed' : (verdictSummary.verdict === 'approve' ? 'LGTM' : 'Comments posted'),
1349+
summary: `${finalComments.length} inline comments across ${files.length} files.${hasFailures ? ` ${failedFileCount} file${failedFileCount === 1 ? '' : 's'} could not be reviewed after repeated provider outages.` : ''}`,
1350+
});
1351+
// Only now is the check run genuinely completed -- record it so the maintenance sweep doesn't
1352+
// redo it. If the update above threw, this line is skipped and completeTerminalCheckRuns will
1353+
// finish the check run on a later invocation with a fresh budget.
1354+
await markJobCheckRunCompleted(env, job.id);
1355+
}
1356+
1357+
if (config.review.labels !== false) {
1358+
const labels = config.review.labels;
1359+
const labelMap = {
1360+
comment: { name: labels.p1, color: 'f79009' },
1361+
approve: { name: labels.p2, color: '027a48' },
1362+
} as const;
1363+
const label = labelMap[verdictSummary.verdict];
1364+
1365+
await github.removeIssueLabelsIfPresent(
1366+
job.owner,
1367+
job.repo,
1368+
job.prNumber,
1369+
[labels.p1, labels.p2, labels.p3].filter(possibleLabel => possibleLabel !== label.name),
1370+
);
1371+
1372+
await github.ensureLabel(job.owner, job.repo, label.name, label.color);
1373+
await github.addIssueLabels(job.owner, job.repo, job.prNumber, [label.name]);
1374+
}
1375+
} catch (error) {
1376+
logger.warn(`Post-review labels/check-run update failed for job ${job.id}; review is posted and job is completed, so leaving it best-effort`, error instanceof Error ? error : new Error(String(error)));
1377+
}
1378+
13611379
await sendReviewTelemetry(env, job, files, reviews, {
13621380
findingsReported: finalComments.length,
13631381
verdict: verdictSummary.verdict,

src/server/db/client.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,29 @@ export function runWithDb<T>(env: DbEnv, fn: () => T): T {
6262
return dbStorage.run(createDbClient(env), fn);
6363
}
6464

65+
// Reused clients for the no-runWithDb() fallback path below. Keyed by connection string so a caller
66+
// that queries outside a runWithDb() scope shares one bounded pool instead of leaking a fresh pool
67+
// (up to `max` connections) on every single query.
68+
const fallbackClients = new Map<string, DbClient>();
69+
6570
export function getDb(env: DbEnv) {
66-
return dbStorage.getStore() ?? createDbClient(env);
71+
const store = dbStorage.getStore();
72+
if (store) return store;
73+
74+
// Outside a runWithDb() scope. In production this never happens -- fetch, queue, scheduled, and
75+
// the workflow all wrap their work in runWithDb() -- so this branch is effectively test-only (the
76+
// Hono test client calls app.fetch() directly, and tests call db helpers directly). Creating a new
77+
// pool per query there leaked connections across the whole test process and exhausted CI's Postgres
78+
// (masked locally by Neon's pooler). Reuse one client per connection string instead. Keeping this
79+
// strictly to the store-less path preserves the per-request client production relies on to satisfy
80+
// Cloudflare's "no I/O across request contexts" rule.
81+
const connectionString = env.HYPERDRIVE.connectionString;
82+
let client = fallbackClients.get(connectionString);
83+
if (!client) {
84+
client = createDbClient(env);
85+
fallbackClients.set(connectionString, client);
86+
}
87+
return client;
6788
}
6889

6990
export async function queryRows<T>(env: DbEnv, sqlText: string, params: unknown[] = []) {

src/server/db/file-reviews.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,33 @@ export async function bulkInheritFileReviews(
335335
});
336336
}
337337

338+
/**
339+
* Mark many files 'failed' in a single INSERT. Finalize backfills reviews for files that never got
340+
* one (e.g. files that appeared mid-review, or unrecoverable ones); doing that one-by-one through
341+
* upsertFileReview runs a transaction per file (several Hyperdrive round-trips each), which for a
342+
* large/growing PR can blow the per-invocation subrequest budget right before the review is posted.
343+
* This collapses the whole backfill into one statement (one subrequest). Skips files that already
344+
* have a row (ON CONFLICT DO NOTHING) so it never clobbers a real review.
345+
*/
346+
export async function bulkMarkFilesFailed(
347+
env: Pick<AppBindings, 'HYPERDRIVE'>,
348+
jobId: string,
349+
files: Array<{ filePath: string; diffLineCount: number }>,
350+
opts: { modelUsed: string; errorMessage: string },
351+
): Promise<void> {
352+
if (files.length === 0) return;
353+
await queryRows(
354+
env,
355+
`
356+
INSERT INTO file_reviews (job_id, file_path, file_status, model_used, diff_line_count, diff_input, error_msg, duration_ms)
357+
SELECT $1::uuid, u.file_path, 'failed', $2, u.diff_line_count, '', $3, 0
358+
FROM UNNEST($4::text[], $5::int[]) AS u(file_path, diff_line_count)
359+
ON CONFLICT (job_id, file_path) DO NOTHING
360+
`,
361+
[jobId, opts.modelUsed, opts.errorMessage, files.map((f) => f.filePath), files.map((f) => f.diffLineCount)],
362+
);
363+
}
364+
338365
export async function getModelUsageStats(env: Pick<AppBindings, 'HYPERDRIVE'>) {
339366
return queryRows<{
340367
model_used: string;

src/server/db/jobs.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ export async function hasPendingMaintenanceWork(env: Pick<AppBindings, 'HYPERDRI
206206
SELECT EXISTS (
207207
SELECT 1 FROM jobs
208208
WHERE status IN ('queued', 'running')
209-
OR (status IN ('failed', 'superseded', 'cancelled') AND check_run_id IS NOT NULL AND check_run_completed_at IS NULL)
209+
OR (status IN ('done', 'failed', 'superseded', 'cancelled') AND check_run_id IS NOT NULL AND check_run_completed_at IS NULL)
210210
) AS has_work
211211
`,
212212
);
@@ -629,7 +629,11 @@ export async function completeJob(
629629
UPDATE jobs
630630
SET status = 'done',
631631
finished_at = now(),
632-
check_run_completed_at = now(),
632+
-- NOTE: check_run_completed_at is intentionally NOT set here. It's set only once the
633+
-- GitHub check run has actually been updated (markJobCheckRunCompleted, called after the
634+
-- best-effort updateCheckRun in finalize). That way, if finalize couldn't update the
635+
-- check run (e.g. subrequest budget spent on a huge PR), it stays NULL and the maintenance
636+
-- sweep (completeTerminalCheckRuns) reconciles it -- the check run always ends 'completed'.
633637
lease_owner = NULL,
634638
lease_expires_at = NULL,
635639
verdict = $2,
@@ -1003,7 +1007,7 @@ export async function getTerminalJobsNeedingCheckRunCompletion(
10031007
SELECT j.*, r.owner, r.repo, r.installation_id
10041008
FROM jobs j
10051009
JOIN repositories r ON j.repository_id = r.id
1006-
WHERE j.status IN ('failed', 'superseded', 'cancelled')
1010+
WHERE j.status IN ('done', 'failed', 'superseded', 'cancelled')
10071011
AND j.check_run_id IS NOT NULL
10081012
AND j.check_run_completed_at IS NULL
10091013
ORDER BY COALESCE(j.finished_at, j.started_at, j.created_at) ASC

0 commit comments

Comments
 (0)