Record a short value history per message
The panel is going to sparkline how a value moved, and nothing kept more
than the latest one. Emissions now append to a capped list beside the
value — 120 points, enough to fill a sparkline without making Redis a
time-series store — and `/flows/{flow}/history/{message}` hands it back
oldest first.
Only numbers are recorded, bools included as neither, so a string
message costs nothing at all. The response carries `numeric` so an empty
series reads as "not plottable" rather than "nothing yet". The append is
one pipelined round-trip per emission batch and sits outside the lock,
since the list is append-only.
MemoryState keeps the same window in a deque, so the endpoint answers
without Redis too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkmeRiyeYmVZqJVwuyHq9o
This commit is contained in:
co-authored by
Claude Opus 5
parent
6156ad1150
commit
3b72d17ca7
@@ -26,11 +26,14 @@ from app.flow.schemas import (
|
|||||||
FlowsPublic,
|
FlowsPublic,
|
||||||
FlowStatePublic,
|
FlowStatePublic,
|
||||||
FlowSummary,
|
FlowSummary,
|
||||||
|
HistoryPoint,
|
||||||
|
MessageHistory,
|
||||||
MessageValue,
|
MessageValue,
|
||||||
NodeSource,
|
NodeSource,
|
||||||
NodeStatusPublic,
|
NodeStatusPublic,
|
||||||
NodeTypeInfo,
|
NodeTypeInfo,
|
||||||
)
|
)
|
||||||
|
from app.flow.state import as_number
|
||||||
from app.flow.store import FlowExists, FlowNotFound
|
from app.flow.store import FlowExists, FlowNotFound
|
||||||
from app.models import Message
|
from app.models import Message
|
||||||
|
|
||||||
@@ -275,6 +278,29 @@ def read_flow_state(name: str, controller: FlowControllerDep) -> Any:
|
|||||||
return _flow_state(controller, name)
|
return _flow_state(controller, name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{name}/history/{message}", response_model=MessageHistory)
|
||||||
|
def read_message_history(
|
||||||
|
name: str,
|
||||||
|
message: str,
|
||||||
|
controller: FlowControllerDep,
|
||||||
|
) -> Any:
|
||||||
|
"""The recent values of one message, for plotting.
|
||||||
|
|
||||||
|
``message`` may be given bare or qualified; a message that never carried a
|
||||||
|
number comes back with an empty series.
|
||||||
|
"""
|
||||||
|
_read_flow(controller, name)
|
||||||
|
key = qualify(name, message)
|
||||||
|
return MessageHistory(
|
||||||
|
message=key,
|
||||||
|
numeric=as_number(controller.state.get(key)) is not None,
|
||||||
|
points=[
|
||||||
|
HistoryPoint(ts=ts, value=value)
|
||||||
|
for ts, value in controller.state.history(key)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Live updates
|
# Live updates
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -322,6 +322,8 @@ class Pipeline:
|
|||||||
state.update(
|
state.update(
|
||||||
{self._timestamp_key(name): ts for name in result},
|
{self._timestamp_key(name): ts for name in result},
|
||||||
)
|
)
|
||||||
|
# Append-only, so it needs no lock of its own.
|
||||||
|
state.append_history(result, ts)
|
||||||
self._increment_message_versions(result)
|
self._increment_message_versions(result)
|
||||||
for name, value in result.items():
|
for name, value in result.items():
|
||||||
self._publish(
|
self._publish(
|
||||||
@@ -436,6 +438,7 @@ class Pipeline:
|
|||||||
with state.lock():
|
with state.lock():
|
||||||
state.update(outputs)
|
state.update(outputs)
|
||||||
state.update({self._timestamp_key(name): ts for name in outputs})
|
state.update({self._timestamp_key(name): ts for name in outputs})
|
||||||
|
state.append_history(outputs, ts)
|
||||||
self._increment_message_versions(outputs)
|
self._increment_message_versions(outputs)
|
||||||
for name, value in outputs.items():
|
for name, value in outputs.items():
|
||||||
self._publish(
|
self._publish(
|
||||||
|
|||||||
@@ -91,6 +91,25 @@ class MessageValue(BaseModel):
|
|||||||
ts: float | None = None
|
ts: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryPoint(BaseModel):
|
||||||
|
"""One numeric value a message carried, and when."""
|
||||||
|
|
||||||
|
ts: float
|
||||||
|
value: float
|
||||||
|
|
||||||
|
|
||||||
|
class MessageHistory(BaseModel):
|
||||||
|
"""A message's recent numeric values, oldest first.
|
||||||
|
|
||||||
|
Only numbers are recorded, so ``numeric`` tells the panel whether an empty
|
||||||
|
series means "nothing plottable here" or "nothing has arrived yet".
|
||||||
|
"""
|
||||||
|
|
||||||
|
message: str
|
||||||
|
numeric: bool = False
|
||||||
|
points: list[HistoryPoint] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class FlowSummary(BaseModel):
|
class FlowSummary(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
title: str = ""
|
title: str = ""
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from collections import deque
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
@@ -16,6 +17,19 @@ from typing import Any, cast
|
|||||||
|
|
||||||
import redis
|
import redis
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
HISTORY_LIMIT = 120
|
||||||
|
|
||||||
|
|
||||||
|
def as_number(value: Any) -> float | None:
|
||||||
|
"""The plottable form of a value, or None if it is not a number."""
|
||||||
|
# bool is an int subclass; a flag is not a measurement.
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
return None
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
class StateBackend(ABC):
|
class StateBackend(ABC):
|
||||||
"""
|
"""
|
||||||
@@ -149,6 +163,36 @@ class StateBackend(ABC):
|
|||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Message history
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def append_history(self, values: dict[str, Any], ts: float) -> None:
|
||||||
|
"""
|
||||||
|
Record freshly published values in each message's capped history.
|
||||||
|
|
||||||
|
Non-numeric payloads are skipped — the history exists to be plotted.
|
||||||
|
|
||||||
|
:param values: Message names mapped to the value just published.
|
||||||
|
:type values: dict[str, Any]
|
||||||
|
:param ts: When they were published.
|
||||||
|
:type ts: float
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def history(self, key: str) -> list[tuple[float, float]]:
|
||||||
|
"""
|
||||||
|
The recorded ``(timestamp, value)`` pairs of one message, oldest first.
|
||||||
|
|
||||||
|
:param key: The message name.
|
||||||
|
:type key: str
|
||||||
|
:returns: At most ``HISTORY_LIMIT`` points; empty if nothing was recorded.
|
||||||
|
:rtype: list[tuple[float, float]]
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
def __contains__(self, key: str) -> bool:
|
def __contains__(self, key: str) -> bool:
|
||||||
return self.exists(key)
|
return self.exists(key)
|
||||||
|
|
||||||
@@ -180,10 +224,11 @@ class MemoryState(StateBackend):
|
|||||||
'value'
|
'value'
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = ("_data", "_lock")
|
__slots__ = ("_data", "_history", "_lock")
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._data: dict[str, Any] = {}
|
self._data: dict[str, Any] = {}
|
||||||
|
self._history: dict[str, deque[tuple[float, float]]] = {}
|
||||||
self._lock = RLock() # Reentrant lock for nested access
|
self._lock = RLock() # Reentrant lock for nested access
|
||||||
|
|
||||||
def get(self, key: str, default: Any = None) -> Any:
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
@@ -201,6 +246,7 @@ class MemoryState(StateBackend):
|
|||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._data.clear()
|
self._data.clear()
|
||||||
|
self._history.clear()
|
||||||
|
|
||||||
def keys(self) -> list[str]:
|
def keys(self) -> list[str]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -251,6 +297,21 @@ class MemoryState(StateBackend):
|
|||||||
self._data.update(updates)
|
self._data.update(updates)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def append_history(self, values: dict[str, Any], ts: float) -> None:
|
||||||
|
"""Append the numeric values to their message's bounded series."""
|
||||||
|
with self._lock:
|
||||||
|
for key, value in values.items():
|
||||||
|
number = as_number(value)
|
||||||
|
if number is None:
|
||||||
|
continue
|
||||||
|
series = self._history.setdefault(key, deque(maxlen=HISTORY_LIMIT))
|
||||||
|
series.append((ts, number))
|
||||||
|
|
||||||
|
def history(self, key: str) -> list[tuple[float, float]]:
|
||||||
|
"""The recorded points of one message, oldest first."""
|
||||||
|
with self._lock:
|
||||||
|
return list(self._history.get(key, ()))
|
||||||
|
|
||||||
|
|
||||||
class RedisState(StateBackend):
|
class RedisState(StateBackend):
|
||||||
"""
|
"""
|
||||||
@@ -485,3 +546,33 @@ class RedisState(StateBackend):
|
|||||||
except redis.WatchError:
|
except redis.WatchError:
|
||||||
# Another client modified one of the watched keys
|
# Another client modified one of the watched keys
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _history_key(self, key: str) -> str:
|
||||||
|
return self._key(f"__history__:{key}")
|
||||||
|
|
||||||
|
def append_history(self, values: dict[str, Any], ts: float) -> None:
|
||||||
|
"""Push the numeric values onto their capped list, in one round-trip."""
|
||||||
|
pipe = self._client.pipeline()
|
||||||
|
queued = False
|
||||||
|
for key, value in values.items():
|
||||||
|
number = as_number(value)
|
||||||
|
if number is None:
|
||||||
|
continue
|
||||||
|
history_key = self._history_key(key)
|
||||||
|
pipe.lpush(history_key, json.dumps([ts, number]))
|
||||||
|
pipe.ltrim(history_key, 0, HISTORY_LIMIT - 1)
|
||||||
|
if self._ttl:
|
||||||
|
pipe.expire(history_key, self._ttl)
|
||||||
|
queued = True
|
||||||
|
if queued:
|
||||||
|
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))
|
||||||
|
# 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)
|
||||||
|
points.append((float(ts), float(value)))
|
||||||
|
return points
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Message history: the series a node panel draws as a sparkline."""
|
||||||
|
|
||||||
|
from app.flow.messages import MessageSpec
|
||||||
|
from app.flow.nodes import Node
|
||||||
|
from app.flow.pipeline import Pipeline
|
||||||
|
from app.flow.state import HISTORY_LIMIT, MemoryState
|
||||||
|
|
||||||
|
|
||||||
|
def emitter(values) -> Node:
|
||||||
|
"""A node publishing the given values, one per execution."""
|
||||||
|
remaining = list(values)
|
||||||
|
|
||||||
|
def process(params):
|
||||||
|
return {"out": remaining.pop(0)}
|
||||||
|
|
||||||
|
node = Node(f=process, requires=[], provides=[MessageSpec(name="out")], name="src")
|
||||||
|
node.assign_flow("f", "src")
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_series_follows_the_order_values_were_published():
|
||||||
|
node = emitter([1.0, 2.0, 3.0])
|
||||||
|
pipeline = Pipeline(nodes=[node])
|
||||||
|
|
||||||
|
# An injecting node publishes directly, an executed one goes through the
|
||||||
|
# executor; both paths land in the same series.
|
||||||
|
node.inject()
|
||||||
|
pipeline.run()
|
||||||
|
pipeline.run()
|
||||||
|
|
||||||
|
points = pipeline.state.history("f.out")
|
||||||
|
assert [value for _, value in points] == [1.0, 2.0, 3.0]
|
||||||
|
assert [ts for ts, _ in points] == sorted(ts for ts, _ in points)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_series_is_capped():
|
||||||
|
state = MemoryState()
|
||||||
|
for i in range(HISTORY_LIMIT + 20):
|
||||||
|
state.append_history({"f.out": float(i)}, ts=float(i))
|
||||||
|
|
||||||
|
points = state.history("f.out")
|
||||||
|
assert len(points) == HISTORY_LIMIT
|
||||||
|
# The oldest twenty fell off the end.
|
||||||
|
assert points[0] == (20.0, 20.0)
|
||||||
|
assert points[-1] == (139.0, 139.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_numbers_are_recorded():
|
||||||
|
state = MemoryState()
|
||||||
|
state.append_history({"f.text": "warm", "f.flag": True, "f.temp": 21}, ts=1.0)
|
||||||
|
|
||||||
|
assert state.history("f.text") == []
|
||||||
|
assert state.history("f.flag") == []
|
||||||
|
assert state.history("f.temp") == [(1.0, 21.0)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_message_without_history_comes_back_empty():
|
||||||
|
assert MemoryState().history("f.never") == []
|
||||||
Reference in New Issue
Block a user