mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
901b5c696a
* fix(api): install every PostgreSQL extension in the public schema (#4118) CREATE EXTENSION installs into the first schema on the session search_path. During migrations that path starts with the tenant schema, so in schema mode `CREATE EXTENSION pg_trgm` landed pg_trgm inside the tenant schema. The runtime connects with the default search_path and fully-qualifies its tables, so it never saw it: entity resolution failed with `operator does not exist: text % text` on every retain, retried forever, and nothing surfaced to the caller — `/health` stayed green and retain still returned `accepted`. All extension creation now goes through `_pg_extensions.create_extension`, which pins the install schema to `public` and restores the caller's search_path — pg_trgm, pgvector, vectorscale, vchord, alloydb_scann, pgroonga, vchord_bm25, pg_textsearch and pg_search alike. Extensions that pin their own schema in their control file (vchord_bm25 -> bm25_catalog) are unaffected; PostgreSQL ignores the search_path for those and the runtime already puts those schemas on its path. Startup also repairs existing databases: `ensure_extensions_in_public` moves any managed, relocatable extension found outside `public` with ALTER EXTENSION SET SCHEMA. That replaces pgvector's old DROP EXTENSION CASCADE + CREATE relocation, which would have taken every embedding column with it. A guard test fails if any module builds CREATE EXTENSION SQL directly, so the rule holds for extensions added later. * refactor(api): sweep misplaced extensions in one catalog query
192 lines
6.6 KiB
Python
192 lines
6.6 KiB
Python
"""Every PostgreSQL extension Hindsight installs must land in the public schema.
|
|
|
|
Regression cover for #4118: in schema mode the migration search_path starts with
|
|
the tenant schema, so a bare ``CREATE EXTENSION pg_trgm`` installed pg_trgm
|
|
there. The runtime connects with the default search_path and fully-qualifies its
|
|
tables, so it could not resolve ``%`` and every retain failed forever, silently.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hindsight_api._pg_extensions import (
|
|
MANAGED_EXTENSIONS,
|
|
create_extension,
|
|
ensure_extensions_in_public,
|
|
extension_schema,
|
|
relocate_extension_to_public,
|
|
)
|
|
from hindsight_api._vector_index import bootstrap_extension
|
|
from hindsight_api.migrations import _bootstrap_vector_extension_for_migrations
|
|
from tests.pg_extension_fakes import FakePgConnection
|
|
|
|
TENANT_SEARCH_PATH = '"hindsight", public'
|
|
|
|
|
|
def test_create_extension_pins_install_schema_to_public():
|
|
conn = FakePgConnection(search_path=TENANT_SEARCH_PATH)
|
|
|
|
create_extension(conn, "pg_trgm")
|
|
|
|
assert conn.extensions["pg_trgm"][0] == "public"
|
|
assert conn.statements[0].startswith("SELECT current_setting('search_path')")
|
|
assert conn.params[1] == {"schema": "public"}
|
|
assert conn.statements[2] == "CREATE EXTENSION IF NOT EXISTS pg_trgm"
|
|
|
|
|
|
def test_create_extension_restores_the_callers_search_path():
|
|
conn = FakePgConnection(search_path=TENANT_SEARCH_PATH)
|
|
|
|
create_extension(conn, "pg_trgm")
|
|
|
|
assert conn.search_path == TENANT_SEARCH_PATH
|
|
assert conn.params[-1] == {"previous": TENANT_SEARCH_PATH}
|
|
|
|
|
|
def test_create_extension_appends_cascade_only_when_asked():
|
|
conn = FakePgConnection()
|
|
|
|
create_extension(conn, "pgroonga", cascade=True)
|
|
|
|
assert "CREATE EXTENSION IF NOT EXISTS pgroonga CASCADE" in conn.statements
|
|
|
|
|
|
def test_create_extension_propagates_failures_without_masking_them():
|
|
conn = FakePgConnection(search_path=TENANT_SEARCH_PATH, fail_on="CREATE EXTENSION")
|
|
|
|
with pytest.raises(RuntimeError, match="simulated failure"):
|
|
create_extension(conn, "pg_trgm")
|
|
|
|
|
|
def test_create_extension_rejects_names_that_are_not_identifiers():
|
|
conn = FakePgConnection()
|
|
|
|
with pytest.raises(ValueError, match="Invalid PostgreSQL extension name"):
|
|
create_extension(conn, 'pg_trgm"; DROP TABLE banks; --')
|
|
|
|
assert conn.statements == []
|
|
|
|
|
|
def test_extension_schema_reports_where_an_extension_lives():
|
|
conn = FakePgConnection(extensions={"vector": ("public", True)})
|
|
|
|
assert extension_schema(conn, "vector") == "public"
|
|
assert extension_schema(conn, "pg_trgm") is None
|
|
|
|
|
|
def test_relocate_moves_a_misplaced_relocatable_extension_into_public():
|
|
conn = FakePgConnection(extensions={"pg_trgm": ("hindsight", True)})
|
|
|
|
assert relocate_extension_to_public(conn, "pg_trgm") is True
|
|
assert conn.extensions["pg_trgm"][0] == "public"
|
|
assert 'ALTER EXTENSION pg_trgm SET SCHEMA "public"' in conn.statements
|
|
|
|
|
|
def test_relocate_is_a_noop_when_already_public_or_absent():
|
|
conn = FakePgConnection(extensions={"vector": ("public", True)})
|
|
|
|
assert relocate_extension_to_public(conn, "vector") is False
|
|
assert relocate_extension_to_public(conn, "pg_trgm") is False
|
|
assert not any("ALTER EXTENSION" in s for s in conn.statements)
|
|
|
|
|
|
def test_relocate_leaves_non_relocatable_extensions_alone():
|
|
# vchord_bm25 pins bm25_catalog in its control file; PostgreSQL rejects the
|
|
# move, and the runtime puts that schema on its search_path instead.
|
|
conn = FakePgConnection(extensions={"vchord_bm25": ("bm25_catalog", False)})
|
|
|
|
assert relocate_extension_to_public(conn, "vchord_bm25") is False
|
|
assert not any("ALTER EXTENSION" in s for s in conn.statements)
|
|
|
|
|
|
def test_relocate_survives_a_permission_denied_alter():
|
|
conn = FakePgConnection(extensions={"pg_trgm": ("hindsight", True)}, fail_on="ALTER EXTENSION")
|
|
|
|
assert relocate_extension_to_public(conn, "pg_trgm") is False
|
|
assert conn.rollbacks == 1
|
|
|
|
|
|
def test_ensure_extensions_in_public_repairs_every_managed_extension():
|
|
conn = FakePgConnection(
|
|
extensions={
|
|
"vector": ("hindsight", True),
|
|
"pg_trgm": ("hindsight", True),
|
|
"pgroonga": ("public", True),
|
|
}
|
|
)
|
|
|
|
ensure_extensions_in_public(conn)
|
|
|
|
assert conn.extensions["vector"][0] == "public"
|
|
assert conn.extensions["pg_trgm"][0] == "public"
|
|
assert conn.extensions["pgroonga"][0] == "public"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("backend", "expected"),
|
|
[
|
|
("pgvector", ["vector"]),
|
|
("pgvectorscale", ["vector", "vectorscale"]),
|
|
("vchord", ["vchord"]),
|
|
("scann", ["vector", "alloydb_scann"]),
|
|
],
|
|
)
|
|
def test_vector_backends_install_into_public_in_schema_mode(backend, expected):
|
|
conn = FakePgConnection(search_path=TENANT_SEARCH_PATH)
|
|
|
|
bootstrap_extension(conn, backend)
|
|
|
|
assert conn.created_extensions() == expected
|
|
assert all(conn.extensions[name][0] == "public" for name in expected)
|
|
|
|
|
|
def test_migration_bootstrap_relocates_a_tenant_schema_pg_trgm():
|
|
# The #4118 shape: an existing deployment whose pg_trgm was created inside
|
|
# the tenant schema by an older version. Startup must repair it.
|
|
conn = FakePgConnection(
|
|
search_path=TENANT_SEARCH_PATH,
|
|
extensions={"vector": ("public", True), "pg_trgm": ("hindsight", True)},
|
|
)
|
|
|
|
_bootstrap_vector_extension_for_migrations(conn, "pgvector")
|
|
|
|
assert conn.extensions["pg_trgm"][0] == "public"
|
|
|
|
|
|
def test_managed_extensions_covers_every_extension_the_code_installs():
|
|
source = _api_sources()
|
|
for name in _extension_names_in_source(source):
|
|
assert name in MANAGED_EXTENSIONS, f"{name} is created but not listed in MANAGED_EXTENSIONS"
|
|
|
|
|
|
def test_no_raw_create_extension_outside_the_helper():
|
|
"""All extension creation goes through create_extension(), so the rule holds everywhere."""
|
|
offenders = [
|
|
path
|
|
for path, source in _api_sources().items()
|
|
if path.name != "_pg_extensions.py" and "CREATE EXTENSION IF NOT EXISTS" in source
|
|
]
|
|
|
|
assert offenders == [], (
|
|
"These modules build CREATE EXTENSION SQL directly; use "
|
|
"hindsight_api._pg_extensions.create_extension so the extension lands in public: "
|
|
f"{[str(p) for p in offenders]}"
|
|
)
|
|
|
|
|
|
def _api_sources() -> dict[Path, str]:
|
|
root = Path(__file__).resolve().parent.parent / "hindsight_api"
|
|
return {path: path.read_text() for path in root.rglob("*.py")}
|
|
|
|
|
|
def _extension_names_in_source(sources: dict[Path, str]) -> set[str]:
|
|
import re
|
|
|
|
names: set[str] = set()
|
|
for path, source in sources.items():
|
|
if path.name == "_pg_extensions.py":
|
|
continue
|
|
names.update(re.findall(r'create_extension\(\s*[^,]+,\s*"(\w+)"', source))
|
|
return names
|