diff --git a/hindsight-api-slim/hindsight_api/engine/db/postgresql.py b/hindsight-api-slim/hindsight_api/engine/db/postgresql.py index d5c29f64b5..347a2e3592 100644 --- a/hindsight-api-slim/hindsight_api/engine/db/postgresql.py +++ b/hindsight-api-slim/hindsight_api/engine/db/postgresql.py @@ -72,15 +72,22 @@ async def apply_session_settings(conn: asyncpg.Connection, settings: list[tuple[ for name, value in settings: try: await conn.execute("SELECT set_config($1, $2, false)", name, value) - except asyncpg.exceptions.UndefinedObjectError: + except (asyncpg.exceptions.UndefinedObjectError, asyncpg.exceptions.InvalidNameError): # The server does not define this GUC — an extension we tune for is absent - # or predates it (hnsw.iterative_scan needs pgvector 0.8+, and pgvector - # reserves the "hnsw." prefix, so an older one rejects it rather than - # accepting a placeholder). Remember it: otherwise every acquire from here - # on re-pays a failed batch plus one statement per setting, which behind a - # transaction-mode pooler is a server-side transaction each — the burn - # #3499 removed. Narrow to UndefinedObjectError so a transient failure - # does not disable a setting the server does support. + # or predates it (hnsw.iterative_scan needs pgvector 0.8+). Which error + # that raises depends on the extension: a plain unknown name is 42704 + # UndefinedObjectError ("unrecognized configuration parameter"), while a + # name under a prefix the loaded extension has reserved is 42602 + # InvalidNameError ("invalid configuration parameter name") — pgvector + # 0.6.0 answers the latter for hnsw.iterative_scan. Both verdicts are + # permanent for this server, so remember either: otherwise every acquire + # from here on re-pays a failed batch plus one statement per setting, + # which behind a transaction-mode pooler is a server-side transaction + # each — the burn #3499 removed — and, worse, setting_rejected_by_server + # keeps answering False, so retain's link probing SET LOCALs the GUC + # inside its own transaction and aborts the whole link computation. + # Still narrow (no bare PostgresError) so a transient failure does not + # disable a setting the server does support. logger.info("Server does not know %s — not sending it again on this process", name) _unsupported_settings.add(name) except asyncpg.exceptions.PostgresError: diff --git a/hindsight-api-slim/tests/test_db_abstraction.py b/hindsight-api-slim/tests/test_db_abstraction.py index 54541388c9..17d2feae13 100644 --- a/hindsight-api-slim/tests/test_db_abstraction.py +++ b/hindsight-api-slim/tests/test_db_abstraction.py @@ -645,17 +645,23 @@ async def cb(conn): class _RecordingConnection: """Captures every statement, optionally failing the first (batched) one.""" - def __init__(self, fail_batched: bool = False, reject: str | None = None) -> None: + def __init__( + self, + fail_batched: bool = False, + reject: str | None = None, + reject_error: type[Exception] = asyncpg.exceptions.UndefinedObjectError, + ) -> None: self.calls: list[tuple[str, tuple]] = [] self._fail_batched = fail_batched self._reject = reject + self._reject_error = reject_error async def execute(self, query: str, *args) -> None: self.calls.append((query, args)) if self._fail_batched and len(self.calls) == 1: - raise asyncpg.exceptions.UndefinedObjectError("unrecognized configuration parameter") + raise self._reject_error("unrecognized configuration parameter") if self._reject is not None and self._reject in args: - raise asyncpg.exceptions.UndefinedObjectError("unrecognized configuration parameter") + raise self._reject_error("unrecognized configuration parameter") class TestApplySessionSettings: @@ -727,6 +733,36 @@ async def test_a_setting_the_server_rejects_is_not_sent_again(self): assert "pg_trgm.similarity_threshold" not in args assert args == ("hnsw.ef_search", "200", "statement_timeout", "600s") + @pytest.mark.asyncio + async def test_invalid_name_rejection_is_remembered_like_undefined_object(self): + """A reserved-prefix GUC rejection (InvalidNameError) must also be permanent. + + The docstring above assumes an old server answers "unrecognized configuration + parameter" (UndefinedObjectError, 42704). PG16 + pgvector 0.6.0 answers + `invalid configuration parameter name "hnsw.iterative_scan"` instead — + InvalidNameError (42602), because the loaded extension reserves the "hnsw." + prefix but predates the GUC. If only UndefinedObjectError is remembered, + the name never reaches the unsupported-set, ``setting_rejected_by_server`` + keeps answering False, and retain's link probing sends the GUC via SET LOCAL + inside its own transaction — aborting the whole link computation on every + retain (observed in production on PG16 + pgvector 0.6.0, 2026-08-26). + """ + conn = _RecordingConnection( + fail_batched=True, + reject="hnsw.ef_search", + reject_error=asyncpg.exceptions.InvalidNameError, + ) + await apply_session_settings(conn, self._SETTINGS) + + assert pg_backend.setting_rejected_by_server("hnsw.ef_search") + + # Next acquire must not re-send the rejected name. + conn = _RecordingConnection() + await apply_session_settings(conn, self._SETTINGS) + assert len(conn.calls) == 1 + _, args = conn.calls[0] + assert "hnsw.ef_search" not in args + @pytest.mark.asyncio async def test_a_transient_failure_does_not_disable_a_setting(self): """Only "unrecognized configuration parameter" is permanent; anything else retries."""