Spaces:
Build error
Build error
| # Server Progress Bars | |
| This document describes how to implement progress bars for long-running server processes using SSE (Server-Sent Events). | |
| ## Overview | |
| The progress system allows backend processes to display progress indicators in the frontend. It supports: | |
| - Multiple simultaneous progress widgets (identified by unique `progress_id`) | |
| - Determinate (percentage) and indeterminate progress modes | |
| - User cancellation with configurable cancel endpoints | |
| - Toast notifications for completion/error messages | |
| - Widget minimization with state persistence | |
| ## Architecture | |
| ``` | |
| Backend Frontend | |
| ┌─────────────────┐ ┌─────────────────┐ | |
| │ ProgressBar │──SSE Events──│ progress.js │ | |
| │ (sse_utils.py) │ │ plugin │ | |
| └─────────────────┘ └─────────────────┘ | |
| │ │ | |
| │ progressShow │ Creates widget | |
| │ progressValue │ Updates progress | |
| │ progressLabel │ Updates label | |
| │ progressHide │ Removes widget | |
| │ │ | |
| │ ┌───────▼───────┐ | |
| │◄───POST cancel────────│ Cancel button │ | |
| │ └───────────────┘ | |
| ``` | |
| ## Backend Usage | |
| ### Basic Progress Bar | |
| ```python | |
| from fastapi import Depends | |
| from fastapi_app.lib.core.dependencies import get_sse_service | |
| from fastapi_app.lib.sse.sse_utils import ProgressBar | |
| @router.post("/process") | |
| async def process_files( | |
| session_id: str, | |
| sse_service = Depends(get_sse_service) | |
| ): | |
| # Create progress bar instance | |
| progress = ProgressBar(sse_service, session_id) | |
| # Show with initial label (indeterminate mode) | |
| progress.show(label="Starting...", cancellable=True) | |
| files = get_files_to_process() | |
| for i, file in enumerate(files): | |
| # Update label and percentage | |
| progress.set_label(f"Processing {file.name}") | |
| progress.set_value(int((i + 1) / len(files) * 100)) | |
| await process_file(file) | |
| # Hide when complete | |
| progress.hide() | |
| return {"status": "complete"} | |
| ``` | |
| ### Progress Bar with Cancellation | |
| ```python | |
| from fastapi_app.lib.sse.sse_utils import ProgressBar, send_notification | |
| # Module-level cancellation registry | |
| _cancellation_tokens: dict[str, bool] = {} | |
| class CancellationToken: | |
| """Simple cancellation token for cooperative cancellation.""" | |
| def __init__(self, progress_id: str): | |
| self.progress_id = progress_id | |
| _cancellation_tokens[progress_id] = False | |
| def cancel(self): | |
| _cancellation_tokens[self.progress_id] = True | |
| @property | |
| def is_cancelled(self) -> bool: | |
| return _cancellation_tokens.get(self.progress_id, False) | |
| def cleanup(self): | |
| _cancellation_tokens.pop(self.progress_id, None) | |
| @router.post("/cancel/{progress_id}") | |
| async def cancel_operation(progress_id: str): | |
| """Cancel endpoint called by frontend.""" | |
| if progress_id in _cancellation_tokens: | |
| _cancellation_tokens[progress_id] = True | |
| return {"status": "cancelled"} | |
| return {"status": "not_found"} | |
| @router.post("/long-operation") | |
| async def long_operation( | |
| session_id: str, | |
| sse_service = Depends(get_sse_service) | |
| ): | |
| progress = ProgressBar(sse_service, session_id) | |
| cancel_url = f"/api/my-plugin/cancel/{progress.progress_id}" | |
| token = CancellationToken(progress.progress_id) | |
| progress.show( | |
| label="Processing...", | |
| cancellable=True, | |
| cancel_url=cancel_url # Frontend will POST here on cancel | |
| ) | |
| try: | |
| for i, item in enumerate(items): | |
| # Check for cancellation | |
| if token.is_cancelled: | |
| send_notification( | |
| sse_service, session_id, | |
| "Operation cancelled", "warning" | |
| ) | |
| progress.hide() | |
| return {"status": "cancelled"} | |
| progress.set_label(f"Item {i+1}/{len(items)}") | |
| progress.set_value(int((i / len(items)) * 100)) | |
| await process_item(item) | |
| # Success notification | |
| send_notification( | |
| sse_service, session_id, | |
| "Operation complete", "success" | |
| ) | |
| progress.hide() | |
| return {"status": "complete"} | |
| finally: | |
| token.cleanup() | |
| progress.hide() | |
| ``` | |
| ### ProgressBar API | |
| ```python | |
| class ProgressBar: | |
| def __init__( | |
| self, | |
| sse_service: SSEService, | |
| session_id: str, | |
| progress_id: str | None = None # Auto-generated if not provided | |
| ): | |
| ... | |
| @property | |
| def progress_id(self) -> str: | |
| """Unique identifier for this progress instance.""" | |
| ... | |
| def show( | |
| self, | |
| label: str | None = None, | |
| value: int | None = None, # 0-100, None for indeterminate | |
| cancellable: bool = True, | |
| cancel_url: str | None = None # URL for cancel button POST | |
| ) -> bool: | |
| ... | |
| def hide(self) -> bool: | |
| ... | |
| def set_value(self, value: int | None) -> bool: | |
| """Set progress value (0-100) or None for indeterminate.""" | |
| ... | |
| def set_label(self, label: str) -> bool: | |
| """Update the progress label text.""" | |
| ... | |
| ``` | |
| ### Sending Notifications | |
| ```python | |
| from fastapi_app.lib.sse.sse_utils import send_notification | |
| # Variants: "info", "success", "warning", "error" | |
| send_notification( | |
| sse_service, | |
| session_id, | |
| message="Operation completed successfully", | |
| variant="success", | |
| icon="check-circle" # Optional Shoelace icon name | |
| ) | |
| ``` | |
| ## Frontend Usage | |
| The progress plugin is automatically installed and listens for SSE events. You typically don't need to interact with it directly, but the API is available: | |
| ```javascript | |
| import { progress } from '../plugins.js' | |
| // Show a progress widget programmatically | |
| progress.show('my-progress-id', { | |
| label: 'Processing...', | |
| value: null, // null for indeterminate | |
| cancellable: true, | |
| cancelUrl: '/api/my-endpoint/cancel/my-progress-id' | |
| }) | |
| // Update progress | |
| progress.setValue('my-progress-id', 50) | |
| progress.setLabel('my-progress-id', 'Halfway there...') | |
| // Hide widget | |
| progress.hide('my-progress-id') | |
| // Check if visible | |
| const visible = progress.isVisible('my-progress-id') | |
| // Get list of active widgets | |
| const activeIds = progress.getActiveWidgets() | |
| ``` | |
| ## SSE Event Format | |
| All progress events are JSON-encoded with a `progress_id` field: | |
| ### progressShow | |
| ```json | |
| { | |
| "progress_id": "abc123", | |
| "label": "Processing...", | |
| "value": null, | |
| "cancellable": true, | |
| "cancelUrl": "/api/plugins/grobid/cancel/abc123" | |
| } | |
| ``` | |
| ### progressValue | |
| ```json | |
| { | |
| "progress_id": "abc123", | |
| "value": 50 | |
| } | |
| ``` | |
| ### progressLabel | |
| ```json | |
| { | |
| "progress_id": "abc123", | |
| "label": "Step 2 of 5..." | |
| } | |
| ``` | |
| ### progressHide | |
| ```json | |
| { | |
| "progress_id": "abc123" | |
| } | |
| ``` | |
| ### notification | |
| ```json | |
| { | |
| "message": "Operation complete", | |
| "variant": "success", | |
| "icon": "check-circle" | |
| } | |
| ``` | |
| ## Widget Behavior | |
| - **Click to toggle**: Clicking on the widget toggles between minimized and maximized states | |
| - **Stacking**: Multiple widgets stack vertically (bottom-left when minimized, centered when maximized) | |
| - **State persistence**: Minimized state is stored per `progress_id` in session storage | |
| - **Cancel**: When clicked, sends POST to the configured `cancelUrl` | |
| ## Complete Example: Collection Processing | |
| This example shows a complete implementation for processing all documents in a collection: | |
| ### Backend Route | |
| ```python | |
| @router.get("/process-collection") | |
| async def process_collection( | |
| collection: str = Query(...), | |
| session_id: str | None = Query(None), | |
| x_session_id: str | None = Header(None, alias="X-Session-ID"), | |
| session_manager = Depends(get_session_manager), | |
| auth_manager = Depends(get_auth_manager), | |
| sse_service = Depends(get_sse_service), | |
| ): | |
| # Authentication | |
| session_id_value = x_session_id or session_id | |
| if not session_id_value: | |
| raise HTTPException(status_code=401, detail="Authentication required") | |
| settings = get_settings() | |
| if not session_manager.is_session_valid(session_id_value, settings.session_timeout): | |
| raise HTTPException(status_code=401, detail="Invalid session") | |
| user = auth_manager.get_user_by_session_id(session_id_value, session_manager) | |
| if not user: | |
| raise HTTPException(status_code=401, detail="User not found") | |
| # Set up progress with cancellation | |
| progress = ProgressBar(sse_service, session_id_value) | |
| cancel_url = f"/api/my-plugin/cancel/{progress.progress_id}" | |
| token = CancellationToken(progress.progress_id) | |
| progress.show( | |
| label=f"Processing collection {collection}...", | |
| cancellable=True, | |
| cancel_url=cancel_url | |
| ) | |
| try: | |
| db = get_db() | |
| file_repo = FileRepository(db) | |
| files = file_repo.get_files_by_collection(collection) | |
| processed = 0 | |
| for i, file in enumerate(files): | |
| if token.is_cancelled: | |
| send_notification( | |
| sse_service, session_id_value, | |
| "Processing cancelled", "warning" | |
| ) | |
| raise HTTPException(status_code=499, detail="Cancelled") | |
| progress.set_label(f"File {i+1}/{len(files)}: {file.doc_id[:20]}...") | |
| progress.set_value(int((i / len(files)) * 100)) | |
| await process_file(file) | |
| processed += 1 | |
| send_notification( | |
| sse_service, session_id_value, | |
| f"Processed {processed} files", "success" | |
| ) | |
| progress.hide() | |
| return {"processed": processed} | |
| except HTTPException: | |
| progress.hide() | |
| token.cleanup() | |
| raise | |
| finally: | |
| token.cleanup() | |
| ``` | |
| ### Cancel Endpoint | |
| ```python | |
| @router.post("/cancel/{progress_id}") | |
| async def cancel_progress(progress_id: str): | |
| if progress_id in _cancellation_tokens: | |
| _cancellation_tokens[progress_id] = True | |
| return {"status": "cancelled"} | |
| return {"status": "not_found"} | |
| ``` | |
| ## Handling Blocking Operations | |
| SSE events are delivered through the async event loop. If your route contains **synchronous blocking code** (e.g., HTTP requests using `requests`, file I/O, CPU-intensive operations), SSE events will be queued but not delivered until the blocking operation completes. | |
| ### Problem: Blocking Code Prevents SSE Delivery | |
| ```python | |
| # BAD: Blocking call prevents SSE updates from being delivered | |
| @router.get("/process") | |
| async def process_files(session_id: str, sse_service = Depends(get_sse_service)): | |
| progress = ProgressBar(sse_service, session_id) | |
| progress.show(label="Starting...") # Event queued but not sent yet | |
| for file in files: | |
| progress.set_label(f"Processing {file.name}") # Queued | |
| result = blocking_http_request(file) # Blocks event loop! | |
| # SSE events won't be delivered until this returns | |
| progress.hide() | |
| ``` | |
| ### Solution 1: Run Blocking Code in Thread Pool | |
| Use `asyncio.to_thread()` to run blocking operations in a thread pool, allowing the event loop to process SSE events: | |
| ```python | |
| import asyncio | |
| @router.get("/process") | |
| async def process_files(session_id: str, sse_service = Depends(get_sse_service)): | |
| progress = ProgressBar(sse_service, session_id) | |
| progress.show(label="Starting...") | |
| await asyncio.sleep(0) # Yield to deliver the show event | |
| for i, file in enumerate(files): | |
| progress.set_label(f"Processing {file.name}") | |
| progress.set_value(int((i / len(files)) * 100)) | |
| await asyncio.sleep(0) # Yield to deliver updates | |
| # Run blocking operation in thread pool | |
| result = await asyncio.to_thread(blocking_http_request, file) | |
| progress.hide() | |
| ``` | |
| ### Solution 2: Yield Control After SSE Calls | |
| For quick operations, adding `await asyncio.sleep(0)` after SSE calls yields control to the event loop: | |
| ```python | |
| progress.show(label="Starting...") | |
| await asyncio.sleep(0) # Allow event to be sent | |
| for i, item in enumerate(items): | |
| progress.set_label(f"Item {i+1}") | |
| progress.set_value(int((i / len(items)) * 100)) | |
| await asyncio.sleep(0) # Allow updates to be sent | |
| await process_item(item) # Must be async | |
| ``` | |
| ### When to Use Each Approach | |
| | Scenario | Solution | | |
| | -------- | -------- | | |
| | Calling external APIs with `requests` | `asyncio.to_thread()` | | |
| | Heavy file I/O operations | `asyncio.to_thread()` | | |
| | CPU-intensive processing | `asyncio.to_thread()` | | |
| | Quick async operations | `await asyncio.sleep(0)` after SSE calls | | |
| | Mixed sync/async code | Combine both approaches | | |
| ## Best Practices | |
| 1. **Always hide on completion or error**: Ensure `progress.hide()` is called in all code paths | |
| 2. **Use try/finally**: Clean up cancellation tokens and hide progress in finally blocks | |
| 3. **Provide meaningful labels**: Update labels to show current operation and item counts | |
| 4. **Use notifications for final status**: Send success/error notifications when operations complete | |
| 5. **Check cancellation frequently**: Check `token.is_cancelled` at the start of each iteration | |
| 6. **Use indeterminate mode for unknown durations**: Pass `value=None` when total count is unknown | |
| 7. **Run blocking code in thread pool**: Use `asyncio.to_thread()` for synchronous operations to allow SSE delivery | |
| 8. **Yield after SSE calls**: Add `await asyncio.sleep(0)` after progress updates when needed | |