Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/db/db_transaction_queue/redis_update_buffer.py: 22%
248 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""
2Handles buffering database `UPDATE` transactions in Redis before committing them to the database
4This is to prevent deadlocks and improve reliability
5"""
7import asyncio
8import json
9from collections.abc import Mapping, Sequence
10from datetime import datetime
11from functools import reduce
12from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, TypeVar, cast
14from redis.exceptions import RedisError
16from litellm._logging import verbose_proxy_logger
17from litellm.caching import RedisCache
18from litellm.constants import (
19 MAX_REDIS_BUFFER_DEQUEUE_COUNT,
20 REDIS_DAILY_AGENT_SPEND_UPDATE_BUFFER_KEY,
21 REDIS_DAILY_END_USER_SPEND_UPDATE_BUFFER_KEY,
22 REDIS_DAILY_ORG_SPEND_UPDATE_BUFFER_KEY,
23 REDIS_DAILY_SPEND_UPDATE_BUFFER_KEY,
24 REDIS_DAILY_TAG_SPEND_UPDATE_BUFFER_KEY,
25 REDIS_DAILY_TEAM_SPEND_UPDATE_BUFFER_KEY,
26 REDIS_SPEND_LOGS_BUFFER_KEY,
27 REDIS_SPEND_LOGS_BUFFER_MAX_ROWS,
28 REDIS_UPDATE_BUFFER_KEY,
29 REDIS_WINDOW_SPEND_UPDATE_BUFFER_KEY,
30)
31from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
32from litellm.proxy._types import (
33 BaseDailySpendTransaction,
34 DailyAgentSpendTransaction,
35 DailyEndUserSpendTransaction,
36 DailyOrganizationSpendTransaction,
37 DailyTagSpendTransaction,
38 DailyTeamSpendTransaction,
39 DailyUserSpendTransaction,
40 DBSpendUpdateTransactions,
41 Litellm_EntityType,
42 SpendUpdateQueueItem,
43)
44from litellm.proxy.db.db_transaction_queue.base_update_queue import service_logger_obj
45from litellm.proxy.db.db_transaction_queue.daily_spend_update_queue import (
46 DailySpendUpdateQueue,
47)
48from litellm.proxy.db.db_transaction_queue.spend_update_queue import SpendUpdateQueue
49from litellm.proxy.db.db_transaction_queue.window_spend_update_queue import (
50 WindowSpendTransaction,
51 WindowSpendUpdateQueue,
52 to_wire_payload,
53)
54from litellm.proxy.db.spend_log_batching import SpendLogRow
55from litellm.secret_managers.main import str_to_bool
56from litellm.types.caching import (
57 RedisPipelineLpopOperation,
58 RedisPipelineRpushOperation,
59)
60from litellm.types.services import ServiceTypes
62if TYPE_CHECKING: 62 ↛ 63line 62 didn't jump to line 63 because the condition on line 62 was never true
63 from litellm.proxy.utils import PrismaClient
64else:
65 PrismaClient = Any
67BufferedSpendTransactions: TypeAlias = DBSpendUpdateTransactions | Mapping[str, BaseDailySpendTransaction]
69_SpendTransactionField: TypeAlias = Literal[
70 "user_list_transactions",
71 "end_user_list_transactions",
72 "key_list_transactions",
73 "team_list_transactions",
74 "team_member_list_transactions",
75 "org_list_transactions",
76 "org_member_list_transactions",
77 "project_list_transactions",
78 "tag_list_transactions",
79 "agent_list_transactions",
80 "model_access_group_list_transactions",
81]
83_SPEND_TRANSACTION_FIELDS: Final[tuple[_SpendTransactionField, ...]] = (
84 "user_list_transactions",
85 "end_user_list_transactions",
86 "key_list_transactions",
87 "team_list_transactions",
88 "team_member_list_transactions",
89 "org_list_transactions",
90 "org_member_list_transactions",
91 "project_list_transactions",
92 "tag_list_transactions",
93 "agent_list_transactions",
94 "model_access_group_list_transactions",
95)
97_ValueT = TypeVar("_ValueT")
100def _spend_log_json_default(value: object) -> str:
101 return value.isoformat() if isinstance(value, datetime) else str(value)
104def _encode_spend_log_row(row: SpendLogRow) -> str:
105 return json.dumps(row, default=_spend_log_json_default)
108def _decode_spend_log_row(encoded: str) -> dict[str, object] | None:
109 decoded: Final = json.loads(encoded)
110 return decoded if isinstance(decoded, dict) else None
113def _accumulated_spend(totals: Mapping[str, float], entities: Mapping[str, float]) -> dict[str, float]:
114 return {**totals, **{entity_id: totals.get(entity_id, 0) + amount for entity_id, amount in entities.items()}}
117def _entity_transactions(transaction: DBSpendUpdateTransactions, field: _SpendTransactionField) -> dict[str, float]:
118 entities: Final[dict[str, float] | None] = transaction.get(field)
119 return entities if isinstance(entities, dict) else {}
122def _merged_entity_transactions(
123 list_of_transactions: Sequence[DBSpendUpdateTransactions],
124 field: _SpendTransactionField,
125) -> dict[str, float]:
126 return reduce(
127 _accumulated_spend,
128 (_entity_transactions(transaction, field) for transaction in list_of_transactions),
129 {},
130 )
133class RedisUpdateBuffer:
134 """
135 Handles buffering database `UPDATE` transactions in Redis before committing them to the database
137 This is to prevent deadlocks and improve reliability
138 """
140 def __init__(
141 self,
142 redis_cache: RedisCache | None = None,
143 ):
144 self.redis_cache = redis_cache
146 @staticmethod
147 def _should_commit_spend_updates_to_redis() -> bool:
148 """
149 Checks if the Pod should commit spend updates to Redis
151 This setting enables buffering database transactions in Redis
152 to improve reliability and reduce database contention
153 """
154 from litellm.proxy.proxy_server import general_settings
156 _use_redis_transaction_buffer: bool | str | None = general_settings.get("use_redis_transaction_buffer", False)
157 if isinstance(_use_redis_transaction_buffer, str): 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true
158 _use_redis_transaction_buffer = str_to_bool(_use_redis_transaction_buffer)
159 if _use_redis_transaction_buffer is None: 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true
160 return False
161 return _use_redis_transaction_buffer
163 async def _store_transactions_in_redis(
164 self,
165 transactions: Mapping[str, BaseDailySpendTransaction] | None,
166 redis_key: str,
167 service_type: ServiceTypes,
168 ) -> None:
169 """
170 Helper method to store transactions in Redis and emit an event
172 Args:
173 transactions: The transactions to store
174 redis_key: The Redis key to store under
175 service_type: The service type for event emission
176 """
177 if transactions is None or len(transactions) == 0:
178 return
180 list_of_transactions: Final = [safe_dumps(transactions)]
181 if self.redis_cache is None:
182 return
183 try:
184 current_redis_buffer_size: Final = await self.redis_cache.async_rpush(
185 key=redis_key,
186 values=list_of_transactions,
187 )
188 verbose_proxy_logger.debug(
189 "Spend tracking - pushed spend updates to Redis buffer. redis_key=%s, buffer_size=%s",
190 redis_key,
191 current_redis_buffer_size,
192 )
193 await self._emit_new_item_added_to_redis_buffer_event(
194 queue_size=current_redis_buffer_size,
195 service=service_type,
196 )
197 except Exception as e:
198 verbose_proxy_logger.error(
199 "Spend tracking - failed to push spend updates to Redis (redis_key=%s). Error: %s",
200 redis_key,
201 str(e),
202 )
204 async def store_in_memory_spend_updates_in_redis(
205 self,
206 spend_update_queue: SpendUpdateQueue,
207 daily_spend_update_queue: DailySpendUpdateQueue,
208 daily_team_spend_update_queue: DailySpendUpdateQueue,
209 daily_org_spend_update_queue: DailySpendUpdateQueue,
210 daily_end_user_spend_update_queue: DailySpendUpdateQueue,
211 daily_agent_spend_update_queue: DailySpendUpdateQueue,
212 window_spend_update_queue: WindowSpendUpdateQueue | None = None,
213 ):
214 """
215 Stores the in-memory spend updates to Redis
217 Stores the following in memory data structures in Redis:
218 - SpendUpdateQueue - Key, User, Team, TeamMember, Org, EndUser Spend updates
219 - DailySpendUpdateQueue - Daily Spend updates Aggregate view
221 For SpendUpdateQueue:
222 Each transaction is a dict stored as following:
223 - key is the entity id
224 - value is the spend amount
226 ```
227 Redis List:
228 key_list_transactions:
229 [
230 "0929880201": 1.2,
231 "0929880202": 0.01,
232 "0929880203": 0.001,
233 ]
234 ```
236 For DailySpendUpdateQueue:
237 Each transaction is a Dict[str, DailyUserSpendTransaction] stored as following:
238 - key is the daily_transaction_key
239 - value is the DailyUserSpendTransaction
241 ```
242 Redis List:
243 daily_spend_update_transactions:
244 [
245 {
246 "user_keyhash_1_model_1": {
247 "spend": 1.2,
248 "prompt_tokens": 1000,
249 "completion_tokens": 1000,
250 "api_requests": 1000,
251 "successful_requests": 1000,
252 },
254 }
255 ]
256 ```
257 """
258 if self.redis_cache is None:
259 verbose_proxy_logger.debug("redis_cache is None, skipping store_in_memory_spend_updates_in_redis")
260 return
262 # Get all transactions
263 db_spend_update_transactions: Final = (
264 await spend_update_queue.flush_and_get_aggregated_db_spend_update_transactions()
265 )
266 daily_spend_update_transactions: Final = (
267 await daily_spend_update_queue.flush_and_get_aggregated_daily_spend_update_transactions()
268 )
269 daily_team_spend_update_transactions: Final = (
270 await daily_team_spend_update_queue.flush_and_get_aggregated_daily_spend_update_transactions()
271 )
272 daily_org_spend_update_transactions: Final = (
273 await daily_org_spend_update_queue.flush_and_get_aggregated_daily_spend_update_transactions()
274 )
275 daily_end_user_spend_update_transactions: Final = (
276 await daily_end_user_spend_update_queue.flush_and_get_aggregated_daily_spend_update_transactions()
277 )
278 daily_agent_spend_update_transactions: Final = (
279 await daily_agent_spend_update_queue.flush_and_get_aggregated_daily_spend_update_transactions()
280 )
281 window_spend_update_transactions: Final = (
282 await window_spend_update_queue.flush_and_get_aggregated_window_spend_transactions()
283 if window_spend_update_queue is not None
284 else ()
285 )
287 verbose_proxy_logger.debug("ALL DB SPEND UPDATE TRANSACTIONS: %s", db_spend_update_transactions)
288 verbose_proxy_logger.debug("ALL DAILY SPEND UPDATE TRANSACTIONS: %s", daily_spend_update_transactions)
290 # Build a list of rpush operations, skipping empty/None transaction sets
291 _queue_configs: Final[list[tuple[BufferedSpendTransactions | None, str, ServiceTypes]]] = [
292 (
293 db_spend_update_transactions,
294 REDIS_UPDATE_BUFFER_KEY,
295 ServiceTypes.REDIS_SPEND_UPDATE_QUEUE,
296 ),
297 (
298 daily_spend_update_transactions,
299 REDIS_DAILY_SPEND_UPDATE_BUFFER_KEY,
300 ServiceTypes.REDIS_DAILY_SPEND_UPDATE_QUEUE,
301 ),
302 (
303 daily_team_spend_update_transactions,
304 REDIS_DAILY_TEAM_SPEND_UPDATE_BUFFER_KEY,
305 ServiceTypes.REDIS_DAILY_TEAM_SPEND_UPDATE_QUEUE,
306 ),
307 (
308 daily_org_spend_update_transactions,
309 REDIS_DAILY_ORG_SPEND_UPDATE_BUFFER_KEY,
310 ServiceTypes.REDIS_DAILY_ORG_SPEND_UPDATE_QUEUE,
311 ),
312 (
313 daily_end_user_spend_update_transactions,
314 REDIS_DAILY_END_USER_SPEND_UPDATE_BUFFER_KEY,
315 ServiceTypes.REDIS_DAILY_END_USER_SPEND_UPDATE_QUEUE,
316 ),
317 (
318 daily_agent_spend_update_transactions,
319 REDIS_DAILY_AGENT_SPEND_UPDATE_BUFFER_KEY,
320 ServiceTypes.REDIS_DAILY_AGENT_SPEND_UPDATE_QUEUE,
321 ),
322 (
323 tuple(map(to_wire_payload, window_spend_update_transactions)),
324 REDIS_WINDOW_SPEND_UPDATE_BUFFER_KEY,
325 ServiceTypes.REDIS_WINDOW_SPEND_UPDATE_QUEUE,
326 ),
327 ]
329 rpush_list: Final[list[RedisPipelineRpushOperation]] = []
330 service_types: Final[list[ServiceTypes]] = []
331 for transactions, redis_key, service_type in _queue_configs:
332 if transactions is None or len(transactions) == 0:
333 continue
334 rpush_list.append(
335 RedisPipelineRpushOperation(
336 key=redis_key,
337 values=[safe_dumps(transactions)],
338 )
339 )
340 service_types.append(service_type)
342 if len(rpush_list) == 0:
343 return
345 try:
346 result_lengths: Final = await self.redis_cache.async_rpush_pipeline(
347 rpush_list=rpush_list,
348 )
349 except Exception as e:
350 # The in-memory queues were already drained above. If we let the
351 # exception propagate without restoring, the aggregated spend is
352 # permanently lost. Re-enqueue so the next scheduler tick retries.
353 verbose_proxy_logger.error(
354 "Spend tracking - failed to push aggregated spend updates to Redis. "
355 "Restoring %d transaction sets to in-memory queues for retry on next tick. "
356 "Error: %s",
357 len(rpush_list),
358 str(e),
359 )
360 await self._restore_spend_updates_to_in_memory_queues(
361 db_spend_update_transactions=db_spend_update_transactions,
362 daily_spend_update_transactions=daily_spend_update_transactions,
363 daily_team_spend_update_transactions=daily_team_spend_update_transactions,
364 daily_org_spend_update_transactions=daily_org_spend_update_transactions,
365 daily_end_user_spend_update_transactions=daily_end_user_spend_update_transactions,
366 daily_agent_spend_update_transactions=daily_agent_spend_update_transactions,
367 window_spend_update_transactions=window_spend_update_transactions,
368 spend_update_queue=spend_update_queue,
369 daily_spend_update_queue=daily_spend_update_queue,
370 daily_team_spend_update_queue=daily_team_spend_update_queue,
371 daily_org_spend_update_queue=daily_org_spend_update_queue,
372 daily_end_user_spend_update_queue=daily_end_user_spend_update_queue,
373 daily_agent_spend_update_queue=daily_agent_spend_update_queue,
374 window_spend_update_queue=window_spend_update_queue,
375 )
376 return
378 # Emit gauge events for each queue
379 for i, queue_size in enumerate(result_lengths):
380 if i < len(service_types):
381 await self._emit_new_item_added_to_redis_buffer_event(
382 queue_size=queue_size,
383 service=service_types[i],
384 )
386 @staticmethod
387 async def _restore_spend_updates_to_in_memory_queues(
388 db_spend_update_transactions: DBSpendUpdateTransactions | None,
389 daily_spend_update_transactions: dict[str, BaseDailySpendTransaction] | None,
390 daily_team_spend_update_transactions: dict[str, BaseDailySpendTransaction] | None,
391 daily_org_spend_update_transactions: dict[str, BaseDailySpendTransaction] | None,
392 daily_end_user_spend_update_transactions: dict[str, BaseDailySpendTransaction] | None,
393 daily_agent_spend_update_transactions: dict[str, BaseDailySpendTransaction] | None,
394 window_spend_update_transactions: tuple[WindowSpendTransaction, ...] | None,
395 spend_update_queue: SpendUpdateQueue,
396 daily_spend_update_queue: DailySpendUpdateQueue,
397 daily_team_spend_update_queue: DailySpendUpdateQueue,
398 daily_org_spend_update_queue: DailySpendUpdateQueue,
399 daily_end_user_spend_update_queue: DailySpendUpdateQueue,
400 daily_agent_spend_update_queue: DailySpendUpdateQueue,
401 window_spend_update_queue: WindowSpendUpdateQueue | None,
402 ) -> None:
403 """
404 Put drained-but-unpushed transactions back into in-memory queues.
406 Called when the Redis rpush pipeline raises. Without this, all spend
407 data aggregated during the current scheduler tick is permanently lost
408 because the source queues were already drained before the rpush.
409 """
410 if db_spend_update_transactions is not None:
411 entity_entries: Final[list[tuple[Litellm_EntityType, dict[str, float] | None]]] = [
412 (
413 Litellm_EntityType.USER,
414 db_spend_update_transactions.get("user_list_transactions"),
415 ),
416 (
417 Litellm_EntityType.END_USER,
418 db_spend_update_transactions.get("end_user_list_transactions"),
419 ),
420 (
421 Litellm_EntityType.KEY,
422 db_spend_update_transactions.get("key_list_transactions"),
423 ),
424 (
425 Litellm_EntityType.TEAM,
426 db_spend_update_transactions.get("team_list_transactions"),
427 ),
428 (
429 Litellm_EntityType.TEAM_MEMBER,
430 db_spend_update_transactions.get("team_member_list_transactions"),
431 ),
432 (
433 Litellm_EntityType.ORGANIZATION,
434 db_spend_update_transactions.get("org_list_transactions"),
435 ),
436 (
437 Litellm_EntityType.ORGANIZATION_MEMBER,
438 db_spend_update_transactions.get("org_member_list_transactions"),
439 ),
440 (
441 Litellm_EntityType.PROJECT,
442 db_spend_update_transactions.get("project_list_transactions"),
443 ),
444 (
445 Litellm_EntityType.TAG,
446 db_spend_update_transactions.get("tag_list_transactions"),
447 ),
448 (
449 Litellm_EntityType.AGENT,
450 db_spend_update_transactions.get("agent_list_transactions"),
451 ),
452 (
453 Litellm_EntityType.MODEL_ACCESS_GROUP,
454 db_spend_update_transactions.get("model_access_group_list_transactions"),
455 ),
456 ]
457 for entity_type, entities in entity_entries:
458 if not entities:
459 continue
460 for entity_id, cost in entities.items():
461 await spend_update_queue.add_update(
462 SpendUpdateQueueItem(
463 entity_type=entity_type,
464 entity_id=entity_id,
465 response_cost=cost,
466 )
467 )
469 daily_pairs: Final[list[tuple[dict[str, BaseDailySpendTransaction] | None, DailySpendUpdateQueue]]] = [
470 (daily_spend_update_transactions, daily_spend_update_queue),
471 (daily_team_spend_update_transactions, daily_team_spend_update_queue),
472 (daily_org_spend_update_transactions, daily_org_spend_update_queue),
473 (
474 daily_end_user_spend_update_transactions,
475 daily_end_user_spend_update_queue,
476 ),
477 (daily_agent_spend_update_transactions, daily_agent_spend_update_queue),
478 ]
479 for daily_txns, daily_queue in daily_pairs:
480 if daily_txns:
481 await daily_queue.update_queue.put(daily_txns)
483 if window_spend_update_transactions and window_spend_update_queue is not None:
484 await window_spend_update_queue.update_queue.put(window_spend_update_transactions)
486 async def restore_transactions_to_redis(
487 self,
488 db_spend_update_transactions: DBSpendUpdateTransactions | None = None,
489 daily_spend_update_transactions: Mapping[str, BaseDailySpendTransaction] | None = None,
490 daily_team_spend_update_transactions: Mapping[str, BaseDailySpendTransaction] | None = None,
491 daily_org_spend_update_transactions: Mapping[str, BaseDailySpendTransaction] | None = None,
492 daily_end_user_spend_update_transactions: Mapping[str, BaseDailySpendTransaction] | None = None,
493 daily_agent_spend_update_transactions: Mapping[str, BaseDailySpendTransaction] | None = None,
494 daily_tag_spend_update_transactions: Mapping[str, BaseDailySpendTransaction] | None = None,
495 window_spend_update_transactions: Sequence[WindowSpendTransaction] | None = None,
496 ) -> None:
497 """
498 Re-push transactions that were popped from Redis but not committed to the DB.
500 The leader drains the buffers with a destructive ``lpop`` before committing to
501 the database. When a commit fails after its retries are exhausted, the popped
502 transactions must be pushed back so a later scheduler tick can retry them;
503 otherwise the aggregated spend is lost permanently. The re-pushed payloads use
504 the same JSON encoding as the store path, so the next drain parses them normally.
505 """
506 if self.redis_cache is None:
507 return
509 restore_configs: Final = (
510 (db_spend_update_transactions, REDIS_UPDATE_BUFFER_KEY),
511 (daily_spend_update_transactions, REDIS_DAILY_SPEND_UPDATE_BUFFER_KEY),
512 (daily_team_spend_update_transactions, REDIS_DAILY_TEAM_SPEND_UPDATE_BUFFER_KEY),
513 (daily_org_spend_update_transactions, REDIS_DAILY_ORG_SPEND_UPDATE_BUFFER_KEY),
514 (daily_end_user_spend_update_transactions, REDIS_DAILY_END_USER_SPEND_UPDATE_BUFFER_KEY),
515 (daily_agent_spend_update_transactions, REDIS_DAILY_AGENT_SPEND_UPDATE_BUFFER_KEY),
516 (daily_tag_spend_update_transactions, REDIS_DAILY_TAG_SPEND_UPDATE_BUFFER_KEY),
517 (
518 None
519 if window_spend_update_transactions is None
520 else tuple(map(to_wire_payload, window_spend_update_transactions)),
521 REDIS_WINDOW_SPEND_UPDATE_BUFFER_KEY,
522 ),
523 )
525 rpush_list: Final = tuple(
526 RedisPipelineRpushOperation(key=redis_key, values=(safe_dumps(transactions),))
527 for transactions, redis_key in restore_configs
528 if transactions
529 )
530 if len(rpush_list) == 0:
531 return
533 try:
534 await self.redis_cache.async_rpush_pipeline(rpush_list=rpush_list)
535 verbose_proxy_logger.info(
536 "Spend tracking - restored %d uncommitted transaction set(s) to Redis for retry on next tick.",
537 len(rpush_list),
538 )
539 except RedisError as e:
540 verbose_proxy_logger.error(
541 "Spend tracking - failed to restore uncommitted transactions to Redis. "
542 "These spend updates are lost. Error: %s",
543 str(e),
544 )
546 async def store_spend_logs_in_redis(
547 self,
548 rows: Sequence[SpendLogRow],
549 max_rows: int = REDIS_SPEND_LOGS_BUFFER_MAX_ROWS,
550 ) -> bool:
551 """Park spend-log rows in Redis so they outlive this pod, dropping the oldest past ``max_rows``."""
552 if self.redis_cache is None or len(rows) == 0 or not self._should_commit_spend_updates_to_redis():
553 return False
554 try:
555 buffer_size: Final = await self.redis_cache.async_rpush_and_trim(
556 key=REDIS_SPEND_LOGS_BUFFER_KEY,
557 values=tuple(_encode_spend_log_row(row) for row in rows),
558 max_len=max_rows,
559 )
560 overflow: Final = buffer_size - max_rows
561 if overflow > 0:
562 verbose_proxy_logger.error(
563 "Spend tracking - Redis spend log buffer is at its %d row cap; dropped the %d oldest spend logs",
564 max_rows,
565 overflow,
566 )
567 except Exception as e: # noqa: BLE001 # the caller falls back to the in-memory queue on any Redis fault
568 verbose_proxy_logger.error(
569 "Spend tracking - failed to park %d spend log rows in Redis. Error: %s", len(rows), str(e)
570 )
571 return False
572 verbose_proxy_logger.info("Spend tracking - parked %d spend log rows in Redis for a later flush", len(rows))
573 return True
575 async def get_spend_logs_from_redis_buffer(self, limit: int) -> tuple[dict[str, object], ...]:
576 """Atomically take up to ``limit`` parked spend-log rows out of Redis."""
577 if self.redis_cache is None or not self._should_commit_spend_updates_to_redis(): 577 ↛ 579line 577 didn't jump to line 579 because the condition on line 577 was always true
578 return ()
579 popped: Final[str | list[str] | None] = await self.redis_cache.async_lpop(
580 key=REDIS_SPEND_LOGS_BUFFER_KEY,
581 count=limit,
582 )
583 if popped is None:
584 return ()
585 encoded_rows: Final = tuple(popped) if isinstance(popped, list) else (popped,)
586 decoded_rows: Final = (_decode_spend_log_row(encoded) for encoded in encoded_rows)
587 return tuple(row for row in decoded_rows if row is not None)
589 @staticmethod
590 def _number_of_transactions_to_store_in_redis(
591 db_spend_update_transactions: DBSpendUpdateTransactions,
592 ) -> int:
593 """
594 Gets the number of transactions to store in Redis
595 """
596 return sum(
597 len(_entity_transactions(db_spend_update_transactions, field)) for field in _SPEND_TRANSACTION_FIELDS
598 )
600 @staticmethod
601 def _remove_prefix_from_keys(data: Mapping[str, _ValueT], prefix: str) -> dict[str, _ValueT]:
602 """
603 Removes the specified prefix from the keys of a dictionary.
604 """
605 return {key.replace(prefix, "", 1): value for key, value in data.items()}
607 async def get_all_update_transactions_from_redis_buffer(
608 self,
609 ) -> DBSpendUpdateTransactions | None:
610 """
611 Gets all the update transactions from Redis
613 On Redis we store a list of transactions as a JSON string
615 eg.
616 [
617 DBSpendUpdateTransactions(
618 user_list_transactions={
619 "user_id_1": 1.2,
620 "user_id_2": 0.01,
621 },
622 end_user_list_transactions={},
623 key_list_transactions={
624 "0929880201": 1.2,
625 "0929880202": 0.01,
626 },
627 team_list_transactions={},
628 team_member_list_transactions={},
629 org_list_transactions={},
630 ),
631 DBSpendUpdateTransactions(
632 user_list_transactions={
633 "user_id_3": 1.2,
634 "user_id_4": 0.01,
635 },
636 end_user_list_transactions={},
637 key_list_transactions={
638 "key_id_1": 1.2,
639 "key_id_2": 0.01,
640 },
641 team_list_transactions={},
642 team_member_list_transactions={},
643 org_list_transactions={},
644 ]
645 """
646 if self.redis_cache is None:
647 return None
648 list_of_transactions: Final[str | list[str] | None] = await self.redis_cache.async_lpop(
649 key=REDIS_UPDATE_BUFFER_KEY,
650 count=MAX_REDIS_BUFFER_DEQUEUE_COUNT,
651 )
652 if list_of_transactions is None:
653 return None
655 verbose_proxy_logger.info(
656 "Spend tracking - popped %d spend update batches from Redis buffer (key=%s). "
657 "These items are now removed from Redis and must be committed to DB.",
658 len(list_of_transactions) if isinstance(list_of_transactions, list) else 1,
659 REDIS_UPDATE_BUFFER_KEY,
660 )
662 # Parse the list of transactions from JSON strings
663 parsed_transactions: Final = self._parse_list_of_transactions(list_of_transactions)
665 # If there are no transactions, return None
666 if len(parsed_transactions) == 0:
667 return None
669 # Combine all transactions into a single transaction
670 combined_transaction: Final = self._combine_list_of_transactions(parsed_transactions)
672 return combined_transaction
674 async def get_all_transactions_from_redis_buffer_pipeline(
675 self,
676 ) -> tuple[
677 DBSpendUpdateTransactions | None,
678 dict[str, DailyUserSpendTransaction] | None,
679 dict[str, DailyTeamSpendTransaction] | None,
680 dict[str, DailyOrganizationSpendTransaction] | None,
681 dict[str, DailyEndUserSpendTransaction] | None,
682 dict[str, DailyAgentSpendTransaction] | None,
683 tuple[WindowSpendTransaction, ...] | None,
684 ]:
685 """
686 Drains the main 7 Redis buffer queues in a single pipeline round-trip.
688 Returns a 7-tuple of parsed results in this order:
689 0: DBSpendUpdateTransactions
690 1: daily user spend
691 2: daily team spend
692 3: daily org spend
693 4: daily end-user spend
694 5: daily agent spend
695 6: budget window spend
696 """
697 if self.redis_cache is None:
698 return None, None, None, None, None, None, None
700 lpop_list: Final[list[RedisPipelineLpopOperation]] = [
701 RedisPipelineLpopOperation(key=REDIS_UPDATE_BUFFER_KEY, count=MAX_REDIS_BUFFER_DEQUEUE_COUNT),
702 RedisPipelineLpopOperation(
703 key=REDIS_DAILY_SPEND_UPDATE_BUFFER_KEY,
704 count=MAX_REDIS_BUFFER_DEQUEUE_COUNT,
705 ),
706 RedisPipelineLpopOperation(
707 key=REDIS_DAILY_TEAM_SPEND_UPDATE_BUFFER_KEY,
708 count=MAX_REDIS_BUFFER_DEQUEUE_COUNT,
709 ),
710 RedisPipelineLpopOperation(
711 key=REDIS_DAILY_ORG_SPEND_UPDATE_BUFFER_KEY,
712 count=MAX_REDIS_BUFFER_DEQUEUE_COUNT,
713 ),
714 RedisPipelineLpopOperation(
715 key=REDIS_DAILY_END_USER_SPEND_UPDATE_BUFFER_KEY,
716 count=MAX_REDIS_BUFFER_DEQUEUE_COUNT,
717 ),
718 RedisPipelineLpopOperation(
719 key=REDIS_DAILY_AGENT_SPEND_UPDATE_BUFFER_KEY,
720 count=MAX_REDIS_BUFFER_DEQUEUE_COUNT,
721 ),
722 RedisPipelineLpopOperation(
723 key=REDIS_WINDOW_SPEND_UPDATE_BUFFER_KEY,
724 count=MAX_REDIS_BUFFER_DEQUEUE_COUNT,
725 ),
726 ]
728 raw_results: Final = await self.redis_cache.async_lpop_pipeline(lpop_list=lpop_list)
730 # Pad with None if pipeline returned fewer results than expected
731 while len(raw_results) < 7:
732 raw_results.append(None)
734 # Slot 0: DBSpendUpdateTransactions
735 db_spend: DBSpendUpdateTransactions | None = None
736 if raw_results[0] is not None:
737 parsed: Final = self._parse_list_of_transactions(raw_results[0])
738 if len(parsed) > 0:
739 db_spend = self._combine_list_of_transactions(parsed)
741 # Slots 1-5: daily spend categories
742 daily_results: Final[list[dict[str, BaseDailySpendTransaction] | None]] = []
743 for slot in range(1, 6):
744 slot_result = raw_results[slot]
745 if slot_result is None:
746 daily_results.append(None)
747 else:
748 list_of_daily = [json.loads(t) for t in slot_result]
749 aggregated = DailySpendUpdateQueue.get_aggregated_daily_spend_update_transactions(list_of_daily)
750 daily_results.append(aggregated)
752 window_spend: Final = (
753 WindowSpendUpdateQueue.get_aggregated_window_spend_transactions(
754 tuple(json.loads(transaction) for transaction in raw_results[6])
755 )
756 if raw_results[6] is not None
757 else None
758 )
760 return (
761 db_spend,
762 cast(dict[str, DailyUserSpendTransaction] | None, daily_results[0]),
763 cast(dict[str, DailyTeamSpendTransaction] | None, daily_results[1]),
764 cast(dict[str, DailyOrganizationSpendTransaction] | None, daily_results[2]),
765 cast(dict[str, DailyEndUserSpendTransaction] | None, daily_results[3]),
766 cast(dict[str, DailyAgentSpendTransaction] | None, daily_results[4]),
767 window_spend,
768 )
770 async def store_in_memory_daily_tag_spend_updates_in_redis(
771 self,
772 daily_tag_spend_update_queue: DailySpendUpdateQueue,
773 ) -> None:
774 """
775 Flush in-memory daily tag spend updates and append them to Redis.
776 """
777 daily_tag_spend_update_transactions: Final = (
778 await daily_tag_spend_update_queue.flush_and_get_aggregated_daily_spend_update_transactions()
779 )
780 await self._store_transactions_in_redis(
781 transactions=daily_tag_spend_update_transactions,
782 redis_key=REDIS_DAILY_TAG_SPEND_UPDATE_BUFFER_KEY,
783 service_type=ServiceTypes.REDIS_DAILY_TAG_SPEND_UPDATE_QUEUE,
784 )
786 async def _lpop_daily_spend_transactions(
787 self,
788 redis_key: str,
789 ) -> list[dict[str, BaseDailySpendTransaction]] | None:
790 """
791 Drains a daily spend buffer key and parses each popped item as JSON.
792 """
793 if self.redis_cache is None:
794 return None
795 list_of_transactions: Final[list[str] | None] = await self.redis_cache.async_lpop(
796 key=redis_key,
797 count=MAX_REDIS_BUFFER_DEQUEUE_COUNT,
798 )
799 if list_of_transactions is None:
800 return None
801 return [json.loads(transaction) for transaction in list_of_transactions]
803 async def get_all_daily_spend_update_transactions_from_redis_buffer(
804 self,
805 ) -> dict[str, DailyUserSpendTransaction] | None:
806 """
807 Gets all the daily spend update transactions from Redis
808 """
809 if self.redis_cache is None:
810 return None
811 list_of_daily_spend_update_transactions: Final = await self._lpop_daily_spend_transactions(
812 REDIS_DAILY_SPEND_UPDATE_BUFFER_KEY
813 )
814 if list_of_daily_spend_update_transactions is None:
815 return None
816 return cast(
817 dict[str, DailyUserSpendTransaction],
818 DailySpendUpdateQueue.get_aggregated_daily_spend_update_transactions(
819 list_of_daily_spend_update_transactions
820 ),
821 )
823 async def get_all_daily_team_spend_update_transactions_from_redis_buffer(
824 self,
825 ) -> dict[str, DailyTeamSpendTransaction] | None:
826 """
827 Gets all the daily team spend update transactions from Redis
828 """
829 if self.redis_cache is None:
830 return None
831 list_of_daily_spend_update_transactions: Final = await self._lpop_daily_spend_transactions(
832 REDIS_DAILY_TEAM_SPEND_UPDATE_BUFFER_KEY
833 )
834 if list_of_daily_spend_update_transactions is None:
835 return None
836 return cast(
837 dict[str, DailyTeamSpendTransaction],
838 DailySpendUpdateQueue.get_aggregated_daily_spend_update_transactions(
839 list_of_daily_spend_update_transactions
840 ),
841 )
843 async def get_all_daily_org_spend_update_transactions_from_redis_buffer(
844 self,
845 ) -> dict[str, DailyOrganizationSpendTransaction] | None:
846 """
847 Gets all the daily organization spend update transactions from Redis
848 """
849 if self.redis_cache is None:
850 return None
851 list_of_daily_spend_update_transactions: Final = await self._lpop_daily_spend_transactions(
852 REDIS_DAILY_ORG_SPEND_UPDATE_BUFFER_KEY
853 )
854 if list_of_daily_spend_update_transactions is None:
855 return None
856 return cast(
857 dict[str, DailyOrganizationSpendTransaction],
858 DailySpendUpdateQueue.get_aggregated_daily_spend_update_transactions(
859 list_of_daily_spend_update_transactions
860 ),
861 )
863 async def get_all_daily_end_user_spend_update_transactions_from_redis_buffer(
864 self,
865 ) -> dict[str, DailyEndUserSpendTransaction] | None:
866 """
867 Gets all the daily end-user spend update transactions from Redis
868 """
869 if self.redis_cache is None:
870 return None
871 list_of_daily_spend_update_transactions: Final = await self._lpop_daily_spend_transactions(
872 REDIS_DAILY_END_USER_SPEND_UPDATE_BUFFER_KEY
873 )
874 if list_of_daily_spend_update_transactions is None:
875 return None
876 return cast(
877 dict[str, DailyEndUserSpendTransaction],
878 DailySpendUpdateQueue.get_aggregated_daily_spend_update_transactions(
879 list_of_daily_spend_update_transactions
880 ),
881 )
883 async def get_all_daily_agent_spend_update_transactions_from_redis_buffer(
884 self,
885 ) -> dict[str, DailyAgentSpendTransaction] | None:
886 """
887 Gets all the daily agent spend update transactions from Redis
888 """
889 if self.redis_cache is None:
890 return None
891 list_of_daily_spend_update_transactions: Final = await self._lpop_daily_spend_transactions(
892 REDIS_DAILY_AGENT_SPEND_UPDATE_BUFFER_KEY
893 )
894 if list_of_daily_spend_update_transactions is None:
895 return None
896 return cast(
897 dict[str, DailyAgentSpendTransaction],
898 DailySpendUpdateQueue.get_aggregated_daily_spend_update_transactions(
899 list_of_daily_spend_update_transactions
900 ),
901 )
903 async def get_all_daily_tag_spend_update_transactions_from_redis_buffer(
904 self,
905 ) -> dict[str, DailyTagSpendTransaction] | None:
906 """
907 Gets all the daily tag spend update transactions from Redis
908 """
909 if self.redis_cache is None:
910 return None
911 list_of_daily_spend_update_transactions: Final = await self._lpop_daily_spend_transactions(
912 REDIS_DAILY_TAG_SPEND_UPDATE_BUFFER_KEY
913 )
914 if list_of_daily_spend_update_transactions is None:
915 return None
916 return cast(
917 dict[str, DailyTagSpendTransaction],
918 DailySpendUpdateQueue.get_aggregated_daily_spend_update_transactions(
919 list_of_daily_spend_update_transactions
920 ),
921 )
923 @staticmethod
924 def _parse_list_of_transactions(
925 list_of_transactions: str | list[str],
926 ) -> list[DBSpendUpdateTransactions]:
927 """
928 Parses the list of transactions from Redis
929 """
930 if isinstance(list_of_transactions, list):
931 return [json.loads(transaction) for transaction in list_of_transactions]
932 else:
933 return [json.loads(list_of_transactions)]
935 @staticmethod
936 def _combine_list_of_transactions(
937 list_of_transactions: list[DBSpendUpdateTransactions],
938 ) -> DBSpendUpdateTransactions:
939 """
940 Combines the list of transactions into a single DBSpendUpdateTransactions object
941 """
942 return DBSpendUpdateTransactions(
943 user_list_transactions=_merged_entity_transactions(list_of_transactions, "user_list_transactions"),
944 end_user_list_transactions=_merged_entity_transactions(list_of_transactions, "end_user_list_transactions"),
945 key_list_transactions=_merged_entity_transactions(list_of_transactions, "key_list_transactions"),
946 team_list_transactions=_merged_entity_transactions(list_of_transactions, "team_list_transactions"),
947 team_member_list_transactions=_merged_entity_transactions(
948 list_of_transactions, "team_member_list_transactions"
949 ),
950 org_list_transactions=_merged_entity_transactions(list_of_transactions, "org_list_transactions"),
951 org_member_list_transactions=_merged_entity_transactions(
952 list_of_transactions, "org_member_list_transactions"
953 ),
954 project_list_transactions=_merged_entity_transactions(list_of_transactions, "project_list_transactions"),
955 tag_list_transactions=_merged_entity_transactions(list_of_transactions, "tag_list_transactions"),
956 agent_list_transactions=_merged_entity_transactions(list_of_transactions, "agent_list_transactions"),
957 model_access_group_list_transactions=_merged_entity_transactions(
958 list_of_transactions, "model_access_group_list_transactions"
959 ),
960 )
962 async def _emit_new_item_added_to_redis_buffer_event(
963 self,
964 service: ServiceTypes,
965 queue_size: int,
966 ):
967 asyncio.create_task(
968 service_logger_obj.async_service_success_hook(
969 service=service,
970 duration=0,
971 call_type="_emit_new_item_added_to_queue_event",
972 event_metadata={
973 "gauge_labels": service,
974 "gauge_value": queue_size,
975 },
976 )
977 )