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:
co-authored by
Claude Opus 5
parent
82356810ce
commit
11d491dae0
+8
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user