Skip to content

fix: batch-limit stale managed object cleanup to prevent 300K row UPDATE - #24451

Closed
ishaan-jaff wants to merge 3 commits into
mainfrom
fix/stale-cleanup-batch-limit
Closed

fix: batch-limit stale managed object cleanup to prevent 300K row UPDATE#24451
ishaan-jaff wants to merge 3 commits into
mainfrom
fix/stale-cleanup-batch-limit

Conversation

@ishaan-jaff

Copy link
Copy Markdown
Contributor

Summary

  • Adds STALE_OBJECT_CLEANUP_BATCH_SIZE constant (default 1000, configurable via env var) to cap how many stale rows are marked per poll cycle
  • Replaces the unbounded update_many in _cleanup_stale_managed_objects with a single execute_raw using a subquery SELECT ... LIMIT — zero rows loaded into Python memory, one DB round-trip
  • Removes the file_purpose="response" filter so stale objects of any purpose (batch, fine-tune, response) get cleaned up
  • Extracts _expire_stale_rows as a testable method

Test plan

  • E2E tested against real Neon Postgres DB: seeded 5 stale rows (backdated 30 days), called _expire_stale_rows(batch_size=3) three times — got 3, 2, 0 rows updated respectively, all rows verified as stale_expired
  • Confirmed batch LIMIT is enforced (never exceeded batch_size)
  • Confirmed terminal-state rows are not re-processed

Made with Cursor

Configurable batch limit (default 1000) for stale managed object cleanup,
preventing unbounded UPDATE queries from hitting 300K+ rows at once.

Made-with: Cursor
@vercel

vercel Bot commented Mar 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Apr 5, 2026 6:52am

Request Review

@codspeed-hq

codspeed-hq Bot commented Mar 23, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 16 untouched benchmarks


Comparing fix/stale-cleanup-batch-limit (5d572b0) with main (8a3aa4d)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Mar 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a bounded stale-object cleanup strategy for CheckResponsesCost._cleanup_stale_managed_objects, replacing an unbounded update_many (which could issue a 300K-row UPDATE in a single poll cycle) with a raw SQL UPDATE ... WHERE id IN (SELECT ... LIMIT $2) subquery capped by a new STALE_OBJECT_CLEANUP_BATCH_SIZE constant (default 1000, env-configurable).

Key changes:

  • litellm/constants.py: adds STALE_OBJECT_CLEANUP_BATCH_SIZE = max(1, int(os.getenv(\"STALE_OBJECT_CLEANUP_BATCH_SIZE\", 1000))) alongside existing managed-object constants.
  • enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py: extracts _expire_stale_rows(cutoff, batch_size) as a mockable helper that issues the bounded raw SQL, and simplifies _cleanup_stale_managed_objects to call it.
  • CI/CD and workflow config files were mass-updated (unrelated to the logic change).

Positive aspects:

  • The ORDER BY created_at ASC in the subquery ensures oldest rows are processed first — correct bias for backlog clearing.
  • The max(1, ...) guard on the constant prevents an accidental zero/negative batch size.
  • The docstring in _expire_stale_rows explicitly justifies the PostgreSQL-only syntax and references the spend_log_cleanup.py precedent — good documentation of a deliberate deviation.
  • Extracting _expire_stale_rows is good design: the raw-SQL call can now be independently mocked in tests.

Remaining concern (new finding):

  • The outer UPDATE only filters WHERE \"id\" IN (...) without re-checking status. Under PostgreSQL's READ COMMITTED isolation, a row that transitions to completed between subquery evaluation and UPDATE lock acquisition will have its status overwritten to stale_expired. Adding AND \"status\" NOT IN (...) to the outer UPDATE makes it safe under concurrent writes (see inline comment).

Confidence Score: 4/5

Mostly safe to merge; the bounded cleanup prevents the 300K-row UPDATE regression, but a subtle status-overwrite race and unresolved concerns from previous review rounds remain.

The core fix is sound and well-structured. One new P2 finding (missing status re-check on the outer UPDATE) is a correctness edge case under concurrent writes. Prior review threads flagged P1 issues (file_purpose filter still present, CheckBatchCost still unbounded, PostgreSQL-only SQL) that remain unaddressed in this commit, keeping confidence at 4 rather than 5.

enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py (outer UPDATE race condition); enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py (still has unbounded update_many per prior review)

Important Files Changed

Filename Overview
enterprise/litellm_enterprise/proxy/common_utils/check_responses_cost.py Replaces unbounded update_many with a bounded raw-SQL UPDATE/LIMIT subquery; outer UPDATE lacks a status re-check guard that could overwrite a concurrently-completed row
litellm/constants.py Adds STALE_OBJECT_CLEANUP_BATCH_SIZE constant (default 1000) with env-override and max(1,...) safety guard — clean and correct

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[check_responses_cost poll cycle] --> B[_cleanup_stale_managed_objects]
    B --> C[compute cutoff = now - MANAGED_OBJECT_STALENESS_CUTOFF_DAYS]
    C --> D[_expire_stale_rows cutoff, STALE_OBJECT_CLEANUP_BATCH_SIZE]
    D --> E["execute_raw: UPDATE LiteLLM_ManagedObjectTable\nSET status = 'stale_expired'\nWHERE id IN (\n  SELECT id ... WHERE file_purpose='response'\n  AND status NOT IN terminal states\n  AND created_at < cutoff\n  ORDER BY created_at ASC\n  LIMIT batch_size\n)"]
    E --> F{rows updated > 0?}
    F -- yes --> G[log warning with count]
    F -- no --> H[silent]
    G --> I[find_many queued/in_progress response jobs]
    H --> I
    I --> J[for each job: aget_responses]
    J --> K{terminal state?}
    K -- completed/failed/cancelled --> L[collect in completed_jobs list]
    K -- in_progress --> M[skip]
    L --> N[update_many completed_jobs to status=completed]
Loading

Reviews (4): Last reviewed commit: "chore: fixes" | Re-trigger Greptile

Comment on lines +47 to +57
SET "status" = 'stale_expired'
WHERE "id" IN (
SELECT "id" FROM "LiteLLM_ManagedObjectTable"
WHERE "status" NOT IN ('completed', 'complete', 'failed', 'expired', 'cancelled', 'stale_expired')
AND "created_at" < $1::timestamptz
ORDER BY "created_at" ASC
LIMIT $2
)
""",
cutoff,
batch_size,

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 PostgreSQL-only raw SQL breaks non-Postgres deployments

The raw query uses several PostgreSQL-specific constructs:

  • $1::timestamptz — PostgreSQL parameter placeholder and cast syntax
  • $2 as a LIMIT argument — while this is valid in PostgreSQL, MySQL and SQLite do not support parameterized LIMIT values
  • Double-quoted identifiers ("LiteLLM_ManagedObjectTable", "id", "status", "created_at") are PostgreSQL convention

Any LiteLLM deployment backed by MySQL or SQLite will get a DB error on this cleanup cycle. The previous update_many call went through Prisma's ORM layer which generated dialect-appropriate SQL automatically.

If LiteLLM is PostgreSQL-only today, add a comment asserting that assumption and document it. If multi-DB support is required, the query should be wrapped in a dialect check or the raw SQL approach should be reconsidered.

Two fixes to _cleanup_stale_managed_objects:

1. Replace unbounded update_many with a single execute_raw using a
   subquery LIMIT, capping each poll cycle to STALE_OBJECT_CLEANUP_BATCH_SIZE
   rows. Zero rows loaded into Python memory — everything stays in Postgres.
   Uses the same PostgreSQL raw-SQL pattern as spend_log_cleanup.py
   (the proxy requires PostgreSQL per schema.prisma).

2. Extract _expire_stale_rows as a separate method for testability.

Keeps the file_purpose='response' filter to avoid incorrectly expiring
long-running batch or fine-tune jobs that legitimately exceed the
staleness cutoff.

Made-with: Cursor
SET "status" = 'stale_expired'
WHERE "id" IN (
SELECT "id" FROM "LiteLLM_ManagedObjectTable"
WHERE "file_purpose" = 'response'

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 file_purpose filter contradicts PR description

The PR description explicitly states: "Removes the file_purpose="response" filter so stale objects of any purpose (batch, fine-tune, response) get cleaned up."

However, the filter is still present in the SQL subquery:

WHERE "file_purpose" = 'response'

This means stale batch and fine-tune managed objects are still excluded from this cleanup cycle, which is the opposite of the stated intent. If the filter should have been removed, the corrected subquery would be:

Suggested change
WHERE "file_purpose" = 'response'
WHERE "status" NOT IN ('completed', 'complete', 'failed', 'expired', 'cancelled', 'stale_expired')

(dropping the "file_purpose" = 'response' line entirely).

If the filter is intentionally kept — perhaps because a sibling CheckBatchCost class handles other purposes — the PR description should be updated to avoid confusion.

@ishaan-jaff

Copy link
Copy Markdown
Contributor Author

@greptile review again

Comment on lines 66 to +78
async def _cleanup_stale_managed_objects(self) -> None:
"""
Mark managed objects older than MANAGED_OBJECT_STALENESS_CUTOFF_DAYS days
in non-terminal states as 'stale_expired'. These will never complete and
should not be polled.

Runs as a single DB query with a subquery LIMIT so no rows are loaded
into Python memory. Processes at most STALE_OBJECT_CLEANUP_BATCH_SIZE
rows per invocation to avoid overwhelming the DB when there is a large
backlog.
"""
cutoff = datetime.now(timezone.utc) - timedelta(days=MANAGED_OBJECT_STALENESS_CUTOFF_DAYS)
result = await self.prisma_client.db.litellm_managedobjecttable.update_many(
where={
"file_purpose": "response",
"status": {"not_in": ["completed", "complete", "failed", "expired", "cancelled", "stale_expired"]},
"created_at": {"lt": cutoff},
},
data={"status": "stale_expired"},
)
result = await self._expire_stale_rows(cutoff, STALE_OBJECT_CLEANUP_BATCH_SIZE)

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 CheckBatchCost still has unbounded update_many

This PR fixes the runaway UPDATE in CheckResponsesCost._cleanup_stale_managed_objects, but the identical function in CheckBatchCost._cleanup_stale_managed_objects (check_batch_cost.py line 66-73) still uses the old unbounded update_many. If there are 300K stale batch-purpose rows the same problem will fire there every poll cycle:

# check_batch_cost.py – still unbounded
result = await self.prisma_client.db.litellm_managedobjecttable.update_many(
    where={
        "file_purpose": "batch",
        "status": {"not_in": ["completed", "complete", "failed", "expired", "cancelled", "stale_expired"]},
        "created_at": {"lt": cutoff},
    },
    data={"status": "stale_expired"},
)

The same _expire_stale_rows / STALE_OBJECT_CLEANUP_BATCH_SIZE treatment (or at minimum a LIMIT-based raw query) should be applied to CheckBatchCost as well, otherwise the 300K-row UPDATE protection is only partial.

@yuneng-berri
yuneng-berri requested a review from a team April 5, 2026 06:51
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ ishaan-jaff
❌ yuneng-berri
You have signed the CLA already but the status is still pending? Let us recheck it.

@ishaan-berri

Copy link
Copy Markdown
Contributor

Closing in favor of #25227 which contains only Ishaan's changes (removes the unrelated CI/workflow changes from the yuneng-berri commit).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants