Merge pull request #41283 from BerriAI/litellm_writer_pinned_raw_sql_writes

fix(proxy): keep access-group raw SQL writes on the writer while writer_unavailable is stale
This commit is contained in:
Yassin Kortam 2026-09-15 13:13:30 -07:00 committed by GitHub
commit 0e5be275b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 87 additions and 7 deletions

View file

@ -81,6 +81,11 @@ class WriterPinnedClient:
self.db: Final = db.writer if isinstance(db, RoutingPrismaWrapper) and not db.writer_unavailable else db
def writer_wrapper(db: "PrismaWrapper | RoutingPrismaWrapper") -> PrismaWrapper:
"""Unlike `WriterPinnedClient`, ignores `writer_unavailable`: a raw SQL write has no replica fallback."""
return db.writer if isinstance(db, RoutingPrismaWrapper) else db
class RoutingPrismaWrapper:
"""
Routes Prisma operations between a writer and a reader Prisma client.

View file

@ -38,7 +38,7 @@ from litellm.proxy._types import (
from litellm.proxy.auth.auth_checks import (
_delete_cache_access_object, # pyright: ignore[reportPrivateUsage] # the access-group endpoints reach for this same cache primitive
)
from litellm.proxy.db.routing_prisma_wrapper import WriterPinnedClient
from litellm.proxy.db.routing_prisma_wrapper import writer_wrapper
from litellm.repositories.table_repositories import AccessGroupRepository
@ -75,7 +75,7 @@ _REPOINT_KEY_SQL: Final = (
def _raw_executor(prisma_client: object) -> _RawExecutor:
"""Narrow the untyped Prisma client down to the raw-query call this module makes, pinned to the writer."""
db: Final = AccessGroupRepository(prisma_client).prisma_client.db # pyright: ignore[reportAny] # untyped Prisma client
return WriterPinnedClient(db).db # pyright: ignore[reportAny, reportReturnType] # untyped Prisma client behind the pin
return writer_wrapper(db) # pyright: ignore[reportAny, reportReturnType] # untyped Prisma client behind the pin
async def _invalidate_access_group_cache(access_group_id: str) -> None:

View file

@ -10,7 +10,7 @@ from typing import Final, Protocol
from pydantic import BaseModel
from litellm.proxy.db.routing_prisma_wrapper import WriterPinnedClient
from litellm.proxy.db.routing_prisma_wrapper import writer_wrapper
from litellm.proxy.management_helpers.access_group_team_sync import invalidate_access_group_caches
from litellm.repositories.table_repositories import AccessGroupRepository
from litellm.router import Router
@ -56,7 +56,7 @@ _REMOVE_MODEL_NAME_SQL: Final = (
def _raw_executor(prisma_client: object) -> _RawExecutor:
db: Final = AccessGroupRepository(prisma_client).prisma_client.db # pyright: ignore[reportAny] # untyped Prisma client
return WriterPinnedClient(db).db # pyright: ignore[reportAny, reportReturnType] # untyped Prisma client behind the pin
return writer_wrapper(db) # pyright: ignore[reportAny, reportReturnType] # untyped Prisma client behind the pin
def _config_sourced_sibling(llm_router: Router, deployment_id: str, model_id: str) -> bool:

View file

@ -145,6 +145,18 @@ def test_writer_pinned_client_yields_to_routed_reads_when_writer_down():
assert pinned.db.litellm_proxymodeltable.find_many is reader_inner.litellm_proxymodeltable.find_many
def test_writer_wrapper_keeps_raw_sql_on_the_writer_while_writer_flagged_down():
from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper, writer_wrapper
writer, writer_inner, reader, reader_inner = _make_wrappers()
routing = RoutingPrismaWrapper(writer=writer, reader=reader)
routing._writer_unavailable = True
assert writer_wrapper(routing).query_raw is writer_inner.query_raw
assert writer_wrapper(routing).query_raw is not reader_inner.query_raw
assert writer_wrapper(writer) is writer
@pytest.mark.asyncio
async def test_connect_invokes_both_clients():
from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper

View file

@ -11,14 +11,15 @@ from litellm.proxy.management_helpers.access_group_key_sync import (
)
def _routed_prisma_client():
def _routed_prisma_client(writer_unavailable: bool = False):
writer_inner = MagicMock(name="writer_prisma")
reader_inner = MagicMock(name="reader_prisma")
writer_inner.query_raw = AsyncMock(return_value=[])
reader_inner.query_raw = AsyncMock(return_value=[])
reader_inner.query_raw = AsyncMock(side_effect=RuntimeError("cannot execute UPDATE in a read-only transaction"))
writer = PrismaWrapper(original_prisma=writer_inner, iam_token_db_auth=False)
reader = PrismaWrapper(original_prisma=reader_inner, iam_token_db_auth=False)
routing = RoutingPrismaWrapper(writer=writer, reader=reader)
routing._writer_unavailable = writer_unavailable
return SimpleNamespace(db=routing), writer_inner, reader_inner
@ -39,6 +40,39 @@ async def test_regeneration_repoint_update_runs_on_the_writer():
reader_inner.query_raw.assert_not_awaited()
@pytest.mark.asyncio
async def test_regeneration_repoint_update_stays_on_the_writer_while_writer_flagged_unavailable():
prisma_client, writer_inner, reader_inner = _routed_prisma_client(writer_unavailable=True)
await sync_key_regeneration_access_group_membership(
prisma_client=prisma_client,
previous_key_token="old-token",
new_key_token="new-token",
data=None,
existing_key_row=MagicMock(),
)
writer_inner.query_raw.assert_awaited_once()
assert writer_inner.query_raw.await_args.args[0].startswith('UPDATE "LiteLLM_AccessGroupTable"')
assert writer_inner.query_raw.await_args.args[1:] == ("old-token", "new-token")
reader_inner.query_raw.assert_not_awaited()
@pytest.mark.asyncio
async def test_membership_attach_and_detach_updates_stay_on_the_writer_while_writer_flagged_unavailable():
prisma_client, writer_inner, reader_inner = _routed_prisma_client(writer_unavailable=True)
await sync_key_access_group_membership(
prisma_client=prisma_client,
key_token="token",
previous_access_group_ids=["ag-old"],
updated_access_group_ids=["ag-new"],
)
assert writer_inner.query_raw.await_count == 2
reader_inner.query_raw.assert_not_awaited()
@pytest.mark.asyncio
async def test_membership_attach_and_detach_updates_run_on_the_writer():
prisma_client, writer_inner, reader_inner = _routed_prisma_client()

View file

@ -13,7 +13,7 @@ from litellm.proxy.management_helpers.access_group_model_sync import (
_INVALIDATE = "litellm.proxy.management_helpers.access_group_model_sync.invalidate_access_group_caches"
def _routed_prisma_client(deployment_count: int):
def _routed_prisma_client(deployment_count: int, writer_unavailable: bool = False):
async def query_raw(sql, *params):
if sql.startswith("SELECT COUNT(*)"):
return [{"deployment_count": deployment_count}]
@ -26,6 +26,7 @@ def _routed_prisma_client(deployment_count: int):
writer = PrismaWrapper(original_prisma=writer_inner, iam_token_db_auth=False)
reader = PrismaWrapper(original_prisma=reader_inner, iam_token_db_auth=False)
routing = RoutingPrismaWrapper(writer=writer, reader=reader)
routing._writer_unavailable = writer_unavailable
return SimpleNamespace(db=routing), writer_inner, reader_inner
@ -53,6 +54,20 @@ async def test_rename_replaces_the_old_name_when_no_other_deployment_carries_it(
reader_inner.query_raw.assert_not_awaited()
@pytest.mark.asyncio
async def test_rename_update_stays_on_the_writer_while_writer_flagged_unavailable():
prisma_client, writer_inner, reader_inner = _routed_prisma_client(deployment_count=0, writer_unavailable=True)
with patch(_INVALIDATE, new=AsyncMock()):
await sync_access_groups_for_renamed_model(
prisma_client, model_id="m-1", old_name="gpt-5.6", new_name="gpt-5.6-eu", llm_router=None
)
(update_call,) = _access_group_updates(writer_inner)
assert update_call.args[1:] == ("gpt-5.6", "gpt-5.6-eu")
reader_inner.query_raw.assert_not_awaited()
@pytest.mark.asyncio
async def test_rename_appends_the_new_name_when_a_sibling_row_keeps_the_old_one():
prisma_client, writer_inner, _ = _routed_prisma_client(deployment_count=1)
@ -168,3 +183,17 @@ async def test_delete_keeps_the_name_while_a_sibling_row_still_backs_it():
assert _access_group_updates(writer_inner) == []
invalidate.assert_not_awaited()
@pytest.mark.asyncio
async def test_delete_counts_backing_rows_on_the_writer_not_a_lagging_replica_while_writer_flagged_unavailable():
prisma_client, writer_inner, reader_inner = _routed_prisma_client(deployment_count=0, writer_unavailable=True)
reader_inner.query_raw = AsyncMock(return_value=[{"deployment_count": 1}])
with patch(_INVALIDATE, new=AsyncMock()) as invalidate:
await sync_access_groups_for_deleted_model(prisma_client, model_id="m-1", model_name="gpt-5.6", llm_router=None)
(update_call,) = _access_group_updates(writer_inner)
assert "array_remove" in update_call.args[0]
invalidate.assert_awaited_once_with(("ag-1", "ag-2"))
reader_inner.query_raw.assert_not_awaited()