litellm/tests/proxy_migration_tests/test_replica_identity_full.py
Yassin Kortam 87c2e03af8
feat(db): opt-in REPLICA IDENTITY FULL after prisma migrations (#35267)
Logical replication consumers need FULL replica identity to reconstruct the
old row of an UPDATE or DELETE, and prisma leaves every table it creates at
the postgres default. Operators had to re-apply the setting by hand after
each migration run.

Setting LITELLM_SET_REPLICA_IDENTITY_FULL now re-asserts it on every LiteLLM
table at the end of a successful migration run, through the prisma CLI so the
dependency-free proxy-extras package stays that way. Tables that are already
FULL are skipped, foreign tables in the same schema are left alone, and a
database that refuses the ALTER is reported rather than failing the run.

Resolves LIT-3022
2026-07-30 15:45:40 -07:00

159 lines
5.6 KiB
Python

"""Coverage for the opt-in REPLICA IDENTITY FULL post-migration step.
The DB-backed tests run against the same Postgres the migration suite uses, in
a throwaway schema so they cannot disturb the migrated tables.
"""
import os
import uuid
import pytest
from litellm_proxy_extras.replica_identity import (
REPLICA_IDENTITY_FULL_ENV_VAR,
apply_replica_identity_full,
)
from litellm_proxy_extras.utils import ProxyExtrasDBManager
psycopg = pytest.importorskip("psycopg")
requires_db = pytest.mark.skipif(
"DATABASE_URL" not in os.environ,
reason="requires a postgres database (DATABASE_URL)",
)
def _base_url() -> str:
return os.environ["DATABASE_URL"].split("?")[0]
def _replica_identities(schema: str) -> dict:
with psycopg.connect(_base_url(), autocommit=True) as conn:
rows = conn.execute(
"SELECT c.relname, c.relreplident FROM pg_class c "
"JOIN pg_namespace n ON n.oid = c.relnamespace "
"WHERE n.nspname = %s AND c.relkind = 'r'",
(schema,),
).fetchall()
return dict(rows)
@pytest.fixture
def scratch_schema(monkeypatch):
"""A schema holding two LiteLLM tables and one foreign table, all at the default."""
schema = f"replica_identity_{uuid.uuid4().hex[:8]}"
with psycopg.connect(_base_url(), autocommit=True) as conn:
conn.execute(f'CREATE SCHEMA "{schema}"')
conn.execute(
f'CREATE TABLE "{schema}"."LiteLLM_ScratchTable" (id TEXT PRIMARY KEY, note TEXT)'
)
conn.execute(f'CREATE TABLE "{schema}"."LiteLLM_ScratchSibling" (id TEXT PRIMARY KEY)')
conn.execute(f'CREATE TABLE "{schema}"."ScratchForeignTable" (id TEXT PRIMARY KEY)')
monkeypatch.setenv("DATABASE_URL", f"{_base_url()}?schema={schema}")
yield schema
with psycopg.connect(_base_url(), autocommit=True) as conn:
conn.execute(f'DROP SCHEMA "{schema}" CASCADE')
@requires_db
def test_applies_full_to_litellm_tables_only(scratch_schema, monkeypatch):
monkeypatch.setenv(REPLICA_IDENTITY_FULL_ENV_VAR, "true")
assert ProxyExtrasDBManager.apply_replica_identity_full_if_requested() is True
identities = _replica_identities(scratch_schema)
assert identities["LiteLLM_ScratchTable"] == "f"
assert identities["LiteLLM_ScratchSibling"] == "f"
assert identities["ScratchForeignTable"] == "d"
@requires_db
def test_a_locked_table_does_not_block_the_others(scratch_schema, monkeypatch):
"""ALTER TABLE needs an exclusive lock, so a table busy with a long read has
to be skipped for the next run instead of stalling every other table behind it."""
monkeypatch.setenv(REPLICA_IDENTITY_FULL_ENV_VAR, "true")
with psycopg.connect(_base_url()) as holder:
holder.execute(f'SELECT * FROM "{scratch_schema}"."LiteLLM_ScratchTable"')
assert ProxyExtrasDBManager.apply_replica_identity_full_if_requested() is True
identities = _replica_identities(scratch_schema)
assert identities["LiteLLM_ScratchTable"] == "d"
assert identities["LiteLLM_ScratchSibling"] == "f"
@requires_db
def test_leaves_tables_alone_when_not_requested(scratch_schema, monkeypatch):
monkeypatch.delenv(REPLICA_IDENTITY_FULL_ENV_VAR, raising=False)
assert ProxyExtrasDBManager.apply_replica_identity_full_if_requested() is False
assert _replica_identities(scratch_schema)["LiteLLM_ScratchTable"] == "d"
@requires_db
def test_is_idempotent_across_runs(scratch_schema, monkeypatch):
monkeypatch.setenv(REPLICA_IDENTITY_FULL_ENV_VAR, "true")
assert ProxyExtrasDBManager.apply_replica_identity_full_if_requested() is True
assert ProxyExtrasDBManager.apply_replica_identity_full_if_requested() is True
assert _replica_identities(scratch_schema)["LiteLLM_ScratchTable"] == "f"
@requires_db
def test_reports_failure_without_raising(scratch_schema, monkeypatch):
"""A run that cannot execute the statement must not take the migration down."""
monkeypatch.setenv(REPLICA_IDENTITY_FULL_ENV_VAR, "true")
monkeypatch.setattr(
ProxyExtrasDBManager,
"_get_prisma_dir",
staticmethod(lambda: "/nonexistent/prisma/dir"),
)
assert ProxyExtrasDBManager.apply_replica_identity_full_if_requested() is False
assert _replica_identities(scratch_schema)["LiteLLM_ScratchTable"] == "d"
def test_reports_an_unrunnable_prisma_cli_without_raising(tmp_path):
"""A deployment without the Prisma CLI on PATH must still finish its
migration run instead of dying on the optional replication step."""
assert (
apply_replica_identity_full(
schema_path=str(tmp_path / "schema.prisma"),
prisma_command=str(tmp_path / "no-such-prisma"),
prisma_env={},
)
is False
)
def test_setup_database_applies_after_a_successful_migration_run(monkeypatch):
applied = []
monkeypatch.setattr(
ProxyExtrasDBManager, "_run_migrations", staticmethod(lambda **kwargs: True)
)
monkeypatch.setattr(
ProxyExtrasDBManager,
"apply_replica_identity_full_if_requested",
staticmethod(lambda: applied.append(True)),
)
assert ProxyExtrasDBManager.setup_database(use_migrate=True) is True
assert applied == [True]
def test_setup_database_skips_replica_identity_when_migrations_fail(monkeypatch):
applied = []
monkeypatch.setattr(
ProxyExtrasDBManager, "_run_migrations", staticmethod(lambda **kwargs: False)
)
monkeypatch.setattr(
ProxyExtrasDBManager,
"apply_replica_identity_full_if_requested",
staticmethod(lambda: applied.append(True)),
)
assert ProxyExtrasDBManager.setup_database(use_migrate=True) is False
assert applied == []