fix: resume interrupted translations without losing saved progress
This commit is contained in:
1 parent
a8902deaf7
commit
e14184435c
6 files changed
+321
-44
No files matched your search
@@ -0,0 +1,152 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "node:test";
|
||||
import { build } from "esbuild";
|
||||
import { parseHTML } from "linkedom";
|
||||
|
||||
const { window, document } = parseHTML("<html><body></body></html>");
|
||||
Object.assign(globalThis, { window, document, HTMLElement: window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true });
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: { userAgent: "test" } });
|
||||
// Bundled React's act() uses its browser fallback; avoid leaving native MessagePorts open.
|
||||
globalThis.MessageChannel = class {
|
||||
port1 = { onmessage: null };
|
||||
port2 = { postMessage: () => setImmediate(() => this.port1.onmessage?.()) };
|
||||
};
|
||||
const storage = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: (key) => storage.get(key) ?? null,
|
||||
setItem: (key, value) => storage.set(key, value), removeItem: (key) => storage.delete(key),
|
||||
};
|
||||
const compiled = await build({ stdin: { contents: `
|
||||
export {createElement, act} from 'react';
|
||||
export {createRoot} from 'react-dom/client';
|
||||
export {MemoryRouter} from 'react-router-dom';
|
||||
export * from './translator/runner';
|
||||
export {TranslatorClient} from './translator/client';`,
|
||||
resolveDir: fileURLToPath(new URL("../src/", import.meta.url)), loader: "tsx" },
|
||||
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
|
||||
const { createElement: h, act, createRoot, MemoryRouter, TranslationRunProvider, TranslationProgress,
|
||||
useTranslationRun, TranslatorClient } = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
|
||||
Error.stackTraceLimit = 2;
|
||||
const respond = (data, status = 200) => new Response(JSON.stringify(data), { status });
|
||||
const savedKey = "pg.translation.run";
|
||||
|
||||
function fixture({ failSave = false, broken = false, hold = false } = {}) {
|
||||
storage.clear();
|
||||
const rows = [{ key: "a", source: "Hello" }, { key: "b", source: "Bye" }];
|
||||
const saved = new Map(), jobs = new Map(), sizes = [];
|
||||
let firstSave = failSave;
|
||||
globalThis.fetch = async (raw, init = {}) => {
|
||||
const url = new URL(raw, "http://library");
|
||||
if (url.pathname.endsWith("/entries")) {
|
||||
const open = rows.filter((row) => !saved.has(row.key));
|
||||
return respond({ total: open.length, items: open.slice(Number(url.searchParams.get("offset"))) });
|
||||
}
|
||||
if (url.pathname === "/api/versions/v/i18n/ru") {
|
||||
if (firstSave) { firstSave = false; return respond({}, 503); }
|
||||
const body = JSON.parse(init.body);
|
||||
for (const [key, value] of Object.entries(body.lines)) saved.set(key, value.t);
|
||||
return respond({});
|
||||
}
|
||||
if (url.pathname.endsWith("/jobs") && init.method === "POST") {
|
||||
const body = JSON.parse(init.body);
|
||||
sizes.push(body.batch_size);
|
||||
const failed = broken && sizes.length === 1;
|
||||
const running = hold && sizes.length === 1;
|
||||
const job = { id: String(jobs.size), status: running ? "running" : failed ? "completed_with_errors" : "completed", model: "tomie-translator", error: null,
|
||||
items: body.items.map((item, i) => ({ id: item.id, source: item.text,
|
||||
status: running && i > 0 ? "pending" : failed ? "failed" : "completed", translation: failed || (running && i > 0) ? null : `RU ${item.text}`,
|
||||
error: failed ? "Model output was truncated or filtered; reduce batch size or increase max_tokens" : null })) };
|
||||
jobs.set(job.id, job);
|
||||
return respond(job);
|
||||
}
|
||||
if (url.pathname.endsWith("/cancel")) return respond({ ...jobs.get(url.pathname.split("/").at(-2)), status: "cancelled" });
|
||||
if (url.pathname.startsWith("/jobs/")) return respond(jobs.get(url.pathname.split("/").at(-1)));
|
||||
throw new Error(`Unexpected request ${url.pathname}`);
|
||||
};
|
||||
let api, root;
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const Probe = () => { api = useTranslationRun(); return h(TranslationProgress); };
|
||||
const mount = async () => {
|
||||
root = createRoot(container);
|
||||
await act(async () => root.render(h(MemoryRouter, null, h(TranslationRunProvider, null, h(Probe)))));
|
||||
};
|
||||
const settle = async (check) => {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!check()) {
|
||||
if (Date.now() > deadline) throw new Error("Translation did not settle");
|
||||
await act(async () => new Promise((resolve) => setTimeout(resolve, 5)));
|
||||
}
|
||||
};
|
||||
return { saved, sizes, container, mount, settle, get api() { return api; },
|
||||
unmount: async () => { await act(async () => root.unmount()); },
|
||||
start: async () => { await act(async () => api.start({ client: new TranslatorClient("http://translator"),
|
||||
projectId: "p", versionId: "v", lang: "ru", title: "MIST", kind: "lines", module: "", q: "", states: ["untranslated"], batch: 6 })); },
|
||||
};
|
||||
}
|
||||
|
||||
test("a failed save survives reload and Resume recovers the existing job", async () => {
|
||||
const f = fixture({ failSave: true });
|
||||
await f.mount();
|
||||
try {
|
||||
await f.start();
|
||||
await f.settle(() => f.api.task?.status === "failed");
|
||||
assert.match(f.container.textContent, /Не удалось сохранить перевод: HTTP 503/);
|
||||
assert.match(f.container.textContent, /Возобновить/);
|
||||
assert.ok(storage.has(savedKey));
|
||||
await f.unmount();
|
||||
await f.mount();
|
||||
assert.equal(f.api.task.status, "failed");
|
||||
await act(async () => f.api.resume());
|
||||
await f.settle(() => f.api.task?.status === "done");
|
||||
assert.equal(f.saved.size, 2);
|
||||
assert.equal(f.api.task.progress.done, 2);
|
||||
assert.deepEqual(f.sizes, [6], "a completed translator job is not submitted again");
|
||||
assert.equal(storage.has(savedKey), false);
|
||||
} finally { await f.unmount(); }
|
||||
});
|
||||
|
||||
test("Stop retains completed rows and Resume sends only the remaining work", async () => {
|
||||
const f = fixture({ hold: true });
|
||||
await f.mount();
|
||||
try {
|
||||
await f.start();
|
||||
await f.settle(() => f.sizes.length === 1);
|
||||
await act(async () => f.api.stop());
|
||||
await f.settle(() => f.api.task?.status === "stopped");
|
||||
assert.equal(f.saved.size, 1);
|
||||
assert.equal(f.api.task.progress.done, 1);
|
||||
assert.ok(storage.has(savedKey));
|
||||
await act(async () => f.api.resume());
|
||||
await f.settle(() => f.api.task?.status === "done");
|
||||
assert.equal(f.saved.size, 2);
|
||||
assert.equal(f.api.task.progress.done, 2);
|
||||
assert.equal(f.api.task.progress.total, 2);
|
||||
assert.equal(storage.has(savedKey), false);
|
||||
} finally { await f.unmount(); }
|
||||
});
|
||||
|
||||
test("Resume retries failed rows with a smaller batch, including after an old checkpoint", async () => {
|
||||
const f = fixture({ broken: true });
|
||||
await f.mount();
|
||||
try {
|
||||
await f.start();
|
||||
await f.settle(() => f.api.task?.status === "done");
|
||||
assert.equal(f.api.task.progress.failed, 2);
|
||||
assert.equal(f.api.task.resumable, true);
|
||||
const snapshot = JSON.parse(storage.get(savedKey));
|
||||
snapshot.at = Date.now() - 86400000;
|
||||
storage.set(savedKey, JSON.stringify(snapshot));
|
||||
await f.unmount();
|
||||
await f.mount();
|
||||
assert.equal(f.api.task.status, "stopped");
|
||||
const button = [...f.container.querySelectorAll("button")].find((button) => button.textContent.includes("Возобновить"));
|
||||
await act(async () => button.dispatchEvent(new window.Event("click", { bubbles: true })));
|
||||
await f.settle(() => f.api.task?.status === "done");
|
||||
assert.deepEqual(f.sizes, [6, 3]);
|
||||
assert.equal(f.api.task.progress.done, 2);
|
||||
assert.equal(f.api.task.progress.failed, 0);
|
||||
assert.equal(f.saved.size, 2);
|
||||
} finally { await f.unmount(); }
|
||||
});
|
||||
Reference in new issue
Block a user