Skip to content

fix(proxy): wait for the spend-log table before creating startup views - #41974

Merged
mateo-berri merged 4 commits into
mainfrom
litellm_fix_startup_view_creation_race
Sep 20, 2026
Merged

mateo-berri merged 4 commits into
mainfrom
litellm_fix_startup_view_creation_race

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Fresh database: the proxy boots while the migrations Job is still running
  • Startup view creation ran once, hit the missing table, and died silently
  • /global/spend* routes and the Usage tab 500 until someone restarts the pod
  • The spend-log row count stayed at 0 for the life of the process

How it solves it:

  • One held background task waits for the spend-log table to exist
  • Once it does, it creates the views and records the row count
  • Keeps retrying through the migration window, for up to 15 minutes
  • Logs one clear ERROR naming what blocked it if it never succeeds
  • Shutdown cancels the task instead of leaking it

User Flow

Before: an operator's fresh helm install comes up with the Usage tab broken, and it stays broken until they restart the pod

  1. They run helm install against an empty Postgres. The migrations Job and the proxy Deployment start at the same time, and the pod runs with DISABLE_SCHEMA_UPDATE=true
  2. About 40 s later GET https://litellm-domain/health/liveliness returns 200 while the Job is still creating tables
  3. The proxy log shows a Task exception was never retrieved traceback ending in relation "LiteLLM_SpendLogs" does not exist, and nothing retries after the Job finishes 80 s later
  4. They open https://litellm-domain/ui/?page=usage and the Global Spend panels show an error instead of numbers
  5. GET https://litellm-domain/global/spend with the master key returns 500 with relation "MonthlyGlobalSpend" does not exist in the body. GET /global/spend/keys and GET /global/spend/models return a bare 500 Internal server error, GET /global/spend/tags?start_date=...&end_date=... returns 500 naming DailyTagSpend, and GET /global/spend/logs returns 500 naming MonthlyGlobalSpend
  6. Those routes keep returning 500 until they delete the pod and let it boot again against the migrated database

After: the same install serves the Usage tab on its own as soon as the migrations finish, with no restart

  1. They run helm install against an empty Postgres. The migrations Job and the proxy Deployment start at the same time, and the pod runs with DISABLE_SCHEMA_UPDATE=true
  2. About 40 s later GET https://litellm-domain/health/liveliness returns 200 while the Job is still creating tables
  3. The proxy log shows no traceback. With debug logging on it prints Waiting for table "LiteLLM_SpendLogs" before creating the spend views every 5 s
  4. Within 5 s of the Job finishing, the proxy creates the eight spend views on its own
  5. GET https://litellm-domain/global/spend with the master key returns 200 with an empty list, and GET /global/spend/keys, GET /global/spend/models, GET /global/spend/tags?start_date=...&end_date=..., and GET /global/spend/logs all return 200
  6. https://litellm-domain/ui/?page=usage shows the Global Spend panels with zeros instead of an error
  7. If nobody ever runs the migrations, after 15 minutes the log carries one ERROR line naming the missing table and telling them to run the migrations and restart. If the table is there but every attempt fails for another reason, such as a database role without CREATE on the schema, that ERROR line quotes the last failure instead

Relevant issues

Affected release

Linear ticket

Resolves LIT-5211

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*, make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Both legs ran the same rig on this machine: a fresh Postgres (initdb + pg_ctl on a random port, empty database), two proxies of 2 uvicorn workers each booted against that empty database with DISABLE_SCHEMA_UPDATE=true (the helm ordering, where the pods come up while the migration Job is still running), the migration Job started only after both proxies answered /health/liveliness, five spend routes probed on both proxies right after the migrations finished, one real chat completion per proxy (claude-haiku-4-5 through Anthropic, real spend), the same probes again 75 s later, and the old Usage page of the Admin UI opened on proxy A. The only difference between the legs is the commit the proxies were started from

# empty database, both proxies up before the migrations run
DATABASE_URL="$DB" DISABLE_SCHEMA_UPDATE=true STORE_MODEL_IN_DB=false litellm --config config.yaml --port "$PORT_A" --num_workers 2 &
DATABASE_URL="$DB" DISABLE_SCHEMA_UPDATE=true STORE_MODEL_IN_DB=false litellm --config config.yaml --port "$PORT_B" --num_workers 2 &
until curl -sf "http://127.0.0.1:$PORT_A/health/liveliness" && curl -sf "http://127.0.0.1:$PORT_B/health/liveliness"; do sleep 1; done
# the migration Job
DATABASE_URL="$DB" DISABLE_SCHEMA_UPDATE=false python litellm/proxy/prisma_migration.py
# the probes, run on both ports right after the migrations and again 75 s after the completions
for route in /global/spend /global/spend/keys /global/spend/models '/global/spend/tags?start_date=2026-09-13&end_date=2026-09-20' /global/spend/logs; do
  curl -s -w ' -> %{http_code}\n' -H "Authorization: Bearer $MASTER_KEY" "http://127.0.0.1:$PORT$route"
done
curl -s -X POST "http://127.0.0.1:$PORT/v1/chat/completions" -H "Authorization: Bearer $MASTER_KEY" -H 'Content-Type: application/json' \
  -d '{"model":"claude-haiku-4-5","messages":[{"role":"user","content":"Reply with the single word ok"}],"max_tokens":5}'

Before (8df260a)

Case 1: the spend routes on a database migrated after the proxies booted

  1. Boot and migrate. Observed: proxy A answered /health/liveliness 26 s after launch, proxy B 1 s later, both stayed 200 through the migrations, and the migration Job exited 0 with 82 tables 52 s after it started; 30 s after that the database still had 0 views

  2. Probe both proxies right after the migrations. Observed, identical on A and B:

    GET /global/spend -> 500 {"error":{"message":"/global/spend Errorrelation \"MonthlyGlobalSpend\" does not exist\nTraceback (most recent call last): ...
    GET /global/spend/keys -> 500 {"error":{"message":"Internal server error","type":"internal_server_error"}}
    GET /global/spend/models -> 500 {"error":{"message":"Internal server error","type":"internal_server_error"}}
    GET /global/spend/tags?start_date=2026-09-13&end_date=2026-09-20 -> 500 {"error":{"message":"/spend/tags Errorrelation \"DailyTagSpend\" does not exist\nTraceback (most recent call last): ...
    GET /global/spend/logs -> 500 {"error":{"message":"/global/spend/logs Errorrelation \"MonthlyGlobalSpend\" does not exist\nTraceback (most recent call last): ...
    
  3. One chat completion through each proxy. Observed: POST /v1/chat/completions -> 200 on both, "content":"ok", spend written

  4. Probe again 75 s later. Observed: the same five 500s on both proxies, the views never appeared, and the proxy log carried Task exception was never retrieved twice per process from the view setup that died at boot

Case 2: the old Usage page of the Admin UI on proxy A

  1. Sign in at http://127.0.0.1:$PORT_A/ui/ with the master key and open http://127.0.0.1:$PORT_A/ui/old-usage/. Observed in the network panel: /global/spend/tags 500, /global/spend/logs 500, /global/spend/keys?limit=5 500, /global/spend/models?limit=5 500 (/global/spend/provider, /global/spend/teams, /global/spend/all_tag_names, /global/spend/end_users 200 since they read tables, not views); 15 console errors, among them Error fetching overall spend: ApiError: /global/spend/logs Errorrelation "MonthlyGlobalSpend" does not exist, Error fetching top tags ... relation "DailyTagSpend" does not exist, Error fetching top keys Error: Internal server error, Error fetching top models Error: Internal server error
  2. What the page shows: Total Spend $0.0000, an empty Monthly Spend chart, Top Virtual Keys "No results", Top Models "No data", while Spend by Provider already lists anthropic for the completions that went through

before top
before keys/models

After (327447b)

Case 1: the spend routes on a database migrated after the proxies booted

  1. Boot and migrate. Observed: proxy A answered /health/liveliness 25 s after launch, proxy B 1 s later, both stayed 200 through the migrations, the migration Job exited 0 with 82 tables 52 s after it started, and all 8 views existed 1 s after that

  2. Probe both proxies right after the migrations. Observed, identical on A and B:

    GET /global/spend -> 200 {"spend":null,"max_budget":0.0}
    GET /global/spend/keys -> 200 []
    GET /global/spend/models -> 200 []
    GET /global/spend/tags?start_date=2026-09-13&end_date=2026-09-20 -> 200 {"spend_per_tag":[]}
    GET /global/spend/logs -> 200 []
    
  3. One chat completion through each proxy. Observed: POST /v1/chat/completions -> 200 on both, "content":"ok"

  4. Probe again 75 s later. Observed, identical on A and B, and no Task exception was never retrieved anywhere in either proxy log:

    GET /global/spend -> 200 {"spend":6.6e-05,"max_budget":0.0}
    GET /global/spend/keys -> 200 [{"api_key":"litellm_proxy_master_key","key_alias":null,"key_name":null,"total_spend":6.6e-05}]
    GET /global/spend/models -> 200 [{"model":"anthropic/claude-haiku-4-5","total_spend":6.6e-05}]
    GET /global/spend/tags?start_date=2026-09-13&end_date=2026-09-20 -> 200 {"spend_per_tag":[{"name":"User-Agent: curl/8.7.1","spend":0.0001,"log_count":2},{"name":"User-Agent: curl","spend":0.0001,"log_count":2}]}
    GET /global/spend/logs -> 200 [{"date":"2026-09-20","spend":6.6e-05}]
    

Case 2: the old Usage page of the Admin UI on proxy A

  1. Same sign-in, same page. Observed in the network panel: every /global/spend* request 200, including /global/spend/tags, /global/spend/logs, /global/spend/keys?limit=5, /global/spend/models?limit=5; 0 console errors
  2. What the page shows: a Monthly Spend bar for September, Top Virtual Keys with the litellm_pro… row, Top Models with anthropic/claude-haiku-4-5, Spend by Provider anthropic

after top
after keys/models

The tip 327447b is the merge of main into this branch to settle a conflict in tests/test_litellm/proxy/test_proxy_server.py; its diff against main is the same 7 files, +411/-38, as a114af2, so the /live-pr-risk legs recorded under Live PR risk below (schema mode, default mode, restricted role) still describe this tip

Observations from the run, none caused by this PR:

  • 500 body leaks a traceback path; pre-existing, untouched
  • Other startup readers still traceback at boot; pre-existing
  • NUM_SPEND_LOGS_ROWS 0 before, -1 after: reltuples of a fresh table
  • Old-usage page swallows 500s, renders empty cards; pre-existing
  • Before: "Task exception was never retrieved" twice per process; After: none
  • Dashboard honors PROXY_BASE_URL from .env; worker override used both legs

Type

🐛 Bug Fix
✅ Test

Design decisions

A held task instead of a wider backoff. Widening the max_tries / max_time on check_view_exists would still be a fire-and-forget task nobody awaits, so a failure would still vanish with "Task exception was never retrieved", and there is no number that covers a migrations Job of unknown length without also turning a real misconfiguration into a very long silent retry. The client now owns the task the same way it owns the DB health watchdog, so the failure path is a value the wrapper logs, shutdown can cancel it, and a second start is a no-op. The two backoff decorators stay as they were, as the inner retry for a flaky connection once the schema is there

Gate on the spend-log table with to_regclass, not on migration state. The proxy cannot know which migration set another image is applying, and db push setups have no _prisma_migrations table at all, so migration bookkeeping is the wrong signal. Prisma's advisory lock only says a migration is running right now, not whether the schema the views need exists. SELECT to_regclass($1) IS NOT NULL returns false instead of raising when the relation is missing, so each poll is one quiet parameterized query. The probe hands to_regclass the quoted, unqualified name "LiteLLM_SpendLogs" as the $1 bind value, so Postgres resolves it through the connection's search_path, the same way the CREATE VIEW statements and the ::regclass row-count check already resolve. An earlier revision of this PR qualified the name with DATABASE_SCHEMA (default public), which broke every deployment that picks its schema with ?schema= in DATABASE_URL and never sets DATABASE_SCHEMA: Prisma pointed the search_path at that schema, the migrations and the views landed there, and the probe looked in public forever, so the spend routes returned 500 on the fixed proxy where the merge base served them (the live A/B below shows it). Parsing ?schema= out of DATABASE_URL instead would have reimplemented Prisma's own resolution and still missed a role whose default search_path is set on the server, so the probe follows the connection

Keep polling after a raise, and remember why. LiteLLM_SpendLogs lands early in the migration set while LiteLLM_VerificationTokenView references columns the newest migrations add, so the first attempt after the table appears can still fail mid-migration. A failed attempt logs a WARNING with the exception and the loop tries again on the next tick; the same applies to a probe that fails on a connection blip. Each attempt reports one of three things, ready, table missing, or the exception it hit, so the ERROR at the deadline says "the table never appeared, run the migrations" only when that is what happened and otherwise quotes the last exception. A first draft folded all three into a boolean and would have blamed missing migrations for a permission error. Cancellation is not caught, since CancelledError is a BaseException. The row count is recorded on each attempt as soon as the table exists and before the views are created, rather than in a parallel task or only after the views succeed. An earlier revision ran it after check_view_exists, so a database role without CREATE on the schema, where view creation fails on every attempt, never got the count: /sso/get/ui_settings reported NUM_SPEND_LOGS_ROWS 0 where the merge base reported the real statistic, and the Admin UI's expensive-query guard reads that number, so a large table behind such a role would have lost the guard. The row-count query swallows its own errors and returns 0, so it cannot block the views

5 seconds and 15 minutes, hardcoded. A fresh-DB migration set took about 86 s in the reproduction, so a 5 s poll costs at most one extra cheap query per tick and lands the views within seconds of the Job finishing. The 15-minute cap turns a database nobody migrated into one ERROR that names the table and says what to do, instead of an infinite loop. Neither is a knob because the correct value does not vary by deployment: anyone whose migrations take longer than 15 minutes has a bigger problem than missing views, and the failure mode past the cap is loud

The e2e routes. The five view-backed routes in test_spend_routes.py were skipped with a pointer to this ticket. They now run, and because the views land a few seconds after the migrations rather than before the proxy answers health, those five get a bounded re-probe (probe_until_healthy, built on the harness's await_converged with its 120 s poll_timeout and 5 s poll_interval) while the other routes stay single-shot. The design note suggested about 60 s; 120 s was used because the reproduction's migration set took about 86 s after the proxy went live, and the harness constant already exists. That leaves roughly 19 s of margin in the worst case (86 s of migrations, a 5 s tick, and up to 10 s of inner backoff on the last failed attempt). One honest limit: the harness targets a proxy the operator starts with the documented litellm --config ... --port 4000 command, which runs the migrations before serving, so under that boot the five routes pass with or without this fix. The un-skip proves the routes work again; the race itself is covered by the unit tests and by the live QA run

Changed from the design note. _run_view_setup is the whole loop, and it takes the poll interval and deadline as parameters with the production constants as defaults, so the tests drive the timeout path with millisecond values and assert the log without patching module constants. The probe failure is retried too, not only a check_view_exists failure. The probe's rows go through a Pydantic TypeAdapter(tuple[Mapping[str, bool], ...]) instead of a typed annotation on the untyped query_raw result, which keeps one reportAny out of the basedpyright count; a malformed probe response raises inside the attempt and is retried like any other failure. The shutdown test hands the lifespan its fake client through the module's prisma_client global, the same seam the lifespan already reads, instead of patching ProxyStartupEvent._setup_prisma_client

Test expectations changed in a114af2, all in test_prisma_client_lifecycle.py, because the probe became search_path-relative and the row count moved ahead of the views:

  • test_view_setup_waits_for_the_spend_logs_table_before_creating_views: the probe argument is now "LiteLLM_SpendLogs" with no schema prefix, and the call order is probe, row_count, views
  • test_view_setup_probes_the_configured_database_schema became test_view_setup_probe_resolves_through_the_connection_search_path: with DATABASE_SCHEMA=litellm_tenant set, the probe still binds the unqualified name, which is the regression test for the ?schema= deployments
  • test_view_setup_retries_when_view_creation_fails_mid_migration and test_view_setup_retries_when_the_table_probe_itself_fails: the call sequences gained the row_count step between probe and views
  • test_run_view_setup_logs_an_error_naming_the_table_on_timeout: the ERROR names "LiteLLM_SpendLogs" instead of "public"."LiteLLM_SpendLogs"
  • new test_view_setup_sets_the_row_count_even_when_view_creation_keeps_failing: a check_view_exists that raises on every attempt still leaves the row count set before the deadline ERROR

Caveats (if any)

Low

  • Views land up to 5 s after the columns they need exist, so the Usage tab can 500 inside that window on a first boot that races the migrations; the merge base never created them on that boot at all. Not fixed: a 1 s poll would only shave that first-boot window at five times the probe rate on a never-migrated database
  • A never-migrated database polls quietly for 15 minutes (a DEBUG line per tick) before the ERROR names the missing migrations; the merge base died silently within seconds. Not fixed: every other DB-backed route already reports the missing tables on its first request, so an earlier INFO line would add a state flag to the loop for a signal the operator already has
  • The 5 s poll and 15 min cap are constants, not settings. Not fixed: nobody asked for a knob, and a longer cap only delays the ERROR that names the fix on a schema that will never settle
  • Other startup readers that log "does not exist" on an empty schema (config, users, MCP tables) are untouched. Not fixed: each is its own reader with its own retry story, out of this PR's scope
  • Every uvicorn worker runs its own view setup task, so a 2-worker pod polls twice and both workers issue the CREATE VIEW statements; create_view_tolerating_race already absorbs the concurrent create, and the merge base ran check_view_exists once per worker too. Not fixed: electing one worker needs shared state the proxy does not hold at boot
  • Each attempt that reaches the table before the columns the views need exist logs one WARNING per 5 s tick, so a slow migration set prints a few WARNING lines, and a database that is unreachable at boot prints that WARNING every 5 s for 15 minutes before the ERROR. Not fixed: that WARNING is the only place the failing statement is printed, and an unreachable database is already reported by every other startup reader
  • LiteLLM_VerificationTokenView can be created between the migration that adds tpd_limit and the one that adds total_spend, so on a fresh install it can lack total_spend the way it already does on every upgraded deployment; nothing in the proxy selects from that view, key auth reads the base table. Not fixed: pre-existing on every upgraded deployment, and a view refresh belongs in a migration, not in the startup task
  • stop_view_setup_task swallows a CancelledError aimed at the shutdown coroutine itself, the same way stop_db_health_watchdog_task does right above it; only shutdown is affected. Not fixed: re-raising there would turn a cancelled shutdown into an error log on the one path that mirrors the watchdog's
  • check_view_exists still reads DATABASE_SCHEMA (default public) for its pg_views lookup, so a ?schema= URL without that variable re-issues the CREATE VIEW statements on every boot; the live schema-mode leg shows the restart absorbing them with no WARNING, and the merge base does the same. Not fixed: pre-existing, and the gate itself no longer depends on that variable
  • The probe runs through self.db.query_raw, so on a read-replica setup a lagging replica can answer "missing" one tick after the writer has the table, which only delays the views by 5 s. Not driven live: the rig has no replica
  • CodeQL flags an empty except at stop_db_health_watchdog_task on the PR's merge commit (utils.py:6352 there, 6339 at the tip); that function predates this PR (fix(proxy): self-heal Prisma connection for auth and runtime #21706) and this diff does not touch it. Not fixed here: cleaning up a function this PR does not change belongs in its own change
  • CI at this tip: ci/circleci: google_generate_content_endpoint_testing (job 2193727) is red, and every one of its 6 errors is the session fixture in tests/unified_google_tests/conftest.py failing with RuntimeError: LiteLLM proxy failed to start because feat(proxy)!: refuse to start with an unset, empty, or publicly known master key #42019 (main b6dd3d9, merged after this branch's original merge base and pulled in by the merge commit) refuses the test config's master_key: sk-1234 with UnsafeMasterKeyError. The same test errors the same way at the merge base 8df260a with none of this PR's code (run locally, LiteLLM proxy refused to start: the master key is a publicly known default), the last main pipeline (89901, at 1fcef68) predates feat(proxy)!: refuse to start with an unset, empty, or publicly known master key #42019 so main's next scheduled run reds too, and this PR touches nothing under tests/unified_google_tests. Tracked in LIT-8226. Not fixed here: the fix belongs to that suite's fixture, not to this PR
  • Buildkite: litellm-e2e-pr build 526 for a114af2 failed on the streaming spend e2e, build 530 for the same PR commit passed once the e2e runner's own fix (completion with split tests - auto generate id for completion when nothing is passed  #253) landed with no change to this PR in between, and the tip's e2e-tests build 15598 (quota_management, selected from the diff) passed. Not fixed: nothing in this PR's diff was involved

QA runbook

Prerequisites: a fresh Postgres and a proxy booted with DATABASE_URL and DISABLE_SCHEMA_UPDATE=true before python litellm/proxy/prisma_migration.py is run against that database (the helm ordering); nothing provider-side is needed

  • tests/e2e/quota_management/spend_tracking/test_spend_routes.py::test_spend_route_responsive[/global/spend] - the monthly spend route answers on its own once the migrations land, with no restart
    • Boot the proxy against the empty database, confirm GET http://localhost:4000/health/liveliness returns 200, then run the migrations
    • Within 5 s of the migrations finishing, GET http://localhost:4000/global/spend with the master key returns 200 with a JSON list
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky
  • tests/e2e/quota_management/spend_tracking/test_spend_routes.py::test_spend_route_responsive[/global/spend/keys] - the top keys route answers once the migrations land
    • Same boot and migration ordering as above
    • GET http://localhost:4000/global/spend/keys with the master key returns 200 instead of a bare 500 Internal server error
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky
  • tests/e2e/quota_management/spend_tracking/test_spend_routes.py::test_spend_route_responsive[/global/spend/models] - the top models route answers once the migrations land
    • Same boot and migration ordering as above
    • GET http://localhost:4000/global/spend/models with the master key returns 200 instead of a bare 500 Internal server error
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky
  • tests/e2e/quota_management/spend_tracking/test_spend_routes.py::test_spend_route_responsive[/global/spend/tags] - the tag spend route answers once the migrations land
    • Same boot and migration ordering as above
    • GET http://localhost:4000/global/spend/tags?start_date=<yesterday>&end_date=<today> with the master key returns 200 instead of 500 relation "DailyTagSpend" does not exist
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky
  • tests/e2e/quota_management/spend_tracking/test_spend_routes.py::test_spend_route_responsive[/global/spend/logs] - the global spend logs route answers once the migrations land
    • Same boot and migration ordering as above
    • GET http://localhost:4000/global/spend/logs with the master key returns 200 instead of 500 relation "MonthlyGlobalSpend" does not exist
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky

Live PR risk (327447b)

Every leg below ran the real proxy (2 uvicorn workers, real Postgres) on both sides of the change; the a114af2 legs stand for this tip because the merge commit added nothing to the PR's diff

Breaking

None. The diff adds start_view_setup_task, stop_view_setup_task, and the private _run_view_setup, _attempt_view_setup, _log_view_setup_timeout, _view_setup_gate_table_present to PrismaClient plus four module constants; no existing signature, response field, config key, env var, or DB column changes

Backward incompatible

None observed. On a database that is already migrated when the proxy boots (the common case) the views are created on the first attempt and the routes answer the same as before: the default-mode legs (base b8d837b vs head a114af2, fresh database migrated by the proxy itself, then a restart against the migrated database) returned 200 with the same bodies on every /global/spend* route on both sides, and /sso/get/ui_settings kept its shape

Regression risk

  • Installs whose migrations take longer than 15 minutes: the task logs the ERROR naming the missing table and stops, and the views need a restart; the merge base never created them on such a boot either. Documented under Caveats, not driven
  • Read replicas: the table probe runs through self.db.query_raw, so a lagging replica can answer "missing" one 5 s tick after the writer has the table, which only delays the views. Inferred from the code, not driven
  • Two workers per proxy each run the task, so a pod issues the CREATE VIEW statements twice: observed in every leg, absorbed by create_view_tolerating_race, no error on either worker

Dependency graph

  • start_view_setup_task from proxy_startup_event: verified live in both legs of the proof above
  • stop_view_setup_task from the shutdown path: unit-tested in test_prisma_client_lifecycle.py; the QA proxies shut down cleanly when stopped
  • spend_logs_row_count -> ui_sso.py -> GET /sso/get/ui_settings (NUM_SPEND_LOGS_ROWS): verified live, 0 on the merge base for the life of the process, -1 at the tip on a fresh never-analyzed table (Postgres reltuples), a real count after the first analyze
  • Dashboard usage pages reading /global/spend/*: verified live through the old Usage page screenshots above
  • check_view_exists reading DATABASE_SCHEMA for its pg_views lookup: pre-existing; the schema-mode legs (?schema=lit5211 in the URL, base b8d837b vs head a114af2) created all 8 views under lit5211 and answered 200 on every route on both sides, on the fresh boot and on the restart
  • Restricted database role (the proxy connects as a role with no CREATE on public, migrations run as postgres): all five routes 500 identically on base b8d837b, on 1b8f704, and on head a114af2, so a role that cannot create views is unchanged by this PR

Not verified

  • The helm chart's migration Job ordering on a real cluster; the rig reproduced that ordering locally with two proxy processes and a separately started migration run
  • Read replicas
  • DATABASE_SCHEMA exported explicitly together with a ?schema= URL that names a different schema

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Link to Devin session: https://app.devin.ai/sessions/ca016fcfcb0e45a0814ed91336e594d7
Requested by: @mateo-berri

On a fresh database where the migrations run in a separate job while the
proxy boots with DISABLE_SCHEMA_UPDATE=true, the startup view check ran
as a fire-and-forget task, used up its three 10 second retries before
LiteLLM_SpendLogs existed, and died with an unretrieved exception. The
spend views were never created, so the /global/spend routes returned 500
until the pod was restarted

PrismaClient now holds a view setup task. It polls to_regclass for the
spend-log table every 5 seconds, creates the views and loads the spend
log row count once the table is there, keeps polling if an attempt raises
while the schema is still settling, and logs an ERROR with the last
failure if nothing worked after 15 minutes. Proxy shutdown cancels the
task

The spend route e2e tests for the five view-backed routes are no longer
skipped and wait for the views through the harness convergence helper
@devin-ai-integration

devin-ai-integration Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor Author

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@codspeed

codspeed Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_fix_startup_view_creation_race (327447b) with main (b444709)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (8df260a) during the generation of this report, so b444709 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

@greptile-apps

greptile-apps Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge, with no outstanding correctness or repository-rule findings

Findings

  1. P1 Setup Stops Permanently ▶

Summary

The PR replaces detached startup view creation with an owned background task that waits for the spend-log table, retries setup failures, reports terminal failures, and is cancelled during shutdown

  • Probes for the spend-log table through the database connection search path
  • Records the spend-log row count before attempting view creation
  • Retries through migration races for up to 15 minutes
  • Enables bounded retries for view-backed spend-route E2E checks
  • Adds lifecycle, ordering, retry, timeout, and shutdown coverage

Reviews (4) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..."

Comment thread litellm/proxy/utils.py
Comment on lines +6368 to +6370
if time.monotonic() >= deadline:
self._log_view_setup_timeout(attempt, deadline_seconds)
return "timed_out"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Setup Stops Permanently

If external migrations take longer than 15 minutes, this task exits and never restarts, leaving view-backed spend routes broken until proxy restart

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A fresh-database migration run takes about 90 seconds against a 15 minute cap, the old window was 10 seconds, and the ERROR names the restart

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That’s fair. With migrations taking about 90 seconds in the fresh-database case, a 15-minute bounded window is ample compared with the previous 10-second attempt, and the explicit ERROR tells operators to run migrations and restart if the deadline is exceeded. I’m withdrawing this concern.

Comment thread tests/test_litellm/proxy/test_proxy_server.py
Comment thread litellm/proxy/utils.py Fixed
@codecov

codecov Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.15385% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/proxy/proxy_server.py 66.66% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

… starts at boot

Use contextlib.suppress for the cancelled task in stop_view_setup_task, make the legacy prisma setup test inject a plain mock for the synchronous start_view_setup_task and assert it is called, and drop the docstrings the branch added to tests
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

Comment thread litellm/proxy/utils.py Fixed
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

…iew_creation_race

# Conflicts:
#	tests/test_litellm/proxy/test_proxy_server.py
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 327447b. Configure here.

@mateo-berri mateo-berri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@mateo-berri
mateo-berri merged commit 9d7f779 into main Sep 20, 2026
153 of 154 checks passed
@mateo-berri
mateo-berri deleted the litellm_fix_startup_view_creation_race branch September 20, 2026 03:33

This branch was successfully deployed

1 active deployment
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants