Wait for a deadline instead of polling for one
Docs / docs (push) Successful in 25s
Playwright Tests / test-playwright (1, 2) (push) Successful in 2m23s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m0s
pre-commit / pre-commit (push) Failing after 4m31s
Test Backend / test-backend (push) Successful in 2m55s
Compose Smoke Test / test-compose (push) Successful in 35s
Playwright Tests / merge-reports (push) Successful in 1m11s

The timer thread promoted due work on a fixed one-second tick, so every
delayed item was 0-1000ms late whatever the load — measured on the house
at 705ms mean on a rollershutter stop, which is 2-4% of a 26-second
travel and accumulates in the position the motor node believes it is at.
It now sleeps to the soonest deadline and is woken when a nearer one is
scheduled, which measures 0.9ms end to end through Redis.

A promoted timer also went to the back of the queue. It goes into a due
lane of its own that `claim` reads first, so work that has waited out a
deadline is not held up by work that is merely queued.

Beside it, in the same code: seeding a message now bumps its version, so
a re-put flow's synchronous nodes no longer wait forever on a value that
is sitting in state; the consumer group drops the consumers of engines
that are gone (138 had accumulated on this installation); and the cast
that closes the long-standing `xclaim` mypy error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 11:51:58 +02:00
co-authored by Claude Opus 5
parent e2f97d36e4
commit c3675688c8
6 changed files with 311 additions and 41 deletions
+36 -1
View File
@@ -36,6 +36,10 @@ RECLAIM_INTERVAL_S = 30.0
# node may run for as long as it likes, so what marks an item abandoned is this
# stopping — which is what an engine that died does.
TOUCH_INTERVAL_S = 20.0
# The longest the timer thread sleeps with nothing due. It is a housekeeping
# cadence and a backstop for a deadline written by another process, not the
# resolution of a delay: a delayed item is waited for exactly, so what a timer
# fires late by is a wake-up and a promotion rather than up to a whole second.
DELAYED_INTERVAL_S = 1.0
#: How many cascades may be in flight, unless the service is given a number.
#: Sustained throughput is this over the mean cascade time, so an installation
@@ -67,6 +71,10 @@ class ExecutionService:
self.max_cascades = max_cascades or MAX_CASCADES
self._pipeline: Pipeline | None = None
self._stop = threading.Event()
# Set when a deadline moves closer, so the timer thread stops waiting
# on the one it read and goes back for the new one.
self._timer_wake = threading.Event()
queue.on_delayed = self._timer_wake.set
self._intake = threading.Event()
self._intake.set()
self._inflight = 0
@@ -104,6 +112,8 @@ class ExecutionService:
def stop(self) -> None:
self._stop.set()
# The timer thread sleeps on this, not on _stop.
self._timer_wake.set()
for thread in (self._consumer, self._timers):
if thread is not None:
thread.join(timeout=5)
@@ -173,19 +183,44 @@ class ExecutionService:
for item in items:
self._dispatch(item)
def _sleep_until_due(self) -> None:
"""Wait for the soonest deadline, the housekeeping cap, or a new one.
A fixed poll here made every delayed item late by 0-1000ms whatever the
load — on a rollershutter driven for a measured 26 seconds, 2-4% of its
travel every time, accumulating in the position its node believes it is
at. Sleeping to the deadline instead leaves a wake-up and a promotion,
which is milliseconds.
"""
self._timer_wake.clear()
try:
# Read after the clear: a deadline arriving in between sets the
# event again, so the wait below returns immediately rather than
# sleeping through work that landed in the gap.
due = self.queue.next_due()
except Exception as exc:
logger.error("Could not read the next deadline: %s", exc)
due = None
wait = DELAYED_INTERVAL_S if due is None else due - time.time()
self._timer_wake.wait(min(max(wait, 0.0), DELAYED_INTERVAL_S))
def _tick(self) -> None:
"""Promote delayed items, and take back what a dead engine dropped."""
last_reclaim = 0.0
last_touch = 0.0
last_backlog = 0.0
while not self._stop.is_set():
self._stop.wait(DELAYED_INTERVAL_S)
self._sleep_until_due()
if self._stop.is_set():
break
try:
self.queue.move_due(time.time())
except Exception as exc:
logger.error("Could not promote delayed work: %s", exc)
# The item is still due, so the wait above would be zero and
# this would spin on a queue that is down. Back off to what a
# fixed poll used to cost.
self._stop.wait(DELAYED_INTERVAL_S)
now = time.monotonic()
if now - last_backlog >= BACKLOG_INTERVAL_S: