feat: enforce schema readiness and optimistic resources
This commit is contained in:
@@ -67,7 +67,10 @@ response, state, task, error, and permission dimensions.
|
|||||||
|
|
||||||
Database migrations are ordered SQL files applied transactionally and recorded
|
Database migrations are ordered SQL files applied transactionally and recorded
|
||||||
with SHA-256 checksums. Re-running `make db-migrate` is safe; changing an already
|
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
|
Seed profiles are deterministic and replace the previously seeded simulation
|
||||||
state atomically. `small` creates one node, two QEMU guests, one LXC, two
|
state atomically. `small` creates one node, two QEMU guests, one LXC, two
|
||||||
storages, an administrator, and completed task history. `medium` creates three
|
storages, an administrator, and completed task history. `medium` creates three
|
||||||
|
|||||||
+11
-2
@@ -8,6 +8,9 @@ import asyncpg # type: ignore[import-untyped]
|
|||||||
from asyncpg import Pool
|
from asyncpg import Pool
|
||||||
|
|
||||||
from app.config import Settings
|
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):
|
class Database(Protocol):
|
||||||
@@ -62,12 +65,18 @@ class AsyncpgDatabase:
|
|||||||
await pool.close()
|
await pool.close()
|
||||||
|
|
||||||
async def is_ready(self) -> bool:
|
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:
|
if self._pool is None:
|
||||||
return False
|
return False
|
||||||
try:
|
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:
|
except asyncpg.PostgresError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Typed PostgreSQL repositories for simulation domain state."""
|
||||||
@@ -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
@@ -46,7 +46,7 @@ services:
|
|||||||
image: nginx:1.28.0-alpine
|
image: nginx:1.28.0-alpine
|
||||||
depends_on:
|
depends_on:
|
||||||
simulator:
|
simulator:
|
||||||
condition: service_healthy
|
condition: service_started
|
||||||
ports:
|
ports:
|
||||||
- "8007:8443"
|
- "8007:8443"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -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
|
complete stateful semantics. All other imported endpoints remain visibly
|
||||||
unsupported until their handlers and compatibility tests exist.
|
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
|
## Deployment model
|
||||||
|
|
||||||
One Uvicorn process runs per container. Horizontal replicas coordinate through
|
One Uvicorn process runs per container. Horizontal replicas coordinate through
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ live admin report renders the same evidence deterministically.
|
|||||||
LXC, disks, NICs, snapshots, backups, pools, users/groups/roles/ACLs/tokens and
|
LXC, disks, NICs, snapshots, backups, pools, users/groups/roles/ACLs/tokens and
|
||||||
observed contracts. Preserve version, metadata, timestamps, and cluster-wide
|
observed contracts. Preserve version, metadata, timestamps, and cluster-wide
|
||||||
VMID uniqueness.
|
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.
|
unhealthy before schema creation.
|
||||||
- [x] Match the required `small` profile (one node, two QEMU, one LXC, two
|
- [x] Match the required `small` profile (one node, two QEMU, one LXC, two
|
||||||
storages, administrator, completed tasks).
|
storages, administrator, completed tasks).
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import uuid
|
|||||||
import asyncpg # type: ignore[import-untyped]
|
import asyncpg # type: ignore[import-untyped]
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
from app.db.migrations import migrate
|
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
|
from app.simulation.seed import apply_seed, small_profile
|
||||||
|
|
||||||
pytestmark = [
|
pytestmark = [
|
||||||
@@ -58,3 +62,34 @@ async def test_small_seed_is_idempotent() -> None:
|
|||||||
assert await connection.fetchval("SELECT count(*) FROM storage_contents") == 4
|
assert await connection.fetchval("SELECT count(*) FROM storage_contents") == 4
|
||||||
finally:
|
finally:
|
||||||
await connection.close()
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user