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
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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user