Files
PROJECT-CONTORL/backend/app/services/trigger_scheduler.py
2026-01-11 08:37:21 +08:00

902 lines
32 KiB
Python

"""
Scheduled Trigger Execution Service
This module provides functionality for parsing cron expressions and executing
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
from croniter import croniter
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.models import Trigger, TriggerLog, Task, Project, ProjectMember, User, Role
from app.services.notification_service import NotificationService
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."""
@staticmethod
def parse_cron_expression(expression: str) -> Tuple[bool, Optional[str]]:
"""
Validate a cron expression.
Args:
expression: A cron expression string (e.g., "0 9 * * 1-5" for weekdays at 9am)
Returns:
Tuple of (is_valid, error_message)
- is_valid: True if the expression is valid
- error_message: None if valid, otherwise an error description
"""
try:
# croniter requires a base time for initialization
base_time = datetime.now(timezone.utc)
croniter(expression, base_time)
return True, None
except (ValueError, KeyError) as e:
return False, f"Invalid cron expression: {str(e)}"
@staticmethod
def get_next_run_time(expression: str, base_time: Optional[datetime] = None) -> Optional[datetime]:
"""
Get the next scheduled run time for a cron expression.
Args:
expression: A cron expression string
base_time: The base time to calculate from (defaults to now)
Returns:
The next datetime when the schedule matches, or None if invalid
"""
try:
if base_time is None:
base_time = datetime.now(timezone.utc)
cron = croniter(expression, base_time)
return cron.get_next(datetime)
except (ValueError, KeyError):
return None
@staticmethod
def get_previous_run_time(expression: str, base_time: Optional[datetime] = None) -> Optional[datetime]:
"""
Get the previous scheduled run time for a cron expression.
Args:
expression: A cron expression string
base_time: The base time to calculate from (defaults to now)
Returns:
The previous datetime when the schedule matched, or None if invalid
"""
try:
if base_time is None:
base_time = datetime.now(timezone.utc)
cron = croniter(expression, base_time)
return cron.get_prev(datetime)
except (ValueError, KeyError):
return None
@staticmethod
def should_trigger(
trigger: Trigger,
current_time: datetime,
last_execution_time: Optional[datetime] = None,
) -> bool:
"""
Check if a schedule trigger should fire based on its cron expression.
A trigger should fire if:
1. It's a schedule-type trigger and is active
2. Its conditions contain a valid cron expression
3. The cron schedule has matched since the last execution
Args:
trigger: The trigger to evaluate
current_time: The current time to check against
last_execution_time: The time of the last successful execution
Returns:
True if the trigger should fire, False otherwise
"""
# Only process schedule triggers
if trigger.trigger_type != "schedule":
return False
if not trigger.is_active:
return False
# Get cron expression from conditions
conditions = trigger.conditions or {}
cron_expression = conditions.get("cron_expression")
if not cron_expression:
logger.warning(f"Trigger {trigger.id} has no cron_expression in conditions")
return False
# Validate cron expression
is_valid, error = TriggerSchedulerService.parse_cron_expression(cron_expression)
if not is_valid:
logger.warning(f"Trigger {trigger.id} has invalid cron: {error}")
return False
# Get the previous scheduled time before current_time
prev_scheduled = TriggerSchedulerService.get_previous_run_time(cron_expression, current_time)
if prev_scheduled is None:
return False
# If no last execution, check if we're within the execution window (5 minutes)
if last_execution_time is None:
# Only trigger if the scheduled time was within the last 5 minutes
window_seconds = 300 # 5 minutes
time_since_scheduled = (current_time - prev_scheduled).total_seconds()
return 0 <= time_since_scheduled < window_seconds
# Trigger if the previous scheduled time is after the last execution
return prev_scheduled > last_execution_time
@staticmethod
def get_last_execution_time(db: Session, trigger_id: str) -> Optional[datetime]:
"""
Get the last successful execution time for a trigger.
Args:
db: Database session
trigger_id: The trigger ID
Returns:
The datetime of the last successful execution, or None
"""
last_log = db.query(TriggerLog).filter(
TriggerLog.trigger_id == trigger_id,
TriggerLog.status == "success",
).order_by(TriggerLog.executed_at.desc()).first()
return last_log.executed_at if last_log else None
@staticmethod
def execute_scheduled_triggers(db: Session) -> List[TriggerLog]:
"""
Main execution function that evaluates and executes all scheduled triggers.
This function should be called periodically (e.g., every minute) by a scheduler.
Args:
db: Database session
Returns:
List of TriggerLog entries for executed triggers
"""
logs: List[TriggerLog] = []
current_time = datetime.now(timezone.utc)
# Get all active schedule-type triggers
triggers = db.query(Trigger).filter(
Trigger.trigger_type == "schedule",
Trigger.is_active == True,
).all()
logger.info(f"Evaluating {len(triggers)} scheduled triggers at {current_time}")
for trigger in triggers:
try:
# Get last execution time
last_execution = TriggerSchedulerService.get_last_execution_time(db, trigger.id)
# Check if trigger should fire
if TriggerSchedulerService.should_trigger(trigger, current_time, last_execution):
logger.info(f"Executing scheduled trigger: {trigger.name} (ID: {trigger.id})")
log = TriggerSchedulerService._execute_trigger(db, trigger)
logs.append(log)
except Exception as e:
logger.error(f"Error evaluating trigger {trigger.id}: {e}")
# Log the error
error_log = TriggerSchedulerService._log_execution(
db=db,
trigger=trigger,
status="failed",
details={"error_type": type(e).__name__},
error_message=str(e),
)
logs.append(error_log)
if logs:
db.commit()
logger.info(f"Executed {len(logs)} scheduled triggers")
return logs
@staticmethod
def _execute_trigger(db: Session, trigger: Trigger) -> TriggerLog:
"""
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 = []
last_error = None
attempt = 0
while attempt < MAX_RETRIES:
attempt += 1
executed_actions = []
last_error = None
try:
logger.info(
f"Executing trigger {trigger.id} (attempt {attempt}/{MAX_RETRIES})"
)
for action in actions:
action_type = action.get("type")
if action_type == "notify":
TriggerSchedulerService._execute_notify_action(db, action, trigger)
executed_actions.append({"type": action_type, "status": "success"})
# 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="permanently_failed",
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": MAX_RETRIES,
"permanent_failure": True,
},
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:
"""
Execute a notify action for a scheduled trigger.
Args:
db: Database session
action: The action configuration
trigger: The parent trigger
"""
target = action.get("target", "project_owner")
template = action.get("template", "Scheduled trigger '{trigger_name}' has fired")
# For scheduled triggers, we typically notify project-level users
project = trigger.project
if not project:
logger.warning(f"Trigger {trigger.id} has no associated project")
return
recipient_ids = TriggerSchedulerService._resolve_target(db, project, target)
if not recipient_ids:
logger.debug(f"No target user resolved for trigger {trigger.id} with target '{target}'")
return
# Format message with variables
message = TriggerSchedulerService._format_template(template, trigger, project)
for user_id in recipient_ids:
NotificationService.create_notification(
db=db,
user_id=user_id,
notification_type="scheduled_trigger",
reference_type="trigger",
reference_id=trigger.id,
title=f"Scheduled: {trigger.name}",
message=message,
)
@staticmethod
def _resolve_target(db: Session, project: Project, target: str) -> List[str]:
"""
Resolve notification target to user IDs.
Args:
project: The project context
target: Target specification (e.g., "project_owner", "user:<id>")
Returns:
List of user IDs
"""
recipients: Set[str] = set()
if target == "project_owner":
if project.owner_id:
recipients.add(project.owner_id)
elif target == "project_members":
if project.owner_id:
recipients.add(project.owner_id)
member_rows = db.query(ProjectMember.user_id).join(
User,
User.id == ProjectMember.user_id,
).filter(
ProjectMember.project_id == project.id,
User.is_active == True,
).all()
recipients.update(row[0] for row in member_rows if row and row[0])
elif target.startswith("department:"):
department_id = target.split(":", 1)[1]
if department_id:
user_rows = db.query(User.id).filter(
User.department_id == department_id,
User.is_active == True,
).all()
recipients.update(row[0] for row in user_rows if row and row[0])
elif target.startswith("role:"):
role_name = target.split(":", 1)[1].strip()
if role_name:
role = db.query(Role).filter(func.lower(Role.name) == role_name.lower()).first()
if role:
user_rows = db.query(User.id).filter(
User.role_id == role.id,
User.is_active == True,
).all()
recipients.update(row[0] for row in user_rows if row and row[0])
elif target.startswith("user:"):
user_id = target.split(":", 1)[1]
if user_id:
recipients.add(user_id)
return list(recipients)
@staticmethod
def _format_template(template: str, trigger: Trigger, project: Project) -> str:
"""
Format message template with trigger/project variables.
Args:
template: Template string with {variable} placeholders
trigger: The trigger context
project: The project context
Returns:
Formatted message string
"""
replacements = {
"{trigger_name}": trigger.name,
"{trigger_id}": trigger.id,
"{project_name}": project.title if project else "Unknown",
"{project_id}": project.id if project else "Unknown",
}
result = template
for key, value in replacements.items():
result = result.replace(key, str(value))
return result
@staticmethod
def _log_execution(
db: Session,
trigger: Trigger,
status: str,
details: Optional[Dict[str, Any]] = None,
error_message: Optional[str] = None,
task_id: Optional[str] = None,
) -> TriggerLog:
"""
Create a trigger execution log entry.
Args:
db: Database session
trigger: The trigger that was executed
status: Execution status ("success" or "failed")
details: Optional execution details
error_message: Optional error message if failed
task_id: Optional task ID for deadline reminders
Returns:
The created TriggerLog entry
"""
log = TriggerLog(
id=str(uuid.uuid4()),
trigger_id=trigger.id,
task_id=task_id,
status=status,
details=details,
error_message=error_message,
)
db.add(log)
return log
# =========================================================================
# Deadline Reminder Methods
# =========================================================================
@staticmethod
def execute_deadline_reminders(db: Session) -> List[TriggerLog]:
"""
Check all deadline reminder triggers and send notifications for tasks
that are within N days of their due date.
Each task only receives one reminder per trigger configuration.
Args:
db: Database session
Returns:
List of TriggerLog entries for sent reminders
"""
logs: List[TriggerLog] = []
current_time = datetime.now(timezone.utc)
today = current_time.date()
# Get all active schedule triggers with deadline_reminder_days
triggers = db.query(Trigger).filter(
Trigger.trigger_type == "schedule",
Trigger.is_active == True,
).all()
# Filter triggers that have deadline_reminder_days configured
deadline_triggers = [
t for t in triggers
if t.conditions and t.conditions.get("deadline_reminder_days") is not None
]
if not deadline_triggers:
return logs
logger.info(f"Evaluating {len(deadline_triggers)} deadline reminder triggers")
for trigger in deadline_triggers:
try:
reminder_days = trigger.conditions.get("deadline_reminder_days")
if not isinstance(reminder_days, int) or reminder_days < 1:
continue
# Calculate the target date range
# We want to find tasks whose due_date is exactly N days from today
target_date = today + timedelta(days=reminder_days)
# Get tasks in this project that:
# 1. Have a due_date matching the target date
# 2. Are not deleted
# 3. Have not already received a reminder for this trigger
tasks = TriggerSchedulerService._get_tasks_for_deadline_reminder(
db, trigger, target_date
)
for task in tasks:
try:
log = TriggerSchedulerService._send_deadline_reminder(
db, trigger, task, reminder_days
)
logs.append(log)
except Exception as e:
logger.error(
f"Error sending deadline reminder for task {task.id}: {e}"
)
error_log = TriggerSchedulerService._log_execution(
db=db,
trigger=trigger,
status="failed",
details={
"trigger_type": DEADLINE_REMINDER_LOG_TYPE,
"task_id": task.id,
"reminder_days": reminder_days,
},
error_message=str(e),
task_id=task.id,
)
logs.append(error_log)
except Exception as e:
logger.error(f"Error processing deadline trigger {trigger.id}: {e}")
if logs:
db.commit()
logger.info(f"Processed {len(logs)} deadline reminders")
return logs
@staticmethod
def _get_tasks_for_deadline_reminder(
db: Session,
trigger: Trigger,
target_date,
) -> List[Task]:
"""
Get tasks that need deadline reminders for a specific trigger.
Args:
db: Database session
trigger: The deadline reminder trigger
target_date: The date that matches (today + N days)
Returns:
List of tasks that need reminders
"""
# Get IDs of tasks that already received reminders for this trigger
already_notified = db.query(TriggerLog.task_id).filter(
TriggerLog.trigger_id == trigger.id,
TriggerLog.status == "success",
TriggerLog.task_id.isnot(None),
).all()
notified_task_ids: Set[str] = {t[0] for t in already_notified if t[0]}
# Use date range comparison for cross-database compatibility
# target_date is a date object, we need to find tasks due on that date
target_start = datetime.combine(target_date, datetime.min.time()).replace(tzinfo=timezone.utc)
target_end = datetime.combine(target_date, datetime.max.time()).replace(tzinfo=timezone.utc)
# Query tasks matching criteria
tasks = db.query(Task).filter(
Task.project_id == trigger.project_id,
Task.is_deleted == False,
Task.due_date.isnot(None),
Task.due_date >= target_start,
Task.due_date <= target_end,
).all()
# Filter out tasks that already received reminders
return [t for t in tasks if t.id not in notified_task_ids]
@staticmethod
def _send_deadline_reminder(
db: Session,
trigger: Trigger,
task: Task,
reminder_days: int,
) -> TriggerLog:
"""
Send a deadline reminder notification for a task.
Args:
db: Database session
trigger: The trigger configuration
task: The task approaching its deadline
reminder_days: Number of days before deadline
Returns:
TriggerLog entry for this reminder
"""
actions = trigger.actions if isinstance(trigger.actions, list) else [trigger.actions]
executed_actions = []
error_message = None
try:
for action in actions:
action_type = action.get("type")
if action_type == "notify":
TriggerSchedulerService._execute_deadline_notify_action(
db, action, trigger, task, reminder_days
)
executed_actions.append({"type": action_type, "status": "success"})
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 deadline reminder for task {task.id}: {e}")
return TriggerSchedulerService._log_execution(
db=db,
trigger=trigger,
status=status,
details={
"trigger_name": trigger.name,
"trigger_type": DEADLINE_REMINDER_LOG_TYPE,
"reminder_days": reminder_days,
"task_title": task.title,
"due_date": str(task.due_date),
"actions_executed": executed_actions,
},
error_message=error_message,
task_id=task.id,
)
@staticmethod
def _execute_deadline_notify_action(
db: Session,
action: Dict[str, Any],
trigger: Trigger,
task: Task,
reminder_days: int,
) -> None:
"""
Execute a notify action for a deadline reminder.
Args:
db: Database session
action: The action configuration
trigger: The parent trigger
task: The task with approaching deadline
reminder_days: Days until deadline
"""
target = action.get("target", "assignee")
template = action.get(
"template",
"Task '{task_title}' is due in {reminder_days} days"
)
# Resolve target user
recipient_ids = TriggerSchedulerService._resolve_deadline_target(db, task, target)
if not recipient_ids:
logger.debug(
f"No target user resolved for deadline reminder, task {task.id}, target '{target}'"
)
return
# Format message with variables
message = TriggerSchedulerService._format_deadline_template(
template, trigger, task, reminder_days
)
for user_id in recipient_ids:
NotificationService.create_notification(
db=db,
user_id=user_id,
notification_type="deadline_reminder",
reference_type="task",
reference_id=task.id,
title=f"Deadline Reminder: {task.title}",
message=message,
)
@staticmethod
def _resolve_deadline_target(db: Session, task: Task, target: str) -> List[str]:
"""
Resolve notification target for deadline reminders.
Args:
task: The task context
target: Target specification
Returns:
List of user IDs
"""
recipients: Set[str] = set()
if target == "assignee":
if task.assignee_id:
recipients.add(task.assignee_id)
elif target == "creator":
if task.created_by:
recipients.add(task.created_by)
elif target == "project_owner":
if task.project and task.project.owner_id:
recipients.add(task.project.owner_id)
elif target == "project_members":
if task.project:
if task.project.owner_id:
recipients.add(task.project.owner_id)
member_rows = db.query(ProjectMember.user_id).join(
User,
User.id == ProjectMember.user_id,
).filter(
ProjectMember.project_id == task.project_id,
User.is_active == True,
).all()
recipients.update(row[0] for row in member_rows if row and row[0])
elif target.startswith("department:"):
department_id = target.split(":", 1)[1]
if department_id:
user_rows = db.query(User.id).filter(
User.department_id == department_id,
User.is_active == True,
).all()
recipients.update(row[0] for row in user_rows if row and row[0])
elif target.startswith("role:"):
role_name = target.split(":", 1)[1].strip()
if role_name:
role = db.query(Role).filter(func.lower(Role.name) == role_name.lower()).first()
if role:
user_rows = db.query(User.id).filter(
User.role_id == role.id,
User.is_active == True,
).all()
recipients.update(row[0] for row in user_rows if row and row[0])
elif target.startswith("user:"):
user_id = target.split(":", 1)[1]
if user_id:
recipients.add(user_id)
return list(recipients)
@staticmethod
def _format_deadline_template(
template: str,
trigger: Trigger,
task: Task,
reminder_days: int,
) -> str:
"""
Format message template for deadline reminders.
Args:
template: Template string with {variable} placeholders
trigger: The trigger context
task: The task context
reminder_days: Days until deadline
Returns:
Formatted message string
"""
project = trigger.project
replacements = {
"{trigger_name}": trigger.name,
"{trigger_id}": trigger.id,
"{task_title}": task.title,
"{task_id}": task.id,
"{due_date}": str(task.due_date.date()) if task.due_date else "N/A",
"{reminder_days}": str(reminder_days),
"{project_name}": project.title if project else "Unknown",
"{project_id}": project.id if project else "Unknown",
}
result = template
for key, value in replacements.items():
result = result.replace(key, str(value))
return result
@staticmethod
def evaluate_schedule_triggers(db: Session) -> List[TriggerLog]:
"""
Main entry point for evaluating all schedule triggers.
This method runs both cron-based triggers and deadline reminders.
Should be called every minute by the scheduler.
Args:
db: Database session
Returns:
Combined list of TriggerLog entries from all evaluations
"""
all_logs: List[TriggerLog] = []
# Execute cron-based schedule triggers
cron_logs = TriggerSchedulerService.execute_scheduled_triggers(db)
all_logs.extend(cron_logs)
# Execute deadline reminder triggers
deadline_logs = TriggerSchedulerService.execute_deadline_reminders(db)
all_logs.extend(deadline_logs)
return all_logs