Repository navigation
fix(proxy): wait for the spend-log table before creating startup views #41974
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2863559
1b8f704
a114af2
327447b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,6 +38,7 @@ | |
| Literal, | ||
| Optional, | ||
| Protocol, | ||
| TypeAlias, | ||
| TypeVar, | ||
| Union, | ||
| cast, | ||
|
|
@@ -269,6 +270,15 @@ class _RelTuplesRow(TypedDict): | |
| reltuples: ReadOnly[int] | ||
|
|
||
|
|
||
| _VIEW_SETUP_POLL_INTERVAL_SECONDS: Final = 5.0 | ||
| _VIEW_SETUP_DEADLINE_SECONDS: Final = 15 * 60.0 | ||
| _VIEW_SETUP_GATE_TABLE: Final = '"LiteLLM_SpendLogs"' | ||
| _VIEW_SETUP_GATE_PROBE_ROWS: Final = TypeAdapter(tuple[Mapping[str, bool], ...]) | ||
|
|
||
| _ViewSetupOutcome: TypeAlias = Literal["ready", "timed_out"] | ||
| _ViewSetupAttempt: TypeAlias = Literal["ready", "table_missing"] | Exception | ||
|
|
||
|
|
||
| class _EndUserBatchTable(Protocol): | ||
| def upsert(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> None: ... | ||
|
|
||
|
|
@@ -4297,6 +4307,7 @@ def __init__( | |
| self.db = writer_wrapper # Client to connect to Prisma db | ||
| self._db_reconnect_lock = asyncio.Lock() | ||
| self._db_health_watchdog_task: asyncio.Task | None = None | ||
| self._view_setup_task: asyncio.Task[_ViewSetupOutcome] | None = None | ||
| self._db_last_reconnect_attempt_ts: float = 0.0 | ||
| self._db_reconnect_cooldown_seconds: int = max(1, int(os.getenv("PRISMA_RECONNECT_COOLDOWN_SECONDS", "15"))) | ||
| self._db_read_only_recreate_ts: float = 0.0 | ||
|
|
@@ -6343,6 +6354,71 @@ async def stop_db_health_watchdog_task(self) -> None: | |
| self._db_health_watchdog_task = None | ||
| verbose_proxy_logger.info("Stopped Prisma DB health watchdog") | ||
|
|
||
| def start_view_setup_task(self) -> None: | ||
| if self._view_setup_task is not None: | ||
| return | ||
| self._view_setup_task = asyncio.create_task(self._run_view_setup()) | ||
|
|
||
| async def stop_view_setup_task(self) -> None: | ||
| if self._view_setup_task is None: | ||
| return | ||
| self._view_setup_task.cancel() | ||
| with contextlib.suppress(asyncio.CancelledError): | ||
| await self._view_setup_task | ||
| self._view_setup_task = None | ||
|
|
||
| async def _run_view_setup( | ||
| self, | ||
| poll_interval_seconds: float = _VIEW_SETUP_POLL_INTERVAL_SECONDS, | ||
| deadline_seconds: float = _VIEW_SETUP_DEADLINE_SECONDS, | ||
| ) -> _ViewSetupOutcome: | ||
| deadline: Final = time.monotonic() + deadline_seconds | ||
| while True: | ||
| if (attempt := await self._attempt_view_setup()) == "ready": | ||
| return "ready" | ||
| if time.monotonic() >= deadline: | ||
| self._log_view_setup_timeout(attempt, deadline_seconds) | ||
| return "timed_out" | ||
|
Comment on lines
+6379
to
+6381
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| await asyncio.sleep(poll_interval_seconds) | ||
|
|
||
| async def _attempt_view_setup(self) -> _ViewSetupAttempt: | ||
| try: | ||
| if not await self._view_setup_gate_table_present(): | ||
| verbose_proxy_logger.debug( | ||
| "Waiting for table %s before creating the spend views", _VIEW_SETUP_GATE_TABLE | ||
| ) | ||
| return "table_missing" | ||
| await self._set_spend_logs_row_count_in_proxy_state() | ||
| await self.check_view_exists() | ||
| return "ready" | ||
| except Exception as e: | ||
| verbose_proxy_logger.warning("Spend view setup attempt failed, retrying until the schema settles: %s", e) | ||
| return e | ||
|
|
||
| def _log_view_setup_timeout( | ||
| self, last_attempt: Literal["table_missing"] | Exception, deadline_seconds: float | ||
| ) -> None: | ||
| if isinstance(last_attempt, Exception): | ||
| verbose_proxy_logger.error( | ||
| "Gave up creating the spend views after %ss; the last attempt failed with: %s. " | ||
| "Fix that error and restart the proxy.", | ||
| deadline_seconds, | ||
| last_attempt, | ||
| ) | ||
| return | ||
| verbose_proxy_logger.error( | ||
| "Gave up creating the spend views: table %s did not appear within %ss. " | ||
| "Run the database migrations against this database and restart the proxy.", | ||
| _VIEW_SETUP_GATE_TABLE, | ||
| deadline_seconds, | ||
| ) | ||
|
|
||
| async def _view_setup_gate_table_present(self) -> bool: | ||
| rows: Final = _VIEW_SETUP_GATE_PROBE_ROWS.validate_python( | ||
| await self.db.query_raw("SELECT to_regclass($1) IS NOT NULL AS present", _VIEW_SETUP_GATE_TABLE) | ||
| ) | ||
| return rows[0]["present"] | ||
|
|
||
| async def _db_health_watchdog_loop(self) -> None: | ||
| while True: | ||
| try: | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.