fix(v2): enforce M1 runtime persistence invariants
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user