Skip to content

Commit f97a32f

Browse files
authored
fix(db): schedule user persistence via after() to prevent Vercel timeout (#235)
- Add scheduleBackground utility in @/lib/background using next/server after() - Wrap user persistence and cache warming in scheduleBackground to prevent Vercel lambda freezing - Increase serverless connectionTimeoutMillis to 20s to tolerate cold starts - Add unit tests for scheduleBackground
1 parent af156f3 commit f97a32f

5 files changed

Lines changed: 99 additions & 10 deletions

File tree

‎src/features/developer/services/user-service.ts‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getUserData } from "@/lib/github";
22
import { calculateUserScore } from "@/features/scoring";
33
import type { CalculateUserScoreResult } from "@/features/scoring/services";
4+
import { scheduleBackground } from "@/lib/background";
45
import { persistUserScores } from "./user-persistence";
56
import { getCachedProfile, setCachedProfile } from "./profile-cache";
67
import type { UserProfileResponse, UserResult } from "../types";
@@ -75,7 +76,9 @@ export async function getUserProfile(
7576
};
7677

7778
// Warm Redis profile cache
78-
void setCachedProfile(normalizedUsername, profile, selectedLanguages);
79+
scheduleBackground(() => {
80+
void setCachedProfile(normalizedUsername, profile, selectedLanguages);
81+
});
7982

8083
return profile;
8184
}
@@ -131,14 +134,14 @@ export async function getUserProfile(
131134
location: data.location ?? null,
132135
};
133136

134-
// Warm Redis profile cache with the computed profile
135-
void setCachedProfile(normalizedUsername, response, selectedLanguages);
136-
137-
// Persist canonical scores into DB
138-
void persistUserScores({
139-
data,
140-
score,
141-
selectedLanguages,
137+
// Warm Redis profile cache and persist canonical scores to DB after response
138+
scheduleBackground(async () => {
139+
void setCachedProfile(normalizedUsername, response, selectedLanguages);
140+
await persistUserScores({
141+
data,
142+
score,
143+
selectedLanguages,
144+
});
142145
});
143146

144147
return response;

‎src/lib/background/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./schedule-background";
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { after } from "next/server";
2+
3+
export type BackgroundTask = () => Promise<void> | void;
4+
5+
/**
6+
* Schedules background work (e.g. cache warming, DB persistence) to run
7+
* after the HTTP response has been sent to the client.
8+
*
9+
* In Next.js / Vercel serverless environments, this delegates to Next.js's
10+
* native `after()` API to ensure the serverless function is not frozen
11+
* or terminated before the background task finishes.
12+
*
13+
* If called outside of a Next.js request context (such as during Vitest unit tests,
14+
* CLI scripts, or background workers), it gracefully executes the task immediately.
15+
*
16+
* @param task The synchronous or asynchronous function to execute in the background.
17+
*/
18+
export function scheduleBackground(task: BackgroundTask): void {
19+
try {
20+
after(task);
21+
} catch {
22+
// Fallback for non-request environments (e.g., tests, CLI runners)
23+
void task();
24+
}
25+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { scheduleBackground } from "../schedule-background";
3+
import * as nextServer from "next/server";
4+
5+
vi.mock("next/server", () => ({
6+
after: vi.fn(),
7+
}));
8+
9+
describe("scheduleBackground", () => {
10+
beforeEach(() => {
11+
vi.clearAllMocks();
12+
});
13+
14+
it("delegates to Next.js after() when inside a request context", () => {
15+
const afterMock = vi.mocked(nextServer.after);
16+
afterMock.mockImplementation((task) => {
17+
// Mock successful scheduling
18+
return task as unknown as void;
19+
});
20+
21+
const task = vi.fn();
22+
scheduleBackground(task);
23+
24+
expect(afterMock).toHaveBeenCalledTimes(1);
25+
expect(afterMock).toHaveBeenCalledWith(task);
26+
// Task itself is deferred, not immediately called by scheduleBackground
27+
expect(task).not.toHaveBeenCalled();
28+
});
29+
30+
it("gracefully runs the task immediately when after() throws outside a request scope", () => {
31+
const afterMock = vi.mocked(nextServer.after);
32+
afterMock.mockImplementation(() => {
33+
throw new Error("`after` was called outside a request scope.");
34+
});
35+
36+
const task = vi.fn();
37+
scheduleBackground(task);
38+
39+
expect(afterMock).toHaveBeenCalledTimes(1);
40+
expect(task).toHaveBeenCalledTimes(1);
41+
});
42+
43+
it("gracefully runs async tasks when outside a request scope", async () => {
44+
const afterMock = vi.mocked(nextServer.after);
45+
afterMock.mockImplementation(() => {
46+
throw new Error("`after` was called outside a request scope.");
47+
});
48+
49+
let asyncDone = false;
50+
const asyncTask = async () => {
51+
await Promise.resolve();
52+
asyncDone = true;
53+
};
54+
55+
scheduleBackground(asyncTask);
56+
57+
await Promise.resolve();
58+
expect(asyncDone).toBe(true);
59+
});
60+
});

‎src/lib/db/db-store.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ function getPoolConfig(): PoolConfig {
8888
connectionString,
8989
max: isServerless ? 2 : 10,
9090
idleTimeoutMillis: isServerless ? 10_000 : 30_000,
91-
connectionTimeoutMillis: 10_000,
91+
connectionTimeoutMillis: isServerless ? 20_000 : 10_000,
9292
};
9393
}
9494

0 commit comments

Comments
 (0)