Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions hindsight-api-slim/hindsight_api/engine/db/postgresql.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
42 changes: 39 additions & 3 deletions hindsight-api-slim/tests/test_db_abstraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down