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