Merge pull request #41460 from BerriAI/litellm_pg10_migration_default_guard

ci(migrations): flag defaulted ADD COLUMN on request-log tables
This commit is contained in:
Yassin Kortam 2026-09-16 13:45:48 -07:00 committed by GitHub
commit 8cdb275ec0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 189 additions and 3 deletions

View file

@ -15,6 +15,14 @@ all read the whole table and all pass. That is deliberate: a rule wide enough to
reach them fires on most ordinary migrations, and a marker everyone adds by reflex
stops carrying information. The outage this was written for was a backfill.
The one schema change banned outright is `ADD COLUMN ... DEFAULT` on a table in
`REQUEST_LOG_TABLES`, the tables that hold a row per request. Postgres 11 stores such
a default as metadata and touches no rows, but Postgres 10, which is supported,
rewrites the whole heap and rebuilds every index under an `ACCESS EXCLUSIVE` lock,
which on a spend-log-sized table is the same outage as a backfill. Every other table
is small enough that the rewrite is not worth a rule, and a column added to a log
table without a default is still free on every version.
Flagged, per statement, by its leading keyword:
UPDATE rewrites every matching row, and `WHERE` does not bound the scan
@ -32,6 +40,10 @@ Flagged, per statement, by its leading keyword:
against the part of the statement holding it, so a writable CTE
bounded by its own `VALUES` list is not handed the query the statement
ends with as the rows it copies
ALTER only `ALTER TABLE` on a request-log table, and only when one of its
actions adds a column with a `DEFAULT`. An `ALTER COLUMN ... SET
DEFAULT` written after the column exists changes metadata alone, so it
passes, as does an `ADD CONSTRAINT`
Referential actions (`ON DELETE CASCADE`, `ON UPDATE CASCADE`) are schema, never a
statement's leading keyword, so they pass.
@ -85,7 +97,7 @@ would let one written for a `DO` block silence a rewrite added to that block lat
`GRANDFATHERED` freezes the violations that predate this check. Prisma records a
checksum for every applied migration and this repo treats applied files as
immutable, so those two cannot take an inline marker. The set is closed; a new
immutable, so those files cannot take an inline marker. The set is closed; a new
migration belongs nowhere in it.
"""
@ -102,11 +114,15 @@ MIGRATIONS_DIR = REPO_ROOT / "litellm-proxy-extras" / "litellm_proxy_extras" / "
GRANDFATHERED = frozenset(
{
"20250425182129_add_session_id",
"20260817000000_shadow_eval_multi_key",
"20260818000000_add_spend_log_timestamps",
"20260818224500_add_shadow_eval_stopped_by",
}
)
REQUEST_LOG_TABLES = frozenset({"LiteLLM_SpendLogs", "LiteLLM_ErrorLogs"})
MARKER = re.compile(r"--[ \t]*data-migration-ok:[ \t]*(\S.*?)[ \t]*$", re.MULTILINE)
DOLLAR_TAG = re.compile(r"\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$")
FIRST_WORD = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
@ -128,6 +144,8 @@ DEFINES_A_ROUTINE = re.compile(
)
QUALIFIED_NAME = r"(?:\"[^\"]*\"|[A-Za-z_][A-Za-z0-9_$]*)"
ROUTINE_NAME = re.compile(rf"\s*(?:{QUALIFIED_NAME}\s*\.\s*)?({QUALIFIED_NAME})")
TABLE_NAME = ROUTINE_NAME
ALTERS_A_TABLE = re.compile(r"\bALTER\s+TABLE\b(?:\s+IF\s+EXISTS)?(?:\s+ONLY)?", re.IGNORECASE)
OPENS_A_CALL = re.compile(r"\s*\(")
NAMES_AN_INDEX = re.compile(r"\bCREATE\b.+\bINDEX\b", re.IGNORECASE | re.DOTALL)
INTRODUCES_A_RELATION = frozenset({"TABLE", "INTO", "REFERENCES", "EXISTS", "COPY"})
@ -185,6 +203,10 @@ statement with the bound spelled out:
-- data-migration-ok: <what bounds this>
UPDATE ...
On Postgres 10 an `ADD COLUMN ... DEFAULT` on a request-log table rewrites the table
too. Add the column nullable with no default, then set the default in a separate
`ALTER COLUMN ... SET DEFAULT`, which never touches existing rows.
"""
@ -537,6 +559,51 @@ def row_source_in(text: str) -> str | None:
return next((word for word in ("SELECT", "TABLE") if contains(text, word)), None)
def rewrites_a_log_table(clause: str, region: str, base: int) -> str | None:
"""The keyword to report when an `ALTER TABLE` adds a defaulted column to a request-log
table, which Postgres 10 answers by rewriting the whole table. The table is read from the
region rather than the masked clause, since masking blanks the quoted name in place, after
stepping over any comment sitting between `TABLE` and the name, which masking blanked as
well. Each action of the statement is read on its own so that a `SET DEFAULT` on one column
does not stand in for a default on a column another action adds."""
altered = ALTERS_A_TABLE.search(clause)
if altered is None:
return None
named = TABLE_NAME.match(region, skip_comments(region, base + altered.end()))
if named is None or named.group(1).strip('"') not in REQUEST_LOG_TABLES:
return None
actions = strip_parens(clause[named.end() - base :]).split(",")
if not any(adds_a_defaulted_column(action) for action in actions):
return None
return f"ADD COLUMN ... DEFAULT on {named.group(1)}"
def skip_comments(sql: str, start: int) -> int:
index = start
while index < len(sql):
pair = sql[index : index + 2]
if pair == "--":
stop = sql.find("\n", index)
index = len(sql) if stop == -1 else stop
elif pair == "/*":
index = skip_block_comment(sql, index)
elif sql[index].isspace():
index += 1
else:
return index
return index
def adds_a_defaulted_column(action: str) -> bool:
"""Whether an `ALTER TABLE` action is an `ADD COLUMN` carrying a column default. A `DEFAULT`
right after `SET` is the referential action of an inline foreign key, which fills nothing
in, so it does not count."""
words = tuple(word.group().upper() for word in FIRST_WORD.finditer(action))
if words[:1] != ("ADD",) or words[1:2] == ("CONSTRAINT",):
return False
return any(word == "DEFAULT" and previous != "SET" for previous, word in zip(words, words[1:]))
def hands_off_sql(statement: str, executed: frozenset[str]) -> bool:
"""Whether a statement gives the server a string literal to run as SQL. `EXECUTE` runs one
outright, and so does `DO`, whose body is a string wherever it is not dollar-quoted. An
@ -724,9 +791,12 @@ def scan_region(
)
keyword = offending_keyword(clause)
if keyword is None or exempt:
if exempt:
continue
yield Violation(migration, line_of(document, offset + keyword_start(clause, base)), keyword)
found = keyword or rewrites_a_log_table(clause, region, base)
if found is None:
continue
yield Violation(migration, line_of(document, offset + keyword_start(clause, base)), found)
for body in bodies:
if not runs_when_applied(masked, region, bodies, runnable, identifiers, body):

View file

@ -89,6 +89,122 @@ class TestSchemaStatementsPass:
assert _keywords(tmp_path, "-- nothing to do here\n") == ()
SPEND_LOGS_DEFAULT = 'ADD COLUMN ... DEFAULT on "LiteLLM_SpendLogs"'
class TestDefaultedColumnsOnRequestLogTables:
def test_the_shipped_timestamp_migration_is_flagged(self, tmp_path):
sql = (
'ALTER TABLE "LiteLLM_SpendLogs"\n'
'ADD COLUMN IF NOT EXISTS "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n'
'ADD COLUMN IF NOT EXISTS "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;\n'
)
assert _keywords(tmp_path, sql) == (SPEND_LOGS_DEFAULT,)
def test_a_nullable_column_with_a_default_is_flagged(self, tmp_path):
sql = 'ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN IF NOT EXISTS "proxy_server_request" JSONB DEFAULT \'{}\';'
assert _keywords(tmp_path, sql) == (SPEND_LOGS_DEFAULT,)
def test_error_logs_is_a_request_log_table(self, tmp_path):
sql = 'ALTER TABLE "LiteLLM_ErrorLogs" ADD COLUMN "status" TEXT DEFAULT \'failure\';'
assert _keywords(tmp_path, sql) == ('ADD COLUMN ... DEFAULT on "LiteLLM_ErrorLogs"',)
def test_a_column_without_a_default_passes(self, tmp_path):
assert _keywords(tmp_path, 'ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN IF NOT EXISTS "status" TEXT;') == ()
def test_set_default_on_an_existing_column_passes(self, tmp_path):
sql = 'ALTER TABLE "LiteLLM_SpendLogs" ALTER COLUMN "status" SET DEFAULT \'success\';'
assert _keywords(tmp_path, sql) == ()
def test_adding_a_column_and_defaulting_another_passes(self, tmp_path):
sql = 'ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN "a" TEXT, ALTER COLUMN "b" SET DEFAULT 1;'
assert _keywords(tmp_path, sql) == ()
def test_a_referential_set_default_on_the_new_column_passes(self, tmp_path):
sql = (
'ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN "team_id" TEXT '
'REFERENCES "LiteLLM_TeamTable"("team_id") ON DELETE SET DEFAULT;'
)
assert _keywords(tmp_path, sql) == ()
def test_a_column_default_beside_a_referential_set_default_is_flagged(self, tmp_path):
sql = (
'ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN "team_id" TEXT DEFAULT \'t\' '
'REFERENCES "LiteLLM_TeamTable"("team_id") ON DELETE SET DEFAULT;'
)
assert _keywords(tmp_path, sql) == (SPEND_LOGS_DEFAULT,)
def test_a_block_comment_before_the_table_name_is_flagged(self, tmp_path):
sql = 'ALTER TABLE /* audit */ "LiteLLM_SpendLogs" ADD COLUMN "a" TEXT DEFAULT \'x\';'
assert _keywords(tmp_path, sql) == (SPEND_LOGS_DEFAULT,)
def test_a_line_comment_before_the_table_name_is_flagged(self, tmp_path):
sql = 'ALTER TABLE IF EXISTS -- audit\n"LiteLLM_SpendLogs" ADD COLUMN "a" TEXT DEFAULT \'x\';'
assert _keywords(tmp_path, sql) == (SPEND_LOGS_DEFAULT,)
def test_a_defaulted_column_among_other_actions_is_flagged(self, tmp_path):
sql = 'ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN "a" TEXT, ADD COLUMN "b" INTEGER DEFAULT 0;'
assert _keywords(tmp_path, sql) == (SPEND_LOGS_DEFAULT,)
def test_a_comma_inside_the_type_does_not_split_the_action(self, tmp_path):
sql = 'ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN "a" NUMERIC(10, 2) DEFAULT 0;'
assert _keywords(tmp_path, sql) == (SPEND_LOGS_DEFAULT,)
def test_a_foreign_key_set_default_action_passes(self, tmp_path):
sql = (
'ALTER TABLE "LiteLLM_SpendLogs" ADD CONSTRAINT "fk" FOREIGN KEY ("team_id") '
'REFERENCES "LiteLLM_TeamTable"("team_id") ON DELETE SET DEFAULT;'
)
assert _keywords(tmp_path, sql) == ()
def test_a_default_inside_a_check_constraint_passes(self, tmp_path):
sql = 'ALTER TABLE "LiteLLM_SpendLogs" ADD CONSTRAINT "c" CHECK ("status" IS DISTINCT FROM DEFAULT);'
assert _keywords(tmp_path, sql) == ()
def test_other_tables_pass(self, tmp_path):
sql = 'ALTER TABLE "LiteLLM_DailyUserSpend" ADD COLUMN "a" INTEGER NOT NULL DEFAULT 0;'
assert _keywords(tmp_path, sql) == ()
def test_schema_qualified_and_if_exists_forms_are_flagged(self, tmp_path):
sql = (
'ALTER TABLE "public"."LiteLLM_SpendLogs" ADD COLUMN "a" INTEGER DEFAULT 0;\n'
'ALTER TABLE IF EXISTS ONLY "LiteLLM_SpendLogs" ADD COLUMN "b" INTEGER DEFAULT 0;\n'
)
assert _keywords(tmp_path, sql) == (SPEND_LOGS_DEFAULT, SPEND_LOGS_DEFAULT)
def test_inside_a_do_block_is_flagged(self, tmp_path):
sql = (
"DO $$\nBEGIN\n"
" IF NOT EXISTS (SELECT 1 FROM information_schema.columns\n"
" WHERE table_name = 'LiteLLM_SpendLogs' AND column_name = 'a') THEN\n"
' ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN "a" INTEGER DEFAULT 0;\n'
" END IF;\nEND $$;\n"
)
violations = _scan(tmp_path, sql)
assert [(violation.line, violation.keyword) for violation in violations] == [(5, SPEND_LOGS_DEFAULT)]
def test_handed_to_execute_is_flagged(self, tmp_path):
sql = 'DO $$ BEGIN EXECUTE \'ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN "a" INTEGER DEFAULT 0\'; END $$;'
assert _keywords(tmp_path, sql) == (SPEND_LOGS_DEFAULT,)
def test_in_a_comment_passes(self, tmp_path):
sql = '-- ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN "a" INTEGER DEFAULT 0;\nSELECT 1;'
assert _keywords(tmp_path, sql) == ()
def test_a_marker_exempts_it(self, tmp_path):
sql = (
"-- data-migration-ok: table is created empty two statements up\n"
'ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN "a" INTEGER DEFAULT 0;'
)
assert _keywords(tmp_path, sql) == ()
def test_the_report_names_the_table(self, tmp_path):
sql = '\nALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN "a" INTEGER DEFAULT 0;'
rendered = _scan(tmp_path, sql)[0].render()
assert "20260101000000_fixture/migration.sql:2" in rendered
assert 'ADD COLUMN ... DEFAULT on "LiteLLM_SpendLogs" rewrites existing rows at boot' in rendered
class TestInsert:
def test_insert_values_is_bounded_and_passes(self, tmp_path):
assert _keywords(tmp_path, "INSERT INTO \"Foo\" (\"id\") VALUES ('a'), ('b');") == ()