feat(import): run catalog synchronizations as durable jobs
CI / check (push) Successful in 57s
CI / deploy (push) Successful in 18s
CI / publish-container (push) Successful in 1m18s

This commit is contained in:
Simo committed 2026-09-11 00:36:22 +02:00
1 parent a2954e4408
commit 76f0420d64
14 files changed
+559 -801

No files matched your search

+100 -76
View File
@@ -1,4 +1,6 @@
import { randomUUID } from "node:crypto";
import { createStore, runWithStore } from "@/lib/foundation/request-context";
import type { IpAddress, RequestId, UserId } from "@/lib/foundation/types";
import type { ImportJob } from "@/lib/furni/import-job";
import { redis } from "@/lib/redis";
import { logServerError } from "@/lib/server-log";
@@ -10,6 +12,7 @@ import { readFurnitureAttachment } from "./furni-attachment";
import { patchLocalizedFurniDataEntries } from "./furni-data-i18n";
import { ensureDirectories, importSingleFurni } from "./furni-import";
import { ImportJobStore } from "./furni-job-store";
import { runSyncJobItem } from "./furni-sync-item";
import { repairFurniture } from "./furniture-repair";
import { rcon } from "./rcon";
@@ -100,87 +103,108 @@ async function drain() {
}
job.state = "running";
await saveOwned(job);
await withCatalogExport(async () => {
await ensureDirectories();
const source = job.sourceId ? await getSource(job.sourceId) : null;
for (const item of job.items) {
await requireLease();
if (item.state !== "pending") continue;
if (await store.isCancellationRequested(job.id)) {
for (const remaining of job.items)
if (remaining.state === "pending") remaining.state = "cancelled";
break;
}
item.state = "running";
await saveOwned(job);
try {
if (job.sourceId && !source)
throw Error("Import source no longer exists");
const providedNitro = item.attachmentId
? await readFurnitureAttachment(
item.attachmentId,
item.classname,
job.userId,
)
: undefined;
const operation = createStore("worker" as IpAddress);
operation.requestId = (job.operationId ?? job.id) as RequestId;
operation.userId = job.userId as UserId;
await runWithStore(operation, () =>
withCatalogExport(async () => {
await ensureDirectories();
const source = job.sourceId ? await getSource(job.sourceId) : null;
for (const item of job.items) {
await requireLease();
const result = await (job.mode === "repair"
? repairFurniture
: importSingleFurni)({
...item,
repairExisting: true,
providedNitro,
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
nitroBaseUrl: source?.nitroBaseUrl,
iconBaseUrl: source?.iconBaseUrl,
});
await requireLease();
item.warnings = result.warnings;
item.itemId = result.itemId;
if (!result.ok) throw Error(result.error || "Import failed");
item.state = "done";
logAudit({
userId: job.userId,
action: "furni_import",
target: "ItemsBase",
targetId: result.itemId ?? 0,
after: {
classname: item.classname,
if (item.state !== "pending") continue;
if (await store.isCancellationRequested(job.id)) {
for (const remaining of job.items)
if (remaining.state === "pending")
remaining.state = "cancelled";
break;
}
item.state = "running";
await saveOwned(job);
try {
if (job.sourceId && !source)
throw Error("Import source no longer exists");
const providedNitro = item.attachmentId
? await readFurnitureAttachment(
item.attachmentId,
item.classname,
job.userId,
)
: undefined;
await requireLease();
const result = job.syncKind
? await runSyncJobItem(item)
: await (job.mode === "repair"
? repairFurniture
: importSingleFurni)({
...item,
repairExisting: true,
providedNitro,
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
nitroBaseUrl: source?.nitroBaseUrl,
iconBaseUrl: source?.iconBaseUrl,
});
await requireLease();
item.warnings = result.warnings;
item.itemId = result.itemId;
if (!result.ok) throw Error(result.error || "Import failed");
item.state = "done";
await logAudit({
userId: job.userId,
action: "furni_import",
target: "ItemsBase",
targetId: result.itemId ?? 0,
after: {
classname: item.classname,
jobId: job.id,
operationId: job.operationId,
syncKind: job.syncKind,
repairExisting: true,
},
}).catch((auditError) => {
item.warnings ??= [];
item.warnings.push("Audit record could not be saved");
logServerError("furni.audit_failed", auditError, {
jobId: job.id,
classname: item.classname,
});
});
if (job.translate && job.mode !== "repair")
try {
await patchLocalizedFurniDataEntries([item], true, job.langs);
} catch {
item.warnings.push(
"Translation failed; furniture imported successfully",
);
}
} catch (error) {
if (error instanceof ImportLeaseLostError) throw error;
await requireLease();
logServerError("furni.item_failed", error, {
jobId: job.id,
repairExisting: true,
},
});
if (job.translate && job.mode !== "repair")
try {
await patchLocalizedFurniDataEntries([item], true, job.langs);
} catch {
item.warnings.push(
"Translation failed; furniture imported successfully",
);
}
classname: item.classname,
});
item.state = "failed";
item.error =
error instanceof Error ? error.message : "Import failed";
}
await saveOwned(job);
}
await requireLease();
try {
await rcon.updateCatalog();
await requireLease();
await rcon.updateItems();
} catch (error) {
if (error instanceof ImportLeaseLostError) throw error;
await requireLease();
item.state = "failed";
item.error =
error instanceof Error ? error.message : "Import failed";
for (const item of job.items)
if (item.state === "done") {
item.warnings ??= [];
item.warnings.push("Game cache refresh failed");
}
}
await saveOwned(job);
}
await requireLease();
try {
await rcon.updateCatalog();
await requireLease();
await rcon.updateItems();
} catch (error) {
if (error instanceof ImportLeaseLostError) throw error;
for (const item of job.items)
if (item.state === "done") {
item.warnings ??= [];
item.warnings.push("Game cache refresh failed");
}
}
});
}),
);
await requireLease();
job.state = job.items.some((item) => item.state === "cancelled")
? "cancelled"