pdf-tei-editor / docs /development /server-progress.md
cmboulanger's picture
disaster-recovery deploy of pdf-tei-editor
6a49f21 verified
|
Raw
History Blame Contribute Delete
13.8 kB
# 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