Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

620 lines
21 KiB
Markdown

# Notification Flow - Complete Trace
This document traces the complete flow of notifications from research completion/failure through to delivery via Apprise.
## Overview
Notifications are sent when:
1. **Research completes successfully**`RESEARCH_COMPLETED` event
2. **Research fails**`RESEARCH_FAILED` event
3. **Research is queued**`RESEARCH_QUEUED` event
4. **Subscription updates**`SUBSCRIPTION_UPDATE` event
5. **Subscription errors**`SUBSCRIPTION_ERROR` event
6. **API quota/rate limits exceeded**`API_QUOTA_WARNING` event
7. **Authentication fails**`AUTH_ISSUE` event
## Complete Flow: Research Completed
### 1. Research Thread Completes (`research_service.py:1166-1168`)
When research finishes successfully in the background thread:
```python
# src/local_deep_research/web/services/research_service.py:1166
cleanup_research_resources(
research_id, active_research, termination_flags, username
)
```
### 2. Cleanup Calls Queue Processor (`research_service.py:1727`)
The cleanup function notifies the queue processor:
```python
# src/local_deep_research/web/services/research_service.py:1727
queue_processor.notify_research_completed(username, research_id, user_password=user_password)
```
**Key Points:**
- Called from background research thread
- Passes `user_password` for secure database access
- Uses `processor_v2` which handles encrypted per-user databases
### 3. Queue Processor Updates Status (`processor_v2.py:278-306`)
```python
# src/local_deep_research/web/queue/processor_v2.py:278-306
def notify_research_completed(self, username: str, research_id: str, user_password: str = None):
with get_user_db_session(username, user_password) as session:
# Update queue status
queue_service = UserQueueService(session)
queue_service.update_task_status(research_id, "completed")
# Send notification if enabled
self._send_research_notification(
session=session,
username=username,
research_id=research_id,
event_type="RESEARCH_COMPLETED",
)
```
**Key Points:**
- Opens encrypted user database with password
- Updates queue status first
- Delegates to `_send_research_notification` helper
### 4. Build Notification Context (`processor_v2.py:357-243`)
The helper method prepares the notification:
```python
# src/local_deep_research/web/queue/processor_v2.py:357-243
def _send_research_notification(
self,
session,
username: str,
research_id: str,
event_type: str,
error_message: str = None,
):
# Get settings snapshot for thread-safe notification sending
settings_manager = SettingsManager(session)
settings_snapshot = settings_manager.get_settings_snapshot()
# Lookup research details (with retry logic for timing issues)
research = session.query(ResearchHistory).filter_by(id=research_id).first()
if research:
# Create notification manager with settings snapshot and user_id
notification_manager = NotificationManager(
settings_snapshot=settings_snapshot,
user_id=username # Enables per-user rate limiting
)
# Build full URL for notification
full_url = build_notification_url(
f"/research/{research_id}",
settings_manager=settings_manager,
)
# Build notification context
context = {
"query": research.query or "Unknown query",
"research_id": research_id,
"summary": report_content[:200] + "...", # Truncated
"url": full_url, # Full clickable URL
}
# Send notification (user_id already set in manager init)
result = notification_manager.send_notification(
event_type=EventType.RESEARCH_COMPLETED,
context=context,
)
```
**Key Points:**
- **Settings Snapshot**: Captures settings at notification time (thread-safe)
- **Research Lookup**: Queries database for research details with 3 retry attempts
- **URL Building**: Constructs full URL using `app.external_url` or `app.host`/`app.port`
- **Context Building**: Includes query, research_id, summary (truncated to 200 chars), full URL
- **No Session Passed**: NotificationManager gets `settings_snapshot`, NOT session (thread-safe)
### 5. NotificationManager Checks Settings (`manager.py:91-126`)
```python
# src/local_deep_research/notifications/manager.py:91-126
def send_notification(
self,
event_type: EventType,
context: Dict[str, Any],
user_id: Optional[str] = None,
force: bool = False,
) -> bool:
# Check if notifications are enabled for this event type
should_notify = self._should_notify(event_type, user_id)
if not force and not should_notify:
logger.debug(f"Notifications disabled for event type: {event_type.value}")
return False
# Check rate limit
rate_limit_ok = self._rate_limiter.allow(user_id or "default")
if not force and not rate_limit_ok:
raise RateLimitError("Notification rate limit exceeded")
# Get service URLs from settings snapshot
service_urls = self._get_setting("notifications.service_url", default="")
if not service_urls or not service_urls.strip():
logger.debug("No notification service URLs configured")
return False
# Send notification with service URLs
result = self.service.send_event(event_type, context, service_urls=service_urls)
return result
```
**Settings Checked (from `settings_snapshot`):**
1. **`notifications.on_research_completed`** - Is this event type enabled? (default: False for most events)
2. **Per-User Rate Limits** - Check shared rate limiter with user-specific limits:
- `notifications.rate_limit_per_hour` (default: 10) - Configured per user
- `notifications.rate_limit_per_day` (default: 50) - Configured per user
- Each user has independent rate limit counters
3. **`notifications.service_url`** - Comma-separated list of Apprise URLs (required)
**Key Points:**
- All settings come from `settings_snapshot` (captured earlier)
- Rate limiter is **shared singleton** with per-user limits and counters
- Each user's rate limits are configured independently when `NotificationManager` is created with `user_id`
- One user hitting their rate limit does NOT affect other users
- `force=False` by default (respects settings and rate limits)
### 6. NotificationService Formats Message (`service.py:198-235`)
```python
# src/local_deep_research/notifications/service.py:198-235
def send_event(
self,
event_type: EventType,
context: Dict[str, Any],
service_urls: Optional[str] = None,
tag: Optional[str] = None,
custom_template: Optional[Dict[str, str]] = None,
) -> bool:
# Format notification using template
message = NotificationTemplate.format(
event_type, context, custom_template
)
# Send notification
result = self.send(
title=message["title"],
body=message["body"],
service_urls=service_urls,
tag=tag,
)
return result
```
**Template Used (`templates.py`):**
```python
EventType.RESEARCH_COMPLETED: {
"title": "Research Completed: {query}",
"body": "Your research '{query}' has completed successfully.\n\n"
"Summary: {summary}\n\n"
"View results: {url}",
}
```
**Context Variables:**
- `{query}` - Research query text
- `{summary}` - Truncated report content (max 200 chars)
- `{url}` - Full clickable URL to view research
- `{research_id}` - Research ID (available but not used in default template)
### 7. NotificationService Sends via Apprise (`service.py:47-196`)
```python
# src/local_deep_research/notifications/service.py:47-196
def send(
self,
title: str,
body: str,
service_urls: Optional[str] = None,
tag: Optional[str] = None,
attach: Optional[List[str]] = None,
) -> bool:
# Retry logic with exponential backoff
retry_delay = INITIAL_RETRY_DELAY # 0.5s
for attempt in range(1, MAX_RETRY_ATTEMPTS + 1): # 3 attempts
try:
# Create temporary Apprise instance
# Automatically garbage collected by Python when out of scope
apprise_instance = apprise.Apprise()
apprise_instance.add(service_urls, tag=tag)
# Send notification
notify_result = apprise_instance.notify(
title=title,
body=body,
tag=tag,
attach=attach,
)
if notify_result:
return True
# Retry with exponential backoff
time.sleep(retry_delay)
retry_delay *= RETRY_BACKOFF_MULTIPLIER # 2x
except Exception as e:
logger.error(f"Error sending notification: {e}")
time.sleep(retry_delay)
retry_delay *= RETRY_BACKOFF_MULTIPLIER
# All attempts failed
raise SendError("Failed to send notification after 3 attempts")
```
**Retry Strategy:**
- **Attempt 1**: Immediate send
- **Attempt 2**: Wait 0.5s, retry
- **Attempt 3**: Wait 1.0s, retry
- **After 3 attempts**: Raise `SendError`
**Apprise Instances:**
- Temporary instances created for each send operation
- Automatically garbage collected by Python
- Multiple service URLs supported (comma-separated)
### 8. Apprise Delivers Notification
Apprise handles the actual delivery to configured services:
```python
# User's settings: notifications.service_url
# Security: placeholder example credentials below, not real secrets
"discord://webhook_id/webhook_token,mailto://user:password@smtp.gmail.com"
```
**Supported Services** (via Apprise):
- Discord, Slack, Telegram
- Email (SMTP, Gmail, etc.)
- Pushover, Gotify
- Many more: https://github.com/caronc/apprise/wiki
**Security:**
- Service URLs encrypted at rest (AES-256 via SQLCipher)
- Encryption key derived from user's login password (PBKDF2-SHA512)
- URLs masked in logs (e.g., `discord://webhook_id/***`)
## Complete Flow: Research Failed
Similar to completed flow, but:
1. **Entry Point**: `research_service.py:1642` (exception handler)
2. **Queue Method**: `queue_processor.queue_error_update()` (processor.py)
3. **Notification**: Sent from `processor.py:577-606` (error update handler)
4. **Event Type**: `EventType.RESEARCH_FAILED`
5. **Context**: Includes `error` field instead of `summary`
**Template for RESEARCH_FAILED:**
```python
EventType.RESEARCH_FAILED: {
"title": "Research Failed: {query}",
"body": "Research on '{query}' failed.\n\n"
"Error: {error}\n\n"
"Please check the logs for more details.",
}
```
**Note**: Error messages are sanitized for security to avoid exposing sensitive information in notifications.
## Settings Snapshot Pattern
**Why Settings Snapshot?**
Notifications are sent from background threads that shouldn't access Flask `g` or SQLite sessions (not thread-safe). The solution is to **capture settings once** in the main thread and pass them as a dict.
**How It Works:**
```python
# 1. In main thread (with database session)
settings_manager = SettingsManager(session)
settings_snapshot = settings_manager.get_settings_snapshot()
# Returns dict like: {"notifications.service_url": "...", "notifications.on_research_completed": True, ...}
# 2. Pass to NotificationManager (thread-safe - no session!)
notification_manager = NotificationManager(
settings_snapshot=settings_snapshot,
user_id=username
)
# 3. NotificationManager reads from snapshot
def _get_setting(self, key: str, default: Any = None) -> Any:
return self._settings_snapshot.get(key, default)
```
**Benefits:**
- ✅ Thread-safe (no database access in background threads)
- ✅ Consistent settings (captured at notification time, not changed mid-notification)
- ✅ No Flask `g` context needed
- ✅ Works from queue processors, schedulers, etc.
## Rate Limiting
**Implementation:** In-memory, per-user limits and counters, shared singleton
```python
class RateLimiter:
_lock = threading.Lock()
_user_limits: Dict[str, tuple[int, int]] = {} # user_id -> (max_per_hour, max_per_day)
_hourly_counts: Dict[str, deque] = {} # user_id -> timestamps
_daily_counts: Dict[str, deque] = {} # user_id -> timestamps
def set_user_limits(self, user_id: str, max_per_hour: int, max_per_day: int):
"""Configure rate limits for a specific user."""
with self._lock:
self._user_limits[user_id] = (max_per_hour, max_per_day)
def allow(self, user_id: str) -> bool:
with self._lock: # Thread-safe
now = datetime.now(timezone.utc)
# Clean old entries (> 1 hour, > 1 day)
self._clean_old_entries(user_id, now)
# Get user-specific limits or defaults
max_per_hour, max_per_day = self._user_limits.get(
user_id, (self.max_per_hour, self.max_per_day)
)
# Check limits using user-specific values
if len(self._hourly_counts[user_id]) >= max_per_hour:
return False
if len(self._daily_counts[user_id]) >= max_per_day:
return False
# Record this notification
self._hourly_counts[user_id].append(now)
self._daily_counts[user_id].append(now)
return True
```
**Shared Singleton Pattern with Per-User Configuration:**
```python
class NotificationManager:
_shared_rate_limiter: Optional["RateLimiter"] = None
_rate_limiter_lock = threading.Lock()
def __init__(self, settings_snapshot: Dict[str, Any], user_id: str):
with NotificationManager._rate_limiter_lock:
if NotificationManager._shared_rate_limiter is None:
# Create shared rate limiter with defaults
NotificationManager._shared_rate_limiter = RateLimiter(
max_per_hour=settings_snapshot.get("notifications.rate_limit_per_hour", 10),
max_per_day=settings_snapshot.get("notifications.rate_limit_per_day", 50),
)
self._rate_limiter = NotificationManager._shared_rate_limiter
# Configure per-user limits (user_id is required)
max_per_hour = settings_snapshot.get("notifications.rate_limit_per_hour", 10)
max_per_day = settings_snapshot.get("notifications.rate_limit_per_day", 50)
self._rate_limiter.set_user_limits(user_id, max_per_hour, max_per_day)
```
**Key Points:**
- **One rate limiter instance** across all NotificationManager instances (singleton)
- **Per-user rate limits**: Each user can have different limits based on their settings
- **Per-user counters**: Each user has independent notification counters
- **User isolation**: One user hitting their limit does NOT affect others
- **Thread-safe** with `threading.Lock()` for all operations
- **Automatic cleanup** of old entries (> 1 hour, > 1 day)
- **Periodic cleanup** of inactive users (every 24 hours)
- **Memory efficient**: ~24 bytes per user for limit storage
## Configuration
### Required Settings
```python
# Service URL (required) - comma-separated list
# Security: placeholder example credentials below, not real secrets
notifications.service_url = "discord://webhook_id/token,mailto://user:pass@smtp.gmail.com"
# Event-specific toggles (default: False for most events)
notifications.on_research_completed = True # Default: True
notifications.on_research_failed = True # Default: True
notifications.on_research_queued = False # Default: False
notifications.on_subscription_update = True # Default: True
notifications.on_subscription_error = False # Default: False
notifications.on_api_quota_warning = False # Default: False
notifications.on_auth_issue = False # Default: False
# Rate limits (per-user, configured independently for each user)
notifications.rate_limit_per_hour = 10 # Max notifications per hour (per user)
notifications.rate_limit_per_day = 50 # Max notifications per day (per user)
# URL configuration (for clickable links)
app.external_url = "https://ldr.example.com" # Preferred
# OR
app.host = "localhost"
app.port = 5000
```
### Testing Notifications
```python
from local_deep_research.notifications.manager import NotificationManager
# Create manager with settings and required user_id
notification_manager = NotificationManager(
settings_snapshot={},
user_id="test_user" # Required for per-user rate limiting
)
# Test a service URL
result = notification_manager.test_service("discord://webhook_id/webhook_token")
print(result) # {'success': True, 'message': 'Test notification sent successfully'}
```
## Error Handling
### Rate Limit Exceeded
```python
try:
notification_manager.send_notification(
event_type=EventType.RESEARCH_COMPLETED,
context=context,
)
except RateLimitError as e:
logger.warning(f"Rate limit exceeded: {e}")
# Notification not sent, user needs to wait
# Note: This only affects the user that the manager was created for
```
### Send Failure (After 3 Retries)
```python
try:
result = service.send(title="...", body="...", service_urls="...")
except SendError as e:
logger.error(f"Failed to send notification after 3 attempts: {e}")
# All retry attempts exhausted
```
### No Service URLs Configured
```python
result = notification_manager.send_notification(...)
# Returns: False (no error raised)
# Log: "No notification service URLs configured for user {user_id}"
```
### Notifications Disabled for Event Type
```python
# settings_snapshot = {"notifications.on_research_completed": False}
result = notification_manager.send_notification(
event_type=EventType.RESEARCH_COMPLETED,
context=context
)
# Returns: False (respects user preference)
# Log: "Notifications disabled for event type: research_completed"
```
## Architecture Diagram
```
[Research Thread]
cleanup_research_resources()
queue_processor.notify_research_completed(username, research_id, password)
[Queue Processor - Main Thread]
get_user_db_session(username, password) → [Encrypted DB]
SettingsManager(session).get_settings_snapshot() → settings_snapshot
ResearchHistory.query.filter_by(id=research_id).first() → research details
build_notification_url() → full_url
NotificationManager(settings_snapshot=settings_snapshot, user_id=username)
notification_manager.send_notification(
event_type=EventType.RESEARCH_COMPLETED,
context={query, research_id, summary, url}
)
[NotificationManager]
├─ Check: notifications.on_research_completed
├─ Check: Per-user rate limiter (hourly/daily)
└─ Get: notifications.service_url
NotificationService.send_event(event_type, context, service_urls)
NotificationTemplate.format(event_type, context) → {title, body}
NotificationService.send(title, body, service_urls)
[Retry Loop: 3 attempts with exponential backoff]
├─ Attempt 1: Immediate
├─ Attempt 2: Wait 0.5s
└─ Attempt 3: Wait 1.0s
Apprise.add(service_urls)
Apprise.notify(title=title, body=body)
[Apprise - Delivery]
├─ Discord webhook
├─ SMTP email
└─ Other services...
```
## Key Design Decisions
1. **Settings Snapshot Pattern**: Avoids thread-safety issues with database sessions
2. **Shared Rate Limiter with Per-User Limits**: Single rate limiter instance ensures correct per-user enforcement while maintaining separate limits and counters for each user
3. **User Isolation**: Each user's rate limits are independent - one user hitting their limit does not affect others
4. **Temporary Apprise Instances**: Created per-send and automatically garbage collected
5. **Exponential Backoff**: 3 retry attempts with increasing delays (0.5s → 1.0s → 2.0s)
6. **Encrypted Storage**: Service URLs stored encrypted in per-user SQLCipher database
7. **URL Masking**: Credentials hidden in logs (e.g., `discord://webhook_id/***`)
8. **No Session in Manager**: NotificationManager never receives database session (thread-safe)
9. **Error Sanitization**: Error messages sanitized in notifications to prevent information exposure
## Additional Notification Events
### Research Queued Notifications
Sent when research is added to the queue:
- **Event**: `EventType.RESEARCH_QUEUED`
- **Triggered from**: `web/queue/manager.py` when adding research to queue
- **Context**: `query`, `position`, `wait_time`
- **Default**: Disabled (opt-in)
### API Quota Warnings
Sent when API rate limits are exceeded:
- **Event**: `EventType.API_QUOTA_WARNING`
- **Triggered from**: `error_handling/error_reporter.py` when detecting rate limit errors
- **Context**: `service`, `current`, `limit`, `reset_time`
- **Default**: Disabled (opt-in)
### Authentication Issues
Sent when API authentication fails:
- **Event**: `EventType.AUTH_ISSUE`
- **Triggered from**: `error_handling/error_reporter.py` when detecting auth errors
- **Context**: `service`
- **Default**: Disabled (opt-in)
### Subscription Notifications
Sent when subscriptions update or fail:
- **Events**: `EventType.SUBSCRIPTION_UPDATE`, `EventType.SUBSCRIPTION_ERROR`
- **Context**: `subscription_name`, `subscription_id`, `item_count`/`error`, `url`
## Testing
Run notification tests:
```bash
pdm run python -m pytest tests/notifications/ -v
```
All 98 tests passing ✓ (including 7 new per-user rate limiting tests)