Files
app/backend/fluksio/flow/queue.py
T
stroblmeandClaude Opus 5 0ffcabfdb9
Docs / docs (push) Successful in 30s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m7s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m54s
pre-commit / pre-commit (push) Failing after 4m24s
Test Backend / test-backend (push) Successful in 3m8s
Compose Smoke Test / test-compose (push) Successful in 40s
Playwright Tests / merge-reports (push) Successful in 1m33s
Media dtypes: image, audio and video as narrowed artifact references
A port may now declare `image`, `audio` or `video`. Each is the artifact
reference the engine already had, narrowed by the `media_type` on it, so a
speech recogniser declares what it eats rather than taking any bytes at all and
finding out. Bytes still never travel as a message and nothing on the wire
stops being JSON: a camera publishes one reference per frame, a microphone one
per chunk, and a reference may carry a `meta` dict nothing here interprets.

Streaming media is therefore an ordinary streaming port — with one change to
what that means. An emission used to journal an item with no payload, so
downstream read whatever was current when the item was claimed; a consumer
slower than its producer saw only the newest chunk and the ones between were
lost. That is right for a training curve and wrong for a second of speech, so
an emission now journals a `kind="emission"` item carrying its values, and the
executor hands them to the nodes reading that message instead of writing them
to state again. The value in state stays the latest, which is what everything
else reads, and the wave is filtered by what actually changed rather than
walking everything reachable. No queue serialization change — the existing
`outputs` field carries it.

Continuous media makes the store's missing GC a real problem, so this closes
it: `sweep_artifacts` runs hourly, keeps every digest a `run_artifact` row
records or a live message holds, spares anything written in the last hour, and
stands aside entirely while a run is in flight, since a node may store a
checkpoint long before it returns the reference to it. That also collects the
orphans a deleted flow has always left behind. `ARTIFACT_GC_INTERVAL_S=0` turns
it off.

Around the edges: `GET /artifacts/{digest}` serves the media type the caller
passes and answers ranged requests, so a browser plays a clip rather than
downloading it; `PUT` spools to disk instead of holding the whole body in
memory, as does `save_artifact` given a path; a Media widget draws whatever its
message points at, and a wall panel may fetch the bytes its own tiles are
showing and nothing else; and a connector gets `save_artifact`, for a device
whose readings are bytes.

What this cannot do is live video: a frame every second or two is a glance, and
the honest answer above that is the camera's own stream, which the widget takes
as a URL and the browser plays from source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 23:44:55 +02:00

609 lines
22 KiB
Python

"""The engine's work queue: what survives a crash.
Every external event — an MQTT message, a webhook, a cron tick, a connector
poll — becomes a work item before anything runs. The item is journaled first
and acknowledged only once the wave it started has quiesced, so an engine that
dies mid-cascade picks the work up again on the way back rather than losing it.
Two implementations: Redis Streams, which is what makes the above true, and an
in-memory one for tests and for running without Redis, where "durable" degrades
honestly to "not".
"""
from __future__ import annotations
import heapq
import logging
import threading
import time
from abc import ABC, abstractmethod
from collections import deque
from dataclasses import dataclass, field
from typing import Any, cast
import orjson
import redis
logger = logging.getLogger(__name__)
# The stream is a buffer, not an archive: anything this far behind is long
# superseded by a newer value of the same message.
STREAM_MAXLEN = 10_000
# An item redelivered this often is not going to succeed. Park it where someone
# can look rather than letting it loop forever.
MAX_DELIVERIES = 3
GROUP = "engine"
@dataclass
class WorkItem:
"""One unit of journaled work.
:param kind: ``cascade`` replays a node's outputs and runs what is
downstream; ``emission`` is a value a node published while still
running, already in state, carried so its readers get *that* value
rather than whichever is newest when they run; ``flush`` lets out what
a node's rate limits held back; ``run`` is a whole batch run, and
carries only its id.
:param node: The node the item is about — the source for a cascade, the
one whose rate limits are released for a flush.
:param flow: The flow that node belongs to, so gating needs no lookup.
:param outputs: What the source node emitted (cascade and emission).
:param cause: Where the work came from, for logs and debugging.
:param not_before: Epoch seconds before which the item must not run.
:param guard: ``(key, value)`` the target node must still remember for
this item to be worth running — how a rescheduled wait cancels the
one it replaced.
:param entry_id: Set by the queue on claim; stable across redeliveries,
which is what makes it usable as an idempotency key.
:param deliveries: How many times this item has been handed out.
:param enqueued_at: When the item was made. Carried rather than read off
the entry id, because only the Redis queue has timestamps in its ids.
"""
kind: str
node: str
flow: str
outputs: dict[str, Any] = field(default_factory=dict)
cause: str = "system"
not_before: float = 0.0
guard_key: str = ""
guard_value: str = ""
entry_id: str = ""
deliveries: int = 1
enqueued_at: float = field(default_factory=time.time)
#: The run this item is, on a ``run`` item. Everything else about a run is
#: in its database row, so the journal only has to name it.
run_id: str = ""
def to_fields(self) -> dict[str, str]:
return {
"kind": self.kind,
"node": self.node,
"flow": self.flow,
"outputs": orjson.dumps(self.outputs).decode(),
"cause": self.cause,
"not_before": str(self.not_before),
"guard_key": self.guard_key,
"guard_value": self.guard_value,
"enqueued_at": str(self.enqueued_at),
"run_id": self.run_id,
}
@classmethod
def from_fields(
cls, fields: dict[str, str], entry_id: str, deliveries: int = 1
) -> WorkItem:
return cls(
kind=fields.get("kind", "cascade"),
node=fields.get("node", ""),
flow=fields.get("flow", ""),
outputs=orjson.loads(fields.get("outputs") or "{}"),
cause=fields.get("cause", "system"),
not_before=float(fields.get("not_before") or 0.0),
guard_key=fields.get("guard_key", ""),
guard_value=fields.get("guard_value", ""),
entry_id=entry_id,
deliveries=deliveries,
enqueued_at=float(fields.get("enqueued_at") or time.time()),
run_id=fields.get("run_id", ""),
)
class WorkQueue(ABC):
"""What the execution service pulls from."""
@abstractmethod
def add(self, item: WorkItem) -> None:
"""Journal an item for immediate execution."""
@abstractmethod
def add_delayed(self, item: WorkItem, not_before: float) -> None:
"""Journal an item that must not run before ``not_before``."""
@abstractmethod
def claim(self, count: int, block_ms: int) -> list[WorkItem]:
"""Take up to ``count`` items, waiting up to ``block_ms`` for one."""
@abstractmethod
def ack(self, item: WorkItem) -> None:
"""Mark an item done, so it is never redelivered."""
def touch(self, entry_ids: list[str]) -> None: # noqa: B027
"""Say these items are still being worked on, not abandoned.
A node with no timeout may run far longer than the reclaim window, and
nothing else distinguishes that from an engine that died holding the
item. Concrete rather than abstract: a queue with no redelivery has
nothing to answer here.
"""
@abstractmethod
def reclaim_stale(self, min_idle_ms: int) -> list[WorkItem]:
"""Take back items claimed by a consumer that never acknowledged them."""
@abstractmethod
def move_due(self, now: float) -> int:
"""Promote delayed items whose time has come. Returns how many."""
@abstractmethod
def dead_letter(self, item: WorkItem, reason: str) -> None:
"""Set an item aside after it has failed too often."""
@abstractmethod
def park(self, flow: str, item: WorkItem) -> None:
"""Hold an item while its flow is paused."""
@abstractmethod
def unpark(self, flow: str) -> list[WorkItem]:
"""Return a paused flow's held items to the queue, oldest first."""
@abstractmethod
def unpark_one(self, flow: str) -> WorkItem | None:
"""Take the oldest held item, leaving the rest parked. That is a step."""
@abstractmethod
def clear_flow(self, flow: str) -> None:
"""Forget anything held for a flow that no longer exists."""
@abstractmethod
def stats(self) -> dict[str, Any]:
"""Backlog, in-flight, delayed and parked counts plus the oldest
pending age, for the health endpoint."""
def backlog(self) -> int:
"""How much work is waiting to be claimed.
Distinct from ``pending``, which is what has been handed to a consumer
and not yet acknowledged — work in progress. An engine hours behind has
a small ``pending`` and a large backlog, which is why one is not the
other. Concrete rather than abstract so the degradation watcher can ask
any queue.
"""
return 0
def backlog_flows(self, sample: int = 100) -> dict[str, int]:
"""Which flows the waiting work belongs to, as far as can be sampled."""
return {}
@abstractmethod
def dead_letters(self, count: int = 50) -> list[dict[str, Any]]:
"""What was given up on, newest first."""
@abstractmethod
def mark_done(self, entry_id: str, node: str) -> None:
"""Record that a side effect already happened for this delivery."""
@abstractmethod
def was_done(self, entry_id: str, node: str) -> bool:
"""Did this exact item already run that side-effecting node?"""
@abstractmethod
def close(self) -> None:
"""Release whatever the queue holds."""
class MemoryWorkQueue(WorkQueue):
"""In-process queue. No durability: a crash loses whatever is in flight."""
def __init__(self) -> None:
self._items: deque[WorkItem] = deque()
self._delayed: list[tuple[float, int, WorkItem]] = []
self._parked: dict[str, list[WorkItem]] = {}
self._done: set[tuple[str, str]] = set()
# Claimed and not yet acknowledged, which is what Redis's `pending` is.
self._in_flight = 0
self._counter = 0
self._seq = 0
self._lock = threading.Lock()
self._wake = threading.Condition(self._lock)
def _next_id(self) -> str:
self._seq += 1
return f"mem-{self._seq}"
def add(self, item: WorkItem) -> None:
with self._wake:
if not item.entry_id:
item.entry_id = self._next_id()
self._items.append(item)
self._wake.notify()
def add_delayed(self, item: WorkItem, not_before: float) -> None:
with self._wake:
if not item.entry_id:
item.entry_id = self._next_id()
item.not_before = not_before
self._counter += 1
heapq.heappush(self._delayed, (not_before, self._counter, item))
self._wake.notify()
def claim(self, count: int, block_ms: int) -> list[WorkItem]:
deadline = time.monotonic() + block_ms / 1000.0
with self._wake:
while not self._items:
remaining = deadline - time.monotonic()
if remaining <= 0:
return []
self._wake.wait(remaining)
claimed = [
self._items.popleft() for _ in range(min(count, len(self._items)))
]
self._in_flight += len(claimed)
return claimed
def ack(self, item: WorkItem) -> None:
"""Nothing to take off the queue — claiming did that. Only counted."""
with self._lock:
self._in_flight = max(0, self._in_flight - 1)
def reclaim_stale(self, min_idle_ms: int) -> list[WorkItem]:
"""Nothing to reclaim: an item lost here died with the process."""
return []
def move_due(self, now: float) -> int:
moved = 0
with self._wake:
while self._delayed and self._delayed[0][0] <= now:
self._items.append(heapq.heappop(self._delayed)[2])
moved += 1
if moved:
self._wake.notify()
return moved
def dead_letter(self, item: WorkItem, reason: str) -> None:
logger.error("Dropping work item for '%s': %s", item.node, reason)
def park(self, flow: str, item: WorkItem) -> None:
with self._lock:
self._parked.setdefault(flow, []).append(item)
def unpark(self, flow: str) -> list[WorkItem]:
with self._lock:
return self._parked.pop(flow, [])
def unpark_one(self, flow: str) -> WorkItem | None:
with self._lock:
held = self._parked.get(flow)
return held.pop(0) if held else None
def clear_flow(self, flow: str) -> None:
with self._lock:
self._parked.pop(flow, None)
def backlog(self) -> int:
with self._lock:
return len(self._items)
def stats(self) -> dict[str, Any]:
with self._lock:
return {
"backlog": len(self._items),
"pending": self._in_flight,
"delayed": len(self._delayed),
"parked": sum(len(v) for v in self._parked.values()),
"oldest_pending_s": 0.0,
"durable": False,
}
def dead_letters(self, count: int = 50) -> list[dict[str, Any]]:
"""Nothing is kept: a dropped item here died with the process."""
return []
def mark_done(self, entry_id: str, node: str) -> None:
with self._lock:
self._done.add((entry_id, node))
def was_done(self, entry_id: str, node: str) -> bool:
with self._lock:
return (entry_id, node) in self._done
def close(self) -> None:
"""Nothing is held outside the process."""
class RedisWorkQueue(WorkQueue):
"""Redis Streams queue: the one that survives the process.
Items live in a stream read through a consumer group, so an item handed to
a consumer that dies before acknowledging it stays pending and is reclaimed
by whoever comes next.
"""
def __init__(
self,
host: str,
port: int = 6379,
# A root of its own: the state backend owns `pipeline:`, and sharing the
# prefix meant `RedisState.clear()` could DEL the queue stream.
namespace: str = "queue",
consumer: str | None = None,
) -> None:
self._redis = redis.Redis(host=host, port=port, decode_responses=True)
self._ns = namespace
self._consumer = consumer or f"engine-{int(time.time() * 1000) % 1_000_000}"
self._stream = f"{namespace}:__queue__"
self._delayed_key = f"{namespace}:__delayed__"
self._dead_key = f"{namespace}:__dead__"
# Which flows have something parked. Maintained rather than discovered:
# finding them with a keyspace scan walked every state and idempotency
# key in the database, twice per health poll.
self._parked_flows_key = f"{namespace}:__parked_flows__"
self._ensure_group()
def _ensure_group(self) -> None:
try:
self._redis.xgroup_create(self._stream, GROUP, id="0", mkstream=True)
except redis.ResponseError as exc:
if "BUSYGROUP" not in str(exc):
raise
def _parked_key(self, flow: str) -> str:
return f"{self._ns}:__parked__:{flow}"
def _done_key(self, entry_id: str, node: str) -> str:
return f"{self._ns}:__done__:{entry_id}:{node}"
def add(self, item: WorkItem) -> None:
self._redis.xadd(
self._stream,
cast(Any, item.to_fields()),
maxlen=STREAM_MAXLEN,
approximate=True,
)
def add_delayed(self, item: WorkItem, not_before: float) -> None:
item.not_before = not_before
self._redis.zadd(
self._delayed_key, {orjson.dumps(item.to_fields()): not_before}
)
def claim(self, count: int, block_ms: int) -> list[WorkItem]:
response = cast(
list[Any],
self._redis.xreadgroup(
GROUP,
self._consumer,
{self._stream: ">"},
count=count,
block=block_ms,
),
)
items: list[WorkItem] = []
for _stream, entries in response or []:
for entry_id, fields in entries:
items.append(WorkItem.from_fields(fields, entry_id))
return items
def ack(self, item: WorkItem) -> None:
if item.entry_id:
self._redis.xack(self._stream, GROUP, item.entry_id)
def touch(self, entry_ids: list[str]) -> None:
if not entry_ids:
return
# Claiming an entry we already hold resets how long it has been idle,
# which is the only thing `reclaim_stale` reads. `justid` keeps the
# delivery count where it is, so a long node neither redelivers nor
# spends its way towards the dead-letter cap. An entry already
# acknowledged is simply not there, and this is a no-op for it.
self._redis.xclaim(
self._stream,
GROUP,
self._consumer,
min_idle_time=0,
message_ids=entry_ids,
justid=True,
)
def reclaim_stale(self, min_idle_ms: int) -> list[WorkItem]:
"""Take over entries a dead consumer never acknowledged."""
_cursor, entries, _deleted = cast(
tuple[Any, list[Any], Any],
self._redis.xautoclaim(
self._stream,
GROUP,
self._consumer,
min_idle_time=min_idle_ms,
count=32,
),
)
if not entries:
return []
# xautoclaim does not report delivery counts, so ask xpending for them.
counts: dict[str, int] = {}
for record in cast(
list[dict[str, Any]],
self._redis.xpending_range(self._stream, GROUP, min="-", max="+", count=64),
):
counts[record["message_id"]] = record["times_delivered"]
return [
WorkItem.from_fields(fields, entry_id, counts.get(entry_id, 1))
for entry_id, fields in entries
if fields
]
def move_due(self, now: float) -> int:
due = cast(
list[str],
self._redis.zrangebyscore(self._delayed_key, "-inf", now, start=0, num=100),
)
if not due:
return 0
# Whoever removes it owns it: a second engine gets 0 for that member and
# leaves it alone. One round trip for the batch rather than one each,
# which matters because this runs every second.
pipe = self._redis.pipeline()
for raw in due:
pipe.zrem(self._delayed_key, raw)
claimed = cast(list[int], pipe.execute())
pipe = self._redis.pipeline()
moved = 0
for raw, owned in zip(due, claimed, strict=True):
if owned:
pipe.xadd(
self._stream,
cast(Any, orjson.loads(raw)),
maxlen=STREAM_MAXLEN,
approximate=True,
)
moved += 1
if moved:
pipe.execute()
return moved
def dead_letter(self, item: WorkItem, reason: str) -> None:
fields = item.to_fields()
fields["reason"] = reason
self._redis.xadd(
self._dead_key, cast(Any, fields), maxlen=1000, approximate=True
)
logger.error("Dead-lettered work item for '%s': %s", item.node, reason)
def park(self, flow: str, item: WorkItem) -> None:
pipe = self._redis.pipeline()
pipe.rpush(self._parked_key(flow), orjson.dumps(item.to_fields()))
pipe.sadd(self._parked_flows_key, flow)
pipe.execute()
def unpark(self, flow: str) -> list[WorkItem]:
key = self._parked_key(flow)
raw = cast(list[str], self._redis.lrange(key, 0, -1))
pipe = self._redis.pipeline()
pipe.delete(key)
pipe.srem(self._parked_flows_key, flow)
pipe.execute()
return [WorkItem.from_fields(orjson.loads(r), "") for r in raw]
def unpark_one(self, flow: str) -> WorkItem | None:
key = self._parked_key(flow)
pipe = self._redis.pipeline()
pipe.lpop(key)
pipe.llen(key)
raw, remaining = cast(tuple["str | None", int], pipe.execute())
if not remaining:
self._redis.srem(self._parked_flows_key, flow)
return WorkItem.from_fields(orjson.loads(raw), "") if raw else None
def clear_flow(self, flow: str) -> None:
pipe = self._redis.pipeline()
pipe.delete(self._parked_key(flow))
pipe.srem(self._parked_flows_key, flow)
pipe.execute()
def _group_info(self) -> dict[str, Any]:
"""This consumer group's row of ``XINFO GROUPS``, or an empty one."""
try:
groups = cast(list[dict[str, Any]], self._redis.xinfo_groups(self._stream))
except redis.ResponseError:
# No stream yet: nothing has ever been enqueued.
return {}
return next((g for g in groups if g.get("name") == GROUP), {})
def backlog(self) -> int:
"""Entries in the stream this group has never been handed.
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
before anyone read them — and unknown is reported as none waiting,
since the alternative is a health screen crying wolf after a trim.
"""
return int(self._group_info().get("lag") or 0)
def backlog_flows(self, sample: int = 100) -> dict[str, int]:
"""Which flows the waiting work belongs to, from a sample of the tail.
The group's lag is one number for the whole stream, and the actionable
half of "the engine is behind" is always *what* is producing the work.
Reads forward from the last entry the group was handed, which is where
the undelivered entries start.
"""
after = self._group_info().get("last-delivered-id")
if not after:
return {}
entries = cast(
list[tuple[str, dict[str, str]]],
self._redis.xrange(self._stream, min=f"({after}", max="+", count=sample),
)
counts: dict[str, int] = {}
for _entry_id, fields in entries:
flow = fields.get("flow", "")
if flow:
counts[flow] = counts.get(flow, 0) + 1
return counts
def stats(self) -> dict[str, Any]:
pending = cast(dict[str, Any], self._redis.xpending(self._stream, GROUP))
oldest = 0.0
count = pending.get("pending", 0) if isinstance(pending, dict) else 0
if count:
records = cast(
list[dict[str, Any]],
self._redis.xpending_range(
self._stream, GROUP, min="-", max="+", count=1
),
)
if records:
oldest = records[0]["time_since_delivered"] / 1000.0
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)
return {
"backlog": self.backlog(),
"pending": count,
"delayed": cast(int, self._redis.zcard(self._delayed_key)),
"parked": parked,
"oldest_pending_s": round(oldest, 1),
"durable": True,
}
def dead_letters(self, count: int = 50) -> list[dict[str, Any]]:
"""What was set aside, newest first. The entry id carries the time."""
entries = cast(
list[tuple[str, dict[str, str]]],
self._redis.xrevrange(self._dead_key, count=count),
)
return [
{
"id": entry_id,
"ts": int(entry_id.split("-")[0]) / 1000.0,
"flow": fields.get("flow", ""),
"node": fields.get("node", ""),
"cause": fields.get("cause", ""),
"reason": fields.get("reason", ""),
}
for entry_id, fields in entries
]
def mark_done(self, entry_id: str, node: str) -> None:
# An hour outlives any redelivery; after that the marker is noise.
self._redis.set(self._done_key(entry_id, node), "1", ex=3600)
def was_done(self, entry_id: str, node: str) -> bool:
return bool(self._redis.exists(self._done_key(entry_id, node)))
def close(self) -> None:
self._redis.close()