feat: machine translation resumes after a page reload
The run is recorded at every page and picked up from the translator's finished job; one run across tabs via Web Locks. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7fa88e4025
commit
68b66a1d94
5 files changed
+200
-30
No files matched your search
@@ -216,8 +216,14 @@ translate russian strings:
|
|||||||
* запуск живёт в провайдере выше страниц (`translator/runner.tsx`): перевод идёт,
|
* запуск живёт в провайдере выше страниц (`translator/runner.tsx`): перевод идёт,
|
||||||
пока вкладка открыта, даже если уйти из редактора; его карточка с прогрессом и
|
пока вкладка открыта, даже если уйти из редактора; его карточка с прогрессом и
|
||||||
кнопкой «Остановить» стоит в том же плавающем окне, что и загрузки. Одновременно
|
кнопкой «Остановить» стоит в том же плавающем окне, что и загрузки. Одновременно
|
||||||
работает только один перевод (на все игры и языки), пока он идёт, кнопки
|
работает только один перевод (на все игры и языки; между вкладками это
|
||||||
отдельных строк недоступны; закрытие вкладки браузер подтверждает;
|
держит Web Lock), пока он идёт, кнопки
|
||||||
|
отдельных строк недоступны; закрытие вкладки браузер подтверждает. На каждой странице запуск
|
||||||
|
записывается в `localStorage` (`pg.translation.run`: что переводится, где
|
||||||
|
остановились, какое задание отправлено в переводчик). После обновления страницы
|
||||||
|
сайт сам продолжает: забирает готовое задание из переводчика, сохраняет его и идёт
|
||||||
|
дальше, а карточка и бейджи снова обновляются. Запись старше 30 минут не
|
||||||
|
подхватывается;
|
||||||
* автоперевод идёт по списку так, как его видит пользователь: текущая
|
* автоперевод идёт по списку так, как его видит пользователь: текущая
|
||||||
вкладка («Реплики» или «Имена и меню»), фильтр по файлу и поиск, сверху вниз.
|
вкладка («Реплики» или «Имена и меню»), фильтр по файлу и поиск, сверху вниз.
|
||||||
Берутся строки «не переведено» (и «устарело» по желанию) страницами по 40;
|
Берутся строки «не переведено» (и «устарело» по желанию) страницами по 40;
|
||||||
|
|||||||
@@ -65,11 +65,11 @@ export function MachineTranslate({ session, versionId, lang, title, view, follow
|
|||||||
setPreparing(true);
|
setPreparing(true);
|
||||||
try {
|
try {
|
||||||
const projectId = await session.project();
|
const projectId = await session.project();
|
||||||
const started = run.start({
|
const started = await run.start({
|
||||||
client, projectId, versionId, lang, title, kind: view.kind, module: view.module, q: view.q,
|
client, projectId, versionId, lang, title, kind: view.kind, module: view.module, q: view.q,
|
||||||
states, batch: prefs.batch,
|
states, batch: prefs.batch,
|
||||||
});
|
});
|
||||||
if (!started) setError("Другой перевод уже идёт: дождитесь его или остановите.");
|
if (!started) setError("Другой перевод уже идёт (возможно, в другой вкладке): дождитесь его или остановите.");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -23,6 +23,21 @@ export interface RunOptions {
|
|||||||
onPage?: (items: Source[]) => void;
|
onPage?: (items: Source[]) => void;
|
||||||
onProgress: (p: Progress) => void;
|
onProgress: (p: Progress) => void;
|
||||||
pollMs?: number;
|
pollMs?: number;
|
||||||
|
/** where an interrupted run stood: the counters and how many lines to skip */
|
||||||
|
initial?: { total: number; done: number; failed: number; skip: number };
|
||||||
|
/** the job that was being translated when the run was interrupted, and the lines it carries */
|
||||||
|
resume?: { jobId: string; keys: string[] };
|
||||||
|
/** a page was handed to the translator (`jobId`) or fully handled (`null`): enough to continue from here */
|
||||||
|
onCheckpoint?: (cp: Checkpoint) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Checkpoint {
|
||||||
|
jobId: string | null;
|
||||||
|
keys: string[];
|
||||||
|
skip: number;
|
||||||
|
total: number;
|
||||||
|
done: number;
|
||||||
|
failed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Progress {
|
export interface Progress {
|
||||||
@@ -70,23 +85,42 @@ export async function waitForJob(client: Pick<TranslatorClient, "job" | "cancel"
|
|||||||
|
|
||||||
/** Throws when the translator becomes unreachable or a save fails. */
|
/** Throws when the translator becomes unreachable or a save fails. */
|
||||||
export async function runTranslation(opts: RunOptions): Promise<Outcome> {
|
export async function runTranslation(opts: RunOptions): Promise<Outcome> {
|
||||||
const progress: Progress = { total: 0, done: 0, failed: 0, lastError: "", model: null };
|
const start = opts.initial;
|
||||||
|
const progress: Progress = { total: start?.total ?? 0, done: start?.done ?? 0, failed: start?.failed ?? 0, lastError: "", model: null };
|
||||||
const emit = () => opts.onProgress({ ...progress });
|
const emit = () => opts.onProgress({ ...progress });
|
||||||
let skip = 0;
|
let skip = start?.skip ?? 0;
|
||||||
|
let resume = opts.resume;
|
||||||
|
const checkpoint = (jobId: string | null, keys: string[]) =>
|
||||||
|
opts.onCheckpoint?.({ jobId, keys, skip, total: progress.total, done: progress.done, failed: progress.failed });
|
||||||
|
|
||||||
while (!opts.signal.aborted) {
|
while (!opts.signal.aborted) {
|
||||||
const page = await opts.fetchPage(skip, opts.pageSize);
|
let items: Source[];
|
||||||
if (opts.signal.aborted || !page.items.length) break;
|
let job: Job;
|
||||||
// `page.total` still counts the lines that failed earlier, so it is the whole remaining work
|
if (resume) {
|
||||||
progress.total = progress.done + page.total;
|
// the page was already sent before the page was reloaded: pick its result up
|
||||||
const items = page.items.filter((it) => it.source.trim());
|
const taken = resume;
|
||||||
// lines that stay untranslated keep matching the filter, so the next page starts after them
|
resume = undefined;
|
||||||
skip += page.items.length - items.length;
|
const known = await opts.client.job(taken.jobId, opts.signal).catch(() => null);
|
||||||
if (!items.length) continue;
|
// the translator has forgotten it: the lines are still untranslated and come with the next page
|
||||||
|
if (!known) continue;
|
||||||
|
job = known;
|
||||||
|
items = taken.keys.map((key, i) => ({ key, source: job.items[i]?.source ?? "" }));
|
||||||
|
opts.onPage?.(items);
|
||||||
|
} else {
|
||||||
|
const page = await opts.fetchPage(skip, opts.pageSize);
|
||||||
|
if (opts.signal.aborted || !page.items.length) break;
|
||||||
|
// `page.total` still counts the lines that failed earlier, so it is the whole remaining work
|
||||||
|
progress.total = progress.done + page.total;
|
||||||
|
items = page.items.filter((it) => it.source.trim());
|
||||||
|
// lines that stay untranslated keep matching the filter, so the next page starts after them
|
||||||
|
skip += page.items.length - items.length;
|
||||||
|
if (!items.length) continue;
|
||||||
|
|
||||||
opts.onPage?.(items);
|
opts.onPage?.(items);
|
||||||
let job = await opts.client.createJob(
|
job = await opts.client.createJob(
|
||||||
opts.projectId, items.map((it, i) => ({ id: String(i), text: it.source })), opts.batchSize);
|
opts.projectId, items.map((it, i) => ({ id: String(i), text: it.source })), opts.batchSize);
|
||||||
|
checkpoint(job.id, items.map((it) => it.key));
|
||||||
|
}
|
||||||
job = await waitForJob(opts.client, job, opts.signal, opts.pollMs);
|
job = await waitForJob(opts.client, job, opts.signal, opts.pollMs);
|
||||||
progress.model = job.model ?? progress.model;
|
progress.model = job.model ?? progress.model;
|
||||||
|
|
||||||
@@ -101,6 +135,7 @@ export async function runTranslation(opts: RunOptions): Promise<Outcome> {
|
|||||||
if (job.status !== "cancelled") progress.failed += items.length - ready.length;
|
if (job.status !== "cancelled") progress.failed += items.length - ready.length;
|
||||||
skip += items.length - ready.length;
|
skip += items.length - ready.length;
|
||||||
emit();
|
emit();
|
||||||
|
checkpoint(null, []);
|
||||||
if (job.status === "failed") throw new Error(job.error || "Переводчик не смог выполнить задание");
|
if (job.status === "failed") throw new Error(job.error || "Переводчик не смог выполнить задание");
|
||||||
if (job.status === "cancelled") break;
|
if (job.status === "cancelled") break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// The one machine translation run of this browser. It lives above the pages, so it goes on while
|
// The one machine translation run of this browser. It lives above the pages, so it goes on while
|
||||||
// the user moves around the site, and its card sits in the same floating window as the uploads.
|
// the user moves around the site, and its card sits in the same floating window as the uploads.
|
||||||
|
// The run is written down at every page, so reloading the site picks it up where it stood.
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
@@ -7,15 +8,18 @@ import { api } from "../api";
|
|||||||
import type { LangStats } from "../pages/translation-types";
|
import type { LangStats } from "../pages/translation-types";
|
||||||
import { ErrorNote } from "../ui/Bits";
|
import { ErrorNote } from "../ui/Bits";
|
||||||
import { Icon } from "../ui/Icon";
|
import { Icon } from "../ui/Icon";
|
||||||
import type { TranslatorClient } from "./client";
|
import { TranslatorClient } from "./client";
|
||||||
import { runTranslation, type Progress, type Source } from "./run";
|
import { runTranslation, type Checkpoint, type Progress, type Source } from "./run";
|
||||||
|
|
||||||
const PAGE = 40;
|
const PAGE = 40;
|
||||||
|
const SAVED_KEY = "pg.translation.run";
|
||||||
|
const LOCK = "pg.translation.run";
|
||||||
|
/** a run last heard of longer ago than this is not picked up again */
|
||||||
|
const FRESH_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
export type RunState = "untranslated" | "outdated";
|
export type RunState = "untranslated" | "outdated";
|
||||||
|
|
||||||
export interface RunSpec {
|
interface RunInfo {
|
||||||
client: TranslatorClient;
|
|
||||||
projectId: string;
|
projectId: string;
|
||||||
versionId: string;
|
versionId: string;
|
||||||
lang: string;
|
lang: string;
|
||||||
@@ -29,6 +33,22 @@ export interface RunSpec {
|
|||||||
batch: number;
|
batch: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RunSpec extends RunInfo {
|
||||||
|
client: TranslatorClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What is kept in the browser to continue after a reload. */
|
||||||
|
interface SavedRun {
|
||||||
|
info: RunInfo;
|
||||||
|
/** address of the translator */
|
||||||
|
base: string;
|
||||||
|
/** which of `states` was being walked, and the totals of the ones before it */
|
||||||
|
phase: number;
|
||||||
|
before: { total: number; done: number; failed: number };
|
||||||
|
checkpoint: Checkpoint | null;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TranslationTask {
|
export interface TranslationTask {
|
||||||
id: number;
|
id: number;
|
||||||
versionId: string;
|
versionId: string;
|
||||||
@@ -57,7 +77,8 @@ interface RunApi {
|
|||||||
task: TranslationTask | null;
|
task: TranslationTask | null;
|
||||||
/** a run is going: no other can start */
|
/** a run is going: no other can start */
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
start: (spec: RunSpec) => boolean;
|
/** `false` when another run (here or in another tab) already holds the translator */
|
||||||
|
start: (spec: RunSpec) => Promise<boolean>;
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
dismiss: () => void;
|
dismiss: () => void;
|
||||||
subscribe: (listener: (event: FillEvent) => void) => () => void;
|
subscribe: (listener: (event: FillEvent) => void) => () => void;
|
||||||
@@ -66,6 +87,39 @@ interface RunApi {
|
|||||||
const RunContext = createContext<RunApi | null>(null);
|
const RunContext = createContext<RunApi | null>(null);
|
||||||
const EMPTY: Progress = { total: 0, done: 0, failed: 0, lastError: "", model: null };
|
const EMPTY: Progress = { total: 0, done: 0, failed: 0, lastError: "", model: null };
|
||||||
|
|
||||||
|
function loadSaved(): SavedRun | null {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem(SAVED_KEY) ?? "null") as SavedRun | null;
|
||||||
|
return saved && saved.info && Date.now() - saved.at < FRESH_MS ? saved : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function store(saved: SavedRun | null) {
|
||||||
|
try {
|
||||||
|
if (saved) localStorage.setItem(SAVED_KEY, JSON.stringify(saved));
|
||||||
|
else localStorage.removeItem(SAVED_KEY);
|
||||||
|
} catch {
|
||||||
|
/* the run just cannot be picked up after a reload */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs `work` only if no other tab runs a translation; the lock is released when the work ends or the tab goes. */
|
||||||
|
function exclusive(work: () => Promise<void>): Promise<boolean> {
|
||||||
|
if (!navigator.locks) {
|
||||||
|
void work();
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
void navigator.locks.request(LOCK, { ifAvailable: true }, async (lock) => {
|
||||||
|
if (!lock) return resolve(false);
|
||||||
|
resolve(true);
|
||||||
|
await work();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function TranslationRunProvider({ children }: { children: ReactNode }) {
|
export function TranslationRunProvider({ children }: { children: ReactNode }) {
|
||||||
const [task, setTask] = useState<TranslationTask | null>(null);
|
const [task, setTask] = useState<TranslationTask | null>(null);
|
||||||
const control = useRef<AbortController | null>(null);
|
const control = useRef<AbortController | null>(null);
|
||||||
@@ -77,27 +131,42 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
|
|||||||
setTask((t) => (t && t.id === id ? { ...t, ...(typeof change === "function" ? change(t) : change) } : t));
|
setTask((t) => (t && t.id === id ? { ...t, ...(typeof change === "function" ? change(t) : change) } : t));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const start = useCallback((spec: RunSpec) => {
|
const launch = useCallback(async (spec: RunSpec, saved?: SavedRun): Promise<boolean> => {
|
||||||
if (busyRef.current) return false;
|
if (busyRef.current) return false;
|
||||||
busyRef.current = true;
|
busyRef.current = true;
|
||||||
const id = ++sequence.current;
|
const id = ++sequence.current;
|
||||||
const ctl = new AbortController();
|
const ctl = new AbortController();
|
||||||
control.current = ctl;
|
control.current = ctl;
|
||||||
|
const before = saved?.before ?? { total: 0, done: 0, failed: 0 };
|
||||||
|
const resumed = saved?.checkpoint;
|
||||||
setTask({ id, versionId: spec.versionId, lang: spec.lang, title: spec.title, kind: spec.kind, status: "running",
|
setTask({ id, versionId: spec.versionId, lang: spec.lang, title: spec.title, kind: spec.kind, status: "running",
|
||||||
progress: EMPTY, keys: [], cursor: "", error: "" });
|
progress: { ...EMPTY, total: before.total + (resumed?.total ?? 0), done: before.done + (resumed?.done ?? 0),
|
||||||
|
failed: before.failed + (resumed?.failed ?? 0) },
|
||||||
|
keys: [], cursor: "", error: "" });
|
||||||
|
|
||||||
|
const info: RunInfo = {
|
||||||
|
projectId: spec.projectId, versionId: spec.versionId, lang: spec.lang, title: spec.title, kind: spec.kind,
|
||||||
|
module: spec.module, q: spec.q, states: spec.states, batch: spec.batch,
|
||||||
|
};
|
||||||
const url = (state: RunState, offset: number, limit: number) =>
|
const url = (state: RunState, offset: number, limit: number) =>
|
||||||
`/api/versions/${spec.versionId}/i18n/${spec.lang}/entries?${new URLSearchParams({
|
`/api/versions/${spec.versionId}/i18n/${spec.lang}/entries?${new URLSearchParams({
|
||||||
kind: spec.kind, state, module: spec.module, q: spec.q, offset: String(offset), limit: String(limit) })}`;
|
kind: spec.kind, state, module: spec.module, q: spec.q, offset: String(offset), limit: String(limit) })}`;
|
||||||
|
|
||||||
void (async () => {
|
const work = async () => {
|
||||||
const base = { total: 0, done: 0, failed: 0 };
|
// written only once this tab owns the translator, so it never overwrites another tab's record
|
||||||
|
store({ info, base: spec.client.base, phase: saved?.phase ?? 0, before, checkpoint: resumed ?? null, at: Date.now() });
|
||||||
|
const base = { ...before };
|
||||||
let last: Progress = EMPTY;
|
let last: Progress = EMPTY;
|
||||||
try {
|
try {
|
||||||
for (const state of spec.states) {
|
for (let phase = saved?.phase ?? 0; phase < spec.states.length; phase++) {
|
||||||
|
const state = spec.states[phase];
|
||||||
|
const again = phase === saved?.phase ? saved.checkpoint : null;
|
||||||
const outcome = await runTranslation({
|
const outcome = await runTranslation({
|
||||||
client: spec.client, projectId: spec.projectId, batchSize: spec.batch, pageSize: PAGE, signal: ctl.signal,
|
client: spec.client, projectId: spec.projectId, batchSize: spec.batch, pageSize: PAGE, signal: ctl.signal,
|
||||||
|
initial: again ? { total: again.total, done: again.done, failed: again.failed, skip: again.skip } : undefined,
|
||||||
|
resume: again?.jobId ? { jobId: again.jobId, keys: again.keys } : undefined,
|
||||||
fetchPage: (skip, limit) => api.get<{ total: number; items: Source[] }>(url(state, skip, limit)),
|
fetchPage: (skip, limit) => api.get<{ total: number; items: Source[] }>(url(state, skip, limit)),
|
||||||
|
onCheckpoint: (checkpoint) => store({ info, base: spec.client.base, phase, before: { ...base }, checkpoint, at: Date.now() }),
|
||||||
onPage: (items) => patch(id, { keys: items.map((it) => it.key), cursor: items[0]?.key ?? "" }),
|
onPage: (items) => patch(id, { keys: items.map((it) => it.key), cursor: items[0]?.key ?? "" }),
|
||||||
save: async (results) => {
|
save: async (results) => {
|
||||||
const changes = Object.fromEntries(results.map((r) => [r.key, { t: r.text, state: "machine" }]));
|
const changes = Object.fromEntries(results.map((r) => [r.key, { t: r.text, state: "machine" }]));
|
||||||
@@ -121,13 +190,37 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
patch(id, { status: "failed", error: (e as Error).message, keys: [] });
|
patch(id, { status: "failed", error: (e as Error).message, keys: [] });
|
||||||
} finally {
|
} finally {
|
||||||
|
store(null);
|
||||||
busyRef.current = false;
|
busyRef.current = false;
|
||||||
control.current = null;
|
control.current = null;
|
||||||
}
|
}
|
||||||
})();
|
};
|
||||||
return true;
|
|
||||||
|
const got = await exclusive(work);
|
||||||
|
if (!got) {
|
||||||
|
// another tab is translating
|
||||||
|
busyRef.current = false;
|
||||||
|
control.current = null;
|
||||||
|
setTask((t) => (t && t.id === id ? null : t));
|
||||||
|
}
|
||||||
|
return got;
|
||||||
}, [patch]);
|
}, [patch]);
|
||||||
|
|
||||||
|
const start = useCallback((spec: RunSpec) => launch(spec), [launch]);
|
||||||
|
|
||||||
|
// A reload ends the page's loop, but the translator goes on with its page: continue from the saved point.
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = loadSaved();
|
||||||
|
if (!saved) return;
|
||||||
|
const resume = () => launch({ ...saved.info, client: new TranslatorClient(saved.base) }, saved);
|
||||||
|
let retry: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
// the page that was reloaded may still be letting go of its lock: look once more shortly
|
||||||
|
void resume().then((got) => {
|
||||||
|
if (!got && !busyRef.current) retry = setTimeout(() => void resume(), 2000);
|
||||||
|
});
|
||||||
|
return () => clearTimeout(retry);
|
||||||
|
}, [launch]);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
control.current?.abort();
|
control.current?.abort();
|
||||||
setTask((t) => (t && t.status === "running" ? { ...t, status: "stopping" } : t));
|
setTask((t) => (t && t.status === "running" ? { ...t, status: "stopping" } : t));
|
||||||
@@ -142,7 +235,7 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const busy = task?.status === "running" || task?.status === "stopping";
|
const busy = task?.status === "running" || task?.status === "stopping";
|
||||||
|
|
||||||
// closing the tab would end the run: ask first
|
// A reload is survivable now, but it still pauses the run: say so
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!busy) return;
|
if (!busy) return;
|
||||||
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
|
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
|
||||||
|
|||||||
@@ -138,3 +138,39 @@ test("addresses are completed and cleaned", () => {
|
|||||||
assert.equal(normalizeUrl(" 127.0.0.1:8787/ "), "http://127.0.0.1:8787");
|
assert.equal(normalizeUrl(" 127.0.0.1:8787/ "), "http://127.0.0.1:8787");
|
||||||
assert.equal(normalizeUrl("https://t.example//"), "https://t.example");
|
assert.equal(normalizeUrl("https://t.example//"), "https://t.example");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a run that was interrupted picks up the page the translator finished meanwhile", async () => {
|
||||||
|
const server = fakeServer(["a", "b", "c", "d", "e"]);
|
||||||
|
const translator = fakeTranslator();
|
||||||
|
// the page "a","b" was sent before the reload; the translator completed it by itself
|
||||||
|
const sent = await translator.createJob("p", [{ id: "0", text: "a" }, { id: "1", text: "b" }]);
|
||||||
|
const checkpoints = [];
|
||||||
|
const outcome = await run(translator, server, {
|
||||||
|
initial: { total: 5, done: 0, failed: 0, skip: 0 },
|
||||||
|
resume: { jobId: sent.id, keys: ["k0", "k1"] },
|
||||||
|
onCheckpoint: (c) => checkpoints.push(c),
|
||||||
|
});
|
||||||
|
assert.equal(outcome, "finished");
|
||||||
|
assert.deepEqual(Object.keys(server.saved), ["k0", "k1", "k2", "k3", "k4"]);
|
||||||
|
// the first page was not sent twice: the resumed job plus two new pages
|
||||||
|
assert.equal(translator.created.length, 3);
|
||||||
|
const last = checkpoints.at(-1);
|
||||||
|
assert.deepEqual([last.jobId, last.done, last.failed], [null, 5, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a job the translator no longer knows is skipped and its lines come back with the list", async () => {
|
||||||
|
const server = fakeServer(["a", "b", "c"]);
|
||||||
|
const translator = { ...fakeTranslator(), async job() { throw new Error("404"); } };
|
||||||
|
// `job` is also used while waiting, but the resumed job fails at once, so nothing waits
|
||||||
|
const created = [];
|
||||||
|
const client = { ...translator, async createJob(...args) { const j = await fakeTranslator().createJob(...args); created.push(j); return j; } };
|
||||||
|
const outcome = await run(client, server, { pageSize: 3, resume: { jobId: "gone", keys: ["k0"] } });
|
||||||
|
assert.equal(outcome, "finished");
|
||||||
|
assert.deepEqual(Object.keys(server.saved), ["k0", "k1", "k2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkpoints say which page is out and when it is handled", async () => {
|
||||||
|
const marks = [];
|
||||||
|
await run(fakeTranslator(), fakeServer(["a", "b", "c"]), { pageSize: 2, onCheckpoint: (c) => marks.push([c.jobId ? "sent" : "done", c.keys.length, c.skip]) });
|
||||||
|
assert.deepEqual(marks, [["sent", 2, 0], ["done", 0, 0], ["sent", 1, 0], ["done", 0, 0]]);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user