Files
app/backend/tests/flow/test_queue.py
T
stroblmeandClaude Opus 5 da528340a9 Cut the round trips a message costs the engine
Measured with `make bench-engine` against a real Redis: 103.6 -> 164.4
messages a second on a five-node chain (p50 latency 2125 -> 1171 ms) and
34.8 -> 63.2 on a fan-out of twenty. Against the memory backend, which is
what a pip install runs on, 262 -> 626.

The two that bought most of it:

- `StateBackend.record` puts a published value, its timestamp, its series
  and its version counter in one round trip. They were four calls building
  four pipelines, and a value crossing an edge pays them twice. A released
  rate-limit hold rides along instead of a DEL per port.
- the readiness check reads a node's inputs and hands them to the node,
  rather than reading the triggering ones to count them and having the node
  read the same keys again a moment later.

`apply_outputs` was a second copy of `_record_outputs` and is now the same
code plus the event that distinguishes it.

The rest, each small:

- `_derive` builds a node-by-id map and a `consumes` index, so dispatching
  an item and publishing a value stop scanning every node in the
  installation.
- `read_all` is memoised against the store revision — it sits on the
  publish path, so a dashboard slider was reading and validating every
  flow file per value. Same mechanism `_wiring` already uses.
- the `message_value` source block is built once per node instead of per
  emission.
- both timer threads ask the queue to promote only when something is
  actually due, which takes an idle engine from ~4 Redis round trips a
  second to one.
- the shared httpx client is bounded (32 connections, one retry); its
  default pool is 100 with no per-host cap, so one slow endpoint could
  take it and every other sender node with it.
- the MQTT and delay nodes no longer log a line per message at INFO.

Robustness, in the same pass:

- `MemoryWorkQueue._done` was a set nothing ever removed from — one entry
  per non-idempotent node per item, for the life of the process, in the
  default configuration. Capped, the way the Redis side expires its
  markers.
- a saturated engine can claim from the due lane past the cascade limit.
  The capacity gate sits in front of the claim, so the due lane's priority
  — decided inside it — did not apply while every slot was held: a motor's
  stop was not behind the long nodes, it was unread. Only after a slot has
  genuinely failed to free for half a second, and briefly, so the backlog
  is not starved in turn.
- `reclaim_stale` dispatches through that same gate. It could return sixty
  entries and push in-flight far past the limit the gate exists to hold.
- a flow's nodes are stopped together rather than one after another. Each
  gets `NODE_STOP_TIMEOUT`, so a flow whose broker was unreachable took
  five seconds per node — long enough to outlast `REBUILD_WAIT` and 503
  the deploy.
- the worker pool and the HTTP client are closed on a thread, not on the
  event loop, and a run closes the state backend it built (on Redis, a
  client and a connection pool per run).
- the five background tasks say something when they die. Each catches
  exceptions inside its loop, so one raised anywhere else left the engine
  serving with no metrics, no alerts or no artifact sweep, silently.

`tests/flow/test_round_trips.py` counts the state operations one message
costs — four, where it was about eleven — because none of the above would
fail a behavioural test if it were undone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6hPWS6YEbT1P8LxhhFb2T
2026-08-29 19:58:39 +02:00

629 lines
20 KiB
Python

"""The work queue, and what the execution service does with it."""
import threading
import time
from fluksio.flow import executor
from fluksio.flow.executor import ExecutionService
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.nodes import Node
from fluksio.flow.pipeline import Pipeline
from fluksio.flow import queue as queue_module
from fluksio.flow.queue import MemoryWorkQueue, WorkItem
from fluksio.flow.state import MemoryState
def test_items_come_back_in_the_order_they_went_in():
queue = MemoryWorkQueue()
for i in range(3):
queue.add(WorkItem(kind="cascade", node=f"f.n{i}", flow="f"))
claimed = queue.claim(10, 10)
assert [item.node for item in claimed] == ["f.n0", "f.n1", "f.n2"]
# Every item gets an id, which is what idempotency markers hang off.
assert all(item.entry_id for item in claimed)
def test_claiming_an_empty_queue_waits_and_gives_up():
queue = MemoryWorkQueue()
started = time.monotonic()
assert queue.claim(1, 50) == []
assert time.monotonic() - started >= 0.04
def test_a_delayed_item_stays_put_until_it_is_due():
queue = MemoryWorkQueue()
queue.add_delayed(WorkItem(kind="cascade", node="f.n", flow="f"), time.time() + 60)
assert queue.claim(1, 10) == []
assert queue.move_due(time.time()) == 0
assert queue.move_due(time.time() + 61) == 1
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():
queue = MemoryWorkQueue()
for i in range(3):
queue.park("heating", WorkItem(kind="cascade", node=f"f.n{i}", flow="heating"))
assert [i.node for i in queue.unpark("heating")] == ["f.n0", "f.n1", "f.n2"]
# Unparking empties it, so a second resume does not replay the same work.
assert queue.unpark("heating") == []
def test_a_deleted_flow_leaves_nothing_parked():
queue = MemoryWorkQueue()
queue.park("gone", WorkItem(kind="cascade", node="gone.n", flow="gone"))
queue.clear_flow("gone")
assert queue.unpark("gone") == []
def test_claimed_work_counts_as_in_flight_until_it_is_acknowledged():
"""The health tile's "in flight" reads zero without this."""
queue = MemoryWorkQueue()
queue.add(WorkItem(kind="cascade", node="f.n", flow="f"))
assert queue.stats()["pending"] == 0
# The stream length was never a backlog, so the key is gone from both queues.
assert "depth" not in queue.stats()
(item,) = queue.claim(1, 10)
assert queue.stats()["pending"] == 1
queue.ack(item)
assert queue.stats()["pending"] == 0
def test_work_waiting_to_be_claimed_is_the_backlog():
"""`pending` is what is running; an engine hours behind reports it as idle."""
queue = MemoryWorkQueue()
queue.add(WorkItem(kind="cascade", node="f.n", flow="f"))
assert queue.stats()["backlog"] == 1
assert queue.stats()["pending"] == 0
(item,) = queue.claim(1, 10)
assert queue.stats()["backlog"] == 0
assert queue.stats()["pending"] == 1
queue.ack(item)
assert queue.stats()["backlog"] == 0
def test_a_sustained_backlog_says_the_engine_is_behind():
"""A flow enqueuing faster than the pool drains produced no signal at all."""
events: list[dict] = []
queue = MemoryWorkQueue()
service = ExecutionService(queue)
service._publish = events.append # type: ignore[method-assign]
for _ in range(executor.BACKLOG_DEGRADED):
queue.add(WorkItem(kind="cascade", node="f.n", flow="f"))
for _ in range(executor.BACKLOG_STRIKES - 1):
service._check_backlog()
assert events == []
service._check_backlog()
assert [e["type"] for e in events] == ["engine_degraded"]
assert service.stats()["behind"] is True
# Said once, not once every five seconds for as long as it lasts.
service._check_backlog()
assert len(events) == 1
# And a drained queue clears it, so the next backlog is announced again.
queue.claim(executor.BACKLOG_DEGRADED, 10)
service._check_backlog()
assert service.stats()["behind"] is False
def _pipeline_with_a_consumer() -> tuple[Pipeline, Node, MemoryState, list]:
"""A source whose message a consumer records."""
seen: list[float] = []
def consume(reading, params):
seen.append(reading)
return {"doubled": reading * 2}
source = Node(
f=lambda params: None,
provides=[MessageSpec(name="reading", port="reading", dtype=DType.FLOAT)],
name="source",
)
consumer = Node(
f=consume,
requires=[MessageSpec(name="reading", port="reading", dtype=DType.FLOAT)],
provides=[MessageSpec(name="doubled", port="doubled", dtype=DType.FLOAT)],
name="consumer",
)
source.assign_flow("f", "source")
consumer.assign_flow("f", "consumer")
state = MemoryState()
queue = MemoryWorkQueue()
pipeline = Pipeline(nodes=[source, consumer], state=state, work_queue=queue)
return pipeline, source, state, seen
def test_a_trigger_is_journaled_rather_than_run_on_the_spot():
pipeline, source, state, seen = _pipeline_with_a_consumer()
source.inject({"reading": 3.0})
# Nothing ran yet: the value is in the queue, not in state.
assert seen == []
assert "f.reading" not in state
service = ExecutionService(pipeline._queue)
service.bind(pipeline)
for item in pipeline._queue.claim(10, 10):
service._run_item(item)
assert seen == [3.0]
assert state["f.doubled"] == 6.0
def test_work_for_a_paused_flow_is_held_and_released_on_resume():
pipeline, source, state, seen = _pipeline_with_a_consumer()
service = ExecutionService(pipeline._queue)
service.bind(pipeline)
pipeline.pause("f")
source.inject({"reading": 1.0})
for item in pipeline._queue.claim(10, 10):
service._run_item(item)
assert seen == []
pipeline.resume("f")
for item in pipeline._queue.unpark("f"):
pipeline._queue.add(item)
for item in pipeline._queue.claim(10, 10):
service._run_item(item)
assert seen == [1.0]
def test_a_step_runs_one_held_item_and_leaves_the_flow_paused():
pipeline, source, _state, seen = _pipeline_with_a_consumer()
service = ExecutionService(pipeline._queue)
service.bind(pipeline)
pipeline.pause("f")
source.inject({"reading": 1.0})
source.inject({"reading": 2.0})
for item in pipeline._queue.claim(10, 10):
service._run_item(item)
assert seen == []
assert service.step("f") == "f.source"
assert seen == [1.0]
# Still paused, and the second value is still waiting for the next step.
assert pipeline.is_paused("f")
assert [i.outputs for i in pipeline._queue.unpark("f")] == [{"f.reading": 2.0}]
def test_stepping_a_flow_with_nothing_held_says_so_rather_than_failing():
pipeline, _source, _state, _seen = _pipeline_with_a_consumer()
service = ExecutionService(pipeline._queue)
service.bind(pipeline)
pipeline.pause("f")
assert service.step("f") is None
def test_work_for_a_stopped_flow_is_dropped():
pipeline, source, state, seen = _pipeline_with_a_consumer()
stopped = Pipeline(
nodes=pipeline.nodes,
state=pipeline.state,
work_queue=pipeline._queue,
disabled_flows={"f"},
)
service = ExecutionService(pipeline._queue)
service.bind(stopped)
# Reaching the queue at all takes a direct add: trigger drops it earlier.
stopped._queue.add(
WorkItem(kind="cascade", node="f.source", flow="f", outputs={"f.reading": 1.0})
)
for item in stopped._queue.claim(10, 10):
service._run_item(item)
assert seen == []
def test_an_item_that_keeps_coming_back_is_dead_lettered():
pipeline, _source, _state, seen = _pipeline_with_a_consumer()
service = ExecutionService(pipeline._queue)
service.bind(pipeline)
item = WorkItem(
kind="cascade",
node="f.source",
flow="f",
outputs={"f.reading": 1.0},
deliveries=4,
)
service._run_item(item)
# Given up on rather than run again, so a poison item cannot loop forever.
assert seen == []
def test_an_item_for_a_node_that_no_longer_exists_is_dropped():
pipeline, _source, _state, seen = _pipeline_with_a_consumer()
service = ExecutionService(pipeline._queue)
service.bind(pipeline)
service._run_item(WorkItem(kind="cascade", node="f.removed", flow="f"))
assert seen == []
def test_a_replayed_item_does_not_repeat_a_side_effect():
"""At-least-once delivery must not mean two of the same outgoing request."""
calls: list[float] = []
def send(reading, params):
calls.append(reading)
return None
source = Node(
f=lambda params: None,
provides=[MessageSpec(name="reading", port="reading", dtype=DType.FLOAT)],
name="source",
)
class SendingNode(Node):
"""Stands in for the built-ins that reach outside."""
idempotent = False
sender = SendingNode(
f=send,
requires=[MessageSpec(name="reading", port="reading", dtype=DType.FLOAT)],
name="sender",
)
source.assign_flow("f", "source")
sender.assign_flow("f", "sender")
queue = MemoryWorkQueue()
pipeline = Pipeline(nodes=[source, sender], state=MemoryState(), work_queue=queue)
service = ExecutionService(queue)
service.bind(pipeline)
source.inject({"reading": 5.0})
(item,) = queue.claim(10, 10)
service._run_item(item)
assert calls == [5.0]
# The same item again, as a reaper would hand it back after a crash.
item.deliveries = 2
service._run_item(item)
assert calls == [5.0]
# -----------------------------------------------------------------------------
# Telling the queue that a long node is working, not lost
#
# What marks an item abandoned is nobody touching it. A node with no timeout
# may run for hours, so the engine holding it says so on a timer — and an
# engine that died says nothing, which is the distinction the reaper needs.
# -----------------------------------------------------------------------------
class RecordingQueue(MemoryWorkQueue):
"""A memory queue that writes down what it was asked to hold on to."""
def __init__(self) -> None:
super().__init__()
self.touched: list[list[str]] = []
def touch(self, entry_ids: list[str]) -> None:
self.touched.append(list(entry_ids))
def test_work_in_flight_is_touched_until_it_finishes(monkeypatch):
monkeypatch.setattr(executor, "TOUCH_INTERVAL_S", 0.0)
monkeypatch.setattr(executor, "DELAYED_INTERVAL_S", 0.05)
running = threading.Event()
release = threading.Event()
def slow(reading, params):
running.set()
release.wait(5)
return {"doubled": reading * 2}
source = Node(
f=lambda params: None,
provides=[MessageSpec(name="reading", dtype=DType.FLOAT)],
name="source",
)
consumer = Node(
f=slow,
requires=[MessageSpec(name="reading", dtype=DType.FLOAT)],
provides=[MessageSpec(name="doubled", dtype=DType.FLOAT)],
name="consumer",
)
source.assign_flow("f", "source")
consumer.assign_flow("f", "consumer")
queue = RecordingQueue()
pipeline = Pipeline(nodes=[source, consumer], state=MemoryState(), work_queue=queue)
service = ExecutionService(queue)
service.bind(pipeline)
service.start()
try:
source.inject({"reading": 3.0})
assert running.wait(5)
# Give the timer a couple of passes while the node is still in there.
time.sleep(0.2)
held = [ids for ids in queue.touched if ids]
assert held, "a running item was never touched"
release.set()
time.sleep(0.3)
# Once it is done it is acknowledged, so there is nothing to hold.
assert queue.touched[-1] == []
finally:
release.set()
service.stop()
def test_no_more_is_claimed_than_the_pool_can_run():
"""A backlog belongs in the queue, not inside the process.
Claiming ahead of the pool used to leave every waiting item counted as a
busy cascade and holding its journal entry open, so four cascade threads
reported hundreds in flight on an engine that was merely behind.
"""
release = threading.Event()
def slow(reading, params):
release.wait(5)
return {"doubled": reading * 2}
source = Node(
f=lambda params: None,
provides=[MessageSpec(name="reading", dtype=DType.FLOAT)],
name="source",
)
consumer = Node(
f=slow,
requires=[MessageSpec(name="reading", dtype=DType.FLOAT)],
provides=[MessageSpec(name="doubled", dtype=DType.FLOAT)],
name="consumer",
)
source.assign_flow("f", "source")
consumer.assign_flow("f", "consumer")
queue = MemoryWorkQueue()
pipeline = Pipeline(nodes=[source, consumer], state=MemoryState(), work_queue=queue)
service = ExecutionService(queue)
service.bind(pipeline)
service.start()
try:
for i in range(40):
queue.add(
WorkItem(
kind="cascade",
node="f.source",
flow="f",
outputs={"f.reading": float(i)},
)
)
time.sleep(0.5)
stats = service.stats()
assert stats["cascades_busy"] <= service.max_cascades
# And the journal entries of what is only waiting are still free.
assert stats["pending"] <= service.max_cascades
# Waiting is not idle: the rest of the forty is the backlog.
assert stats["backlog"] >= 30
finally:
release.set()
service.stop()
# -----------------------------------------------------------------------------
# Emissions: values a node publishes while it is still running
# -----------------------------------------------------------------------------
def _emitting_pipeline() -> tuple[Pipeline, Node, MemoryState, list]:
"""A source with a streaming port, and a consumer that records every value."""
seen: list[float] = []
def consume(chunk, params):
seen.append(chunk)
return None
source = Node(
f=lambda params: None,
provides=[
MessageSpec(name="chunk", port="chunk", dtype=DType.FLOAT, stream=True)
],
name="source",
)
consumer = Node(
f=consume,
requires=[MessageSpec(name="chunk", port="chunk", dtype=DType.FLOAT)],
name="consumer",
)
source.assign_flow("f", "source")
consumer.assign_flow("f", "consumer")
state = MemoryState()
queue = MemoryWorkQueue()
pipeline = Pipeline(nodes=[source, consumer], state=state, work_queue=queue)
return pipeline, source, state, seen
def test_every_emitted_chunk_reaches_the_consumer():
"""A consumer slower than its producer must not skip what it missed.
Reading state instead would give whichever chunk is newest by the time the
item runs — fine for a temperature, lossy for a second of speech.
"""
pipeline, source, state, seen = _emitting_pipeline()
service = ExecutionService(pipeline._queue)
service.bind(pipeline)
# Both emitted before either item is claimed, so state has moved on.
pipeline.publish_emission(source, {"f.chunk": 1.0})
pipeline.publish_emission(source, {"f.chunk": 2.0})
assert state["f.chunk"] == 2.0
for item in pipeline._queue.claim(10, 10):
service._run_item(item)
assert seen == [1.0, 2.0]
def test_a_delivered_emission_does_not_write_state_a_second_time():
"""The value in state stays the newest one, whenever an item is claimed."""
pipeline, source, state, seen = _emitting_pipeline()
service = ExecutionService(pipeline._queue)
service.bind(pipeline)
pipeline.publish_emission(source, {"f.chunk": 1.0})
# What the node returned at the end, after the emission it made on the way.
pipeline.apply_outputs(source, {"f.chunk": 9.0})
for item in pipeline._queue.claim(10, 10):
service._run_item(item)
assert seen[0] == 1.0
# Re-applying the carried chunk here is what would undo the final value.
assert state["f.chunk"] == 9.0
def test_a_throttled_emission_wakes_nothing():
pipeline, source, _state, seen = _emitting_pipeline()
source.provides["f.chunk"] = MessageSpec(
name="f.chunk", port="chunk", dtype=DType.FLOAT, stream=True, interval=60
)
service = ExecutionService(pipeline._queue)
service.bind(pipeline)
pipeline.publish_emission(source, {"f.chunk": 1.0})
pipeline.publish_emission(source, {"f.chunk": 2.0})
for item in pipeline._queue.claim(10, 10):
service._run_item(item)
# The first is let through; the second is held by the interval, and a value
# nothing published is nothing to wake on.
assert seen == [1.0]
def test_a_due_timer_is_claimable_while_the_backlog_is_not():
"""What a saturated engine uses to get a motor's stop out.
Every cascade slot held by a long node used to mean the due lane was not
merely behind the backlog but unread, because the capacity gate sits in
front of the claim.
"""
queue = MemoryWorkQueue()
queue.add(WorkItem(kind="cascade", node="f.backlog", flow="f"))
queue.add_delayed(
WorkItem(kind="cascade", node="f.timer", flow="f"), time.time() - 1
)
assert queue.move_due(time.time()) == 1
claimed = queue.claim(10, 10, due_only=True)
assert [item.node for item in claimed] == ["f.timer"]
# And the backlog is still there for whoever comes next.
assert [item.node for item in queue.claim(10, 10)] == ["f.backlog"]
def test_a_due_only_claim_of_nothing_does_not_take_the_backlog():
queue = MemoryWorkQueue()
queue.add(WorkItem(kind="cascade", node="f.backlog", flow="f"))
assert queue.claim(10, 10, due_only=True) == []
assert len(queue.claim(10, 10)) == 1
def test_done_markers_do_not_grow_without_bound():
"""The Redis side expires them after an hour; this one is capped by count."""
queue = MemoryWorkQueue()
for i in range(queue_module.DONE_MARKERS + 500):
queue.mark_done(f"entry-{i}", "f.node")
assert len(queue._done) == queue_module.DONE_MARKERS
# The newest are what redelivery would ask about.
assert queue.was_done(f"entry-{queue_module.DONE_MARKERS + 499}", "f.node")
assert not queue.was_done("entry-0", "f.node")