Initial commit: scheduler service extraction from portainer-core
Build and Push / build (release) Failing after 17s

Extracted standalone scheduler service with:
- FastAPI REST API for task management
- APScheduler-based task execution
- PostgreSQL persistence
- Docker container support
- Gitea Actions CI/CD workflow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-11 11:59:32 +01:00
co-authored by Claude Opus 4.5
commit 64574bcc39
31 changed files with 6284 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
"""
Configuration management for The Scheduler.
Uses Pydantic BaseSettings for type-safe environment variable loading.
"""
from functools import lru_cache
from pydantic_settings import BaseSettings
from pydantic import Field
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Application
app_name: str = Field(default="The Scheduler", alias="APP_NAME")
app_version: str = Field(default="1.0.0", alias="APP_VERSION")
debug: bool = Field(default=False, alias="DEBUG")
host: str = Field(default="0.0.0.0", alias="HOST")
port: int = Field(default=8090, alias="PORT")
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
# Security
scheduler_api_key: str = Field(default="dev-key-change-me", alias="SCHEDULER_API_KEY")
# PostgreSQL
postgres_host: str = Field(default="postgres-shared", alias="POSTGRES_HOST")
postgres_port: int = Field(default=5432, alias="POSTGRES_PORT")
postgres_db: str = Field(default="library_scheduler", alias="POSTGRES_DB")
postgres_user: str = Field(default="library_scheduler_user", alias="POSTGRES_USER")
postgres_password: str = Field(default="", alias="POSTGRES_PASSWORD")
# Redis
redis_host: str = Field(default="redis-shared", alias="REDIS_HOST")
redis_port: int = Field(default=6379, alias="REDIS_PORT")
redis_db: int = Field(default=3, alias="REDIS_DB")
# Gitea
gitea_url: str = Field(default="http://gitea:3000", alias="GITEA_URL")
gitea_user: str = Field(default="library", alias="GITEA_USER")
gitea_password: str = Field(default="", alias="GITEA_PASSWORD")
gitea_ssh_host: str = Field(default="gitea", alias="GITEA_SSH_HOST")
gitea_ssh_port: int = Field(default=22, alias="GITEA_SSH_PORT")
# Backup Configuration
backup_retention_daily: int = Field(default=7, alias="BACKUP_RETENTION_DAILY")
backup_retention_weekly: int = Field(default=4, alias="BACKUP_RETENTION_WEEKLY")
backup_retention_monthly: int = Field(default=12, alias="BACKUP_RETENTION_MONTHLY")
# Documentation Mirroring
docs_mirror_path: str = Field(default="/docs-mirror", alias="DOCS_MIRROR_PATH")
docs_check_interval: int = Field(default=21600, alias="DOCS_CHECK_INTERVAL") # 6 hours
@property
def database_url(self) -> str:
"""PostgreSQL connection URL for APScheduler."""
return f"postgresql://{self.postgres_user}:{self.postgres_password}@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
@property
def redis_url(self) -> str:
"""Redis connection URL."""
return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}"
class Config:
env_file = ".env"
case_sensitive = False
@lru_cache
def get_settings() -> Settings:
"""
Get cached settings instance.
Using lru_cache ensures we only create one Settings instance.
"""
return Settings()
+23
View File
@@ -0,0 +1,23 @@
"""
Executor modules for The Scheduler.
Each executor must implement an async execute(config, settings) function.
Example:
async def execute(config: dict, settings: Settings) -> str:
'''
Perform the task.
Args:
config: Task-specific configuration from scheduled_tasks.config
settings: Global scheduler settings
Returns:
Output message (success)
Raises:
Exception: On failure (will be logged as error)
'''
# Your task logic here
return "Task completed successfully"
"""
+159
View File
@@ -0,0 +1,159 @@
"""
Config Backup Executor
Backs up Docker container configs and host-based service configs.
Replicates functionality of maintenance container's backup-configs.sh
"""
import asyncio
import logging
import tarfile
import tempfile
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Dict, Any
from src.config import Settings
logger = logging.getLogger(__name__)
async def execute(config: dict, settings: Settings) -> str:
"""
Execute config backup task.
Config schema:
{
"sources": [
{
"path": "/data/docker-data",
"name": "docker-data",
"excludes": ["*/cache/*", "*/temp/*", "*.log"]
}
],
"backup_dir": "/backups/docker-configs",
"retention_days": 30,
"compress": true
}
Args:
config: Backup configuration
settings: Global scheduler settings
Returns:
Summary of backup operation
Raises:
Exception: On backup failure
"""
sources = config.get('sources', [])
backup_dir = Path(config.get('backup_dir', '/backups/docker-configs'))
retention_days = config.get('retention_days', 30)
compress = config.get('compress', True)
if not sources:
raise ValueError("No backup sources configured")
timestamp = datetime.now().strftime('%Y%m%d-%H%M%S')
backup_filename = f"docker-configs-{timestamp}.tar.gz"
backup_file = backup_dir / backup_filename
logger.info(f"Starting Docker configs backup: {backup_filename}")
# Create backup directory
backup_dir.mkdir(parents=True, exist_ok=True)
# Create temporary directory for staging
with tempfile.TemporaryDirectory(prefix='backup-') as temp_dir:
temp_path = Path(temp_dir)
results = []
# Backup each source
for source in sources:
source_path = Path(source['path'])
source_name = source['name']
excludes = source.get('excludes', [])
if not source_path.exists():
logger.warning(f"Source path does not exist: {source_path}")
continue
logger.info(f"Backing up {source_name} from {source_path}")
# Create tar for this source
source_tar = temp_path / f"{source_name}.tar.gz"
def tar_filter(tarinfo):
"""Filter function to exclude patterns."""
for pattern in excludes:
# Simple pattern matching (could be enhanced with fnmatch)
if pattern.replace('*/', '').replace('/*', '') in tarinfo.name:
logger.debug(f"Excluding: {tarinfo.name}")
return None
return tarinfo
with tarfile.open(source_tar, 'w:gz') as tar:
tar.add(
source_path,
arcname=source_name,
filter=tar_filter,
recursive=True
)
source_size = source_tar.stat().st_size / (1024 * 1024) # MB
results.append(f"{source_name}: {source_size:.2f}MB")
logger.info(f"Backed up {source_name}: {source_size:.2f}MB")
# Combine all source backups into final archive
logger.info("Creating combined backup archive...")
with tarfile.open(backup_file, 'w:gz') as final_tar:
for item in temp_path.glob('*.tar.gz'):
final_tar.add(item, arcname=item.name)
# Verify backup created
if not backup_file.exists():
raise Exception("Backup file was not created")
backup_size = backup_file.stat().st_size / (1024 * 1024) # MB
logger.info(f"Backup created successfully: {backup_size:.2f}MB")
# Clean up old backups
await cleanup_old_backups(backup_dir, retention_days)
# Count remaining backups
backup_count = len(list(backup_dir.glob('docker-configs-*.tar.gz')))
total_size = sum(f.stat().st_size for f in backup_dir.glob('docker-configs-*.tar.gz'))
total_size_mb = total_size / (1024 * 1024)
output = (
f"Backup completed: {backup_filename} ({backup_size:.2f}MB). "
f"Sources: {', '.join(results)}. "
f"Retention: {backup_count} backups, {total_size_mb:.2f}MB total."
)
logger.info(output)
return output
async def cleanup_old_backups(backup_dir: Path, retention_days: int):
"""Remove backups older than retention period."""
cutoff_date = datetime.now() - timedelta(days=retention_days)
removed_count = 0
removed_size = 0
logger.info(f"Cleaning up backups older than {retention_days} days...")
for backup_file in backup_dir.glob('docker-configs-*.tar.gz'):
# Get file modification time
file_mtime = datetime.fromtimestamp(backup_file.stat().st_mtime)
if file_mtime < cutoff_date:
file_size = backup_file.stat().st_size
logger.info(f"Removing old backup: {backup_file.name} (from {file_mtime:%Y-%m-%d})")
backup_file.unlink()
removed_count += 1
removed_size += file_size
if removed_count > 0:
removed_size_mb = removed_size / (1024 * 1024)
logger.info(f"Removed {removed_count} old backups, freed {removed_size_mb:.2f}MB")
else:
logger.info("No old backups to remove")
+259
View File
@@ -0,0 +1,259 @@
"""
Documentation Sync Executor
Syncs documentation from upstream Git repositories to Gitea.
Clones source repos, extracts docs directories, pushes to Gitea.
"""
import asyncio
import logging
import shutil
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from src.config import Settings
logger = logging.getLogger(__name__)
async def execute(config: dict, settings: Settings) -> str:
"""
Sync documentation from upstream Git repo to Gitea.
Config schema:
{
"project": "fastapi",
"upstream_repo": "https://github.com/tiangolo/fastapi.git",
"docs_paths": ["/docs", "/docs_src"],
"gitea_repo": "library/docs-fastapi",
"gitea_url": "http://gitea:3000",
"branch": "main"
}
Args:
config: Documentation sync configuration
settings: Global scheduler settings
Returns:
Summary of sync operation
Raises:
Exception: On sync failure
"""
project = config.get('project')
upstream_repo = config.get('upstream_repo')
docs_paths = config.get('docs_paths', ['/docs'])
gitea_repo = config.get('gitea_repo')
gitea_url = config.get('gitea_url', settings.gitea_url)
branch = config.get('branch', 'main')
if not all([project, upstream_repo, gitea_repo]):
raise ValueError("Missing required config: project, upstream_repo, or gitea_repo")
logger.info(f"Starting doc sync for {project}")
logger.info(f"Upstream: {upstream_repo}")
logger.info(f"Gitea: {gitea_repo}")
# Work directory
work_dir = Path(f"/app/task-data/doc-sync/{project}")
upstream_dir = work_dir / "upstream"
gitea_dir = work_dir / "gitea"
try:
# Clean work directory
if work_dir.exists():
logger.info(f"Cleaning work directory: {work_dir}")
shutil.rmtree(work_dir)
work_dir.mkdir(parents=True)
# Clone upstream repo (shallow clone for speed)
logger.info(f"Cloning upstream repo...")
await _run_command([
'git', 'clone',
'--depth', '1',
'--branch', branch,
upstream_repo,
str(upstream_dir)
])
# Get upstream version/commit
upstream_commit = await _get_git_commit(upstream_dir)
upstream_date = datetime.now().strftime('%Y-%m-%d')
logger.info(f"Upstream commit: {upstream_commit[:8]}")
# Clone Gitea repo (or create if doesn't exist)
# Build authenticated URL for Gitea
gitea_url_clean = gitea_url.replace('http://', '').replace('https://', '')
gitea_clone_url = f"http://{settings.gitea_user}:{settings.gitea_password}@{gitea_url_clean}/{gitea_repo}.git"
# Log without credentials
logger.info(f"Cloning Gitea repo: {gitea_url}/{gitea_repo}.git")
# Try to clone, if fails create new repo
try:
await _run_command([
'git', 'clone',
gitea_clone_url,
str(gitea_dir)
])
except Exception as e:
logger.warning(f"Gitea repo doesn't exist, will create: {e}")
gitea_dir.mkdir(parents=True)
await _run_command(['git', 'init'], cwd=gitea_dir)
await _run_command(['git', 'checkout', '-b', branch], cwd=gitea_dir)
# Set remote
await _run_command([
'git', 'remote', 'add', 'origin',
gitea_clone_url
], cwd=gitea_dir)
# Clear existing content in Gitea repo (except .git)
for item in gitea_dir.iterdir():
if item.name != '.git':
if item.is_dir():
shutil.rmtree(item)
else:
item.unlink()
# Determine what to copy based on docs_paths
copied_paths = []
# If docs_paths is empty or contains "." or "/", sync entire repo
if not docs_paths or any(p in [".", "/", ""] for p in docs_paths):
logger.info(f"Copying entire repository...")
for item in upstream_dir.iterdir():
if item.name != '.git':
dest = gitea_dir / item.name
if item.is_dir():
shutil.copytree(item, dest)
else:
shutil.copy2(item, dest)
copied_paths = ["entire repository"]
else:
# Copy only specified paths
logger.info(f"Copying specific paths: {docs_paths}")
for doc_path in docs_paths:
source = upstream_dir / doc_path.lstrip('/')
if source.exists():
dest = gitea_dir / source.name
logger.info(f"Copying {source.name}...")
if source.is_dir():
shutil.copytree(source, dest)
else:
shutil.copy2(source, dest)
copied_paths.append(source.name)
else:
logger.warning(f"Path not found in upstream: {doc_path}")
if not copied_paths:
raise Exception("No documentation paths were copied")
# Create .SYNC_INFO.md with metadata (don't overwrite README.md from upstream)
sync_info_path = gitea_dir / ".SYNC_INFO.md"
content_desc = "Complete repository mirror" if "entire repository" in copied_paths else f"Paths: {', '.join(copied_paths)}"
sync_info_content = f"""# Sync Information
This is a mirror of the {project.title()} repository.
**Synced from:** {upstream_repo}
**Branch:** {branch}
**Commit:** {upstream_commit}
**Sync Date:** {upstream_date}
**Content:** {content_desc}
---
This repository is automatically synced monthly by The Scheduler.
For the latest updates, visit the official repository.
"""
sync_info_path.write_text(sync_info_content)
# Git add, commit, push
await _run_command(['git', 'add', '.'], cwd=gitea_dir)
# Check if there are changes
status = await _run_command(
['git', 'status', '--porcelain'],
cwd=gitea_dir,
capture=True
)
if not status.strip():
logger.info("No changes detected, skipping commit")
return f"Documentation already up to date (commit: {upstream_commit[:8]})"
# Commit changes
commit_msg = f"Sync {project} docs from {upstream_commit[:8]} on {upstream_date}"
await _run_command([
'git', 'commit',
'-m', commit_msg
], cwd=gitea_dir)
# Tag with date
tag = f"sync-{upstream_date}"
await _run_command([
'git', 'tag', '-f', tag,
'-m', f"Documentation snapshot {upstream_date}"
], cwd=gitea_dir)
# Push to Gitea
logger.info("Pushing to Gitea...")
await _run_command([
'git', 'push', 'origin', branch, '--force'
], cwd=gitea_dir)
await _run_command([
'git', 'push', 'origin', tag, '--force'
], cwd=gitea_dir)
# Cleanup
logger.info("Cleaning up work directory...")
shutil.rmtree(work_dir)
result = (
f"Successfully synced {project} documentation. "
f"Copied: {', '.join(copied_paths)}. "
f"Upstream commit: {upstream_commit[:8]}. "
f"Tagged: {tag}"
)
logger.info(result)
return result
except Exception as e:
# Cleanup on error
if work_dir.exists():
shutil.rmtree(work_dir)
raise
async def _run_command(
cmd: List[str],
cwd: Optional[Path] = None,
capture: bool = False
) -> str:
"""Run shell command asynchronously."""
logger.debug(f"Running: {' '.join(cmd)} (cwd: {cwd})")
proc = await asyncio.create_subprocess_exec(
*cmd,
cwd=cwd,
stdout=asyncio.subprocess.PIPE if capture else asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
error_msg = stderr.decode() if stderr else "Unknown error"
raise Exception(f"Command failed: {' '.join(cmd)}\n{error_msg}")
return stdout.decode() if capture else ""
async def _get_git_commit(repo_dir: Path) -> str:
"""Get current git commit hash."""
output = await _run_command(
['git', 'rev-parse', 'HEAD'],
cwd=repo_dir,
capture=True
)
return output.strip()
+42
View File
@@ -0,0 +1,42 @@
"""
Example executor demonstrating the pattern.
Shows how to write task executors for The Scheduler.
"""
import asyncio
import logging
from src.config import Settings
logger = logging.getLogger(__name__)
async def execute(config: dict, settings: Settings) -> str:
"""
Example task executor.
Args:
config: Task configuration from scheduled_tasks.config JSONB field
Example: {"message": "Hello", "delay_seconds": 2}
settings: Global scheduler settings (database, API keys, etc.)
Returns:
Output message describing what was done
Raises:
Exception: On failure (will be caught and logged by executor framework)
"""
message = config.get('message', 'No message configured')
delay = config.get('delay_seconds', 1)
logger.info(f"Example executor starting: {message}")
# Simulate some work
await asyncio.sleep(delay)
# You can access settings
logger.info(f"Using database: {settings.postgres_db}")
# Return success message
output = f"Executed example task: {message} (took {delay}s)"
logger.info(output)
return output
+266
View File
@@ -0,0 +1,266 @@
"""
Generic REST API Executor
Universal executor for calling any REST API endpoint across the system.
Supports GET, POST, PUT, DELETE with configurable payloads, headers, and authentication.
This executor can be used to trigger any service endpoint:
- Library Desk knowledge consolidation
- Core API operations
- External webhooks
- Any HTTP-based task
Config schema:
{
"url": "http://service:port/endpoint",
"method": "POST", # GET, POST, PUT, DELETE, PATCH
"payload": {...}, # Request body (for POST/PUT/PATCH)
"headers": {...}, # Additional headers
"auth": {
"type": "bearer", # bearer, basic, api_key
"token": "${ENV_VAR}", # Use ${VAR} for env vars
"header": "Authorization" # Optional: header name for API key
},
"timeout": 300, # Timeout in seconds (default: 300)
"verify_ssl": true, # SSL verification (default: true)
"success_codes": [200, 201, 202], # Expected success codes
"response_path": "result.message" # JSONPath to extract from response
}
Example configs:
1. Library Desk Knowledge Consolidation:
{
"url": "http://library-desk:8089/consolidate/knowledge",
"method": "POST",
"payload": {"process_limit": 10, "lookback_days": 7, "dry_run": false},
"auth": {"type": "bearer", "token": "${LIBRARY_DESK_API_KEY}"}
}
2. Core API Container Restart:
{
"url": "http://core-api:8088/v1/infrastructure/containers/nginx/restart",
"method": "POST",
"auth": {"type": "bearer", "token": "${CORE_API_KEY}"}
}
3. External Webhook:
{
"url": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL",
"method": "POST",
"payload": {"text": "Scheduled task completed"},
"verify_ssl": true
}
"""
import logging
import os
import re
import httpx
from typing import Any, Dict, Optional
from src.config import Settings
logger = logging.getLogger(__name__)
async def execute(config: dict, settings: Settings) -> str:
"""
Execute REST API call with configured parameters.
Args:
config: REST API call configuration (see module docstring)
settings: Global scheduler settings
Returns:
Response summary or extracted result
Raises:
ValueError: On configuration error
Exception: On API call failure
"""
# Required configuration
url = config.get('url')
if not url:
raise ValueError("Missing required config: 'url'")
method = config.get('method', 'POST').upper()
if method not in ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']:
raise ValueError(f"Invalid HTTP method: {method}")
# Optional configuration
payload = config.get('payload', {})
headers = config.get('headers', {})
timeout = config.get('timeout', 300)
verify_ssl = config.get('verify_ssl', True)
success_codes = config.get('success_codes', [200, 201, 202, 204])
response_path = config.get('response_path')
# Handle authentication
auth_config = config.get('auth', {})
if auth_config:
auth_header = _build_auth_header(auth_config, settings)
if auth_header:
headers.update(auth_header)
# Substitute environment variables in URL and payload
url = _substitute_env_vars(url)
payload = _substitute_env_vars_recursive(payload)
logger.info(f"Executing REST API call: {method} {url}")
if payload:
logger.debug(f"Payload: {_redact_sensitive(payload)}")
# Make HTTP request
try:
async with httpx.AsyncClient(timeout=timeout, verify=verify_ssl) as client:
if method == 'GET':
response = await client.get(url, headers=headers)
elif method == 'POST':
response = await client.post(url, json=payload, headers=headers)
elif method == 'PUT':
response = await client.put(url, json=payload, headers=headers)
elif method == 'DELETE':
response = await client.delete(url, headers=headers)
elif method == 'PATCH':
response = await client.patch(url, json=payload, headers=headers)
# Check status code
if response.status_code not in success_codes:
error_msg = (
f"API call failed with status {response.status_code}: "
f"{response.text[:500]}"
)
logger.error(error_msg)
raise Exception(error_msg)
# Parse response
try:
response_data = response.json()
except:
response_data = {"text": response.text}
# Extract specific field if response_path provided
result_text = None
if response_path and isinstance(response_data, dict):
result_text = _extract_json_path(response_data, response_path)
if not result_text:
# Build summary from response
if isinstance(response_data, dict):
# Look for common result fields
result_text = (
response_data.get('message') or
response_data.get('result') or
response_data.get('summary') or
f"Success ({response.status_code})"
)
else:
result_text = f"Success ({response.status_code})"
logger.info(f"API call succeeded: {result_text}")
return str(result_text)
except httpx.HTTPStatusError as e:
error_msg = f"HTTP {e.response.status_code}: {e.response.text[:500]}"
logger.error(error_msg)
raise Exception(error_msg)
except httpx.RequestError as e:
error_msg = f"Request failed: {str(e)}"
logger.error(error_msg)
raise Exception(error_msg)
except Exception as e:
logger.error(f"REST API call failed: {e}", exc_info=True)
raise
def _build_auth_header(auth_config: dict, settings: Settings) -> Optional[Dict[str, str]]:
"""Build authentication header from config."""
auth_type = auth_config.get('type', '').lower()
if auth_type == 'bearer':
token = auth_config.get('token', '')
token = _substitute_env_vars(token)
if token:
return {"Authorization": f"Bearer {token}"}
elif auth_type == 'basic':
username = _substitute_env_vars(auth_config.get('username', ''))
password = _substitute_env_vars(auth_config.get('password', ''))
if username and password:
import base64
credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
return {"Authorization": f"Basic {credentials}"}
elif auth_type == 'api_key':
key = _substitute_env_vars(auth_config.get('key', ''))
header_name = auth_config.get('header', 'X-API-Key')
if key:
return {header_name: key}
return None
def _substitute_env_vars(text: str) -> str:
"""Substitute ${ENV_VAR} placeholders with environment variables."""
if not isinstance(text, str):
return text
# Find all ${VAR} patterns
pattern = r'\$\{([A-Z_][A-Z0-9_]*)\}'
matches = re.findall(pattern, text)
for var_name in matches:
env_value = os.getenv(var_name, '')
if not env_value:
logger.warning(f"Environment variable not found: {var_name}")
text = text.replace(f"${{{var_name}}}", env_value)
return text
def _substitute_env_vars_recursive(data: Any) -> Any:
"""Recursively substitute environment variables in nested structures."""
if isinstance(data, dict):
return {k: _substitute_env_vars_recursive(v) for k, v in data.items()}
elif isinstance(data, list):
return [_substitute_env_vars_recursive(item) for item in data]
elif isinstance(data, str):
return _substitute_env_vars(data)
else:
return data
def _extract_json_path(data: dict, path: str) -> Optional[str]:
"""
Extract value from nested dict using dot notation.
Example: "result.message" -> data["result"]["message"]
"""
try:
keys = path.split('.')
value = data
for key in keys:
if isinstance(value, dict):
value = value.get(key)
else:
return None
return str(value) if value is not None else None
except:
return None
def _redact_sensitive(data: Any) -> Any:
"""Redact sensitive fields from logs."""
if isinstance(data, dict):
redacted = {}
sensitive_keys = ['password', 'token', 'api_key', 'secret', 'auth']
for k, v in data.items():
if any(s in k.lower() for s in sensitive_keys):
redacted[k] = '***REDACTED***'
else:
redacted[k] = _redact_sensitive(v)
return redacted
elif isinstance(data, list):
return [_redact_sensitive(item) for item in data]
else:
return data
+530
View File
@@ -0,0 +1,530 @@
"""
The Scheduler - System-wide maintenance orchestration.
Handles backups, documentation mirroring, cleanup, and automated tasks.
Architecture: Hybrid APScheduler + DB-based priority system
- APScheduler runs a single job every minute
- Job queries DB for tasks scheduled in that minute
- Executes up to 5 tasks concurrently based on priority
"""
from fastapi import FastAPI, HTTPException, Depends, Header
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.triggers.cron import CronTrigger
from contextlib import asynccontextmanager
import logging
from src.config import get_settings, Settings
from src.tasks.executor import TaskExecutor
from src.models import TaskCreate, TaskUpdate, TaskResponse
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Global instances
scheduler: AsyncIOScheduler | None = None
task_executor: TaskExecutor | None = None
def get_scheduler() -> AsyncIOScheduler:
"""Dependency to get scheduler instance."""
if scheduler is None:
raise HTTPException(500, "Scheduler not initialized")
return scheduler
def get_task_executor() -> TaskExecutor:
"""Dependency to get task executor instance."""
if task_executor is None:
raise HTTPException(500, "Task executor not initialized")
return task_executor
# Lifespan manager for startup/shutdown
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage application lifecycle - startup and shutdown."""
global scheduler, task_executor
settings = get_settings()
# Startup
logger.info("=" * 60)
logger.info("The Scheduler - System-wide Maintenance Orchestration")
logger.info("=" * 60)
logger.info(f"Architecture: Hybrid APScheduler + DB-based priority system")
logger.info(f"Database: {settings.postgres_host}:{settings.postgres_port}/{settings.postgres_db}")
logger.info(f"API: http://{settings.host}:{settings.port}")
logger.info(f"Docs: http://{settings.host}:{settings.port}/docs")
logger.info("=" * 60)
# Initialize task executor
task_executor = TaskExecutor(settings)
logger.info("Task executor initialized (max 5 concurrent tasks)")
# Initialize APScheduler with minimal configuration
# No jobstore needed - we only have one in-memory job
scheduler = AsyncIOScheduler(
job_defaults={
'coalesce': True, # Combine missed runs
'max_instances': 1, # Only one instance running
'misfire_grace_time': 30 # 30s grace period for minute-based execution
}
)
# Add the single minute-based task processor
scheduler.add_job(
func=task_executor.process_minute,
trigger=CronTrigger(minute='*'), # Run every minute
id='process_tasks',
name='Process scheduled tasks',
replace_existing=True
)
try:
scheduler.start()
logger.info("Scheduler started - processing tasks every minute")
logger.info("Priority system: 1-5 (emergency/system), 10-30 (user), 40-70+ (maintenance)")
except Exception as e:
logger.error(f"Failed to start scheduler: {e}")
raise
yield
# Shutdown
logger.info("Stopping The Scheduler...")
if scheduler:
scheduler.shutdown(wait=True)
logger.info("Scheduler stopped")
# FastAPI app
app = FastAPI(
title="The Scheduler",
version="1.0.0",
description="System-wide maintenance orchestration - backups, doc mirroring, cleanup, task automation",
lifespan=lifespan
)
# Dependencies
async def verify_api_key(
authorization: str = Header(None),
settings: Settings = Depends(get_settings)
):
"""Verify API key from Authorization header."""
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "Missing API key")
key = authorization.replace("Bearer ", "")
if key != settings.scheduler_api_key:
raise HTTPException(403, "Invalid API key")
return key
# ============================================================================
# Public Endpoints (no auth required)
# ============================================================================
@app.get("/health")
async def health(
settings: Settings = Depends(get_settings),
sched: AsyncIOScheduler = Depends(get_scheduler)
):
"""Health check endpoint."""
return {
"status": "healthy",
"scheduler_running": sched.running,
"jobs_count": len(sched.get_jobs()),
"database": settings.postgres_db
}
# ============================================================================
# Protected Endpoints (require API key)
# ============================================================================
@app.get("/tasks")
async def list_tasks(
enabled: bool = None,
service: str = None,
api_key: str = Depends(verify_api_key),
executor: TaskExecutor = Depends(get_task_executor)
):
"""List all scheduled tasks from database."""
import psycopg2.extras
query = "SELECT * FROM scheduled_tasks WHERE 1=1"
params = []
if enabled is not None:
query += " AND enabled = %s"
params.append(enabled)
if service:
query += " AND service = %s"
params.append(service)
query += " ORDER BY priority ASC, task_name ASC"
with executor.get_db_connection() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(query, params)
tasks = [dict(task) for task in cur.fetchall()]
return {
"tasks": tasks,
"count": len(tasks)
}
@app.get("/tasks/{task_name}")
async def get_task_details(
task_name: str,
api_key: str = Depends(verify_api_key),
executor: TaskExecutor = Depends(get_task_executor)
):
"""Get details for a specific task."""
import psycopg2.extras
with executor.get_db_connection() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT * FROM scheduled_tasks WHERE task_name = %s", (task_name,))
task = cur.fetchone()
if not task:
raise HTTPException(404, f"Task '{task_name}' not found")
return dict(task)
@app.post("/tasks", response_model=TaskResponse, tags=["Task Management"])
async def create_task(
task: TaskCreate,
api_key: str = Depends(verify_api_key),
executor: TaskExecutor = Depends(get_task_executor)
):
"""
Create a new scheduled task.
## Schedule Pattern
Use cron-style fields where `-1` means "every":
- `minute: -1, hour: -1` → Runs every minute
- `minute: 0, hour: -1` → Runs at minute 0 of every hour (hourly)
- `minute: 0, hour: 2` → Runs at 2:00 AM every day
- `minute: 0, hour: 2, day_of_week: 0` → Runs at 2:00 AM every Monday
## Priority Levels
- **1-5**: Emergency/critical system tasks
- **10-30**: User-initiated tasks
- **40-70**: Background maintenance
- **70+**: Low priority cleanup
## Executor Types
### `rest_api` Executor
Calls HTTP endpoints. Config format:
```json
{
"method": "POST",
"url": "http://service:port/endpoint",
"headers": {
"Authorization": "Bearer ${ENV_VAR}",
"Content-Type": "application/json"
},
"body": {"key": "value"}
}
```
Environment variables can be referenced with `${VAR_NAME}` syntax.
## Example: Librarian Consolidation Task
```json
{
"task_name": "librarian_consolidation",
"service": "library-desk",
"executor": "rest_api",
"priority": 25,
"description": "Processes search queries and consolidates knowledge",
"minute": 0,
"hour": -1,
"config": {
"method": "POST",
"url": "http://library-desk:8089/consolidation/run",
"headers": {
"Authorization": "Bearer ${LIBRARY_DESK_API_KEY}",
"Content-Type": "application/json"
},
"body": {
"dry_run": false,
"process_limit": 10
}
}
}
```
This creates an hourly task that calls the consolidation endpoint.
"""
import psycopg2.extras
import json
# Convert task model to dict
task_data = task.model_dump()
# Convert config dict to JSON string if present
if 'config' in task_data and isinstance(task_data['config'], dict):
task_data['config'] = json.dumps(task_data['config'])
with executor.get_db_connection() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("""
INSERT INTO scheduled_tasks
(task_name, service, executor, priority, minute, hour,
day_of_month, month, day_of_week, enabled, description,
config, max_retries, timeout_seconds, created_by)
VALUES
(%(task_name)s, %(service)s, %(executor)s, %(priority)s,
%(minute)s, %(hour)s, %(day_of_month)s, %(month)s,
%(day_of_week)s, %(enabled)s, %(description)s,
%(config)s::jsonb, %(max_retries)s, %(timeout_seconds)s,
%(created_by)s)
RETURNING *
""", task_data)
new_task = dict(cur.fetchone())
conn.commit()
logger.info(f"Created task: {new_task['task_name']}")
return new_task
@app.put("/tasks/{task_name}")
async def update_task(
task_name: str,
task_data: dict,
api_key: str = Depends(verify_api_key),
executor: TaskExecutor = Depends(get_task_executor)
):
"""Update an existing scheduled task."""
import psycopg2.extras
import json
# Build update query dynamically
allowed_fields = ['service', 'executor', 'priority', 'minute', 'hour',
'day_of_month', 'month', 'day_of_week', 'enabled',
'description', 'config', 'max_retries', 'timeout_seconds']
updates = {k: v for k, v in task_data.items() if k in allowed_fields}
if not updates:
raise HTTPException(400, "No valid fields to update")
# Convert config dict to JSON string if present
if 'config' in updates and isinstance(updates['config'], dict):
updates['config'] = json.dumps(updates['config'])
set_clause = ', '.join([f"{k} = %({k})s" for k in updates.keys()])
with executor.get_db_connection() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(f"""
UPDATE scheduled_tasks
SET {set_clause}, updated_at = NOW()
WHERE task_name = %(task_name)s
RETURNING *
""", {**updates, 'task_name': task_name})
updated_task = cur.fetchone()
if not updated_task:
raise HTTPException(404, f"Task '{task_name}' not found")
conn.commit()
logger.info(f"Updated task: {task_name}")
return dict(updated_task)
@app.delete("/tasks/{task_name}")
async def delete_task(
task_name: str,
api_key: str = Depends(verify_api_key),
executor: TaskExecutor = Depends(get_task_executor)
):
"""Delete a scheduled task."""
with executor.get_db_connection() as conn:
with conn.cursor() as cur:
cur.execute("""
DELETE FROM scheduled_tasks
WHERE task_name = %s
RETURNING task_name
""", (task_name,))
deleted = cur.fetchone()
if not deleted:
raise HTTPException(404, f"Task '{task_name}' not found")
conn.commit()
logger.info(f"Deleted task: {task_name}")
return {"message": f"Task '{task_name}' deleted successfully"}
@app.post("/tasks/{task_name}/trigger")
async def trigger_task(
task_name: str,
api_key: str = Depends(verify_api_key),
executor: TaskExecutor = Depends(get_task_executor)
):
"""Manually trigger a task to run immediately."""
import psycopg2.extras
# Get task details
with executor.get_db_connection() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("SELECT * FROM scheduled_tasks WHERE task_name = %s", (task_name,))
task = cur.fetchone()
if not task:
raise HTTPException(404, f"Task '{task_name}' not found")
if not task['enabled']:
raise HTTPException(400, f"Task '{task_name}' is disabled")
# Execute task immediately in background
import asyncio
asyncio.create_task(executor.execute_task(dict(task)))
logger.info(f"Manually triggered task: {task_name}")
return {
"message": f"Task '{task_name}' triggered successfully",
"task_name": task_name,
"priority": task['priority'],
"executor": task['executor']
}
# Legacy endpoints (deprecated)
@app.post("/tasks/backup")
async def trigger_backup(api_key: str = Depends(verify_api_key)):
"""Trigger backup tasks manually (deprecated - use POST /tasks/{name}/trigger)"""
# TODO: Implement backup executor
logger.info("Manual backup triggered")
return {
"message": "Backup task triggered",
"status": "not_implemented",
"note": "Backup executor needs to be implemented"
}
@app.post("/tasks/docs/update")
async def trigger_docs_update(
project: str = None,
api_key: str = Depends(verify_api_key)
):
"""Trigger documentation mirror update"""
# TODO: Implement doc mirror executor
logger.info(f"Doc mirror update triggered for project: {project or 'all'}")
return {
"message": f"Documentation update triggered for {project or 'all projects'}",
"status": "not_implemented",
"note": "Doc mirror executor needs to be implemented"
}
@app.post("/tasks/docs/check-versions")
async def check_doc_versions(api_key: str = Depends(verify_api_key)):
"""Check for new documentation versions"""
# TODO: Implement version check executor
logger.info("Version check triggered")
return {
"message": "Version check triggered",
"status": "not_implemented",
"note": "Version check executor needs to be implemented"
}
@app.post("/tasks/cleanup")
async def trigger_cleanup(api_key: str = Depends(verify_api_key)):
"""Run cleanup tasks"""
# TODO: Implement cleanup executor
logger.info("Cleanup task triggered")
return {
"message": "Cleanup task triggered",
"status": "not_implemented",
"note": "Cleanup executor needs to be implemented"
}
@app.get("/executions")
async def task_history(
limit: int = 20,
task_name: str = None,
service: str = None,
status: str = None,
api_key: str = Depends(verify_api_key),
executor: TaskExecutor = Depends(get_task_executor)
):
"""View task execution history."""
import psycopg2.extras
query = "SELECT * FROM task_executions WHERE 1=1"
params = []
if task_name:
query += " AND task_name = %s"
params.append(task_name)
if service:
query += " AND service = %s"
params.append(service)
if status:
query += " AND status = %s"
params.append(status)
query += " ORDER BY triggered_at DESC LIMIT %s"
params.append(limit)
with executor.get_db_connection() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(query, params)
executions = [dict(ex) for ex in cur.fetchall()]
return {
"executions": executions,
"count": len(executions),
"limit": limit
}
@app.get("/stats")
async def stats(
api_key: str = Depends(verify_api_key),
settings: Settings = Depends(get_settings),
sched: AsyncIOScheduler = Depends(get_scheduler),
task_exec: TaskExecutor = Depends(get_task_executor)
):
"""Get system statistics."""
import psycopg2.extras
# Query task stats from database
with task_exec.get_db_connection() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
# Count enabled tasks
cur.execute("SELECT COUNT(*) as count FROM scheduled_tasks WHERE enabled = true")
enabled_tasks = cur.fetchone()['count']
# Count running tasks
cur.execute("SELECT COUNT(*) as count FROM task_executions WHERE status = 'running'")
running_tasks = cur.fetchone()['count']
# Recent execution stats (last 24 hours)
cur.execute("""
SELECT status, COUNT(*) as count
FROM task_executions
WHERE triggered_at > NOW() - INTERVAL '24 hours'
GROUP BY status
""")
execution_stats = {row['status']: row['count'] for row in cur.fetchall()}
return {
"scheduler_running": sched.running,
"minute_processor_active": True, # If we got here, it's running
"database": settings.postgres_db,
"tasks_enabled": enabled_tasks,
"tasks_currently_running": running_tasks,
"concurrent_limit": 5,
"execution_stats_24h": execution_stats,
"priority_system": "1-5 (emergency/system), 10-30 (user), 40-70+ (maintenance)"
}
+210
View File
@@ -0,0 +1,210 @@
"""
Pydantic models for The Scheduler API.
"""
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any
from enum import Enum
class SchedulePattern(str, Enum):
"""Common schedule patterns."""
EVERY_MINUTE = "every_minute"
HOURLY = "hourly"
DAILY = "daily"
WEEKLY = "weekly"
MONTHLY = "monthly"
class TaskExecutor(str, Enum):
"""Available task executors."""
REST_API = "rest_api"
SHELL = "shell"
PYTHON = "python"
class TaskPriorityLevel(str, Enum):
"""Task priority levels."""
EMERGENCY = "emergency" # 1-5: Critical system tasks
SYSTEM = "system" # 5-10: System maintenance
USER = "user" # 10-30: User-initiated tasks
MAINTENANCE = "maintenance" # 40-70: Background maintenance
LOW = "low" # 70+: Low priority cleanup
class TaskBase(BaseModel):
"""Base task fields."""
task_name: str = Field(
...,
description="Unique task identifier (e.g., 'librarian_consolidation')",
example="librarian_consolidation"
)
service: str = Field(
...,
description="Service that owns this task (e.g., 'library-desk', 'core-api')",
example="library-desk"
)
executor: str = Field(
...,
description="Executor type: 'rest_api', 'shell', or 'python'",
example="rest_api"
)
priority: int = Field(
...,
ge=1,
le=100,
description="Priority level (1-5: emergency, 10-30: user, 40-70: maintenance, 70+: low)",
example=25
)
description: Optional[str] = Field(
None,
description="Human-readable task description",
example="Processes unprocessed search queries and consolidates knowledge into wiki pages"
)
class TaskSchedule(BaseModel):
"""Cron-style schedule fields."""
minute: int = Field(
-1,
ge=-1,
le=59,
description="Minute to run (-1 = every minute, 0-59 = specific minute)",
example=-1
)
hour: int = Field(
-1,
ge=-1,
le=23,
description="Hour to run (-1 = every hour, 0-23 = specific hour)",
example=-1
)
day_of_month: int = Field(
-1,
ge=-1,
le=31,
description="Day of month to run (-1 = every day, 1-31 = specific day)",
example=-1
)
month: int = Field(
-1,
ge=-1,
le=12,
description="Month to run (-1 = every month, 1-12 = specific month)",
example=-1
)
day_of_week: int = Field(
-1,
ge=-1,
le=6,
description="Day of week to run (-1 = every day, 0-6 = Monday-Sunday)",
example=-1
)
class TaskConfig(BaseModel):
"""Task execution configuration."""
enabled: bool = Field(
True,
description="Whether task is enabled",
example=True
)
max_retries: int = Field(
3,
ge=0,
le=10,
description="Maximum retry attempts on failure",
example=3
)
timeout_seconds: int = Field(
3600,
ge=1,
description="Execution timeout in seconds",
example=3600
)
config: Optional[Dict[str, Any]] = Field(
None,
description="Executor-specific configuration (varies by executor type)",
example={
"method": "POST",
"url": "http://library-desk:8089/consolidation/run",
"headers": {
"Authorization": "Bearer ${LIBRARY_DESK_API_KEY}",
"Content-Type": "application/json"
},
"body": {
"dry_run": False,
"process_limit": 10,
"lookback_days": 7,
"min_web_results": 2
}
}
)
class TaskCreate(TaskBase, TaskSchedule, TaskConfig):
"""Request model for creating a new scheduled task."""
created_by: Optional[str] = Field(
"api",
description="User or system that created this task",
example="api"
)
class Config:
json_schema_extra = {
"example": {
"task_name": "librarian_consolidation",
"service": "library-desk",
"executor": "rest_api",
"priority": 25,
"description": "Processes unprocessed search queries and consolidates knowledge",
"minute": 0,
"hour": -1,
"day_of_month": -1,
"month": -1,
"day_of_week": -1,
"enabled": True,
"max_retries": 3,
"timeout_seconds": 3600,
"config": {
"method": "POST",
"url": "http://library-desk:8089/consolidation/run",
"headers": {
"Authorization": "Bearer ${LIBRARY_DESK_API_KEY}",
"Content-Type": "application/json"
},
"body": {
"dry_run": False,
"process_limit": 10,
"lookback_days": 7,
"min_web_results": 2
}
},
"created_by": "api"
}
}
class TaskUpdate(BaseModel):
"""Request model for updating a scheduled task."""
service: Optional[str] = None
executor: Optional[str] = None
priority: Optional[int] = Field(None, ge=1, le=100)
minute: Optional[int] = Field(None, ge=-1, le=59)
hour: Optional[int] = Field(None, ge=-1, le=23)
day_of_month: Optional[int] = Field(None, ge=-1, le=31)
month: Optional[int] = Field(None, ge=-1, le=12)
day_of_week: Optional[int] = Field(None, ge=-1, le=6)
enabled: Optional[bool] = None
description: Optional[str] = None
config: Optional[Dict[str, Any]] = None
max_retries: Optional[int] = Field(None, ge=0, le=10)
timeout_seconds: Optional[int] = Field(None, ge=1)
class TaskResponse(TaskCreate):
"""Response model for task operations."""
created_at: str
updated_at: Optional[str] = None
class Config:
from_attributes = True
+1
View File
@@ -0,0 +1 @@
"""Task execution system for The Scheduler."""
+278
View File
@@ -0,0 +1,278 @@
"""
Task execution engine for The Scheduler.
Implements minute-based polling with priority-based concurrent execution.
"""
import asyncio
import logging
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional
import psycopg2
from psycopg2.extras import RealDictCursor
import traceback
from src.config import Settings
logger = logging.getLogger(__name__)
# Priority ranges (for reference)
# 1: Emergency/recovery tasks
# 2: Primary system tasks
# 3: Secondary system tasks
# 5: Urgent user-triggered tasks
# 10: High-priority user tasks
# 15: Reserved
# 20: Backup tasks
# 25: Reserved
# 30: Low-priority user tasks
# 40: Cleanup tasks
# 50: Documentation version checks
# 60: Documentation mirroring
# 70+: Future/experimental tasks
MAX_CONCURRENT_TASKS = 5
class TaskExecutor:
"""Executes scheduled tasks based on priority with concurrency control."""
def __init__(self, settings: Settings):
self.settings = settings
self.running_tasks: Dict[int, asyncio.Task] = {} # task_id -> asyncio.Task
self.semaphore = asyncio.Semaphore(MAX_CONCURRENT_TASKS)
def get_db_connection(self):
"""Create database connection."""
return psycopg2.connect(
host=self.settings.postgres_host,
port=self.settings.postgres_port,
database=self.settings.postgres_db,
user=self.settings.postgres_user,
password=self.settings.postgres_password
)
def get_tasks_for_minute(self, now: datetime) -> List[Dict[str, Any]]:
"""
Query database for tasks scheduled for this minute.
Supports wildcards (-1 = any value).
"""
minute = now.minute
hour = now.hour
day = now.day
month = now.month
# Python: Monday=0, Sunday=6; PostgreSQL: Monday=0, Sunday=6 (same)
weekday = now.weekday()
query = """
SELECT
id, task_name, service, executor, priority,
config, timeout_seconds, max_retries, retry_count,
last_run, last_status
FROM scheduled_tasks
WHERE enabled = true
AND (minute = -1 OR minute = %s)
AND (hour = -1 OR hour = %s)
AND (day_of_month = -1 OR day_of_month = %s)
AND (month = -1 OR month = %s)
AND (day_of_week = -1 OR day_of_week = %s)
AND id NOT IN (
SELECT task_id
FROM task_executions
WHERE status = 'running'
)
ORDER BY priority ASC, task_name ASC
"""
with self.get_db_connection() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(query, (minute, hour, day, month, weekday))
tasks = cur.fetchall()
logger.info(f"Found {len(tasks)} tasks scheduled for {now.strftime('%Y-%m-%d %H:%M')}")
return [dict(task) for task in tasks]
def should_run_task(self, task: Dict[str, Any], now: datetime) -> bool:
"""
Determine if task should run based on last execution.
Prevents running the same task multiple times in the same minute.
"""
if not task['last_run']:
return True
last_run = task['last_run']
if last_run.tzinfo is None:
last_run = last_run.replace(tzinfo=timezone.utc)
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
# Don't run if already executed this minute
if (last_run.year == now.year and
last_run.month == now.month and
last_run.day == now.day and
last_run.hour == now.hour and
last_run.minute == now.minute):
logger.debug(f"Task {task['task_name']} already ran this minute")
return False
return True
async def execute_task(self, task: Dict[str, Any]):
"""
Execute a single task with timeout and error handling.
Updates task_executions table with results.
"""
task_id = task['id']
task_name = task['task_name']
executor_name = task['executor']
timeout = task.get('timeout_seconds', 3600)
execution_id = None
started_at = datetime.now(timezone.utc)
logger.info(f"[Priority {task['priority']}] Starting task: {task_name} (executor: {executor_name})")
try:
# Create execution record
with self.get_db_connection() as conn:
with conn.cursor() as cur:
cur.execute("""
INSERT INTO task_executions
(task_id, task_name, service, executor, priority,
status, triggered_by, started_at)
VALUES (%s, %s, %s, %s, %s, 'running', 'scheduler', %s)
RETURNING id
""", (task_id, task_name, task['service'], executor_name,
task['priority'], started_at))
execution_id = cur.fetchone()[0]
conn.commit()
# Load and execute the task
output, error = await self._run_executor(executor_name, task, timeout)
completed_at = datetime.now(timezone.utc)
duration = int((completed_at - started_at).total_seconds())
status = 'success' if error is None else 'failed'
# Update execution record
with self.get_db_connection() as conn:
with conn.cursor() as cur:
cur.execute("""
UPDATE task_executions
SET status = %s, completed_at = %s, duration_seconds = %s,
output = %s, error = %s
WHERE id = %s
""", (status, completed_at, duration, output, error, execution_id))
# Update scheduled_tasks
cur.execute("""
UPDATE scheduled_tasks
SET last_run = %s, last_status = %s, last_duration_seconds = %s,
retry_count = 0, updated_at = %s
WHERE id = %s
""", (completed_at, status, duration, completed_at, task_id))
conn.commit()
if error:
logger.error(f"Task {task_name} failed: {error}")
else:
logger.info(f"Task {task_name} completed successfully in {duration}s")
except asyncio.TimeoutError:
logger.error(f"Task {task_name} timed out after {timeout}s")
self._update_execution_status(execution_id, 'timeout',
error=f"Task exceeded timeout of {timeout}s")
except Exception as e:
logger.error(f"Task {task_name} failed with exception: {e}")
logger.error(traceback.format_exc())
self._update_execution_status(execution_id, 'failed',
error=f"{str(e)}\n{traceback.format_exc()}")
finally:
# Remove from running tasks
if task_id in self.running_tasks:
del self.running_tasks[task_id]
async def _run_executor(self, executor_name: str, task: Dict[str, Any], timeout: int) -> tuple[Optional[str], Optional[str]]:
"""
Dynamically load and run the executor module.
Returns (output, error) tuple.
"""
try:
# Import executor dynamically
module_path = f"src.executors.{executor_name}"
module = __import__(module_path, fromlist=['execute'])
if not hasattr(module, 'execute'):
return None, f"Executor {executor_name} missing execute() function"
# Run with timeout
config = task.get('config', {})
result = await asyncio.wait_for(
module.execute(config, self.settings),
timeout=timeout
)
return result, None
except ModuleNotFoundError:
return None, f"Executor module not found: {executor_name}"
except Exception as e:
return None, f"Executor error: {str(e)}\n{traceback.format_exc()}"
def _update_execution_status(self, execution_id: int, status: str, error: str = None):
"""Update execution record with final status."""
if execution_id is None:
return
try:
with self.get_db_connection() as conn:
with conn.cursor() as cur:
cur.execute("""
UPDATE task_executions
SET status = %s, completed_at = %s, error = %s
WHERE id = %s
""", (status, datetime.now(timezone.utc), error, execution_id))
conn.commit()
except Exception as e:
logger.error(f"Failed to update execution status: {e}")
async def process_minute(self):
"""
Main entry point: Process all tasks scheduled for the current minute.
Executes up to MAX_CONCURRENT_TASKS in parallel, prioritized by priority field.
"""
now = datetime.now(timezone.utc)
logger.info(f"Processing tasks for {now.strftime('%Y-%m-%d %H:%M')}")
# Get tasks scheduled for this minute
tasks = self.get_tasks_for_minute(now)
# Filter out tasks that already ran this minute
tasks_to_run = [task for task in tasks if self.should_run_task(task, now)]
if not tasks_to_run:
logger.debug("No tasks to run this minute")
return
logger.info(f"Will execute {len(tasks_to_run)} tasks (max {MAX_CONCURRENT_TASKS} concurrent)")
# Process tasks in priority order, respecting concurrency limit
for task in tasks_to_run:
# Wait for available slot
await self.semaphore.acquire()
# Start task
task_coro = self._execute_with_semaphore(task)
asyncio_task = asyncio.create_task(task_coro)
self.running_tasks[task['id']] = asyncio_task
# Wait for all tasks in this batch to complete
if self.running_tasks:
await asyncio.gather(*self.running_tasks.values(), return_exceptions=True)
async def _execute_with_semaphore(self, task: Dict[str, Any]):
"""Execute task and release semaphore when done."""
try:
await self.execute_task(task)
finally:
self.semaphore.release()