Translator: languages that fail to download are tried again
The first start tried the default languages once; a DNS failure at that moment left them missing for good. Languages asked for are now kept as pending in state.json and retried in the background (30 s, doubling to 15 min) until they are in, across restarts; an install from the admin panel joins the same queue. A state from before this, with nothing installed, counts as an unfinished first start. The admin panel shows what is waiting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -349,6 +349,7 @@ class AdminTranslationOut(BaseModel):
|
|||||||
reachable: bool
|
reachable: bool
|
||||||
installed: list[str] = []
|
installed: list[str] = []
|
||||||
installing: dict[str, InstallProgressOut] = {}
|
installing: dict[str, InstallProgressOut] = {}
|
||||||
|
pending: list[str] = [] # waiting to be tried again (no network last time)
|
||||||
available: list[str] = [] # what Mozilla has models for (empty: offline)
|
available: list[str] = [] # what Mozilla has models for (empty: offline)
|
||||||
errors: dict[str, str] = {} # why a language's last install failed
|
errors: dict[str, str] = {} # why a language's last install failed
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ async def test_unreachable_service_is_reported(
|
|||||||
"reachable": False,
|
"reachable": False,
|
||||||
"installed": [],
|
"installed": [],
|
||||||
"installing": {},
|
"installing": {},
|
||||||
|
"pending": [],
|
||||||
"available": [],
|
"available": [],
|
||||||
"errors": {},
|
"errors": {},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,8 +300,11 @@ function Translation() {
|
|||||||
queryKey: ["adminTranslation"],
|
queryKey: ["adminTranslation"],
|
||||||
queryFn: api.adminTranslation,
|
queryFn: api.adminTranslation,
|
||||||
// While something downloads, follow its progress.
|
// While something downloads, follow its progress.
|
||||||
refetchInterval: (q) =>
|
refetchInterval: (q) => {
|
||||||
Object.keys((q.state.data as AdminTranslation | undefined)?.installing ?? {}).length ? 1500 : false,
|
const d = q.state.data as AdminTranslation | undefined;
|
||||||
|
if (Object.keys(d?.installing ?? {}).length) return 1500;
|
||||||
|
return d?.pending.length ? 10000 : false; // retried in the background
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const done = (data: AdminTranslation) => {
|
const done = (data: AdminTranslation) => {
|
||||||
queryClient.setQueryData(["adminTranslation"], data);
|
queryClient.setQueryData(["adminTranslation"], data);
|
||||||
@@ -321,7 +324,12 @@ function Translation() {
|
|||||||
const busy = install.isPending || remove.isPending;
|
const busy = install.isPending || remove.isPending;
|
||||||
const installing = Object.entries(data?.installing ?? {});
|
const installing = Object.entries(data?.installing ?? {});
|
||||||
const addable = (data?.available ?? [])
|
const addable = (data?.available ?? [])
|
||||||
.filter((c) => !data?.installed.includes(c) && !(c in (data?.installing ?? {})))
|
.filter(
|
||||||
|
(c) =>
|
||||||
|
!data?.installed.includes(c) &&
|
||||||
|
!(c in (data?.installing ?? {})) &&
|
||||||
|
!data?.pending.includes(c),
|
||||||
|
)
|
||||||
.sort((a, b) => name(a).localeCompare(name(b), locale));
|
.sort((a, b) => name(a).localeCompare(name(b), locale));
|
||||||
const error = (install.error ?? remove.error) as unknown;
|
const error = (install.error ?? remove.error) as unknown;
|
||||||
|
|
||||||
@@ -377,6 +385,13 @@ function Translation() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
{data.pending.length > 0 && (
|
||||||
|
<p className="text-xs text-amber-600">
|
||||||
|
{t("admin.translationPending", {
|
||||||
|
languages: data.pending.map(name).join(", "),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{Object.entries(data.errors).map(([code, why]) => (
|
{Object.entries(data.errors).map(([code, why]) => (
|
||||||
<p key={code} className="text-xs text-red-500">
|
<p key={code} className="text-xs text-red-500">
|
||||||
{t("admin.translationFailed", { language: name(code) })} {why}
|
{t("admin.translationFailed", { language: name(code) })} {why}
|
||||||
|
|||||||
@@ -701,6 +701,7 @@ export const en = {
|
|||||||
"admin.translationRemoveTitle": "Remove {language}?",
|
"admin.translationRemoveTitle": "Remove {language}?",
|
||||||
"admin.translationRemoveConfirm": "Its models are deleted. Mail in this language can no longer be translated until it is installed again.",
|
"admin.translationRemoveConfirm": "Its models are deleted. Mail in this language can no longer be translated until it is installed again.",
|
||||||
"admin.translationInstalling": "downloading… {percent}%",
|
"admin.translationInstalling": "downloading… {percent}%",
|
||||||
|
"admin.translationPending": "Waiting to download: {languages}. Tried again in the background until the models can be fetched.",
|
||||||
"admin.translationFailed": "Installing {language} failed:",
|
"admin.translationFailed": "Installing {language} failed:",
|
||||||
"admin.translationChoose": "Add a language…",
|
"admin.translationChoose": "Add a language…",
|
||||||
"admin.translationInstall": "Install",
|
"admin.translationInstall": "Install",
|
||||||
|
|||||||
@@ -712,6 +712,7 @@ export const pl: Catalog = {
|
|||||||
"admin.translationRemoveTitle": "Usunąć: {language}?",
|
"admin.translationRemoveTitle": "Usunąć: {language}?",
|
||||||
"admin.translationRemoveConfirm": "Jego modele zostaną usunięte. Poczty w tym języku nie będzie można tłumaczyć, dopóki nie zostanie zainstalowany ponownie.",
|
"admin.translationRemoveConfirm": "Jego modele zostaną usunięte. Poczty w tym języku nie będzie można tłumaczyć, dopóki nie zostanie zainstalowany ponownie.",
|
||||||
"admin.translationInstalling": "pobieranie… {percent}%",
|
"admin.translationInstalling": "pobieranie… {percent}%",
|
||||||
|
"admin.translationPending": "Czeka na pobranie: {languages}. Ponowne próby trwają w tle, aż modele uda się pobrać.",
|
||||||
"admin.translationFailed": "Instalacja nie powiodła się: {language}.",
|
"admin.translationFailed": "Instalacja nie powiodła się: {language}.",
|
||||||
"admin.translationChoose": "Dodaj język…",
|
"admin.translationChoose": "Dodaj język…",
|
||||||
"admin.translationInstall": "Zainstaluj",
|
"admin.translationInstall": "Zainstaluj",
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export interface AdminTranslation {
|
|||||||
installed: string[];
|
installed: string[];
|
||||||
/** Downloads in progress: bytes done of total (total 0 until known). */
|
/** Downloads in progress: bytes done of total (total 0 until known). */
|
||||||
installing: Record<string, { done: number; total: number }>;
|
installing: Record<string, { done: number; total: number }>;
|
||||||
|
/** Asked for, not downloaded yet: tried again in the background. */
|
||||||
|
pending: string[];
|
||||||
/** Languages Mozilla has models for; empty when the catalogue is out of reach. */
|
/** Languages Mozilla has models for; empty when the catalogue is out of reach. */
|
||||||
available: string[];
|
available: string[];
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string>;
|
||||||
|
|||||||
+71
-27
@@ -6,9 +6,12 @@ internal network. A message's text never leaves the server.
|
|||||||
- Firefox's models translate to and from English; any other pair goes
|
- Firefox's models translate to and from English; any other pair goes
|
||||||
through English (Polish → English → German). So a language is installed as
|
through English (Polish → English → German). So a language is installed as
|
||||||
its two models, to and from English, and English is always there.
|
its two models, to and from English, and English is always there.
|
||||||
- What is installed is kept in ``$DATA_DIR/state.json``. The first start
|
- What is installed is kept in ``$DATA_DIR/state.json``, with the languages
|
||||||
installs ``TRANSLATOR_LANGUAGES``; after that the admin panel adds and
|
still to install. The first start asks for ``TRANSLATOR_LANGUAGES``; after
|
||||||
removes languages (``POST``/``DELETE /languages/{code}``).
|
that the admin panel adds and removes languages (``POST``/``DELETE
|
||||||
|
/languages/{code}``). A language that could not be downloaded (no network
|
||||||
|
yet) is tried again in the background, at growing intervals, until it is
|
||||||
|
in — across restarts too.
|
||||||
- Models come from Mozilla's Remote Settings (where Firefox gets them), each
|
- Models come from Mozilla's Remote Settings (where Firefox gets them), each
|
||||||
file checked against its SHA-256; only release models (no test builds).
|
file checked against its SHA-256; only release models (no test builds).
|
||||||
- Language identification: fastText's lid.176 (176 languages), baked into
|
- Language identification: fastText's lid.176 (176 languages), baked into
|
||||||
@@ -113,13 +116,30 @@ def _installed() -> list[str]:
|
|||||||
return [PIVOT] + [c for c in state.get("languages", []) if c != PIVOT]
|
return [PIVOT] + [c for c in state.get("languages", []) if c != PIVOT]
|
||||||
|
|
||||||
|
|
||||||
def _write_installed(codes: list[str]) -> None:
|
def _pending() -> list[str]:
|
||||||
|
"""Languages asked for and not installed yet (tried again until they are)."""
|
||||||
|
return list((_read_state() or {}).get("pending", []))
|
||||||
|
|
||||||
|
|
||||||
|
def _write_state(installed: list[str], pending: list[str]) -> None:
|
||||||
DATA.mkdir(parents=True, exist_ok=True)
|
DATA.mkdir(parents=True, exist_ok=True)
|
||||||
tmp = STATE.with_suffix(".tmp")
|
tmp = STATE.with_suffix(".tmp")
|
||||||
tmp.write_text(json.dumps({"languages": sorted(set(codes) - {PIVOT})}))
|
done = set(installed) - {PIVOT}
|
||||||
|
tmp.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"languages": sorted(done),
|
||||||
|
"pending": [c for c in dict.fromkeys(pending) if c not in done and c != PIVOT],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
tmp.replace(STATE)
|
tmp.replace(STATE)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_installed(codes: list[str]) -> None:
|
||||||
|
_write_state(codes, _pending())
|
||||||
|
|
||||||
|
|
||||||
# --- Mozilla's model catalogue -----------------------------------------------------------
|
# --- Mozilla's model catalogue -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -211,12 +231,12 @@ def _install(code: str) -> None:
|
|||||||
for source, target in directions:
|
for source, target in directions:
|
||||||
_install_direction(code, source, target)
|
_install_direction(code, source, target)
|
||||||
with _lock:
|
with _lock:
|
||||||
_write_installed(_installed() + [code])
|
_write_state(_installed() + [code], [c for c in _pending() if c != code])
|
||||||
for pair in (f"{code}-{PIVOT}", f"{PIVOT}-{code}"):
|
for pair in (f"{code}-{PIVOT}", f"{PIVOT}-{code}"):
|
||||||
_loaded.pop(pair, None) # a reinstall: load the new files
|
_loaded.pop(pair, None) # a reinstall: load the new files
|
||||||
_errors.pop(code, None)
|
_errors.pop(code, None)
|
||||||
log.info("installed %s", code)
|
log.info("installed %s", code)
|
||||||
except Exception as exc: # noqa: BLE001 — reported in /languages, tried again by hand
|
except Exception as exc: # noqa: BLE001 — reported in /languages, tried again later
|
||||||
log.warning("installing %s failed: %s", code, exc)
|
log.warning("installing %s failed: %s", code, exc)
|
||||||
with _lock:
|
with _lock:
|
||||||
_errors[code] = str(exc)[:300]
|
_errors[code] = str(exc)[:300]
|
||||||
@@ -225,34 +245,53 @@ def _install(code: str) -> None:
|
|||||||
_installing.pop(code, None)
|
_installing.pop(code, None)
|
||||||
|
|
||||||
|
|
||||||
def _start_install(code: str) -> bool:
|
_wake = threading.Event() # a new language to install: try now, not after the pause
|
||||||
with _lock:
|
_RETRY_FIRST, _RETRY_MAX = 30.0, 900.0
|
||||||
if code in _installing:
|
|
||||||
return False
|
|
||||||
_installing[code] = {"done": 0, "total": 0}
|
|
||||||
threading.Thread(target=_install, args=(code,), daemon=True).start()
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _first_start() -> None:
|
def _installer() -> None:
|
||||||
"""The first start installs the default languages, one after another."""
|
"""Install the pending languages, one after another; while some fail
|
||||||
if _read_state() is not None:
|
(no network, Mozilla out of reach), try again after a pause that grows
|
||||||
return
|
from half a minute to a quarter of an hour."""
|
||||||
_write_installed([])
|
pause = _RETRY_FIRST
|
||||||
|
while True:
|
||||||
def run() -> None:
|
for code in _pending():
|
||||||
for code in DEFAULT_LANGUAGES:
|
|
||||||
if code and code != PIVOT:
|
|
||||||
with _lock:
|
with _lock:
|
||||||
|
if code in _installing or code in _installed():
|
||||||
|
continue
|
||||||
_installing[code] = {"done": 0, "total": 0}
|
_installing[code] = {"done": 0, "total": 0}
|
||||||
_install(code)
|
_install(code)
|
||||||
|
if not _pending():
|
||||||
|
pause = _RETRY_FIRST
|
||||||
|
_wake.wait() # nothing to do until a language is asked for
|
||||||
|
else:
|
||||||
|
_wake.wait(pause)
|
||||||
|
pause = min(pause * 2, _RETRY_MAX)
|
||||||
|
_wake.clear()
|
||||||
|
|
||||||
threading.Thread(target=run, daemon=True).start()
|
|
||||||
|
def _ask_for(code: str) -> None:
|
||||||
|
with _lock:
|
||||||
|
_write_state(_installed(), _pending() + [code])
|
||||||
|
_errors.pop(code, None)
|
||||||
|
_wake.set()
|
||||||
|
|
||||||
|
|
||||||
|
def _start() -> None:
|
||||||
|
"""The first start asks for the default languages; every start resumes
|
||||||
|
what is still pending."""
|
||||||
|
state = _read_state()
|
||||||
|
# No state yet — or one from before pending languages were kept, with
|
||||||
|
# nothing installed (a first start that could not download): ask for the
|
||||||
|
# defaults.
|
||||||
|
if state is None or ("pending" not in state and not state.get("languages")):
|
||||||
|
_write_state([], [c for c in DEFAULT_LANGUAGES if c])
|
||||||
|
threading.Thread(target=_installer, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _lifespan(_: FastAPI) -> AsyncIterator[None]:
|
async def _lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||||
_first_start()
|
_start()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@@ -377,6 +416,7 @@ def languages() -> dict[str, Any]:
|
|||||||
return {
|
return {
|
||||||
"installed": _installed(),
|
"installed": _installed(),
|
||||||
"installing": {k: dict(v) for k, v in _installing.items()},
|
"installing": {k: dict(v) for k, v in _installing.items()},
|
||||||
|
"pending": [c for c in _pending() if c not in _installing],
|
||||||
"available": available,
|
"available": available,
|
||||||
"errors": dict(_errors),
|
"errors": dict(_errors),
|
||||||
}
|
}
|
||||||
@@ -391,7 +431,10 @@ def install(code: str) -> dict[str, Any]:
|
|||||||
_release_files(PIVOT, code)
|
_release_files(PIVOT, code)
|
||||||
except LookupError as exc:
|
except LookupError as exc:
|
||||||
raise HTTPException(404, str(exc)) from exc
|
raise HTTPException(404, str(exc)) from exc
|
||||||
return {"started": _start_install(code)}
|
except Exception as exc: # noqa: BLE001 — Mozilla out of reach: queue it anyway
|
||||||
|
log.info("model catalogue unavailable, %s queued: %s", code, exc)
|
||||||
|
_ask_for(code)
|
||||||
|
return {"started": True}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/languages/{code}")
|
@app.delete("/languages/{code}")
|
||||||
@@ -399,7 +442,8 @@ def remove(code: str) -> dict[str, bool]:
|
|||||||
if code == PIVOT:
|
if code == PIVOT:
|
||||||
raise HTTPException(400, "English is always installed")
|
raise HTTPException(400, "English is always installed")
|
||||||
with _lock:
|
with _lock:
|
||||||
_write_installed([c for c in _installed() if c != code])
|
_write_state([c for c in _installed() if c != code], [c for c in _pending() if c != code])
|
||||||
|
_errors.pop(code, None)
|
||||||
for pair in (f"{code}-{PIVOT}", f"{PIVOT}-{code}"):
|
for pair in (f"{code}-{PIVOT}", f"{PIVOT}-{code}"):
|
||||||
_loaded.pop(pair, None)
|
_loaded.pop(pair, None)
|
||||||
shutil.rmtree(MODELS / pair, ignore_errors=True)
|
shutil.rmtree(MODELS / pair, ignore_errors=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user