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