Stop paying five Redis round trips and a global lock per message

The engine was I/O-bound on its own state backend. `RedisState.lock()` is one
key — `pipeline:_lock` — for the whole process, taken five times a message at
two round trips each, and every cascade and every node read queued behind it.
Inside it, reading a node's inputs was three round trips per input (an EXISTS
for `in`, then EXISTS and GET for the value), writing was two updates that a
single transaction already gives, and the version counters went one INCR at a
time.

Replaced with the atomic command that was always available: `get_present` is
one MGET and tells a missing key from one holding null, so the lock it used to
be read under bought nothing; value and timestamp land in one `update`, which
is a MULTI/EXEC; `increment_multi` pipelines the counters. `values()` — what
every websocket snapshot calls — is two reads whatever the message count
instead of two per message.

Beside that: every webhook did its blocking XADD on the asyncio event loop
(MQTT already used `to_thread`); the per-execution `NodeOutcome` was built and
validated even with no run watching; `_minute` built a tz-aware datetime per
event on the loop thread to key a dict, and now keys on an int; `move_due`
promoted delayed items one round trip each, every second; `FLOW_MAX_CASCADES`
makes the in-flight ceiling a setting rather than a constant.

`orjson` replaces stdlib json where a message pays for it — state, the
journal, the engine side of the worker pipe. `fluksio-worker` stays
dependency-free, and the run-cache digest stays on stdlib so no stored key is
invalidated. A non-finite number now stores as `null` rather than the bare
`NaN` that was never JSON.

Measured with `scripts/bench_engine.py` against a real Redis, 200 messages:
a five-node chain went from 43.9 to 103.1 msg/s with p50 latency 2110ms →
782ms and p95 3913ms → 1439ms; one source into twenty consumers went from 5.4
to 33.7 msg/s. In memory, twenty consumers went from 187 to 448 msg/s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BpfSinyCBfjuieikyfMPbf
This commit is contained in:
2026-08-26 10:12:25 +02:00
co-authored by Claude Opus 5
parent a9136c7811
commit 180da3d640
13 changed files with 300 additions and 104 deletions
+4
View File
@@ -93,6 +93,10 @@ class Settings(BaseSettings):
MCP_TOKEN_EXPIRE_MINUTES: int = 60
MCP_REFRESH_EXPIRE_DAYS: int = 30
FLOW_MAX_WORKERS: int = 4
# How many cascades may be in flight at once. Sustained throughput is this
# over the mean cascade time, so an installation whose nodes wait on the
# network rather than on a CPU wants it higher than the core count.
FLOW_MAX_CASCADES: int = 4
# How long a python node may be silent before its worker is killed, unless
# the node sets its own. 0, the default, disables it: a dead worker still
# fails fast, and a slow one is left to finish. Set it where silence means
+15 -5
View File
@@ -28,7 +28,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
CLAIM_COUNT = 4
CLAIM_BLOCK_MS = 1000
# Long enough that a busy cascade is not mistaken for a dead one.
RECLAIM_IDLE_MS = 60_000
@@ -38,6 +37,10 @@ RECLAIM_INTERVAL_S = 30.0
# stopping — which is what an engine that died does.
TOUCH_INTERVAL_S = 20.0
DELAYED_INTERVAL_S = 1.0
#: How many cascades may be in flight, unless the service is given a number.
#: Sustained throughput is this over the mean cascade time, so an installation
#: whose nodes wait on a network rather than a CPU may want more of them —
#: `FLOW_MAX_CASCADES` is where that is said.
MAX_CASCADES = 4
# How long a reload waits for claimed work to finish before rebuilding anyway.
DRAIN_TIMEOUT_S = 10.0
@@ -57,9 +60,11 @@ class ExecutionService:
queue: WorkQueue,
max_workers: int | None = None,
events: EventBus | None = None,
max_cascades: int | None = None,
) -> None:
self.queue = queue
self._events = events
self.max_cascades = max_cascades or MAX_CASCADES
self._pipeline: Pipeline | None = None
self._stop = threading.Event()
self._intake = threading.Event()
@@ -72,7 +77,7 @@ class ExecutionService:
max_workers=max_workers or 4, thread_name_prefix="node"
)
self._cascade_pool = ThreadPoolExecutor(
max_workers=MAX_CASCADES, thread_name_prefix="cascade"
max_workers=self.max_cascades, thread_name_prefix="cascade"
)
self._consumer: threading.Thread | None = None
self._timers: threading.Thread | None = None
@@ -136,6 +141,11 @@ class ExecutionService:
def alive(self) -> bool:
return self._consumer is not None and self._consumer.is_alive()
@property
def inflight(self) -> int:
"""Cascades claimed and still running."""
return self._inflight
# -------------------------------------------------------------------------
# Threads
# -------------------------------------------------------------------------
@@ -150,7 +160,7 @@ class ExecutionService:
if not free:
continue
try:
items = self.queue.claim(min(CLAIM_COUNT, free), CLAIM_BLOCK_MS)
items = self.queue.claim(free, CLAIM_BLOCK_MS)
failures = 0
except Exception as exc:
failures += 1
@@ -251,9 +261,9 @@ class ExecutionService:
work that is actually being run.
"""
with self._inflight_lock:
while self._inflight >= MAX_CASCADES and not self._stop.is_set():
while self._inflight >= self.max_cascades and not self._stop.is_set():
self._inflight_lock.wait(0.5)
return 0 if self._stop.is_set() else MAX_CASCADES - self._inflight
return 0 if self._stop.is_set() else self.max_cascades - self._inflight
def _dispatch(self, item: WorkItem) -> None:
with self._inflight_lock:
+17 -6
View File
@@ -67,8 +67,16 @@ RECORDED = {
}
def _minute(ts: float) -> datetime:
return datetime.fromtimestamp(ts, UTC).replace(second=0, microsecond=0)
def _minute(ts: float) -> int:
"""The epoch second the minute containing ``ts`` starts at.
An integer rather than a datetime: this runs once per event on the API's
event loop, and building a tz-aware datetime to key a dict with cost more
than everything the collector does with the event afterwards. The row
still wants one, so `_write` builds it — once per minute per node rather
than once per event.
"""
return int(ts // 60) * 60
def _detail(event: dict[str, Any]) -> str:
@@ -84,7 +92,7 @@ class MetricsCollector:
def __init__(self, events: EventBus, flush_s: float = FLUSH_INTERVAL_S) -> None:
self._events = events
self._flush_s = flush_s
self._buckets: dict[tuple[str, str, datetime], dict[str, float]] = {}
self._buckets: dict[tuple[str, str, int], dict[str, float]] = {}
self._runs: dict[str, dict[str, Any]] = {}
self._pending: list[EngineEvent] = []
# The traceback arrives one event before the failure it belongs to,
@@ -302,7 +310,7 @@ class MetricsCollector:
def _hold(
self,
buckets: dict[tuple[str, str, datetime], dict[str, float]],
buckets: dict[tuple[str, str, int], dict[str, float]],
pending: list[EngineEvent],
) -> None:
"""Take a batch the database refused back, rather than losing it.
@@ -332,7 +340,7 @@ class MetricsCollector:
def _write(
self,
buckets: dict[tuple[str, str, datetime], dict[str, float]],
buckets: dict[tuple[str, str, int], dict[str, float]],
pending: list[EngineEvent],
runs: list[dict[str, Any]],
prune: bool,
@@ -340,7 +348,10 @@ class MetricsCollector:
with Session(engine) as session:
for (flow, node, minute), agg in buckets.items():
statement = insert(MetricBucket).values(
flow=flow, node=node, bucket=minute, **agg
flow=flow,
node=node,
bucket=datetime.fromtimestamp(minute, UTC),
**agg,
)
# The same minute is written several times, so the counters add
# and the maxima take whichever is larger. Columns are read by
+6 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import hmac
import logging
import threading
@@ -388,8 +389,11 @@ class HttpNode(Node):
if spec.port in data:
typed_data[spec.port] = spec.coerce(data[spec.port])
# Inject data into the pipeline (trigger mode nodes inject via provides)
result = self.inject(typed_data)
# Inject data into the pipeline (trigger mode nodes inject via
# provides). Off the loop: journalling is a blocking Redis
# round trip, and every webhook was making it on the thread
# the whole API answers from.
result = await asyncio.to_thread(self.inject, typed_data)
return JSONResponse(
content={
+79 -67
View File
@@ -678,8 +678,7 @@ class Pipeline:
)
def _increment_message_versions(self, outputs: dict[str, Any]) -> None:
for msg_name in outputs:
self._state.increment(self._version_key(msg_name))
self._state.increment_multi([self._version_key(name) for name in outputs])
def _check_synchronous_ready(self, node: Node) -> tuple[bool, dict[str, int]]:
"""A synchronous node runs once every input is newer than last time.
@@ -761,19 +760,17 @@ class Pipeline:
)
return False
with self._state.lock():
for name in due:
self._state[self._delivered_key(node.id, name)] = now
self._state.update({self._delivered_key(node.id, name): now for name in due})
return True
def _is_node_ready(self, node: Node, state: StateBackend) -> bool:
with state.lock():
for msg_name, spec in node.requires.items():
# A non-triggering input is read if it happens to be there;
# waiting for it would make an accumulator's first run
# impossible, since it is what the node is about to write.
if spec.trigger and msg_name not in state:
return False
# A non-triggering input is read if it happens to be there; waiting for
# it would make an accumulator's first run impossible, since it is what
# the node is about to write.
waited_on = [msg for msg, spec in node.requires.items() if spec.trigger]
# One MGET rather than an EXISTS per input under the global state lock.
if len(state.get_present(waited_on)) != len(waited_on):
return False
if not self._input_is_due(node):
return False
@@ -928,8 +925,10 @@ class Pipeline:
started = time.perf_counter()
collected = logs.Collector()
try:
with state.lock():
inputs = {k: state[k] for k in node.requires if k in state}
# One MGET. The lock this used to be read under bought nothing a
# single bulk read does not, and it was the engine's one global
# mutex — every node of every cascade queued behind it.
inputs = state.get_present(list(node.requires))
key = ""
if self.run_cache is not None and node.fingerprint:
@@ -977,37 +976,42 @@ class Pipeline:
"ts": time.time(),
}
)
self._observe(
NodeOutcome(
node=node.id,
ok=True,
duration_ms=duration_ms,
outputs=len(result or {}),
logs=collected.text,
artifacts={
name: value
for name, value in (result or {}).items()
if is_reference(value)
},
cache_key=key,
# Post-throttle: what went into state is what a later run
# restoring this node has to find.
output_values=result,
# Guarded rather than left to `_observe`: a live cascade has no
# observer, and building this model to drop it was one pydantic
# validation per node per message on the path that runs most.
if self.observer is not None:
self._observe(
NodeOutcome(
node=node.id,
ok=True,
duration_ms=duration_ms,
outputs=len(result or {}),
logs=collected.text,
artifacts={
name: value
for name, value in (result or {}).items()
if is_reference(value)
},
cache_key=key,
# Post-throttle: what went into state is what a later
# run restoring this node has to find.
output_values=result,
)
)
)
return result
except Exception as exc:
# One failing node must not take the rest of the graph down.
error = self.publish_error(node, exc, collected, entry_id)
self._observe(
NodeOutcome(
node=node.id,
ok=False,
duration_ms=round((time.perf_counter() - started) * 1000, 2),
error=error,
logs=collected.text,
if self.observer is not None:
self._observe(
NodeOutcome(
node=node.id,
ok=False,
duration_ms=round((time.perf_counter() - started) * 1000, 2),
error=error,
logs=collected.text,
)
)
)
return None
def _record_outputs(
@@ -1021,13 +1025,14 @@ class Pipeline:
node produced, leaving through a port it declared.
"""
ts = time.time()
with state.lock():
state.update(outputs)
state.update({self._timestamp_key(name): ts for name in outputs})
# Value and timestamp in one write, which is what the lock around two
# of them was for — a pipeline is a transaction, so they still land
# together and nobody waits on a mutex to do it.
state.update({**outputs, **{self._timestamp_key(name): ts for name in outputs}})
# Append-only, so it needs no lock of its own.
state.append_history(outputs, ts, self.history_limits)
self._increment_message_versions(outputs)
origin = node_source(node)
origin = node_source(node).model_dump()
for name, value in outputs.items():
self._publish(
{
@@ -1036,7 +1041,7 @@ class Pipeline:
"name": name,
"value": value,
"ts": ts,
"source": origin.model_dump(),
"source": origin,
}
)
@@ -1273,8 +1278,9 @@ class Pipeline:
) -> StateBackend:
"""Execute the graph (or one flow's nodes) against the shared state."""
if inputs:
with self._state.lock():
self._state.update(inputs)
# `update` is already one transaction; the lock around it was not
# holding anything else still.
self._state.update(inputs)
self._increment_message_versions(inputs)
return self._execute_parallel(nodes, self._state, check_ready=False)
@@ -1298,12 +1304,10 @@ class Pipeline:
state = self._state
ts = time.time()
with state.lock():
state.update(outputs)
state.update({self._timestamp_key(name): ts for name in outputs})
state.update({**outputs, **{self._timestamp_key(name): ts for name in outputs}})
state.append_history(outputs, ts, self.history_limits)
self._increment_message_versions(outputs)
origin = node_source(node)
origin = node_source(node).model_dump()
for name, value in outputs.items():
self._publish(
{
@@ -1312,7 +1316,7 @@ class Pipeline:
"name": name,
"value": value,
"ts": ts,
"source": origin.model_dump(),
"source": origin,
}
)
# An injecting node — an MQTT subscriber, a webhook — publishes
@@ -1487,11 +1491,12 @@ class Pipeline:
origin = source or ValueSource(kind="api", label="API")
ts = time.time()
with self._state.lock():
self._state.update(values)
self._state.update({self._timestamp_key(name): ts for name in values})
self._state.update(
{**values, **{self._timestamp_key(name): ts for name in values}}
)
self._state.append_history(values, ts, self.history_limits)
self._increment_message_versions(values)
source_dump = origin.model_dump()
for name, value in values.items():
self._publish(
{
@@ -1500,7 +1505,7 @@ class Pipeline:
"name": name,
"value": value,
"ts": ts,
"source": origin.model_dump(),
"source": source_dump,
}
)
@@ -1580,18 +1585,25 @@ class Pipeline:
self._run_here(node, outputs, cause="external")
def values(self, flow: str | None = None) -> dict[str, dict[str, Any]]:
"""Last value and timestamp of every message, optionally one flow's."""
out: dict[str, dict[str, Any]] = {}
with self._state.lock():
keys = [k for k in self._state.keys() if not k.startswith("__")]
for key in keys:
if flow and flow_of(key) != flow:
continue
out[key] = {
"value": self._state.get(key),
"ts": self._state.get(self._timestamp_key(key)),
}
return out
"""Last value and timestamp of every message, optionally one flow's.
Two reads whatever the message count: this is what every websocket
snapshot calls, and it used to be a round trip per value and another
per timestamp, one at a time under the global state lock.
"""
keys = [
k
for k in self._state.keys()
if not k.startswith("__") and (not flow or flow_of(k) == flow)
]
if not keys:
return {}
stamps = [self._timestamp_key(k) for k in keys]
found = self._state.get_multi(keys + stamps)
return {
key: {"value": found.get(key), "ts": found.get(ts)}
for key, ts in zip(keys, stamps, strict=True)
}
def reset(self) -> None:
self._state.clear()
+26 -12
View File
@@ -13,7 +13,6 @@ honestly to "not".
from __future__ import annotations
import heapq
import json
import logging
import threading
import time
@@ -22,6 +21,7 @@ from collections import deque
from dataclasses import dataclass, field
from typing import Any, cast
import orjson
import redis
logger = logging.getLogger(__name__)
@@ -78,7 +78,7 @@ class WorkItem:
"kind": self.kind,
"node": self.node,
"flow": self.flow,
"outputs": json.dumps(self.outputs),
"outputs": orjson.dumps(self.outputs).decode(),
"cause": self.cause,
"not_before": str(self.not_before),
"guard_key": self.guard_key,
@@ -95,7 +95,7 @@ class WorkItem:
kind=fields.get("kind", "cascade"),
node=fields.get("node", ""),
flow=fields.get("flow", ""),
outputs=json.loads(fields.get("outputs") or "{}"),
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", ""),
@@ -371,7 +371,9 @@ class RedisWorkQueue(WorkQueue):
def add_delayed(self, item: WorkItem, not_before: float) -> None:
item.not_before = not_before
self._redis.zadd(self._delayed_key, {json.dumps(item.to_fields()): 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(
@@ -445,17 +447,29 @@ class RedisWorkQueue(WorkQueue):
list[str],
self._redis.zrangebyscore(self._delayed_key, "-inf", now, start=0, num=100),
)
moved = 0
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:
# Whoever removes it owns it: a second engine would get 0 here.
if self._redis.zrem(self._delayed_key, raw):
self._redis.xadd(
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, json.loads(raw)),
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:
@@ -468,7 +482,7 @@ class RedisWorkQueue(WorkQueue):
def park(self, flow: str, item: WorkItem) -> None:
pipe = self._redis.pipeline()
pipe.rpush(self._parked_key(flow), json.dumps(item.to_fields()))
pipe.rpush(self._parked_key(flow), orjson.dumps(item.to_fields()))
pipe.sadd(self._parked_flows_key, flow)
pipe.execute()
@@ -479,7 +493,7 @@ class RedisWorkQueue(WorkQueue):
pipe.delete(key)
pipe.srem(self._parked_flows_key, flow)
pipe.execute()
return [WorkItem.from_fields(json.loads(r), "") for r in raw]
return [WorkItem.from_fields(orjson.loads(r), "") for r in raw]
def unpark_one(self, flow: str) -> WorkItem | None:
key = self._parked_key(flow)
@@ -489,7 +503,7 @@ class RedisWorkQueue(WorkQueue):
raw, remaining = cast(tuple["str | None", int], pipe.execute())
if not remaining:
self._redis.srem(self._parked_flows_key, flow)
return WorkItem.from_fields(json.loads(raw), "") if raw else None
return WorkItem.from_fields(orjson.loads(raw), "") if raw else None
def clear_flow(self, flow: str) -> None:
pipe = self._redis.pipeline()
+78 -5
View File
@@ -7,7 +7,6 @@ supporting both in-memory storage and Redis for distributed execution.
from __future__ import annotations
import json
from abc import ABC, abstractmethod
from collections import deque
from collections.abc import Iterator
@@ -15,8 +14,16 @@ from contextlib import contextmanager
from threading import RLock
from typing import Any, cast
import orjson
import redis
#: A node may return a dict keyed by something other than a string, and the
#: stdlib encoder this replaced turned those keys into strings rather than
#: refusing them. Two differences remain and are both improvements: a
#: non-finite number is stored as ``null`` instead of the bare ``NaN`` that is
#: not JSON at all, and a datetime serialises rather than raising.
_JSON_OPTS = orjson.OPT_NON_STR_KEYS
# A sparkline only means something for numbers, so the history keeps the values
# it can plot and nothing else. 120 points fill a panel-wide chart while leaving
# Redis a cache rather than a time-series database.
@@ -147,6 +154,26 @@ class StateBackend(ABC):
"""
...
@abstractmethod
def get_present(self, keys: list[str]) -> dict[str, Any]:
"""The values of those keys that exist, in one round trip.
Unlike :meth:`get_multi`, a key that is absent is left out rather than
mapped to None — which is what reading a node's inputs needs, since a
message holding ``null`` is a message the node has, and one that was
never published is not.
"""
...
@abstractmethod
def increment_multi(self, keys: list[str]) -> None:
"""Bump several counters at once.
The new values are not returned: the only reader compares them against
what it saw last time and reads them back itself.
"""
...
@abstractmethod
def compare_and_swap_multi(
self,
@@ -294,6 +321,16 @@ class MemoryState(StateBackend):
with self._lock:
return {k: self._data.get(k) for k in keys}
def get_present(self, keys: list[str]) -> dict[str, Any]:
"""The values of those keys that are there."""
with self._lock:
return {k: self._data[k] for k in keys if k in self._data}
def increment_multi(self, keys: list[str]) -> None:
with self._lock:
for key in keys:
self._data[key] = self._data.get(key, 0) + 1
def compare_and_swap_multi(
self,
expected: dict[str, Any],
@@ -389,11 +426,11 @@ class RedisState(StateBackend):
def _serialize(self, value: Any) -> bytes:
"""Serialize value for storage."""
return json.dumps(value).encode()
return orjson.dumps(value, option=_JSON_OPTS)
def _deserialize(self, data: bytes | None) -> Any:
"""Deserialize value from storage."""
return json.loads(data) if data else None
return orjson.loads(data) if data else None
# redis-py types every command as a sync/async union; this is the
# synchronous client, so the results are narrowed where they are consumed.
@@ -503,6 +540,15 @@ class RedisState(StateBackend):
"""Atomically increment a counter using Redis INCR."""
return cast(int, self._client.incr(self._key(key)))
def increment_multi(self, keys: list[str]) -> None:
"""Bump every counter in one round trip rather than one INCR each."""
if not keys:
return
pipe = self._client.pipeline()
for key in keys:
pipe.incr(self._key(key))
pipe.execute()
def get_multi(self, keys: list[str]) -> dict[str, Any]:
"""Get multiple values atomically using Redis MGET."""
if not keys:
@@ -517,6 +563,33 @@ class RedisState(StateBackend):
return result
def get_present(self, keys: list[str]) -> dict[str, Any]:
"""The keys that are there, from one MGET.
A missing key comes back as ``None`` from Redis and a stored ``null``
comes back as the four bytes; only the raw reply tells the two apart,
which is why this is not ``get_multi`` with the Nones filtered out.
"""
if not keys:
return {}
values = cast(
list[bytes | None], self._client.mget([self._key(k) for k in keys])
)
return {
key: self._deserialize(raw)
for key, raw in zip(keys, values, strict=True)
if raw is not None
}
def __getitem__(self, key: str) -> Any:
# One GET rather than the base class's EXISTS-then-GET: a missing key
# and a stored `null` differ in the raw reply, so nothing has to ask
# twice.
raw = cast(bytes | None, self._client.get(self._key(key)))
if raw is None:
raise KeyError(key)
return self._deserialize(raw)
def compare_and_swap_multi(
self,
expected: dict[str, Any],
@@ -586,7 +659,7 @@ class RedisState(StateBackend):
continue
history_key = self._history_key(key)
cap = (limits or {}).get(key, HISTORY_LIMIT)
pipe.lpush(history_key, json.dumps([ts, number]))
pipe.lpush(history_key, orjson.dumps([ts, number]))
pipe.ltrim(history_key, 0, cap - 1)
if self._ttl:
pipe.expire(history_key, self._ttl)
@@ -600,6 +673,6 @@ class RedisState(StateBackend):
# LPUSH puts the newest first, a chart reads the other way round.
points: list[tuple[float, float]] = []
for entry in reversed(entries):
ts, value = json.loads(entry)
ts, value = orjson.loads(entry)
points.append((float(ts), float(value)))
return points
+8 -3
View File
@@ -14,7 +14,6 @@ from __future__ import annotations
import hashlib
import itertools
import json
import logging
import os
import queue
@@ -28,6 +27,7 @@ from collections.abc import Callable
from pathlib import Path
from typing import Any
import orjson
from fluksio_worker import worker_main as _worker_main
from fluksio.flow.events import EventBus
@@ -155,7 +155,12 @@ class _Worker:
def send(self, request: dict[str, Any]) -> None:
assert self.proc.stdin is not None
self.proc.stdin.write((json.dumps(request) + "\n").encode())
# Node inputs may be keyed by something other than a string, which
# the stdlib encoder this replaced turned into strings rather than
# refusing.
self.proc.stdin.write(
orjson.dumps(request, option=orjson.OPT_NON_STR_KEYS) + b"\n"
)
self.proc.stdin.flush()
def read_line(self, deadline: float) -> str | None:
@@ -414,7 +419,7 @@ class PythonWorkerPool:
)
if line:
try:
message = dict(json.loads(line))
message = dict(orjson.loads(line))
except (TypeError, ValueError) as exc:
# A reply we cannot read leaves this worker out of step:
# whatever is still in its pipe would be taken by the
+1
View File
@@ -107,6 +107,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
queue=_work_queue(),
max_workers=settings.FLOW_MAX_WORKERS,
events=event_bus,
max_cascades=settings.FLOW_MAX_CASCADES,
)
store = FlowStore(settings.FLOWS_DIR)
# The packages node code imports, before anything tries to import them.
+4
View File
@@ -38,6 +38,10 @@ dependencies = [
"pwdlib[argon2,bcrypt]>=0.3.0",
"numpy>=2.2.6",
"redis>=7.1.0",
# Every message costs a round trip's worth of encode and decode — state,
# the journal, the worker pipe. Engine-side only: `fluksio-worker` is
# copied onto other people's machines and stays dependency-free.
"orjson>=3.10",
"cryptography>=44.0.0",
"aiomqtt>=2.0.0",
"influxdb-client[async]>=1.40.0",
+6 -2
View File
@@ -198,14 +198,18 @@ class Engine:
def drain(self, timeout: float = 60.0) -> bool:
"""Wait for the queue to go quiet. False means it never did.
Asks the backlog rather than the whole of ``stats()``: the health
summary's version costs four round trips, and polling it every twenty
milliseconds competes with the engine for the connection it is
supposedly measuring.
Delayed items are not waited for: a rate-limit flush is due a whole
window from now, and the scenario is over long before that.
"""
deadline = time.monotonic() + timeout
quiet_since = 0.0
while time.monotonic() < deadline:
stats = self.service.stats()
busy = stats.get("cascades_busy", 0) or stats.get("backlog", 0)
busy = self.service.inflight or self.queue.backlog()
if not busy:
# Two consecutive quiet reads: a cascade between claim and ack
# shows as neither.
+2 -2
View File
@@ -423,9 +423,9 @@ def test_no_more_is_claimed_than_the_pool_can_run():
time.sleep(0.5)
stats = service.stats()
assert stats["cascades_busy"] <= executor.MAX_CASCADES
assert stats["cascades_busy"] <= service.max_cascades
# And the journal entries of what is only waiting are still free.
assert stats["pending"] <= executor.MAX_CASCADES
assert stats["pending"] <= service.max_cascades
# Waiting is not idle: the rest of the forty is the backlog.
assert stats["backlog"] >= 30
finally: