Keep flow state across a restart, and tighten the flow typing

The engine fell back to MemoryState because there was no Redis to talk
to, so every value was lost on restart. Adds a redis service with
append-only persistence, reachable only from the backend, and points
REDIS_HOST at it.

state.py and pipeline.py leave the strict-mypy exclusion. redis-py types
its calls as sync/async unions; the casts narrow that at the points of
use rather than switching to redis.asyncio, which would make a
deliberately synchronous backend asynchronous. nodes.py stays excluded.

The .env.example CORS entry belongs with the CI work that follows.

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:19:21 +02:00
co-authored by Claude Opus 5
parent 82356810ce
commit 11d491dae0
6 changed files with 67 additions and 21 deletions
+8 -1
View File
@@ -15,7 +15,9 @@ PROJECT_NAME="Fluksio"
STACK_NAME=fluksio-app STACK_NAME=fluksio-app
# Backend # Backend
BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,http://app.localhost,https://localhost,https://localhost:5173" # `http://frontend` is the compose-network origin the Playwright job loads the
# SPA from (docker/compose.ci.yml); without it every spec fails CORS at login.
BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,http://app.localhost,https://localhost,https://localhost:5173,http://frontend"
SECRET_KEY=changethis SECRET_KEY=changethis
FIRST_SUPERUSER=admin@fluksio.com FIRST_SUPERUSER=admin@fluksio.com
FIRST_SUPERUSER_PASSWORD=changethis FIRST_SUPERUSER_PASSWORD=changethis
@@ -36,6 +38,11 @@ POSTGRES_DB=app
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=changethis POSTGRES_PASSWORD=changethis
# Redis — flow engine state. Empty keeps the state in memory; the compose stack
# points the backend at its own `redis` service.
REDIS_HOST=
REDIS_PORT=6379
SENTRY_DSN= SENTRY_DSN=
# Docker registry images # Docker registry images
+6 -5
View File
@@ -32,6 +32,7 @@ class ValidationIssue(BaseModel):
"unconnected_input", "unconnected_input",
"missing_initial_value", "missing_initial_value",
"node_error", "node_error",
"unauthenticated_hook",
] ]
message: str message: str
flow: str = "" flow: str = ""
@@ -243,7 +244,7 @@ class Pipeline:
def _timestamp_key(self, msg_name: str) -> str: def _timestamp_key(self, msg_name: str) -> str:
return f"__ts__:{msg_name}" return f"__ts__:{msg_name}"
def _increment_message_versions(self, outputs: dict) -> None: def _increment_message_versions(self, outputs: dict[str, Any]) -> None:
for msg_name in outputs: for msg_name in outputs:
self._state.increment(self._version_key(msg_name)) self._state.increment(self._version_key(msg_name))
@@ -305,7 +306,7 @@ class Pipeline:
if self._events is not None: if self._events is not None:
self._events.publish(event) self._events.publish(event)
def _execute_node(self, node: Node, state: StateBackend) -> dict | None: def _execute_node(self, node: Node, state: StateBackend) -> dict[str, Any] | None:
"""Run one node and record its outputs. Never raises.""" """Run one node and record its outputs. Never raises."""
started = time.perf_counter() started = time.perf_counter()
try: try:
@@ -378,7 +379,7 @@ class Pipeline:
submitted: set[Node] = set() submitted: set[Node] = set()
skipped: set[Node] = set() skipped: set[Node] = set()
node_futures: dict[Node, Future] = {} node_futures: dict[Node, Future[dict[str, Any] | None]] = {}
def is_ready(n: Node) -> bool: def is_ready(n: Node) -> bool:
if in_degree[n] != 0: if in_degree[n] != 0:
@@ -416,7 +417,7 @@ class Pipeline:
return state return state
def run( def run(
self, inputs: dict | None = None, nodes: set[Node] | None = None self, inputs: dict[str, Any] | None = None, nodes: set[Node] | None = None
) -> StateBackend: ) -> StateBackend:
"""Execute the graph (or one flow's nodes) against the shared state.""" """Execute the graph (or one flow's nodes) against the shared state."""
if inputs: if inputs:
@@ -426,7 +427,7 @@ class Pipeline:
return self._execute_parallel(nodes, self._state, check_ready=False) return self._execute_parallel(nodes, self._state, check_ready=False)
def trigger(self, node: Node, outputs: dict | None) -> StateBackend: def trigger(self, node: Node, outputs: dict[str, Any] | None) -> StateBackend:
"""Publish a node's outputs and run everything downstream of it.""" """Publish a node's outputs and run everything downstream of it."""
state = self._state state = self._state
+19 -11
View File
@@ -12,7 +12,7 @@ from abc import ABC, abstractmethod
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
from typing import Any from typing import Any, cast
import redis import redis
@@ -90,7 +90,7 @@ class StateBackend(ABC):
@abstractmethod @abstractmethod
@contextmanager @contextmanager
def lock(self, timeout: float = 10.0): def lock(self, timeout: float = 10.0) -> Iterator[None]:
""" """
Acquire a lock for thread-safe operations. Acquire a lock for thread-safe operations.
@@ -211,7 +211,7 @@ class MemoryState(StateBackend):
self._data.update(mapping) self._data.update(mapping)
@contextmanager @contextmanager
def lock(self, timeout: float = 10.0): def lock(self, timeout: float = 10.0) -> Iterator[None]:
"""Acquire the internal lock.""" """Acquire the internal lock."""
acquired = self._lock.acquire(timeout=timeout) acquired = self._lock.acquire(timeout=timeout)
if not acquired: if not acquired:
@@ -224,7 +224,7 @@ class MemoryState(StateBackend):
def increment(self, key: str) -> int: def increment(self, key: str) -> int:
"""Atomically increment a counter and return the new value.""" """Atomically increment a counter and return the new value."""
with self._lock: with self._lock:
value = self._data.get(key, 0) value: int = self._data.get(key, 0)
new_value = value + 1 new_value = value + 1
self._data[key] = new_value self._data[key] = new_value
return new_value return new_value
@@ -313,8 +313,10 @@ class RedisState(StateBackend):
"""Deserialize value from storage.""" """Deserialize value from storage."""
return json.loads(data) if data else None return json.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.
def get(self, key: str, default: Any = None) -> Any: def get(self, key: str, default: Any = None) -> Any:
data = self._client.get(self._key(key)) data = cast(bytes | None, self._client.get(self._key(key)))
return self._deserialize(data) if data is not None else default return self._deserialize(data) if data is not None else default
def set(self, key: str, value: Any) -> None: def set(self, key: str, value: Any) -> None:
@@ -332,7 +334,10 @@ class RedisState(StateBackend):
pattern = f"{self._namespace}:*" pattern = f"{self._namespace}:*"
cursor = 0 cursor = 0
while True: while True:
cursor, keys = self._client.scan(cursor, match=pattern, count=100) cursor, keys = cast(
tuple[int, list[bytes]],
self._client.scan(cursor, match=pattern, count=100),
)
if keys: if keys:
self._client.delete(*keys) self._client.delete(*keys)
if cursor == 0: if cursor == 0:
@@ -345,7 +350,10 @@ class RedisState(StateBackend):
result = [] result = []
cursor = 0 cursor = 0
while True: while True:
cursor, keys = self._client.scan(cursor, match=pattern, count=100) cursor, keys = cast(
tuple[int, list[bytes]],
self._client.scan(cursor, match=pattern, count=100),
)
for k in keys: for k in keys:
key_str = k.decode("utf-8") key_str = k.decode("utf-8")
# Skip the lock key # Skip the lock key
@@ -369,7 +377,7 @@ class RedisState(StateBackend):
pipe.execute() pipe.execute()
@contextmanager @contextmanager
def lock(self, timeout: float = 10.0): def lock(self, timeout: float = 10.0) -> Iterator[None]:
""" """
Acquire a distributed lock using Redis. Acquire a distributed lock using Redis.
@@ -402,13 +410,13 @@ class RedisState(StateBackend):
:rtype: bool :rtype: bool
""" """
try: try:
return self._client.ping() return bool(self._client.ping())
except redis.ConnectionError: except redis.ConnectionError:
return False return False
def increment(self, key: str) -> int: def increment(self, key: str) -> int:
"""Atomically increment a counter using Redis INCR.""" """Atomically increment a counter using Redis INCR."""
return self._client.incr(self._key(key)) return cast(int, self._client.incr(self._key(key)))
def get_multi(self, keys: list[str]) -> dict[str, Any]: def get_multi(self, keys: list[str]) -> dict[str, Any]:
"""Get multiple values atomically using Redis MGET.""" """Get multiple values atomically using Redis MGET."""
@@ -416,7 +424,7 @@ class RedisState(StateBackend):
return {} return {}
full_keys = [self._key(k) for k in keys] full_keys = [self._key(k) for k in keys]
values = self._client.mget(full_keys) values = cast(list[bytes | None], self._client.mget(full_keys))
result = {} result = {}
for key, value in zip(keys, values, strict=True): for key, value in zip(keys, values, strict=True):
+4 -4
View File
@@ -44,11 +44,11 @@ build-backend = "hatchling.build"
strict = true strict = true
exclude = ["venv", ".venv", "alembic"] exclude = ["venv", ".venv", "alembic"]
# app/flow/nodes.py still carries the prototype's node classes, and state.py's # app/flow/nodes.py still carries the prototype's node classes: the integration
# redis calls type as sync/async unions under the current stubs. Both are being # nodes need annotations of their own, plus stubs for croniter and influxdb_client.
# revisited; every module around them is checked strictly. # It is being revisited per integration; every module around it is checked strictly.
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = ["app.flow.nodes", "app.flow.pipeline", "app.flow.state"] module = ["app.flow.nodes"]
ignore_errors = true ignore_errors = true
[tool.ruff] [tool.ruff]
+3
View File
@@ -45,6 +45,9 @@ services:
ports: ports:
- "127.0.0.1:8080:8080" - "127.0.0.1:8080:8080"
redis:
restart: "no"
backend: backend:
restart: "no" restart: "no"
ports: ports:
+27
View File
@@ -29,6 +29,27 @@ services:
- POSTGRES_USER=${POSTGRES_USER?Variable not set} - POSTGRES_USER=${POSTGRES_USER?Variable not set}
- POSTGRES_DB=${POSTGRES_DB?Variable not set} - POSTGRES_DB=${POSTGRES_DB?Variable not set}
redis:
image: redis:8.2-alpine
container_name: fluksio-redis
restart: always
security_opt:
- no-new-privileges:true
# Flow state, not a cache: append-only persistence keeps the last value of
# every message across a restart.
command: redis-server --appendonly yes
# Deliberately not on `proxy` and not published: only the backend talks to it.
networks:
- default
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
retries: 5
start_period: 10s
timeout: 5s
volumes:
- app-redis-data:/data
adminer: adminer:
image: adminer image: adminer
container_name: fluksio-adminer container_name: fluksio-adminer
@@ -94,6 +115,9 @@ services:
db: db:
condition: service_healthy condition: service_healthy
restart: true restart: true
redis:
condition: service_healthy
restart: true
prestart: prestart:
condition: service_completed_successfully condition: service_completed_successfully
env_file: env_file:
@@ -116,6 +140,8 @@ services:
- POSTGRES_USER=${POSTGRES_USER?Variable not set} - POSTGRES_USER=${POSTGRES_USER?Variable not set}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
- SENTRY_DSN=${SENTRY_DSN} - SENTRY_DSN=${SENTRY_DSN}
# Flow state survives a restart in Redis; without a host it stays in memory.
- REDIS_HOST=redis
# Flows are files in a git repository; secrets sit encrypted beside it. # Flows are files in a git repository; secrets sit encrypted beside it.
- FLOWS_DIR=/data/flows - FLOWS_DIR=/data/flows
- SECRETS_FILE=/data/secrets.enc - SECRETS_FILE=/data/secrets.enc
@@ -183,6 +209,7 @@ services:
volumes: volumes:
app-db-data: app-db-data:
app-redis-data:
app-flow-data: app-flow-data:
networks: networks: