Files
open-webui/backend/open_webui/utils/async_utils.py
T
PVBLIC Foundation 972b0d8f34 Merge upstream v0.9.5 into main
Resolved conflicts in ~47 files. Kept local customizations and integrated
upstream security fixes, channels streaming, async migration, and new
features.

Backend - RAG / vector / audio / video / storage
- retrieval.py, knowledge.py: kept two-namespace deletion pattern,
  expandable tree, Drive sync hooks, get_namespace_for_collection helper
- pinecone.py + retrieval/vector/utils.py: kept namespace isolation, retry
  logic, PINECONE_DIMENSION PersistentConfig
- retrieval/utils.py + loaders/main.py: kept RobustCSVLoader, transcript
  segments; took upstream Excel/PPTX loaders
- audio.py: preserved results[idx] chunk ordering, HTTPException re-raise,
  audio/* + video/* MIME support
- storage/provider.py: kept caching + signed URL changes

Backend - core
- main.py: combined local schedulers (temp file cleanup, Gmail sync, Drive
  sync) with upstream scheduler_worker_loop; kept gmail.router
- config.py: kept Alembic multi-head cleanup + upgrade-to-heads, expanded
  GOOGLE_OAUTH_SCOPE (Gmail + Drive readonly), merged
  GOOGLE_OAUTH_AUTHORIZE_PARAMS, kept local RAG defaults +
  VIDEO_AUDIO_CHUNKING_STRATEGY
- env.py: kept local WEBUI_NAME branding (no '(Open WebUI)' suffix)
- middleware.py: combined UserUsages.record_usage with upstream
  outlet_filter_handler
- oauth.py: awaited update_session_by_id, kept verbose token-refresh
  errors and trigger_gmail_sync_if_needed
- pdf_generator.py: kept ReportLab implementation with custom fonts
- task.py: made prompt_improvement_template async-aware
- routers/tasks.py: awaited prompt_improvement_template
- utils/models.py: kept missing_function_ids tracking
- utils/misc.py: kept broader audio/video MIME support

Backend - models
- users.py: added Float import, kept UserSpendLimitForm, gmail_sync default,
  UserUsages cleanup; took upstream profile_image_url validator and async
  delete pattern
- chats.py: kept JSONB indexes, archived/pinned, custom indexes, PG17
  JSON_TABLE search optimization; took upstream async migration

Backend - routers (upstream security fixes + local hooks)
- channels.py: took upstream get_filtered_models + CHAT_COMPLETION_HANDLER;
  kept process_message_knowledge for RAG context injection
- chats.py, files.py, users.py, memories.py, openai.py, auths.py:
  took upstream mass-assignment, SSRF, ownership, image URL validation
  fixes; kept spend-limit and external user ID hooks

Alembic migrations
- Renamed upstream e1f2a3b4c5d6_add_is_pinned_to_note.py revision id to
  e2f3a4b5c6d7 to resolve collision with our merge_all_heads migration
- Added f7e8d9c0b1a2_merge_post_v0_9_5.py merging e1f2a3b4c5d6 +
  a0b1c2d3e4f5 into a single head

Frontend (Svelte)
- Chat.svelte: kept previousChatIdProp dedup + messageQueue for in-flight
  messages
- CitationModal.svelte: kept WEBUI_BASE_URL import alongside upstream
  config + injectCsp
- ContentRenderer.svelte: kept csv/json artifact detection alongside
  upstream tool-call/reasoning rendering
- MarkdownTokens.svelte: kept tableToCSV alongside new GROUPABLE_DETAIL
  helpers
- Placeholder.svelte: kept mobile-friendly model selection layout
- channel/Messages/Message.svelte: re-inserted Knowledge Sources display;
  took upstream timestamp formatting
- channel/MessageInput/InputMenu.svelte: removed bits-ui, kept knowledge
  attach tabs
- workspace/Knowledge/KnowledgeBase/AddContentMenu.svelte: kept Connect
  Google Drive entry, normalized button styling
- layout/Navbar/Menu.svelte + layout/Sidebar/ChatMenu.svelte: kept
  ReportLab backend PDF + Word doc export, layered upstream client-side
  PDF as alternative

Translations + assets
- en-US/translation.json: merged new upstream keys, kept local PPTX,
  Gmail sync, video chapters, spend-limit strings
- favicons left as-is (kept ours)

Dependencies
- requirements.txt + pyproject.toml: harmonized redis 7.4.0, kept
  unstructured[all-docs] extra, took upstream pydub 0.25.1, ddgs 9.11.4

Dockerfile
- Adopted upstream ENV UV_LINK_MODE=copy for QEMU arm64 cross-builds;
  kept local CUDA + model pre-download blocks

Version bump
- package.json + pyproject.toml -> 0.9.5
- CHANGELOG.md: added Fork Merge Summary section

Made-with: Cursor
2026-05-11 07:44:29 -07:00

223 lines
6.7 KiB
Python

"""
Async Utilities
Shared utilities for async operations including:
- Concurrent execution with semaphore control
- Parallel batch processing
- Rate-limited async operations
"""
import asyncio
import logging
from typing import (
Any,
Awaitable,
Callable,
Dict,
List,
Optional,
Tuple,
TypeVar,
Union,
)
logger = logging.getLogger(__name__)
T = TypeVar("T")
R = TypeVar("R")
async def gather_with_concurrency(
tasks: List[Awaitable[T]],
max_concurrent: int = 10,
return_exceptions: bool = True,
) -> List[Union[T, Exception]]:
"""
Execute async tasks with controlled concurrency.
This is a common pattern for limiting parallel operations to prevent
resource exhaustion (memory, connections, API rate limits).
Args:
tasks: List of awaitable tasks to execute
max_concurrent: Maximum number of concurrent tasks (default: 10)
return_exceptions: If True, exceptions are returned in results.
If False, first exception is raised.
Returns:
List of results in the same order as input tasks.
Failed tasks return Exception objects if return_exceptions=True.
Example:
>>> async def fetch(url):
... async with aiohttp.ClientSession() as session:
... async with session.get(url) as resp:
... return await resp.text()
>>> urls = ["http://example.com"] * 100
>>> tasks = [fetch(url) for url in urls]
>>> results = await gather_with_concurrency(tasks, max_concurrent=10)
"""
if not tasks:
return []
if max_concurrent <= 0:
max_concurrent = len(tasks) # Unlimited
semaphore = asyncio.Semaphore(max_concurrent)
async def limited_task(task: Awaitable[T]) -> T:
async with semaphore:
return await task
limited_tasks = [limited_task(task) for task in tasks]
return await asyncio.gather(*limited_tasks, return_exceptions=return_exceptions)
async def process_batch_parallel(
items: List[T],
processor: Callable[[T], Awaitable[R]],
max_concurrent: int = 5,
on_progress: Optional[Callable[[int, int], Awaitable[None]]] = None,
yield_interval: int = 0,
yield_delay: float = 0.0,
) -> Tuple[List[R], List[Dict[str, Any]]]:
"""
Process a batch of items in parallel with controlled concurrency.
Provides a clean abstraction for parallel processing with:
- Semaphore-based concurrency control
- Error isolation (one failure doesn't stop others)
- Progress callbacks
- Periodic yields to keep event loop responsive
Args:
items: List of items to process
processor: Async function to process each item
max_concurrent: Maximum parallel processors (default: 5)
on_progress: Optional async callback(processed_count, total_count)
yield_interval: Yield to event loop every N items (0 = disabled)
yield_delay: Delay in seconds when yielding (default: 0)
Returns:
Tuple of (successful_results, errors)
- successful_results: List of successful processor outputs
- errors: List of dicts with {index, item, error}
Example:
>>> async def embed_text(text):
... return await embedding_service.embed(text)
>>> texts = ["Hello", "World", "Test"]
>>> results, errors = await process_batch_parallel(
... items=texts,
... processor=embed_text,
... max_concurrent=5
... )
"""
if not items:
return [], []
successful_results: List[R] = []
errors: List[Dict[str, Any]] = []
processed_count = 0
total_count = len(items)
semaphore = asyncio.Semaphore(max_concurrent)
async def process_with_semaphore(index: int, item: T) -> Tuple[int, Optional[R], Optional[Exception]]:
async with semaphore:
try:
result = await processor(item)
return index, result, None
except Exception as e:
return index, None, e
# Create all tasks
tasks = [process_with_semaphore(i, item) for i, item in enumerate(items)]
# Process with gather (maintains order via index)
results = await asyncio.gather(*tasks, return_exceptions=False)
# Sort by index and collect results
for index, result, error in sorted(results, key=lambda x: x[0]):
processed_count += 1
if error:
errors.append(
{
"index": index,
"item": items[index],
"error": error,
}
)
logger.debug(f"Item {index} failed: {error}")
else:
successful_results.append(result)
# Progress callback
if on_progress:
await on_progress(processed_count, total_count)
# Periodic yield to event loop
if yield_interval > 0 and processed_count % yield_interval == 0:
await asyncio.sleep(yield_delay)
return successful_results, errors
async def retry_async(
func: Callable[[], Awaitable[T]],
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 30.0,
exponential: bool = True,
retryable_exceptions: Tuple[type, ...] = (Exception,),
) -> T:
"""
Retry an async function with exponential backoff.
Args:
func: Async function to retry (takes no arguments)
max_retries: Maximum retry attempts (default: 3)
base_delay: Initial delay in seconds (default: 1.0)
max_delay: Maximum delay cap in seconds (default: 30.0)
exponential: Use exponential backoff (default: True)
retryable_exceptions: Tuple of exception types to retry on
Returns:
Result of successful function call
Raises:
Last exception if all retries fail
Example:
>>> async def flaky_api_call():
... return await api.call()
>>> result = await retry_async(
... flaky_api_call,
... max_retries=3,
... retryable_exceptions=(aiohttp.ClientError,)
... )
"""
import random
last_exception = None
for attempt in range(max_retries):
try:
return await func()
except retryable_exceptions as e:
last_exception = e
if attempt < max_retries - 1:
if exponential:
delay = min(max_delay, base_delay * (2**attempt) + random.uniform(0, 1))
else:
delay = base_delay
logger.debug(f"Retry {attempt + 1}/{max_retries} after {delay:.1f}s: {e}")
await asyncio.sleep(delay)
else:
logger.warning(f"All {max_retries} retries failed: {e}")
raise last_exception