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