fix(team): serialize member_add, member_delete, and delete under the team's advisory lock - #37969
Conversation
|
@greptileai please review |
|
|
440b16d to
bb66b9d
Compare
|
@greptileai re-requesting review after pushing bb66b9d (fixed a PT011 lint failure in the new test file's exception type; no production-code change). |
Greptile SummaryThis PR serializes team member addition, member deletion, and team deletion with a shared PostgreSQL advisory lock
Confidence Score: 5/5The PR appears safe to merge No blocking failure remains
|
| Filename | Overview |
|---|---|
| litellm/proxy/management_endpoints/team_endpoints.py | Coordinates member and team deletion through a shared advisory lock and transactionally updates linked records |
| litellm/repositories/team_repository.py | Replaces the deadlock-prone row-locking read with a plain read guarded by the caller-held advisory lock |
| litellm/proxy/management_helpers/access_group_team_sync.py | Exposes the existing team advisory-lock statement for consistent reuse by team lifecycle operations |
| litellm/proxy/management_endpoints/key_management_endpoints.py | Allows deleted verification-token records to be persisted through an existing transaction |
| tests/proxy_admin_ui_tests/test_team_delete_member_add_race.py | Exercises deterministic advisory-lock interleavings against PostgreSQL |
| tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py | Updates transaction mocks and verifies locked reconciliation and rollback behavior |
Reviews (4): Last reviewed commit: "fix(team): serialize member_add, member_..." | Re-trigger Greptile
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 2 · PR risk: 0/10 |
| @@ -0,0 +1,232 @@ | |||
| """ | |||
There was a problem hiding this comment.
Regression tests bypass mapped file
These tests cover an existing team endpoint bug but create a standalone module instead of extending tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py, splitting related regression coverage and making the repository's established test mapping less reliable.
Context Used: CLAUDE.md (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
bb66b9d to
aa0f4e5
Compare
|
@greptileai re-requesting review after pushing aa0f4e5: team_member_delete now also takes the team's advisory lock and re-reads the roster under it before writing, closing the member_delete-vs-member_add gap flagged in the last review. Added a third deterministic Postgres test pinning it. |
|
@greptileai the real-Postgres location is deliberate: tests/test_litellm runs mocked-only in CI with no DATABASE_URL, matching sibling test_access_group_team_sync.py's same-directory precedent for identical reasons. |
|
That's a solid rationale, and the precedent is real — Accepted — placement is correct. |
|
@greptileai please re-review the current head, aa0f4e5, which adds the team_member_delete advisory-lock fix on top of what you last scored. |
…team's advisory lock /team/member_add validated a team exists and then wrote the user's teams array and a membership row without holding anything across that gap, so a /team/delete could commit its reference sweeps in between and leave a member pointing at a team id that no longer exists. The write path already re-read members_with_roles under a row lock before this change, but SELECT ... FOR UPDATE can deadlock with the access-group endpoints, which lock an access group and then a team. member_add now takes pg_advisory_xact_lock(hashtext(team_id)) before re-reading the team and only writes if it is still there, so a delete that already committed is visible before any write happens. delete_team takes the same lock around its own row delete and reference sweep, so the two requests can never interleave: whichever acquires the lock first runs to completion before the other's read can proceed. Dropping the row lock from member_add's read also dropped the incidental protection it gave against a concurrent member_delete, which still wrote from the snapshot it validated against, unlocked, and could silently overwrite whatever member_add had just committed. member_delete now takes the same advisory lock and re-reads the roster under it before computing its own write, so it can never resurrect a member by overwriting from stale data. Resolves LIT-5544
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…erge Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The transaction path was creating the email-identified user row outright, where the regular client path upserts on user_id. Share one upsert helper between both member paths so the create stays idempotent on the lock holder's connection. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
… transaction Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
abf4da9 to
33a9581
Compare
6c0c91c
into
litellm_internal_staging
TLDR
Problem this solves:
/team/member_addmid-flight during/team/deletecould still write a reference to the deleted team/team/deleteitself took no lock, so it could race a concurrent add either way/team/member_delete, which could then silently undo a concurrent addHow it solves it:
member_addtakes the team's advisory lock, re-reads the team, and only writes if it is still theredelete_teamtakes the same lock around its own row delete and reference sweepmember_deletetakes the same lock too, and re-reads the roster under it instead of computing from the snapshot it validated againstSELECT ... FOR UPDATEwithpg_advisory_xact_lock, which the access-group endpoints never takeUser Flow
Before: an admin who deletes a team, or removes one of its members, while another admin is mid-flight adding a different member to it can end up with a member reference surviving the delete, or with a removed member's slot silently coming back
POST /team/member_addfor team T with a new member, and admin B callsPOST /team/delete(orPOST /team/member_deletefor a different member) for team T microseconds apartGET /user/info?user_id=<the new member>afterward can still list team T alongside whatever team the user actually belongs to, and a membership row for T can still exist in the database; orGET /team/info?team_id=Tcan still list the member B just removed500 Internal server errorafter five seconds and neither removal is appliedAfter: the requests are fully serialized by the database
POST /team/member_addfor team T with a new member, and admin B callsPOST /team/delete(orPOST /team/member_delete) for team T microseconds apartGET /user/info?user_id=<the new member>afterward shows either the member on a team that is genuinely still there, or no trace of team T at all;GET /team/info?team_id=Tnever shows a member a delete already removed200, in whichever order the lock granted them, and the roster ends up with both members goneRelevant issues
Linear ticket
Resolves LIT-5544
Pre-Submission checklist
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
This branches off #37959 (LIT-5541) for its transaction-threading prerequisite: the advisory lock is transaction-scoped, and that PR is what gives these endpoints' write paths a
prisma_client.tx()to hold it in. If #37959 has not merged tolitellm_internal_stagingyet, this PR's diff and commit list will show its commit too; merge that one first, or review this diff against its branch instead of against stagingShared setup, used by every case below. A real Postgres, a proxy on port 14544 with
database_connection_pool_limit: 2anddatabase_connection_pool_timeout: 10, master keysk-1234, and a fresh team per run:The small pool is what makes case 1 observable at two concurrent requests instead of dozens: a waiter that needs a second connection starves the holder, and Postgres' interactive-transaction timeout (5s) then kills both. Case 2 needs a temporary 3-second delay injected into
member_addright after it takes the lock, sodelete(fired 1 second later) has to wait on the lock rather than win a timing race against itBefore (11b60b9, the commit before this PR's tip)
Case 1: two admins removing different members of the same team at once
Case 2: a member_add mid-flight against a concurrent team/delete
member_add's own re-read catches the ordinary ordering and 404s). It is pinned instead by the deterministic Postgres harness intests/proxy_admin_ui_tests/test_team_delete_member_add_race.py, whose three legs each fail with the corresponding lock acquisition removedAfter (abf4da9, this PR's tip)
Case 1: two admins removing different members of the same team at once
Case 2: a member_add mid-flight against a concurrent team/delete
member_add:deletetook 2.07s instead of returning immediately: it genuinely waited on the lockmember_addheld rather than racing it.member_addsucceeded legitimately, the team was still live when it read it, and thendelete's own locked sweep reaped the fresh referenceType
🐛 Bug Fix
Caveats (if any)
Final Attestation
Link to Devin session: https://app.devin.ai/sessions/fc97c5d8da914f75a09dd5fa7273c437
Requested by: @yassin-berriai