Give the work queue its own Redis root, and pin what the suite runs as

The queue shared the `pipeline:` prefix with flow state, so `RedisState.clear()`
could DEL the queue stream and `keys()` enumerated queue entries — only callers
filtering `__`-prefixed names kept it safe. It moves to `queue:` without a
migration: whatever is in flight at the upgrade is dropped once, documented in
DEPLOY.md rather than papered over.

Alongside it: `pool_pre_ping`, so a connection idle across a Postgres restart
costs a round trip instead of a failed request; the test suite pins
ENVIRONMENT=local and DOMAIN=localhost itself rather than inheriting a
deployment's .env; and `depth` leaves the queue stats, where it reported the
capped journal length as if it were a backlog.

ALERTS_FILE and PANELS_FILE now point at /data. They defaulted to a path on no
volume, so alert routing and every wall-panel pairing were living in the
container's writable layer and vanishing on each rebuild. Carrying the existing
files across is a manual step; DEPLOY.md has it.

development.md was still the upstream template — compose.override.yml,
localhost.tiangolo.com, `docker compose watch` as the dev flow — and said
nothing about the Playwright suite. Rewritten against what the Makefiles
actually do. deployment.md was template text too, duplicating the root
DEPLOY.md, and is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
This commit is contained in:
2026-08-21 10:10:57 +02:00
co-authored by Claude Opus 5
parent a8d1b3927e
commit a8065ad91c
8 changed files with 171 additions and 550 deletions
+3 -1
View File
@@ -4,7 +4,9 @@ from app import crud
from app.core.config import settings
from app.models import User, UserCreate
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))
# A connection idle across a Postgres restart is dead but still pooled; the
# pre-ping spends a round trip to find out instead of failing the request.
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI), pool_pre_ping=True)
# make sure all SQLModel models are imported (app.models) before initializing DB
+5 -4
View File
@@ -156,7 +156,8 @@ class WorkQueue(ABC):
@abstractmethod
def stats(self) -> dict[str, Any]:
"""Queue depth and age, for the health endpoint."""
"""In-flight, delayed and parked counts plus the oldest pending age,
for the health endpoint."""
@abstractmethod
def dead_letters(self, count: int = 50) -> list[dict[str, Any]]:
@@ -266,7 +267,6 @@ class MemoryWorkQueue(WorkQueue):
def stats(self) -> dict[str, Any]:
with self._lock:
return {
"depth": len(self._items),
"pending": self._in_flight,
"delayed": len(self._delayed),
"parked": sum(len(v) for v in self._parked.values()),
@@ -302,7 +302,9 @@ class RedisWorkQueue(WorkQueue):
self,
host: str,
port: int = 6379,
namespace: str = "pipeline",
# A root of its own: the state backend owns `pipeline:`, and sharing the
# prefix meant `RedisState.clear()` could DEL the queue stream.
namespace: str = "queue",
consumer: str | None = None,
) -> None:
self._redis = redis.Redis(host=host, port=port, decode_responses=True)
@@ -448,7 +450,6 @@ class RedisWorkQueue(WorkQueue):
for key in self._redis.scan_iter(f"{self._ns}:__parked__:*")
)
return {
"depth": cast(int, self._redis.xlen(self._stream)),
"pending": count,
"delayed": cast(int, self._redis.zcard(self._delayed_key)),
"parked": parked,
+1 -1
View File
@@ -51,7 +51,7 @@ def _state_backend() -> StateBackend:
return MemoryState()
def _work_queue(namespace: str = "pipeline") -> WorkQueue:
def _work_queue(namespace: str = "queue") -> WorkQueue:
"""Redis makes queued work survive the process; memory does not pretend to."""
if settings.REDIS_HOST:
return RedisWorkQueue(
+5
View File
@@ -16,3 +16,8 @@ os.environ["MCP_ENABLED"] = "false"
# The private seeding endpoints are opt-in; the suite is one of the two places
# (with the dev stack) where they are meant to work.
os.environ["PRIVATE_API_ENABLED"] = "true"
# The suite is the development configuration by definition: `/private` is
# gated on it (app/api/routes/private.py) and the MCP host allow-list is
# built from DOMAIN (app/mcp/http.py), while the tests speak to api.localhost.
os.environ["ENVIRONMENT"] = "local"
os.environ["DOMAIN"] = "localhost"
+2
View File
@@ -66,6 +66,8 @@ def test_claimed_work_counts_as_in_flight_until_it_is_acknowledged():
queue.add(WorkItem(kind="cascade", node="f.n", flow="f"))
assert queue.stats()["pending"] == 0
# The stream length was never a backlog, so the key is gone from both queues.
assert "depth" not in queue.stats()
(item,) = queue.claim(1, 10)
assert queue.stats()["pending"] == 1