Keep the engine's state in SQLite, not Postgres

One process owns this database — the image has run a single uvicorn
worker for that reason since the four-engines bug — so a file beside the
flows is the honest shape for it, and it is what lets `fluksio serve`
need no infrastructure at all. Live values, node execution and the work
queue never came here anyway; what does is a rollup a minute at a time,
a row per cascade and the run history, and WAL keeps the readers going
while that one writer works.

DATA_DIR is now the one setting that moves everything an installation
keeps; the rest derive from it and the images still spell theirs out.
The schema is prepared in-process at startup, so the prestart service is
gone, and the ten Postgres-only revisions collapse into one portable
baseline.

Three things only worked because psycopg was casting for us: a token's
subject arriving as a string where the column is a UUID, `greatest`, and
`date_bin`. The timestamps needed a column type of their own — SQLite
stores no offset, and a naive datetime read back either raises against an
aware `now` or serialises as local time.

Postgres stays in the stack only for Umami, behind the analytics profile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 22:19:45 +02:00
co-authored by Claude Opus 5
parent 2c369ac75f
commit 961a8f881d
51 changed files with 841 additions and 1089 deletions
+22 -4
View File
@@ -1,14 +1,32 @@
"""Redirect the suite at its own database.
`app.core.db` builds the engine at import time and several modules bind that
object, so the name has to be in the environment before anything imports the
settings. Overriding it here — the first module pytest imports for the
`fluksio.core.db` builds the engine at import time and several modules bind
that object, so the URL has to be in the environment before anything imports
the settings. Overriding it here — the first module pytest imports for the
package — keeps a test run from touching the development data.
A file rather than `:memory:`: the suite starts the engine's own threads (the
run service, the metrics collector, the portal connector) against this engine,
and an in-memory database is per connection unless every one of them shares a
single connection, which those threads would then contend for.
"""
import os
import tempfile
os.environ["POSTGRES_DB"] = "app_test"
_DB_DIR = tempfile.mkdtemp(prefix="fluksio-test-")
os.environ.setdefault("DATABASE_URL", f"sqlite:///{_DB_DIR}/app_test.db")
# Nothing outside this directory: the suite must not pick up a checkout's .env
# and write into the development data.
os.environ.setdefault("FLUKSIO_ENV_FILE", os.path.join(_DB_DIR, "env"))
os.environ.setdefault("DATA_DIR", _DB_DIR)
# Named here rather than taken from a developer's .env, so the suite is the
# same run everywhere and a checkout's own superuser is never involved.
os.environ.setdefault("FIRST_SUPERUSER", "admin@example.com")
os.environ.setdefault("FIRST_SUPERUSER_PASSWORD", "testpassword")
# What `emails_enabled` needs; the tests that send mail patch SMTP_HOST on top
# of it. Nothing is delivered — the sender is faked where it matters.
os.environ.setdefault("EMAILS_FROM_EMAIL", "noreply@example.com")
# The MCP session manager can only be entered once per instance, and the suite
# builds a TestClient — and so a lifespan — per test module. Tests that want the
# endpoint mount it themselves.
+14
View File
@@ -3,12 +3,15 @@
import base64
import hashlib
import secrets
import uuid
from urllib.parse import parse_qs, urlparse
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, select
from fluksio.core.config import settings
from fluksio.models import OAuthRefreshToken
PREFIX = f"{settings.API_V1_STR}/oauth"
REDIRECT = "http://127.0.0.1:41234/callback"
@@ -235,6 +238,7 @@ def test_everything_is_refused_while_mcp_is_off(
def test_one_agent_can_be_revoked_without_touching_the_others(
client: TestClient,
db: Session,
superuser_token_headers: dict[str, str],
normal_user_token_headers: dict[str, str],
) -> None:
@@ -272,3 +276,13 @@ def test_one_agent_can_be_revoked_without_touching_the_others(
data={"grant_type": "refresh_token", "refresh_token": tokens["refresh_token"]},
)
assert refreshed.status_code == 400
# Went, rather than merely stopped working: nothing here deletes those rows
# itself, so an orphan is a foreign key the database is not enforcing —
# which SQLite does not do unless it is asked (`PRAGMA foreign_keys`).
left = db.exec(
select(OAuthRefreshToken).where(
OAuthRefreshToken.client_id == uuid.UUID(client_id)
)
).all()
assert left == []
+4 -1
View File
@@ -1,3 +1,5 @@
import uuid
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, select
@@ -20,7 +22,8 @@ def test_create_user(client: TestClient, db: Session) -> None:
data = r.json()
user = db.exec(select(User).where(User.id == data["id"])).first()
# The id comes back from JSON as a string, and the column is a UUID.
user = db.exec(select(User).where(User.id == uuid.UUID(data["id"]))).first()
assert user
assert user.email == "pollo@listo.com"
+16 -19
View File
@@ -1,18 +1,18 @@
import uuid
from collections.abc import Generator
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from sqlalchemy.engine import make_url
from sqlmodel import Session, SQLModel, select
from sqlmodel import Session, select
from fluksio.cloud import config as cloud_config
from fluksio.core.config import settings
from fluksio.core.db import engine, init_db
from fluksio.core.db import engine, prepare
from fluksio.main import app
from fluksio.models import User
from tests.utils.portal import INSTALLATION_ID, ISSUER, jwks
@@ -33,28 +33,25 @@ def flow_data(tmp_path_factory: pytest.TempPathFactory) -> Generator[None, None,
@pytest.fixture(scope="session", autouse=True)
def db() -> Generator[Session, None, None]:
"""Create the throwaway database `tests/__init__.py` points at, drop it after."""
url = make_url(str(settings.SQLALCHEMY_DATABASE_URI))
# The teardown drops this database, so refuse to run against anything but
url = make_url(settings.SQLALCHEMY_DATABASE_URI)
# The teardown deletes this database, so refuse to run against anything but
# the dedicated test one.
assert url.database and url.database.endswith("_test"), url.database
assert url.get_backend_name() == "sqlite", url.get_backend_name()
assert url.database and url.database.endswith("_test.db"), url.database
maintenance = create_engine(
url.set(database="postgres"), isolation_level="AUTOCOMMIT"
)
drop = text(f'DROP DATABASE IF EXISTS "{url.database}" WITH (FORCE)')
with maintenance.connect() as connection:
connection.execute(drop)
connection.execute(text(f'CREATE DATABASE "{url.database}"'))
SQLModel.metadata.create_all(engine)
path = Path(url.database)
path.unlink(missing_ok=True)
# The real path, so the suite runs against a database built the way a
# deployment's is — including the alembic stamp the app's own startup
# would otherwise trip over.
prepare(engine)
with Session(engine) as session:
init_db(session)
yield session
engine.dispose()
with maintenance.connect() as connection:
connection.execute(drop)
maintenance.dispose()
# The write-ahead log and its index are part of the database.
for suffix in ("", "-wal", "-shm"):
path.with_name(path.name + suffix).unlink(missing_ok=True)
@pytest.fixture(scope="module")
+9 -3
View File
@@ -146,7 +146,9 @@ def test_a_connector_is_discovered_from_its_entry_point(monkeypatch):
def load(self):
return Sensor
monkeypatch.setattr("fluksio.flow.plugins.entry_points", lambda group: [FakeEntry()])
monkeypatch.setattr(
"fluksio.flow.plugins.entry_points", lambda group: [FakeEntry()]
)
try:
assert load_plugins() == ["test_sensor"]
assert NODE_TYPES["test_sensor"].plugin == "fluksio-connector-test 0.1.0"
@@ -166,7 +168,9 @@ def test_a_connector_written_for_another_contract_is_refused(monkeypatch):
def load(self):
return Outdated
monkeypatch.setattr("fluksio.flow.plugins.entry_points", lambda group: [FakeEntry()])
monkeypatch.setattr(
"fluksio.flow.plugins.entry_points", lambda group: [FakeEntry()]
)
assert load_plugins() == []
assert "outdated" not in NODE_TYPES
@@ -179,6 +183,8 @@ def test_a_connector_may_not_take_over_a_built_in_type(monkeypatch):
def load(self): # pragma: no cover - never reached
raise AssertionError("should not be loaded")
monkeypatch.setattr("fluksio.flow.plugins.entry_points", lambda group: [FakeEntry()])
monkeypatch.setattr(
"fluksio.flow.plugins.entry_points", lambda group: [FakeEntry()]
)
assert load_plugins() == []
assert NODE_TYPES["mqtt"].plugin is None
+2 -2
View File
@@ -123,7 +123,7 @@ def test_a_node_imports_the_standard_library_not_the_engines_own_modules(pool):
def test_the_engines_secrets_are_not_in_a_workers_environment(pool, monkeypatch):
monkeypatch.setenv("SECRET_KEY", "not-for-nodes")
monkeypatch.setenv("POSTGRES_PASSWORD", "not-for-nodes")
monkeypatch.setenv("DATABASE_URL", "not-for-nodes")
monkeypatch.setenv("FLUKSIO_HARMLESS", "fine")
# A fresh process, so it is built from the environment set just now.
pool.respawn_all()
@@ -132,7 +132,7 @@ def test_the_engines_secrets_are_not_in_a_workers_environment(pool, monkeypatch)
pool,
"import os\n\n\n"
"def process():\n"
" return {'out': [k for k in ('SECRET_KEY', 'POSTGRES_PASSWORD',\n"
" return {'out': [k for k in ('SECRET_KEY', 'DATABASE_URL',\n"
" 'FLUKSIO_HARMLESS') if k in os.environ]}\n",
)
assert result == {"out": ["FLUKSIO_HARMLESS"]}
View File
@@ -1,33 +0,0 @@
from unittest.mock import MagicMock, patch
from sqlmodel import select
from fluksio.backend_pre_start import init, logger
def test_init_successful_connection() -> None:
engine_mock = MagicMock()
session_mock = MagicMock()
session_mock.__enter__.return_value = session_mock
select1 = select(1)
with (
patch("fluksio.backend_pre_start.Session", return_value=session_mock),
patch("fluksio.backend_pre_start.select", return_value=select1),
patch.object(logger, "info"),
patch.object(logger, "error"),
patch.object(logger, "warn"),
):
try:
init(engine_mock)
connection_successful = True
except Exception:
connection_successful = False
assert connection_successful, (
"The database connection should be successful and not raise an exception."
)
session_mock.exec.assert_called_once_with(select1)
@@ -1,33 +0,0 @@
from unittest.mock import MagicMock, patch
from sqlmodel import select
from fluksio.tests_pre_start import init, logger
def test_init_successful_connection() -> None:
engine_mock = MagicMock()
session_mock = MagicMock()
session_mock.__enter__.return_value = session_mock
select1 = select(1)
with (
patch("fluksio.tests_pre_start.Session", return_value=session_mock),
patch("fluksio.tests_pre_start.select", return_value=select1),
patch.object(logger, "info"),
patch.object(logger, "error"),
patch.object(logger, "warn"),
):
try:
init(engine_mock)
connection_successful = True
except Exception:
connection_successful = False
assert connection_successful, (
"The database connection should be successful and not raise an exception."
)
session_mock.exec.assert_called_once_with(select1)
+3 -1
View File
@@ -206,7 +206,9 @@ def test_adding_a_remote_user_maps_and_revokes(
return_value={"user_id": "portal-user-9", "email": "remote@example.com"}
),
)
with patch("fluksio.api.routes.cloud.httpx.post", return_value=portal_reply) as post:
with patch(
"fluksio.api.routes.cloud.httpx.post", return_value=portal_reply
) as post:
added = client.post(
f"{settings.API_V1_STR}/cloud/users",
headers=superuser_token_headers,
+33
View File
@@ -0,0 +1,33 @@
"""What the database has to keep true whatever dialect is under it."""
from datetime import datetime, timedelta, timezone
from sqlmodel import Session, select
from fluksio.core.db import engine
from fluksio.models import EngineEvent
# The `db` fixture is session-scoped and autouse, so the schema is already there.
def test_a_stored_instant_comes_back_aware_and_in_utc() -> None:
"""SQLite stores no offset, so the column type has to put one back.
Without it a timestamp read from the database is naive: comparing it to
`datetime.now(timezone.utc)` raises, and serialising it hands the frontend
a time with no zone, which it reads as local.
"""
stamp = datetime(2026, 8, 21, 15, 30, tzinfo=timezone(timedelta(hours=2)))
with Session(engine) as session:
session.add(
EngineEvent(ts=stamp, type="node_error", flow="tz", node="n", detail="")
)
session.commit()
with Session(engine) as session:
stored = session.exec(select(EngineEvent).where(EngineEvent.flow == "tz")).one()
assert stored.ts.tzinfo is not None
assert stored.ts.utcoffset() == timedelta(0)
assert stored.ts == stamp
# And it still compares against an aware "now" rather than raising.
assert stored.ts < datetime.now(timezone.utc)