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