mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 20:26:33 +00:00
build() runs eleven generator subprocesses before Sphinx; build_language() went straight to sphinx-build. The translated site therefore rendered without generated/ - no role pages, no overview, no regenerated READMEs - while the .pot extractor does call the generators (i18n.py:47). The catalog described a superset of what the translation could ever show. The loop moves into _generate() and both builds call it. Extracting it also surfaced a division by zero on an empty generator list, now an early return. build_language() collapsed two different reasons into one bail and treated them alike: drop the marker, request the base. Dropping is right when the code is not translated. It is wrong when the base is merely stale, because the base build short-circuits and never reaches the tail that re-queues the languages, so the translation was lost for good and /deployed/de/ would answer 202 forever. The two reasons are now separate and the stale case re-queues the translation alongside its base. fetch() re-requests every 900 s and _current() can never be true for an unservable site, so a failed build rebuilt itself forever and starved the background lane. _failed now records the ref it failed on, and request() refuses a BACKGROUND entry for a marker that failed at the ref it would build now. A waiting visitor still gets a retry; only work nobody waits on is throttled. The guard needs a non-empty ref so an empty snapshot ref cannot lock everything out, and _ready replaces the whole state dict, so a later success clears it. Verified: 158 passed over the roles entire docs unit suite in the container. The extraction is behaviour-identical on the build() path - same PYTHONPATH, phase, progress formula and run call - so the measured delta there is zero. Not verified: build_language has never executed the generator phase anywhere yet, which is the one real risk this carries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
128 lines
4.3 KiB
Python
128 lines
4.3 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 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 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):
|
|
"""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.
|
|
|
|
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 run_builder(self, interval):
|
|
while not self.acquire_builder():
|
|
time.sleep(POLL_SECONDS)
|
|
if self.snapshot.is_dir():
|
|
self.request(DEPLOYED)
|
|
next_fetch = 0.0
|
|
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_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)
|