Serialize exports to prevent OOM spikes from stacking
/api/export-excel now holds a per-process mutex: concurrent callers get an instant 429 instead of stacking multiple ~1.3GB export spikes that exceed Node's default ~2GB heap. Frontend handleExport treats 429 as 'another export running' and auto-retries (3s backoff, up to 7 attempts). A 90s timeout reclaims a stuck lock. Heap raise + orphan cleanup applied at deploy. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -41,7 +41,40 @@ const DATE_COLUMNS = new Set([
|
|||||||
"烘洗",
|
"烘洗",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Serialize exports. A single export of the full dataset (~33k rows) peaks at
|
||||||
|
* ~1.3GB heap; letting two run at once would exceed Node's default ~2GB heap
|
||||||
|
* and OOM the server. All /api/export-excel requests are handled by the one
|
||||||
|
* Next server process (the listener on the port), so a module-level flag
|
||||||
|
* correctly serializes concurrent requests: the 2nd+ callers get an instant
|
||||||
|
* 429 and the frontend retries. Memory peak is capped at one export at a time.
|
||||||
|
*/
|
||||||
|
let exportLocked = false;
|
||||||
|
let exportLockedAt = 0;
|
||||||
|
const EXPORT_LOCK_TIMEOUT_MS = 90_000; // safety: reclaim a stuck lock after 90s
|
||||||
|
|
||||||
|
function acquireExportLock(): boolean {
|
||||||
|
if (exportLocked && Date.now() - exportLockedAt > EXPORT_LOCK_TIMEOUT_MS) {
|
||||||
|
console.warn("Export lock reclaimed after timeout");
|
||||||
|
exportLocked = false;
|
||||||
|
}
|
||||||
|
if (exportLocked) return false;
|
||||||
|
exportLocked = true;
|
||||||
|
exportLockedAt = Date.now();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseExportLock() {
|
||||||
|
exportLocked = false;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
if (!acquireExportLock()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "正在导出,请稍候" },
|
||||||
|
{ status: 429, headers: { "Retry-After": "3" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
let pool: sql.ConnectionPool | undefined;
|
let pool: sql.ConnectionPool | undefined;
|
||||||
try {
|
try {
|
||||||
pool = await sql.connect(dbConfig);
|
pool = await sql.connect(dbConfig);
|
||||||
@@ -136,5 +169,6 @@ export async function GET() {
|
|||||||
if (pool) {
|
if (pool) {
|
||||||
await pool.close();
|
await pool.close();
|
||||||
}
|
}
|
||||||
|
releaseExportLock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -542,8 +542,15 @@ export default function Home() {
|
|||||||
|
|
||||||
const handleExport = useCallback(async () => {
|
const handleExport = useCallback(async () => {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
const MAX_ATTEMPTS = 7; // 429 = 另有人在导出,退避后自动重试
|
||||||
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||||
const res = await fetch("/api/export-excel");
|
const res = await fetch("/api/export-excel");
|
||||||
|
if (res.status === 429) {
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const json = await res.json().catch(() => null);
|
const json = await res.json().catch(() => null);
|
||||||
setError(json?.error || "导出失败");
|
setError(json?.error || "导出失败");
|
||||||
@@ -558,6 +565,9 @@ export default function Home() {
|
|||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError("导出繁忙,请稍后重试");
|
||||||
} catch {
|
} catch {
|
||||||
setError("导出请求失败");
|
setError("导出请求失败");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user