Files
computer-playbook/roles/web-app-docs/files/python/infinito_docs/build_queue.py
T
kevinveenbirkenbachandClaude Opus 5 6bd1279e5e fix(web-app-docs): warm the languages, and let a failed build say so
Run 37553816857 failed 59 jobs, and web-app-docs is in five of every six
that named a role. The spec waits 88 minutes for a German site that never
arrives:

    tests/test-languages.js:25:7 › the deployed working tree is published in de
    Expected the de site of the deployed working tree to be built
    Expected: 200   Received: 202

The container log of any job that deployed the role carries the whole chain:

    "GET /deployed/\1/ HTTP/1.1" 202
    build latest failed: Command '[... 'infinito_docs.generators.integrations' ...]'
      returned non-zero exit status 1.
    "GET /deployed/de/ HTTP/1.1" 202     (every 31s, for 88 minutes)

Four defects, in the order they fire.

The preload asked for a path named `\1/`. The replacement reached re.sub with
its backslash still escaped, so the backreference stayed literal and no
language was ever warmed; the first build then started when a visitor polled.
Reproduced through Templar rather than argued: the old expression renders
['', '\1/'] and the new one ['de/', '']. Plain Jinja2 with Ansible's filters
renders the old one correctly, which is why the first measurement of this
cleared code that the access log had already convicted. The replacement
appends with '$' and carries no backreference to escape.

`latest` then failed to build at all. builder.py puts the documented checkout
first on PYTHONPATH, so the image's generator meets the utils of the version
it is documenting, and a05071fe78 dropped the get_ prefix from entity_name.
A builder that documents every version has to tolerate every version's API,
so the import falls back.

A failed build kept answering 202 and re-queueing itself, so every client
waited its whole timeout and a doomed build restarted on each poll. This is
what turned a one-minute failure into 88-minute waits and drove one job into
the six-hour ceiling, where it was killed with 137. The server now answers
500, through a queue accessor that reuses the comparison `request` already
makes: a failure at an older ref still retries. For a translation the parent
version counts too, because here /deployed/de/ polled while `latest` was the
build that had failed.

The failure line now carries the captured log tail. The detail was never
lost -- _run merges stderr into the build state -- but it was absent from the
container log, which is what a rescue bundle holds, so the traceback had to be
reproduced locally to be read at all.

Nothing here is deployed. The 500 is a live change for three consumers that
accept 202 -- the preload task, test-front-page and test-on-demand-build --
but it is reachable only when a version is unservable AND its build failed at
the ref it would build now, so a healthy deployment sees what it saw before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 13:30:18 +02:00

208 lines
7.4 KiB
Python

"""The build queue, the cross-replica builder lock and the daemon loop.
``Queue`` is a mixin of :class:`infinito_docs.library.Library`. A marker is a
file named ``version`` or ``version:code``; the background lane holds the
builds nobody is waiting on - the daemon's own prefetch and every language no
visitor has asked for.
"""
from __future__ import annotations
import fcntl
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from infinito_docs.builder import DEPLOYED, QUEUE_SEPARATOR
POLL_SECONDS = 2
BACKGROUND_LANE = "background"
class Queue:
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 everything a visitor is waiting on.
"""
head, _ = self.refs()
if not head and version != DEPLOYED:
return
if code is None:
if self._current(version, head):
return
marker = version
elif not self.translatable(version, code) or self._translation_current(
version, code, head
):
return
else:
marker = f"{version}{QUEUE_SEPARATOR}{code}"
failed_ref = self._failed_at(marker)
if background and failed_ref and failed_ref == self._wanted_ref(version, head):
return
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
queued = lane / marker
if queued.exists():
return
queued.touch()
def failed(self, version, code=None):
"""Whether this target's last build failed at the ref it would build now.
Args:
version: version directory name.
code: language code of a translation, or None for the site itself.
"""
head, _ = self.refs()
marker = version if code is None else f"{version}{QUEUE_SEPARATOR}{code}"
failed_ref = self._failed_at(marker)
return bool(failed_ref) and failed_ref == self._wanted_ref(version, head)
def is_queued(self, marker):
"""Whether ``marker`` waits in either lane.
Args:
marker: queue file name, ``version`` or ``version:code``.
"""
return (self.queue / marker).exists() or (
self.queue / BACKGROUND_LANE / marker
).exists()
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, skip=()):
"""Return the oldest marker a visitor is waiting on, else the oldest background one.
Args:
skip: markers to pass over, normally the ones already building.
"""
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() and marker.name not in skip
),
key=lambda marker: marker.stat().st_mtime,
)
if waiting:
return waiting[0].name
return None
def _buildable(self, marker, head):
"""Whether ``marker`` can be built now rather than bounced.
A translated site needs its version current. Deciding that here and
not in ``build_language`` keeps a free slot from claiming the marker,
bouncing it back into the lane and claiming it again for as long as
the version takes to build.
Args:
marker: queue file name, ``version`` or ``version:code``.
head: the mirror's current commit.
"""
version, separator, _code = marker.partition(QUEUE_SEPARATOR)
return not separator or self._current(version, head)
def next_free(self):
"""Claim the oldest marker a slot can build now, or None.
The build functions dequeue their marker only when they finish, so
without a claim the same marker would be handed to every free slot.
"""
head, _ = self.refs()
with self._lock:
skip = set(self._in_flight)
while True:
marker = self.next_queued(skip=frozenset(skip))
if marker is None:
return None
if self._buildable(marker, head):
self._in_flight.add(marker)
return marker
skip.add(marker)
def acquire_builder(self):
"""Try to become the builder of all replicas without blocking.
Returns:
``True`` while this process holds the builder lock.
"""
if self._lock_handle is not None:
return True
self.lock_file.parent.mkdir(parents=True, exist_ok=True)
handle = self.lock_file.open("a+")
try:
fcntl.lockf(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
handle.close()
return False
self._lock_handle = handle
return True
def _build_marker(self, marker):
"""Build what ``marker`` names, then release its slot.
Args:
marker: queue file name, ``version`` or ``version:code``.
"""
try:
version, separator, code = marker.partition(QUEUE_SEPARATOR)
if separator:
self.build_language(version, code)
else:
self.build(version)
except Exception as exc:
print(f"build {marker} crashed: {exc!r}", file=sys.stderr, flush=True)
self._dequeue(marker)
finally:
with self._lock:
self._in_flight.discard(marker)
def run_builder(self, interval, parallel=1):
"""Serve the queue until the process ends.
Args:
interval: seconds between fetches of the mirror.
parallel: builds to run at once. A translated site waits for its
version to be current, so the first pair still serialises; the
slots pay off on the languages queued behind a built version.
"""
while not self.acquire_builder():
time.sleep(POLL_SECONDS)
if self.snapshot.is_dir():
self.request(DEPLOYED)
next_fetch = 0.0
with ThreadPoolExecutor(max_workers=max(1, int(parallel))) as pool:
while True:
if time.monotonic() >= next_fetch:
try:
self.fetch()
except (OSError, subprocess.CalledProcessError) as exc:
print(
f"fetch of {self.repository} failed: {exc}", file=sys.stderr
)
next_fetch = time.monotonic() + interval
marker = self.next_free()
if marker is None:
time.sleep(POLL_SECONDS)
continue
pool.submit(self._build_marker, marker)