feat(web-app-docs): build a translated site on request instead of all of them inline

build() ran sphinx once per translated language before returning, so a deploy blocked for roughly 30 minutes per language. With 29 translated catalogs German sat eighth and its site appeared after about three and a half hours, which is why the language spec polled /deployed/de/ into a 404 for its whole run. The server already answered an unbuilt version with 202 and a build request; an unbuilt language got a bare 404.

Give languages the same treatment. request() takes a code and writes a version:code marker, build_language() builds exactly one from its own checkout via the extracted _checkout(), and the server answers a translated but unbuilt language with 202 while an untranslated one keeps its 404. No language is dropped: build() now queues every translated one into queue/background/, which next_queued() drains only after the lane a visitor is waiting on, so the requested language overtakes the backlog.

languages.json grows a translated list beside known, and _language_index() reads a pre-split file as known-only so an existing volume keeps naming its languages instead of rendering an empty switcher.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-25 12:30:06 +02:00
1 parent 0ff296faf2
commit f5eff500c1
4 files changed
+300 -76

No files matched your search

@@ -22,6 +22,8 @@ LATEST = "latest"
DEPLOYED = "deployed"
LOG_TAIL = 40
POLL_SECONDS = 2
QUEUE_SEPARATOR = ":"
BACKGROUND_LANE = "background"
REFS_TTL_SECONDS = 10
TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
@@ -198,25 +200,54 @@ class Library:
)
return report
def request(self, version):
"""Queue a build of ``version`` unless its site is current.
def request(self, version, code=None, background=False):
"""Queue a build of ``version``, or of one of its translated sites.
Args:
version: ``latest`` or a release tag.
code: ISO 639-1 code to build instead of the version's own site.
background: queue behind every language a visitor asked for.
"""
head, _ = self.refs()
if (not head and version != DEPLOYED) or self._current(version, head):
if not head and version != DEPLOYED:
return
self.queue.mkdir(parents=True, exist_ok=True)
(self.queue / version).touch(exist_ok=True)
if code is None:
if self._current(version, head):
return
marker = version
elif self.translation_servable(version, code) or not self.translates(
version, code
):
return
else:
marker = f"{version}{QUEUE_SEPARATOR}{code}"
lane = self.queue / BACKGROUND_LANE if background else self.queue
lane.mkdir(parents=True, exist_ok=True)
if background and (self.queue / marker).exists():
return
(lane / marker).touch(exist_ok=True)
def _dequeue(self, marker):
"""Drop ``marker`` from both lanes.
Args:
marker: queue file name, ``version`` or ``version:code``.
"""
(self.queue / marker).unlink(missing_ok=True)
(self.queue / BACKGROUND_LANE / marker).unlink(missing_ok=True)
def next_queued(self):
if not self.queue.is_dir():
return None
waiting = sorted(
self.queue.iterdir(), key=lambda marker: marker.stat().st_mtime
)
return waiting[0].name if waiting else None
"""Return the oldest marker a visitor is waiting on, else the oldest background one."""
for lane in (self.queue, self.queue / BACKGROUND_LANE):
if not lane.is_dir():
continue
waiting = sorted(
(marker for marker in lane.iterdir() if marker.is_file()),
key=lambda marker: marker.stat().st_mtime,
)
if waiting:
return waiting[0].name
return None
def acquire_builder(self):
"""Try to become the builder of all replicas without blocking.
@@ -268,9 +299,13 @@ class Library:
except (OSError, subprocess.CalledProcessError) as exc:
print(f"fetch of {self.repository} failed: {exc}", file=sys.stderr)
next_fetch = time.monotonic() + interval
version = self.next_queued()
if version is None:
marker = self.next_queued()
if marker is None:
time.sleep(POLL_SECONDS)
continue
version, separator, code = marker.partition(QUEUE_SEPARATOR)
if separator:
self.build_language(version, code)
else:
self.build(version)
@@ -287,6 +322,19 @@ class Library:
"""
return self._resolve(self.sites / version / "html", rest)
def _language_index(self, version):
try:
payload = json.loads(
(self.translations / version / "languages.json").read_text(
encoding="utf-8"
)
)
except (OSError, ValueError):
return {}
if isinstance(payload.get("known"), dict):
return payload
return {"known": payload, "translated": []}
def languages(self, version):
"""Return the languages of ``version`` and the ones with a built site.
@@ -297,19 +345,21 @@ class Library:
``(known, built)``: every language code of the version mapped to
its native name, and the codes whose translated site is servable.
"""
try:
known = json.loads(
(self.translations / version / "languages.json").read_text(
encoding="utf-8"
)
)
except (OSError, ValueError):
return {}, []
known = self._language_index(version).get("known") or {}
built = [
code for code in sorted(known) if self.translation_servable(version, code)
]
return known, built
def translates(self, version, code):
"""Return whether ``version`` carries translations for ``code``.
Args:
version: ``latest`` or a release tag.
code: ISO 639-1 code.
"""
return code in (self._language_index(version).get("translated") or [])
def translation_servable(self, version, code):
return (self.translations / version / code / "html" / "index.html").is_file()
@@ -352,10 +402,10 @@ class Library:
self._forget_refs()
head, _ = self.refs()
if version not in self.versions():
(self.queue / version).unlink(missing_ok=True)
self._dequeue(version)
return
if self._current(version, head):
(self.queue / version).unlink(missing_ok=True)
self._dequeue(version)
return
ref = {LATEST: head, DEPLOYED: self.snapshot_ref()}.get(version, version)
work = self.scratch / version
@@ -364,21 +414,7 @@ class Library:
state = {"state": "building", "phase": "checkout", "progress": 0, "log": []}
try:
self._save_state(version, **state)
shutil.rmtree(work, ignore_errors=True)
if version == DEPLOYED:
shutil.copytree(self.snapshot, src)
else:
src.mkdir(parents=True)
archive = work / "src.tar"
self._git("archive", "--format=tar", "-o", str(archive), ref)
with tarfile.open(archive) as tar:
tar.extractall(src, filter="data")
shutil.copytree(self.package_dir, conf)
if (src / "assets" / "img").is_dir():
shutil.copytree(
src / "assets" / "img", conf / "assets" / "img", dirs_exist_ok=True
)
self._checkout(version, ref, work)
generators = generate_commands(src)
env = {**os.environ, "PYTHONPATH": tooling}
@@ -389,12 +425,7 @@ class Library:
self._save_state(version, **state)
state["phase"] = "sphinx"
sphinx_env = {
**os.environ,
"PYTHONPATH": os.pathsep.join([str(src), tooling]),
"PYTHONUNBUFFERED": "1",
"DOCS_VERSION": version,
}
sphinx_env = self._sphinx_env(version, src)
self._run(
version,
state,
@@ -415,42 +446,108 @@ class Library:
self._publish(version, out / "html", ref)
known, translated = translated_languages(src)
(self.translations / version).mkdir(parents=True, exist_ok=True)
_write_json(self.translations / version / "languages.json", known)
_write_json(
self.translations / version / "languages.json",
{"known": known, "translated": translated},
)
self._save_state(version, state="ready", phase="", progress=100, log=[])
for code in translated:
state["phase"] = f"translate {code}"
self._save_state(version, **state)
target = work / f"out-{code}"
self._run(
version,
state,
[
"sphinx-build",
"-M",
"html",
str(src),
str(target),
"-c",
str(conf),
"-j",
str(self.jobs),
"-D",
f"language={code}",
"-D",
"html_copy_source=0",
],
sphinx_env,
work,
)
self._publish_site(
self.translations / version, code, target / "html", ref
)
self.request(version, code, background=True)
except (OSError, subprocess.CalledProcessError, tarfile.TarError) as exc:
state["log"] = [*state["log"][-(LOG_TAIL - 1) :], str(exc)]
self._save_state(version, **{**state, "state": "failed"})
finally:
self._dequeue(version)
shutil.rmtree(work, ignore_errors=True)
def _checkout(self, version, ref, work):
"""Lay out ``src`` and ``conf`` of ``ref`` under a fresh ``work``.
Args:
version: ``latest`` or a release tag.
ref: the commit or digest the version resolves to.
work: scratch directory to replace.
"""
src, conf = work / "src", work / "conf"
shutil.rmtree(work, ignore_errors=True)
if version == DEPLOYED:
shutil.copytree(self.snapshot, src)
else:
src.mkdir(parents=True)
archive = work / "src.tar"
self._git("archive", "--format=tar", "-o", str(archive), ref)
with tarfile.open(archive) as tar:
tar.extractall(src, filter="data")
shutil.copytree(self.package_dir, conf)
if (src / "assets" / "img").is_dir():
shutil.copytree(
src / "assets" / "img", conf / "assets" / "img", dirs_exist_ok=True
)
def _sphinx_env(self, version, src):
return {
**os.environ,
"PYTHONPATH": os.pathsep.join([str(src), str(self.package_dir.parent)]),
"PYTHONUNBUFFERED": "1",
"DOCS_VERSION": version,
}
def build_language(self, version, code):
"""Build one translated site of ``version`` into its own scratch.
Args:
version: ``latest`` or a release tag.
code: ISO 639-1 code of a language the version's catalogs translate.
"""
marker = f"{version}{QUEUE_SEPARATOR}{code}"
self._forget_refs()
head, _ = self.refs()
if not self._current(version, head) or not self.translates(version, code):
self._dequeue(marker)
self.request(version)
return
ref = {LATEST: head, DEPLOYED: self.snapshot_ref()}.get(version, version)
work = self.scratch / f"{version}{QUEUE_SEPARATOR}{code}"
src, conf = work / "src", work / "conf"
target = work / "out"
state = {
"state": "building",
"phase": f"translate {code}",
"progress": 0,
"log": [],
}
try:
self._save_state(version, **state)
self._checkout(version, ref, work)
self._run(
version,
state,
[
"sphinx-build",
"-M",
"html",
str(src),
str(target),
"-c",
str(conf),
"-j",
str(self.jobs),
"-D",
f"language={code}",
"-D",
"html_copy_source=0",
],
self._sphinx_env(version, src),
work,
)
self._publish_site(self.translations / version, code, target / "html", ref)
self._save_state(version, state="ready", phase="", progress=100, log=[])
except (OSError, subprocess.CalledProcessError, tarfile.TarError) as exc:
state["log"] = [*state["log"][-(LOG_TAIL - 1) :], str(exc)]
self._save_state(version, **{**state, "state": "failed"})
finally:
(self.queue / version).unlink(missing_ok=True)
shutil.rmtree(work, ignore_errors=True)
self._dequeue(marker)
def _publish(self, version, html, ref):
self._publish_site(self.sites, version, html, ref)
@@ -140,7 +140,12 @@ class DocsHandler(SimpleHTTPRequestHandler):
def _translation(self, version, code, path, head):
rest = path.lstrip("/").partition("/")[2].partition("/")[2]
if not self.library.translation_servable(version, code):
self.send_error(HTTPStatus.NOT_FOUND)
if not self.library.translates(version, code):
self.send_error(HTTPStatus.NOT_FOUND)
return
self.library.request(version, code)
body = BUILDING.format(version=html.escape(f"{version}/{code}"))
self._page(HTTPStatus.ACCEPTED, f"Building {version}/{code}", body, head)
elif (not rest and not path.endswith("/")) or (
target := self.library.resolve_translation(version, code, rest)
) is None:
@@ -1,6 +1,7 @@
from __future__ import annotations
import importlib
import json
import os
import subprocess
import sys
@@ -161,7 +162,86 @@ class TestLibrary(unittest.TestCase):
shelf.request("deployed")
self.assertFalse((shelf.queue / "deployed").exists())
def test_every_translated_language_gets_its_own_site(self) -> None:
def _drain(self, limit=20):
"""Run the builder's dispatch until the queue empties.
Args:
limit: hard stop so a marker that never clears fails loudly.
"""
drained = []
for _ in range(limit):
marker = self.library.next_queued()
if marker is None:
return drained
drained.append(marker)
version, separator, code = marker.partition(":")
if separator:
self.library.build_language(version, code)
else:
self.library.build(version)
self.fail(f"queue did not empty in {limit} steps, drained {drained}")
def test_the_builder_drains_every_queued_language(self) -> None:
(self.repo / "meta").mkdir()
(self.repo / "meta" / "languages.yml").write_text(
"en:\n native: English\nde:\n native: Deutsch\n"
"fr:\n native: Français\nit:\n native: Italiano\n",
encoding="utf-8",
)
for code in ("de", "fr", "it"):
catalog = self.repo / "locale" / code / "LC_MESSAGES" / "docs.po"
catalog.parent.mkdir(parents=True)
catalog.write_text(
f'msgid ""\nmsgstr ""\n"Language: {code}\\n"\n\nmsgid "Hello"\nmsgstr "Hallo {code}"\n',
encoding="utf-8",
)
_commit(self.repo, "three translations")
self.library.fetch()
self.library.build("latest")
self.library.request("latest", "it")
drained = self._drain()
self.assertEqual(
drained[0], "latest:it", "the requested language must run first"
)
self.assertEqual(sorted(drained), ["latest:de", "latest:fr", "latest:it"])
self.assertEqual(self.library.languages("latest")[1], ["de", "fr", "it"])
self.assertIsNone(self.library.next_queued())
def test_a_language_index_from_before_the_split_still_names_its_languages(
self,
) -> None:
index = self.library.translations / "latest" / "languages.json"
index.parent.mkdir(parents=True, exist_ok=True)
index.write_text(json.dumps({"de": "Deutsch", "fr": "Français"}), "utf-8")
site = self.library.translations / "latest" / "de" / "html"
site.mkdir(parents=True, exist_ok=True)
(site / "index.html").write_text("de", encoding="utf-8")
known, built = self.library.languages("latest")
self.assertEqual(sorted(known), ["de", "fr"])
self.assertEqual(built, ["de"])
self.assertFalse(self.library.translates("latest", "de"))
def test_a_requested_language_outruns_an_older_background_one(self) -> None:
background = self.library.queue / "background"
background.mkdir(parents=True, exist_ok=True)
for stale in (*self.library.queue.iterdir(), *background.iterdir()):
if stale.is_file():
stale.unlink()
(background / "latest:ar").touch()
(self.library.queue / "latest:de").touch()
self.assertEqual(self.library.next_queued(), "latest:de")
self.library._dequeue("latest:de")
self.assertEqual(self.library.next_queued(), "latest:ar")
self.library._dequeue("latest:ar")
self.assertIsNone(self.library.next_queued())
def test_a_translated_language_builds_its_site_on_request(self) -> None:
(self.repo / "meta").mkdir()
(self.repo / "meta" / "languages.yml").write_text(
"en:\n native: English\nde:\n native: Deutsch\nfr:\n native: Français\n",
@@ -181,7 +261,27 @@ class TestLibrary(unittest.TestCase):
known, built = self.library.languages("latest")
self.assertEqual(sorted(known), ["de", "en", "fr"])
self.assertEqual(built, ["de"])
self.assertEqual(built, [], "a version build must not prebuild any language")
self.assertTrue(self.library.translates("latest", "de"))
self.assertFalse(self.library.translates("latest", "fr"))
self.assertTrue(
(self.library.queue / "background" / "latest:de").exists(),
"every translated language must stay queued for a background build",
)
self.assertFalse((self.library.queue / "background" / "latest:fr").exists())
self.assertEqual(self.library.next_queued(), "latest:de")
self.library.request("latest", "fr")
self.assertFalse(
(self.library.queue / "latest:fr").exists(),
"an untranslated language must not enter the queue",
)
self.library.request("latest", "de")
self.assertTrue((self.library.queue / "latest:de").exists())
self.library.build_language("latest", "de")
self.assertEqual(self.library.languages("latest")[1], ["de"])
self.assertFalse((self.library.queue / "latest:de").exists())
self.assertTrue(self.library.translation_servable("latest", "de"))
page = self.library.resolve_translation("latest", "de", "docs/")
self.assertEqual(read_text(str(page)), "docs")
@@ -99,6 +99,28 @@ class TestServer(unittest.TestCase):
def test_unknown_version_is_not_found(self) -> None:
self.assertEqual(self._request("/v9.9.9/")[0], 404)
def test_a_translated_language_starts_its_build_and_an_untranslated_one_is_not_found(
self,
) -> None:
index = self.library.translations / "latest" / "languages.json"
index.parent.mkdir(parents=True, exist_ok=True)
index.write_text(
json.dumps(
{"known": {"de": "Deutsch", "aa": "Afar"}, "translated": ["de"]}
),
encoding="utf-8",
)
try:
status, _, body = self._request("/latest/de/")
self.assertEqual(status, 202)
self.assertIn("latest/de", body)
self.assertTrue((self.library.queue / "latest:de").exists())
self.assertEqual(self._request("/latest/aa/")[0], 404)
self.assertFalse((self.library.queue / "latest:aa").exists())
finally:
(self.library.queue / "latest:de").unlink(missing_ok=True)
index.unlink(missing_ok=True)
def test_unbuilt_version_starts_its_build_and_shows_progress(self) -> None:
status, _, body = self._request("/v1.0.0/page.html")