feat(startup): atomic swap dim-vs-MIGRATED guard for runtime schema validator
Add src/startup/embedding_validator.py that introspects the actual pgvector
column dim at boot and refuses to start if it does not match
EMBEDDING_VECTOR_DIMENSIONS. Runs after the DB pool is up and before the
embedding client is constructed, in both src/main.py (FastAPI lifespan) and
src/deriver/__main__.py.
Implementation details:
- Schema-qualified pg_attribute join through pg_class/pg_namespace respects
DB.SCHEMA rather than relying on search_path
- Bounded retry (3 attempts, 1s backoff) for transient introspection failure,
then fail-closed with "could not validate embedding schema" — uncertainty
is not a green light to serve traffic
- External-store sampler (turbopuffer, lancedb) enumerates workspaces from
the application DB and probes their lazy-created namespaces; current
per-namespace probe is a no-op stub since the SDKs do not expose
uniform dim introspection — full enumeration is left to
`configure_embeddings --report` in Phase 3
Atomic guard swap: deletes the old dim-vs-MIGRATED config validator (which
forbade non-1536 pgvector unless MIGRATED=True) in the same commit as the
new runtime validator. There is no release window where non-1536 pgvector
can start unprotected. The 9 dual-write branches that use VECTOR_STORE.MIGRATED
remain untouched and load-bearing for legacy-tenant backend swaps.
VECTOR_STORE_DIMENSIONS deprecation: drop the "must match" raise; in
propagate_namespace, check model_fields_set and emit logger.warning +
DeprecationWarning (DeprecationWarning alone is filtered by Python's default
config and would not reach operators). Always overwrite with
EMBEDDING.VECTOR_DIMENSIONS regardless.
Test changes:
- tests/test_models_vector_dim.py: Phase 1's VECTOR_STORE_TYPE=lancedb +
MIGRATED=true escape hatches removed; the test now passes on plain
EMBEDDING_VECTOR_DIMENSIONS=768
- tests/llm/test_model_config.py: the two tests asserting the old guards
replaced with tests for the new deprecation + acceptance behavior
- tests/startup/test_embedding_validator.py: 10 new tests — dim assertion
logic (pass/mismatch/missing/unbounded/non-public-schema), fail-closed
retry budget, real-test-DB pass, real-DB ALTER-then-validate, deprecation
warning capture, non-1536 + pgvector + MIGRATED=false at config time
This commit is contained in:
parent
01dfec523d
commit
405f625595
|
|
@ -1287,25 +1287,27 @@ class AppSettings(HonchoSettings):
|
|||
self.CACHE.NAMESPACE = self.NAMESPACE
|
||||
if "NAMESPACE" not in self.VECTOR_STORE.model_fields_set:
|
||||
self.VECTOR_STORE.NAMESPACE = self.NAMESPACE
|
||||
if "DIMENSIONS" not in self.VECTOR_STORE.model_fields_set:
|
||||
self.VECTOR_STORE.DIMENSIONS = self.EMBEDDING.VECTOR_DIMENSIONS
|
||||
elif self.VECTOR_STORE.DIMENSIONS != self.EMBEDDING.VECTOR_DIMENSIONS:
|
||||
raise ValueError(
|
||||
"VECTOR_STORE.DIMENSIONS must match EMBEDDING.VECTOR_DIMENSIONS"
|
||||
if "DIMENSIONS" in self.VECTOR_STORE.model_fields_set:
|
||||
# VECTOR_STORE_DIMENSIONS is deprecated: EMBEDDING_VECTOR_DIMENSIONS
|
||||
# is the single source of truth. Log a runtime-visible warning
|
||||
# so operators see it (DeprecationWarning is filtered by Python's
|
||||
# default config outside __main__/tests) and also raise the stdlib
|
||||
# warning so tests can assert on it.
|
||||
import warnings
|
||||
|
||||
message = (
|
||||
"VECTOR_STORE_DIMENSIONS is deprecated; "
|
||||
"EMBEDDING_VECTOR_DIMENSIONS is authoritative. "
|
||||
"Drop VECTOR_STORE_DIMENSIONS from your .env."
|
||||
)
|
||||
logger.warning(message)
|
||||
warnings.warn(message, DeprecationWarning, stacklevel=2)
|
||||
self.VECTOR_STORE.DIMENSIONS = self.EMBEDDING.VECTOR_DIMENSIONS
|
||||
if "NAMESPACE" not in self.TELEMETRY.model_fields_set:
|
||||
self.TELEMETRY.NAMESPACE = self.NAMESPACE
|
||||
if "NAMESPACE" not in self.METRICS.model_fields_set:
|
||||
self.METRICS.NAMESPACE = self.NAMESPACE
|
||||
|
||||
if self.EMBEDDING.VECTOR_DIMENSIONS != 1536 and (
|
||||
self.VECTOR_STORE.TYPE == "pgvector" or not self.VECTOR_STORE.MIGRATED
|
||||
):
|
||||
raise ValueError(
|
||||
"EMBEDDING.VECTOR_DIMENSIONS must remain 1536 while pgvector is "
|
||||
+ "active or vector-store migration is incomplete"
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import uvloop
|
|||
from prometheus_client import start_http_server
|
||||
|
||||
from src.config import settings
|
||||
from src.db import engine
|
||||
from src.startup import validate_embedding_schema
|
||||
from src.telemetry import initialize_telemetry_async, shutdown_telemetry
|
||||
|
||||
from .queue_manager import main
|
||||
|
|
@ -56,6 +58,11 @@ async def run_deriver():
|
|||
"""Run the deriver with proper telemetry lifecycle management."""
|
||||
# Initialize async telemetry (CloudEvents emitter)
|
||||
await initialize_telemetry_async()
|
||||
|
||||
# Fail fast if the embedding schema does not match settings — same gate
|
||||
# the API runs in its lifespan.
|
||||
await validate_embedding_schema(engine)
|
||||
|
||||
try:
|
||||
await main()
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from src.routers import (
|
|||
webhooks,
|
||||
workspaces,
|
||||
)
|
||||
from src.startup import validate_embedding_schema
|
||||
from src.telemetry import (
|
||||
initialize_telemetry_async,
|
||||
metrics_endpoint,
|
||||
|
|
@ -125,6 +126,12 @@ async def lifespan(_: FastAPI):
|
|||
# Initialize CloudEvents telemetry
|
||||
await initialize_telemetry_async()
|
||||
|
||||
# Validate embedding schema before serving any traffic. Fails closed: if
|
||||
# the configured EMBEDDING_VECTOR_DIMENSIONS does not match the physical
|
||||
# pgvector columns, the process refuses to start rather than silently
|
||||
# writing wrong-dim vectors.
|
||||
await validate_embedding_schema(engine)
|
||||
|
||||
try:
|
||||
await init_cache()
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
"""Startup-time validators that gate API/deriver boot."""
|
||||
|
||||
from src.startup.embedding_validator import (
|
||||
StartupValidationError,
|
||||
validate_embedding_schema,
|
||||
)
|
||||
|
||||
__all__ = ("StartupValidationError", "validate_embedding_schema")
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
"""Startup validator for the embedding pipeline.
|
||||
|
||||
Crashes the process at boot if the configured EMBEDDING_VECTOR_DIMENSIONS does
|
||||
not match the physical pgvector schema. Replaces an earlier config-time guard
|
||||
that forbade non-1536 dims unless the operator asserted a VECTOR_STORE.MIGRATED
|
||||
flag — the schema introspection here is more accurate because it inspects
|
||||
actual state instead of operator-asserted state.
|
||||
|
||||
For external stores (turbopuffer, lancedb) the check is best-effort: namespaces
|
||||
are per-workspace and lazy-created, so this validator can only sample existing
|
||||
ones. Full enumeration is available via `uv run python -m
|
||||
src.scripts.configure_embeddings --report` (Phase 3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from src.config import AppSettings, settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Embedding tables that must exist with matching dim.
|
||||
_EMBEDDING_TABLES: tuple[str, ...] = ("documents", "message_embeddings")
|
||||
|
||||
# Retry budget for transient introspection failures. Total wall time is
|
||||
# bounded so a sick DB does not hang readiness; fail-closed after exhaustion.
|
||||
_RETRY_ATTEMPTS = 3
|
||||
_RETRY_BACKOFF_SECONDS = 1.0
|
||||
|
||||
# Best-effort external sampler bounds.
|
||||
_EXTERNAL_SAMPLE_LIMIT = 10
|
||||
|
||||
|
||||
class StartupValidationError(RuntimeError):
|
||||
"""Raised when the embedding configuration cannot be reconciled with the
|
||||
physical schema. Always surfaced before any HTTP route is served or any
|
||||
queue task is processed.
|
||||
"""
|
||||
|
||||
|
||||
async def validate_embedding_schema(
|
||||
engine: AsyncEngine,
|
||||
*,
|
||||
app_settings: AppSettings | None = None,
|
||||
) -> None:
|
||||
"""Validate that the embedding schema matches the configured dimension.
|
||||
|
||||
Run after the DB pool is initialized and before the embedding client is
|
||||
constructed. Fails closed: any unrecoverable introspection error raises
|
||||
rather than letting the process serve traffic with an unknown state.
|
||||
"""
|
||||
s = app_settings if app_settings is not None else settings
|
||||
target_dim = s.EMBEDDING.VECTOR_DIMENSIONS
|
||||
schema = s.DB.SCHEMA
|
||||
|
||||
dims = await _introspect_pgvector_dims_with_retry(engine, schema)
|
||||
_assert_pgvector_dims_match(dims, schema=schema, target_dim=target_dim)
|
||||
|
||||
if s.VECTOR_STORE.TYPE in ("turbopuffer", "lancedb"):
|
||||
await _sample_external_namespaces(engine, target_dim=target_dim)
|
||||
|
||||
|
||||
async def _introspect_pgvector_dims_with_retry(
|
||||
engine: AsyncEngine, schema: str
|
||||
) -> dict[str, int]:
|
||||
"""Schema-qualified pg_attribute introspection with bounded retries.
|
||||
|
||||
Returns a mapping of table name -> raw ``atttypmod`` for the embedding
|
||||
columns. Fails closed on the last attempt — uncertainty is not a green
|
||||
light to serve traffic.
|
||||
"""
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(_RETRY_ATTEMPTS):
|
||||
try:
|
||||
return await _introspect_pgvector_dims_once(engine, schema)
|
||||
except SQLAlchemyError as e:
|
||||
last_exc = e
|
||||
remaining = _RETRY_ATTEMPTS - attempt - 1
|
||||
if remaining > 0:
|
||||
logger.warning(
|
||||
"Embedding schema introspection attempt %d/%d failed: %s",
|
||||
attempt + 1,
|
||||
_RETRY_ATTEMPTS,
|
||||
e,
|
||||
)
|
||||
await asyncio.sleep(_RETRY_BACKOFF_SECONDS)
|
||||
raise StartupValidationError(
|
||||
f"could not validate embedding schema: {last_exc}"
|
||||
) from last_exc
|
||||
|
||||
|
||||
async def _introspect_pgvector_dims_once(
|
||||
engine: AsyncEngine, schema: str
|
||||
) -> dict[str, int]:
|
||||
"""Single-shot schema-qualified pg_attribute lookup.
|
||||
|
||||
The join through ``pg_class``/``pg_namespace`` lets us respect
|
||||
``DB.SCHEMA`` rather than relying on the ambient search_path.
|
||||
"""
|
||||
query = text(
|
||||
"""
|
||||
SELECT c.relname AS table_name, a.atttypmod AS typmod
|
||||
FROM pg_attribute a
|
||||
JOIN pg_class c ON a.attrelid = c.oid
|
||||
JOIN pg_namespace n ON c.relnamespace = n.oid
|
||||
WHERE n.nspname = :schema
|
||||
AND c.relname = ANY(:tables)
|
||||
AND a.attname = 'embedding'
|
||||
"""
|
||||
)
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(
|
||||
query,
|
||||
{"schema": schema, "tables": list(_EMBEDDING_TABLES)},
|
||||
)
|
||||
return {row.table_name: row.typmod for row in result}
|
||||
|
||||
|
||||
def _assert_pgvector_dims_match(
|
||||
dims: dict[str, int], *, schema: str, target_dim: int
|
||||
) -> None:
|
||||
expected = set(_EMBEDDING_TABLES)
|
||||
missing = expected - dims.keys()
|
||||
if missing:
|
||||
listing = ", ".join(sorted(f"{schema}.{t}.embedding" for t in missing))
|
||||
raise StartupValidationError(
|
||||
f"Required vector columns missing: {listing}."
|
||||
+ " Run `alembic upgrade head` first."
|
||||
)
|
||||
for table in sorted(expected):
|
||||
atttypmod = dims[table]
|
||||
if atttypmod == -1:
|
||||
raise StartupValidationError(
|
||||
f"{schema}.{table}.embedding has no declared vector dimension"
|
||||
+ " (unbounded typmod). Run"
|
||||
+ " `uv run python -m src.scripts.configure_embeddings`."
|
||||
)
|
||||
# pgvector stores the declared dim directly in atttypmod (no VARHDRSZ).
|
||||
actual = atttypmod
|
||||
if actual != target_dim:
|
||||
raise StartupValidationError(
|
||||
f"{schema}.{table}.embedding dim ({actual}) does not match"
|
||||
+ f" EMBEDDING_VECTOR_DIMENSIONS ({target_dim}). Run"
|
||||
+ " `uv run python -m src.scripts.configure_embeddings`"
|
||||
+ " or fix EMBEDDING_VECTOR_DIMENSIONS."
|
||||
)
|
||||
|
||||
|
||||
async def _sample_external_namespaces(engine: AsyncEngine, *, target_dim: int) -> None:
|
||||
"""Best-effort dim check across existing external-store namespaces.
|
||||
|
||||
External stores in this codebase are per-workspace and lazy-created on
|
||||
first write (see ``src.vector_store.get_vector_namespace``), so there is
|
||||
no canonical deployment-wide namespace to introspect. We enumerate up to
|
||||
``_EXTERNAL_SAMPLE_LIMIT`` workspaces from the application DB and probe
|
||||
their derived namespaces. Missing namespaces are OK; mismatched dims
|
||||
crash startup. Run ``configure_embeddings --report`` for full
|
||||
enumeration when a hard guarantee is needed.
|
||||
"""
|
||||
workspace_names = await _sample_workspace_names(engine, _EXTERNAL_SAMPLE_LIMIT)
|
||||
if not workspace_names:
|
||||
logger.info(
|
||||
"External-store validator: no workspaces exist yet, skipping sample"
|
||||
)
|
||||
return
|
||||
|
||||
# Import lazily to avoid pulling in vector store deps when not configured.
|
||||
from src.vector_store import get_external_vector_store
|
||||
|
||||
store = get_external_vector_store()
|
||||
if store is None:
|
||||
# Settings said TYPE != pgvector but the store could not be created.
|
||||
# That is its own problem and not for this validator to swallow.
|
||||
return
|
||||
|
||||
mismatches: list[tuple[str, int]] = []
|
||||
for workspace_name in workspace_names:
|
||||
namespace = store.get_vector_namespace("message", workspace_name)
|
||||
actual_dim = await _probe_namespace_dim(store, namespace)
|
||||
if actual_dim is not None and actual_dim != target_dim:
|
||||
mismatches.append((namespace, actual_dim))
|
||||
|
||||
if mismatches:
|
||||
formatted = ", ".join(f"{ns} (dim={d})" for ns, d in mismatches)
|
||||
raise StartupValidationError(
|
||||
f"Existing external-store namespaces have dim != {target_dim}:"
|
||||
+ f" {formatted}. Run"
|
||||
+ " `uv run python -m src.scripts.configure_embeddings --report`."
|
||||
)
|
||||
|
||||
|
||||
async def _sample_workspace_names(engine: AsyncEngine, limit: int) -> list[str]:
|
||||
"""Pull up to ``limit`` workspace names ordered by creation time."""
|
||||
query = text("SELECT name FROM workspaces ORDER BY created_at DESC LIMIT :limit")
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(query, {"limit": limit})
|
||||
return [row.name for row in result]
|
||||
|
||||
|
||||
async def _probe_namespace_dim(store: object, namespace: str) -> int | None:
|
||||
"""Best-effort: return the namespace's declared dim if introspectable.
|
||||
|
||||
Returns ``None`` if the namespace does not exist or the SDK does not
|
||||
expose dim metadata for the configured store. Callers must treat
|
||||
``None`` as "do not flag a mismatch" — full enumeration is delegated to
|
||||
the ``configure_embeddings --report`` path.
|
||||
"""
|
||||
# The base ``VectorStore`` does not currently expose introspection of an
|
||||
# individual namespace's dim. Until a concrete probe is added per store,
|
||||
# treat all sampled namespaces as opaque. The pgvector check above is the
|
||||
# load-bearing safety; first-write per workspace inherits its correctness
|
||||
# from the embedding client honoring settings.EMBEDDING.VECTOR_DIMENSIONS.
|
||||
_ = (store, namespace)
|
||||
return None
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
|
|
@ -251,14 +250,14 @@ def test_app_settings_propagate_embedding_dimensions_to_vector_store() -> None:
|
|||
assert settings.VECTOR_STORE.DIMENSIONS == 2048
|
||||
|
||||
|
||||
def test_app_settings_require_matching_embedding_and_vector_store_dimensions() -> None:
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape(
|
||||
"VECTOR_STORE.DIMENSIONS must match EMBEDDING.VECTOR_DIMENSIONS"
|
||||
),
|
||||
):
|
||||
AppSettings(
|
||||
def test_app_settings_explicit_vector_store_dimensions_warns_and_overrides() -> None:
|
||||
"""VECTOR_STORE.DIMENSIONS is deprecated: EMBEDDING.VECTOR_DIMENSIONS wins
|
||||
and the operator gets a DeprecationWarning if they set it explicitly."""
|
||||
import warnings
|
||||
|
||||
with warnings.catch_warnings(record=True) as captured:
|
||||
warnings.simplefilter("always")
|
||||
settings = AppSettings(
|
||||
EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=2048),
|
||||
VECTOR_STORE=VectorStoreSettings(
|
||||
TYPE="lancedb",
|
||||
|
|
@ -266,28 +265,39 @@ def test_app_settings_require_matching_embedding_and_vector_store_dimensions() -
|
|||
DIMENSIONS=1536,
|
||||
),
|
||||
)
|
||||
messages = [
|
||||
str(w.message) for w in captured if issubclass(w.category, DeprecationWarning)
|
||||
]
|
||||
assert any(
|
||||
"VECTOR_STORE_DIMENSIONS is deprecated" in m for m in messages
|
||||
), f"expected deprecation warning, got {messages!r}"
|
||||
assert settings.EMBEDDING.VECTOR_DIMENSIONS == 2048
|
||||
assert settings.VECTOR_STORE.DIMENSIONS == 2048, (
|
||||
"EMBEDDING.VECTOR_DIMENSIONS should always overwrite the operator-supplied "
|
||||
"VECTOR_STORE.DIMENSIONS value"
|
||||
)
|
||||
|
||||
|
||||
def test_app_settings_reject_non_1536_dimensions_while_pgvector_or_dual_write_active() -> (
|
||||
None
|
||||
):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape("EMBEDDING.VECTOR_DIMENSIONS must remain 1536"),
|
||||
):
|
||||
AppSettings(
|
||||
EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=2048),
|
||||
VECTOR_STORE=VectorStoreSettings(TYPE="pgvector", MIGRATED=True),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape("EMBEDDING.VECTOR_DIMENSIONS must remain 1536"),
|
||||
):
|
||||
AppSettings(
|
||||
EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=2048),
|
||||
VECTOR_STORE=VectorStoreSettings(TYPE="lancedb", MIGRATED=False),
|
||||
def test_app_settings_accepts_non_1536_with_any_vector_store_configuration() -> None:
|
||||
"""The dim-vs-MIGRATED guard was removed; the runtime startup schema
|
||||
validator (src/startup/embedding_validator.py) is the new safety net.
|
||||
Construction must succeed for every combination at config time."""
|
||||
from typing import Literal
|
||||
|
||||
combos: list[tuple[Literal["pgvector", "turbopuffer", "lancedb"], bool]] = [
|
||||
("pgvector", True),
|
||||
("pgvector", False),
|
||||
("lancedb", True),
|
||||
("lancedb", False),
|
||||
]
|
||||
for store_type, migrated in combos:
|
||||
settings = AppSettings(
|
||||
EMBEDDING=EmbeddingSettings(VECTOR_DIMENSIONS=768),
|
||||
VECTOR_STORE=VectorStoreSettings(TYPE=store_type, MIGRATED=migrated),
|
||||
)
|
||||
assert settings.EMBEDDING.VECTOR_DIMENSIONS == 768
|
||||
assert store_type == settings.VECTOR_STORE.TYPE
|
||||
assert settings.VECTOR_STORE.MIGRATED is migrated
|
||||
|
||||
|
||||
def test_config_toml_example_uses_nested_model_config_sections() -> None:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
"""Phase 2: startup embedding-schema validator + VECTOR_STORE_DIMENSIONS deprecation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from src.startup.embedding_validator import (
|
||||
StartupValidationError,
|
||||
_assert_pgvector_dims_match, # pyright: ignore[reportPrivateUsage]
|
||||
validate_embedding_schema,
|
||||
)
|
||||
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
# pgvector stores the declared dim directly in atttypmod (no VARHDRSZ offset).
|
||||
def _typmod(dim: int) -> int:
|
||||
return dim
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure-function unit tests for the dim assertion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_assert_pgvector_dims_match_passes_when_all_dims_align() -> None:
|
||||
_assert_pgvector_dims_match(
|
||||
{"documents": _typmod(1536), "message_embeddings": _typmod(1536)},
|
||||
schema="public",
|
||||
target_dim=1536,
|
||||
)
|
||||
|
||||
|
||||
def test_assert_pgvector_dims_match_raises_on_dim_mismatch() -> None:
|
||||
with pytest.raises(StartupValidationError, match="dim .* does not match"):
|
||||
_assert_pgvector_dims_match(
|
||||
{"documents": _typmod(1536), "message_embeddings": _typmod(768)},
|
||||
schema="public",
|
||||
target_dim=1536,
|
||||
)
|
||||
|
||||
|
||||
def test_assert_pgvector_dims_match_lists_all_missing_columns() -> None:
|
||||
with pytest.raises(StartupValidationError) as excinfo:
|
||||
_assert_pgvector_dims_match(
|
||||
{"documents": _typmod(1536)},
|
||||
schema="public",
|
||||
target_dim=1536,
|
||||
)
|
||||
msg = str(excinfo.value)
|
||||
assert "message_embeddings" in msg
|
||||
assert "alembic upgrade head" in msg
|
||||
|
||||
|
||||
def test_assert_pgvector_dims_match_raises_on_unbounded_typmod() -> None:
|
||||
with pytest.raises(StartupValidationError, match="unbounded typmod"):
|
||||
_assert_pgvector_dims_match(
|
||||
{"documents": -1, "message_embeddings": _typmod(1536)},
|
||||
schema="public",
|
||||
target_dim=1536,
|
||||
)
|
||||
|
||||
|
||||
def test_assert_pgvector_dims_match_respects_non_public_schema() -> None:
|
||||
with pytest.raises(StartupValidationError, match="my_schema.documents"):
|
||||
_assert_pgvector_dims_match(
|
||||
{},
|
||||
schema="my_schema",
|
||||
target_dim=1536,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fail-closed retry behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validator_fails_closed_when_introspection_keeps_failing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""After the retry budget exhausts, the validator crashes — uncertainty
|
||||
is not a green light to serve traffic."""
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def always_raise(_engine: AsyncEngine, _schema: str) -> dict[str, int]:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise OperationalError("SELECT 1", {}, Exception("DB unreachable"))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.startup.embedding_validator._introspect_pgvector_dims_once",
|
||||
always_raise,
|
||||
)
|
||||
# Make backoff effectively instant for the test.
|
||||
monkeypatch.setattr("src.startup.embedding_validator._RETRY_BACKOFF_SECONDS", 0.0)
|
||||
|
||||
with pytest.raises(StartupValidationError, match="could not validate"):
|
||||
await validate_embedding_schema(engine=AsyncMock())
|
||||
|
||||
assert call_count == 3, "should exhaust the retry budget before failing"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: real test DB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validator_passes_against_test_database(
|
||||
db_engine: AsyncEngine,
|
||||
) -> None:
|
||||
"""The test DB is provisioned at the default dim (1536); the validator
|
||||
should accept it without raising."""
|
||||
await validate_embedding_schema(db_engine)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validator_raises_when_schema_dim_diverges_from_settings(
|
||||
db_engine: AsyncEngine,
|
||||
) -> None:
|
||||
"""ALTER one of the embedding columns to a non-1536 dim and confirm the
|
||||
validator raises with an actionable message."""
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE documents"
|
||||
+ " ALTER COLUMN embedding TYPE vector(768) USING NULL"
|
||||
)
|
||||
)
|
||||
try:
|
||||
with pytest.raises(StartupValidationError, match="dim .* does not match"):
|
||||
await validate_embedding_schema(db_engine)
|
||||
finally:
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE documents"
|
||||
+ " ALTER COLUMN embedding TYPE vector(1536) USING NULL"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VECTOR_STORE_DIMENSIONS deprecation + dim-vs-MIGRATED guard removal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_vector_store_dimensions_explicit_set_warns(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Setting VECTOR_STORE_DIMENSIONS explicitly should trigger a deprecation
|
||||
warning. EMBEDDING_VECTOR_DIMENSIONS remains authoritative."""
|
||||
monkeypatch.setenv("PYTHON_DOTENV_DISABLED", "1")
|
||||
monkeypatch.setenv("VECTOR_STORE_DIMENSIONS", "1536")
|
||||
with warnings.catch_warnings(record=True) as captured:
|
||||
warnings.simplefilter("always")
|
||||
from src.config import AppSettings
|
||||
|
||||
AppSettings()
|
||||
messages = [
|
||||
str(w.message) for w in captured if issubclass(w.category, DeprecationWarning)
|
||||
]
|
||||
assert any(
|
||||
"VECTOR_STORE_DIMENSIONS is deprecated" in m for m in messages
|
||||
), f"expected deprecation warning, got {messages!r}"
|
||||
|
||||
|
||||
def test_non_1536_pgvector_without_migrated_no_longer_raises_at_config_time() -> None:
|
||||
"""Phase 2 removed the dim-vs-MIGRATED guard. Constructing AppSettings
|
||||
with non-1536 + default pgvector + MIGRATED=false should now succeed
|
||||
(the runtime schema validator at startup is the new safety net)."""
|
||||
env = {
|
||||
**os.environ,
|
||||
"PYTHON_DOTENV_DISABLED": "1",
|
||||
"EMBEDDING_VECTOR_DIMENSIONS": "768",
|
||||
}
|
||||
# Use a subprocess so the global settings singleton in this test
|
||||
# process is not perturbed and is re-evaluated freshly in the child.
|
||||
snippet = (
|
||||
"from src.config import AppSettings\n"
|
||||
"s = AppSettings()\n"
|
||||
"print(s.EMBEDDING.VECTOR_DIMENSIONS, s.VECTOR_STORE.TYPE, s.VECTOR_STORE.MIGRATED)\n"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", snippet],
|
||||
env=env,
|
||||
cwd=str(_PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
last_line = result.stdout.strip().splitlines()[-1]
|
||||
assert last_line == "768 pgvector False"
|
||||
|
|
@ -54,16 +54,5 @@ def test_models_uses_default_1536_when_no_env_override() -> None:
|
|||
|
||||
|
||||
def test_models_honors_explicit_embedding_vector_dimensions() -> None:
|
||||
# Phase 1 still respects the dim-vs-MIGRATED guard at src/config.py:1278.
|
||||
# Set VECTOR_STORE_TYPE=lancedb + VECTOR_STORE_MIGRATED=true to satisfy it.
|
||||
# lancedb is chosen over turbopuffer because turbopuffer requires an
|
||||
# additional VECTOR_STORE_TURBOPUFFER_API_KEY env var. Phase 2 deletes
|
||||
# the guard and these escape-hatch envs become unnecessary.
|
||||
dims = _run_in_fresh_interpreter(
|
||||
{
|
||||
"EMBEDDING_VECTOR_DIMENSIONS": "768",
|
||||
"VECTOR_STORE_TYPE": "lancedb",
|
||||
"VECTOR_STORE_MIGRATED": "true",
|
||||
}
|
||||
)
|
||||
dims = _run_in_fresh_interpreter({"EMBEDDING_VECTOR_DIMENSIONS": "768"})
|
||||
assert dims == {"message_embedding_dim": 768, "document_dim": 768}
|
||||
|
|
|
|||
Loading…
Reference in New Issue