mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 02:41:14 +00:00
[AZ-304] C6 Postgres schema: additive 0002 migration + UUIDv5
Strictly additive Alembic migration on the AZ-263 baseline (data_model
.md § 6.1 / § 6.3): six new tiles columns (tile_uuid UNIQUE,
location_hash, content_sha256, disk_bytes, accessed_at, uploaded_at),
four new btree indices, one UNIQUE expression index over the
COALESCE-zero-uuid natural key, CHECK widening of
ck_tiles_freshness_status to the AZ-263 + AZ-303 vocabulary UNION,
four NULLable bbox columns on sector_classifications, and a new
tile_freshness_rules table seeded with the two default thresholds.
Pinned UUIDv5 namespace (TILE_NAMESPACE_UUID =
5b8d0c2e-1a4f-4b3a-8c9d-e7f6a3b2c1d0) + derive_tile_id /
derive_location_hash helpers cross-coordinated with
satellite-provider. Migration runner apply_migrations(config) drives
Alembic command.upgrade("head") against the AZ-263 env with one
retry on PG SQLSTATE 40001 and structured INFO logs on apply / no-op.
Contract bump tile_metadata_store.md v1.1.0 -> v1.2.0 adds
TileMetadata.location_hash: UUID | None = None (non-breaking).
module-layout.md updated so c6_tile_cache explicitly Owns
db/migrations/**.
Tier-1 tests: UUIDv5 determinism + locked vectors + DSN resolution +
retry mocked DBAPIError -> 1180 passed, 32 skipped. Tier-2 docker
schema tests gated by @pytest.mark.docker run against the existing
docker-compose.test.yml db service.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
"""AZ-304 NFR-reliability-retry + unit tests for the c6_tile_cache migration runner.
|
||||
|
||||
These tests stub :mod:`gps_denied_onboard.components.c6_tile_cache.migrations`
|
||||
internals so the runner's retry / DSN-resolution / error-mapping paths can
|
||||
be exercised without a real Postgres. The integration suite
|
||||
(``test_postgres_schema.py``, ``@pytest.mark.docker``) covers the happy
|
||||
path end-to-end.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import DBAPIError
|
||||
|
||||
from gps_denied_onboard.components.c6_tile_cache import migrations as migrations_module
|
||||
from gps_denied_onboard.components.c6_tile_cache.config import C6TileCacheConfig
|
||||
from gps_denied_onboard.components.c6_tile_cache.migrations import (
|
||||
MigrationError,
|
||||
MigrationResult,
|
||||
apply_migrations,
|
||||
)
|
||||
from gps_denied_onboard.config.schema import Config
|
||||
|
||||
|
||||
def _config(dsn: str = "postgresql://user:pass@host:5432/db") -> Config:
|
||||
return Config.with_blocks(c6_tile_cache=C6TileCacheConfig(postgres_dsn=dsn))
|
||||
|
||||
|
||||
class _FakeSerializationFailure(Exception):
|
||||
"""Lightweight stand-in for ``psycopg.errors.SerializationFailure``."""
|
||||
|
||||
sqlstate = "40001"
|
||||
|
||||
|
||||
def _make_dbapi_error(orig: Exception) -> DBAPIError:
|
||||
"""Construct a SQLAlchemy DBAPIError wrapping ``orig`` (matches runtime shape)."""
|
||||
return DBAPIError(statement="", params=None, orig=orig)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# DSN resolution.
|
||||
|
||||
|
||||
def test_resolve_dsn_prefers_config_over_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Arrange
|
||||
monkeypatch.setenv("DB_URL", "postgresql://env-host/db")
|
||||
cfg = _config("postgresql://cfg-host/db")
|
||||
|
||||
# Act
|
||||
dsn = migrations_module._resolve_dsn(cfg)
|
||||
|
||||
# Assert
|
||||
assert dsn == "postgresql://cfg-host/db"
|
||||
|
||||
|
||||
def test_resolve_dsn_falls_back_to_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Arrange
|
||||
monkeypatch.setenv("DB_URL", "postgresql://env-host/db")
|
||||
cfg = _config("")
|
||||
|
||||
# Act
|
||||
dsn = migrations_module._resolve_dsn(cfg)
|
||||
|
||||
# Assert
|
||||
assert dsn == "postgresql://env-host/db"
|
||||
|
||||
|
||||
def test_resolve_dsn_raises_when_no_source(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Arrange
|
||||
monkeypatch.delenv("DB_URL", raising=False)
|
||||
cfg = _config("")
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(MigrationError, match="no DSN available"):
|
||||
migrations_module._resolve_dsn(cfg)
|
||||
|
||||
|
||||
def test_to_sqlalchemy_url_rewrites_postgresql_prefix() -> None:
|
||||
# Act + Assert
|
||||
assert migrations_module._to_sqlalchemy_url("postgresql://h/db") == "postgresql+psycopg://h/db"
|
||||
# Already-prefixed URLs pass through unchanged.
|
||||
assert (
|
||||
migrations_module._to_sqlalchemy_url("postgresql+psycopg://h/db")
|
||||
== "postgresql+psycopg://h/db"
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# NFR-reliability-retry: one SerializationFailure → retry → succeed.
|
||||
|
||||
|
||||
def test_nfr_reliability_retry_once_on_serialization_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Arrange — stub all DB-touching internals so the retry path is the only
|
||||
# variable. Both iterations see the DB BELOW head pre-upgrade; the first
|
||||
# upgrade attempt raises SQLSTATE 40001, the second succeeds. Post-upgrade
|
||||
# revision is read once after the successful attempt.
|
||||
rev_sequence = iter([None, None, "0002_c6_tile_identity_and_lru"])
|
||||
upgrade_calls: list[int] = []
|
||||
|
||||
def fake_upgrade(_cfg: Any, _target: str) -> None:
|
||||
upgrade_calls.append(len(upgrade_calls))
|
||||
if len(upgrade_calls) == 1:
|
||||
raise _make_dbapi_error(_FakeSerializationFailure("conflict"))
|
||||
|
||||
def fake_current_revision(_url: str) -> str | None:
|
||||
return next(rev_sequence)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gps_denied_onboard.components.c6_tile_cache.migrations.command.upgrade",
|
||||
fake_upgrade,
|
||||
)
|
||||
monkeypatch.setattr(migrations_module, "_current_revision", fake_current_revision)
|
||||
monkeypatch.setattr(
|
||||
migrations_module,
|
||||
"_head_revision",
|
||||
lambda _cfg: "0002_c6_tile_identity_and_lru",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
migrations_module,
|
||||
"_resolve_applied",
|
||||
lambda _cfg, _pre, _post: ["0002_c6_tile_identity_and_lru"],
|
||||
)
|
||||
|
||||
# Act
|
||||
result = apply_migrations(_config())
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, MigrationResult)
|
||||
assert result.applied == ["0002_c6_tile_identity_and_lru"]
|
||||
assert result.no_op is False
|
||||
assert len(upgrade_calls) == 2, "runner must retry on SQLSTATE 40001"
|
||||
|
||||
|
||||
def test_nfr_reliability_terminal_after_two_serialization_failures(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Arrange
|
||||
def fake_upgrade(_cfg: Any, _target: str) -> None:
|
||||
raise _make_dbapi_error(_FakeSerializationFailure("persistent conflict"))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gps_denied_onboard.components.c6_tile_cache.migrations.command.upgrade",
|
||||
fake_upgrade,
|
||||
)
|
||||
monkeypatch.setattr(migrations_module, "_current_revision", lambda _url: None)
|
||||
monkeypatch.setattr(
|
||||
migrations_module,
|
||||
"_head_revision",
|
||||
lambda _cfg: "0002_c6_tile_identity_and_lru",
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(MigrationError, match="database error"):
|
||||
apply_migrations(_config())
|
||||
|
||||
|
||||
def test_nfr_reliability_does_not_retry_on_non_serialization_dbapi_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Arrange
|
||||
upgrade_calls: list[int] = []
|
||||
|
||||
class _GenericDbApiOrig(Exception):
|
||||
sqlstate = "42P01" # "undefined_table" — NOT a serialization conflict
|
||||
|
||||
def fake_upgrade(_cfg: Any, _target: str) -> None:
|
||||
upgrade_calls.append(len(upgrade_calls))
|
||||
raise _make_dbapi_error(_GenericDbApiOrig("missing relation"))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gps_denied_onboard.components.c6_tile_cache.migrations.command.upgrade",
|
||||
fake_upgrade,
|
||||
)
|
||||
monkeypatch.setattr(migrations_module, "_current_revision", lambda _url: None)
|
||||
monkeypatch.setattr(
|
||||
migrations_module,
|
||||
"_head_revision",
|
||||
lambda _cfg: "0002_c6_tile_identity_and_lru",
|
||||
)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(MigrationError):
|
||||
apply_migrations(_config())
|
||||
assert len(upgrade_calls) == 1, "non-40001 errors must fail-fast (no retry)"
|
||||
|
||||
|
||||
def test_apply_migrations_logs_no_op_when_already_at_head(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Arrange
|
||||
monkeypatch.setattr(
|
||||
migrations_module, "_current_revision", lambda _url: "0002_c6_tile_identity_and_lru"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
migrations_module, "_head_revision", lambda _cfg: "0002_c6_tile_identity_and_lru"
|
||||
)
|
||||
|
||||
# Act
|
||||
result = apply_migrations(_config())
|
||||
|
||||
# Assert
|
||||
assert result == MigrationResult(
|
||||
applied=[], current_revision="0002_c6_tile_identity_and_lru", no_op=True
|
||||
)
|
||||
|
||||
|
||||
def test_migration_error_is_not_a_tile_cache_error_member() -> None:
|
||||
"""`MigrationError` deliberately lives outside the TileCacheError family."""
|
||||
# Arrange
|
||||
from gps_denied_onboard.components.c6_tile_cache.errors import TileCacheError
|
||||
|
||||
# Act + Assert
|
||||
assert not issubclass(MigrationError, TileCacheError)
|
||||
Reference in New Issue
Block a user