diff --git a/src/features/developer/services/user-service.ts b/src/features/developer/services/user-service.ts index 12f757b..3befd2f 100644 --- a/src/features/developer/services/user-service.ts +++ b/src/features/developer/services/user-service.ts @@ -1,6 +1,7 @@ import { getUserData } from "@/lib/github"; import { calculateUserScore } from "@/features/scoring"; import type { CalculateUserScoreResult } from "@/features/scoring/services"; +import { scheduleBackground } from "@/lib/background"; import { persistUserScores } from "./user-persistence"; import { getCachedProfile, setCachedProfile } from "./profile-cache"; import type { UserProfileResponse, UserResult } from "../types"; @@ -75,7 +76,9 @@ export async function getUserProfile( }; // Warm Redis profile cache - void setCachedProfile(normalizedUsername, profile, selectedLanguages); + scheduleBackground(() => { + void setCachedProfile(normalizedUsername, profile, selectedLanguages); + }); return profile; } @@ -131,14 +134,14 @@ export async function getUserProfile( location: data.location ?? null, }; - // Warm Redis profile cache with the computed profile - void setCachedProfile(normalizedUsername, response, selectedLanguages); - - // Persist canonical scores into DB - void persistUserScores({ - data, - score, - selectedLanguages, + // Warm Redis profile cache and persist canonical scores to DB after response + scheduleBackground(async () => { + void setCachedProfile(normalizedUsername, response, selectedLanguages); + await persistUserScores({ + data, + score, + selectedLanguages, + }); }); return response; diff --git a/src/lib/background/index.ts b/src/lib/background/index.ts new file mode 100644 index 0000000..3b99e21 --- /dev/null +++ b/src/lib/background/index.ts @@ -0,0 +1 @@ +export * from "./schedule-background"; diff --git a/src/lib/background/schedule-background.ts b/src/lib/background/schedule-background.ts new file mode 100644 index 0000000..c5ad6ec --- /dev/null +++ b/src/lib/background/schedule-background.ts @@ -0,0 +1,25 @@ +import { after } from "next/server"; + +export type BackgroundTask = () => Promise | void; + +/** + * Schedules background work (e.g. cache warming, DB persistence) to run + * after the HTTP response has been sent to the client. + * + * In Next.js / Vercel serverless environments, this delegates to Next.js's + * native `after()` API to ensure the serverless function is not frozen + * or terminated before the background task finishes. + * + * If called outside of a Next.js request context (such as during Vitest unit tests, + * CLI scripts, or background workers), it gracefully executes the task immediately. + * + * @param task The synchronous or asynchronous function to execute in the background. + */ +export function scheduleBackground(task: BackgroundTask): void { + try { + after(task); + } catch { + // Fallback for non-request environments (e.g., tests, CLI runners) + void task(); + } +} diff --git a/src/lib/background/tests/schedule-background.test.ts b/src/lib/background/tests/schedule-background.test.ts new file mode 100644 index 0000000..81761fc --- /dev/null +++ b/src/lib/background/tests/schedule-background.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { scheduleBackground } from "../schedule-background"; +import * as nextServer from "next/server"; + +vi.mock("next/server", () => ({ + after: vi.fn(), +})); + +describe("scheduleBackground", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("delegates to Next.js after() when inside a request context", () => { + const afterMock = vi.mocked(nextServer.after); + afterMock.mockImplementation((task) => { + // Mock successful scheduling + return task as unknown as void; + }); + + const task = vi.fn(); + scheduleBackground(task); + + expect(afterMock).toHaveBeenCalledTimes(1); + expect(afterMock).toHaveBeenCalledWith(task); + // Task itself is deferred, not immediately called by scheduleBackground + expect(task).not.toHaveBeenCalled(); + }); + + it("gracefully runs the task immediately when after() throws outside a request scope", () => { + const afterMock = vi.mocked(nextServer.after); + afterMock.mockImplementation(() => { + throw new Error("`after` was called outside a request scope."); + }); + + const task = vi.fn(); + scheduleBackground(task); + + expect(afterMock).toHaveBeenCalledTimes(1); + expect(task).toHaveBeenCalledTimes(1); + }); + + it("gracefully runs async tasks when outside a request scope", async () => { + const afterMock = vi.mocked(nextServer.after); + afterMock.mockImplementation(() => { + throw new Error("`after` was called outside a request scope."); + }); + + let asyncDone = false; + const asyncTask = async () => { + await Promise.resolve(); + asyncDone = true; + }; + + scheduleBackground(asyncTask); + + await Promise.resolve(); + expect(asyncDone).toBe(true); + }); +}); diff --git a/src/lib/db/db-store.ts b/src/lib/db/db-store.ts index c83eaf0..be5959e 100644 --- a/src/lib/db/db-store.ts +++ b/src/lib/db/db-store.ts @@ -88,7 +88,7 @@ function getPoolConfig(): PoolConfig { connectionString, max: isServerless ? 2 : 10, idleTimeoutMillis: isServerless ? 10_000 : 30_000, - connectionTimeoutMillis: 10_000, + connectionTimeoutMillis: isServerless ? 20_000 : 10_000, }; }