""" Job Executor for Python Scheduler Handles job execution in background threads with timeout support Based on DataHub web_panel/utils/job_manager.py pattern """ import subprocess import threading import json import time import sys from datetime import datetime from pathlib import Path from typing import Optional, Dict, Any from loguru import logger import psutil from core.database import SchedulerDatabase from core.config import SchedulerConfig class JobExecutor: """Executes jobs in background threads with proper tracking""" def __init__(self, db: SchedulerDatabase, config: SchedulerConfig, notification_manager=None): self.db = db self.config = config self.notification_manager = notification_manager self.active_jobs = {} # execution_id -> process self.lock = threading.Lock() logger.info("JobExecutor initialized") def _kill_process_tree(self, pid: int): """ Terminate process and all its children recursively Args: pid: Process ID to terminate """ try: parent = psutil.Process(pid) children = parent.children(recursive=True) # Kill children first (bottom-up) for child in children: try: logger.debug(f"Killing child process {child.pid}: {child.name()}") child.kill() except psutil.NoSuchProcess: pass # Kill parent try: logger.debug(f"Killing parent process {parent.pid}: {parent.name()}") parent.kill() except psutil.NoSuchProcess: pass # Wait for all to terminate (max 5 seconds) gone, alive = psutil.wait_procs([parent] + children, timeout=5) # Force kill if still alive for p in alive: try: logger.warning(f"Force killing process {p.pid}: {p.name()}") p.kill() except psutil.NoSuchProcess: pass except psutil.NoSuchProcess: logger.warning(f"Process {pid} already terminated") except Exception as e: logger.error(f"Error killing process tree {pid}: {e}") def execute_job(self, job_id: int, triggered_by: str = 'scheduler', **kwargs) -> Optional[int]: """ Execute a job asynchronously Args: job_id: ID of job to execute triggered_by: Who/what triggered execution **kwargs: Additional execution parameters Returns: Execution ID for tracking, or None if job not found/disabled """ job = self.db.get_job(job_id) if not job: logger.error(f"Job {job_id} not found") return None if not job['enabled']: logger.warning(f"Job '{job['name']}' (ID {job_id}) is disabled, skipping") return None # Create execution record execution_id = self.db.create_execution( job_id=job_id, status='queued', triggered_by=triggered_by, start_time=datetime.now(), **kwargs ) logger.info(f"Queued job '{job['name']}' (execution ID {execution_id})") # Launch in thread thread = threading.Thread( target=self._run_job, args=(execution_id, job, triggered_by), daemon=False # Important: allow clean shutdown ) thread.start() return execution_id def _run_job(self, execution_id: int, job: Dict[str, Any], triggered_by: str = 'scheduler'): """ Internal: Execute job in subprocess Args: execution_id: Execution record ID job: Job definition dict """ job_name = job['name'] log_file_path = self._create_log_file_path(execution_id, job_name) try: # Update status to running self.db.update_execution( execution_id, status='running', start_time=datetime.now(), log_file=str(log_file_path) ) logger.info(f"Starting job '{job_name}' (execution {execution_id})") # Build command cmd = self._build_command(job) # Execute with timeout start_time = datetime.now() # Check if we should show console (debug mode) show_console = job.get('show_console', False) if show_console: # Debug mode: Show console window, no log file redirection logger.info(f"Job '{job_name}' configured to show console window (debug mode) - output will be visible in CMD window") # Create minimal log file with header only with open(log_file_path, 'w', encoding='utf-8') as log_file: log_file.write(f"=== Job Execution Log ===\n") log_file.write(f"Job: {job_name}\n") log_file.write(f"Execution ID: {execution_id}\n") log_file.write(f"Start Time: {start_time.strftime('%Y-%m-%d %H:%M:%S')}\n") log_file.write(f"Command: {cmd if isinstance(cmd, str) else ' '.join(cmd)}\n") log_file.write(f"Working Directory: {job['working_directory']}\n") log_file.write(f"{'=' * 50}\n\n") log_file.write(f"NOTE: Output displayed in console window (debug mode)\n") log_file.write(f"Log file not captured - see console window for output\n") # Note: show_console=True is configured for this job # User can open the log monitor manually from the GUI (History tab -> Monitor Log button) logger.info(f"Job '{job_name}' has show_console enabled - log can be monitored from GUI") # Start the actual job process (hidden, output goes to log file) # Convert cmd to list if it's a string if isinstance(cmd, str): import shlex cmd = shlex.split(cmd) with open(log_file_path, 'a', encoding='utf-8') as log_file: process = subprocess.Popen( cmd, cwd=job['working_directory'] if job['working_directory'] else None, stdout=log_file, stderr=subprocess.STDOUT, text=True ) # Track active job with self.lock: self.active_jobs[execution_id] = process # Wait with timeout timeout = job['timeout_seconds'] try: exit_code = process.wait(timeout=timeout) status = 'completed' if exit_code == 0 else 'failed' except subprocess.TimeoutExpired: logger.warning(f"Job '{job_name}' timed out after {timeout} seconds") logger.info(f"Terminating process tree for job '{job_name}' (PID: {process.pid})") self._kill_process_tree(process.pid) exit_code = -1 status = 'timeout' # Remove from active with self.lock: self.active_jobs.pop(execution_id, None) else: # Normal mode: Redirect to log file, no visible console with open(log_file_path, 'w', encoding='utf-8') as log_file: # Write header log_file.write(f"=== Job Execution Log ===\n") log_file.write(f"Job: {job_name}\n") log_file.write(f"Execution ID: {execution_id}\n") log_file.write(f"Start Time: {start_time.strftime('%Y-%m-%d %H:%M:%S')}\n") log_file.write(f"Command: {cmd if isinstance(cmd, str) else ' '.join(cmd)}\n") log_file.write(f"Working Directory: {job['working_directory']}\n") log_file.write(f"{'=' * 50}\n\n") log_file.flush() # Start process with stdout/stderr redirected to log file process = subprocess.Popen( cmd, stdout=log_file, stderr=subprocess.STDOUT, cwd=job['working_directory'] if job['working_directory'] else None, text=True ) # Track active job with self.lock: self.active_jobs[execution_id] = process # Wait with timeout timeout = job['timeout_seconds'] try: exit_code = process.wait(timeout=timeout) status = 'completed' if exit_code == 0 else 'failed' except subprocess.TimeoutExpired: logger.warning(f"Job '{job_name}' timed out after {timeout} seconds") logger.info(f"Terminating process tree for job '{job_name}' (PID: {process.pid})") self._kill_process_tree(process.pid) exit_code = -1 status = 'timeout' # Remove from active with self.lock: self.active_jobs.pop(execution_id, None) # Calculate duration end_time = datetime.now() duration = (end_time - start_time).total_seconds() # Read log file for previews stdout_preview, stderr_preview = self._read_log_previews(log_file_path) # Update database self.db.update_execution( execution_id, status=status, end_time=end_time, duration_seconds=duration, exit_code=exit_code, stdout_preview=stdout_preview, stderr_preview=stderr_preview ) if status == 'completed': logger.info(f"Job '{job_name}' completed successfully in {duration:.1f}s") elif status == 'timeout': logger.error(f"Job '{job_name}' timed out after {timeout}s") else: logger.error(f"Job '{job_name}' failed with exit code {exit_code}") # Send notification # If job has retries enabled and this failure is not the final one, # suppress intermediate notifications — _handle_retry sends the final one. has_retries = job['retry_count'] > 0 if self.notification_manager and not (status == 'failed' and has_retries): self.notification_manager.notify_job_result( execution_id=execution_id, job_name=job_name, status=status, duration=duration, exit_code=exit_code ) # Handle retry if needed if status == 'failed' and has_retries: self._handle_retry(execution_id, job) except Exception as e: logger.exception(f"Error executing job '{job_name}': {e}") # Update database with error self.db.update_execution( execution_id, status='failed', end_time=datetime.now(), error_message=str(e) ) # Write error to log file try: with open(log_file_path, 'a', encoding='utf-8') as log_file: log_file.write(f"\n\n{'=' * 50}\n") log_file.write(f"EXECUTION ERROR: {e}\n") log_file.write(f"{'=' * 50}\n") except: pass def _build_command(self, job: Dict[str, Any]) -> list: """Build command list for subprocess Returns: list: Command and arguments as a list """ job_type = job['job_type'] executable_path = job['executable_path'] if job_type == 'batch': # Execute .bat file, or .vbs with cscript.exe if executable_path.lower().endswith('.vbs'): cmd = ['cscript.exe', '//NoLogo', executable_path] else: # Return as STRING (not list) to bypass list2cmdline. # cmd.exe /c ""path"" → strips outer quotes → "path" (still quoted) → works with spaces. cmd = f'cmd.exe /c ""{executable_path}""' elif job_type == 'script': # Execute Python script with appropriate Python interpreter python_exe = self._get_python_exe(job.get('working_directory')) cmd = [python_exe, executable_path] elif job_type == 'python_module': # Execute as Python module python_exe = self._get_python_exe(job.get('working_directory')) cmd = [python_exe, '-m', executable_path] else: raise ValueError(f"Unknown job type: {job_type}") # Add arguments if job['arguments']: if isinstance(cmd, str): cmd += ' ' + job['arguments'] else: try: args = json.loads(job['arguments']) cmd.extend(args) except json.JSONDecodeError: cmd.append(job['arguments']) return cmd def _get_python_exe(self, working_directory: Optional[str]) -> str: """Get Python executable, preferring venv if available""" import sys if working_directory: # Check for venv in working directory venv_python = Path(working_directory) / 'venv' / 'Scripts' / 'python.exe' if venv_python.exists(): return str(venv_python) # Check for venv in project directories from config projects = self.config.get('projects', {}) if working_directory: for project_name, project_config in projects.items(): if working_directory.startswith(project_config.get('root', '')): venv_path = project_config.get('venv') if venv_path: venv_python = Path(venv_path) / 'Scripts' / 'python.exe' if venv_python.exists(): return str(venv_python) # Fallback to system Python return sys.executable def _create_log_file_path(self, execution_id: int, job_name: str) -> Path: """Create log file path with timestamp""" log_dir = Path(self.config.execution_log_dir) log_dir.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') sanitized_name = job_name.lower().replace(' ', '_').replace('/', '_') log_filename = f"{sanitized_name}_{timestamp}.log" return log_dir / log_filename def _read_log_previews(self, log_file_path: Path, preview_lines: int = 20) -> tuple: """Read last N lines from log file for preview""" try: with open(log_file_path, 'r', encoding='utf-8', errors='replace') as f: lines = f.readlines() # Get last N lines preview = ''.join(lines[-preview_lines:]) if lines else '' # For now, stdout and stderr are combined return preview, None except Exception as e: logger.warning(f"Could not read log preview: {e}") return None, None def _handle_retry(self, execution_id: int, job: Dict[str, Any]): """Handle job retry logic""" # Check how many times this job has failed recently recent_executions = self.db.get_recent_executions(limit=10, job_id=job['id']) failed_count = sum(1 for ex in recent_executions if ex['status'] == 'failed') if failed_count <= job['retry_count']: retry_delay = job['retry_delay_seconds'] logger.info(f"Retrying job '{job['name']}' in {retry_delay} seconds (attempt {failed_count + 1}/{job['retry_count'] + 1})") # Schedule retry def retry_job(): time.sleep(retry_delay) self.execute_job(job['id'], triggered_by='retry') threading.Thread(target=retry_job, daemon=True).start() else: # All retries exhausted — send single final notification logger.error(f"Job '{job['name']}' exhausted all {job['retry_count']} retries ({failed_count} failures)") if self.notification_manager: self.notification_manager.notify_final_failure( job_name=job['name'], attempt_count=failed_count ) def get_active_executions(self) -> list: """Get list of currently running executions""" with self.lock: return list(self.active_jobs.keys()) def stop_execution(self, execution_id: int) -> bool: """ Stop a running execution Args: execution_id: Execution ID to stop Returns: True if stopped successfully """ with self.lock: if execution_id not in self.active_jobs: return False process = self.active_jobs[execution_id] if process.poll() is None: # Process still running try: process.terminate() # Wait for graceful termination try: process.wait(timeout=5) except subprocess.TimeoutExpired: # Force kill process.kill() # Update database self.db.update_execution( execution_id, status='cancelled', end_time=datetime.now() ) # Remove from active self.active_jobs.pop(execution_id, None) logger.info(f"Stopped execution {execution_id}") return True except Exception as e: logger.error(f"Error stopping execution {execution_id}: {e}") return False return False