fix(v2): enforce M1 runtime persistence invariants

This commit is contained in:
2026-07-27 19:42:41 +02:00
parent 45a526a1e9
commit 15f8794029
7 changed files with 188 additions and 39 deletions
+8 -12
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from alembic.config import Config
@@ -12,23 +11,23 @@ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from backup_tool.config import Settings
from . import sqlite as sqlite_runtime
class SchemaNotCurrentError(RuntimeError):
pass
def create_engine(settings: Settings) -> AsyncEngine:
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
engine = create_async_engine(
settings.database_url,
pool_pre_ping=True,
connect_args=sqlite_runtime.sqlite_connect_args(settings.sqlite_busy_timeout_ms),
)
@event.listens_for(engine.sync_engine, "connect")
def configure_sqlite(dbapi_connection: Any, _connection_record: Any) -> None:
cursor = dbapi_connection.cursor()
try:
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute(f"PRAGMA busy_timeout={settings.sqlite_busy_timeout_ms}")
cursor.execute("PRAGMA journal_mode=WAL")
finally:
cursor.close()
sqlite_runtime.configure_sqlite_connection(dbapi_connection)
return engine
@@ -45,6 +44,3 @@ async def assert_schema_current(engine: AsyncEngine, alembic_config: Config) ->
raise SchemaNotCurrentError(
f"database schema is not current: expected {expected!r}, found {current!r}"
)
SchemaCheck = Callable[[AsyncEngine, Config], Any]
+20 -17
View File
@@ -7,7 +7,6 @@ from sqlalchemy import (
JSON,
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
Index,
Integer,
@@ -21,6 +20,10 @@ from sqlalchemy import (
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from backup_tool.ids import new_uuid7
from .types import UTCDateTime
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
@@ -35,15 +38,15 @@ class Base(DeclarativeBase):
class IdentityMixin:
id: Mapped[str] = mapped_column(String(36), primary_key=True)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(new_uuid7()))
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.current_timestamp()
UTCDateTime(), nullable=False, server_default=func.current_timestamp()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
UTCDateTime(),
nullable=False,
server_default=func.current_timestamp(),
onupdate=func.current_timestamp(),
@@ -63,8 +66,8 @@ class ApiToken(IdentityMixin, TimestampMixin, Base):
owner_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="RESTRICT"))
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
scopes: Mapped[list[str]] = mapped_column(JSON, nullable=False)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
revoked_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
__table_args__ = (Index("ix_api_tokens_owner_id", "owner_id"),)
@@ -134,7 +137,7 @@ class Schedule(IdentityMixin, TimestampMixin, Base):
misfire_grace_seconds: Mapped[int] = mapped_column(Integer, nullable=False, default=900)
overlap_policy: Mapped[str] = mapped_column(String(32), nullable=False, default="prohibit")
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
next_nominal_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
next_nominal_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
last_enqueue_outcome: Mapped[str | None] = mapped_column(String(64))
__table_args__ = (
CheckConstraint("misfire_grace_seconds >= 0", name="misfire_nonnegative"),
@@ -146,18 +149,18 @@ class Execution(IdentityMixin, TimestampMixin, Base):
__tablename__ = "executions"
job_id: Mapped[str] = mapped_column(ForeignKey("jobs.id", ondelete="RESTRICT"))
schedule_id: Mapped[str | None] = mapped_column(ForeignKey("schedules.id", ondelete="RESTRICT"))
nominal_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
nominal_run_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
trigger: Mapped[str] = mapped_column(String(32), nullable=False)
state: Mapped[str] = mapped_column(String(32), nullable=False, default="queued")
attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
lease_owner: Mapped[str | None] = mapped_column(String(255))
lease_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
lease_expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
heartbeat_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
progress: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
reason_code: Mapped[str | None] = mapped_column(String(64))
operator_message: Mapped[str | None] = mapped_column(Text)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
started_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
__table_args__ = (
CheckConstraint("attempt > 0", name="attempt_positive"),
CheckConstraint(
@@ -192,9 +195,9 @@ class Backup(IdentityMixin, Base):
stored_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
integrity: Mapped[str] = mapped_column(String(32), nullable=False, default="unverified")
pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
tombstoned_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
tombstoned_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.current_timestamp()
UTCDateTime(), nullable=False, server_default=func.current_timestamp()
)
__table_args__ = (
CheckConstraint("logical_bytes >= 0", name="logical_bytes_nonnegative"),
@@ -233,7 +236,7 @@ class AuditEvent(IdentityMixin, Base):
request_id: Mapped[str] = mapped_column(String(36), nullable=False)
details: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.current_timestamp()
UTCDateTime(), nullable=False, server_default=func.current_timestamp()
)
__table_args__ = (
CheckConstraint("outcome IN ('success','failure','denied')", name="outcome"),
@@ -264,7 +267,7 @@ class NotificationDelivery(IdentityMixin, TimestampMixin, Base):
attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
state: Mapped[str] = mapped_column(String(32), nullable=False)
response_class: Mapped[str | None] = mapped_column(String(64))
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
next_attempt_at: Mapped[datetime | None] = mapped_column(UTCDateTime())
__table_args__ = (
CheckConstraint("attempt > 0", name="attempt_positive"),
CheckConstraint("state IN ('pending','delivered','retry','failed')", name="state"),
@@ -282,7 +285,7 @@ class IdempotencyRecord(IdentityMixin, Base):
response_resource_type: Mapped[str] = mapped_column(String(64), nullable=False)
response_resource_id: Mapped[str] = mapped_column(String(36), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.current_timestamp()
UTCDateTime(), nullable=False, server_default=func.current_timestamp()
)
__table_args__ = (
UniqueConstraint("actor_id", "key", "operation", name="uq_idempotency_actor_key_operation"),
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from typing import Any
DEFAULT_BUSY_TIMEOUT_MS = 5_000
def sqlite_connect_args(busy_timeout_ms: int) -> dict[str, float]:
"""Configure SQLite lock waiting without dynamic PRAGMA SQL."""
return {"timeout": busy_timeout_ms / 1_000}
def configure_sqlite_connection(dbapi_connection: Any) -> None:
"""Apply connection-local safety and persistent WAL mode."""
cursor = dbapi_connection.cursor()
try:
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA journal_mode=WAL")
finally:
cursor.close()
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import DateTime
from sqlalchemy.engine import Dialect
from sqlalchemy.types import TypeDecorator
class UTCDateTime(TypeDecorator[datetime]):
"""Store instants and always return timezone-aware UTC datetimes."""
impl = DateTime
cache_ok = True
def load_dialect_impl(self, dialect: Dialect) -> Any:
return dialect.type_descriptor(DateTime(timezone=True))
def process_bind_param(self, value: datetime | None, dialect: Dialect) -> datetime | None:
del dialect
if value is None:
return None
if value.tzinfo is None:
raise ValueError("datetime values must be timezone-aware")
return value.astimezone(UTC)
def process_result_value(self, value: datetime | None, dialect: Dialect) -> datetime | None:
del dialect
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)