Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,58 @@ jobs:
-n auto \
--splits 4 --group ${{ matrix.shard }}

postgres-migrations:
name: PostgreSQL Migration Compatibility
runs-on: ubuntu-latest
needs: backend-lint
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: printops
POSTGRES_PASSWORD: printops
POSTGRES_DB: printops
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U printops -d printops"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgresql+asyncpg://printops:printops@127.0.0.1:5432/printops
PRINTOPS_POSTGRES_TEST_URL: postgresql+asyncpg://printops:printops@127.0.0.1:5432/printops
DATA_DIR: /tmp/printops-postgres-test
LOG_TO_FILE: 'false'
TESTING: '1'
steps:
- uses: actions/checkout@v6

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ env.PYTHON_VERSION }}

- name: Cache pip
uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements-dev.lock.txt') }}
restore-keys: |
${{ runner.os }}-pip-

- name: Install dependencies
run: pip install --require-hashes -r requirements-dev.lock.txt

- name: Configure non-UTC database default
run: >-
docker exec "${{ job.services.postgres.id }}"
psql -U printops -d printops
-c "ALTER DATABASE printops SET timezone TO 'Europe/Istanbul'"

- name: Run live PostgreSQL migration tests
run: python -m pytest backend/tests/postgres/test_issue_142_postgres_runtime.py -q

# ============================================================================
# Frontend Checks
# ============================================================================
Expand Down
6 changes: 3 additions & 3 deletions backend/app/api/routes/support.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
)
from backend.app.services.network_utils import get_network_interfaces
from backend.app.services.printer_manager import printer_manager
from backend.app.utils.local_time import utcnow_naive

router = APIRouter(prefix="/support", tags=["support"])
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -486,9 +487,8 @@ async def _collect_queue_info(db: AsyncSession) -> dict:
)
).scalar_one_or_none()
if oldest_row is not None:
# created_at is naive in this codebase (server_default=func.now()); compare
# against naive utc-now to get the actual age without TZ-conversion surprises.
age = (datetime.now() - oldest_row).total_seconds()
# created_at is naive UTC; compare it with the same clock contract.
age = (utcnow_naive() - oldest_row).total_seconds()
info["oldest_pending_age_seconds"] = int(age)
else:
info["oldest_pending_age_seconds"] = None
Expand Down
43 changes: 19 additions & 24 deletions backend/app/core/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from backend.app.core.active_print_migrations import migrate_active_print_spoolman
from backend.app.core.archive_metadata_migration import repair_archive_plate_metadata
from backend.app.core.config import settings
from backend.app.core.db_dialect import is_sqlite
from backend.app.core.db_dialect import is_already_applied as _is_already_applied, is_sqlite, postgres_connect_args
from backend.app.core.library_migrations import reclassify_sliced_3mf_library_files
from backend.app.core.number_sequence_migrations import migrate_number_sequence_monthly_reset_policy
from backend.app.core.rfid_core_weight_migration import repair_rfid_core_weights
Expand Down Expand Up @@ -48,9 +48,17 @@ def _resolve_pool_kwargs() -> dict:
return kwargs


def _resolve_connect_args() -> dict:
"""Return dialect-appropriate arguments for new DB connections."""
return postgres_connect_args(settings.database_url, sqlite=is_sqlite())


def _create_engine():
"""Create the async engine with dialect-appropriate settings."""
kwargs = _resolve_pool_kwargs()
connect_args = _resolve_connect_args()
if connect_args:
kwargs["connect_args"] = connect_args
global _pool_config
_pool_config = {
"pool_size": kwargs["pool_size"],
Expand Down Expand Up @@ -453,13 +461,9 @@ async def _migrate_encrypt_legacy_secrets() -> None:
async def _safe_execute(conn, sql):
"""Execute a DDL migration statement, silently ignoring idempotency errors.

'already exists', 'duplicate column name' (SQLite ADD COLUMN), 'no such column'
(SQLite RENAME COLUMN), 'duplicate key', and the compound
'column … does not exist' (PostgreSQL RENAME COLUMN idempotency) are swallowed
so that re-running DDL migrations is safe. The compound check additionally
requires the SQL to be a RENAME COLUMN statement so that "does not exist" errors
from ADD COLUMN or CREATE INDEX (which would indicate schema corruption, not
idempotency) are never silently swallowed.
PostgreSQL uses locale-independent SQLSTATE codes; SQLite retains its stable
error-text fallback. Missing-column errors are accepted for RENAME COLUMN
only, so corrupt schemas still abort startup.
Any other error is logged and re-raised — callers must not assume silent
recovery, as a failure will abort the migration sequence and prevent
application startup.
Expand All @@ -477,14 +481,7 @@ async def _safe_execute(conn, sql):
async with conn.begin_nested():
await conn.execute(text(sql))
except (OperationalError, ProgrammingError) as exc:
msg = str(exc).lower()
# Only swallow "column … does not exist" for RENAME COLUMN — not for ADD COLUMN
# or CREATE INDEX where it would indicate schema corruption, not idempotency.
column_not_exists = "rename column" in sql.lower() and "column" in msg and "does not exist" in msg
if (
not any(k in msg for k in ("already exists", "duplicate key", "duplicate column name", "no such column"))
and not column_not_exists
):
if not _is_already_applied(exc, sql):
logger.error("Migration statement failed: %s | SQL: %.200s", exc, sql)
raise

Expand Down Expand Up @@ -2479,17 +2476,15 @@ async def run_migrations(conn):
# SQLite does not support ALTER TABLE ADD CONSTRAINT — handled by __table_args__ at creation.
# Runs AFTER the backfill so Fall B rows don't fail constraint validation.
if not is_sqlite():
add_constraint = (
"ALTER TABLE oidc_providers ADD CONSTRAINT ck_auto_link_requires_verified_email_claim "
"CHECK (auto_link_existing_accounts = FALSE OR email_claim != 'email' OR require_email_verified = TRUE)"
)
try:
async with conn.begin_nested():
await conn.execute(
text(
"ALTER TABLE oidc_providers ADD CONSTRAINT ck_auto_link_requires_verified_email_claim "
"CHECK (auto_link_existing_accounts = FALSE OR email_claim != 'email' OR require_email_verified = TRUE)"
)
)
await conn.execute(text(add_constraint))
except (OperationalError, ProgrammingError) as exc:
msg = str(exc).lower()
if "already exists" not in msg:
if not _is_already_applied(exc, add_constraint):
logger.error(
"Security constraint migration FAILED — auto_link safety constraint may not be enforced: %s",
exc,
Expand Down
37 changes: 37 additions & 0 deletions backend/app/core/db_dialect.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

from sqlalchemy import func, text

_PG_ALREADY_APPLIED = frozenset({"42701", "42P07", "42710", "23505"})
_PG_UNDEFINED_COLUMN = "42703"


def is_postgres() -> bool:
"""Check if using PostgreSQL based on DATABASE_URL."""
Expand All @@ -21,6 +24,40 @@ def is_sqlite() -> bool:
return settings.database_url.startswith("sqlite")


def postgres_connect_args(database_url: str, *, sqlite: bool) -> dict:
"""Pin PostgreSQL sessions to UTC while leaving SQLite untouched."""
if sqlite:
return {}
if "+asyncpg" in database_url:
return {"server_settings": {"timezone": "UTC"}}
return {"options": "-c timezone=UTC"}


def sqlstate(exc: BaseException) -> str | None:
"""Return a SQLAlchemy-wrapped PostgreSQL SQLSTATE when available."""
orig = getattr(exc, "orig", None)
for attr in ("sqlstate", "pgcode"):
code = getattr(orig, attr, None)
if code:
return str(code)
return None


def is_already_applied(exc: BaseException, sql: str) -> bool:
"""Classify idempotent DDL by SQLSTATE, with SQLite's text fallback."""
is_rename = "rename column" in sql.lower()
state = sqlstate(exc)
if state is not None:
return state in _PG_ALREADY_APPLIED or (state == _PG_UNDEFINED_COLUMN and is_rename)

message = str(exc).lower()
if any(
marker in message for marker in ("already exists", "duplicate key", "duplicate column name", "no such column")
):
return True
return is_rename and "column" in message and "does not exist" in message


async def upsert_setting(db, model, key: str, value: str):
"""Dialect-aware INSERT ... ON CONFLICT UPDATE for the Settings table."""
if is_postgres():
Expand Down
6 changes: 3 additions & 3 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@
)
from backend.app.services.tasmota import tasmota_service
from backend.app.utils.filament_types import printer_filament_type
from backend.app.utils.local_time import utcnow_naive
from backend.app.utils.print_jobs import ignore_internal_printer_job


Expand Down Expand Up @@ -5251,14 +5252,13 @@ async def record_ams_history():
_ams_cleanup_counter += 1
if _ams_cleanup_counter >= 288:
_ams_cleanup_counter = 0
# Get retention days from settings
from backend.app.models.settings import Settings

result = await db.execute(select(Settings).where(Settings.key == "ams_history_retention_days"))
setting = result.scalar_one_or_none()
retention_days = int(setting.value) if setting else AMS_HISTORY_RETENTION_DAYS

cutoff = datetime.utcnow() - timedelta(days=retention_days)
cutoff = utcnow_naive() - timedelta(days=retention_days)
result = await db.execute(delete(AMSSensorHistory).where(AMSSensorHistory.recorded_at < cutoff))
await db.commit()
if result.rowcount > 0:
Expand Down Expand Up @@ -5384,7 +5384,7 @@ async def record_printer_sensor_history():
setting = result.scalar_one_or_none()
retention_days = int(setting.value) if setting else PRINTER_SENSOR_HISTORY_RETENTION_DAYS

cutoff = datetime.utcnow() - timedelta(days=retention_days)
cutoff = utcnow_naive() - timedelta(days=retention_days)
cleanup = await db.execute(
delete(PrinterSensorHistory).where(PrinterSensorHistory.recorded_at < cutoff)
)
Expand Down
79 changes: 79 additions & 0 deletions backend/tests/postgres/test_issue_142_postgres_runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Live PostgreSQL coverage for locale-safe migrations and UTC timestamps (#142)."""

from __future__ import annotations

import os
from datetime import datetime, timedelta, timezone

import pytest
from sqlalchemy import text
from sqlalchemy.exc import ProgrammingError

POSTGRES_TEST_URL = os.environ.get("PRINTOPS_POSTGRES_TEST_URL")
pytestmark = pytest.mark.skipif(not POSTGRES_TEST_URL, reason="live PostgreSQL test URL not configured")


@pytest.mark.asyncio
async def test_repeated_startup_localised_errors_and_utc_defaults():
from backend.app.core import database

assert database.settings.database_url == POSTGRES_TEST_URL

# Fresh installation followed by a repeated startup exercises the complete
# migration sequence against PostgreSQL, including already-applied DDL.
await database.init_db()
await database.init_db()

async with database.engine.begin() as conn:
assert (await conn.execute(text("SHOW TimeZone"))).scalar_one() == "UTC"

await conn.execute(text("DROP TABLE IF EXISTS issue_142_runtime_probe"))
await conn.execute(
text(
"CREATE TABLE issue_142_runtime_probe ("
"id INTEGER PRIMARY KEY, created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP)"
)
)
await conn.execute(text("INSERT INTO issue_142_runtime_probe (id) VALUES (1)"))
stored = (await conn.execute(text("SELECT created_at FROM issue_142_runtime_probe WHERE id = 1"))).scalar_one()
utc_now = datetime.now(timezone.utc).replace(tzinfo=None)
assert abs(stored - utc_now) < timedelta(minutes=1)

await database._safe_execute(conn, "ALTER TABLE issue_142_runtime_probe ADD COLUMN value INTEGER")
await database._safe_execute(conn, "ALTER TABLE issue_142_runtime_probe ADD COLUMN value INTEGER")

# PostgreSQL emits the error and SQLSTATE; only the message is supplied
# in German to prove classification never depends on English wording.
with pytest.raises(ProgrammingError) as duplicate:
async with conn.begin_nested():
await conn.execute(
text(
"DO $$ BEGIN RAISE EXCEPTION USING ERRCODE = '42701', "
"MESSAGE = 'Spalte ist bereits vorhanden'; END $$"
)
)
assert "Spalte ist bereits vorhanden" in str(duplicate.value)
assert database._is_already_applied(
duplicate.value,
"ALTER TABLE issue_142_runtime_probe ADD COLUMN value INTEGER",
)

with pytest.raises(ProgrammingError) as unexpected:
async with conn.begin_nested():
await conn.execute(
text(
"DO $$ BEGIN RAISE EXCEPTION USING ERRCODE = '42P01', "
"MESSAGE = 'already exists, aber die Tabelle fehlt'; END $$"
)
)
assert not database._is_already_applied(
unexpected.value,
"ALTER TABLE issue_142_runtime_probe ADD COLUMN other INTEGER",
)
with pytest.raises(ProgrammingError):
await database._safe_execute(
conn,
"ALTER TABLE issue_142_missing_table ADD COLUMN value INTEGER",
)

await conn.execute(text("DROP TABLE issue_142_runtime_probe"))
Loading