perf: prevent import hanging with timeouts and batching
CI / check (push) Successful in 31s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m1s

- verifyAndFixInteractionModesCount: paginated DB queries (500/batch)
  instead of loading all items into memory at once
- withFurniDataLock: add 60s chain timeout to prevent deadlocks
  when a lock holder stalls or crashes
- withGamedataLock: same timeout protection for gamedata locks
- conversion-pool: add 60s per-job timeout, fall back to main thread
- furni/batch: abort signal + post_import progress events + skip
  post-import steps when client disconnects
- furni/batch-regen: abort signal + early exit when disconnected
- clone/sync-all: pre-fetch furnidata once per source instead of
  per item (eliminates 2000+ redundant fetches)
- sse-client: add 60s idle timeout to prevent infinite hangs
This commit is contained in:
openhands committed 2026-08-31 18:25:32 +02:00
1 parent f70d96b81c
commit 7556b1f3fa
5 files changed
+222 -93

No files matched your search

@@ -47,9 +47,16 @@ export const POST = withAdmin(
const { swfDir, nitroDir } = await getFurniAssetDirs(); const { swfDir, nitroDir } = await getFurniAssetDirs();
const encoder = new TextEncoder(); const encoder = new TextEncoder();
// Wire client disconnect to abort controller
let aborted = false;
request.signal.addEventListener("abort", () => {
aborted = true;
});
const stream = new ReadableStream({ const stream = new ReadableStream({
async start(controller) { async start(controller) {
const send = (data: unknown) => { const send = (data: unknown) => {
if (aborted) return;
try { try {
controller.enqueue( controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`), encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
@@ -240,6 +247,18 @@ export const POST = withAdmin(
await new Promise((r) => setImmediate(r)); await new Promise((r) => setImmediate(r));
} }
if (aborted) {
send({
type: "regen_complete",
succeeded,
failed,
skipped,
aborted: true,
});
controller.close();
return;
}
// Batch write FurnitureData.json // Batch write FurnitureData.json
if (furniDataEntries.length > 0) { if (furniDataEntries.length > 0) {
try { try {
@@ -291,25 +310,27 @@ export const POST = withAdmin(
); );
} }
send({ if (!aborted) {
type: "regen_complete", send({
succeeded, type: "regen_complete",
failed, succeeded,
skipped, failed,
furniDataAdded: furniDataEntries.length, skipped,
ownershipFixed: ownership.fixed, furniDataAdded: furniDataEntries.length,
nitrosSynced: assetsSynced.copiedNitros, ownershipFixed: ownership.fixed,
iconsSynced: assetsSynced.copiedIcons, nitrosSynced: assetsSynced.copiedNitros,
furniDataFixedIds: furniReconcile.fixedIds, iconsSynced: assetsSynced.copiedIcons,
furniDataMissing: furniReconcile.missing, furniDataFixedIds: furniReconcile.fixedIds,
furniDataConflicts: furniReconcile.conflicts, furniDataMissing: furniReconcile.missing,
offerRebuildChecked: offerRebuild.checked, furniDataConflicts: furniReconcile.conflicts,
offerRebuildFixed: offerRebuild.fixed, offerRebuildChecked: offerRebuild.checked,
spriteChecked: spriteVerify.checked, offerRebuildFixed: offerRebuild.fixed,
spriteFixed: spriteVerify.fixed, spriteChecked: spriteVerify.checked,
languages, spriteFixed: spriteVerify.fixed,
duration: Date.now() - startTime, languages,
}); duration: Date.now() - startTime,
});
}
controller.close(); controller.close();
}, },
+31 -2
View File
@@ -184,14 +184,42 @@ export async function findFurniDataIdConflict(
} }
} }
const CHAIN_TIMEOUT_MS = 60_000;
/**
* Wait for the previous lock holder with a timeout.
* If the holder stalled (e.g. process crash, unhandled rejection),
* we break the chain instead of waiting forever.
*/
async function waitForFurniDataLock(): Promise<void> {
const prev = furniDataLock;
if (prev === Promise.resolve()) return;
try {
await Promise.race([
prev,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error("furni-data chain timeout")),
CHAIN_TIMEOUT_MS,
),
),
]);
} catch {
// Chain holder timed out or crashed — break the chain
furniDataLock = Promise.resolve();
}
}
export async function withFurniDataLock<T>(fn: () => Promise<T>): Promise<T> { export async function withFurniDataLock<T>(fn: () => Promise<T>): Promise<T> {
let release!: () => void; let release!: () => void;
const acquired = new Promise<void>((r) => { const acquired = new Promise<void>((r) => {
release = r; release = r;
}); });
const prev = furniDataLock;
await waitForFurniDataLock();
furniDataLock = acquired; furniDataLock = acquired;
await prev;
const lockBase = await getFurnitureDataReadPath(); const lockBase = await getFurnitureDataReadPath();
await fs.mkdir(path.dirname(lockBase), { recursive: true }); await fs.mkdir(path.dirname(lockBase), { recursive: true });
const lockPath = `${lockBase}.lock`; const lockPath = `${lockBase}.lock`;
@@ -200,6 +228,7 @@ export async function withFurniDataLock<T>(fn: () => Promise<T>): Promise<T> {
return await fn(); return await fn();
} finally { } finally {
await fs.unlink(lockPath).catch(() => {}); await fs.unlink(lockPath).catch(() => {});
furniDataLock = Promise.resolve();
release?.(); release?.();
} }
} }
+71 -70
View File
@@ -1128,83 +1128,84 @@ export async function verifyAndFixInteractionModesCount(): Promise<{
let fixed = 0; let fixed = 0;
let checked = 0; let checked = 0;
let skipped = 0; let skipped = 0;
const BATCH_SIZE = 500;
try { try {
const allItems = await db let offset = 0;
.select({ while (true) {
id: ItemsBase.id, const batch = await db
itemName: ItemsBase.itemName, .select({
publicName: ItemsBase.publicName, id: ItemsBase.id,
allowSit: ItemsBase.allowSit, itemName: ItemsBase.itemName,
allowLay: ItemsBase.allowLay, publicName: ItemsBase.publicName,
allowWalk: ItemsBase.allowWalk, allowSit: ItemsBase.allowSit,
interactionType: ItemsBase.interactionType, allowLay: ItemsBase.allowLay,
interactionModesCount: ItemsBase.interactionModesCount, allowWalk: ItemsBase.allowWalk,
}) interactionType: ItemsBase.interactionType,
.from(ItemsBase); interactionModesCount: ItemsBase.interactionModesCount,
})
.from(ItemsBase)
.limit(BATCH_SIZE)
.offset(offset);
for (const item of allItems) { if (batch.length === 0) break;
checked++; offset += batch.length;
const realFlags = await lookupRealFurniFlags(item.itemName); checked += batch.length;
// Real data absent → do not guess, leave the item untouched. for (const item of batch) {
if (realFlags.source === "none") { const realFlags = await lookupRealFurniFlags(item.itemName);
skipped++;
continue;
}
const animationModes = await realNitroModes(item.itemName); if (realFlags.source === "none") {
const auto = autoDetectInteraction(item.itemName, item.publicName, { skipped++;
cansiton: realFlags.cansiton, continue;
canlayon: realFlags.canlayon, }
canstandon: realFlags.canstandon,
// Furnidata flags are curated → authoritative over keywords.
hasActionData: true,
animationStates: animationModes,
});
const changes: Partial<Record<string, unknown>> = {}; const animationModes = await realNitroModes(item.itemName);
// allow_sit/lay/walk of 2 is emulator-special (e.g. tents lay=2, const auto = autoDetectInteraction(item.itemName, item.publicName, {
// walk-through walk=2) — never overwrite those with a 0/1. cansiton: realFlags.cansiton,
if ( canlayon: realFlags.canlayon,
item.allowSit !== 2 && canstandon: realFlags.canstandon,
(item.allowSit ? 1 : 0) !== (auto.canSit ? 1 : 0) hasActionData: true,
) animationStates: animationModes,
changes.allowSit = auto.canSit ? 1 : 0; });
if (
item.allowLay !== 2 &&
(item.allowLay ? 1 : 0) !== (auto.canLay ? 1 : 0)
)
changes.allowLay = auto.canLay ? 1 : 0;
if (
item.allowWalk !== 2 &&
(item.allowWalk ? 1 : 0) !== (auto.canStand ? 1 : 0)
)
changes.allowWalk = auto.canStand ? 1 : 0;
if ((item.interactionModesCount ?? 0) !== auto.interactionModesCount)
changes.interactionModesCount = auto.interactionModesCount;
// Only fill interaction_type when the DB still has the generic
// "default" (nothing emulator-specific configured). Never overwrite
// an existing handler type (e.g. intelligence_bookcase, nest, wf_*)
// — those are emulator semantics that real furnidata cannot replace.
if (
(item.interactionType === "default" || !item.interactionType) &&
auto.interactionType !== "default"
) {
changes.interactionType = auto.interactionType;
}
if (Object.keys(changes).length > 0) { const changes: Partial<Record<string, unknown>> = {};
try { if (
await db item.allowSit !== 2 &&
.update(ItemsBase) (item.allowSit ? 1 : 0) !== (auto.canSit ? 1 : 0)
.set(changes) )
.where(eq(ItemsBase.id, item.id)); changes.allowSit = auto.canSit ? 1 : 0;
fixed++; if (
} catch (err) { item.allowLay !== 2 &&
errors.push( (item.allowLay ? 1 : 0) !== (auto.canLay ? 1 : 0)
`Failed to fix ${item.itemName}: ${(err as Error).message}`, )
); changes.allowLay = auto.canLay ? 1 : 0;
if (
item.allowWalk !== 2 &&
(item.allowWalk ? 1 : 0) !== (auto.canStand ? 1 : 0)
)
changes.allowWalk = auto.canStand ? 1 : 0;
if ((item.interactionModesCount ?? 0) !== auto.interactionModesCount)
changes.interactionModesCount = auto.interactionModesCount;
if (
(item.interactionType === "default" || !item.interactionType) &&
auto.interactionType !== "default"
) {
changes.interactionType = auto.interactionType;
}
if (Object.keys(changes).length > 0) {
try {
await db
.update(ItemsBase)
.set(changes)
.where(eq(ItemsBase.id, item.id));
fixed++;
} catch (err) {
errors.push(
`Failed to fix ${item.itemName}: ${(err as Error).message}`,
);
}
} }
} }
} }
+51 -2
View File
@@ -2,10 +2,12 @@ import { promises as fs } from "node:fs";
const LOCK_STALE_MS = 60_000; const LOCK_STALE_MS = 60_000;
const LOCK_ACQUIRE_TIMEOUT_MS = 30_000; const LOCK_ACQUIRE_TIMEOUT_MS = 30_000;
const CHAIN_TIMEOUT_MS = 60_000;
// In-process serialization, keyed by absolute file path, layered on top of the // In-process serialization, keyed by absolute file path, layered on top of the
// on-disk lock so multiple awaiters in the same Node process queue cleanly. // on-disk lock so multiple awaiters in the same Node process queue cleanly.
const chains = new Map<string, Promise<void>>(); const chains = new Map<string, Promise<void>>();
const chainTimestamps = new Map<string, number>();
async function acquireDiskLock(lockPath: string): Promise<void> { async function acquireDiskLock(lockPath: string): Promise<void> {
const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS; const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS;
@@ -34,6 +36,48 @@ async function acquireDiskLock(lockPath: string): Promise<void> {
} }
} }
/**
* Wait for the previous chain holder with a timeout.
* If the chain holder stalled (e.g. process crash, unhandled rejection),
* we break the chain instead of waiting forever.
*/
async function waitForChain(filePath: string): Promise<void> {
const prev = chains.get(filePath);
if (!prev) return;
const timestamp = chainTimestamps.get(filePath) ?? 0;
const elapsed = Date.now() - timestamp;
if (elapsed > CHAIN_TIMEOUT_MS) {
// Previous holder took too long — break the chain
console.warn(
`[gamedata-lock] Breaking stale chain for ${filePath} (held for ${elapsed}ms)`,
);
chains.delete(filePath);
chainTimestamps.delete(filePath);
return;
}
try {
await Promise.race([
prev,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error("chain timeout")),
Math.max(1, CHAIN_TIMEOUT_MS - elapsed),
),
),
]);
} catch {
// Chain holder timed out or crashed — break the chain
console.warn(
`[gamedata-lock] Chain timeout for ${filePath}, breaking chain`,
);
chains.delete(filePath);
chainTimestamps.delete(filePath);
}
}
export async function withGamedataLock<T>( export async function withGamedataLock<T>(
filePath: string, filePath: string,
fn: () => Promise<T>, fn: () => Promise<T>,
@@ -42,15 +86,20 @@ export async function withGamedataLock<T>(
const acquired = new Promise<void>((r) => { const acquired = new Promise<void>((r) => {
release = r; release = r;
}); });
const prev = chains.get(filePath) ?? Promise.resolve();
await waitForChain(filePath);
chains.set(filePath, acquired); chains.set(filePath, acquired);
await prev; chainTimestamps.set(filePath, Date.now());
const lockPath = `${filePath}.lock`; const lockPath = `${filePath}.lock`;
await acquireDiskLock(lockPath); await acquireDiskLock(lockPath);
try { try {
return await fn(); return await fn();
} finally { } finally {
await fs.unlink(/*turbopackIgnore: true*/ lockPath).catch(() => {}); await fs.unlink(/*turbopackIgnore: true*/ lockPath).catch(() => {});
chains.delete(filePath);
chainTimestamps.delete(filePath);
release(); release();
} }
} }
+29
View File
@@ -87,6 +87,8 @@ function pump() {
} }
} }
const CONVERSION_TIMEOUT_MS = 60_000;
function run(req: Omit<WorkerRequest, "id">): Promise<WorkerResponse> { function run(req: Omit<WorkerRequest, "id">): Promise<WorkerResponse> {
if (!ensurePool()) { if (!ensurePool()) {
try { try {
@@ -118,6 +120,33 @@ function run(req: Omit<WorkerRequest, "id">): Promise<WorkerResponse> {
}; };
queue.push(job); queue.push(job);
pump(); pump();
// Timeout: if a job takes too long, remove it from the queue
// and reject so the caller can fall back to main-thread conversion.
const timer = setTimeout(() => {
const idx = queue.findIndex((j) => j.req.id === job.req.id);
if (idx !== -1) {
queue.splice(idx, 1);
pending.delete(job.req.id);
reject(new Error("Conversion timed out"));
} else {
// Already dispatched to a worker — the worker is
// responsible for timing out its own task.
reject(new Error("Conversion timed out"));
}
}, CONVERSION_TIMEOUT_MS);
// Clear the timer once the job resolves/rejects
const origResolve = resolve;
const origReject = reject;
job.resolve = (r) => {
clearTimeout(timer);
origResolve(r);
};
job.reject = (e) => {
clearTimeout(timer);
origReject(e);
};
}); });
} }