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 # 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. # stopping — which is what an engine that died does.
TOUCH_INTERVAL_S = 20.0 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 DELAYED_INTERVAL_S = 1.0
#: How many cascades may be in flight, unless the service is given a number. #: 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 #: 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.max_cascades = max_cascades or MAX_CASCADES
self._pipeline: Pipeline | None = None self._pipeline: Pipeline | None = None
self._stop = threading.Event() 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 = threading.Event()
self._intake.set() self._intake.set()
self._inflight = 0 self._inflight = 0
@@ -104,6 +112,8 @@ class ExecutionService:
def stop(self) -> None: def stop(self) -> None:
self._stop.set() self._stop.set()
# The timer thread sleeps on this, not on _stop.
self._timer_wake.set()
for thread in (self._consumer, self._timers): for thread in (self._consumer, self._timers):
if thread is not None: if thread is not None:
thread.join(timeout=5) thread.join(timeout=5)
@@ -173,19 +183,44 @@ class ExecutionService:
for item in items: for item in items:
self._dispatch(item) 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: def _tick(self) -> None:
"""Promote delayed items, and take back what a dead engine dropped.""" """Promote delayed items, and take back what a dead engine dropped."""
last_reclaim = 0.0 last_reclaim = 0.0
last_touch = 0.0 last_touch = 0.0
last_backlog = 0.0 last_backlog = 0.0
while not self._stop.is_set(): while not self._stop.is_set():
self._stop.wait(DELAYED_INTERVAL_S) self._sleep_until_due()
if self._stop.is_set(): if self._stop.is_set():
break break
try: try:
self.queue.move_due(time.time()) self.queue.move_due(time.time())
except Exception as exc: except Exception as exc:
logger.error("Could not promote delayed work: %s", 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() now = time.monotonic()
if now - last_backlog >= BACKLOG_INTERVAL_S: if now - last_backlog >= BACKLOG_INTERVAL_S:
+12 -1
View File
@@ -395,13 +395,24 @@ class Pipeline:
self.replace_flow(flow, []) self.replace_flow(flow, [])
def _seed(self, initial_values: dict[str, Any] | None) -> None: def _seed(self, initial_values: dict[str, Any] | None) -> None:
"""Give messages a starting value, without overwriting one already there.""" """Give messages a starting value, without overwriting one already there.
A seeded value counts as having arrived. Writing it without its version
left the value in state at version 0, which `_check_synchronous_ready`
reads as "never published" — so a flow that was dropped and recreated
came back with its synchronous nodes waiting on inputs that were sitting
right there, reporting `active` and `ok` and never running again.
"""
if not initial_values: if not initial_values:
return return
seeded = []
with self._state.lock(): with self._state.lock():
for name, value in initial_values.items(): for name, value in initial_values.items():
if name not in self._state: if name not in self._state:
self._state[name] = value self._state[name] = value
seeded.append(name)
if seeded:
self._state.increment_multi([self._version_key(name) for name in seeded])
def get_node_by_id(self, nid: str) -> Node | None: def get_node_by_id(self, nid: str) -> Node | None:
return next((n for n in self._nodes if n.id == nid), None) return next((n for n in self._nodes if n.id == nid), None)
+154 -29
View File
@@ -18,6 +18,7 @@ import threading
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections import deque from collections import deque
from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, cast from typing import Any, cast
@@ -33,6 +34,13 @@ STREAM_MAXLEN = 10_000
# can look rather than letting it loop forever. # can look rather than letting it loop forever.
MAX_DELIVERIES = 3 MAX_DELIVERIES = 3
GROUP = "engine" GROUP = "engine"
# Entry ids from the due lane wear this, so one id still says which stream it
# came from. Deterministic per stream, which is what keeps it usable as an
# idempotency key across redeliveries.
DUE_PREFIX = "due:"
# A consumer this far past its last read belongs to an engine that is gone. A
# live one interacts every claim, so nothing in service comes close.
STALE_CONSUMER_IDLE_MS = 3_600_000
@dataclass @dataclass
@@ -113,6 +121,21 @@ class WorkItem:
class WorkQueue(ABC): class WorkQueue(ABC):
"""What the execution service pulls from.""" """What the execution service pulls from."""
#: Called after an item is journaled for later, so whoever is waiting on
#: the next deadline can find out it just moved closer. Set by the
#: execution service; a queue nobody is timing leaves it alone.
on_delayed: Callable[[], None] | None = None
def _woken(self) -> None:
"""Say a deadline changed. Never lets a listener break the enqueue."""
wake = self.on_delayed
if wake is None:
return
try:
wake()
except Exception:
logger.exception("Delayed-work listener failed")
@abstractmethod @abstractmethod
def add(self, item: WorkItem) -> None: def add(self, item: WorkItem) -> None:
"""Journal an item for immediate execution.""" """Journal an item for immediate execution."""
@@ -121,6 +144,15 @@ class WorkQueue(ABC):
def add_delayed(self, item: WorkItem, not_before: float) -> None: def add_delayed(self, item: WorkItem, not_before: float) -> None:
"""Journal an item that must not run before ``not_before``.""" """Journal an item that must not run before ``not_before``."""
@abstractmethod
def next_due(self) -> float | None:
"""When the soonest delayed item comes due, or None if there is none.
What lets the engine sleep until a timer rather than poll for it: a
fixed poll makes every delayed item late by up to its interval, which
a motor driven for a measured number of seconds notices.
"""
@abstractmethod @abstractmethod
def claim(self, count: int, block_ms: int) -> list[WorkItem]: def claim(self, count: int, block_ms: int) -> list[WorkItem]:
"""Take up to ``count`` items, waiting up to ``block_ms`` for one.""" """Take up to ``count`` items, waiting up to ``block_ms`` for one."""
@@ -237,6 +269,13 @@ class MemoryWorkQueue(WorkQueue):
self._counter += 1 self._counter += 1
heapq.heappush(self._delayed, (not_before, self._counter, item)) heapq.heappush(self._delayed, (not_before, self._counter, item))
self._wake.notify() self._wake.notify()
# Outside the lock: the listener is whoever is asleep until the old
# deadline, and it comes straight back here to read the new one.
self._woken()
def next_due(self) -> float | None:
with self._lock:
return self._delayed[0][0] if self._delayed else None
def claim(self, count: int, block_ms: int) -> list[WorkItem]: def claim(self, count: int, block_ms: int) -> list[WorkItem]:
deadline = time.monotonic() + block_ms / 1000.0 deadline = time.monotonic() + block_ms / 1000.0
@@ -262,14 +301,17 @@ class MemoryWorkQueue(WorkQueue):
return [] return []
def move_due(self, now: float) -> int: def move_due(self, now: float) -> int:
moved = 0 due: list[WorkItem] = []
with self._wake: with self._wake:
while self._delayed and self._delayed[0][0] <= now: while self._delayed and self._delayed[0][0] <= now:
self._items.append(heapq.heappop(self._delayed)[2]) due.append(heapq.heappop(self._delayed)[2])
moved += 1 if due:
if moved: # In front of the backlog, in due order: an item that has
# waited out a deadline is late by however long it queues
# here, while work merely enqueued is not waiting on a clock.
self._items.extendleft(reversed(due))
self._wake.notify() self._wake.notify()
return moved return len(due)
def dead_letter(self, item: WorkItem, reason: str) -> None: def dead_letter(self, item: WorkItem, reason: str) -> None:
logger.error("Dropping work item for '%s': %s", item.node, reason) logger.error("Dropping work item for '%s': %s", item.node, reason)
@@ -343,6 +385,10 @@ class RedisWorkQueue(WorkQueue):
self._ns = namespace self._ns = namespace
self._consumer = consumer or f"engine-{int(time.time() * 1000) % 1_000_000}" self._consumer = consumer or f"engine-{int(time.time() * 1000) % 1_000_000}"
self._stream = f"{namespace}:__queue__" self._stream = f"{namespace}:__queue__"
# Promoted timers, read before the backlog. A stream of its own rather
# than a flag on the entry, because a stream is ordered by id and there
# is no way to put a late item in front of one already in it.
self._due_stream = f"{namespace}:__due__"
self._delayed_key = f"{namespace}:__delayed__" self._delayed_key = f"{namespace}:__delayed__"
self._dead_key = f"{namespace}:__dead__" self._dead_key = f"{namespace}:__dead__"
# Which flows have something parked. Maintained rather than discovered: # Which flows have something parked. Maintained rather than discovered:
@@ -350,14 +396,43 @@ class RedisWorkQueue(WorkQueue):
# key in the database, twice per health poll. # key in the database, twice per health poll.
self._parked_flows_key = f"{namespace}:__parked_flows__" self._parked_flows_key = f"{namespace}:__parked_flows__"
self._ensure_group() self._ensure_group()
self._drop_stale_consumers()
def _ensure_group(self) -> None: def _ensure_group(self) -> None:
for stream in (self._stream, self._due_stream):
try: try:
self._redis.xgroup_create(self._stream, GROUP, id="0", mkstream=True) self._redis.xgroup_create(stream, GROUP, id="0", mkstream=True)
except redis.ResponseError as exc: except redis.ResponseError as exc:
if "BUSYGROUP" not in str(exc): if "BUSYGROUP" not in str(exc):
raise raise
def _drop_stale_consumers(self) -> None:
"""Forget the consumers of engines that are gone.
One is registered per engine start and none was ever removed, so the
list grew without bound — and `XPENDING`, `XAUTOCLAIM` and `XINFO` all
walk it. Only consumers holding nothing and untouched for an hour are
dropped, so a live engine blocked on a read is never one of them; a
consumer is recreated by its next read anyway.
"""
for stream in (self._stream, self._due_stream):
try:
consumers = cast(
list[dict[str, Any]], self._redis.xinfo_consumers(stream, GROUP)
)
except redis.ResponseError:
continue
for row in consumers:
name = str(row.get("name", ""))
if name == self._consumer or row.get("pending"):
continue
if int(row.get("idle") or 0) < STALE_CONSUMER_IDLE_MS:
continue
try:
self._redis.xgroup_delconsumer(stream, GROUP, name)
except redis.ResponseError:
pass
def _parked_key(self, flow: str) -> str: def _parked_key(self, flow: str) -> str:
return f"{self._ns}:__parked__:{flow}" return f"{self._ns}:__parked__:{flow}"
@@ -377,27 +452,56 @@ class RedisWorkQueue(WorkQueue):
self._redis.zadd( self._redis.zadd(
self._delayed_key, {orjson.dumps(item.to_fields()): not_before} self._delayed_key, {orjson.dumps(item.to_fields()): not_before}
) )
self._woken()
def next_due(self) -> float | None:
soonest = cast(
list[tuple[str, float]],
self._redis.zrange(self._delayed_key, 0, 0, withscores=True),
)
return soonest[0][1] if soonest else None
def _route(self, entry_id: str) -> tuple[str, str]:
"""The stream an entry id belongs to, and its plain id there."""
if entry_id.startswith(DUE_PREFIX):
return self._due_stream, entry_id[len(DUE_PREFIX) :]
return self._stream, entry_id
def claim(self, count: int, block_ms: int) -> list[WorkItem]: def claim(self, count: int, block_ms: int) -> list[WorkItem]:
"""Take up to ``count`` items, due timers before anything queued.
One read over both streams rather than a read each: the block has to
cover the due lane, or an item promoted while this waits sits there
until the block expires — which is the second of lateness the sleeping
timer thread exists to remove. ``COUNT`` is per stream, so a read may
return up to twice what was asked for; the excess is bounded by the
cascade pool's own free count, and starting a due timer is worth more
than holding that number exactly.
"""
response = cast( response = cast(
list[Any], list[Any],
self._redis.xreadgroup( self._redis.xreadgroup(
GROUP, GROUP,
self._consumer, self._consumer,
{self._stream: ">"}, {self._due_stream: ">", self._stream: ">"},
count=count, count=count,
block=block_ms, block=block_ms,
), ),
) )
items: list[WorkItem] = [] due: list[WorkItem] = []
for _stream, entries in response or []: backlog: list[WorkItem] = []
for stream, entries in response or []:
is_due = stream == self._due_stream
into = due if is_due else backlog
prefix = DUE_PREFIX if is_due else ""
for entry_id, fields in entries: for entry_id, fields in entries:
items.append(WorkItem.from_fields(fields, entry_id)) into.append(WorkItem.from_fields(fields, prefix + entry_id))
return items return due + backlog
def ack(self, item: WorkItem) -> None: def ack(self, item: WorkItem) -> None:
if item.entry_id: if item.entry_id:
self._redis.xack(self._stream, GROUP, item.entry_id) stream, entry_id = self._route(item.entry_id)
self._redis.xack(stream, GROUP, entry_id)
def touch(self, entry_ids: list[str]) -> None: def touch(self, entry_ids: list[str]) -> None:
if not entry_ids: if not entry_ids:
@@ -407,21 +511,33 @@ class RedisWorkQueue(WorkQueue):
# delivery count where it is, so a long node neither redelivers nor # delivery count where it is, so a long node neither redelivers nor
# spends its way towards the dead-letter cap. An entry already # spends its way towards the dead-letter cap. An entry already
# acknowledged is simply not there, and this is a no-op for it. # acknowledged is simply not there, and this is a no-op for it.
by_stream: dict[str, list[str]] = {}
for entry_id in entry_ids:
stream, plain = self._route(entry_id)
by_stream.setdefault(stream, []).append(plain)
for stream, ids in by_stream.items():
self._redis.xclaim( self._redis.xclaim(
self._stream, stream,
GROUP, GROUP,
self._consumer, self._consumer,
min_idle_time=0, min_idle_time=0,
message_ids=entry_ids, # redis-py types `message_ids` as an invariant list of every id
# shape it accepts, which a list[str] is not assignable to.
message_ids=cast("list[int | bytes | str | memoryview]", ids),
justid=True, justid=True,
) )
def reclaim_stale(self, min_idle_ms: int) -> list[WorkItem]: def reclaim_stale(self, min_idle_ms: int) -> list[WorkItem]:
"""Take over entries a dead consumer never acknowledged.""" """Take over entries a dead consumer never acknowledged."""
return self._reclaim(self._due_stream, DUE_PREFIX, min_idle_ms) + self._reclaim(
self._stream, "", min_idle_ms
)
def _reclaim(self, stream: str, prefix: str, min_idle_ms: int) -> list[WorkItem]:
_cursor, entries, _deleted = cast( _cursor, entries, _deleted = cast(
tuple[Any, list[Any], Any], tuple[Any, list[Any], Any],
self._redis.xautoclaim( self._redis.xautoclaim(
self._stream, stream,
GROUP, GROUP,
self._consumer, self._consumer,
min_idle_time=min_idle_ms, min_idle_time=min_idle_ms,
@@ -435,12 +551,12 @@ class RedisWorkQueue(WorkQueue):
counts: dict[str, int] = {} counts: dict[str, int] = {}
for record in cast( for record in cast(
list[dict[str, Any]], list[dict[str, Any]],
self._redis.xpending_range(self._stream, GROUP, min="-", max="+", count=64), self._redis.xpending_range(stream, GROUP, min="-", max="+", count=64),
): ):
counts[record["message_id"]] = record["times_delivered"] counts[record["message_id"]] = record["times_delivered"]
return [ return [
WorkItem.from_fields(fields, entry_id, counts.get(entry_id, 1)) WorkItem.from_fields(fields, prefix + entry_id, counts.get(entry_id, 1))
for entry_id, fields in entries for entry_id, fields in entries
if fields if fields
] ]
@@ -464,8 +580,10 @@ class RedisWorkQueue(WorkQueue):
moved = 0 moved = 0
for raw, owned in zip(due, claimed, strict=True): for raw, owned in zip(due, claimed, strict=True):
if owned: if owned:
# Into the due lane, which `claim` reads first: what has waited
# out a deadline should not then queue behind a backlog.
pipe.xadd( pipe.xadd(
self._stream, self._due_stream,
cast(Any, orjson.loads(raw)), cast(Any, orjson.loads(raw)),
maxlen=STREAM_MAXLEN, maxlen=STREAM_MAXLEN,
approximate=True, approximate=True,
@@ -514,24 +632,29 @@ class RedisWorkQueue(WorkQueue):
pipe.srem(self._parked_flows_key, flow) pipe.srem(self._parked_flows_key, flow)
pipe.execute() pipe.execute()
def _group_info(self) -> dict[str, Any]: def _group_info(self, stream: str = "") -> dict[str, Any]:
"""This consumer group's row of ``XINFO GROUPS``, or an empty one.""" """This consumer group's row of ``XINFO GROUPS``, or an empty one."""
try: try:
groups = cast(list[dict[str, Any]], self._redis.xinfo_groups(self._stream)) groups = cast(
list[dict[str, Any]], self._redis.xinfo_groups(stream or self._stream)
)
except redis.ResponseError: except redis.ResponseError:
# No stream yet: nothing has ever been enqueued. # No stream yet: nothing has ever been enqueued.
return {} return {}
return next((g for g in groups if g.get("name") == GROUP), {}) return next((g for g in groups if g.get("name") == GROUP), {})
def backlog(self) -> int: def backlog(self) -> int:
"""Entries in the stream this group has never been handed. """Entries in either stream this group has never been handed.
Redis calls it the group's ``lag``. It is nil rather than zero when the Redis calls it the group's ``lag``. It is nil rather than zero when the
stream has been trimmed under the group — entries that were dropped stream has been trimmed under the group — entries that were dropped
before anyone read them — and unknown is reported as none waiting, before anyone read them — and unknown is reported as none waiting,
since the alternative is a health screen crying wolf after a trim. since the alternative is a health screen crying wolf after a trim.
""" """
return int(self._group_info().get("lag") or 0) return sum(
int(self._group_info(stream).get("lag") or 0)
for stream in (self._stream, self._due_stream)
)
def backlog_flows(self, sample: int = 100) -> dict[str, int]: def backlog_flows(self, sample: int = 100) -> dict[str, int]:
"""Which flows the waiting work belongs to, from a sample of the tail. """Which flows the waiting work belongs to, from a sample of the tail.
@@ -556,18 +679,20 @@ class RedisWorkQueue(WorkQueue):
return counts return counts
def stats(self) -> dict[str, Any]: def stats(self) -> dict[str, Any]:
pending = cast(dict[str, Any], self._redis.xpending(self._stream, GROUP)) count = 0
oldest = 0.0 oldest = 0.0
count = pending.get("pending", 0) if isinstance(pending, dict) else 0 for stream in (self._stream, self._due_stream):
if count: pending = cast(dict[str, Any], self._redis.xpending(stream, GROUP))
waiting = pending.get("pending", 0) if isinstance(pending, dict) else 0
count += waiting
if not waiting:
continue
records = cast( records = cast(
list[dict[str, Any]], list[dict[str, Any]],
self._redis.xpending_range( self._redis.xpending_range(stream, GROUP, min="-", max="+", count=1),
self._stream, GROUP, min="-", max="+", count=1
),
) )
if records: if records:
oldest = records[0]["time_since_delivered"] / 1000.0 oldest = max(oldest, records[0]["time_since_delivered"] / 1000.0)
flows = cast(set[str], self._redis.smembers(self._parked_flows_key)) flows = cast(set[str], self._redis.smembers(self._parked_flows_key))
parked = sum(cast(int, self._redis.llen(self._parked_key(f))) for f in flows) parked = sum(cast(int, self._redis.llen(self._parked_key(f))) for f in flows)
return { return {
+62
View File
@@ -43,6 +43,68 @@ def test_a_delayed_item_stays_put_until_it_is_due():
assert [i.node for i in queue.claim(1, 10)] == ["f.n"] assert [i.node for i in queue.claim(1, 10)] == ["f.n"]
def test_the_queue_says_when_the_soonest_delayed_item_is_due():
"""What lets the engine sleep to a deadline rather than poll for it."""
queue = MemoryWorkQueue()
assert queue.next_due() is None
queue.add_delayed(WorkItem(kind="cascade", node="f.late", flow="f"), 500.0)
queue.add_delayed(WorkItem(kind="cascade", node="f.soon", flow="f"), 100.0)
assert queue.next_due() == 100.0
queue.move_due(200.0)
assert queue.next_due() == 500.0
def test_scheduling_says_a_deadline_moved():
"""A delay scheduled for 200ms must cut short a sleep to the next second."""
queue = MemoryWorkQueue()
woken = threading.Event()
queue.on_delayed = woken.set
queue.add_delayed(WorkItem(kind="cascade", node="f.n", flow="f"), time.time() + 0.2)
assert woken.is_set()
def test_a_due_timer_is_claimed_before_the_backlog():
"""Lateness is what a timer is measured by; queued work waits on no clock."""
queue = MemoryWorkQueue()
for i in range(3):
queue.add(WorkItem(kind="cascade", node=f"f.queued{i}", flow="f"))
queue.add_delayed(WorkItem(kind="cascade", node="f.timer", flow="f"), 100.0)
queue.move_due(200.0)
assert [i.node for i in queue.claim(10, 10)][0] == "f.timer"
def test_a_delay_fires_without_waiting_out_the_poll():
"""The whole point: a 200ms delay is 200ms late, not up to a second."""
queue = MemoryWorkQueue()
service = ExecutionService(queue)
dispatched: list[float] = []
service._dispatch = lambda item: dispatched.append(time.monotonic()) # type: ignore[method-assign]
service.start()
try:
started = time.monotonic()
queue.add_delayed(
WorkItem(kind="cascade", node="f.n", flow="f"), time.time() + 0.2
)
# Claimed by the consumer thread, which is what calls _dispatch.
deadline = time.monotonic() + 2.0
while not dispatched and time.monotonic() < deadline:
time.sleep(0.01)
finally:
service.stop()
assert dispatched, "the deferred item never ran"
late = dispatched[0] - started - 0.2
# A fixed one-second poll made this up to 1.0s; the budget is generous
# because CI is not a real-time machine.
assert late < 0.3, f"fired {late:.3f}s late"
def test_parked_work_comes_back_oldest_first(): def test_parked_work_comes_back_oldest_first():
queue = MemoryWorkQueue() queue = MemoryWorkQueue()
for i in range(3): for i in range(3):
@@ -72,6 +72,37 @@ def test_synchronous_node_waits_for_all_inputs_to_be_fresh():
assert sorted(runs) == ["eager", "sync"] assert sorted(runs) == ["eager", "sync"]
def test_a_seeded_value_counts_as_having_arrived():
"""A re-put flow used to come back with its synchronous nodes wedged.
Seeding wrote the value and not its version, and version 0 reads as
"never published" — so the node reported active and ok and never ran
again, with the value it was waiting for sitting right there in state.
"""
runs: list[str] = []
def sync(a, b, params):
runs.append("sync")
return None
def sensor_b(params):
return {"b": 2.0}
b = node("b", sensor_b, provides=[spec("b")])
sync_node = node(
"sync", sync, requires=[spec("a"), spec("b")], params={"synchronous": True}
)
Pipeline(
nodes=[b, sync_node],
max_workers=1,
initial_values={"f.a": 1.0},
)
b.inject()
assert runs == ["sync"]
def test_increment_and_multi_get(): def test_increment_and_multi_get():
state = MemoryState() state = MemoryState()
+6
View File
@@ -155,6 +155,12 @@ Order of operations: rate check → delay → forward. With a `cron` and no inpu
it emits the current time on each tick; with inputs it emits the last value it it emits the current time on each tick; with inputs it emits the last value it
received. received.
The engine sleeps until a delay is due rather than polling for it, so a `delay`
fires within a few milliseconds of its deadline on an idle engine, and a due
timer is taken off the queue before work that is merely waiting. It is not a
real-time guarantee: the wait ends on a normal thread, and a node that then
needs a busy cascade slot waits for one.
!!! note "Not in a batch flow" !!! note "Not in a batch flow"
A rate limit holds a value back for a timer to release, and a run has no A rate limit holds a value back for a timer to release, and a run has no