Source code for simstack.core.services.node_execution_service

import asyncio
import logging
import platform
import subprocess

from simstack.core.context import context
from simstack.core.definitions import TaskStatus
from simstack.core.node import node_from_database
from simstack.core.node_claim import claim_submitted_node
from simstack.core.run_docker import run_docker
from simstack.core.services.base_service import BaseService
from simstack.core.submit_node import submit_node
from simstack.models import NodeRegistry
from simstack.models.parameters import Resource
from simstack.models.runner_model import RunnerEventEnum

logger = logging.getLogger("NodeRunner")


[docs] async def run_node_from_registry(registry_entry: NodeRegistry) -> bool: # Create the node from the registry entry node = await node_from_database(registry_entry) if not node: logger.error( f"Failed to create node from registry entry task_id: {registry_entry.id} on resource {context.config.resource}" ) registry_entry.status = TaskStatus.FAILED await context.db.save(registry_entry) return False registry_entry = node.registry_entry # it may have changed assert registry_entry is not None if ( node.status == TaskStatus.RETRIEVED or node.status == TaskStatus.SUBMITTED or node.status == TaskStatus.SLURM_QUEUED ) or ( node.status == TaskStatus.COMPLETED and registry_entry.parameters.force_rerun ): await node.execute_node_locally() else: logger.info( f"task_id: {registry_entry.id} skipping task: {registry_entry.name} with status {registry_entry.status}" ) return bool(node.status == TaskStatus.COMPLETED)
[docs] class NodeExecutionService(BaseService): def __init__( self, resource: Resource, interval: int, max_concurrent: int, shutdown_event: asyncio.Event | None, detach: bool = True, is_default: bool = False, ) -> None: super().__init__( "JobPolling", resource, interval, shutdown_event=shutdown_event ) self._resource_name = str(resource) self._semaphore = asyncio.Semaphore(max_concurrent) self._running_tasks: set[asyncio.Task[bool]] = set() self._started = False self._detach = detach self._is_default = is_default
[docs] async def run_node(self, registry_entry: NodeRegistry) -> bool: """Run a single node by its ID from the database""" await self.write_node_event(RunnerEventEnum.NODE_STARTED, registry_entry.id) try: logger.info( f"Running node task_id: {registry_entry.id} on resource {context.config.resource} with status {registry_entry.status}" ) queue = ( registry_entry.parameters.queue if hasattr(registry_entry.parameters, "queue") else "default" ) if queue is None: logger.error( f"Queue parameter not found for task_id: {registry_entry.id}" ) return False if queue == "slurm-queue": return await submit_node(registry_entry) elif queue == "docker": return await run_docker(registry_entry) elif queue == "default": if self._detach: # Spawn independent process that survives when the runner dies cmd = [ "uv", "run", "--directory", str(context.config.project_root), "run_node", "--node-id", str(registry_entry.id), "--resource", str(self._resource_name), ] # Use platform specific flags to ensure the process survives if runner is killed creationflags = 0 if platform.system() == "Windows": create_new_process_group = getattr( subprocess, "CREATE_NEW_PROCESS_GROUP", 0 ) detached_process = getattr(subprocess, "DETACHED_PROCESS", 0) creationflags = create_new_process_group | detached_process try: process = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, creationflags=creationflags, start_new_session=True if platform.system() != "Windows" else False, ) except Exception as e: logger.error( f"Failed to spawn detached process for task id: {registry_entry.id}. task_id: {registry_entry.id}. " f"Command: {' '.join(cmd)}. Error: {str(e)}" ) raise logger.info( f"Spawned detached process for task_id: {registry_entry.id} with PID: {process.pid}" ) return True else: return await run_node_from_registry(registry_entry) else: logger.error( f"Queue {queue} not supported for task_id: {registry_entry.id}" ) return False except Exception as e: logger.exception( f"Error running node task_id: {registry_entry.id} on resource {context.config.resource} : {str(e)}" ) if registry_entry: registry_entry.status = TaskStatus.FAILED await context.db.save(registry_entry) return False
[docs] async def execute(self) -> None: if not self._started: await self.write_resource_event(RunnerEventEnum.RUNNER_STARTED) self._started = True # Clean up the completed tasks completed_tasks = {task for task in self._running_tasks if task.done()} for task in completed_tasks: try: await task except Exception as e: logger.exception(f"Task completed with error: {e}") self._running_tasks.remove(task) # Load tasks registry_entry_list = await context.db.load_waiting_tasks_for_resource(self._resource_name) if registry_entry_list: logger.info( f"Retrieved {len(registry_entry_list)} tasks for {self._resource_name}" ) for entry in registry_entry_list: if not await claim_submitted_node(entry): continue task = asyncio.create_task(self._run_with_semaphore(entry)) self._running_tasks.add(task)
async def _run_with_semaphore(self, entry: NodeRegistry) -> bool: async with self._semaphore: return await self.run_node(entry)