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:
Melvin Strobl
2026-08-15 21:39:59 +02:00
co-authored by Claude Opus 5
parent 6156ad1150
commit 3b72d17ca7
5 changed files with 198 additions and 1 deletions
+26
View File
@@ -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
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
+3
View File
@@ -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(
+19
View File
@@ -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 = ""
+92 -1
View File
@@ -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
+58
View File
@@ -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") == []