fix(proxy): coordinate v2 migration startup and add container regression CI

This commit is contained in:
Yuneng Jiang 2026-09-12 18:24:59 -07:00
parent d565031b9c
commit 94032014df
No known key found for this signature in database
21 changed files with 1909 additions and 190 deletions

View file

@ -1,10 +1,32 @@
version: 2.1
parameters:
run_migration_tests:
type: boolean
default: false
migration_candidate_image:
type: string
default: ""
migration_source_sha:
type: string
default: ""
orbs:
codecov: codecov/codecov@4.0.1
node: circleci/node@5.1.0 # Add this line to declare the node orb
win: circleci/windows@5.0 # Add Windows orb
commands:
checkout_migration_source:
steps:
- run:
name: Select the requested migration test revision
environment:
MIGRATION_SOURCE_SHA: << pipeline.parameters.migration_source_sha >>
command: |
if [ -n "$MIGRATION_SOURCE_SHA" ]; then
[[ "$MIGRATION_SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]] || exit 1
git fetch origin "$MIGRATION_SOURCE_SHA"
git checkout --detach "$MIGRATION_SOURCE_SHA"
fi
skip_if_unrelated_changes:
parameters:
category:
@ -2853,14 +2875,25 @@ jobs:
working_directory: ~/project
steps:
- checkout
- checkout_migration_source
- skip_if_unrelated_changes
- run:
name: Build Docker image
environment:
MIGRATION_CANDIDATE_IMAGE: << pipeline.parameters.migration_candidate_image >>
command: |
docker build \
-t litellm-docker-database:ci \
-f docker/Dockerfile.database .
if [ -n "$MIGRATION_CANDIDATE_IMAGE" ]; then
[[ "$MIGRATION_CANDIDATE_IMAGE" =~ ^ghcr.io/berriai/[a-z0-9._/-]+@sha256:[0-9a-f]{64}$ ]] || exit 1
docker pull "$MIGRATION_CANDIDATE_IMAGE"
docker tag "$MIGRATION_CANDIDATE_IMAGE" litellm-docker-database:ci
else
docker build \
--label org.opencontainers.image.revision="$(git rev-parse HEAD)" \
-t litellm-docker-database:ci \
-f docker/Dockerfile.database .
fi
python3 .circleci/scripts/run_migration_tests.py record-image
- run:
name: Save Docker image to workspace root
@ -2871,6 +2904,79 @@ jobs:
root: .
paths:
- litellm-docker-database.tar.zst
- migration-image.json
migration_startup_tests:
parameters:
suite:
type: enum
enum: [startup, recovery, legacy]
machine:
image: ubuntu-2204:2024.04.1
resource_class: large
working_directory: ~/project
environment:
LITELLM_MIGRATION_TESTS: "1"
LITELLM_MIGRATION_TEST_IMAGE: litellm-docker-database:ci
MIGRATION_TEST_ADMIN_URL: postgresql://postgres:postgres@127.0.0.1:5432/postgres
MIGRATION_TEST_CONTAINER_ADMIN_URL: postgresql://postgres:postgres@host.docker.internal:5432/postgres
MIGRATION_TEST_OUTPUT: /tmp/migration-results
PYTHONPATH: tests/e2e
steps:
- checkout
- checkout_migration_source
- install_uv
- install_rust
- restore_cache:
keys:
- v1-uv-cache-{{ checksum "uv.lock" }}
- run:
name: Install test dependencies
command: uv sync --frozen --all-groups --all-extras --python 3.12
- attach_workspace:
at: ~/project
- run:
name: Load the shared candidate and start PostgreSQL
command: |
zstd -d litellm-docker-database.tar.zst --stdout | docker load
docker run -d --name migration-postgres \
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
postgres:16@sha256:e17e86066e5ef83e0952a9347f5c792b7ece00972e2aa787a6986f471b3dd3d5
- wait_for_service:
url: tcp://localhost:5432
timeout: "60"
- run:
name: Run migration startup regressions
environment:
MIGRATION_TEST_SUITE: << parameters.suite >>
MIGRATION_CANDIDATE_IMAGE: << pipeline.parameters.migration_candidate_image >>
command: |
mkdir -p /tmp/migration-results
uv run --no-sync python .circleci/scripts/run_migration_tests.py
no_output_timeout: 15m
- store_test_results:
path: /tmp/migration-results/junit
- run:
name: Package migration diagnostics
when: always
command: |
mkdir -p /tmp/migration-artifacts
if [ -d /tmp/migration-results ]; then
tar -czf /tmp/migration-artifacts/diagnostics.tar.gz -C /tmp/migration-results .
if [ -f /tmp/migration-results/verdict.json ]; then
cp /tmp/migration-results/verdict.json /tmp/migration-artifacts/verdict.json
fi
fi
- store_artifacts:
path: /tmp/migration-artifacts
destination: migration-results
- run:
name: Remove migration test containers
when: always
command: |
docker ps -aq --filter label=litellm-migration-test=true | xargs -r docker rm -f
docker rm -f migration-postgres || true
test_bad_database_url:
machine:
@ -2915,7 +3021,32 @@ jobs:
fi
workflows:
migration_startup:
when: << pipeline.parameters.run_migration_tests >>
jobs: &migration_jobs
- build_docker_database_image
- migration_startup_tests:
name: migration-startup
suite: startup
requires: [build_docker_database_image]
- migration_startup_tests:
name: migration-recovery
suite: recovery
requires: [build_docker_database_image]
- migration_startup_tests:
name: migration-legacy-and-pooling
suite: legacy
requires: [build_docker_database_image]
migration_startup_scheduled:
triggers:
- schedule:
cron: "17 0,6,12,18 * * *"
filters:
branches:
only: litellm_internal_staging
jobs: *migration_jobs
build_and_test:
unless: << pipeline.parameters.run_migration_tests >>
jobs:
- using_litellm_on_windows:
filters: &main_branches

View file

@ -0,0 +1,113 @@
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Final
from xml.etree import ElementTree
SUITES: Final = {
"startup": (("test_startup.py",), 12),
"recovery": (("test_recovery.py",), 15),
"legacy": (("test_legacy.py", "test_pooling.py"), 11),
}
def successful_junit(path: Path, expected: int, exit_code: int) -> bool:
if exit_code != 0 or not path.is_file():
return False
try:
root: Final = ElementTree.parse(path).getroot()
except ElementTree.ParseError:
return False
cases: Final = tuple(root.iter("testcase"))
identities: Final = frozenset((case.get("classname"), case.get("name")) for case in cases)
return len(cases) == len(identities) == expected and all(
not any(case.find(tag) is not None for tag in ("failure", "error", "skipped")) for case in cases
)
def output(*command: str) -> str:
return subprocess.check_output(command, text=True, timeout=90).strip()
def record_image() -> None:
source: Final = output("git", "rev-parse", "HEAD")
image: Final = output("docker", "image", "inspect", "litellm-docker-database:ci", "--format", "{{.Id}}")
revision: Final = output(
"docker",
"image",
"inspect",
"litellm-docker-database:ci",
"--format",
'{{index .Config.Labels "org.opencontainers.image.revision"}}',
)
assert re.fullmatch(r"[0-9a-f]{40}", source), "Invalid source revision"
assert revision == source, "Candidate image revision differs from the tested source"
Path("migration-image.json").write_text(
json.dumps(
{
"source_sha": source,
"image_id": image,
"candidate_image": os.environ.get("MIGRATION_CANDIDATE_IMAGE", ""),
}
)
)
def main() -> int:
suite: Final = os.environ["MIGRATION_TEST_SUITE"]
files, expected = SUITES[suite]
metadata: Final = json.loads(Path("migration-image.json").read_text())
assert metadata["source_sha"] == output("git", "rev-parse", "HEAD"), "Image and test source revisions differ"
assert metadata["image_id"] == output(
"docker", "image", "inspect", os.environ["LITELLM_MIGRATION_TEST_IMAGE"], "--format", "{{.Id}}"
), "Loaded image differs from the build output"
assert metadata["candidate_image"] == os.environ.get("MIGRATION_CANDIDATE_IMAGE", ""), "Wrong release candidate"
destination: Final = Path(os.environ["MIGRATION_TEST_OUTPUT"])
junit: Final = destination / "junit" / "results.xml"
junit.parent.mkdir(parents=True, exist_ok=True)
result: Final = subprocess.run(
(
sys.executable,
"-m",
"pytest",
*(f"tests/e2e/migrations/{name}" for name in files),
"-vv",
"--tb=short",
"--durations=10",
f"--junitxml={junit}",
"-o",
"addopts=",
"--reruns=0",
),
check=False,
timeout=1200,
)
passed: Final = successful_junit(junit, expected, result.returncode)
(destination / "verdict.json").write_text(
json.dumps(
{
**metadata,
"suite": suite,
"expected_cases": expected,
"passed": passed,
"pytest_exit_code": result.returncode,
"test_revision": metadata["source_sha"],
"workflow_id": os.environ.get("CIRCLE_WORKFLOW_ID", ""),
"job_number": os.environ.get("CIRCLE_BUILD_NUM", ""),
},
indent=2,
)
)
return 0 if passed else 1
if __name__ == "__main__":
if sys.argv[1:] == ["record-image"]:
record_image()
else:
raise SystemExit(main())

View file

@ -0,0 +1,89 @@
import random
import time
from collections.abc import Generator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from litellm_proxy_extras._logging import logger
from litellm_proxy_extras.prisma_toolchain import MIGRATION_LOCK_TIMEOUT_ENV_VAR, migration_lock_timeout
MIGRATION_LOCK_KEY: Final = int.from_bytes(b"llm_mig2", "big")
if TYPE_CHECKING:
import psycopg
def migration_environment(environment: Mapping[str, str]) -> Mapping[str, str]:
database_url: Final = environment.get("DATABASE_URL")
direct_url: Final = environment.get("DIRECT_URL")
if not database_url or not direct_url:
return environment
schema: Final = next((value for key, value in parse_qsl(urlsplit(database_url).query) if key == "schema"), "public")
direct: Final = urlsplit(direct_url)
parameters: Final = tuple((key, value) for key, value in parse_qsl(direct.query) if key != "schema")
return {
**environment,
"DATABASE_URL": urlunsplit(direct._replace(query=urlencode((*parameters, ("schema", schema))))),
}
@dataclass(frozen=True, slots=True)
class _LockResult:
acquired: bool
def _try_lock(connection: "psycopg.Connection[tuple[object, ...]]", key: int = MIGRATION_LOCK_KEY) -> bool:
from psycopg.rows import class_row
with connection.cursor(row_factory=class_row(_LockResult)) as cursor:
row: Final = cursor.execute("SELECT pg_try_advisory_xact_lock(%s) AS acquired", (key,)).fetchone()
return row is not None and row.acquired
@dataclass(frozen=True, slots=True)
class MigrationCoordinator:
connection: "psycopg.Connection[tuple[object, ...]]"
def check_connection(self) -> None:
self.connection.execute("SELECT 1")
def acquire_prisma_lock(self) -> None:
deadline: Final = time.monotonic() + migration_lock_timeout()
while time.monotonic() < deadline:
if _try_lock(self.connection, 72707369):
return
time.sleep(min(random.uniform(0.5, 1.5), max(0.0, deadline - time.monotonic())))
raise RuntimeError(
"Timed out waiting for Prisma's lock to recover migration history. LiteLLM startup has stopped. "
"Another migration or a pooled database session may still hold the lock. Check the database lock holder. "
"When using a transaction pooler, configure DIRECT_URL to reach the same database without the pooler."
)
@contextmanager
def migration_lock(database_url: str) -> Generator[MigrationCoordinator, None, None]:
import psycopg
wait_seconds: Final = migration_lock_timeout()
deadline: Final = time.monotonic() + wait_seconds
try:
with psycopg.connect(database_url, connect_timeout=10, autocommit=True) as connection:
coordinator: Final = MigrationCoordinator(connection)
logger.info("Waiting for the v2 migration coordinator lock (up to %ss)", wait_seconds)
while time.monotonic() < deadline:
with connection.transaction():
if _try_lock(connection):
logger.info("Acquired the v2 migration coordinator lock")
yield coordinator
coordinator.check_connection()
return
time.sleep(min(random.uniform(0.5, 1.5), max(0.0, deadline - time.monotonic())))
except psycopg.Error as exc:
raise RuntimeError(f"Lost or could not establish v2 migration coordination with the database: {exc}") from exc
raise RuntimeError(
f"Timed out waiting for another v2 migration resolver after {wait_seconds}s. "
f"Check the running migration or increase {MIGRATION_LOCK_TIMEOUT_ENV_VAR}."
)

View file

@ -0,0 +1,158 @@
import hashlib
import subprocess
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Final
from uuid import uuid4
from litellm_proxy_extras import prisma_toolchain
from litellm_proxy_extras._logging import logger
from litellm_proxy_extras.migration_lock import MigrationCoordinator
if TYPE_CHECKING:
import psycopg
@dataclass(frozen=True, slots=True)
class MigrationProgress:
checksum: str
applied_steps_count: int
logs: str
id: str = ""
finished: bool = False
def confirms_completion(self, script: bytes) -> bool:
return (
self.applied_steps_count == 1
and not self.logs.strip()
and self.checksum == hashlib.sha256(script).hexdigest()
)
def _migration_records(
connection: "psycopg.Connection[tuple[object, ...]]", schema: str, migration: Path
) -> tuple[MigrationProgress, ...]:
from psycopg import sql
from psycopg.rows import class_row
with connection.cursor(row_factory=class_row(MigrationProgress)) as cursor:
records: Final = cursor.execute(
sql.SQL(
"SELECT id, checksum, applied_steps_count, coalesce(logs, '') AS logs, "
"finished_at IS NOT NULL AS finished FROM {} "
"WHERE migration_name = %s AND rolled_back_at IS NULL"
).format(sql.Identifier(schema, "_prisma_migrations")),
(migration.parent.name,),
).fetchall()
return tuple(records)
def recover_completed_migration(coordinator: MigrationCoordinator, schema: str, migration: Path) -> bool:
"""Finish a proven successful row without erasing its durable completion evidence.
The caller commits this checkpoint before running another Prisma command.
"""
from psycopg import sql
coordinator.acquire_prisma_lock()
records: Final = _migration_records(coordinator.connection, schema, migration)
unfinished: Final = tuple(record for record in records if not record.finished)
script: Final = migration.read_bytes()
if not unfinished:
return any(record.checksum == hashlib.sha256(script).hexdigest() for record in records)
if len(unfinished) != 1 or not unfinished[0].confirms_completion(script):
return False
progress: Final = unfinished[0]
result: Final = coordinator.connection.execute(
sql.SQL(
"UPDATE {} SET finished_at = current_timestamp "
"WHERE id = %s AND checksum = %s AND applied_steps_count = 1 "
"AND finished_at IS NULL AND rolled_back_at IS NULL AND coalesce(logs, '') = %s"
).format(sql.Identifier(schema, "_prisma_migrations")),
(progress.id, progress.checksum, progress.logs),
)
if result.rowcount != 1:
raise RuntimeError("Could not complete the confirmed migration history row; retry startup.")
logger.info("Completed migration %s using its successful SQL step and matching checksum", migration.parent.name)
return True
def migration_files(directory: Path) -> tuple[tuple[str, str], ...]:
return tuple(
(path.parent.name, hashlib.sha256(path.read_bytes()).hexdigest())
for path in sorted((directory / "migrations").glob("*/migration.sql"))
)
def baseline_current_schema(
coordinator: MigrationCoordinator,
schema: str,
migrations_dir: Path,
prisma_command: str,
prisma_env: Mapping[str, str],
) -> None:
from psycopg import sql
packaged_dir: Final = Path(__file__).parent
migrations: Final = migration_files(migrations_dir)
if (
not migrations
or migrations != migration_files(packaged_dir)
or (migrations_dir / "schema.prisma").read_bytes() != (packaged_dir / "schema.prisma").read_bytes()
):
raise RuntimeError("Cannot automatically baseline an existing database with custom migration history.")
coordinator.acquire_prisma_lock()
existing: Final = coordinator.connection.execute(
"SELECT to_regclass(%s)", (sql.Identifier(schema, "_prisma_migrations").as_string(coordinator.connection),)
).fetchone()
if existing is not None and existing[0] is not None:
return
try:
prisma_toolchain.run_prisma(
(
prisma_command,
"migrate",
"diff",
"--from-schema-datasource",
str(migrations_dir / "schema.prisma"),
"--to-schema-datamodel",
str(migrations_dir / "schema.prisma"),
"--exit-code",
),
timeout=prisma_toolchain.prisma_command_timeout(),
env=prisma_env,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
raise RuntimeError(
"Cannot automatically baseline this database: its schema has not been verified to match this build. "
"Establish the existing migration history before retrying. No schema reconciliation was performed. "
"If using a transaction pooler, configure DIRECT_URL to reach the same database without the pooler. "
f"Schema verification detail: {exc.stderr}"
) from exc
coordinator.check_connection()
ledger: Final = sql.Identifier(schema, "_prisma_migrations")
coordinator.connection.execute(
sql.SQL(
"CREATE TABLE {} (id varchar(36) PRIMARY KEY NOT NULL, checksum varchar(64) NOT NULL, "
"finished_at timestamptz, migration_name varchar(255) NOT NULL, logs text, rolled_back_at timestamptz, "
"started_at timestamptz NOT NULL DEFAULT now(), applied_steps_count integer NOT NULL DEFAULT 0)"
).format(ledger)
)
with coordinator.connection.cursor() as cursor:
cursor.executemany(
sql.SQL(
"INSERT INTO {} (id, checksum, migration_name, logs, started_at, finished_at) "
"VALUES (%s, %s, %s, '', current_timestamp, current_timestamp)"
).format(ledger),
tuple((str(uuid4()), checksum, name) for name, checksum in migrations),
)
logger.warning(
"Legacy migration history was missing. The existing Prisma schema matches this build; "
"adopted %s packaged migrations as a baseline. No schema changes were applied, and "
"historical data backfills were not replayed or verified. Continuing startup; "
"review any feature-specific backfill requirements.",
len(migrations),
)

View file

@ -59,6 +59,7 @@ except ImportError:
PRISMA_COMMAND_TIMEOUT_ENV_VAR = "LITELLM_PRISMA_COMMAND_TIMEOUT"
PRISMA_BOOTSTRAP_TIMEOUT_ENV_VAR = "LITELLM_PRISMA_BOOTSTRAP_TIMEOUT"
PRISMA_MIGRATE_DEPLOY_TIMEOUT_ENV_VAR = "LITELLM_PRISMA_MIGRATE_DEPLOY_TIMEOUT"
MIGRATION_LOCK_TIMEOUT_ENV_VAR = "LITELLM_MIGRATION_LOCK_TIMEOUT"
NODEENV_CACHE_DIR_ENV_VAR = "PRISMA_NODEENV_CACHE_DIR"
DEFAULT_PRISMA_COMMAND_TIMEOUT = 60.0
@ -106,6 +107,10 @@ def prisma_command_timeout() -> float:
)
def migration_lock_timeout() -> float:
return _timeout_from_env(MIGRATION_LOCK_TIMEOUT_ENV_VAR, 600.0)
def prisma_bootstrap_timeout() -> float:
"""Seconds the one-time Node toolchain install may run for."""
return _timeout_from_env(

View file

@ -6,6 +6,7 @@ import shutil
import subprocess
import tempfile
import time
from collections.abc import Callable
from dataclasses import dataclass, replace
from pathlib import Path
from typing import TYPE_CHECKING, Final, Optional
@ -78,15 +79,10 @@ MAX_MIGRATE_DEPLOY_ATTEMPTS = 4
@dataclass(frozen=True)
class _MigrateAttemptBudget:
"""Retries left, and the recoveries already run.
A recovery that lands something new costs nothing, so a database full of
objects `prisma db push` created works through them one per pass. Anything
that made no progress spends an attempt, so a stuck run still gives up.
"""
"""Independent bounds for failed attempts and Prisma lock contention."""
attempts_left: int
recoveries: frozenset[str] = frozenset()
contention_seconds_left: float = 600.0
@property
def exhausted(self) -> bool:
@ -99,10 +95,14 @@ class _MigrateAttemptBudget:
def spend(self) -> "_MigrateAttemptBudget":
return replace(self, attempts_left=self.attempts_left - 1)
def after_recovery(self, recovery: str) -> "_MigrateAttemptBudget":
if recovery in self.recoveries:
return self.spend()
return replace(self, recoveries=self.recoveries | {recovery})
def after_contention(self, elapsed: float) -> "_MigrateAttemptBudget":
remaining: Final = self.contention_seconds_left - elapsed
if remaining <= 0:
raise RuntimeError(
"Timed out waiting for Prisma's migration advisory lock. Check the running migration "
"or increase LITELLM_MIGRATION_LOCK_TIMEOUT."
)
return replace(self, contention_seconds_left=remaining)
_SPEND_LOGS_ALTER_RE = re.compile(r'^ALTER\s+TABLE\s+"LiteLLM_SpendLogs"\s', re.IGNORECASE)
@ -836,12 +836,47 @@ class ProxyExtrasDBManager:
@staticmethod
def _setup_database_v2(use_migrate: bool) -> bool:
if not use_migrate:
return ProxyExtrasDBManager._run_database_v2(False)
from litellm_proxy_extras.migration_lock import migration_environment, migration_lock
from litellm_proxy_extras.migration_recovery import baseline_current_schema, recover_completed_migration
database_url: Final = os.environ.get("DATABASE_URL")
if not database_url:
raise RuntimeError("DATABASE_URL is required for v2 migrations")
lock_url: Final = ProxyExtrasDBManager._strip_prisma_query_params(os.environ.get("DIRECT_URL") or database_url)
schema: Final = ProxyExtrasDBManager._prisma_schema_param(database_url) or "public"
def recover_completed(name: str) -> bool:
if Path(name).name != name or "\\" in name:
return False
migration: Final = Path(os.getcwd()) / "migrations" / name / "migration.sql"
if not migration.is_file():
return False
with migration_lock(lock_url) as coordinator:
return recover_completed_migration(coordinator, schema, migration)
def baseline_existing(migrations_dir: str) -> None:
with migration_lock(lock_url) as coordinator:
baseline_current_schema(
coordinator, schema, Path(migrations_dir), _get_prisma_command(), migration_environment(_get_prisma_env())
)
while not ProxyExtrasDBManager._run_database_v2(True, recover_completed, baseline_existing):
continue
return True
@staticmethod
def _run_database_v2(
use_migrate: bool,
recover_completed: Callable[[str], bool] = lambda name: False,
baseline_existing: "Callable[[str], None] | None" = None,
) -> bool:
"""
v2 migration resolver (opt-in via --use_v2_migration_resolver).
Runs `prisma migrate deploy` and handles standard recovery paths
(P3005 baseline, P3009/P3018 idempotent errors, deadlocks against a
concurrent migrate deploy). Critically, it does
Runs `prisma migrate deploy`, baselines verified existing schemas,
and recovers confirmed SQL completion or reported deadlocks. It does
NOT call `_resolve_all_migrations` the diff-and-force recovery that
caused schema thrashing when two LiteLLM versions contended for the
same DB during rolling deploys.
@ -850,10 +885,9 @@ class ProxyExtrasDBManager:
is logged as a warning, not a fatal error users whose DBs got into
weird shapes from the old thrashing should still be able to start.
The retry budget only counts attempts that made no progress: see
_MigrateAttemptBudget.
False requests a committed recovery checkpoint and another deploy
pass. True means every pending migration is complete.
"""
schema_path = ProxyExtrasDBManager._get_prisma_dir() + "/schema.prisma"
migrations_dir = ProxyExtrasDBManager._get_prisma_dir()
if not use_migrate:
@ -886,14 +920,22 @@ class ProxyExtrasDBManager:
original_dir = os.getcwd()
os.chdir(migrations_dir)
deploy_timeout = prisma_migrate_deploy_timeout()
budget = _MigrateAttemptBudget(attempts_left=MAX_MIGRATE_DEPLOY_ATTEMPTS)
from litellm_proxy_extras.migration_lock import migration_environment, migration_lock_timeout
migration_env: Final = migration_environment(_get_prisma_env())
budget = _MigrateAttemptBudget(
attempts_left=MAX_MIGRATE_DEPLOY_ATTEMPTS,
contention_seconds_left=migration_lock_timeout(),
)
try:
while not budget.exhausted:
attempt_started = time.monotonic()
try:
result = prisma_toolchain.run_prisma(
[_get_prisma_command(), "migrate", "deploy"],
timeout=deploy_timeout,
env=_get_prisma_env(),
env=migration_env,
)
logger.info(f"prisma migrate deploy stdout: {result.stdout}")
return True
@ -909,8 +951,16 @@ class ProxyExtrasDBManager:
next_budget = budget.spend()
except subprocess.CalledProcessError as e:
if "P3005" in (e.stderr or "") and baseline_existing is not None:
baseline_existing(migrations_dir)
return False
failed_migration = ProxyExtrasDBManager._v2_failed_migration_name(e.stderr or "")
if failed_migration and recover_completed(failed_migration):
return False
next_budget = ProxyExtrasDBManager._budget_after_deploy_failure(
e, budget, schema_path
e,
budget,
time.monotonic() - attempt_started,
)
if next_budget.attempts_left < budget.attempts_left:
@ -919,19 +969,41 @@ class ProxyExtrasDBManager:
raise RuntimeError(
f"Database migration failed after {MAX_MIGRATE_DEPLOY_ATTEMPTS} "
"attempts that made no progress (timeouts, deadlock retries, or a "
"recovery that had already run once). Check database connectivity, "
"attempts that made no progress (timeouts or deadlock retries). Check database connectivity, "
"load, and _prisma_migrations ledger state, and raise "
f"{PRISMA_MIGRATE_DEPLOY_TIMEOUT_ENV_VAR} if the attempts timed out."
)
finally:
os.chdir(original_dir)
@staticmethod
def _v2_failed_migration_name(stderr: str) -> "str | None":
if "P3009" in stderr:
match = re.search(r"`(\d+_[^`\r\n]+)`", stderr)
return match.group(1) if match else None
if "P3018" in stderr:
match = re.search(r"Migration name: (\d+_[^\r\n]+)", stderr)
return match.group(1) if match else None
return None
@staticmethod
def _v2_roll_back_migration_best_effort(migration_name: str) -> None:
from litellm_proxy_extras.migration_lock import migration_environment
try:
prisma_toolchain.run_prisma(
[_get_prisma_command(), "migrate", "resolve", "--rolled-back", migration_name],
timeout=prisma_command_timeout(),
env=migration_environment(_get_prisma_env()),
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
pass
@staticmethod
def _budget_after_deploy_failure(
error: subprocess.CalledProcessError,
budget: "_MigrateAttemptBudget",
schema_path: str,
attempt_seconds: float = 0.0,
) -> "_MigrateAttemptBudget":
"""Recover from one failed `prisma migrate deploy`, and price the pass.
@ -940,37 +1012,35 @@ class ProxyExtrasDBManager:
"""
stderr = error.stderr or ""
if "P3005" in stderr and "database schema is not empty" in stderr:
logger.info("Schema exists but no migrations ledger — creating baseline")
if ProxyExtrasDBManager._create_baseline_migration(schema_path):
return budget.after_recovery("baseline")
return budget.spend()
if "P3009" in stderr:
migration_match = re.search(r"`(\d+_\S+?)`", stderr)
if migration_match and ProxyExtrasDBManager._is_idempotent_error(stderr):
name = migration_match.group(1)
logger.info(
f"Migration {name} failed idempotently — marking applied and retrying"
)
ProxyExtrasDBManager._mark_migration_applied(name)
return budget.after_recovery(f"resolved:{name}")
if migration_match:
migration_name = migration_match.group(1)
migration_name = ProxyExtrasDBManager._v2_failed_migration_name(stderr)
if migration_name:
ledger_logs = ProxyExtrasDBManager._failed_migration_logs(migration_name)
if ledger_logs is not None and (
ledger_logs == "" or _MIGRATION_DEADLOCK_MARKER in ledger_logs
):
if ledger_logs and _MIGRATION_DEADLOCK_MARKER in ledger_logs:
logger.info(
"Migration %s failed in a concurrent migrate deploy "
"deadlock race, rolling its ledger row back and retrying",
migration_name,
)
ProxyExtrasDBManager._roll_back_migration_best_effort(migration_name)
ProxyExtrasDBManager._v2_roll_back_migration_best_effort(migration_name)
return budget.spend()
raise RuntimeError(
"Database migration failed and cannot be auto-recovered. "
f"Manual intervention required.\n\nPrisma error:\n{stderr}"
"Migration completion could not be verified. LiteLLM startup has stopped.\n\n"
f"Prisma migration history (migration name and start time):\n{stderr}\n\n"
"A migration has a start record but no successful completion record. "
"LiteLLM cannot determine whether its SQL committed from this record alone. "
"Startup stopped to avoid repeating or skipping database changes.\n\n"
"Before resolving, stop other migration runners and inspect _prisma_migrations, "
"the named migration.sql from this build, database logs, and the actual database objects and data. "
"Use the same database and this build's schema and migration files for recovery:\n"
"- Only after verifying every migration change is present, run "
"prisma migrate resolve --applied <migration_name>, then retry startup.\n"
"- Only after verifying no migration changes remain (or fully undoing partial changes), run "
"prisma migrate resolve --rolled-back <migration_name>, then retry startup. "
"This command updates history; it does not undo SQL.\n"
"Replace <migration_name> with the reported name. If the outcome remains uncertain, "
"leave migration history unchanged and contact your database administrator. "
"Repeated restarts alone will not resolve this state."
) from error
if "P3018" in stderr:
@ -981,25 +1051,13 @@ class ProxyExtrasDBManager:
f"and retry.\n\nPrisma error:\n{stderr}"
) from error
migration_match = re.search(r"Migration name: (\d+_\S+)", stderr)
if migration_match and ProxyExtrasDBManager._is_idempotent_error(stderr):
name = migration_match.group(1)
migration_name = ProxyExtrasDBManager._v2_failed_migration_name(stderr)
if migration_name and _MIGRATION_DEADLOCK_MARKER in stderr:
logger.info(
f"Migration {name} SQL hit idempotent error — marking applied and retrying"
)
ProxyExtrasDBManager._mark_migration_applied(name)
return budget.after_recovery(f"resolved:{name}")
if migration_match and _MIGRATION_DEADLOCK_MARKER in stderr:
logger.info(
"Migration %s deadlocked against a concurrent "
"migrate deploy, rolling its ledger row back "
"and retrying",
migration_match.group(1),
)
ProxyExtrasDBManager._roll_back_migration_best_effort(
migration_match.group(1)
"Migration %s deadlocked against a concurrent migrate deploy, rolling its ledger row back and retrying",
migration_name,
)
ProxyExtrasDBManager._v2_roll_back_migration_best_effort(migration_name)
return budget.spend()
raise RuntimeError(
@ -1009,19 +1067,17 @@ class ProxyExtrasDBManager:
if _MIGRATION_DEADLOCK_MARKER in stderr:
logger.info(
"prisma migrate deploy attempt %s deadlocked against "
"a concurrent migrate deploy, retrying",
"prisma migrate deploy attempt %s deadlocked against a concurrent migrate deploy, retrying",
budget.attempt_number,
)
return budget.spend()
if "P1002" in stderr and "advisory lock" in stderr:
logger.info(
"prisma migrate deploy attempt %s timed out waiting for "
"the advisory lock a concurrent migrate deploy holds, retrying",
budget.attempt_number,
"Waiting for the advisory lock held by another Prisma migration; "
"contention does not spend a migration failure attempt"
)
return budget.spend()
return budget.after_contention(attempt_seconds)
raise RuntimeError(
"Database migration failed and cannot be auto-recovered. "

View file

@ -32,9 +32,7 @@ def _fake_migrate_deploy_failure(returncode: int, stderr: str):
def test_v2_p3018_permission_error_raises_runtime_error(monkeypatch, tmp_path):
"""v2: a permission failure during migrate deploy raises RuntimeError."""
monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x")
monkeypatch.setattr(
ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None
)
monkeypatch.setattr(ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None)
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path))
(tmp_path / "schema.prisma").write_text("// stub")
@ -50,9 +48,7 @@ def test_v2_p3018_permission_error_raises_runtime_error(monkeypatch, tmp_path):
def test_v2_non_idempotent_p3009_raises_runtime_error(monkeypatch, tmp_path):
"""v2: a non-idempotent migration failure raises (no silent recovery)."""
monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x")
monkeypatch.setattr(
ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None
)
monkeypatch.setattr(ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None)
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path))
(tmp_path / "schema.prisma").write_text("// stub")
@ -61,7 +57,7 @@ def test_v2_non_idempotent_p3009_raises_runtime_error(monkeypatch, tmp_path):
'Reason: syntax error at or near "BRKN" LINE 42'
)
with patch("litellm_proxy_extras.prisma_toolchain.run_prisma", side_effect=_fake_migrate_deploy_failure(1, stderr)):
with pytest.raises(RuntimeError, match="cannot be auto-recovered"):
with pytest.raises(RuntimeError, match="Migration completion could not be verified"):
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
@ -176,51 +172,33 @@ def test_v2_warn_ahead_of_head_swallows_db_errors(monkeypatch, tmp_path):
ProxyExtrasDBManager._warn_if_db_ahead_of_head(str(tmp_path))
def test_v2_resolve_specific_migration_failure_raises_runtime_error(
monkeypatch, tmp_path
):
"""If marking a migration as applied fails inside P3009 idempotent
recovery, the subprocess error must be re-raised as RuntimeError so
proxy_cli.py catches it cleanly (instead of leaking CalledProcessError)."""
def test_v2_duplicate_object_p3009_is_not_marked_applied(monkeypatch, tmp_path):
_stub_v2_env(monkeypatch, tmp_path)
monkeypatch.setattr(ProxyExtrasDBManager, "_failed_migration_logs", lambda name: "relation already exists")
monkeypatch.setattr(
ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None
ProxyExtrasDBManager,
"_v2_roll_back_migration_best_effort",
lambda name: pytest.fail("duplicate-object errors do not prove rollback is safe"),
)
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path))
(tmp_path / "schema.prisma").write_text("// stub")
monkeypatch.setattr(
ProxyExtrasDBManager, "_roll_back_migration", lambda *a, **kw: None
ProxyExtrasDBManager,
"_resolve_specific_migration",
lambda name: pytest.fail("duplicate-object errors do not prove all SQL completed"),
)
# First call: migrate deploy -> P3009 idempotent error.
# Recovery path tries _resolve_specific_migration; that also raises.
def _failing_resolve(*a, **kw):
raise subprocess.CalledProcessError(
returncode=1,
cmd="prisma migrate resolve --applied",
stderr="resolve failed",
output="",
)
monkeypatch.setattr(
ProxyExtrasDBManager, "_resolve_specific_migration", _failing_resolve
)
stderr = (
"Error: P3009\nMigration `20260101000000_some_migration` failed\n"
"relation already exists"
)
with patch("litellm_proxy_extras.prisma_toolchain.run_prisma", side_effect=_fake_migrate_deploy_failure(1, stderr)):
with pytest.raises(
RuntimeError, match="Failed to mark migration .* as applied"
):
stderr = "Error: P3009\nMigration `20260101000000_some_migration` failed\nrelation already exists"
with patch(
"litellm_proxy_extras.prisma_toolchain.run_prisma", side_effect=_fake_migrate_deploy_failure(1, stderr)
) as run:
with pytest.raises(RuntimeError, match="Migration completion could not be verified"):
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
assert tuple(call.args[0][1:] for call in run.call_args_list if "migrate" in call.args[0]) == (
["migrate", "deploy"],
)
def test_v2_does_not_call_resolve_all_migrations(monkeypatch, tmp_path):
"""v2 must never call _resolve_all_migrations — that's the bug it fixes."""
monkeypatch.setattr(
ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None
)
monkeypatch.setattr(ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None)
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path))
(tmp_path / "schema.prisma").write_text("// stub")
@ -252,9 +230,7 @@ _DEADLOCK_P3018_STDERR = (
def _stub_v2_env(monkeypatch, tmp_path):
monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x")
monkeypatch.setattr(
ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None
)
monkeypatch.setattr(ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None)
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path))
(tmp_path / "schema.prisma").write_text("// stub")
monkeypatch.setattr("time.sleep", lambda _: None)
@ -272,9 +248,7 @@ def _succeed_after(failures: int, stderr: str):
return _OkResult()
calls["n"] += 1
if calls["n"] <= failures:
raise subprocess.CalledProcessError(
returncode=1, cmd=args[0], stderr=stderr, output=""
)
raise subprocess.CalledProcessError(returncode=1, cmd=args[0], stderr=stderr, output="")
return _OkResult()
return _run
@ -288,7 +262,7 @@ def test_v2_p3018_deadlock_rolls_back_and_retries(monkeypatch, tmp_path):
rolled_back = []
monkeypatch.setattr(
ProxyExtrasDBManager,
"_roll_back_migration",
"_v2_roll_back_migration_best_effort",
lambda name: rolled_back.append(name),
)
monkeypatch.setattr(
@ -306,7 +280,7 @@ def test_v2_p3018_deadlock_rolls_back_and_retries(monkeypatch, tmp_path):
def test_v2_p3018_persistent_deadlock_exhausts_attempts(monkeypatch, tmp_path):
"""v2: a deadlock on every attempt still fails after the retry budget."""
_stub_v2_env(monkeypatch, tmp_path)
monkeypatch.setattr(ProxyExtrasDBManager, "_roll_back_migration", lambda name: None)
monkeypatch.setattr(ProxyExtrasDBManager, "_v2_roll_back_migration_best_effort", lambda name: None)
with patch(
"litellm_proxy_extras.prisma_toolchain.run_prisma",
@ -335,7 +309,7 @@ def test_v2_p3009_deadlocked_ledger_row_rolls_back_and_retries(monkeypatch, tmp_
rolled_back = []
monkeypatch.setattr(
ProxyExtrasDBManager,
"_roll_back_migration",
"_v2_roll_back_migration_best_effort",
lambda name: rolled_back.append(name),
)
monkeypatch.setattr(
@ -350,10 +324,8 @@ def test_v2_p3009_deadlocked_ledger_row_rolls_back_and_retries(monkeypatch, tmp_
assert rolled_back == ["20260415120000_health_check_latest_per_model_index"]
def test_v2_p3009_empty_ledger_logs_rolls_back_and_retries(monkeypatch, tmp_path):
"""v2: empty failed ledger logs mean a concurrent deploy moved it on."""
def test_v2_p3009_empty_ledger_logs_do_not_prove_completion(monkeypatch, tmp_path):
_stub_v2_env(monkeypatch, tmp_path)
stderr = (
"Error: P3009\n"
"migrate found failed migrations in the target database\n"
@ -361,22 +333,19 @@ def test_v2_p3009_empty_ledger_logs_rolls_back_and_retries(monkeypatch, tmp_path
"started at 2026-09-01 18:46:13 UTC failed"
)
monkeypatch.setattr(ProxyExtrasDBManager, "_failed_migration_logs", lambda name: "")
rolled_back = []
monkeypatch.setattr(
ProxyExtrasDBManager,
"_roll_back_migration",
lambda name: rolled_back.append(name),
"_v2_roll_back_migration_best_effort",
lambda name: pytest.fail("empty logs do not prove rollback is safe"),
)
monkeypatch.setattr(
ProxyExtrasDBManager,
"_resolve_specific_migration",
lambda name: pytest.fail("a deadlocked migration must never be marked applied"),
with patch(
"litellm_proxy_extras.prisma_toolchain.run_prisma", side_effect=_fake_migrate_deploy_failure(1, stderr)
) as run:
with pytest.raises(RuntimeError, match="Migration completion could not be verified"):
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
assert tuple(call.args[0][1:] for call in run.call_args_list if "migrate" in call.args[0]) == (
["migrate", "deploy"],
)
monkeypatch.setattr("litellm_proxy_extras.prisma_toolchain.run_prisma", _succeed_after(1, stderr))
ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
assert ok is True
assert rolled_back == ["20260415120000_health_check_latest_per_model_index"]
def test_v2_p3009_unreadable_ledger_still_raises(monkeypatch, tmp_path):
@ -392,12 +361,12 @@ def test_v2_p3009_unreadable_ledger_still_raises(monkeypatch, tmp_path):
monkeypatch.setattr(ProxyExtrasDBManager, "_failed_migration_logs", lambda name: None)
monkeypatch.setattr(
ProxyExtrasDBManager,
"_roll_back_migration",
"_v2_roll_back_migration_best_effort",
lambda name: pytest.fail("an unreadable ledger must not trigger a retry"),
)
monkeypatch.setattr("litellm_proxy_extras.prisma_toolchain.run_prisma", _succeed_after(1, stderr))
with pytest.raises(RuntimeError, match="cannot be auto-recovered"):
with pytest.raises(RuntimeError, match="Migration completion could not be verified"):
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
@ -418,7 +387,7 @@ def test_v2_p3009_non_deadlock_ledger_row_still_raises(monkeypatch, tmp_path):
)
with patch("litellm_proxy_extras.prisma_toolchain.run_prisma", side_effect=_fake_migrate_deploy_failure(1, stderr)):
with pytest.raises(RuntimeError, match="cannot be auto-recovered"):
with pytest.raises(RuntimeError, match="Migration completion could not be verified"):
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)

View file

@ -6,6 +6,8 @@ Code-style rules for writing tests under `tests/e2e/`. The harness already encod
Each subdirectory under `tests/e2e/` is one suite, scoped to an endpoint family or behavior area. If you add a new folder, you must add a line here describing what kind of tests belong in it, so the layout stays self-describing. `gateway/` is the exception: it holds proxy configuration only and never tests
- `migrations/` - isolated Docker startup, concurrent migration, crash recovery, and legacy database compatibility. The CircleCI migration workflow enables `LITELLM_MIGRATION_TESTS=1`; these tests own their proxy containers and databases, so they do not use the shared proxy preflight or shared database cleanup
- `llm_translation/` - LLM endpoint and provider-translation behavior: passthrough, custom pricing, OCR, and the non-chat inference endpoints (`/v1/responses`, `/v1/messages`, `/embeddings`, `/v1/rerank`, `/v1/audio/speech`, `/v1/images/generations`), each against a deployment the test creates via `/model/new` and deletes on teardown
- `access_control/` - the gateway's authorization and error-shape contract: per-key model allow-lists, route-group permissions (`allowed_routes`), and unknown-model validation
- `embeddings/` - the `/embeddings` endpoint across providers

View file

@ -64,6 +64,7 @@ def jwt_identity(idp: Keycloak, resources: ResourceManager, proxy: ProxyClient)
def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line("markers", "migration_startup: isolated container startup tests run by the migration CI workflow")
config.addinivalue_line(
"markers",
"e2e: live test that requires a running proxy and real provider keys",
@ -123,6 +124,11 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
traffic only after the latency-sensitive suites have finished."""
for item in items:
attach_result_properties(item)
if os.environ.get("LITELLM_MIGRATION_TESTS") != "1":
deselected = [item for item in items if item.get_closest_marker("migration_startup") is not None]
items[:] = [item for item in items if item.get_closest_marker("migration_startup") is None]
if deselected:
deselected[0].config.hook.pytest_deselected(items=deselected)
items.sort(key=lambda item: item.get_closest_marker("load") is not None)
@ -155,7 +161,7 @@ def pytest_runtest_setup(item: pytest.Item) -> None:
Unmarked tests (unit coverage of the harness) don't touch the proxy, so they
run even when none is up. Never skip for a missing proxy. Replay mode needs
the proxy too: only provider-bound traffic replays from the bundle."""
if item.get_closest_marker("e2e") is None:
if item.get_closest_marker("e2e") is None or item.get_closest_marker("migration_startup") is not None:
return
reason = _proxy_fail_reason()
if reason is not None:
@ -168,7 +174,7 @@ def pytest_runtest_call(item: pytest.Item) -> None:
guard before truncating the spend-log DB. Tests under `tests/e2e/` without the
`e2e` marker (pure unit coverage for the harness itself) never hit the proxy,
so they must not arm the destructive DB truncate."""
if item.get_closest_marker("e2e") is None:
if item.get_closest_marker("e2e") is None or item.get_closest_marker("migration_startup") is not None:
return
item.session.stash[_E2E_TEST_RAN] = True

View file

View file

@ -0,0 +1,130 @@
import hashlib
from contextlib import ExitStack
from typing import Final
from uuid import uuid4
from psycopg import sql
from .containers import Containers, Replica, failed, until
from .database import GATE_KEY, Database
from .startup_models import Migration
COMPLETE_SQL: Final = "CREATE TABLE migration_effect (id int PRIMARY KEY); INSERT INTO migration_effect VALUES (1);"
COMPLETE: Final = Migration("20990101000000_startup_test", COMPLETE_SQL)
NEXT: Final = Migration(
"20990102000000_next_test",
"CREATE TABLE migration_next (id int PRIMARY KEY); INSERT INTO migration_next VALUES (2);",
)
FATAL: Final = Migration(COMPLETE.name, "DO $$ BEGIN RAISE EXCEPTION 'MIGRATION_TEST_FATAL'; END $$;")
GATED: Final = Migration(
COMPLETE.name, f"SELECT pg_advisory_lock({GATE_KEY}); {COMPLETE.script} SELECT pg_advisory_unlock({GATE_KEY});"
)
def start_replicas(
stack: ExitStack, containers: Containers, database: Database, migrations: tuple[Migration, ...] = (), count: int = 3
) -> tuple[Replica, ...]:
return tuple(stack.enter_context(containers.start(database, migrations)) for _ in range(count))
def assert_completed(database: Database, migration: Migration = COMPLETE) -> None:
assert database.query(
"SELECT finished_at IS NOT NULL, rolled_back_at IS NULL, applied_steps_count FROM _prisma_migrations WHERE migration_name = %s",
(migration.name,),
) == ((True, True, 1),), "Expected exactly one successful SQL execution"
assert database.query("SELECT id FROM migration_effect") == ((1,),)
def confirmed_history(database: Database) -> str:
database.execute(COMPLETE_SQL)
row_id: Final = str(uuid4())
database.execute(
"INSERT INTO _prisma_migrations (id, migration_name, checksum, applied_steps_count) VALUES (%s, %s, %s, 1)",
(row_id, COMPLETE.name, hashlib.sha256(COMPLETE.script.encode()).hexdigest()),
)
return row_id
def assert_original_proof(database: Database, row_id: str, finished: bool) -> None:
assert database.query(
"SELECT id, applied_steps_count, finished_at IS NOT NULL, rolled_back_at IS NULL FROM _prisma_migrations WHERE migration_name = %s",
(COMPLETE.name,),
) == ((row_id, 1, finished, True),), "Recovery lost or replaced the original durable SQL proof"
assert database.query("SELECT id FROM migration_effect") == ((1,),)
def pause_completion(database: Database) -> None:
database.execute(
sql.SQL(
"CREATE FUNCTION migration_pause() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN "
"IF NEW.migration_name = {name} AND NEW.finished_at IS NOT NULL THEN "
"PERFORM pg_advisory_lock({gate}); PERFORM pg_advisory_unlock({gate}); END IF; RETURN NEW; END $$; "
"CREATE TRIGGER migration_pause BEFORE UPDATE ON _prisma_migrations FOR EACH ROW EXECUTE FUNCTION migration_pause()"
).format(name=sql.Literal(COMPLETE.name), gate=sql.Literal(GATE_KEY))
)
def interrupt_owner(
containers: Containers, database: Database, after_commit: bool, *, stop_database_session: bool = True
) -> None:
if after_commit:
pause_completion(database)
with database.lock():
with containers.start(database, (COMPLETE if after_commit else GATED,)) as owner:
until("migration at the intended crash boundary", lambda: bool(database.blocked()))
assert database.exists("migration_effect") == after_commit
assert database.query(
"SELECT finished_at IS NULL FROM _prisma_migrations WHERE migration_name = %s", (COMPLETE.name,)
) == ((True,),)
blocked: Final = database.blocked()
assert len(blocked) == 1
backend: Final = blocked[0][0]
assert owner.state().Running
owner.kill()
assert owner.state().ExitCode == 137
if stop_database_session:
database.query("SELECT pg_terminate_backend(%s)", (backend,))
until(
"terminated migration backend released",
lambda: not database.query("SELECT pid FROM pg_stat_activity WHERE pid = %s", (backend,)),
)
assert database.query(
"SELECT finished_at IS NULL, applied_steps_count FROM _prisma_migrations WHERE migration_name = %s",
(COMPLETE.name,),
) == ((True, int(after_commit)),)
assert database.exists("migration_effect") == after_commit
if not stop_database_session:
until(
"database backend noticed container death",
lambda: not database.query("SELECT pid FROM pg_stat_activity WHERE pid = %s", (backend,)),
60,
)
def unconfirmed(replicas: tuple[Replica, ...], database: Database) -> None:
failed(replicas, "Migration completion could not be verified")
started: Final = str(
database.query(
"SELECT to_char(started_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') FROM _prisma_migrations WHERE migration_name = %s",
(COMPLETE.name,),
)[0][0]
)
for replica in replicas:
assert_guidance(replica.logs(), started)
def assert_guidance(log: str, started: str) -> None:
for detail in (
COMPLETE.name,
started,
"cannot determine whether its SQL committed",
"_prisma_migrations",
"migration.sql",
"Only after verifying every migration change is present",
"prisma migrate resolve --applied <migration_name>",
"Only after verifying no migration changes remain",
"prisma migrate resolve --rolled-back <migration_name>",
"leave migration history unchanged",
"Repeated restarts alone",
):
assert detail in log, f"Missing recovery guidance: {detail}"

View file

@ -0,0 +1,62 @@
import json
import os
from collections.abc import Iterator
from pathlib import Path
from typing import Final
from urllib.parse import urlsplit
import pytest
from _pytest.fixtures import SubRequest
from .containers import Containers, docker, ready
from .database import Database, Databases
@pytest.fixture(scope="session")
def migration_image(tmp_path_factory: pytest.TempPathFactory) -> str:
configured: Final = os.environ.get("LITELLM_MIGRATION_TEST_IMAGE")
assert configured, "LITELLM_MIGRATION_TEST_IMAGE must name the built candidate image"
image: Final = docker("image", "inspect", configured, "--format", "{{.Id}}")
assert image.startswith("sha256:"), "Unable to identify the candidate image"
output: Final = Path(os.environ.get("MIGRATION_TEST_OUTPUT", str(tmp_path_factory.getbasetemp())))
output.mkdir(parents=True, exist_ok=True)
(output / "image.json").write_text(json.dumps({"requested": configured, "image_id": image}))
return image
@pytest.fixture(scope="session")
def databases() -> Databases:
admin: Final = os.environ.get("MIGRATION_TEST_ADMIN_URL", "")
parsed: Final = urlsplit(admin)
assert parsed.hostname in ("127.0.0.1", "localhost"), "Use an isolated loopback PostgreSQL test cluster"
assert parsed.port and parsed.path and not parsed.query, "Supply the test cluster port and admin database"
container_admin: Final = os.environ.get(
"MIGRATION_TEST_CONTAINER_ADMIN_URL",
admin.replace("127.0.0.1", "host.docker.internal").replace("localhost", "host.docker.internal"),
)
return Databases(admin, container_admin)
@pytest.fixture(scope="session")
def migrated_template(
databases: Databases, migration_image: str, tmp_path_factory: pytest.TempPathFactory
) -> Iterator[Database]:
output: Final = Path(os.environ.get("MIGRATION_TEST_OUTPUT", str(tmp_path_factory.getbasetemp()))) / "seed"
with databases.create() as database:
with Containers(migration_image, output).start(database) as replica:
ready((replica,), database)
yield database
@pytest.fixture
def database(databases: Databases, migrated_template: Database) -> Iterator[Database]:
with databases.create(migrated_template) as database:
yield database
@pytest.fixture
def containers(migration_image: str, tmp_path: Path, request: SubRequest) -> Containers:
configured: Final = os.environ.get("MIGRATION_TEST_OUTPUT")
output: Final = Path(configured) / request.node.name if configured else tmp_path
output.mkdir(parents=True, exist_ok=True)
return Containers(migration_image, output)

View file

@ -0,0 +1,203 @@
from __future__ import annotations
import hashlib
import subprocess
import time
from collections.abc import Callable, Generator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Final
from uuid import uuid4
from e2e_http import NoBody, Success, unwrap
from models import KeyGenerateBody, KeyGenerateResponse, KeyInfoParams, KeyInfoResponse
from transport import HttpTransport
from .database import Database, prisma_url
from .startup_models import ContainerState, Migration, Observation, Readiness
MASTER_KEY: Final = "sk-migration-ci-fixture"
def docker(*args: str) -> str:
result: Final = subprocess.run(("docker", *args), capture_output=True, text=True, timeout=90)
assert result.returncode == 0, f"Docker operation failed: {result.stderr}"
return result.stdout.strip()
def until(description: str, condition: Callable[[], bool], seconds: float = 150) -> None:
deadline: Final = time.monotonic() + seconds
while time.monotonic() < deadline:
if condition():
return
time.sleep(0.25)
raise AssertionError(f"Timed out waiting for {description}")
@dataclass(frozen=True, slots=True)
class Replica:
name: str
transport: HttpTransport
output: Path
def state(self) -> ContainerState:
return ContainerState.model_validate_json(docker("inspect", "--format", "{{json .State}}", self.name))
def observe(self) -> Observation:
state: Final = self.state()
result: Final = self.transport.get(
"/health/readiness", headers=self.transport.master, params=NoBody(), response_type=Readiness, timeout=1
)
ready: Final = isinstance(result, Success) and result.data.status == "healthy" and result.data.db == "connected"
return Observation(None if state.Running else state.ExitCode, ready)
def logs(self) -> str:
result: Final = subprocess.run(("docker", "logs", self.name), capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
return result.stdout + result.stderr
def kill(self) -> None:
if self.state().Running:
docker("kill", self.name)
def usable(self, database: Database) -> None:
alias: Final = f"migration-{uuid4().hex}"
key: Final = unwrap(
self.transport.post(
"/key/generate",
headers=self.transport.master,
json=KeyGenerateBody(key_alias=alias),
response_type=KeyGenerateResponse,
)
).key
info: Final = unwrap(
self.transport.get(
"/key/info",
headers=self.transport.master,
params=KeyInfoParams(key=key),
response_type=KeyInfoResponse,
)
)
assert info.info.key_alias == alias
assert database.query(
'SELECT key_alias FROM "LiteLLM_VerificationToken" WHERE token = %s',
(hashlib.sha256(key.encode()).hexdigest(),),
) == ((alias,),)
def ready(replicas: tuple[Replica, ...], database: Database) -> None:
def all_ready() -> bool:
observations: Final = tuple(replica.observe() for replica in replicas)
assert all(item.exit_code is None for item in observations), "Replica exited before readiness"
return all(item.ready for item in observations)
until("every replica ready", all_ready)
for replica in replicas:
replica.usable(database)
def failed(replicas: tuple[Replica, ...], marker: str) -> None:
def all_stopped() -> bool:
observations: Final = tuple(replica.observe() for replica in replicas)
assert not any(item.ready for item in observations), "Failed migration exposed a ready proxy"
return all(item.exit_code is not None for item in observations)
until("every replica to reject startup", all_stopped)
for replica in replicas:
assert replica.state().ExitCode != 0, "Failed startup returned success"
assert marker in replica.logs(), f"Startup failed outside the expected migration: {marker}"
def waiting(replicas: tuple[Replica, ...], seconds: float) -> None:
deadline: Final = time.monotonic() + seconds
while time.monotonic() < deadline:
assert all(item.exit_code is None and not item.ready for item in (replica.observe() for replica in replicas)), (
"Contending replica exited or served early"
)
time.sleep(0.25)
@dataclass(frozen=True, slots=True)
class Containers:
image: str
output: Path
@contextmanager
def start(
self,
database: Database,
migrations: tuple[Migration, ...] = (),
*,
v2: bool = True,
disabled: bool = False,
environment: Mapping[str, str] | None = None,
) -> Generator[Replica]:
name: Final = f"litellm-migration-{uuid4().hex[:16]}"
directory: Final = self.output / name
directory.mkdir(parents=True)
for migration in migrations:
write_migration(directory, migration)
(directory / "config.yaml").write_text(
"model_list: []\ngeneral_settings:\n master_key: os.environ/LITELLM_MASTER_KEY\n"
)
env: Final = {
"DATABASE_URL": prisma_url(database.container_url, database.schema),
"LITELLM_MASTER_KEY": MASTER_KEY,
"LITELLM_SALT_KEY": MASTER_KEY,
"LITELLM_LOCAL_MODEL_COST_MAP": "True",
"LITELLM_TELEMETRY": "False",
"LITELLM_LOG": "INFO",
"DATABASE_CONNECTION_POOL_LIMIT": "2",
"DEFAULT_NUM_WORKERS_LITELLM_PROXY": "1",
"USE_V2_MIGRATION_RESOLVER": str(v2).lower(),
"DISABLE_SCHEMA_UPDATE": str(disabled).lower(),
"LITELLM_MIGRATION_DIR": "/migration-test/prisma",
"LITELLM_PRISMA_MIGRATE_DEPLOY_TIMEOUT": "180",
**(environment or {}),
}
try:
docker(
"run",
"-d",
"--name",
name,
"--label",
"litellm-migration-test=true",
"--add-host",
"host.docker.internal:host-gateway",
"-p",
"127.0.0.1::4000",
"-v",
f"{directory}:/migration-test",
*(arg for key, value in env.items() for arg in ("-e", f"{key}={value}")),
self.image,
"--config",
"/migration-test/config.yaml",
"--host",
"0.0.0.0",
"--port",
"4000",
)
port: Final = int(docker("port", name, "4000/tcp").rsplit(":", 1)[1])
replica: Final = Replica(name, HttpTransport(f"http://127.0.0.1:{port}", MASTER_KEY, 15), directory)
yield replica
finally:
try:
state: Final = subprocess.run(
("docker", "inspect", "--format", "{{json .State}}", name),
capture_output=True,
text=True,
timeout=30,
)
(directory / "state.json").write_text(state.stdout or state.stderr)
logs: Final = subprocess.run(("docker", "logs", name), capture_output=True, text=True, timeout=30)
(directory / "proxy.log").write_text(logs.stdout + logs.stderr)
finally:
subprocess.run(("docker", "rm", "-f", name), capture_output=True, text=True, timeout=30, check=True)
def write_migration(directory: Path, migration: Migration) -> None:
path: Final = directory / "prisma" / "migrations" / migration.name
path.mkdir(parents=True)
(path / "migration.sql").write_text(migration.script)

View file

@ -0,0 +1,135 @@
from __future__ import annotations
from collections.abc import Generator
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Final, LiteralString
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from uuid import uuid4
import psycopg
from psycopg import sql
from pydantic import TypeAdapter
Scalar = str | int | bool | None
ROWS: Final = TypeAdapter(tuple[tuple[Scalar, ...], ...])
GATE_KEY: Final = 39178002
PRISMA_LOCK: Final = 72707369
COORDINATOR_LOCK: Final = int.from_bytes(b"llm_mig2", "big")
def connect_url(url: str, name: str) -> str:
return urlunsplit(urlsplit(url)._replace(path=f"/{name}", query=""))
def prisma_url(url: str, schema: str) -> str:
parsed: Final = urlsplit(url)
query: Final = tuple((key, value) for key, value in parse_qsl(parsed.query) if key != "schema")
return urlunsplit(parsed._replace(query=urlencode((*query, ("schema", schema)))))
@dataclass(frozen=True, slots=True)
class Database:
name: str
url: str
container_url: str
schema: str = "public"
@contextmanager
def connection(self) -> Generator[psycopg.Connection[tuple[object, ...]]]:
with psycopg.connect(self.url, autocommit=True, connect_timeout=5) as connection:
connection.execute(sql.SQL("SET search_path TO {}").format(sql.Identifier(self.schema)))
connection.execute("SET statement_timeout = '15s'")
yield connection
def execute(self, statement: LiteralString | sql.Composed, params: tuple[Scalar, ...] = ()) -> None:
with self.connection() as connection:
connection.execute(statement, params or None)
def query(
self, statement: LiteralString | sql.Composed, params: tuple[Scalar, ...] = ()
) -> tuple[tuple[Scalar, ...], ...]:
with self.connection() as connection:
return ROWS.validate_python(connection.execute(statement, params or None).fetchall())
def exists(self, name: str) -> bool:
return self.query("SELECT to_regclass(%s) IS NOT NULL", (name,)) == ((True,),)
def history(self) -> tuple[tuple[Scalar, ...], ...]:
if not self.exists("_prisma_migrations"):
return ()
return self.query(
"SELECT id, migration_name, checksum, started_at::text, finished_at::text, rolled_back_at::text, "
"applied_steps_count, logs FROM _prisma_migrations ORDER BY id"
)
def blocked(self, key: int = GATE_KEY) -> tuple[tuple[Scalar, ...], ...]:
return self.query(
"SELECT pid FROM pg_locks WHERE locktype = 'advisory' AND NOT granted "
"AND database = (SELECT oid FROM pg_database WHERE datname = current_database()) "
"AND classid = %s AND objid = %s ORDER BY pid",
(key >> 32, key & 0xFFFFFFFF),
)
@contextmanager
def lock(self, key: int = GATE_KEY) -> Generator[None]:
with self.connection() as connection:
connection.execute("SELECT pg_advisory_lock(%s)", (key,))
try:
yield
finally:
connection.execute("SELECT pg_advisory_unlock(%s)", (key,))
@dataclass(frozen=True, slots=True)
class Databases:
admin_url: str
container_admin_url: str
@contextmanager
def create(self, template: Database | None = None, schema: str = "public") -> Generator[Database]:
name: Final = f"litellm_migration_test_{uuid4().hex[:20]}"
database: Final = Database(
name, connect_url(self.admin_url, name), connect_url(self.container_admin_url, name), schema
)
with psycopg.connect(self.admin_url, autocommit=True, connect_timeout=5) as connection:
connection.execute(
sql.SQL("CREATE DATABASE {} TEMPLATE {}").format(
sql.Identifier(name), sql.Identifier(template.name if template else "template0")
)
)
try:
yield database
finally:
with psycopg.connect(self.admin_url, autocommit=True, connect_timeout=5) as connection:
connection.execute(sql.SQL("DROP DATABASE {} WITH (FORCE)").format(sql.Identifier(name)))
@contextmanager
def restricted_user(database: Database) -> Generator[Database]:
role: Final = f"migration_reader_{uuid4().hex[:16]}"
password: Final = "migration-test-password"
with database.connection() as connection:
connection.execute(
sql.SQL("CREATE ROLE {} LOGIN PASSWORD {}").format(sql.Identifier(role), sql.Literal(password))
)
try:
database.execute(
sql.SQL("GRANT USAGE ON SCHEMA {} TO {}").format(sql.Identifier(database.schema), sql.Identifier(role))
)
database.execute(
sql.SQL("GRANT SELECT ON ALL TABLES IN SCHEMA {} TO {}").format(
sql.Identifier(database.schema), sql.Identifier(role)
)
)
local: Final = urlsplit(database.url)
remote: Final = urlsplit(database.container_url)
yield Database(
database.name,
urlunsplit(local._replace(netloc=f"{role}:{password}@{local.hostname}:{local.port}")),
urlunsplit(remote._replace(netloc=f"{role}:{password}@{remote.hostname}:{remote.port}")),
database.schema,
)
finally:
database.execute(sql.SQL("DROP OWNED BY {}").format(sql.Identifier(role)))
database.execute(sql.SQL("DROP ROLE {}").format(sql.Identifier(role)))

View file

@ -0,0 +1,25 @@
from dataclasses import dataclass
from pydantic import BaseModel
class Readiness(BaseModel):
status: str = ""
db: str = ""
class ContainerState(BaseModel):
Running: bool
ExitCode: int
@dataclass(frozen=True, slots=True)
class Observation:
exit_code: int | None
ready: bool
@dataclass(frozen=True, slots=True)
class Migration:
name: str
script: str

View file

@ -0,0 +1,84 @@
from contextlib import ExitStack
from dataclasses import replace
from typing import Final, Literal
import pytest
from .checks import COMPLETE, assert_completed, confirmed_history, assert_original_proof, start_replicas
from .containers import Containers, failed, ready
from .database import Database, Databases
pytestmark: Final = [pytest.mark.e2e, pytest.mark.migration_startup]
def adopt_legacy(containers: Containers, database: Database) -> None:
count: Final = database.query("SELECT count(*) FROM _prisma_migrations")[0][0]
existing_keys: Final = database.query('SELECT token FROM "LiteLLM_VerificationToken" ORDER BY token')
database.execute(
"INSERT INTO \"LiteLLM_ShadowEvalJob\" (id, group_id, target_id, router_name, judge_model, shadow_percentage, max_turns, ends_at, stopped_at) VALUES ('migration-legacy', 'migration-legacy', 'target', 'router', 'judge', 1, 1, now(), now())"
)
database.execute("DROP TABLE _prisma_migrations")
with ExitStack() as stack:
replicas: Final = start_replicas(stack, containers, database)
ready(replicas, database)
logs: Final = "\n".join(replica.logs() for replica in replicas)
for detail in (
"Legacy migration history was missing",
"historical data backfills were not replayed or verified",
"Continuing startup",
):
assert detail in logs
assert database.query("SELECT count(*) FROM _prisma_migrations") == ((count,),)
assert database.query(
"SELECT count(*) FROM _prisma_migrations WHERE finished_at IS NULL OR rolled_back_at IS NOT NULL OR applied_steps_count <> 0"
) == ((0,),)
assert set(existing_keys).issubset(database.query('SELECT token FROM "LiteLLM_VerificationToken" ORDER BY token'))
assert database.query("SELECT stopped_by FROM \"LiteLLM_ShadowEvalJob\" WHERE id = 'migration-legacy'") == (
(None,),
)
class TestLegacyMigrations:
def test_matching_schema_warns_and_starts(self, containers: Containers, database: Database) -> None:
adopt_legacy(containers, database)
@pytest.mark.parametrize("fault", ("schema_drift", "custom_migrations", "empty_ledger"))
def test_unrecognized_legacy_state_is_not_baselined(
self, containers: Containers, database: Database, fault: str
) -> None:
if fault == "empty_ledger":
database.execute("TRUNCATE _prisma_migrations")
else:
database.execute("DROP TABLE _prisma_migrations")
if fault == "schema_drift":
database.execute('ALTER TABLE "LiteLLM_VerificationToken" DROP COLUMN key_alias CASCADE')
with containers.start(database, (COMPLETE,) if fault == "custom_migrations" else ()) as replica:
failed((replica,), "Cannot automatically baseline" if fault != "empty_ledger" else "migration")
assert not database.exists("migration_effect")
if database.exists("_prisma_migrations"):
assert database.query(
"SELECT count(*) FROM _prisma_migrations WHERE finished_at IS NOT NULL AND applied_steps_count <> 1"
) == ((0,),)
@pytest.mark.parametrize("scenario", ("upgrade", "recovery", "legacy"))
def test_non_default_schema(
self, containers: Containers, databases: Databases, scenario: Literal["upgrade", "recovery", "legacy"]
) -> None:
with databases.create(schema="migration tenant") as database:
with containers.start(database) as seed:
ready((seed,), database)
match scenario:
case "upgrade":
with ExitStack() as stack:
ready(start_replicas(stack, containers, database, (COMPLETE,)), database)
assert_completed(database)
case "recovery":
original: Final = confirmed_history(database)
with ExitStack() as stack:
ready(start_replicas(stack, containers, database, (COMPLETE,)), database)
assert_original_proof(database, original, True)
case "legacy":
adopt_legacy(containers, database)
public: Final = replace(database, schema="public")
assert not public.exists("_prisma_migrations")
assert not public.exists('"LiteLLM_VerificationToken"')

View file

@ -0,0 +1,135 @@
import subprocess
from collections.abc import Generator
from contextlib import ExitStack, contextmanager
from pathlib import Path
from typing import Final
from urllib.parse import urlsplit, urlunsplit
from uuid import uuid4
import psycopg
import pytest
from psycopg import sql
from .checks import COMPLETE, assert_completed
from .containers import Containers, docker, ready, until
from .database import Database, Databases, prisma_url, restricted_user
POOL_IMAGE: Final = (
"ghcr.io/cloudnative-pg/pgbouncer@sha256:e6ddfe22d845e603825e235dd8334b21ecd125abea2a2172478f556b8dee2bb8"
)
pytestmark: Final = [pytest.mark.e2e, pytest.mark.migration_startup]
@contextmanager
def application_user(database: Database) -> Generator[Database]:
with restricted_user(database) as application:
role: Final = sql.Identifier(str(urlsplit(application.url).username))
schema: Final = sql.Identifier(database.schema)
database.execute(sql.SQL("REVOKE CREATE ON SCHEMA {} FROM PUBLIC").format(schema))
for statement in (
"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA {} TO {}",
"GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA {} TO {}",
"ALTER DEFAULT PRIVILEGES IN SCHEMA {} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {}",
"ALTER DEFAULT PRIVILEGES IN SCHEMA {} GRANT USAGE, SELECT ON SEQUENCES TO {}",
):
database.execute(sql.SQL(statement).format(schema, role))
assert application.query("SELECT has_schema_privilege(current_user, %s, 'CREATE')", (database.schema,)) == (
(False,),
)
yield application
@contextmanager
def pool(database: Database, output: Path) -> Generator[str]:
name: Final = f"litellm-migration-pool-{uuid4().hex[:12]}"
url: Final = urlsplit(database.container_url)
directory: Final = output / name
directory.mkdir(parents=True)
(directory / "users.txt").write_text(f'"{url.username}" "{url.password}"\n')
(directory / "pgbouncer.ini").write_text(
f"[databases]\n* = host={url.hostname} port={url.port} user={url.username} password={url.password}\n"
"[pgbouncer]\nlisten_addr = 0.0.0.0\nlisten_port = 6432\nauth_type = trust\nauth_file = /pool/users.txt\n"
"pool_mode = transaction\ndefault_pool_size = 1\nreserve_pool_size = 0\nmax_client_conn = 100\n"
"max_prepared_statements = 100\nquery_wait_timeout = 8\nignore_startup_parameters = extra_float_digits,options\n"
)
try:
docker(
"run",
"-d",
"--name",
name,
"--label",
"litellm-migration-test=true",
"--add-host",
"host.docker.internal:host-gateway",
"-p",
"0.0.0.0::6432",
"-v",
f"{directory}:/pool:ro",
"--entrypoint",
"/usr/bin/pgbouncer",
POOL_IMAGE,
"/pool/pgbouncer.ini",
)
port: Final = int(docker("port", name, "6432/tcp").splitlines()[0].rsplit(":", 1)[1])
local_url: Final = urlunsplit(url._replace(netloc=f"{url.username}:{url.password}@127.0.0.1:{port}"))
def connected() -> bool:
try:
with psycopg.connect(local_url, autocommit=True, connect_timeout=2) as connection:
return connection.execute("SELECT 1").fetchone() == (1,)
except psycopg.Error:
return False
until("PgBouncer ready", connected, 30)
yield local_url.replace("127.0.0.1", "host.docker.internal") + "?pgbouncer=true"
finally:
try:
logs: Final = subprocess.run(("docker", "logs", name), text=True, capture_output=True, timeout=30)
(directory / "pool.log").write_text(logs.stdout + logs.stderr)
finally:
subprocess.run(("docker", "rm", "-f", name), capture_output=True, text=True, timeout=30, check=True)
class TestMigrationPooling:
@pytest.mark.parametrize("scenario,replica_count", (("fresh", 3), ("upgrade", 3), ("legacy", 3), ("upgrade", 6)))
def test_direct_migrations_with_one_application_backend(
self,
containers: Containers,
databases: Databases,
migrated_template: Database,
scenario: str,
replica_count: int,
) -> None:
with databases.create(None if scenario == "fresh" else migrated_template) as database:
if scenario == "legacy":
database.execute("DROP TABLE _prisma_migrations")
with (
application_user(database) as application,
pool(application, containers.output) as pooled_url,
ExitStack() as stack,
):
replicas: Final = tuple(
stack.enter_context(
containers.start(
database,
(COMPLETE,) if scenario == "upgrade" else (),
environment={
"DATABASE_URL": prisma_url(pooled_url, database.schema),
"DIRECT_URL": database.container_url,
},
)
)
for _ in range(replica_count)
)
ready(replicas, database)
if scenario == "upgrade":
assert_completed(database)
if scenario == "legacy":
assert any(
"historical data backfills were not replayed or verified" in replica.logs()
for replica in replicas
)
assert database.query("SELECT count(*) FROM _prisma_migrations WHERE applied_steps_count <> 0") == (
(0,),
)

View file

@ -0,0 +1,183 @@
from contextlib import ExitStack
from typing import Final, Literal
from uuid import uuid4
import pytest
from .checks import (
COMPLETE,
FATAL,
GATED,
NEXT,
assert_completed,
confirmed_history,
interrupt_owner,
assert_original_proof,
pause_completion,
start_replicas,
unconfirmed,
)
from .containers import Containers, failed, ready, until, waiting
from .database import COORDINATOR_LOCK, GATE_KEY, Database
from .startup_models import Migration
pytestmark: Final = [pytest.mark.e2e, pytest.mark.migration_startup]
class TestMigrationRecovery:
@pytest.mark.parametrize("after_commit", (False, True))
def test_container_owner_crash(self, containers: Containers, database: Database, after_commit: bool) -> None:
interrupt_owner(containers, database, after_commit, stop_database_session=False)
history: Final = database.history()
assert database.query(
"SELECT applied_steps_count FROM _prisma_migrations WHERE migration_name = %s", (COMPLETE.name,)
) == ((int(after_commit),),)
with ExitStack() as stack:
successors: Final = start_replicas(stack, containers, database, (COMPLETE if after_commit else GATED,))
if after_commit:
ready(successors, database)
assert_completed(database)
else:
unconfirmed(successors, database)
assert database.history() == history
@pytest.mark.parametrize("after_commit", (False, True))
def test_owner_and_database_session_crash(
self, containers: Containers, database: Database, after_commit: bool
) -> None:
interrupt_owner(containers, database, after_commit)
history: Final = database.history()
with ExitStack() as stack:
successors: Final = start_replicas(stack, containers, database, (COMPLETE,))
if after_commit:
ready(successors, database)
assert_completed(database)
return
unconfirmed(successors, database)
assert database.history() == history
with containers.start(database, (COMPLETE,)) as restarted:
unconfirmed((restarted,), database)
assert database.history() == history
@pytest.mark.parametrize("later_failure", (False, True))
def test_remaining_migrations_after_recovery(
self, containers: Containers, database: Database, later_failure: bool
) -> None:
original: Final = confirmed_history(database)
next_migration: Final = Migration(
NEXT.name,
f"SELECT pg_advisory_lock({GATE_KEY}); "
+ (FATAL.script if later_failure else NEXT.script)
+ f" SELECT pg_advisory_unlock({GATE_KEY});",
)
with ExitStack() as stack:
with database.lock():
owner: Final = stack.enter_context(containers.start(database, (COMPLETE, next_migration)))
def pending() -> bool:
observation: Final = owner.observe()
assert observation.exit_code is None and not observation.ready, (
"Recovered owner served before pending SQL completed"
)
return bool(database.blocked())
until("recovering owner reached the next migration", pending)
assert_original_proof(database, original, True)
assert not database.exists("migration_next")
followers: Final = start_replicas(stack, containers, database, (COMPLETE, next_migration), count=2)
replicas: Final = (owner, *followers)
waiting(replicas, 1)
if later_failure:
failed(replicas, NEXT.name)
assert database.query(
"SELECT finished_at IS NULL, logs LIKE %s FROM _prisma_migrations WHERE migration_name = %s",
("%MIGRATION_TEST_FATAL%", NEXT.name),
) == ((True, True),)
else:
ready(replicas, database)
assert database.query("SELECT id FROM migration_next") == ((2,),)
assert_original_proof(database, original, True)
def test_second_crash_during_recovery_is_atomic(self, containers: Containers, database: Database) -> None:
original: Final = confirmed_history(database)
pause_completion(database)
with database.lock():
with containers.start(database, (COMPLETE,)) as recovering:
until("history update blocked before commit", lambda: bool(database.blocked()))
assert_original_proof(database, original, False)
blocked: Final = database.blocked()
assert len(blocked) == 1
assert database.query("SELECT pg_terminate_backend(%s)", (blocked[0][0],)) == ((True,),)
failed((recovering,), "Lost or could not establish v2 migration coordination")
assert_original_proof(database, original, False)
with ExitStack() as stack:
ready(start_replicas(stack, containers, database, (COMPLETE,)), database)
assert_original_proof(database, original, True)
def test_competing_recovery_rechecks_stale_failures(self, containers: Containers, database: Database) -> None:
original: Final = confirmed_history(database)
with ExitStack() as stack:
with database.lock(COORDINATOR_LOCK):
replicas: Final = start_replicas(stack, containers, database, (COMPLETE,))
until(
"all replicas observed the unfinished migration",
lambda: all(
"Waiting for the v2 migration coordinator lock" in replica.logs() for replica in replicas
),
)
assert_original_proof(database, original, False)
ready(replicas, database)
assert_original_proof(database, original, True)
@pytest.mark.parametrize(
"fault", ("no_steps", "extra_steps", "failure_logs", "checksum", "duplicate_history", "missing_script")
)
def test_unproven_history_is_never_repaired(
self,
containers: Containers,
database: Database,
fault: Literal["no_steps", "extra_steps", "failure_logs", "checksum", "duplicate_history", "missing_script"],
) -> None:
confirmed_history(database)
match fault:
case "no_steps":
database.execute(
"UPDATE _prisma_migrations SET applied_steps_count = 0 WHERE migration_name = %s", (COMPLETE.name,)
)
case "extra_steps":
database.execute(
"UPDATE _prisma_migrations SET applied_steps_count = 2 WHERE migration_name = %s", (COMPLETE.name,)
)
case "failure_logs":
database.execute(
"UPDATE _prisma_migrations SET logs = 'permission denied' WHERE migration_name = %s",
(COMPLETE.name,),
)
case "checksum":
database.execute(
"UPDATE _prisma_migrations SET checksum = %s WHERE migration_name = %s", ("0" * 64, COMPLETE.name)
)
case "duplicate_history":
database.execute(
"INSERT INTO _prisma_migrations (id, migration_name, checksum, applied_steps_count) SELECT %s, migration_name, checksum, applied_steps_count FROM _prisma_migrations WHERE migration_name = %s",
(str(uuid4()), COMPLETE.name),
)
case "missing_script":
pass
history: Final = database.history()
with containers.start(database, () if fault == "missing_script" else (COMPLETE,)) as replica:
unconfirmed((replica,), database)
assert database.history() == history
assert database.query("SELECT id FROM migration_effect") == ((1,),)
def test_coordinator_timeout_preserves_proof(self, containers: Containers, database: Database) -> None:
original: Final = confirmed_history(database)
with database.lock(COORDINATOR_LOCK):
with containers.start(
database, (COMPLETE,), environment={"LITELLM_MIGRATION_LOCK_TIMEOUT": "3"}
) as replica:
failed((replica,), "Timed out waiting for another v2 migration resolver")
assert_original_proof(database, original, False)
with containers.start(database, (COMPLETE,)) as replica:
ready((replica,), database)
assert_original_proof(database, original, True)

View file

@ -0,0 +1,100 @@
from contextlib import ExitStack
from typing import Final
import pytest
from .checks import COMPLETE, FATAL, GATED, assert_completed, start_replicas
from .containers import Containers, failed, ready, until, waiting
from .database import PRISMA_LOCK, Database, Databases, restricted_user
from .startup_models import Migration
pytestmark: Final = [pytest.mark.e2e, pytest.mark.migration_startup]
class TestMigrationStartup:
@pytest.mark.parametrize("replicas,v2", ((1, True), (3, True), (1, False)))
def test_fresh_database(self, containers: Containers, databases: Databases, replicas: int, v2: bool) -> None:
with databases.create() as database, ExitStack() as stack:
ready(tuple(stack.enter_context(containers.start(database, v2=v2)) for _ in range(replicas)), database)
assert database.query(
"SELECT count(*) FROM _prisma_migrations WHERE finished_at IS NULL AND rolled_back_at IS NULL"
) == ((0,),)
assert database.query("SELECT count(*) > 0 FROM _prisma_migrations") == ((True,),)
def test_concurrent_upgrade(self, containers: Containers, database: Database) -> None:
with ExitStack() as stack:
ready(start_replicas(stack, containers, database, (COMPLETE,)), database)
assert_completed(database)
def test_waiters_survive_prolonged_contention(self, containers: Containers, database: Database) -> None:
with ExitStack() as stack:
with database.lock():
owner: Final = stack.enter_context(containers.start(database, (GATED,)))
until("owner blocked in migration SQL", lambda: bool(database.blocked()))
followers: Final = start_replicas(stack, containers, database, (GATED,), count=2)
until("both followers attempted Prisma locking", lambda: len(database.blocked(PRISMA_LOCK)) == 2)
waiting((owner, *followers), 120)
ready((owner, *followers), database)
assert_completed(database, GATED)
def test_lock_deadline_then_restart(self, containers: Containers, database: Database) -> None:
history: Final = database.history()
with database.lock(PRISMA_LOCK):
with containers.start(
database, (COMPLETE,), environment={"LITELLM_MIGRATION_LOCK_TIMEOUT": "12"}
) as replica:
until("Prisma lock contention", lambda: bool(database.blocked(PRISMA_LOCK)))
failed((replica,), "Timed out waiting for")
assert database.history() == history
assert not database.exists("migration_effect")
with containers.start(database, (COMPLETE,)) as restarted:
ready((restarted,), database)
assert_completed(database)
def test_fatal_sql(self, containers: Containers, database: Database) -> None:
with ExitStack() as stack:
replicas: Final = start_replicas(stack, containers, database, (FATAL,))
failed(replicas, COMPLETE.name)
assert database.query(
"SELECT count(*) FROM _prisma_migrations WHERE migration_name = %s AND logs LIKE %s AND finished_at IS NULL",
(COMPLETE.name, "%MIGRATION_TEST_FATAL%"),
) == ((1,),)
def test_duplicate_object_does_not_hide_incomplete_sql(self, containers: Containers, database: Database) -> None:
database.execute(
"CREATE TABLE migration_existing (id int PRIMARY KEY); INSERT INTO migration_existing VALUES (42)"
)
migration: Final = Migration(
COMPLETE.name, "CREATE TABLE migration_existing (id int PRIMARY KEY); " + COMPLETE.script
)
with ExitStack() as stack:
failed(start_replicas(stack, containers, database, (migration,)), COMPLETE.name)
assert not database.exists("migration_effect")
assert database.query("SELECT id FROM migration_existing") == ((42,),)
assert database.query(
"SELECT finished_at IS NULL FROM _prisma_migrations WHERE migration_name = %s", (COMPLETE.name,)
) == ((True,),)
@pytest.mark.parametrize("v2", (True, False))
def test_restart_preserves_history_and_data(self, containers: Containers, database: Database, v2: bool) -> None:
history: Final = database.history()
before: Final = database.query('SELECT token FROM "LiteLLM_VerificationToken" ORDER BY token')
for _ in range(2):
with containers.start(database, v2=v2) as replica:
ready((replica,), database)
assert database.history() == history
assert set(before).issubset(database.query('SELECT token FROM "LiteLLM_VerificationToken" ORDER BY token'))
def test_disabled_migrations(self, containers: Containers, database: Database) -> None:
history: Final = database.history()
with containers.start(database, (FATAL,), disabled=True) as replica:
ready((replica,), database)
assert database.history() == history
def test_insufficient_privileges(self, containers: Containers, database: Database) -> None:
history: Final = database.history()
with restricted_user(database) as limited:
with containers.start(limited, (COMPLETE,)) as replica:
failed((replica,), "permission denied")
assert database.history() == history
assert not database.exists("migration_effect")

View file

@ -728,12 +728,36 @@ ERROR: relation "SomeTable" already exists
"""
@pytest.mark.parametrize(
"pooled,direct,expected",
(
("postgresql://pool/db?pgbouncer=true", None, "postgresql://pool/db?pgbouncer=true"),
("postgresql://pool/db?pgbouncer=true", "postgresql://writer/db", "postgresql://writer/db?schema=public"),
(
"postgresql://pool/db?schema=tenant%20one&pgbouncer=true",
"postgresql://writer/db?sslmode=require&schema=wrong",
"postgresql://writer/db?sslmode=require&schema=tenant+one",
),
),
)
def test_v2_migrations_use_the_direct_connection_with_the_runtime_schema(pooled, direct, expected):
from litellm_proxy_extras.migration_lock import migration_environment
environment = {"DATABASE_URL": pooled, "PRISMA_OFFLINE_MODE": "true"}
configured = {**environment, **({"DIRECT_URL": direct} if direct else {})}
migrated = migration_environment(configured)
assert migrated["DATABASE_URL"] == expected
assert migrated["PRISMA_OFFLINE_MODE"] == "true"
assert configured["DATABASE_URL"] == pooled
class _MigrateDeployHarness:
"""Drives _setup_database_v2 with a scripted sequence of
`prisma migrate deploy` outcomes, with every recovery command faked out so
nothing touches a database or the packaged migrations directory."""
def __init__(self, monkeypatch, tmp_path, outcomes, repeat_last=False):
def __init__(self, monkeypatch, tmp_path, outcomes, repeat_last=False, confirmed_migrations=()):
import subprocess as subprocess_module
import litellm_proxy_extras.utils as utils_module
@ -744,16 +768,10 @@ class _MigrateDeployHarness:
self._outcomes = list(outcomes)
self._repeat_last = repeat_last
self._subprocess_module = subprocess_module
self.confirmed_migrations = set(confirmed_migrations)
monkeypatch.delenv("DATABASE_URL", raising=False)
monkeypatch.setattr(
ProxyExtrasDBManager, "_get_prisma_dir", staticmethod(lambda: str(tmp_path))
)
monkeypatch.setattr(
ProxyExtrasDBManager,
"_create_baseline_migration",
staticmethod(self._fake_baseline),
)
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", staticmethod(lambda: str(tmp_path)))
monkeypatch.setattr(
ProxyExtrasDBManager,
"_roll_back_migration",
@ -765,13 +783,15 @@ class _MigrateDeployHarness:
staticmethod(self.resolved.append),
)
monkeypatch.setattr(utils_module.prisma_toolchain, "run_prisma", self._fake_run)
monkeypatch.setattr(utils_module, "_get_prisma_env", lambda: {})
monkeypatch.setattr(utils_module.time, "sleep", lambda seconds: None)
self.baseline_succeeds = True
def _fake_baseline(self, *args, **kwargs):
self.baselines += 1
return self.baseline_succeeds
if not self.baseline_succeeds:
raise RuntimeError("The existing schema was not verified")
def _next_outcome(self):
if self._outcomes:
@ -791,79 +811,126 @@ class _MigrateDeployHarness:
raise self._subprocess_module.CalledProcessError(1, cmd, stderr=outcome)
def run(self):
return ProxyExtrasDBManager._setup_database_v2(use_migrate=True)
while not ProxyExtrasDBManager._run_database_v2(
use_migrate=True,
recover_completed=self._fake_recovery,
baseline_existing=self._fake_baseline,
):
continue
return True
def _fake_recovery(self, name):
if name not in self.confirmed_migrations:
return False
self.confirmed_migrations.remove(name)
self.resolved.append(name)
return True
class TestMigrateDeployAttemptAccounting:
"""A `prisma db push` database has a full schema and no ledger, so the v2
resolver baselines it and then works through every migration whose objects
already exist. Those recoveries make progress, so they must not spend the
retry budget, which is there to stop a run that is getting nowhere."""
def test_a_push_created_database_finishes_bootstrapping(
self, monkeypatch, tmp_path
):
already_there = [
"20250329084805_new_cron_job_table",
"20250806095134_rename_alias_to_server_name_mcp_table",
"20260224203854_add_agent_object_permissions_table",
"20260301120000_fourth_table",
"20260302120000_fifth_table",
"20260303120000_sixth_table",
]
def test_a_push_created_database_finishes_bootstrapping(self, monkeypatch, tmp_path):
harness = _MigrateDeployHarness(
monkeypatch,
tmp_path,
[_P3005_STDERR]
+ [_p3018_stderr(name) for name in already_there]
+ ["ok"],
[_P3005_STDERR, "ok"],
)
assert harness.run() is True
assert harness.baselines == 1
assert harness.resolved == already_there
assert len(harness.deploy_calls) == len(already_there) + 2
assert harness.resolved == []
assert len(harness.deploy_calls) == 2
def test_repeated_recovery_of_one_migration_still_gives_up(
self, monkeypatch, tmp_path
):
def test_repeated_recovery_of_one_migration_still_gives_up(self, monkeypatch, tmp_path):
harness = _MigrateDeployHarness(
monkeypatch,
tmp_path,
[_p3018_stderr("20250329084805_new_cron_job_table")],
repeat_last=True,
confirmed_migrations=("20250329084805_new_cron_job_table",),
)
with pytest.raises(RuntimeError):
harness.run()
assert len(harness.deploy_calls) <= _ATTEMPT_BUDGET + 1
assert len(harness.deploy_calls) == 2
assert harness.resolved == ["20250329084805_new_cron_job_table"]
def test_timeouts_still_spend_the_budget(self, monkeypatch, tmp_path):
harness = _MigrateDeployHarness(
monkeypatch, tmp_path, ["timeout"], repeat_last=True
)
harness = _MigrateDeployHarness(monkeypatch, tmp_path, ["timeout"], repeat_last=True)
with pytest.raises(RuntimeError):
harness.run()
assert len(harness.deploy_calls) == _ATTEMPT_BUDGET
def test_a_baseline_that_never_lands_stops_after_the_budget(
self, monkeypatch, tmp_path
):
harness = _MigrateDeployHarness(
monkeypatch, tmp_path, [_P3005_STDERR], repeat_last=True
)
def test_an_unverified_baseline_stops_without_replaying_migrations(self, monkeypatch, tmp_path):
harness = _MigrateDeployHarness(monkeypatch, tmp_path, [_P3005_STDERR], repeat_last=True)
harness.baseline_succeeds = False
with pytest.raises(RuntimeError):
harness.run()
assert len(harness.deploy_calls) == _ATTEMPT_BUDGET
assert len(harness.deploy_calls) == 1
def test_lock_contention_does_not_spend_the_failure_budget(self, monkeypatch, tmp_path):
harness = _MigrateDeployHarness(
monkeypatch,
tmp_path,
["Error: P1002\nTimed out waiting for the advisory lock"] * 6 + ["ok"],
)
assert harness.run() is True
assert len(harness.deploy_calls) == 7
def test_duplicate_object_error_without_completion_proof_is_fatal(self, monkeypatch, tmp_path):
harness = _MigrateDeployHarness(monkeypatch, tmp_path, [_p3018_stderr("20260101000000_x")])
with pytest.raises(RuntimeError, match="cannot be auto-recovered"):
harness.run()
assert harness.resolved == []
assert len(harness.deploy_calls) == 1
@pytest.mark.parametrize("name", ("20260101000000_x", "20260101000000_migration with spaces"))
def test_an_interrupted_migration_with_confirmed_sql_can_finish(self, monkeypatch, tmp_path, name):
harness = _MigrateDeployHarness(
monkeypatch,
tmp_path,
[f"Error: P3009\nThe `{name}` migration failed", "ok"],
confirmed_migrations=(name,),
)
assert harness.run() is True
assert harness.resolved == [name]
def test_an_interrupted_migration_without_confirmation_stops(self, monkeypatch, tmp_path):
name = "20260101000000_x"
started = "2026-09-12 20:15:06.694553 UTC"
report = f"Error: P3009\nThe `{name}` migration started at {started} failed"
harness = _MigrateDeployHarness(
monkeypatch,
tmp_path,
[report],
)
with pytest.raises(RuntimeError, match="Migration completion could not be verified") as failure:
harness.run()
message = str(failure.value)
assert name in message
assert started in message
assert "start record but no successful completion record" in message
assert "cannot determine whether its SQL committed" in message
assert "avoid repeating or skipping database changes" in message
assert "_prisma_migrations" in message
assert "migration.sql" in message
assert "same database" in message
assert "Only after verifying every migration change is present" in message
assert "prisma migrate resolve --applied <migration_name>" in message
assert "Only after verifying no migration changes remain" in message
assert "prisma migrate resolve --rolled-back <migration_name>" in message
assert "leave migration history unchanged" in message
assert "Repeated restarts alone" in message
assert report in message
assert len(harness.deploy_calls) == 1
assert harness.resolved == []
def test_an_unrecoverable_error_is_not_retried(self, monkeypatch, tmp_path):
harness = _MigrateDeployHarness(
monkeypatch,
tmp_path,
["Error: P3018\n\nMigration name: 20260101000000_x\n\nERROR: syntax error at or near \"SLECT\"\n"],
['Error: P3018\n\nMigration name: 20260101000000_x\n\nERROR: syntax error at or near "SLECT"\n'],
repeat_last=True,
)
@ -873,6 +940,36 @@ class TestMigrateDeployAttemptAccounting:
assert harness.resolved == []
@pytest.mark.parametrize(
"steps,logs,script,expected",
(
(1, "", b"CREATE TABLE item (id int);", True),
(0, "", b"CREATE TABLE item (id int);", False),
(0, "already exists", b"CREATE TABLE item (id int);", False),
(1, "permission denied", b"CREATE TABLE item (id int);", False),
(1, "", b"CREATE TABLE item (id text);", False),
(2, "", b"CREATE TABLE item (id int);", False),
),
)
def test_migration_completion_requires_a_matching_successful_script(steps, logs, script, expected):
import hashlib
from litellm_proxy_extras.migration_recovery import MigrationProgress
progress = MigrationProgress(hashlib.sha256(b"CREATE TABLE item (id int);").hexdigest(), steps, logs)
assert progress.confirms_completion(script) is expected
def test_prisma_lock_waiting_has_its_own_deadline():
from litellm_proxy_extras.utils import _MigrateAttemptBudget
budget = _MigrateAttemptBudget(attempts_left=4, contention_seconds_left=2)
waiting = budget.after_contention(1)
assert waiting.attempts_left == 4
with pytest.raises(RuntimeError, match="advisory lock"):
waiting.after_contention(2)
class TestJWTKeyMappingCascade:
"""Regression tests for issue #33702.

View file

@ -0,0 +1,36 @@
import importlib.util
from pathlib import Path
from typing import Final
import pytest
SCRIPT: Final = Path(__file__).resolve().parents[2] / ".circleci/scripts/run_migration_tests.py"
SPEC: Final = importlib.util.spec_from_file_location("migration_ci", SCRIPT)
assert SPEC is not None and SPEC.loader is not None
MODULE: Final = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
@pytest.mark.parametrize(
"xml,expected,exit_code,passed",
(
('<testsuites><testsuite><testcase name="a"/><testcase name="b"/></testsuite></testsuites>', 2, 0, True),
('<testsuites><testsuite><testcase name="a"/></testsuite></testsuites>', 2, 0, False),
("<testsuite><testcase><failure/></testcase></testsuite>", 1, 0, False),
("<testsuite><testcase><error/></testcase></testsuite>", 1, 0, False),
("<testsuite><testcase><skipped/></testcase></testsuite>", 1, 0, False),
("<testsuite><testcase/></testsuite>", 1, 1, False),
("<testsuite><testcase/></testsuite>", 1, 5, False),
("<testsuite/>", 1, 0, False),
("<broken", 1, 0, False),
(None, 1, 0, False),
('<testsuite><testcase name="a"/><testcase name="a"/></testsuite>', 2, 0, False),
),
)
def test_only_a_complete_passing_suite_can_certify_an_image(
tmp_path: Path, xml: str | None, expected: int, exit_code: int, passed: bool
) -> None:
path: Final = tmp_path / "results.xml"
if xml is not None:
path.write_text(xml)
assert MODULE.successful_junit(path, expected, exit_code) is passed