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
This commit is contained in:
@@ -98,6 +98,15 @@ class StateBackend(ABC):
|
||||
"""Clear all keys in the state."""
|
||||
...
|
||||
|
||||
def close(self) -> None: # noqa: B027
|
||||
"""Release whatever the backend holds outside this process.
|
||||
|
||||
A run builds a state backend of its own and drops it when it
|
||||
finishes; on Redis that is a client and its connection pool, which
|
||||
nothing was giving back. Concrete rather than abstract: a backend that
|
||||
holds nothing has nothing to answer here.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def keys(self) -> list[str]:
|
||||
"""
|
||||
@@ -221,6 +230,36 @@ class StateBackend(ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
def record(
|
||||
self,
|
||||
values: dict[str, Any],
|
||||
stamps: dict[str, float],
|
||||
counters: list[str],
|
||||
ts: float,
|
||||
limits: dict[str, int] | None = None,
|
||||
drop: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Everything a published value owes state, in one round trip.
|
||||
|
||||
The value itself, the timestamp beside it, its series and its version
|
||||
counter used to be four calls and four round trips; a backend that can
|
||||
batch them does so here. This default is those four calls, so a
|
||||
backend that cannot batch gains nothing and breaks nothing.
|
||||
|
||||
:param values: Message names mapped to the value just published.
|
||||
:param stamps: The timestamp key of each of those messages.
|
||||
:param counters: The version keys to bump.
|
||||
:param ts: When they were published.
|
||||
:param limits: How many points to keep per message.
|
||||
:param drop: Keys to forget in the same trip — a released rate-limit
|
||||
hold, which is written and cleared on this same path.
|
||||
"""
|
||||
for key in drop or ():
|
||||
self.delete(key)
|
||||
self.update({**values, **stamps})
|
||||
self.append_history(values, ts, limits)
|
||||
self.increment_multi(counters)
|
||||
|
||||
@abstractmethod
|
||||
def history(self, key: str) -> list[tuple[float, float]]:
|
||||
"""
|
||||
@@ -369,6 +408,35 @@ class MemoryState(StateBackend):
|
||||
self._history[key] = series
|
||||
series.append((ts, number))
|
||||
|
||||
def record(
|
||||
self,
|
||||
values: dict[str, Any],
|
||||
stamps: dict[str, float],
|
||||
counters: list[str],
|
||||
ts: float,
|
||||
limits: dict[str, int] | None = None,
|
||||
drop: list[str] | None = None,
|
||||
) -> None:
|
||||
"""The four writes under one acquisition of the one lock."""
|
||||
with self._lock:
|
||||
for key in drop or ():
|
||||
self._data.pop(key, None)
|
||||
self._history.pop(key, None)
|
||||
self._data.update(values)
|
||||
self._data.update(stamps)
|
||||
for key, value in values.items():
|
||||
number = as_number(value)
|
||||
if number is None:
|
||||
continue
|
||||
cap = (limits or {}).get(key, HISTORY_LIMIT)
|
||||
series = self._history.get(key)
|
||||
if series is None or series.maxlen != cap:
|
||||
series = deque(series or (), maxlen=cap)
|
||||
self._history[key] = series
|
||||
series.append((ts, number))
|
||||
for key in counters:
|
||||
self._data[key] = self._data.get(key, 0) + 1
|
||||
|
||||
def history(self, key: str) -> list[tuple[float, float]]:
|
||||
"""The recorded points of one message, oldest first."""
|
||||
with self._lock:
|
||||
@@ -648,6 +716,10 @@ class RedisState(StateBackend):
|
||||
# Another client modified one of the watched keys
|
||||
return False
|
||||
|
||||
def close(self) -> None:
|
||||
"""Give the connection pool back."""
|
||||
self._client.close()
|
||||
|
||||
def _history_key(self, key: str) -> str:
|
||||
return self._key(f"__history__:{key}")
|
||||
|
||||
@@ -671,6 +743,45 @@ class RedisState(StateBackend):
|
||||
if queued:
|
||||
pipe.execute()
|
||||
|
||||
def record(
|
||||
self,
|
||||
values: dict[str, Any],
|
||||
stamps: dict[str, float],
|
||||
counters: list[str],
|
||||
ts: float,
|
||||
limits: dict[str, int] | None = None,
|
||||
drop: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Value, timestamp, series and version counter in one round trip.
|
||||
|
||||
These were four pipelines — four round trips — for one published
|
||||
value, and a value crossing an edge pays them twice. Redis executes a
|
||||
pipeline as one transaction, so batching them changes nothing about
|
||||
what lands together and removes three of the four trips.
|
||||
"""
|
||||
pipe = self._client.pipeline()
|
||||
for key in drop or ():
|
||||
pipe.delete(self._key(key), self._history_key(key))
|
||||
for key, value in {**values, **stamps}.items():
|
||||
data = self._serialize(value)
|
||||
if self._ttl:
|
||||
pipe.setex(self._key(key), self._ttl, data)
|
||||
else:
|
||||
pipe.set(self._key(key), data)
|
||||
for key, value in values.items():
|
||||
number = as_number(value)
|
||||
if number is None:
|
||||
continue
|
||||
history_key = self._history_key(key)
|
||||
cap = (limits or {}).get(key, HISTORY_LIMIT)
|
||||
pipe.lpush(history_key, orjson.dumps([ts, number]))
|
||||
pipe.ltrim(history_key, 0, cap - 1)
|
||||
if self._ttl:
|
||||
pipe.expire(history_key, self._ttl)
|
||||
for key in counters:
|
||||
pipe.incr(self._key(key))
|
||||
pipe.execute()
|
||||
|
||||
def history(self, key: str) -> list[tuple[float, float]]:
|
||||
"""The recorded points of one message, oldest first."""
|
||||
entries = cast(list[bytes], self._client.lrange(self._history_key(key), 0, -1))
|
||||
|
||||
Reference in New Issue
Block a user