feat: implement 8 OpenSpec proposals for security, reliability, and UX improvements
## Security Enhancements (P0) - Add input validation with max_length and numeric range constraints - Implement WebSocket token authentication via first message - Add path traversal prevention in file storage service ## Permission Enhancements (P0) - Add project member management for cross-department access - Implement is_department_manager flag for workload visibility ## Cycle Detection (P0) - Add DFS-based cycle detection for task dependencies - Add formula field circular reference detection - Display user-friendly cycle path visualization ## Concurrency & Reliability (P1) - Implement optimistic locking with version field (409 Conflict on mismatch) - Add trigger retry mechanism with exponential backoff (1s, 2s, 4s) - Implement cascade restore for soft-deleted tasks ## Rate Limiting (P1) - Add tiered rate limits: standard (60/min), sensitive (20/min), heavy (5/min) - Apply rate limits to tasks, reports, attachments, and comments ## Frontend Improvements (P1) - Add responsive sidebar with hamburger menu for mobile - Improve touch-friendly UI with proper tap target sizes - Complete i18n translations for all components ## Backend Reliability (P2) - Configure database connection pool (size=10, overflow=20) - Add Redis fallback mechanism with message queue - Add blocker check before task deletion ## API Enhancements (P3) - Add standardized response wrapper utility - Add /health/ready and /health/live endpoints - Implement project templates with status/field copying ## Tests Added - test_input_validation.py - Schema and path traversal tests - test_concurrency_reliability.py - Optimistic locking and retry tests - test_backend_reliability.py - Connection pool and Redis tests - test_api_enhancements.py - Health check and template tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ scheduled triggers based on their cron schedule, including deadline reminders.
|
||||
|
||||
import uuid
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional, List, Dict, Any, Tuple, Set
|
||||
|
||||
@@ -22,6 +23,10 @@ logger = logging.getLogger(__name__)
|
||||
# Key prefix for tracking deadline reminders already sent
|
||||
DEADLINE_REMINDER_LOG_TYPE = "deadline_reminder"
|
||||
|
||||
# Retry configuration
|
||||
MAX_RETRIES = 3
|
||||
BASE_DELAY_SECONDS = 1 # 1s, 2s, 4s exponential backoff
|
||||
|
||||
|
||||
class TriggerSchedulerService:
|
||||
"""Service for scheduling and executing cron-based triggers."""
|
||||
@@ -220,50 +225,170 @@ class TriggerSchedulerService:
|
||||
@staticmethod
|
||||
def _execute_trigger(db: Session, trigger: Trigger) -> TriggerLog:
|
||||
"""
|
||||
Execute a scheduled trigger's actions.
|
||||
Execute a scheduled trigger's actions with retry mechanism.
|
||||
|
||||
Implements exponential backoff retry (1s, 2s, 4s) for transient failures.
|
||||
After max retries are exhausted, marks as permanently failed and sends alert.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
trigger: The trigger to execute
|
||||
|
||||
Returns:
|
||||
TriggerLog entry for this execution
|
||||
"""
|
||||
return TriggerSchedulerService._execute_trigger_with_retry(
|
||||
db=db,
|
||||
trigger=trigger,
|
||||
task_id=None,
|
||||
log_type="schedule",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _execute_trigger_with_retry(
|
||||
db: Session,
|
||||
trigger: Trigger,
|
||||
task_id: Optional[str] = None,
|
||||
log_type: str = "schedule",
|
||||
) -> TriggerLog:
|
||||
"""
|
||||
Execute trigger actions with exponential backoff retry.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
trigger: The trigger to execute
|
||||
task_id: Optional task ID for context (deadline reminders)
|
||||
log_type: Type of trigger execution for logging
|
||||
|
||||
Returns:
|
||||
TriggerLog entry for this execution
|
||||
"""
|
||||
actions = trigger.actions if isinstance(trigger.actions, list) else [trigger.actions]
|
||||
executed_actions = []
|
||||
error_message = None
|
||||
last_error = None
|
||||
attempt = 0
|
||||
|
||||
try:
|
||||
for action in actions:
|
||||
action_type = action.get("type")
|
||||
while attempt < MAX_RETRIES:
|
||||
attempt += 1
|
||||
executed_actions = []
|
||||
last_error = None
|
||||
|
||||
if action_type == "notify":
|
||||
TriggerSchedulerService._execute_notify_action(db, action, trigger)
|
||||
executed_actions.append({"type": action_type, "status": "success"})
|
||||
try:
|
||||
logger.info(
|
||||
f"Executing trigger {trigger.id} (attempt {attempt}/{MAX_RETRIES})"
|
||||
)
|
||||
|
||||
# Add more action types here as needed
|
||||
for action in actions:
|
||||
action_type = action.get("type")
|
||||
|
||||
status = "success"
|
||||
if action_type == "notify":
|
||||
TriggerSchedulerService._execute_notify_action(db, action, trigger)
|
||||
executed_actions.append({"type": action_type, "status": "success"})
|
||||
|
||||
except Exception as e:
|
||||
status = "failed"
|
||||
error_message = str(e)
|
||||
executed_actions.append({"type": "error", "message": str(e)})
|
||||
logger.error(f"Error executing trigger {trigger.id} actions: {e}")
|
||||
# Add more action types here as needed
|
||||
|
||||
# Success - return log
|
||||
logger.info(f"Trigger {trigger.id} executed successfully on attempt {attempt}")
|
||||
return TriggerSchedulerService._log_execution(
|
||||
db=db,
|
||||
trigger=trigger,
|
||||
status="success",
|
||||
details={
|
||||
"trigger_name": trigger.name,
|
||||
"trigger_type": log_type,
|
||||
"cron_expression": trigger.conditions.get("cron_expression") if trigger.conditions else None,
|
||||
"actions_executed": executed_actions,
|
||||
"attempts": attempt,
|
||||
},
|
||||
error_message=None,
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
executed_actions.append({"type": "error", "message": str(e)})
|
||||
logger.warning(
|
||||
f"Trigger {trigger.id} failed on attempt {attempt}/{MAX_RETRIES}: {e}"
|
||||
)
|
||||
|
||||
# Calculate exponential backoff delay
|
||||
if attempt < MAX_RETRIES:
|
||||
delay = BASE_DELAY_SECONDS * (2 ** (attempt - 1))
|
||||
logger.info(f"Retrying trigger {trigger.id} in {delay}s...")
|
||||
time.sleep(delay)
|
||||
|
||||
# All retries exhausted - permanent failure
|
||||
logger.error(
|
||||
f"Trigger {trigger.id} permanently failed after {MAX_RETRIES} attempts: {last_error}"
|
||||
)
|
||||
|
||||
# Send alert notification for permanent failure
|
||||
TriggerSchedulerService._send_failure_alert(db, trigger, str(last_error), MAX_RETRIES)
|
||||
|
||||
return TriggerSchedulerService._log_execution(
|
||||
db=db,
|
||||
trigger=trigger,
|
||||
status=status,
|
||||
status="permanently_failed",
|
||||
details={
|
||||
"trigger_name": trigger.name,
|
||||
"trigger_type": "schedule",
|
||||
"cron_expression": trigger.conditions.get("cron_expression"),
|
||||
"trigger_type": log_type,
|
||||
"cron_expression": trigger.conditions.get("cron_expression") if trigger.conditions else None,
|
||||
"actions_executed": executed_actions,
|
||||
"attempts": MAX_RETRIES,
|
||||
"permanent_failure": True,
|
||||
},
|
||||
error_message=error_message,
|
||||
error_message=f"Failed after {MAX_RETRIES} retries: {last_error}",
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _send_failure_alert(
|
||||
db: Session,
|
||||
trigger: Trigger,
|
||||
error_message: str,
|
||||
attempts: int,
|
||||
) -> None:
|
||||
"""
|
||||
Send alert notification when trigger exhausts all retries.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
trigger: The failed trigger
|
||||
error_message: The last error message
|
||||
attempts: Number of attempts made
|
||||
"""
|
||||
try:
|
||||
# Notify the project owner about the failure
|
||||
project = trigger.project
|
||||
if not project:
|
||||
logger.warning(f"Cannot send failure alert: trigger {trigger.id} has no project")
|
||||
return
|
||||
|
||||
target_user_id = project.owner_id
|
||||
if not target_user_id:
|
||||
logger.warning(f"Cannot send failure alert: project {project.id} has no owner")
|
||||
return
|
||||
|
||||
message = (
|
||||
f"Trigger '{trigger.name}' has permanently failed after {attempts} attempts. "
|
||||
f"Last error: {error_message}"
|
||||
)
|
||||
|
||||
NotificationService.create_notification(
|
||||
db=db,
|
||||
user_id=target_user_id,
|
||||
notification_type="trigger_failure",
|
||||
reference_type="trigger",
|
||||
reference_id=trigger.id,
|
||||
title=f"Trigger Failed: {trigger.name}",
|
||||
message=message,
|
||||
)
|
||||
|
||||
logger.info(f"Sent failure alert for trigger {trigger.id} to user {target_user_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send failure alert for trigger {trigger.id}: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _execute_notify_action(db: Session, action: Dict[str, Any], trigger: Trigger) -> None:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user