diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 4606a82..b2b2e72 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -349,6 +349,7 @@ class AdminTranslationOut(BaseModel): reachable: bool installed: list[str] = [] 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) errors: dict[str, str] = {} # why a language's last install failed diff --git a/backend/tests/api/test_admin_translation.py b/backend/tests/api/test_admin_translation.py index 9dd1b0c..c7a263a 100644 --- a/backend/tests/api/test_admin_translation.py +++ b/backend/tests/api/test_admin_translation.py @@ -76,6 +76,7 @@ async def test_unreachable_service_is_reported( "reachable": False, "installed": [], "installing": {}, + "pending": [], "available": [], "errors": {}, } diff --git a/frontend/src/admin/AdminApp.tsx b/frontend/src/admin/AdminApp.tsx index 7386034..0f1c6b2 100644 --- a/frontend/src/admin/AdminApp.tsx +++ b/frontend/src/admin/AdminApp.tsx @@ -300,8 +300,11 @@ function Translation() { queryKey: ["adminTranslation"], queryFn: api.adminTranslation, // While something downloads, follow its progress. - refetchInterval: (q) => - Object.keys((q.state.data as AdminTranslation | undefined)?.installing ?? {}).length ? 1500 : false, + refetchInterval: (q) => { + 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) => { queryClient.setQueryData(["adminTranslation"], data); @@ -321,7 +324,12 @@ function Translation() { const busy = install.isPending || remove.isPending; const installing = Object.entries(data?.installing ?? {}); 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)); const error = (install.error ?? remove.error) as unknown; @@ -377,6 +385,13 @@ function Translation() { ))} + {data.pending.length > 0 && ( +

+ {t("admin.translationPending", { + languages: data.pending.map(name).join(", "), + })} +

+ )} {Object.entries(data.errors).map(([code, why]) => (

{t("admin.translationFailed", { language: name(code) })} {why} diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index e8fa0d5..c5c15fe 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -701,6 +701,7 @@ export const en = { "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.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.translationChoose": "Add a language…", "admin.translationInstall": "Install", diff --git a/frontend/src/i18n/pl.ts b/frontend/src/i18n/pl.ts index 4f13ea4..92a85c1 100644 --- a/frontend/src/i18n/pl.ts +++ b/frontend/src/i18n/pl.ts @@ -712,6 +712,7 @@ export const pl: Catalog = { "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.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.translationChoose": "Dodaj język…", "admin.translationInstall": "Zainstaluj", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 1c09e93..33351b9 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -49,6 +49,8 @@ export interface AdminTranslation { installed: string[]; /** Downloads in progress: bytes done of total (total 0 until known). */ installing: Record; + /** 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. */ available: string[]; errors: Record; diff --git a/translator/translator.py b/translator/translator.py index 337ca2e..9d1c30f 100644 --- a/translator/translator.py +++ b/translator/translator.py @@ -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 through English (Polish → English → German). So a language is installed as 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 - installs ``TRANSLATOR_LANGUAGES``; after that the admin panel adds and - removes languages (``POST``/``DELETE /languages/{code}``). +- What is installed is kept in ``$DATA_DIR/state.json``, with the languages + still to install. The first start asks for ``TRANSLATOR_LANGUAGES``; after + 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 file checked against its SHA-256; only release models (no test builds). - 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] -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) 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) +def _write_installed(codes: list[str]) -> None: + _write_state(codes, _pending()) + + # --- Mozilla's model catalogue ----------------------------------------------------------- @@ -211,12 +231,12 @@ def _install(code: str) -> None: for source, target in directions: _install_direction(code, source, target) 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}"): _loaded.pop(pair, None) # a reinstall: load the new files _errors.pop(code, None) 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) with _lock: _errors[code] = str(exc)[:300] @@ -225,34 +245,53 @@ def _install(code: str) -> 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 +_RETRY_FIRST, _RETRY_MAX = 30.0, 900.0 + + +def _installer() -> None: + """Install the pending languages, one after another; while some fail + (no network, Mozilla out of reach), try again after a pause that grows + from half a minute to a quarter of an hour.""" + pause = _RETRY_FIRST + while True: + for code in _pending(): + with _lock: + if code in _installing or code in _installed(): + continue + _installing[code] = {"done": 0, "total": 0} + _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() + + +def _ask_for(code: str) -> None: with _lock: - if code in _installing: - return False - _installing[code] = {"done": 0, "total": 0} - threading.Thread(target=_install, args=(code,), daemon=True).start() - return True + _write_state(_installed(), _pending() + [code]) + _errors.pop(code, None) + _wake.set() -def _first_start() -> None: - """The first start installs the default languages, one after another.""" - if _read_state() is not None: - return - _write_installed([]) - - def run() -> None: - for code in DEFAULT_LANGUAGES: - if code and code != PIVOT: - with _lock: - _installing[code] = {"done": 0, "total": 0} - _install(code) - - threading.Thread(target=run, daemon=True).start() +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 async def _lifespan(_: FastAPI) -> AsyncIterator[None]: - _first_start() + _start() yield @@ -377,6 +416,7 @@ def languages() -> dict[str, Any]: return { "installed": _installed(), "installing": {k: dict(v) for k, v in _installing.items()}, + "pending": [c for c in _pending() if c not in _installing], "available": available, "errors": dict(_errors), } @@ -391,7 +431,10 @@ def install(code: str) -> dict[str, Any]: _release_files(PIVOT, code) except LookupError as 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}") @@ -399,7 +442,8 @@ def remove(code: str) -> dict[str, bool]: if code == PIVOT: raise HTTPException(400, "English is always installed") 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}"): _loaded.pop(pair, None) shutil.rmtree(MODELS / pair, ignore_errors=True)