feat: enforce schema readiness and optimistic resources

This commit is contained in:
Sergey Antropoff
2026-07-13 01:18:50 +03:00
parent 67f75e5484
commit c45044f901
8 changed files with 156 additions and 5 deletions
+4 -1
View File
@@ -67,7 +67,10 @@ response, state, task, error, and permission dimensions.
Database migrations are ordered SQL files applied transactionally and recorded
with SHA-256 checksums. Re-running `make db-migrate` is safe; changing an already
applied migration is rejected instead of silently drifting the schema.
applied migration is rejected instead of silently drifting the schema. Readiness
stays unavailable until the latest packaged migration is present; task workers
retry claims and recover automatically when migrations are applied after process
startup.
Seed profiles are deterministic and replace the previously seeded simulation
state atomically. `small` creates one node, two QEMU guests, one LXC, two
storages, an administrator, and completed task history. `medium` creates three
+11 -2
View File
@@ -8,6 +8,9 @@ import asyncpg # type: ignore[import-untyped]
from asyncpg import Pool
from app.config import Settings
from app.db.migrations import load_migrations
LATEST_SCHEMA_VERSION = max(migration.version for migration in load_migrations())
class Database(Protocol):
@@ -62,12 +65,18 @@ class AsyncpgDatabase:
await pool.close()
async def is_ready(self) -> bool:
"""Check that PostgreSQL accepts a trivial query."""
"""Check connectivity and that all packaged migrations are applied."""
if self._pool is None:
return False
try:
return bool(await self._pool.fetchval("SELECT 1") == 1)
return bool(
await self._pool.fetchval(
"""SELECT COALESCE(max(version), 0) >= $1
FROM schema_migrations""",
LATEST_SCHEMA_VERSION,
)
)
except asyncpg.PostgresError:
return False
+1
View File
@@ -0,0 +1 @@
"""Typed PostgreSQL repositories for simulation domain state."""
+97
View File
@@ -0,0 +1,97 @@
"""Typed resource persistence with explicit optimistic locking."""
from __future__ import annotations
import json
import uuid
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, cast
from asyncpg import Pool # type: ignore[import-untyped]
from app.db.primitives import ConflictError, transaction
@dataclass(frozen=True, slots=True)
class ResourceRecord:
id: uuid.UUID
cluster_id: uuid.UUID
node: str
kind: str
external_id: str
state: dict[str, Any]
metadata: dict[str, Any]
version: int
def _json_object(value: object) -> dict[str, Any]:
if isinstance(value, str):
return cast(dict[str, Any], json.loads(value))
return dict(cast(Mapping[str, Any], value))
def _record(row: Mapping[str, object]) -> ResourceRecord:
return ResourceRecord(
id=cast(uuid.UUID, row["id"]),
cluster_id=cast(uuid.UUID, row["cluster_id"]),
node=str(row["node"]),
kind=str(row["kind"]),
external_id=str(row["external_id"]),
state=_json_object(row["state"]),
metadata=_json_object(row["metadata"]),
version=int(cast(int, row["version"])),
)
class ResourceRepository:
def __init__(self, pool: Pool) -> None:
self._pool = pool
async def list(
self, *, kind: str | None = None, node: str | None = None
) -> list[ResourceRecord]:
rows = await self._pool.fetch(
"""SELECT r.id, r.cluster_id, n.name AS node, r.kind, r.external_id,
r.state, r.metadata, r.version
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE ($1::text IS NULL OR r.kind=$1)
AND ($2::text IS NULL OR n.name=$2)
ORDER BY r.kind, r.external_id""",
kind,
node,
)
return [_record(row) for row in rows]
async def get(self, *, kind: str, external_id: str) -> ResourceRecord | None:
row = await self._pool.fetchrow(
"""SELECT r.id, r.cluster_id, n.name AS node, r.kind, r.external_id,
r.state, r.metadata, r.version
FROM resources r JOIN nodes n ON n.id=r.node_id
WHERE r.kind=$1 AND r.external_id=$2""",
kind,
external_id,
)
return None if row is None else _record(row)
async def update_state(
self,
resource_id: uuid.UUID,
*,
expected_version: int,
state: Mapping[str, object],
) -> ResourceRecord:
async with transaction(self._pool) as connection:
row = await connection.fetchrow(
"""UPDATE resources SET state=$3::jsonb, version=version+1,
updated_at=now() WHERE id=$1 AND version=$2
RETURNING id, cluster_id,
(SELECT name FROM nodes WHERE id=resources.node_id) AS node,
kind, external_id, state, metadata, version""",
resource_id,
expected_version,
json.dumps(dict(state), sort_keys=True),
)
if row is None:
raise ConflictError("resource version conflict or resource missing")
return _record(row)
+1 -1
View File
@@ -46,7 +46,7 @@ services:
image: nginx:1.28.0-alpine
depends_on:
simulator:
condition: service_healthy
condition: service_started
ports:
- "8007:8443"
volumes:
+6
View File
@@ -197,6 +197,12 @@ The first vertical release deliberately supports a small set of endpoints with
complete stateful semantics. All other imported endpoints remain visibly
unsupported until their handlers and compatibility tests exist.
Database readiness includes the latest packaged migration version, not merely a
successful connectivity query. Workers tolerate the documented container-first
startup sequence by retrying failed claims until migration tables exist.
Normalized resource writes use compare-and-swap version updates through a typed
repository, so stale writers receive a domain conflict.
## Deployment model
One Uvicorn process runs per container. Horizontal replicas coordinate through
+1 -1
View File
@@ -29,7 +29,7 @@ live admin report renders the same evidence deterministically.
LXC, disks, NICs, snapshots, backups, pools, users/groups/roles/ACLs/tokens and
observed contracts. Preserve version, metadata, timestamps, and cluster-wide
VMID uniqueness.
- [ ] Make migration readiness explicit so workers cannot become permanently
- [x] Make migration readiness explicit so workers cannot become permanently
unhealthy before schema creation.
- [x] Match the required `small` profile (one node, two QEMU, one LXC, two
storages, administrator, completed tasks).
+35
View File
@@ -6,7 +6,11 @@ import uuid
import asyncpg # type: ignore[import-untyped]
import pytest
from app.config import Settings
from app.db.migrations import migrate
from app.db.pool import AsyncpgDatabase
from app.db.primitives import ConflictError
from app.db.repositories.resources import ResourceRepository
from app.simulation.seed import apply_seed, small_profile
pytestmark = [
@@ -58,3 +62,34 @@ async def test_small_seed_is_idempotent() -> None:
assert await connection.fetchval("SELECT count(*) FROM storage_contents") == 4
finally:
await connection.close()
async def test_schema_readiness_and_optimistic_resource_repository() -> None:
url = os.environ["TEST_DATABASE_URL"]
connection = await asyncpg.connect(url)
database = AsyncpgDatabase(Settings(database_url=url))
try:
await migrate(connection)
await apply_seed(connection, small_profile())
await database.connect()
assert await database.is_ready()
repository = ResourceRepository(database.pool)
resource = await repository.get(kind="qemu", external_id="101")
assert resource is not None
updated = await repository.update_state(
resource.id,
expected_version=resource.version,
state={**resource.state, "status": "running"},
)
assert updated.version == resource.version + 1
assert updated.state["status"] == "running"
with pytest.raises(ConflictError):
await repository.update_state(
resource.id,
expected_version=resource.version,
state=resource.state,
)
finally:
await database.close()
await connection.close()