Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions src/features/developer/services/user-service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/lib/background/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./schedule-background";
25 changes: 25 additions & 0 deletions src/lib/background/schedule-background.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { after } from "next/server";

export type BackgroundTask = () => Promise<void> | 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();
}
}
60 changes: 60 additions & 0 deletions src/lib/background/tests/schedule-background.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
2 changes: 1 addition & 1 deletion src/lib/db/db-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down
Loading