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