From a077df44daed57c4e88bd5296e24286eaba45989 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Fri, 29 Sep 2023 00:48:04 +0200 Subject: [PATCH 01/18] pydantic2 updates --- src/jobflow_remote/config/base.py | 54 ++++++++++++++------------- src/jobflow_remote/config/settings.py | 14 +++---- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/jobflow_remote/config/base.py b/src/jobflow_remote/config/base.py index 0317a61c..2477c361 100644 --- a/src/jobflow_remote/config/base.py +++ b/src/jobflow_remote/config/base.py @@ -8,7 +8,7 @@ from typing import Annotated, Literal from jobflow import JobStore -from pydantic import BaseModel, Extra, Field, validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from qtoolkit.io import BaseSchedulerIO, scheduler_mapping from jobflow_remote.fireworks.launchpad import RemoteLaunchPad @@ -73,8 +73,7 @@ def get_delta_retry(self, step_attempts: int) -> int: ind = min(step_attempts, len(self.delta_retry)) - 1 return self.delta_retry[ind] - class Config: - extra = Extra.forbid + model_config = ConfigDict(extra="forbid") class LogLevel(str, Enum): @@ -133,11 +132,9 @@ class WorkerBase(BaseModel): description="Timeout for the execution of the commands in the worker " "(e.g. submitting a job)", ) + model_config = ConfigDict(extra="forbid") - class Config: - extra = Extra.forbid - - @validator("scheduler_type", always=True) + @field_validator("scheduler_type") def check_scheduler_type(cls, scheduler_type: str, values: dict) -> str: """ Validator to set the default of scheduler_type @@ -146,7 +143,7 @@ def check_scheduler_type(cls, scheduler_type: str, values: dict) -> str: raise ValueError(f"Unknown scheduler type {scheduler_type}") return scheduler_type - @validator("work_dir", always=True) + @field_validator("work_dir") def check_work_dir(cls, v) -> Path: if not v.is_absolute(): raise ValueError("`work_dir` must be an absolute path") @@ -232,8 +229,8 @@ class RemoteWorker(WorkerBase): "remote", description="The discriminator field to determine the worker type" ) host: str = Field(description="The host to which to connect") - user: str = Field(None, description="Login username") - port: int = Field(None, description="Port number") + user: str | None = Field(None, description="Login username") + port: int | None = Field(None, description="Port number") password: str | None = Field(None, description="Login password") key_filename: str | list[str] | None = Field( None, @@ -243,18 +240,20 @@ class RemoteWorker(WorkerBase): passphrase: str | None = Field( None, description="Passphrase used for decrypting private keys" ) - gateway: str = Field( + gateway: str | None = Field( None, description="A shell command string to use as a proxy or gateway" ) - forward_agent: bool = Field( + forward_agent: bool | None = Field( None, description="Whether to enable SSH agent forwarding" ) - connect_timeout: int = Field(None, description="Connection timeout, in seconds") - connect_kwargs: dict = Field( + connect_timeout: int | None = Field( + None, description="Connection timeout, in seconds" + ) + connect_kwargs: dict | None = Field( None, description="Other keyword arguments passed to paramiko.client.SSHClient.connect", ) - inline_ssh_env: bool = Field( + inline_ssh_env: bool | None = Field( None, description="Whether to send environment variables 'inline' as prefixes in " "front of command strings", @@ -336,9 +335,7 @@ class ExecutionConfig(BaseModel): post_run: str | None = Field( None, description="Commands to be executed after the execution of a job" ) - - class Config: - extra = Extra.forbid + model_config = ConfigDict(extra="forbid") class Project(BaseModel): @@ -351,19 +348,23 @@ class Project(BaseModel): None, description="The base directory containing the project related files. Default " "is a folder with the project name inside the projects folder", + validate_default=True, ) tmp_dir: str | None = Field( None, description="Folder where remote files are copied. Default a 'tmp' folder in base_dir", + validate_default=True, ) log_dir: str | None = Field( None, description="Folder containing all the logs. Default a 'log' folder in base_dir", + validate_default=True, ) daemon_dir: str | None = Field( None, description="Folder containing daemon related files. Default to a 'daemon' " "folder in base_dir", + validate_default=True, ) log_level: LogLevel = Field(LogLevel.INFO, description="The level set for logging") runner: RunnerOptions = Field( @@ -379,6 +380,7 @@ class Project(BaseModel): description="Dictionary describing a maggma Store used for the queue data. " "Can contain the monty serialized dictionary or a dictionary with a 'type' " "specifying the Store subclass", + validate_default=True, ) exec_config: dict[str, ExecutionConfig] = Field( default_factory=dict, @@ -389,6 +391,7 @@ class Project(BaseModel): default_factory=lambda: dict(DEFAULT_JOBSTORE), description="The JobStore used for the input. Can contain the monty " "serialized dictionary or the Store int the Jobflow format", + validate_default=True, ) metadata: dict | None = Field( None, description="A dictionary with metadata associated to the project" @@ -429,7 +432,7 @@ def get_launchpad(self) -> RemoteLaunchPad: """ return RemoteLaunchPad(self.get_queue_store()) - @validator("base_dir", always=True) + @field_validator("base_dir") def check_base_dir(cls, base_dir: str, values: dict) -> str: """ Validator to set the default of base_dir based on the project name @@ -440,7 +443,7 @@ def check_base_dir(cls, base_dir: str, values: dict) -> str: return str(Path(SETTINGS.projects_folder, values["name"])) return base_dir - @validator("tmp_dir", always=True) + @field_validator("tmp_dir") def check_tmp_dir(cls, tmp_dir: str, values: dict) -> str: """ Validator to set the default of tmp_dir based on the base_dir @@ -449,7 +452,7 @@ def check_tmp_dir(cls, tmp_dir: str, values: dict) -> str: return str(Path(values["base_dir"], "tmp")) return tmp_dir - @validator("log_dir", always=True) + @field_validator("log_dir") def check_log_dir(cls, log_dir: str, values: dict) -> str: """ Validator to set the default of log_dir based on the base_dir @@ -458,7 +461,7 @@ def check_log_dir(cls, log_dir: str, values: dict) -> str: return str(Path(values["base_dir"], "log")) return log_dir - @validator("daemon_dir", always=True) + @field_validator("daemon_dir") def check_daemon_dir(cls, daemon_dir: str, values: dict) -> str: """ Validator to set the default of daemon_dir based on the base_dir @@ -467,7 +470,7 @@ def check_daemon_dir(cls, daemon_dir: str, values: dict) -> str: return str(Path(values["base_dir"], "daemon")) return daemon_dir - @validator("jobstore", always=True) + @field_validator("jobstore") def check_jobstore(cls, jobstore: dict, values: dict) -> dict: """ Check that the jobstore configuration could be converted to a JobStore. @@ -484,7 +487,7 @@ def check_jobstore(cls, jobstore: dict, values: dict) -> dict: ) from e return jobstore - @validator("queue", always=True) + @field_validator("queue") def check_queue(cls, queue: dict, values: dict) -> dict: """ Check that the queue configuration could be converted to a Store. @@ -498,8 +501,7 @@ def check_queue(cls, queue: dict, values: dict) -> dict: ) from e return queue - class Config: - extra = Extra.forbid + model_config = ConfigDict(extra="forbid") class ConfigError(Exception): diff --git a/src/jobflow_remote/config/settings.py b/src/jobflow_remote/config/settings.py index 4b0610b1..1ed64db3 100644 --- a/src/jobflow_remote/config/settings.py +++ b/src/jobflow_remote/config/settings.py @@ -2,7 +2,8 @@ from pathlib import Path -from pydantic import BaseSettings, Field, root_validator +from pydantic import Field, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict DEFAULT_PROJECTS_FOLDER = Path("~/.jfremote").expanduser().as_posix() @@ -18,7 +19,7 @@ class JobflowRemoteSettings(BaseSettings): projects_folder: str = Field( DEFAULT_PROJECTS_FOLDER, description="Location of the projects files." ) - project: str = Field(None, description="The name of the project used.") + project: str | None = Field(None, description="The name of the project used.") cli_full_exc: bool = Field( False, description="If True prints the full stack trace of the exception when raised in the CLI.", @@ -26,13 +27,10 @@ class JobflowRemoteSettings(BaseSettings): cli_suggestions: bool = Field( True, description="If True prints some suggestions in the CLI commands." ) + model_config = SettingsConfigDict(env_prefix="jfremote_") - class Config: - """Pydantic config settings.""" - - env_prefix = "jfremote_" - - @root_validator(pre=True) + @model_validator(mode="before") + @classmethod def load_default_settings(cls, values): """ Load settings from file or environment variables. From 5fe779f90fa490686ee81da69c1d0dfd8daab408 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Thu, 12 Oct 2023 01:00:13 +0200 Subject: [PATCH 02/18] fix settings for python 3.9 --- src/jobflow_remote/config/settings.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/jobflow_remote/config/settings.py b/src/jobflow_remote/config/settings.py index f64d5d43..ca73c4a9 100644 --- a/src/jobflow_remote/config/settings.py +++ b/src/jobflow_remote/config/settings.py @@ -1,6 +1,5 @@ -from __future__ import annotations - from pathlib import Path +from typing import Optional from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -18,7 +17,7 @@ class JobflowRemoteSettings(BaseSettings): projects_folder: str = Field( DEFAULT_PROJECTS_FOLDER, description="Location of the projects files." ) - project: str | None = Field(None, description="The name of the project used.") + project: Optional[str] = Field(None, description="The name of the project used.") cli_full_exc: bool = Field( False, description="If True prints the full stack trace of the exception when raised in the CLI.", From d37a417b7b77b4893a8f269bb9a5d17b5b15311d Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Fri, 10 Nov 2023 14:15:17 +0100 Subject: [PATCH 03/18] New queue handling --- src/jobflow_remote/cli/__init__.py | 1 + src/jobflow_remote/cli/admin.py | 46 +- src/jobflow_remote/cli/execution.py | 31 + src/jobflow_remote/cli/flow.py | 10 +- src/jobflow_remote/cli/formatting.py | 55 +- src/jobflow_remote/cli/jf.py | 28 +- src/jobflow_remote/cli/jfr_typer.py | 3 + src/jobflow_remote/cli/job.py | 534 +++++- src/jobflow_remote/cli/project.py | 7 +- src/jobflow_remote/cli/types.py | 55 +- src/jobflow_remote/cli/utils.py | 193 +- src/jobflow_remote/config/__init__.py | 1 - src/jobflow_remote/config/base.py | 42 +- src/jobflow_remote/config/settings.py | 6 + src/jobflow_remote/fireworks/__init__.py | 0 src/jobflow_remote/fireworks/convert.py | 177 -- src/jobflow_remote/fireworks/launcher.py | 70 - src/jobflow_remote/fireworks/launchpad.py | 796 --------- src/jobflow_remote/fireworks/tasks.py | 120 -- src/jobflow_remote/jobs/daemon.py | 27 +- src/jobflow_remote/jobs/data.py | 450 +++-- src/jobflow_remote/jobs/jobcontroller.py | 1983 ++++++++++++++++++--- src/jobflow_remote/jobs/run.py | 86 + src/jobflow_remote/jobs/runner.py | 513 ++---- src/jobflow_remote/jobs/state.py | 149 +- src/jobflow_remote/jobs/submit.py | 11 +- src/jobflow_remote/remote/data.py | 19 +- src/jobflow_remote/remote/host/base.py | 21 - src/jobflow_remote/remote/host/remote.py | 21 + src/jobflow_remote/remote/queue.py | 4 +- src/jobflow_remote/utils/db.py | 167 +- src/jobflow_remote/utils/log.py | 73 +- 32 files changed, 3356 insertions(+), 2343 deletions(-) create mode 100644 src/jobflow_remote/cli/execution.py delete mode 100644 src/jobflow_remote/fireworks/__init__.py delete mode 100644 src/jobflow_remote/fireworks/convert.py delete mode 100644 src/jobflow_remote/fireworks/launcher.py delete mode 100644 src/jobflow_remote/fireworks/launchpad.py delete mode 100644 src/jobflow_remote/fireworks/tasks.py create mode 100644 src/jobflow_remote/jobs/run.py diff --git a/src/jobflow_remote/cli/__init__.py b/src/jobflow_remote/cli/__init__.py index 6a4dede2..ffb5fa72 100644 --- a/src/jobflow_remote/cli/__init__.py +++ b/src/jobflow_remote/cli/__init__.py @@ -1,5 +1,6 @@ # Import the submodules with a local app to register them to the main app import jobflow_remote.cli.admin +import jobflow_remote.cli.execution import jobflow_remote.cli.flow import jobflow_remote.cli.job import jobflow_remote.cli.project diff --git a/src/jobflow_remote/cli/admin.py b/src/jobflow_remote/cli/admin.py index cd7ad785..f8b16c4f 100644 --- a/src/jobflow_remote/cli/admin.py +++ b/src/jobflow_remote/cli/admin.py @@ -11,19 +11,16 @@ force_opt, job_ids_indexes_opt, job_state_opt, - remote_state_opt, start_date_opt, ) from jobflow_remote.cli.utils import ( - check_incompatible_opt, - exit_with_error_msg, + check_stopped_runner, + get_config_manager, + get_job_controller, get_job_ids_indexes, loading_spinner, out_console, ) -from jobflow_remote.config import ConfigManager -from jobflow_remote.jobs.daemon import DaemonError, DaemonManager, DaemonStatus -from jobflow_remote.jobs.jobcontroller import JobController app_admin = JFRTyper( name="admin", help="Commands for administering the database", no_args_is_help=True @@ -36,7 +33,7 @@ def reset( reset_output: Annotated[ bool, typer.Option( - "--reset-output", + "--output", "-o", help="Also delete all the documents in the current store", ), @@ -44,8 +41,8 @@ def reset( max_limit: Annotated[ int, typer.Option( - "--max-limit", - "-max", + "--max", + "-m", help=( "The database will be reset only if the number of Flows is lower than the specified limit. 0 means no limit" ), @@ -59,26 +56,10 @@ def reset( """ from jobflow_remote import SETTINGS - dm = DaemonManager() - - try: - with loading_spinner(False) as progress: - progress.add_task(description="Checking the Daemon status...", total=None) - current_status = dm.check_status() - - except DaemonError as e: - exit_with_error_msg( - f"Error while checking the status of the daemon: {getattr(e, 'message', str(e))}" - ) - - if current_status not in (DaemonStatus.STOPPED, DaemonStatus.SHUT_DOWN): - exit_with_error_msg( - f"The status of the daemon is {current_status.value}. " - "The daemon should not be running while resetting the database" - ) + check_stopped_runner(error=True) if not force: - cm = ConfigManager() + cm = get_config_manager() project_name = cm.get_project_data().project.name text = Text.from_markup( "[red]This operation will [bold]delete all the Jobs data[/bold] " @@ -90,7 +71,7 @@ def reset( raise typer.Exit(0) with loading_spinner(False) as progress: progress.add_task(description="Resetting the DB...", total=None) - jc = JobController() + jc = get_job_controller() done = jc.reset(reset_output=reset_output, max_limit=max_limit) not_text = "" if done else "[bold]NOT [/bold]" out_console.print(f"The database was {not_text}reset") @@ -106,7 +87,6 @@ def remove_lock( job_id: job_ids_indexes_opt = None, db_id: db_ids_opt = None, state: job_state_opt = None, - remote_state: remote_state_opt = None, start_date: start_date_opt = None, end_date: end_date_opt = None, force: force_opt = False, @@ -115,11 +95,11 @@ def remove_lock( Forcibly removes the lock from the documents of the selected jobs. WARNING: can lead to inconsistencies if the processes is actually running """ - check_incompatible_opt({"state": state, "remote-state": remote_state}) job_ids_indexes = get_job_ids_indexes(job_id) - jc = JobController() + jc = get_job_controller() + if not force: with loading_spinner(False) as progress: progress.add_task( @@ -130,7 +110,6 @@ def remove_lock( job_ids=job_ids_indexes, db_ids=db_id, state=state, - remote_state=remote_state, start_date=start_date, locked=True, end_date=end_date, @@ -149,11 +128,10 @@ def remove_lock( description="Checking the number of locked documents...", total=None ) - num_unlocked = jc.remove_lock( + num_unlocked = jc.remove_lock_job( job_ids=job_id, db_ids=db_id, state=state, - remote_state=remote_state, start_date=start_date, end_date=end_date, ) diff --git a/src/jobflow_remote/cli/execution.py b/src/jobflow_remote/cli/execution.py new file mode 100644 index 00000000..69596927 --- /dev/null +++ b/src/jobflow_remote/cli/execution.py @@ -0,0 +1,31 @@ +from typing import Optional + +import typer +from typing_extensions import Annotated + +from jobflow_remote.cli.jf import app +from jobflow_remote.cli.jfr_typer import JFRTyper +from jobflow_remote.jobs.run import run_remote_job + +app_execution = JFRTyper( + name="execution", + help="Commands for executing the jobs locally", + no_args_is_help=True, + hidden=True, +) +app.add_typer(app_execution) + + +@app_execution.command() +def run( + run_dir: Annotated[ + Optional[str], + typer.Argument( + help="The path to the folder where the files to job will be executed", + ), + ] = ".", +): + """ + Run the Job in the selected folder based on the + """ + run_remote_job(run_dir) diff --git a/src/jobflow_remote/cli/flow.py b/src/jobflow_remote/cli/flow.py index a13a2a6e..a8405f76 100644 --- a/src/jobflow_remote/cli/flow.py +++ b/src/jobflow_remote/cli/flow.py @@ -29,12 +29,12 @@ check_incompatible_opt, exit_with_error_msg, exit_with_warning_msg, + get_job_controller, get_job_db_ids, get_start_date, loading_spinner, out_console, ) -from jobflow_remote.jobs.jobcontroller import JobController app_flow = JFRTyper( name="flow", help="Commands for managing the flows", no_args_is_help=True @@ -64,7 +64,7 @@ def flows_list( check_incompatible_opt({"start_date": start_date, "days": days, "hours": hours}) check_incompatible_opt({"end_date": end_date, "days": days, "hours": hours}) - jc = JobController() + jc = get_job_controller() start_date = get_start_date(start_date, days, hours) @@ -81,6 +81,7 @@ def flows_list( name=name, limit=max_results, sort=sort, + full=verbosity > 0, ) table = get_flow_info_table(flows_info, verbosity=verbosity) @@ -116,7 +117,7 @@ def delete( start_date = get_start_date(start_date, days, hours) - jc = JobController() + jc = get_job_controller() with loading_spinner(False) as progress: progress.add_task(description="Fetching data...", total=None) @@ -172,13 +173,14 @@ def flow_info( with loading_spinner(): - jc = JobController() + jc = get_job_controller() flows_info = jc.get_flows_info( job_ids=job_ids, db_ids=db_ids, flow_ids=flow_ids, limit=1, + full=True, ) if not flows_info: exit_with_error_msg("No data matching the request") diff --git a/src/jobflow_remote/cli/formatting.py b/src/jobflow_remote/cli/formatting.py index 7f942481..aadc5fd9 100644 --- a/src/jobflow_remote/cli/formatting.py +++ b/src/jobflow_remote/cli/formatting.py @@ -1,7 +1,5 @@ from __future__ import annotations -from dataclasses import asdict - from monty.json import jsanitize from rich.scope import render_scope from rich.table import Table @@ -10,15 +8,14 @@ from jobflow_remote.cli.utils import ReprStr, fmt_datetime from jobflow_remote.config.base import ExecutionConfig, WorkerBase from jobflow_remote.jobs.data import FlowInfo, JobInfo -from jobflow_remote.jobs.state import JobState, RemoteState -from jobflow_remote.utils.data import remove_none +from jobflow_remote.jobs.state import JobState def get_job_info_table(jobs_info: list[JobInfo], verbosity: int): table = Table(title="Jobs info") table.add_column("DB id") table.add_column("Name") - table.add_column("State [Remote]") + table.add_column("State") table.add_column("Job id (Index)") table.add_column("Worker") @@ -36,28 +33,25 @@ def get_job_info_table(jobs_info: list[JobInfo], verbosity: int): table.add_column("Lock id") table.add_column("Lock time") - excluded_states = (JobState.COMPLETED, JobState.PAUSED) for ji in jobs_info: state = ji.state.name - if ji.remote_state is not None and ji.state not in excluded_states: - if ji.retry_time_limit is not None: - state += f" [[bold red]{ji.remote_state.name}[/]]" - else: - state += f" [{ji.remote_state.name}]" + + if ji.remote.retry_time_limit is not None: + state = f"[bold red]{state}[/]" row = [ str(ji.db_id), ji.name, Text.from_markup(state), - f"{ji.job_id} ({ji.job_index})", + f"{ji.uuid} ({ji.index})", ji.worker, - ji.last_updated.strftime(fmt_datetime), + ji.updated_on.strftime(fmt_datetime), ] if verbosity >= 1: - row.append(ji.queue_job_id) + row.append(ji.remote.process_id) prefix = "" - if ji.remote_state == RemoteState.RUNNING: + if ji.state == JobState.RUNNING: run_time = ji.estimated_run_time prefix = "~" else: @@ -69,13 +63,11 @@ def get_job_info_table(jobs_info: list[JobInfo], verbosity: int): else: row.append("") row.append( - ji.retry_time_limit.strftime(fmt_datetime) - if ji.retry_time_limit + ji.remote.retry_time_limit.strftime(fmt_datetime) + if ji.remote.retry_time_limit else None ) - row.append( - ji.remote_previous_state.name if ji.remote_previous_state else None - ) + row.append(ji.previous_state.name if ji.previous_state else None) if verbosity < 2: row.append("*" if ji.lock_id is not None else None) @@ -112,7 +104,7 @@ def get_flow_info_table(flows_info: list[FlowInfo], verbosity: int): fi.state.name, fi.flow_id, str(len(fi.job_ids)), - fi.last_updated.strftime(fmt_datetime), + fi.updated_on.strftime(fmt_datetime), ] if verbosity >= 1: @@ -127,17 +119,17 @@ def get_flow_info_table(flows_info: list[FlowInfo], verbosity: int): def format_job_info(job_info: JobInfo, show_none: bool = False): - d = asdict(job_info) - if not show_none: - d = remove_none(d) + d = job_info.dict(exclude_none=True) d = jsanitize(d, allow_bson=False, enum_values=True) - error_remote = d.get("error_remote") - if error_remote: - d["error_remote"] = ReprStr(error_remote) - error_job = d.get("error_job") - if error_job: - d["error_job"] = ReprStr(error_job) + error = d.get("error") + if error: + d["error"] = ReprStr(error) + + remote_error = d["remote"].get("error") + if remote_error: + d["remote"]["error"] = ReprStr(remote_error) + return render_scope(d) @@ -218,8 +210,7 @@ def get_worker_table(workers: dict[str, WorkerBase], verbosity: int = 0): if verbosity == 1: row.append(render_scope(worker.cli_info)) elif verbosity > 1: - d = worker.dict() - d = remove_none(d) + d = worker.dict(exclude_none=True) d = jsanitize(d, allow_bson=False, enum_values=True) row.append(render_scope(d)) diff --git a/src/jobflow_remote/cli/jf.py b/src/jobflow_remote/cli/jf.py index c1a4033c..99b087c2 100644 --- a/src/jobflow_remote/cli/jf.py +++ b/src/jobflow_remote/cli/jf.py @@ -3,8 +3,15 @@ from typing_extensions import Annotated from jobflow_remote.cli.jfr_typer import JFRTyper -from jobflow_remote.cli.utils import exit_with_error_msg, out_console -from jobflow_remote.config import ConfigError, ConfigManager +from jobflow_remote.cli.utils import ( + cleanup_job_controller, + exit_with_error_msg, + get_config_manager, + initialize_config_manager, + out_console, +) +from jobflow_remote.config import ConfigError +from jobflow_remote.utils.log import initialize_cli_logger app = JFRTyper( name="jf", @@ -15,7 +22,7 @@ ) -@app.callback() +@app.callback(result_callback=cleanup_job_controller) def main( project: Annotated[ str, @@ -41,7 +48,17 @@ def main( """ from jobflow_remote import SETTINGS - cm = ConfigManager() + if full_exc: + SETTINGS.cli_full_exc = True + + initialize_cli_logger( + level=SETTINGS.cli_log_level.to_logging(), full_exc_info=SETTINGS.cli_full_exc + ) + + # initialize the ConfigManager only once, to avoid parsing the configuration + # files multiple times when the command is executed. + initialize_config_manager() + cm = get_config_manager() if project: if project not in cm.projects_data: exit_with_error_msg( @@ -50,9 +67,6 @@ def main( SETTINGS.project = project - if full_exc: - SETTINGS.cli_full_exc = True - try: project_data = cm.get_project_data() text = Text.from_markup( diff --git a/src/jobflow_remote/cli/jfr_typer.py b/src/jobflow_remote/cli/jfr_typer.py index 1fd9b966..e20ce659 100644 --- a/src/jobflow_remote/cli/jfr_typer.py +++ b/src/jobflow_remote/cli/jfr_typer.py @@ -20,6 +20,9 @@ def __init__(self, *args, **kwargs): if "rich_markup_mode" not in kwargs: kwargs["rich_markup_mode"] = "rich" + # if "result_callback" not in kwargs: + # kwargs["result_callback"] = test_cb + super().__init__(*args, **kwargs) def command( diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index 07f35780..cddc2518 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -2,6 +2,7 @@ from pathlib import Path import typer +from qtoolkit.core.data_objects import QResources from typing_extensions import Annotated from jobflow_remote import SETTINGS @@ -9,6 +10,7 @@ from jobflow_remote.cli.jf import app from jobflow_remote.cli.jfr_typer import JFRTyper from jobflow_remote.cli.types import ( + break_lock_opt, days_opt, db_ids_opt, end_date_opt, @@ -17,35 +19,36 @@ job_db_id_arg, job_ids_indexes_opt, job_index_arg, + job_state_arg, job_state_opt, locked_opt, max_results_opt, metadata_opt, name_opt, query_opt, - remote_state_arg, - remote_state_opt, + raise_on_error_opt, reverse_sort_flag_opt, sort_opt, start_date_opt, verbosity_opt, + wait_lock_opt, ) from jobflow_remote.cli.utils import ( SortOption, check_incompatible_opt, - convert_metadata, + check_stopped_runner, + execute_multi_jobs_cmd, exit_with_error_msg, - exit_with_warning_msg, + get_config_manager, + get_job_controller, get_job_db_ids, get_job_ids_indexes, get_start_date, loading_spinner, out_console, print_success_msg, + str_to_dict, ) -from jobflow_remote.config import ConfigManager -from jobflow_remote.jobs.jobcontroller import JobController -from jobflow_remote.jobs.state import RemoteState from jobflow_remote.remote.queue import ERR_FNAME, OUT_FNAME app_job = JFRTyper( @@ -60,7 +63,6 @@ def jobs_list( db_id: db_ids_opt = None, flow_id: flow_ids_opt = None, state: job_state_opt = None, - remote_state: remote_state_opt = None, start_date: start_date_opt = None, end_date: end_date_opt = None, name: name_opt = None, @@ -77,14 +79,13 @@ def jobs_list( """ Get the list of Jobs in the database """ - check_incompatible_opt({"state": state, "remote-state": remote_state}) check_incompatible_opt({"start_date": start_date, "days": days, "hours": hours}) check_incompatible_opt({"end_date": end_date, "days": days, "hours": hours}) - metadata_dict = convert_metadata(metadata) + metadata_dict = str_to_dict(metadata) job_ids_indexes = get_job_ids_indexes(job_id) - jc = JobController() + jc = get_job_controller() start_date = get_start_date(start_date, days, hours) @@ -103,7 +104,6 @@ def jobs_list( db_ids=db_id, flow_ids=flow_id, state=state, - remote_state=remote_state, start_date=start_date, locked=locked, end_date=end_date, @@ -122,11 +122,11 @@ def jobs_list( f"The number of Jobs printed may be limited by the maximum selected: {max_results}", style="yellow", ) - if any(ji.retry_time_limit is not None for ji in jobs_info): + if any(ji.remote.retry_time_limit is not None for ji in jobs_info): text = ( - "Some jobs (remote state in red) have failed while interacting with" + "Some jobs (state in red) have failed while interacting with" " the worker, but will be retried again.\nGet more information about" - " the error with 'jf job info -err JOB_ID'" + " the error with 'jf job info JOB_ID'" ) out_console.print(text, style="yellow") @@ -140,7 +140,7 @@ def job_info( typer.Option( "--with-error", "-err", - help="Also fetch and display information about errors", + help="DEPRECATED: not needed anymore to fetch errors", ), ] = False, show_none: Annotated[ @@ -158,15 +158,20 @@ def job_info( db_id, job_id = get_job_db_ids(job_db_id, job_index) + if with_error: + out_console.print( + "The --with-error option is deprecated and not needed anymore to show error messages", + style="yellow", + ) + with loading_spinner(): - jc = JobController() + jc = get_job_controller() job_info = jc.get_job_info( job_id=job_id, job_index=job_index, db_id=db_id, - full=with_error, ) if not job_info: exit_with_error_msg("No data matching the request") @@ -175,116 +180,291 @@ def job_info( @app_job.command() -def reset_failed( +def set_state( + state: job_state_arg, job_db_id: job_db_id_arg, job_index: job_index_arg = None, ): """ - For a job with a FAILED remote state reset it to the previous state + Sets the state of a Job to an arbitrary value. + WARNING: No checks. This can lead to inconsistencies in the DB. Use with care """ db_id, job_id = get_job_db_ids(job_db_id, job_index) with loading_spinner(): - jc = JobController() + jc = get_job_controller() - succeeded = jc.reset_failed_state( + succeeded = jc.set_job_state( + state=state, job_id=job_id, job_index=job_index, db_id=db_id, ) if not succeeded: - exit_with_error_msg("Could not reset failed state") + exit_with_error_msg("Could not reset the remote attempts") print_success_msg() @app_job.command() -def reset_remote_attempts( - job_db_id: job_db_id_arg, +def rerun( + job_db_id: job_db_id_arg = None, job_index: job_index_arg = None, + job_id: job_ids_indexes_opt = None, + db_id: db_ids_opt = None, + flow_id: flow_ids_opt = None, + state: job_state_opt = None, + start_date: start_date_opt = None, + end_date: end_date_opt = None, + name: name_opt = None, + metadata: metadata_opt = None, + days: days_opt = None, + hours: hours_opt = None, + verbosity: verbosity_opt = 0, + wait: wait_lock_opt = None, + break_lock: break_lock_opt = False, + force: Annotated[ + bool, + typer.Option( + "--force", + "-f", + help=( + "Force the rerun even if some conditions would normally prevent it (e.g. " + "state usually not allowed or children already executed). Can lead to " + "inconsistencies. Advanced users." + ), + ), + ] = False, + raise_on_error: raise_on_error_opt = False, ): """ - Resets the number of attempts to perform a remote action and eliminates - the delay in retrying. This will not restore a Job from its failed state. + Rerun a Job. By default, this is limited to jobs that failed and children did + not start or jobs that are running. The rerun Job is set to READY and children + Jobs to WAITING. If possible, the associated job submitted to the remote queue + will be cancelled. Most of the limitations can be overridden by the 'force' + option. This could lead to inconsistencies in the overall state of the Jobs of + the Flow. """ - - db_id, job_id = get_job_db_ids(job_db_id, job_index) - - with loading_spinner(): - jc = JobController() - - succeeded = jc.reset_remote_attempts( - job_id=job_id, - job_index=job_index, - db_id=db_id, - ) - - if not succeeded: - exit_with_error_msg("Could not reset the remote attempts") - - print_success_msg() + if force or break_lock: + check_stopped_runner(error=False) + + jc = get_job_controller() + + execute_multi_jobs_cmd( + single_cmd=jc.rerun_job, + multi_cmd=jc.rerun_jobs, + job_db_id=job_db_id, + job_index=job_index, + job_ids=job_id, + db_ids=db_id, + flow_ids=flow_id, + state=state, + start_date=start_date, + end_date=end_date, + name=name, + metadata=metadata, + days=days, + hours=hours, + verbosity=verbosity, + wait=wait, + break_lock=break_lock, + force=force, + raise_on_error=raise_on_error, + ) @app_job.command() -def set_remote_state( - state: remote_state_arg, - job_db_id: job_db_id_arg, +def retry( + job_db_id: job_db_id_arg = None, job_index: job_index_arg = None, + job_id: job_ids_indexes_opt = None, + db_id: db_ids_opt = None, + flow_id: flow_ids_opt = None, + state: job_state_opt = None, + start_date: start_date_opt = None, + end_date: end_date_opt = None, + name: name_opt = None, + metadata: metadata_opt = None, + days: days_opt = None, + hours: hours_opt = None, + verbosity: verbosity_opt = 0, + wait: wait_lock_opt = None, + break_lock: break_lock_opt = False, + raise_on_error: raise_on_error_opt = False, ): """ - Sets the remote state to an arbitrary value. - WARNING: this can lead to inconsistencies in the DB. Use with care + Retry to perform the operation that failed for a job in a REMOTE_ERROR state + or reset the number of attempts at remote action, in order to allow the + runner to try it again immediately. """ + if break_lock: + check_stopped_runner(error=False) + + jc = get_job_controller() + + execute_multi_jobs_cmd( + single_cmd=jc.retry_job, + multi_cmd=jc.retry_jobs, + job_db_id=job_db_id, + job_index=job_index, + job_ids=job_id, + db_ids=db_id, + flow_ids=flow_id, + state=state, + start_date=start_date, + end_date=end_date, + name=name, + metadata=metadata, + days=days, + hours=hours, + verbosity=verbosity, + wait=wait, + break_lock=break_lock, + raise_on_error=raise_on_error, + ) - db_id, job_id = get_job_db_ids(job_db_id, job_index) - with loading_spinner(): - jc = JobController() - - succeeded = jc.set_remote_state( - state=state, - job_id=job_id, - job_index=job_index, - db_id=db_id, - ) - - if not succeeded: - exit_with_error_msg("Could not reset the remote attempts") +@app_job.command() +def pause( + job_db_id: job_db_id_arg = None, + job_index: job_index_arg = None, + job_id: job_ids_indexes_opt = None, + db_id: db_ids_opt = None, + flow_id: flow_ids_opt = None, + state: job_state_opt = None, + start_date: start_date_opt = None, + end_date: end_date_opt = None, + name: name_opt = None, + metadata: metadata_opt = None, + days: days_opt = None, + hours: hours_opt = None, + verbosity: verbosity_opt = 0, + wait: wait_lock_opt = None, + raise_on_error: raise_on_error_opt = False, +): + """ + Pause a Job. Only READY and WAITING Jobs can be paused. The operation is reversible. + """ - print_success_msg() + jc = get_job_controller() + + execute_multi_jobs_cmd( + single_cmd=jc.pause_job, + multi_cmd=jc.pause_jobs, + job_db_id=job_db_id, + job_index=job_index, + job_ids=job_id, + db_ids=db_id, + flow_ids=flow_id, + state=state, + start_date=start_date, + end_date=end_date, + name=name, + metadata=metadata, + days=days, + hours=hours, + verbosity=verbosity, + wait=wait, + raise_on_error=raise_on_error, + ) @app_job.command() -def rerun( +def play( + job_db_id: job_db_id_arg = None, + job_index: job_index_arg = None, job_id: job_ids_indexes_opt = None, db_id: db_ids_opt = None, + flow_id: flow_ids_opt = None, state: job_state_opt = None, - remote_state: remote_state_opt = None, start_date: start_date_opt = None, end_date: end_date_opt = None, + name: name_opt = None, + metadata: metadata_opt = None, + days: days_opt = None, + hours: hours_opt = None, + verbosity: verbosity_opt = 0, + wait: wait_lock_opt = None, + raise_on_error: raise_on_error_opt = False, ): """ - Rerun Jobs + Resume a Job that was previously PAUSED. """ - check_incompatible_opt({"state": state, "remote-state": remote_state}) - - job_ids_indexes = get_job_ids_indexes(job_id) - jc = JobController() + jc = get_job_controller() + + execute_multi_jobs_cmd( + single_cmd=jc.play_job, + multi_cmd=jc.play_jobs, + job_db_id=job_db_id, + job_index=job_index, + job_ids=job_id, + db_ids=db_id, + flow_ids=flow_id, + state=state, + start_date=start_date, + end_date=end_date, + name=name, + metadata=metadata, + days=days, + hours=hours, + verbosity=verbosity, + wait=wait, + raise_on_error=raise_on_error, + ) - with loading_spinner(): - fw_ids = jc.rerun_jobs( - job_ids=job_ids_indexes, - db_ids=db_id, - state=state, - remote_state=remote_state, - start_date=start_date, - end_date=end_date, - ) - out_console.print(f"{len(fw_ids)} Jobs were rerun: {fw_ids}") +@app_job.command() +def cancel( + job_db_id: job_db_id_arg = None, + job_index: job_index_arg = None, + job_id: job_ids_indexes_opt = None, + db_id: db_ids_opt = None, + flow_id: flow_ids_opt = None, + state: job_state_opt = None, + start_date: start_date_opt = None, + end_date: end_date_opt = None, + name: name_opt = None, + metadata: metadata_opt = None, + days: days_opt = None, + hours: hours_opt = None, + verbosity: verbosity_opt = 0, + wait: wait_lock_opt = None, + break_lock: break_lock_opt = False, + raise_on_error: raise_on_error_opt = False, +): + """ + Cancel a Job. Only Jobs that did not complete or had an error can be cancelled. + The operation is irreversible. + If possible, the associated job submitted to the remote queue will be cancelled. + """ + if break_lock: + check_stopped_runner(error=False) + + jc = get_job_controller() + + execute_multi_jobs_cmd( + single_cmd=jc.cancel_job, + multi_cmd=jc.cancel_jobs, + job_db_id=job_db_id, + job_index=job_index, + job_ids=job_id, + db_ids=db_id, + flow_ids=flow_id, + state=state, + start_date=start_date, + end_date=end_date, + name=name, + metadata=metadata, + days=days, + hours=hours, + verbosity=verbosity, + wait=wait, + break_lock=break_lock, + raise_on_error=raise_on_error, + ) @app_job.command() @@ -298,9 +478,11 @@ def queue_out( db_id, job_id = get_job_db_ids(job_db_id, job_index) + cm = get_config_manager() + with loading_spinner(processing=False) as progress: progress.add_task(description="Retrieving info...", total=None) - jc = JobController() + jc = get_job_controller() job_info = jc.get_job_info( job_id=job_id, @@ -311,20 +493,6 @@ def queue_out( if not job_info: exit_with_error_msg("No data matching the request") - if job_info.remote_state not in ( - RemoteState.RUNNING, - RemoteState.TERMINATED, - RemoteState.DOWNLOADED, - RemoteState.COMPLETED, - RemoteState.FAILED, - ): - remote_state_str = ( - f"[{job_info.remote_state.value}]" if job_info.remote_state else "" - ) - exit_with_warning_msg( - f"The Job is in state {job_info.state.value}{remote_state_str} and the queue output will not be present" - ) - remote_dir = job_info.run_dir out_path = Path(remote_dir, OUT_FNAME) @@ -335,7 +503,6 @@ def queue_out( err_error = None with loading_spinner(processing=False) as progress: progress.add_task(description="Retrieving files...", total=None) - cm = ConfigManager() worker = cm.get_worker(job_info.worker) host = worker.get_host() @@ -376,3 +543,180 @@ def queue_out( else: out_console.print(f"Queue error from {str(err_path)}:\n") out_console.print(err) + + +app_job_set = JFRTyper( + name="set", help="Commands for managing the jobs", no_args_is_help=True +) +app_job.add_typer(app_job_set) + + +@app_job_set.command() +def worker( + worker_name: Annotated[ + str, + typer.Argument( + help="The name of the worker", + metavar="WORKER", + ), + ], + job_id: job_ids_indexes_opt = None, + db_id: db_ids_opt = None, + flow_id: flow_ids_opt = None, + state: job_state_opt = None, + start_date: start_date_opt = None, + end_date: end_date_opt = None, + name: name_opt = None, + metadata: metadata_opt = None, + days: days_opt = None, + hours: hours_opt = None, + verbosity: verbosity_opt = 0, + raise_on_error: raise_on_error_opt = False, +): + """ + Set the worker for the selected Jobs. Only READY or WAITING Jobs. + """ + + jc = get_job_controller() + execute_multi_jobs_cmd( + single_cmd=jc.set_job_run_properties, + multi_cmd=jc.set_job_run_properties, + job_db_id=None, + job_index=None, + job_ids=job_id, + db_ids=db_id, + flow_ids=flow_id, + state=state, + start_date=start_date, + end_date=end_date, + name=name, + metadata=metadata, + days=days, + hours=hours, + verbosity=verbosity, + raise_on_error=raise_on_error, + worker=worker_name, + ) + + +@app_job_set.command() +def exec_config( + exec_config_value: Annotated[ + str, + typer.Argument( + help="The name of the exec_config", + metavar="EXEC_CONFIG", + ), + ], + job_id: job_ids_indexes_opt = None, + db_id: db_ids_opt = None, + flow_id: flow_ids_opt = None, + state: job_state_opt = None, + start_date: start_date_opt = None, + end_date: end_date_opt = None, + name: name_opt = None, + metadata: metadata_opt = None, + days: days_opt = None, + hours: hours_opt = None, + verbosity: verbosity_opt = 0, + raise_on_error: raise_on_error_opt = False, +): + """ + Set the exec_config for the selected Jobs. Only READY or WAITING Jobs. + """ + + jc = get_job_controller() + execute_multi_jobs_cmd( + single_cmd=jc.set_job_run_properties, + multi_cmd=jc.set_job_run_properties, + job_db_id=None, + job_index=None, + job_ids=job_id, + db_ids=db_id, + flow_ids=flow_id, + state=state, + start_date=start_date, + end_date=end_date, + name=name, + metadata=metadata, + days=days, + hours=hours, + verbosity=verbosity, + raise_on_error=raise_on_error, + exec_config_value=exec_config_value, + ) + + +@app_job_set.command() +def resources( + resources_value: Annotated[ + str, + typer.Argument( + help="The resources to be specified. Can be either a list of" + "comma separated key=value pairs or a string with the JSON " + "representation of a dictionary " + '(e.g \'{"key1.key2": 1, "key3": "test"}\')', + metavar="EXEC_CONFIG", + ), + ], + replace: Annotated[ + bool, + typer.Option( + "--replace", + "-r", + help="If present the value will replace entirely those present " + "instead of updating the DB, otherwise only the selected keys " + "will be updated", + ), + ] = False, + qresources: Annotated[ + bool, + typer.Option( + "--qresources", + "-qr", + help="If present the values will be interpreted as arguments for a QResources object", + ), + ] = False, + job_id: job_ids_indexes_opt = None, + db_id: db_ids_opt = None, + flow_id: flow_ids_opt = None, + state: job_state_opt = None, + start_date: start_date_opt = None, + end_date: end_date_opt = None, + name: name_opt = None, + metadata: metadata_opt = None, + days: days_opt = None, + hours: hours_opt = None, + verbosity: verbosity_opt = 0, + raise_on_error: raise_on_error_opt = False, +): + """ + Set the worker for the selected Jobs. Only READY or WAITING Jobs. + """ + + resources_value = str_to_dict(resources_value) + + if qresources: + resources_value = QResources(**resources_value) + + jc = get_job_controller() + execute_multi_jobs_cmd( + single_cmd=jc.set_job_run_properties, + multi_cmd=jc.set_job_run_properties, + job_db_id=None, + job_index=None, + job_ids=job_id, + db_ids=db_id, + flow_ids=flow_id, + state=state, + start_date=start_date, + end_date=end_date, + name=name, + metadata=metadata, + days=days, + hours=hours, + verbosity=verbosity, + raise_on_error=raise_on_error, + resources=resources_value, + update=not replace, + ) diff --git a/src/jobflow_remote/cli/project.py b/src/jobflow_remote/cli/project.py index 9591c682..69ff6374 100644 --- a/src/jobflow_remote/cli/project.py +++ b/src/jobflow_remote/cli/project.py @@ -12,6 +12,7 @@ check_incompatible_opt, exit_with_error_msg, exit_with_warning_msg, + get_config_manager, loading_spinner, out_console, print_success_msg, @@ -235,7 +236,7 @@ def remove( """ Remove a project from the projects' folder, including the related folders. """ - cm = ConfigManager() + cm = get_config_manager() if name not in cm.projects_data: exit_with_warning_msg(f"Project {name} does not exist") @@ -269,7 +270,7 @@ def remove( def list_exec_config( verbosity: verbosity_opt = 0, ): - cm = ConfigManager() + cm = get_config_manager() project = cm.get_project() table = get_exec_config_table(project.exec_config, verbosity) out_console.print(table) @@ -292,7 +293,7 @@ def list_exec_config( def list_worker( verbosity: verbosity_opt = 0, ): - cm = ConfigManager() + cm = get_config_manager() project = cm.get_project() table = get_worker_table(project.workers, verbosity) out_console.print(table) diff --git a/src/jobflow_remote/cli/types.py b/src/jobflow_remote/cli/types.py index b17d8ec0..4501038e 100644 --- a/src/jobflow_remote/cli/types.py +++ b/src/jobflow_remote/cli/types.py @@ -8,7 +8,7 @@ from jobflow_remote.cli.utils import SerializeFileFormat, SortOption from jobflow_remote.config.base import LogLevel -from jobflow_remote.jobs.state import FlowState, JobState, RemoteState +from jobflow_remote.jobs.state import FlowState, JobState job_ids_indexes_opt = Annotated[ Optional[List[str]], @@ -73,15 +73,6 @@ ] -remote_state_opt = Annotated[ - Optional[RemoteState], - typer.Option( - "--remote-state", - "-rs", - help="One of the remote states", - ), -] - name_opt = Annotated[ Optional[str], typer.Option( @@ -98,16 +89,14 @@ "--metadata", "-meta", help="A string representing the metadata to be queried. Can be either" - " a single key=value pair or a string with the JSON representation " - "of a dictionary containing the mongoDB query for the metadata " - 'subdocument (e.g \'{"key1.key2": 1, "key3": "test"}\')', + " a list of comma separated key=value pairs or a string with the JSON" + " representation of a dictionary containing the mongoDB query for " + 'the metadata subdocument (e.g \'{"key1.key2": 1, "key3": "test"}\')', ), ] -remote_state_arg = Annotated[ - RemoteState, typer.Argument(help="One of the remote states") -] +job_state_arg = Annotated[JobState, typer.Argument(help="One of the job states")] start_date_opt = Annotated[ @@ -271,6 +260,40 @@ ] +wait_lock_opt = Annotated[ + int, + typer.Option( + "--wait", + "-w", + help="When trying to acquire the lock on the documents that need to " + "be modified, wait an amount of seconds equal to the value specified", + ), +] + + +break_lock_opt = Annotated[ + bool, + typer.Option( + "--break-lock", + "-bl", + help="Forcibly break the lock for the documents that need to be modified. " + "Use with care and possibly when the runner is stopped. Can lead to " + "inconsistencies", + ), +] + + +raise_on_error_opt = Annotated[ + bool, + typer.Option( + "--raise-on-error", + "-re", + help="If an error arises during any of the operations raise an exception " + "and stop the execution", + ), +] + + # as of typer version 0.9.0 the dict is not a supported type. Define a custom one class DictType(dict): pass diff --git a/src/jobflow_remote/cli/utils.py b/src/jobflow_remote/cli/utils.py index 355d0a03..cd1b8f98 100644 --- a/src/jobflow_remote/cli/utils.py +++ b/src/jobflow_remote/cli/utils.py @@ -2,17 +2,27 @@ import functools import json +import logging import uuid from contextlib import contextmanager from datetime import datetime, timedelta from enum import Enum +from typing import Callable import typer from click import ClickException from rich.console import Console from rich.progress import Progress, SpinnerColumn, TextColumn +from rich.prompt import Confirm +from rich.text import Text +from jobflow_remote import ConfigManager, JobController from jobflow_remote.config.base import ProjectUndefined +from jobflow_remote.jobs.daemon import DaemonError, DaemonManager, DaemonStatus +from jobflow_remote.jobs.state import JobState + +logger = logging.getLogger(__name__) + err_console = Console(stderr=True) out_console = Console() @@ -21,6 +31,41 @@ fmt_datetime = "%Y-%m-%d %H:%M" +# shared instances of the config manager and job controller, to avoid parsing +# the files multiple times. Needs to be initialized with the +# initialize_config_manager function. +_shared_config_manager: ConfigManager | None = None +_shared_job_controller: JobController | None = None + + +def initialize_config_manager(*args, **kwargs): + global _shared_config_manager + _shared_config_manager = ConfigManager(*args, **kwargs) + + +def get_config_manager() -> ConfigManager: + global _shared_config_manager + if not _shared_config_manager: + raise RuntimeError("The shared config manager needs to be initialized") + return _shared_config_manager + + +def get_job_controller(): + global _shared_job_controller + if _shared_job_controller is None: + cm = get_config_manager() + jc = JobController.from_project(cm.get_project()) + _shared_job_controller = jc + + return _shared_job_controller + + +def cleanup_job_controller(*args, **kwargs): + global _shared_job_controller + if _shared_job_controller is not None: + _shared_job_controller.close() + + class SortOption(Enum): CREATED_ON = "created_on" UPDATED_ON = "updated_on" @@ -117,7 +162,7 @@ def loading_spinner(processing: bool = True): yield progress -def get_job_db_ids(job_db_id: str, job_index: int | None): +def get_job_db_ids(job_db_id: str | int, job_index: int | None): try: db_id = int(job_db_id) job_id = None @@ -188,20 +233,24 @@ def check_valid_uuid(uuid_str): raise typer.BadParameter(f"UUID {uuid_str} is in the wrong format.") -def convert_metadata(string_metadata: str | None) -> dict | None: - if not string_metadata: +def str_to_dict(string: str | None) -> dict | None: + if not string: return None try: - metadata = json.loads(string_metadata) + dictionary = json.loads(string) except json.JSONDecodeError: - split = string_metadata.split("=") - if len(split) != 2: - raise typer.BadParameter(f"Wrong format for metadata {string_metadata}") + dictionary = {} + for chunk in string.split(","): + split = chunk.split("=") + if len(split) != 2: + raise typer.BadParameter( + f"Wrong format for dictionary-like field {string}" + ) - metadata = {split[0]: split[1]} + dictionary[split[0]] = split[1] - return metadata + return dictionary def get_start_date(start_date: datetime | None, days: int | None, hours: int | None): @@ -221,3 +270,129 @@ def get_start_date(start_date: datetime | None, days: int | None, hours: int | N start_date = datetime.now() - timedelta(hours=hours) return start_date + + +def execute_multi_jobs_cmd( + single_cmd: Callable, + multi_cmd: Callable, + job_db_id: str | int | None = None, + job_index: int | None = None, + job_ids: list[str] | None = None, + db_ids: int | list[int] | None = None, + flow_ids: str | list[str] | None = None, + state: JobState | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, + name: str | None = None, + metadata: str | None = None, + days: int | None = None, + hours: int | None = None, + verbosity: int = 0, + raise_on_error: bool = False, + **kwargs, +): + query_values = [ + job_ids, + db_ids, + flow_ids, + state, + start_date, + end_date, + name, + metadata, + days, + hours, + ] + try: + if job_db_id is not None: + if any(query_values): + msg = "If job_db_id is defined all the other query options should be disabled" + exit_with_error_msg(msg) + db_id, job_id = get_job_db_ids(job_db_id, job_index) + with loading_spinner(): + + modified_ids = single_cmd( + job_id=job_id, job_index=job_index, db_id=db_id, **kwargs + ) + if not modified_ids: + exit_with_error_msg("Could not perform the requested operation") + else: + check_incompatible_opt( + {"start_date": start_date, "days": days, "hours": hours} + ) + check_incompatible_opt({"end_date": end_date, "days": days, "hours": hours}) + metadata_dict = str_to_dict(metadata) + + job_ids_indexes = get_job_ids_indexes(job_ids) + start_date = get_start_date(start_date, days, hours) + + if not any( + ( + job_ids_indexes, + db_ids, + flow_ids, + state, + start_date, + end_date, + name, + metadata, + ) + ): + text = Text.from_markup( + "[yellow]No filter has been set. This will apply the change to all " + "the jobs in the DB. Proceed anyway?[/yellow]" + ) + + confirmed = Confirm.ask(text, default=False) + if not confirmed: + raise typer.Exit(0) + + with loading_spinner(): + modified_ids = multi_cmd( + job_ids=job_ids_indexes, + db_ids=db_ids, + flow_ids=flow_ids, + state=state, + start_date=start_date, + end_date=end_date, + name=name, + metadata=metadata_dict, + raise_on_error=raise_on_error, + **kwargs, + ) + + if verbosity: + print_success_msg(f"Operation completed. Modified jobs: {modified_ids}") + else: + print_success_msg(f"Operation completed: {len(modified_ids)} jobs modified") + except Exception: + logger.error("Error executing the operation", exc_info=True) + + +def check_stopped_runner(error: bool = True): + cm = get_config_manager() + dm = DaemonManager.from_project(cm.get_project()) + try: + with loading_spinner(False) as progress: + progress.add_task(description="Checking the Daemon status...", total=None) + current_status = dm.check_status() + + except DaemonError as e: + exit_with_error_msg( + f"Error while checking the status of the daemon: {getattr(e, 'message', str(e))}" + ) + if current_status not in (DaemonStatus.STOPPED, DaemonStatus.SHUT_DOWN): + if error: + exit_with_error_msg( + f"The status of the daemon is {current_status.value}. " + "The daemon should not be running while resetting the database" + ) + else: + text = Text.from_markup( + "[red]The Runner is active. This operation may lead to " + "inconsistencies in this case. Proceed anyway?[/red]" + ) + + confirmed = Confirm.ask(text, default=False) + if not confirmed: + raise typer.Exit(0) diff --git a/src/jobflow_remote/config/__init__.py b/src/jobflow_remote/config/__init__.py index adeb3a13..974fc8dc 100644 --- a/src/jobflow_remote/config/__init__.py +++ b/src/jobflow_remote/config/__init__.py @@ -2,7 +2,6 @@ ConfigError, LocalWorker, Project, - RemoteLaunchPad, RemoteWorker, RunnerOptions, ) diff --git a/src/jobflow_remote/config/base.py b/src/jobflow_remote/config/base.py index 2477c361..91fc4a01 100644 --- a/src/jobflow_remote/config/base.py +++ b/src/jobflow_remote/config/base.py @@ -8,10 +8,10 @@ from typing import Annotated, Literal from jobflow import JobStore +from maggma.stores import MongoStore from pydantic import BaseModel, ConfigDict, Field, field_validator from qtoolkit.io import BaseSchedulerIO, scheduler_mapping -from jobflow_remote.fireworks.launchpad import RemoteLaunchPad from jobflow_remote.remote.host import BaseHost, LocalHost, RemoteHost from jobflow_remote.utils.data import store_from_dict @@ -107,6 +107,10 @@ class WorkerBase(BaseModel): Base class defining the common field for the different types of Worker. """ + type: str = Field( + description="The discriminator field to determine the worker type" + ) + scheduler_type: str = Field( description="Type of the scheduler. Depending on the values supported by QToolKit" ) @@ -132,6 +136,10 @@ class WorkerBase(BaseModel): description="Timeout for the execution of the commands in the worker " "(e.g. submitting a job)", ) + max_jobs: int | None = Field( + None, + description="The maximum number of jobs that can be submitted to the queue.", + ) model_config = ConfigDict(extra="forbid") @field_validator("scheduler_type") @@ -379,7 +387,8 @@ class Project(BaseModel): default_factory=dict, description="Dictionary describing a maggma Store used for the queue data. " "Can contain the monty serialized dictionary or a dictionary with a 'type' " - "specifying the Store subclass", + "specifying the Store subclass. Should be subclass of a MongoStore, as it " + "requires to perform MongoDB actions.", validate_default=True, ) exec_config: dict[str, ExecutionConfig] = Field( @@ -422,15 +431,32 @@ def get_queue_store(self): """ return store_from_dict(self.queue) - def get_launchpad(self) -> RemoteLaunchPad: + # def get_launchpad(self) -> RemoteLaunchPad: + # """ + # Provide an instance of a RemoteLaunchPad based on the queue Store. + # + # Returns + # ------- + # A RemoteLaunchPad + # """ + # return RemoteLaunchPad(self.get_queue_store()) + + def get_jobs_queue(self): """ - Provide an instance of a RemoteLaunchPad based on the queue Store. + Provide an instance of the Queue object to manage jobs based on the queue store. Returns ------- - A RemoteLaunchPad + A Queue """ - return RemoteLaunchPad(self.get_queue_store()) + from jobflow_remote.queue.queue import Queue + + return Queue(self.get_queue_store()) + + def get_job_controller(self): + from jobflow_remote.jobs.jobcontroller import JobController + + return JobController.from_project(self) @field_validator("base_dir") def check_base_dir(cls, base_dir: str, values: dict) -> str: @@ -494,11 +520,13 @@ def check_queue(cls, queue: dict, values: dict) -> dict: """ if queue: try: - store_from_dict(queue) + store = store_from_dict(queue) except Exception as e: raise ValueError( f"error while converting queue to a maggma store. Error: {traceback.format_exc()}" ) from e + if not isinstance(store, MongoStore): + raise ValueError("The queue store should be a subclass of a MongoStore") return queue model_config = ConfigDict(extra="forbid") diff --git a/src/jobflow_remote/config/settings.py b/src/jobflow_remote/config/settings.py index 1ed64db3..e45a655e 100644 --- a/src/jobflow_remote/config/settings.py +++ b/src/jobflow_remote/config/settings.py @@ -5,6 +5,8 @@ from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from jobflow_remote.config.base import LogLevel + DEFAULT_PROJECTS_FOLDER = Path("~/.jfremote").expanduser().as_posix() DEFAULT_CONFIG_FILE_PATH = Path("~/.jfremote.yaml").expanduser().as_posix() @@ -27,6 +29,10 @@ class JobflowRemoteSettings(BaseSettings): cli_suggestions: bool = Field( True, description="If True prints some suggestions in the CLI commands." ) + cli_log_level: LogLevel = Field( + LogLevel.WARN, description="The level set for logging in the CLI" + ) + model_config = SettingsConfigDict(env_prefix="jfremote_") @model_validator(mode="before") diff --git a/src/jobflow_remote/fireworks/__init__.py b/src/jobflow_remote/fireworks/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/jobflow_remote/fireworks/convert.py b/src/jobflow_remote/fireworks/convert.py deleted file mode 100644 index 527d97b4..00000000 --- a/src/jobflow_remote/fireworks/convert.py +++ /dev/null @@ -1,177 +0,0 @@ -from __future__ import annotations - -import typing - -from fireworks import Firework, Workflow -from qtoolkit.core.data_objects import QResources - -from jobflow_remote.config.base import ConfigError, ExecutionConfig -from jobflow_remote.fireworks.tasks import RemoteJobFiretask - -if typing.TYPE_CHECKING: - from typing import Sequence - - import jobflow - -__all__ = ["flow_to_workflow", "job_to_firework"] - - -def flow_to_workflow( - flow: jobflow.Flow | jobflow.Job | list[jobflow.Job], - worker: str, - store: jobflow.JobStore | None = None, - exec_config: str | ExecutionConfig = None, - resources: dict | QResources | None = None, - metadata: dict | None = None, - allow_external_references: bool = False, - **kwargs, -) -> Workflow: - """ - Convert a :obj:`Flow` or a :obj:`Job` to a FireWorks :obj:`Workflow` object. - - Each firework spec is updated with the contents of the - :obj:`Job.config.manager_config` dictionary. Accordingly, a :obj:`.JobConfig` object - can be used to configure FireWork options such as metadata and the fireworker. - - Parameters - ---------- - flow - A flow or job. - worker - The name of the Worker where the calculation will be submitted - store - A job store. Alternatively, if set to None, :obj:`JobflowSettings.JOB_STORE` - will be used. Note, this could be different on the computer that submits the - workflow and the computer which runs the workflow. The value of ``JOB_STORE`` on - the computer that runs the workflow will be used. - exec_config: ExecutionConfig - the options to set before the execution of the job in the submission script. - In addition to those defined in the Worker. - resources: Dict or QResources - information passed to qtoolkit to require the resources for the submission - to the queue. - metadata: Dict - metadata passed to the workflow. The flow uuid will be added with the key - "flow_id". - allow_external_references - If False all the references to other outputs should be from other Jobs - of the Flow. - **kwargs - Keyword arguments passed to Workflow init method. - - Returns - ------- - Workflow - The job or flow as a workflow. - """ - from fireworks.core.firework import Firework, Workflow - from jobflow.core.flow import get_flow - - parent_mapping: dict[str, Firework] = {} - fireworks = [] - - if not worker: - raise ConfigError("Worker name must be set.") - - flow = get_flow(flow, allow_external_references=allow_external_references) - - for job, parents in flow.iterflow(): - fw = job_to_firework( - job, - worker=worker, - store=store, - parents=parents, - parent_mapping=parent_mapping, - exec_config=exec_config, - resources=resources, - ) - fireworks.append(fw) - - metadata = metadata or {} - metadata["flow_id"] = flow.uuid - - return Workflow(fireworks, name=flow.name, metadata=metadata, **kwargs) - - -def job_to_firework( - job: jobflow.Job, - worker: str, - store: jobflow.JobStore | None = None, - parents: Sequence[str] | None = None, - parent_mapping: dict[str, Firework] | None = None, - exec_config: str | ExecutionConfig = None, - resources: dict | QResources | None = None, - **kwargs, -) -> Firework: - """ - Convert a :obj:`Job` to a :obj:`.Firework`. - - The firework spec is updated with the contents of the - :obj:`Job.config.manager_config` dictionary. Accordingly, a :obj:`.JobConfig` object - can be used to configure FireWork options such as metadata and the fireworker. - - Parameters - ---------- - job - A job. - store - A job store. Alternatively, if set to None, :obj:`JobflowSettings.JOB_STORE` - will be used. Note, this could be different on the computer that submits the - workflow and the computer which runs the workflow. The value of ``JOB_STORE`` on - the computer that runs the workflow will be used. - parents - The parent uuids of the job. - parent_mapping - A dictionary mapping job uuids to Firework objects, as ``{uuid: Firework}``. - **kwargs - Keyword arguments passed to the Firework constructor. - - Returns - ------- - Firework - A firework that will run the job. - """ - from fireworks.core.firework import Firework - from jobflow.core.reference import OnMissing - - if (parents is None) is not (parent_mapping is None): - raise ValueError("Both or neither of parents and parent_mapping must be set.") - - if isinstance(exec_config, ExecutionConfig): - exec_config = exec_config.dict() - - manager_config = dict(job.config.manager_config) - resources_from_manager = manager_config.pop("resources", None) - exec_config_manager = manager_config.pop("exec_config", None) - resources = resources_from_manager or resources - exec_config = exec_config_manager or exec_config - - if isinstance(exec_config, ExecutionConfig): - exec_config = exec_config.dict() - - task = RemoteJobFiretask( - job=job, - store=store, - worker=worker, - resources=resources, - exec_config=exec_config, - ) - - job_parents = None - if parents is not None and parent_mapping is not None: - job_parents = ( - [parent_mapping[parent] for parent in parents] if parents else None - ) - - spec = {"_add_launchpad_and_fw_id": True} # this allows the job to know the fw_id - if job.config.on_missing_references != OnMissing.ERROR: - spec["_allow_fizzled_parents"] = True - spec.update(manager_config) - spec.update(job.metadata) # add metadata to spec - - fw = Firework([task], spec=spec, name=job.name, parents=job_parents, **kwargs) - - if parent_mapping is not None: - parent_mapping[job.uuid] = fw - - return fw diff --git a/src/jobflow_remote/fireworks/launcher.py b/src/jobflow_remote/fireworks/launcher.py deleted file mode 100644 index 0da92980..00000000 --- a/src/jobflow_remote/fireworks/launcher.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -import logging - -from fireworks.core.fworker import FWorker - -from jobflow_remote.fireworks.launchpad import RemoteLaunchPad - -logger = logging.getLogger(__name__) - - -def checkout_remote( - rlpad: RemoteLaunchPad, - fworker: FWorker | None = None, - fw_id: int = None, -): - """ - - Parameters - ---------- - rlpad - fworker - fw_id - - Returns - ------- - - """ - fworker = fworker if fworker else FWorker() - - fw, launch_id = None, None - - launch_id = None - try: - - fw, launch_id = rlpad.lpad.reserve_fw(fworker, ".", fw_id=fw_id) - if not fw: - logger.info("No jobs exist in the LaunchPad for submission to queue!") - return None, None - logger.info(f"reserved FW with fw_id: {fw.fw_id}") - - rlpad.add_remote_run(launch_id, fw) - - return fw, launch_id - - except Exception: - logger.exception("Error writing/submitting queue script!") - if launch_id is not None: - try: - logger.info( - f"Un-reserving FW with fw_id, launch_id: {fw.fw_id}, {launch_id}" - ) - rlpad.lpad.cancel_reservation(launch_id) - rlpad.forget_remote(fw.fw_id) - except Exception: - logger.exception(f"Error unreserving FW with fw_id {fw.fw_id}") - - return None, None - - -def rapidfire_checkout(rlpad: RemoteLaunchPad, fworker: FWorker): - n_checked_out = 0 - while True: - fw, launch_id = checkout_remote(rlpad, fworker) - if not fw: - break - - n_checked_out += 1 - - return n_checked_out diff --git a/src/jobflow_remote/fireworks/launchpad.py b/src/jobflow_remote/fireworks/launchpad.py deleted file mode 100644 index 2ae68c9a..00000000 --- a/src/jobflow_remote/fireworks/launchpad.py +++ /dev/null @@ -1,796 +0,0 @@ -from __future__ import annotations - -import datetime -import logging -import traceback -from dataclasses import asdict, dataclass - -from fireworks import Firework, FWAction, Launch, LaunchPad, Workflow -from fireworks.core.launchpad import WFLock, get_action_from_gridfs -from fireworks.utilities.fw_serializers import reconstitute_dates, recursive_dict -from maggma.core import Store -from maggma.stores import MongoStore -from pymongo import ASCENDING, DESCENDING -from qtoolkit.core.data_objects import QState - -from jobflow_remote.jobs.state import RemoteState -from jobflow_remote.remote.data import update_store -from jobflow_remote.utils.data import check_dict_keywords -from jobflow_remote.utils.db import MongoLock - -logger = logging.getLogger(__name__) - - -FW_JOB_PATH = "spec._tasks.job" -FW_UUID_PATH = "spec._tasks.job.uuid" -FW_INDEX_PATH = "spec._tasks.job.index" -REMOTE_DOC_PATH = "spec.remote" -REMOTE_LOCK_PATH = f"{REMOTE_DOC_PATH}.{MongoLock.LOCK_KEY}" -REMOTE_LOCK_TIME_PATH = f"{REMOTE_DOC_PATH}.{MongoLock.LOCK_TIME_KEY}" - - -def get_remote_doc(doc: dict) -> dict: - for k in REMOTE_DOC_PATH.split("."): - doc = doc.get(k, {}) - return doc - - -def get_job_doc(doc: dict) -> dict: - return doc["spec"]["_tasks"][0]["job"] - - -@dataclass -class RemoteRun: - launch_id: int - state: RemoteState = RemoteState.CHECKED_OUT - step_attempts: int = 0 - retry_time_limit: datetime.datetime | None = None - previous_state: RemoteState | None = None - queue_state: QState | None = None - error: str | None = None - lock_id: str | None = None - lock_time: datetime.datetime | None = None - process_id: str | None = None - run_dir: str | None = None - start_time: datetime.datetime | None = None - end_time: datetime.datetime | None = None - - def as_db_dict(self): - d = asdict(self) - d["state"] = d["state"].value - d["previous_state"] = d["previous_state"].value if self.previous_state else None - d["queue_state"] = d["queue_state"].value if self.queue_state else None - d.pop("lock_id") - d.pop("lock_time") - if self.lock_id is not None: - d[MongoLock.LOCK_KEY] = self.lock_id - if self.lock_time is not None: - d[MongoLock.LOCK_TIME_KEY] = self.lock_time - return d - - @classmethod - def from_db_dict(cls, d: dict) -> RemoteRun: - prev_state = d["previous_state"] - if prev_state is not None: - prev_state = RemoteState(prev_state) - qstate = d["queue_state"] - if qstate is not None: - qstate = QState(qstate) - d["state"] = RemoteState(d["state"]) - d["previous_state"] = prev_state - d["queue_state"] = qstate - d["lock_id"] = d.pop(MongoLock.LOCK_KEY, None) - d["lock_time"] = d.pop(MongoLock.LOCK_TIME_KEY, None) - return cls(**d) - - @property - def is_locked(self) -> bool: - return self.lock_id is not None - - -class RemoteLaunchPad: - def __init__(self, store: Store): - if not isinstance(store, MongoStore): - raise ValueError( - f"The store should be an instance of a maggma MongoStore. Got {store.__class__} instead" - ) - self.store = store - self.store.connect() - self.lpad = LaunchPad(strm_lvl="CRITICAL") - self.lpad.db = store._coll.database - self.lpad.fireworks = self.db.fireworks - self.lpad.launches = self.db.launches - self.lpad.offline_runs = self.db.offline_runs - self.lpad.fw_id_assigner = self.db.fw_id_assigner - self.lpad.workflows = self.db.workflows - self.lpad.gridfs_fallback = None - - self.archived_remote_runs = self.db.archived_remote_runs - - @property - def db(self): - return self.lpad.db - - @property - def fireworks(self): - return self.lpad.fireworks - - @property - def workflows(self): - return self.lpad.workflows - - @property - def launches(self): - return self.lpad.launches - - def reset(self, password, require_password=True, max_reset_wo_password=25): - self.lpad.reset(password, require_password, max_reset_wo_password) - self.fireworks.create_index(FW_UUID_PATH, background=True) - self.fireworks.create_index( - [(FW_UUID_PATH, ASCENDING), (FW_INDEX_PATH, DESCENDING)], - unique=True, - background=True, - ) - - def forget_remote(self, fwid): - """ - Delete the remote run document for the given launch or firework id. - - Args: - launchid_or_fwid (int): launch od or firework id - launch_mode (bool): if True then launch id is given. - """ - q = {"fw_id": fwid} - - self.db.fireworks.update_one(q, {"$unset": {"spec._remote": ""}}) - - def add_remote_run(self, launch_id, fw): - """ - Add the launch and firework to the offline_run collection. - - Args: - launch_id (int): launch id - """ - task = fw.tasks[0] - task.get("job") - remote_run = RemoteRun(launch_id) - - self.db.fireworks.update_one( - {"fw_id": fw.fw_id}, {"$set": {REMOTE_DOC_PATH: remote_run.as_db_dict()}} - ) - - def recover_remote( - self, - remote_status, - launch_id, - store, - remote_store, - save, - terminated=True, - ignore_errors=False, - print_errors=False, - ): - """ - Update the launch state using the offline data in FW_offline.json file. - - Args: - launch_id (int): launch id - ignore_errors (bool) - print_errors (bool) - - Returns: - firework id if the recovering fails otherwise None - """ - - # get the launch directory - m_launch = self.lpad.get_launch_by_id(launch_id) - completed = False - try: - self.lpad.m_logger.debug(f"RECOVERING fw_id: {m_launch.fw_id}") - - if "started_on" in remote_status: # started running at some point - already_running = False - for s in m_launch.state_history: - if s["state"] == "RUNNING": - s["created_on"] = reconstitute_dates( - remote_status["started_on"] - ) - already_running = True - - # Fixed with respect to fireworks. - # Otherwise the created_on for RUNNING state is wrong - if not already_running: - m_launch.state = "RUNNING" # this should also add a history item - for s in m_launch.state_history: - if s["state"] == "RUNNING": - s["created_on"] = reconstitute_dates( - remote_status["started_on"] - ) - - status = remote_status.get("state") - if terminated and status not in ("COMPLETED", "FIZZLED"): - raise RuntimeError( - "The remote job should be terminated, but the Firework did not finish" - ) - - if "fwaction" in remote_status: - fwaction = FWAction.from_dict(remote_status["fwaction"]) - m_launch.state = remote_status["state"] - self.lpad.launches.find_one_and_replace( - {"launch_id": m_launch.launch_id}, - m_launch.to_db_dict(), - upsert=True, - ) - - m_launch = Launch.from_dict( - self.lpad.complete_launch(launch_id, fwaction, m_launch.state) - ) - - for s in m_launch.state_history: - if s["state"] == remote_status["state"]: - s["created_on"] = reconstitute_dates( - remote_status["completed_on"] - ) - self.lpad.launches.find_one_and_update( - {"launch_id": m_launch.launch_id}, - {"$set": {"state_history": m_launch.state_history}}, - ) - - completed = True - - else: - previous_launch = self.lpad.launches.find_one_and_replace( - {"launch_id": m_launch.launch_id}, - m_launch.to_db_dict(), - upsert=True, - ) - fw_id = previous_launch["fw_id"] - f = self.lpad.fireworks.find_one_and_update( - {"fw_id": fw_id}, - { - "$set": { - "state": "RUNNING", - "updated_on": datetime.datetime.utcnow().isoformat(), - } - }, - ) - if f: - self.lpad._refresh_wf(fw_id) - - if completed: - update_store(store, remote_store, save) - - except Exception: - if print_errors: - self.lpad.m_logger.error( - f"failed recovering launch_id {launch_id}.\n{traceback.format_exc()}" - ) - if not ignore_errors: - traceback.print_exc() - m_action = FWAction( - stored_data={ - "_message": "runtime error during task", - "_task": None, - "_exception": { - "_stacktrace": traceback.format_exc(), - "_details": None, - }, - }, - exit=True, - ) - self.lpad.complete_launch(launch_id, m_action, "FIZZLED") - - completed = True - return m_launch, completed - - def add_wf(self, wf): - return self.lpad.add_wf(wf) - - def get_fw_dict( - self, - fw_id: int | None = None, - job_id: str | None = None, - job_index: int | None = None, - ): - """ - Given a fw id or a job id, return firework dict. - - Parameters - ---------- - fw_id: int - The fw_id of the Firework - job_id: str - The job_id of the Firework to retrieve - - Returns - ------- - dict - The dictionary defining the Firework - """ - query, sort = self.generate_id_query(fw_id, job_id, job_index) - fw_dict = self.fireworks.find_one(query, sort=sort) - if not fw_dict: - raise ValueError( - f"No Firework exists with fw id: {fw_id} or job_id {job_id}" - ) - # recreate launches from the launch collection - launches = list( - self.launches.find( - {"launch_id": {"$in": fw_dict["launches"]}}, - sort=[("launch_id", ASCENDING)], - ) - ) - for launch in launches: - launch["action"] = get_action_from_gridfs( - launch.get("action"), self.lpad.gridfs_fallback - ) - fw_dict["launches"] = launches - launches = list( - self.launches.find( - {"launch_id": {"$in": fw_dict["archived_launches"]}}, - sort=[("launch_id", ASCENDING)], - ) - ) - for launch in launches: - launch["action"] = get_action_from_gridfs( - launch.get("action"), self.lpad.gridfs_fallback - ) - fw_dict["archived_launches"] = launches - return fw_dict - - @staticmethod - def generate_id_query( - fw_id: int | None = None, - job_id: str | None = None, - job_index: int | None = None, - ) -> tuple[dict, list | None]: - query: dict = {} - sort: list | None = None - - if (job_id is None) == (fw_id is None): - raise ValueError( - "One and only one among job_id and db_id should be defined" - ) - - if fw_id: - query["fw_id"] = fw_id - if job_id: - query[FW_UUID_PATH] = job_id - if job_index is None: - # note: this format is suitable for collection.find(sort=.), - # but not for $sort in an aggregation. - sort = [[FW_INDEX_PATH, DESCENDING]] - else: - query[FW_INDEX_PATH] = job_index - if not query: - raise ValueError("At least one among fw_id and job_id should be specified") - return query, sort - - def _check_ids( - self, - fw_id: int | None = None, - job_id: str | None = None, - job_index: int | None = None, - ): - if (job_id is None) == (fw_id is None): - raise ValueError( - "One and only one among fw_id and job_id should be defined" - ) - if job_id: - fw_id = self.get_fw_id_from_job_id(job_id, job_index) - return fw_id, job_id - - def get_fw( - self, - fw_id: int | None = None, - job_id: str | None = None, - job_index: int | None = None, - ): - """ - Given a fw id or a job id, return the Firework object. - - Parameters - ---------- - fw_id: int - The fw_id of the Firework - job_id: str - The job_id of the Firework to retrieve - - Returns - ------- - Firework - The retrieved Firework - """ - return Firework.from_dict(self.get_fw_dict(fw_id, job_id, job_index)) - - def get_fw_id_from_job_id(self, job_id: str, job_index: int | None = None): - query, sort = self.generate_id_query(job_id=job_id, job_index=job_index) - fw_dict = self.fireworks.find_one(query, projection=["fw_id"], sort=sort) - if not fw_dict: - raise ValueError(f"No Firework exists with id: {job_id}") - - return fw_dict["fw_id"] - - def rerun_fw( - self, - fw_id: int | None = None, - job_id: str | None = None, - job_index: int | None = None, - recover_launch: int | str | None = None, - recover_mode: str | None = None, - ): - """ - Rerun the firework corresponding to the given id. - - Args: - fw_id (int): firework id - recover_launch ('last' or int): launch_id for last recovery, if set to - 'last' (default), recovery will find the last available launch. - If it is an int, will recover that specific launch - recover_mode ('prev_dir' or 'cp'): flag to indicate whether to copy - or run recovery fw in previous directory - - Returns: - [int]: list of firework ids that were rerun - """ - query, sort = self.generate_id_query( - fw_id=fw_id, job_id=job_id, job_index=job_index - ) - - m_fw = self.fireworks.find_one( - query, projection={"state": 1, "fw_id": 1}, sort=sort - ) - - if not m_fw: - raise ValueError(f"FW with id: {fw_id or job_id} not found!") - fw_id = m_fw["fw_id"] - - reruns = [] - - # Launch recovery - if recover_launch is not None: - recovery = self.lpad.get_recovery(fw_id, recover_launch) - recovery.update({"_mode": recover_mode}) - set_spec = recursive_dict({"$set": {"spec._recovery": recovery}}) - if recover_mode == "prev_dir": - prev_dir = self.lpad.get_launch_by_id( - recovery.get("_launch_id") - ).launch_dir - set_spec["$set"]["spec._launch_dir"] = prev_dir - self.fireworks.find_one_and_update({"fw_id": fw_id}, set_spec) - - # If no launch recovery specified, unset the firework recovery spec - else: - set_spec = {"$unset": {"spec._recovery": ""}} - self.fireworks.find_one_and_update({"fw_id": fw_id}, set_spec) - - # rerun this FW - if m_fw["state"] in ["ARCHIVED", "DEFUSED"]: - self.lpad.m_logger.info( - f"Cannot rerun fw_id: {fw_id}: it is {m_fw['state']}." - ) - elif m_fw["state"] == "WAITING" and not recover_launch: - self.lpad.m_logger.debug( - f"Skipping rerun fw_id: {fw_id}: it is already WAITING." - ) - else: - with WFLock(self.lpad, fw_id): - wf = self.lpad.get_wf_by_fw_id_lzyfw(fw_id) - updated_ids = wf.rerun_fw(fw_id) - # before updating the fireworks in the database deal with the - # remote part of the document in the fireworks. Copy the content to - # archived ones and remove the "remote" from the FW. - remote_docs = [] - for fw in wf.fws: - if fw.fw_id in updated_ids: - remote_doc = fw.spec.pop("remote") - if remote_doc: - remote_docs.append(remote_doc) - - if remote_docs: - self.archived_remote_runs.insert_many(remote_docs) - - # now update the fw and wf in the db - self.lpad._update_wf(wf, updated_ids) - reruns.append(fw_id) - - return reruns - - def set_remote_values( - self, - values: dict, - fw_id: int | None, - job_id: str | None = None, - job_index: int | None = None, - break_lock: bool = False, - ) -> bool: - lock_filter, sort = self.generate_id_query(fw_id, job_id, job_index) - with MongoLock( - collection=self.fireworks, - filter=lock_filter, - break_lock=break_lock, - lock_subdoc=REMOTE_DOC_PATH, - sort=sort, - ) as lock: - if lock.locked_document: - values = {f"{REMOTE_DOC_PATH}.{k}": v for k, v in values.items()} - values["updated_on"] = datetime.datetime.utcnow().isoformat() - lock.update_on_release = {"$set": values} - return True - - return False - - def remove_lock(self, query: dict | None = None) -> int: - result = self.fireworks.update_many( - filter=query, - update={"$unset": {REMOTE_LOCK_PATH: "", REMOTE_LOCK_TIME_PATH: ""}}, - ) - return result.modified_count - - def is_locked( - self, - fw_id: int | None = None, - job_id: str | None = None, - job_index: int | None = None, - ) -> bool: - query, sort = self.generate_id_query(fw_id, job_id, job_index) - result = self.fireworks.find_one( - query, projection=[REMOTE_LOCK_PATH], sort=sort - ) - if not result: - raise ValueError("No job matching id") - return REMOTE_LOCK_PATH in result - - def reset_failed_state( - self, - fw_id: int | None = None, - job_id: str | None = None, - job_index: int | None = None, - ) -> bool: - lock_filter, sort = self.generate_id_query(fw_id, job_id, job_index) - with MongoLock( - collection=self.fireworks, - filter=lock_filter, - lock_subdoc=REMOTE_DOC_PATH, - sort=sort, - ) as lock: - doc = lock.locked_document - remote = get_remote_doc(doc) - if remote: - state = remote["state"] - if state != RemoteState.FAILED.value: - raise ValueError("Job is not in a FAILED state") - previous_state = remote["previous_state"] - try: - RemoteState(previous_state) - except ValueError: - raise ValueError( - f"The registered previous state: {previous_state} is not a valid state" - ) - set_dict = { - "state": previous_state, - "step_attempts": 0, - "retry_time_limit": None, - "previous_state": None, - "queue_state": None, - "error": None, - } - for k, v in list(set_dict.items()): - set_dict[f"{REMOTE_DOC_PATH}.{k}"] = v - set_dict.pop(k) - set_dict["updated_on"] = datetime.datetime.utcnow().isoformat() - - lock.update_on_release = {"$set": set_dict} - return True - - return False - - def delete_wf(self, fw_id: int | None = None, job_id: str | None = None): - """ - Delete the workflow containing firework with the given id. - - """ - # index is not needed here, since all the jobs with one job_id will - # belong to the same Workflow - fw_id, job_id = self._check_ids(fw_id, job_id) - - links_dict = self.workflows.find_one({"nodes": fw_id}) - if not links_dict: - raise ValueError( - f"No Flow matching the criteria db_id: {fw_id} job_id: {job_id}" - ) - fw_ids = links_dict["nodes"] - self.lpad.delete_fws(fw_ids, delete_launch_dirs=False) - self.archived_remote_runs.delete_many({"fw_id": {"$in": fw_ids}}) - self.workflows.delete_one({"nodes": fw_id}) - - def get_remote_run( - self, - fw_id: int | None = None, - job_id: str | None = None, - job_index: int | None = None, - ) -> RemoteRun: - - query, sort = self.generate_id_query(fw_id, job_id, job_index) - - fw = self.fireworks.find_one(query) - if not fw: - msg = f"No Job exists with fw id: {fw_id} or job_id {job_id}" - if job_index is not None: - msg += f" and job index {job_index}" - raise ValueError(msg) - - remote_dict = get_remote_doc(fw) - if not remote_dict: - msg = f"No Remote run exists with fw id: {fw_id} or job_id {job_id}" - if job_index is not None: - msg += f" and job index {job_index}" - raise ValueError(msg) - - return RemoteRun.from_db_dict(remote_dict) - - def get_fws( - self, query: dict | None = None, sort: list[tuple] | None = None, limit: int = 0 - ) -> list[Firework]: - result = self.fireworks.find(query, sort=sort, limit=limit) - - fws = [] - for doc in result: - fws.append(Firework.from_dict(doc)) - return fws - - def get_fw_remote_run( - self, - query: dict | None = None, - projection: dict | None = None, - sort: list | None = None, - limit: int = 0, - ) -> list[tuple[Firework, RemoteRun | None]]: - fws = self.fireworks.find(query, projection=projection, sort=sort, limit=limit) - - data = [] - for fw_dict in fws: - r = get_remote_doc(fw_dict) - if r: - remote_run = RemoteRun.from_db_dict(r) - else: - remote_run = None - - # remove the launches as they will require additional queries to the db - fw_dict.pop("launches") - fw_dict.pop("archived_launches") - - fw = Firework.from_dict(fw_dict) - data.append((fw, remote_run)) - - return data - - def get_fw_ids( - self, query: dict | None = None, sort: dict | None = None, limit: int = 0 - ) -> list[int]: - result = self.fireworks.find( - filter=query, sort=sort, limit=limit, projection={"fw_id": 1} - ) - - fw_ids = [] - for doc in result: - fw_ids.append(doc["fw_id"]) - - return fw_ids - - def get_fw_remote_run_from_id( - self, - fw_id: int | None = None, - job_id: str | None = None, - job_index: int | None = None, - ) -> tuple[Firework, RemoteRun] | None: - query, sort = self.generate_id_query(fw_id, job_id, job_index) - results = self.get_fw_remote_run(query=query, sort=sort) - if not results: - return None - return results[0] - - def get_wf_fw_data( - self, - query: dict | None = None, - projection: dict | None = None, - sort: dict | None = None, - limit: int = 0, - ) -> list[dict]: - - pipeline: list[dict] = [ - { - "$lookup": { - "from": "fireworks", - "localField": "nodes", - "foreignField": "fw_id", - "as": "fws", - } - } - ] - - if query: - pipeline.append({"$match": query}) - - if projection: - pipeline.append({"$project": projection}) - - if sort: - pipeline.append({"$sort": {k: v for (k, v) in sort}}) - - if limit: - pipeline.append({"$limit": limit}) - - return list(self.workflows.aggregate(pipeline)) - - def get_wf_fw_remote_run( - self, query: dict | None = None, sort: dict | None = None, limit: int = 0 - ) -> list[tuple[Workflow, dict[int, RemoteRun]]]: - raw_data = self.get_wf_fw_data(query=query, sort=sort, limit=limit) - - data = [] - for d in raw_data: - fws = d["fws"] - remotes_dict = {} - for fw_dict in fws: - r = get_remote_doc(fw_dict) - if r: - remotes_dict[fw_dict["fw_id"]] = RemoteRun.from_db_dict(r) - - wf = Workflow.from_dict(d) - data.append((wf, remotes_dict)) - - return data - - def get_wf_ids( - self, query: dict | None = None, sort: dict | None = None, limit: int = 0 - ) -> list[int]: - full_required = check_dict_keywords(query, ["fws."]) - - if full_required: - result = self.get_wf_fw_data( - query=query, sort=sort, limit=limit, projection={"fw_id": 1} - ) - else: - result = self.lpad.get_wf_ids(query, sort=sort, limit=limit) - - fw_ids = [] - for doc in result: - fw_ids.append(doc["fw_id"]) - - return fw_ids - - def get_fw_launch_remote_run_data( - self, - query: dict | None = None, - projection: dict | None = None, - sort: dict | None = None, - limit: int = 0, - ) -> list[dict]: - - # only take the most recent launch - pipeline: list[dict] = [ - { - "$lookup": { - "from": "launches", - "localField": "fw_id", - "foreignField": "fw_id", - "as": "launch", - "pipeline": [{"$sort": {"time_start": -1}}, {"$limit": 1}], - } - }, - ] - - if query: - pipeline.append({"$match": query}) - - if projection: - pipeline.append({"$project": projection}) - - if sort: - pipeline.append({"$sort": sort}) - - if limit: - pipeline.append({"$limit": limit}) - - return list(self.fireworks.aggregate(pipeline)) diff --git a/src/jobflow_remote/fireworks/tasks.py b/src/jobflow_remote/fireworks/tasks.py deleted file mode 100644 index 2cf7c12b..00000000 --- a/src/jobflow_remote/fireworks/tasks.py +++ /dev/null @@ -1,120 +0,0 @@ -from __future__ import annotations - -import glob -import os - -from fireworks import FiretaskBase, FWAction, explicit_serialize -from jobflow import JobStore -from monty.shutil import decompress_file - -from jobflow_remote.remote.data import ( - default_orjson_serializer, - get_remote_store_filenames, -) - - -@explicit_serialize -class RemoteJobFiretask(FiretaskBase): - """ - A firetask that will run any job, tailored for the execution on a remote resource. - - Other Parameters - ---------------- - job : Dict - A serialized job. - store : JobStore - A job store. Alternatively, if set to None, :obj:`JobflowSettings.JOB_STORE` - will be used. Note, this will use the configuration defined on the local - machine, even if the Task is executed on a remote one. An actual store - should be set before the Task is executed remotely. - worker: Str - The id of the Worker where the calculation will be submitted - exec_config: ExecutionConfig - the options to set before the execution of the job in the submission script. - In addition to those defined in the Worker. - resources: Dict or QResources - information passed to qtoolkit to require the resources for the submission - to the queue. - original_store: JobStore - The original JobStore. Used to set the value to following Jobs in case of - a dynamical Flow. - """ - - required_params = ["job", "store", "worker"] - optional_params = ["exec_config", "resources", "original_store"] - - def run_task(self, fw_spec): - """Run the job and handle any dynamic firework submissions.""" - from jobflow import initialize_logger - from jobflow.core.job import Job - - job: Job = self.get("job") - store = self.get("store") - - # needs to be set here again since it does not get properly serialized. - # it is possible to serialize the default function before serializing, but - # avoided that to avoid that any refactoring of the default_orjson_serializer - # breaks the deserialization of old Fireworks - store.docs_store.serialization_default = default_orjson_serializer - for additional_store in store.additional_stores.values(): - additional_store.serialization_default = default_orjson_serializer - - store.connect() - - if hasattr(self, "fw_id"): - job.metadata.update({"db_id": self.fw_id}) - - initialize_logger() - - try: - response = job.run(store=store) - finally: - # some jobs may have compressed the FW files while being executed, - # try to decompress them if that is the case. - self.decompress_files(store) - - detours = None - additions = None - # in case of dynamic Flow set the same parameters as the current Job - kwargs_dynamic = { - "worker": self.get("worker"), - "store": self.get("original_store"), - "resources": self.get("resources"), - "exec_config": self.get("exec_config"), - } - from jobflow_remote.fireworks.convert import flow_to_workflow - - if response.replace is not None: - # create a workflow from the new additions; be sure to use original store - detours = [flow_to_workflow(flow=response.replace, **kwargs_dynamic)] - - if response.addition is not None: - additions = [flow_to_workflow(flow=response.addition, **kwargs_dynamic)] - - if response.detour is not None: - detour_wf = flow_to_workflow(flow=response.detour, **kwargs_dynamic) - if detours is not None: - detours.append(detour_wf) - else: - detours = [detour_wf] - - fwa = FWAction( - stored_data=response.stored_data, - detours=detours, - additions=additions, - defuse_workflow=response.stop_jobflow, - defuse_children=response.stop_children, - ) - return fwa - - def decompress_files(self, store: JobStore): - file_names = ["FW.json", "FW_offline.json"] - file_names.extend(get_remote_store_filenames(store)) - - for fn in file_names: - # If the file is already present do not decompress it, even if - # a compressed version is present. - if os.path.isfile(fn): - continue - for f in glob.glob(fn + ".*"): - decompress_file(f) diff --git a/src/jobflow_remote/jobs/daemon.py b/src/jobflow_remote/jobs/daemon.py index 7753812a..ff103544 100644 --- a/src/jobflow_remote/jobs/daemon.py +++ b/src/jobflow_remote/jobs/daemon.py @@ -10,7 +10,7 @@ from supervisor.states import RUNNING_STATES, STOPPED_STATES, ProcessStates from supervisor.xmlrpc import Faults -from jobflow_remote.config import ConfigManager +from jobflow_remote.config import ConfigManager, Project logger = logging.getLogger(__name__) @@ -61,19 +61,26 @@ class DaemonManager: def __init__( self, - daemon_dir: str | Path | None = None, - log_dir: str | Path | None = None, - project_name: str | None = None, + daemon_dir: str | Path, + log_dir: str | Path, + project: Project, ): - config_manager = ConfigManager() - self.project = config_manager.get_project(project_name) - if not daemon_dir: - daemon_dir = self.project.daemon_dir + self.project = project self.daemon_dir = Path(daemon_dir).absolute() - if not log_dir: - log_dir = self.project.log_dir self.log_dir = Path(log_dir).absolute() + @classmethod + def from_project(cls, project: Project): + daemon_dir = project.daemon_dir + log_dir = project.log_dir + return cls(daemon_dir, log_dir, project) + + @classmethod + def from_project_name(cls, project_name: str): + config_manager = ConfigManager() + project = config_manager.get_project(project_name) + return cls.from_project(project) + @property def conf_filepath(self) -> Path: return self.daemon_dir / "supervisord.conf" diff --git a/src/jobflow_remote/jobs/data.py b/src/jobflow_remote/jobs/data.py index 274ff736..96f273fe 100644 --- a/src/jobflow_remote/jobs/data.py +++ b/src/jobflow_remote/jobs/data.py @@ -1,155 +1,104 @@ from __future__ import annotations -from dataclasses import dataclass, field +from collections import defaultdict from datetime import datetime, timezone +from enum import Enum +from functools import cached_property -from jobflow import Job, JobStore +from jobflow import Flow, Job, JobStore +from monty.json import jsanitize +from pydantic import BaseModel, Field +from qtoolkit.core.data_objects import QResources, QState -from jobflow_remote.fireworks.launchpad import ( - FW_INDEX_PATH, - FW_UUID_PATH, - REMOTE_DOC_PATH, - get_job_doc, - get_remote_doc, -) -from jobflow_remote.jobs.state import FlowState, JobState, RemoteState -from jobflow_remote.utils.db import MongoLock +from jobflow_remote.config.base import ExecutionConfig +from jobflow_remote.jobs.state import FlowState, JobState +IN_FILENAME = "jfremote_in.json" +OUT_FILENAME = "jfremote_out.json" -@dataclass -class JobData: - job: Job - state: JobState - db_id: int - store: JobStore - info: JobInfo | None = None - remote_state: RemoteState | None = None - output: dict | None = None - - -job_info_projection = { - "fw_id": 1, - FW_UUID_PATH: 1, - FW_INDEX_PATH: 1, - "state": 1, - f"{REMOTE_DOC_PATH}.state": 1, - "name": 1, - "updated_on": 1, - f"{REMOTE_DOC_PATH}.updated_on": 1, - f"{REMOTE_DOC_PATH}.previous_state": 1, - f"{REMOTE_DOC_PATH}.{MongoLock.LOCK_KEY}": 1, - f"{REMOTE_DOC_PATH}.{MongoLock.LOCK_TIME_KEY}": 1, - f"{REMOTE_DOC_PATH}.retry_time_limit": 1, - f"{REMOTE_DOC_PATH}.process_id": 1, - f"{REMOTE_DOC_PATH}.run_dir": 1, - f"{REMOTE_DOC_PATH}.start_time": 1, - f"{REMOTE_DOC_PATH}.end_time": 1, - "spec._tasks.worker": 1, - "spec._tasks.job.hosts": 1, -} - - -@dataclass -class JobInfo: + +def get_initial_job_doc_dict( + job: Job, + parents: list[str] | None, + db_id: int, + worker: str, + exec_config: ExecutionConfig | None, + resources: dict | QResources | None, +): + from monty.json import jsanitize + + # take the resources either from the job, if they are defined + # (they can be defined dynamically by the update_config) or the + # defined value + job_resources = job.config.manager_config.get("resources") or resources + job_exec_config = job.config.manager_config.get("exec_config") or exec_config + worker = job.config.manager_config.get("worker") or worker + + job_doc = JobDoc( + job=jsanitize(job, strict=True, enum_values=True), + uuid=job.uuid, + index=job.index, + db_id=db_id, + state=JobState.WAITING if parents else JobState.READY, + parents=parents, + worker=worker, + exec_config=job_exec_config, + resources=job_resources, + ) + + return job_doc.as_db_dict() + + +def get_initial_flow_doc_dict(flow: Flow, job_dicts: list[dict]): + + jobs = [j["uuid"] for j in job_dicts] + ids = [(j["db_id"], j["uuid"], j["index"]) for j in job_dicts] + parents = {j["uuid"]: {"1": j["parents"]} for j in job_dicts} + + flow_doc = FlowDoc( + uuid=flow.uuid, + jobs=jobs, + state=FlowState.READY, + name=flow.name, + ids=ids, + parents=parents, + ) + + return flow_doc.as_db_dict() + + +class RemoteInfo(BaseModel): + step_attempts: int = 0 + queue_state: QState | None = None + process_id: str | None = None + retry_time_limit: datetime | None = None + error: str | None = None + + +class JobInfo(BaseModel): + uuid: str + index: int db_id: int - job_id: str - job_index: int - state: JobState - name: str - last_updated: datetime worker: str - remote_state: RemoteState | None = None - remote_previous_state: RemoteState | None = None + name: str + state: JobState + remote: RemoteInfo = RemoteInfo() + parents: list[str] | None = None + previous_state: JobState | None = None + error: str | None = None lock_id: str | None = None lock_time: datetime | None = None - retry_time_limit: datetime | None = None - queue_job_id: str | None = None run_dir: str | None = None - error_job: str | None = None - error_remote: str | None = None - host_flows_ids: list[str] = field(default_factory=lambda: list()) start_time: datetime | None = None end_time: datetime | None = None + created_on: datetime = datetime.utcnow() + updated_on: datetime = datetime.utcnow() + priority: int = 0 + metadata: dict | None = None - @classmethod - def from_fw_dict(cls, d): - remote = get_remote_doc(d) - remote_state_val = remote.get("state") - remote_state = ( - RemoteState(remote_state_val) if remote_state_val is not None else None - ) - state = JobState.from_states(d["state"], remote_state) - if isinstance(d["updated_on"], str): - last_updated = datetime.fromisoformat(d["updated_on"]) - else: - last_updated = d["updated_on"] - # the dates should be in utc time. Convert them to the system time - last_updated = last_updated.replace(tzinfo=timezone.utc).astimezone(tz=None) - remote_previous_state_val = remote.get("previous_state") - remote_previous_state = ( - RemoteState(remote_previous_state_val) - if remote_previous_state_val is not None - else None - ) - lock_id = remote.get(MongoLock.LOCK_KEY) - lock_time = remote.get(MongoLock.LOCK_TIME_KEY) - if lock_time is not None: - # TODO when updating the state of a Firework fireworks replaces the dict - # with its serialized version, where dates are replaced by strings. - # Intercept those cases and convert back to dates. This should be removed - # if fireworks is replaced by another tool. - if isinstance(lock_time, str): - lock_time = datetime.fromisoformat(lock_time) - lock_time = lock_time.replace(tzinfo=timezone.utc).astimezone(tz=None) - retry_time_limit = remote.get("retry_time_limit") - if retry_time_limit is not None: - retry_time_limit = retry_time_limit.replace(tzinfo=timezone.utc).astimezone( - tz=None - ) - - error_job = None - launch = d.get("launch") or {} - if launch: - launch = launch[0] - stored_data = launch.get("action", {}).get("stored_data", {}) - message = stored_data.get("_message") - stack_strace = stored_data.get("_exception", {}).get("_stacktrace") - if message or stack_strace: - error_job = f"Message: {message}\nStack trace:\n{stack_strace}" - - queue_job_id = remote.get("process_id") - if queue_job_id is not None: - # convert to string in case the format is the one of an integer - queue_job_id = str(queue_job_id) - - start_time = remote.get("start_time") - if start_time: - start_time = start_time.replace(tzinfo=timezone.utc).astimezone(tz=None) - end_time = remote.get("end_time") - if end_time: - end_time = end_time.replace(tzinfo=timezone.utc).astimezone(tz=None) - - return cls( - db_id=d["fw_id"], - job_id=d["spec"]["_tasks"][0]["job"]["uuid"], - job_index=d["spec"]["_tasks"][0]["job"]["index"], - state=state, - name=d["name"], - last_updated=last_updated, - worker=d["spec"]["_tasks"][0]["worker"], - remote_state=remote_state, - remote_previous_state=remote_previous_state, - lock_id=lock_id, - lock_time=lock_time, - retry_time_limit=retry_time_limit, - queue_job_id=queue_job_id, - run_dir=remote.get("run_dir"), - error_remote=remote.get("error"), - error_job=error_job, - host_flows_ids=d["spec"]["_tasks"][0]["job"]["hosts"], - start_time=start_time, - end_time=end_time, - ) + @property + def is_locked(self) -> bool: + return self.lock_id is not None @property def run_time(self) -> float | None: @@ -167,31 +116,159 @@ def estimated_run_time(self) -> float | None: return None + @classmethod + def from_query_output(cls, d) -> JobInfo: + job = d.pop("job") + for k in ["name", "metadata"]: + d[k] = job[k] + return cls.model_validate(d) + + +def _projection_db_info() -> list[str]: + projection = list(JobInfo.model_fields.keys()) + projection.remove("name") + projection.append("job.name") + projection.append("job.metadata") + return projection -flow_info_projection = { - "fws.fw_id": 1, - f"fws.{FW_UUID_PATH}": 1, - f"fws.{FW_INDEX_PATH}": 1, - "fws.state": 1, - "fws.name": 1, - f"fws.{REMOTE_DOC_PATH}.state": 1, - "name": 1, - "updated_on": 1, - "fws.updated_on": 1, - "fws.spec._tasks.worker": 1, - "metadata.flow_id": 1, -} +projection_job_info = _projection_db_info() -@dataclass -class FlowInfo: + +class JobDoc(BaseModel): + # TODO consider defining this as a dict and provide a get_job() method to + # get the real Job. This would avoid (de)serializing jobs if this document + # is used often to interact with the DB. + job: Job + uuid: str + index: int + db_id: int + worker: str + state: JobState + remote: RemoteInfo = RemoteInfo() + # only the uuid as list of parents for a JobDoc (i.e. uuid+index) is + # enough to determine the parents, since once a job with a uuid is + # among the parents, all the index will still be parents. + # Note that for just the uuid this condition is not true: JobDocs with + # the same uuid but different indexes may have different parents + parents: list[str] | None = None + previous_state: JobState | None = None + error: str | None = None # TODO is there a better way to serialize it? + lock_id: str | None = None + lock_time: datetime | None = None + run_dir: str | None = None + start_time: datetime | None = None + end_time: datetime | None = None + created_on: datetime = datetime.utcnow() + updated_on: datetime = datetime.utcnow() + priority: int = 0 + store: JobStore | None = None + exec_config: ExecutionConfig | str | None = None + resources: QResources | dict | None = None + + stored_data: dict | None = None + history: list[str] | None = None # ? + + def as_db_dict(self): + # required since the resources are not serialized otherwise + if isinstance(self.resources, QResources): + resources_dict = self.resources.as_dict() + d = jsanitize( + self.model_dump(mode="python"), + strict=True, + allow_bson=True, + enum_values=True, + ) + if isinstance(self.resources, QResources): + d["resources"] = resources_dict + return d + + +class FlowDoc(BaseModel): + uuid: str + jobs: list[str] + state: FlowState + name: str + lock_id: str | None = None + lock_time: datetime | None = None + created_on: datetime = datetime.utcnow() + updated_on: datetime = datetime.utcnow() + metadata: dict = Field(default_factory=dict) + # parents need to include both the uuid and the index. + # When dynamically replacing a Job with a Flow some new Jobs will + # be parents of the job with index=i+1, but will not be parents of + # the job with index i. + # index is stored as string, since mongodb needs string keys + parents: dict[str, dict[str, list[str]]] = Field(default_factory=dict) + # ids correspond to db_id, uuid, index for each JobDoc + ids: list[tuple[int, str, int]] = Field(default_factory=list) + # jobs_states: dict[str, FlowState] + + def as_db_dict(self): + d = jsanitize( + self.model_dump(mode="python"), + strict=True, + allow_bson=True, + enum_values=True, + ) + return d + + @cached_property + def int_index_parents(self): + d = defaultdict(dict) + for child_id, index_parents in self.parents.items(): + for index, parents in index_parents.items(): + d[child_id][int(index)] = parents + return dict(d) + + @cached_property + def children(self) -> dict[str, list[tuple[str, int]]]: + d = defaultdict(list) + for job_id, index_parents in self.parents.items(): + for index, parents in index_parents.items(): + for parent_id in parents: + d[parent_id].append((job_id, int(index))) + + return dict(d) + + def descendants(self, job_uuid: str) -> list[tuple[str, int]]: + descendants = set() + + def add_descendants(uuid): + children = self.children.get(uuid) + if children: + descendants.update(children) + for child in children: + add_descendants(child[0]) + + add_descendants(job_uuid) + + return list(descendants) + + @cached_property + def ids_mapping(self) -> dict[str, dict[int, int]]: + d: dict = defaultdict(dict) + + for db_id, job_id, index in self.ids: + d[job_id][int(index)] = db_id + + return dict(d) + + +class RemoteError(RuntimeError): + def __init__(self, msg, no_retry=False): + self.msg = msg + self.no_retry = no_retry + + +class FlowInfo(BaseModel): db_ids: list[int] job_ids: list[str] job_indexes: list[int] flow_id: str state: FlowState name: str - last_updated: datetime + updated_on: datetime workers: list[str] job_states: list[JobState] job_names: list[str] @@ -200,33 +277,22 @@ class FlowInfo: def from_query_dict(cls, d): # the dates should be in utc time. Convert them to the system time updated_on = d["updated_on"] - if isinstance(updated_on, str): - updated_on = datetime.fromisoformat(updated_on) - last_updated = updated_on.replace(tzinfo=timezone.utc).astimezone(tz=None) - flow_id = d["metadata"].get("flow_id") - fws = d.get("fws") or [] + updated_on = updated_on.replace(tzinfo=timezone.utc).astimezone(tz=None) + flow_id = d["uuid"] + + db_ids, job_ids, job_indexes = list(zip(*d["ids"])) + + jobs_data = d.get("jobs_list") or [] workers = [] job_states = [] job_names = [] - db_ids = [] - job_ids = [] - job_indexes = [] - for fw_doc in fws: - db_ids.append(fw_doc["fw_id"]) - job_doc = get_job_doc(fw_doc) - remote_doc = get_remote_doc(fw_doc) - job_ids.append(job_doc["uuid"]) - job_indexes.append(job_doc["index"]) - job_names.append(fw_doc["name"]) - if remote_doc: - remote_state = RemoteState(remote_doc["state"]) - else: - remote_state = None - fw_state = fw_doc["state"] - job_states.append(JobState.from_states(fw_state, remote_state)) - workers.append(fw_doc["spec"]["_tasks"][0]["worker"]) - - state = FlowState.from_jobs_states(job_states) + for job_doc in jobs_data: + job_names.append(job_doc["job"]["name"]) + state = job_doc["state"] + job_states.append(JobState(state)) + workers.append(job_doc["worker"]) + + state = FlowState(d["state"]) return cls( db_ids=db_ids, @@ -235,8 +301,36 @@ def from_query_dict(cls, d): flow_id=flow_id, state=state, name=d["name"], - last_updated=last_updated, + updated_on=updated_on, workers=workers, job_states=job_states, job_names=job_names, ) + + +class DynamicResponseType(Enum): + REPLACE = "replace" + DETOUR = "detour" + ADDITION = "addition" + + +def get_reset_job_base_dict() -> dict: + """ + Return a dictionary with the basic properties to update in case of reset. + + Returns + ------- + + """ + d = { + "remote.step_attempts": 0, + "remote.retry_time_limit": None, + "previous_state": None, + "remote.queue_state": None, + "remote.error": None, + "error": None, + "updated_on": datetime.utcnow(), + "start_time": None, + "end_time": None, + } + return d diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index 3740092e..e277d7d4 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -1,88 +1,116 @@ from __future__ import annotations +import contextlib import fnmatch -import io import logging -from contextlib import redirect_stdout -from datetime import datetime, timezone -from typing import cast +import traceback +import warnings +from contextlib import ExitStack +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Callable, cast -from fireworks import Firework -from jobflow import JobStore -from monty.json import MontyDecoder +import jobflow +import pymongo +from jobflow import JobStore, OnMissing +from maggma.stores import MongoStore +from monty.serialization import loadfn +from qtoolkit.core.data_objects import CancelStatus, QResources -from jobflow_remote.config.base import Project +from jobflow_remote.config.base import ConfigError, ExecutionConfig, Project from jobflow_remote.config.manager import ConfigManager -from jobflow_remote.fireworks.launchpad import ( - FW_INDEX_PATH, - FW_JOB_PATH, - FW_UUID_PATH, - REMOTE_DOC_PATH, - RemoteLaunchPad, - get_remote_doc, -) from jobflow_remote.jobs.data import ( + OUT_FILENAME, + DynamicResponseType, + FlowDoc, FlowInfo, - JobData, + JobDoc, JobInfo, - flow_info_projection, - job_info_projection, + RemoteError, + get_initial_flow_doc_dict, + get_initial_job_doc_dict, + get_reset_job_base_dict, + projection_job_info, +) +from jobflow_remote.jobs.state import ( + PAUSABLE_STATES, + RESETTABLE_STATES, + RUNNING_STATES, + FlowState, + JobState, ) -from jobflow_remote.jobs.state import FlowState, JobState, RemoteState -from jobflow_remote.utils.db import MongoLock +from jobflow_remote.remote.data import get_remote_store, update_store +from jobflow_remote.remote.queue import QueueManager +from jobflow_remote.utils.data import deep_merge_dict +from jobflow_remote.utils.db import FlowLockedError, JobLockedError, MongoLock logger = logging.getLogger(__name__) class JobController: def __init__( - self, project_name: str | None = None, jobstore: JobStore | None = None + self, + queue_store: MongoStore, + jobstore: JobStore, + flows_collection: str = "flows", + id_generator_collection: str = "job_id_generator", + project: Project | None = None, ): - self.project_name = project_name - self.config_manager: ConfigManager = ConfigManager() - self.project: Project = self.config_manager.get_project(project_name) - self.rlpad: RemoteLaunchPad = self.project.get_launchpad() - if not jobstore: - jobstore = self.project.get_jobstore() + self.queue_store = queue_store self.jobstore = jobstore + self.jobs_collection = self.queue_store.collection_name + self.flows_collection = flows_collection + self.id_generator_collection = id_generator_collection + # TODO should it connect here? Or the passed stored should be connected? + self.queue_store.connect() self.jobstore.connect() + self.db = self.queue_store._collection.database + self.jobs = self.queue_store._collection + self.flows = self.db[self.flows_collection] + self.id_generator = self.db[self.id_generator_collection] + self.project = project - def get_job_data( - self, - job_id: str | None = None, - db_id: str | None = None, - job_index: int | None = None, - load_output: bool = False, - ): - fw, remote_run = self.rlpad.get_fw_remote_run_from_id( - job_id=job_id, fw_id=db_id, job_index=job_index - ) - job = fw.tasks[0].get("job") - state = JobState.from_states(fw.state, remote_run.state if remote_run else None) - output = None - jobstore = fw.tasks[0].get("store") or self.jobstore - if load_output and state == JobState.COMPLETED: - output = jobstore.query_one({"uuid": job_id}, load=True) + @classmethod + def from_project_name(cls, project_name: str | None = None): + config_manager: ConfigManager = ConfigManager() + project: Project = config_manager.get_project(project_name) + queue_store = project.get_queue_store() + jobstore = project.get_jobstore() + return cls(queue_store=queue_store, jobstore=jobstore, project=project) - return JobData(job=job, state=state, db_id=fw.fw_id, output=output) + @classmethod + def from_project(cls, project: Project): + queue_store = project.get_queue_store() + jobstore = project.get_jobstore() + return cls(queue_store=queue_store, jobstore=jobstore, project=project) - def _build_query_fw( + def close(self): + try: + self.queue_store.close() + except Exception: + logger.error( + "Error while closing the connection to the queue store", exc_info=True + ) + + try: + self.jobstore.close() + except Exception: + logger.error( + "Error while closing the connection to the job store", exc_info=True + ) + + def _build_query_job( self, job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, db_ids: int | list[int] | None = None, flow_ids: str | list[str] | None = None, state: JobState | None = None, - remote_state: RemoteState | None = None, locked: bool = False, start_date: datetime | None = None, end_date: datetime | None = None, name: str | None = None, metadata: dict | None = None, ) -> dict: - if state is not None and remote_state is not None: - raise ValueError("state and remote_state cannot be queried simultaneously") - if remote_state is not None: - remote_state = [remote_state] if job_ids and not any(isinstance(ji, (list, tuple)) for ji in job_ids): # without these cast mypy is confused about the type @@ -95,49 +123,41 @@ def _build_query_fw( query: dict = {} if db_ids: - query["fw_id"] = {"$in": db_ids} + query["db_id"] = {"$in": db_ids} if job_ids: job_ids = cast(list[tuple[str, int]], job_ids) or_list = [] for job_id, job_index in job_ids: - or_list.append({FW_UUID_PATH: job_id, FW_INDEX_PATH: job_index}) + or_list.append({"uuid": job_id, "index": job_index}) query["$or"] = or_list if flow_ids: - query[f"{FW_JOB_PATH}.hosts"] = {"$in": flow_ids} + query["hosts"] = {"$in": flow_ids} if state: - fw_states, remote_state = state.to_states() - query["state"] = {"$in": fw_states} - - if remote_state: - query[f"{REMOTE_DOC_PATH}.state"] = { - "$in": [rs.value for rs in remote_state] - } + query["state"] = state.value if start_date: - start_date_str = start_date.astimezone(timezone.utc).isoformat() + start_date_str = start_date.astimezone(timezone.utc) query["updated_on"] = {"$gte": start_date_str} if end_date: - end_date_str = end_date.astimezone(timezone.utc).isoformat() + end_date_str = end_date.astimezone(timezone.utc) query["updated_on"] = {"$lte": end_date_str} if locked: - query[f"{REMOTE_DOC_PATH}.{MongoLock.LOCK_KEY}"] = {"$exists": True} + query["lock_id"] = {"$ne": None} if name: mongo_regex = "^" + fnmatch.translate(name).replace("\\\\", "\\") query["name"] = {"$regex": mongo_regex} if metadata: - metadata_dict = { - f"{FW_JOB_PATH}.metadata.{k}": v for k, v in metadata.items() - } + metadata_dict = {f"metadata.{k}": v for k, v in metadata.items()} query.update(metadata_dict) return query - def _build_query_wf( + def _build_query_flow( self, job_ids: str | list[str] | None = None, db_ids: int | list[int] | None = None, @@ -156,61 +176,23 @@ def _build_query_wf( query: dict = {} if db_ids: - query["nodes"] = {"$in": db_ids} + # the "0" refers to the index in the ids list. + # needs to be a string, but is correctly recognized by MongoDB + query["ids"] = {"$elemMatch": {"0": {"$in": db_ids}}} if job_ids: - query[f"fws.{FW_UUID_PATH}"] = {"$in": job_ids} + query["jobs"] = {"$in": job_ids} if flow_ids: - query["metadata.flow_id"] = {"$in": flow_ids} + query["uuid"] = {"$in": flow_ids} if state: - if state == FlowState.WAITING: - not_in_states = list(Firework.STATE_RANKS.keys()) - not_in_states.remove("WAITING") - query["fws.state"] = {"$nin": not_in_states} - elif state == FlowState.PAUSED: - not_in_states = list(Firework.STATE_RANKS.keys()) - not_in_states.remove("PAUSED") - query["fws.state"] = {"$nin": not_in_states} - elif state == FlowState.READY: - query["state"] = "READY" - elif state == FlowState.COMPLETED: - query["state"] = "COMPLETED" - elif state == FlowState.ONGOING: - query["state"] = "RUNNING" - query["fws.state"] = { - "$in": ["WAITING", "COMPLETED", "READY", "RUNNING", "RESERVED"] - } - query[f"fws.{REMOTE_DOC_PATH}.state"] = { - "$nin": [JobState.FAILED.value, JobState.REMOTE_ERROR.value] - } - elif state == FlowState.FAILED: - query["$or"] = [ - {"state": "FIZZLED"}, - { - "$and": [ - {"state": "DEFUSED"}, - {"fws.state": {"$in": ["FIZZLED"]}}, - ] - }, - { - f"fws.{REMOTE_DOC_PATH}.state": { - "$in": [JobState.FAILED.value, JobState.REMOTE_ERROR.value] - } - }, - ] - elif state == FlowState.STOPPED: - query["state"] = "DEFUSED" - query["fws.state"] = {"$nin": ["FIZZLED"]} - else: - raise RuntimeError("Unknown flow state.") + query["state"] = state.value - # at variance with Firework doc, the dates in the Workflow are Date objects if start_date: - start_date_str = start_date.astimezone(timezone.utc).isoformat() + start_date_str = start_date.astimezone(timezone.utc) query["updated_on"] = {"$gte": start_date_str} if end_date: - end_date_str = end_date.astimezone(timezone.utc).isoformat() + end_date_str = end_date.astimezone(timezone.utc) query["updated_on"] = {"$lte": end_date_str} if name: @@ -219,87 +201,57 @@ def _build_query_wf( return query - def get_jobs_data( + def get_jobs_info_query(self, query: dict = None, **kwargs) -> list[JobInfo]: + data = self.jobs.find(query, projection=projection_job_info, **kwargs) + + jobs_data = [] + for d in data: + jobs_data.append(JobInfo.from_query_output(d)) + + return jobs_data + + def get_jobs_info( self, job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, db_ids: int | list[int] | None = None, flow_ids: str | list[str] | None = None, state: JobState | None = None, - remote_state: RemoteState | None = None, start_date: datetime | None = None, end_date: datetime | None = None, name: str | None = None, metadata: dict | None = None, - sort: dict | None = None, + locked: bool = False, + sort: list[tuple] | None = None, limit: int = 0, - load_output: bool = False, - ) -> list[JobData]: - query = self._build_query_fw( + ) -> list[JobInfo]: + query = self._build_query_job( job_ids=job_ids, db_ids=db_ids, flow_ids=flow_ids, state=state, - remote_state=remote_state, + locked=locked, start_date=start_date, end_date=end_date, name=name, metadata=metadata, ) + return self.get_jobs_info_query(query=query, sort=sort, limit=limit) - data = self.rlpad.fireworks.find(query, sort=sort, limit=limit) - jobs_data = [] - for fw_dict in data: - # deserialize the task to get the objects - decoded_task = MontyDecoder().process_decoded(fw_dict["spec"]["_tasks"][0]) - job = decoded_task["job"] - remote_dict = get_remote_doc(fw_dict) - remote_state_job = ( - RemoteState(remote_dict["state"]) if remote_dict else None - ) - state = JobState.from_states(fw_dict["state"], remote_state_job) - store = decoded_task.get("store") or self.jobstore - info = JobInfo.from_fw_dict(fw_dict) - - output = None - if state == JobState.COMPLETED and load_output: - output = store.query_one({"uuid": job.uuid}, load=True) - jobs_data.append( - JobData( - job=job, - state=state, - db_id=fw_dict["fw_id"], - remote_state=remote_state, - store=store, - info=info, - output=output, - ) - ) - - return jobs_data - - def get_jobs_info_query( - self, - query: dict = None, - sort: list[tuple] | None = None, - limit: int = 0, - ) -> list[JobInfo]: - data = self.rlpad.fireworks.find( - query, sort=sort, limit=limit, projection=job_info_projection - ) + def get_jobs_doc_query(self, query: dict = None, **kwargs) -> list[JobInfo]: + data = self.jobs.find(query, **kwargs) jobs_data = [] for d in data: - jobs_data.append(JobInfo.from_fw_dict(d)) + jobs_data.append(JobDoc.model_validate(d)) return jobs_data - def get_jobs_info( + def get_jobs_doc( self, job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, db_ids: int | list[int] | None = None, flow_ids: str | list[str] | None = None, state: JobState | None = None, - remote_state: RemoteState | None = None, start_date: datetime | None = None, end_date: datetime | None = None, name: str | None = None, @@ -308,55 +260,106 @@ def get_jobs_info( sort: list[tuple] | None = None, limit: int = 0, ) -> list[JobInfo]: - query = self._build_query_fw( + query = self._build_query_job( job_ids=job_ids, db_ids=db_ids, flow_ids=flow_ids, state=state, - remote_state=remote_state, locked=locked, start_date=start_date, end_date=end_date, name=name, metadata=metadata, ) - return self.get_jobs_info_query(query=query, sort=sort, limit=limit) + return self.get_jobs_doc_query(query=query, sort=sort, limit=limit) + + @staticmethod + def generate_job_id_query( + db_id: int | None = None, + job_id: str | None = None, + job_index: int | None = None, + ) -> tuple[dict, list | None]: + query: dict = {} + sort: list | None = None + + if (job_id is None) == (db_id is None): + raise ValueError( + "One and only one among job_id and db_id should be defined" + ) + + if db_id: + query["db_id"] = db_id + if job_id: + query["uuid"] = job_id + if job_index is None: + # note: this format is suitable for collection.find(sort=.), + # but not for $sort in an aggregation. + sort = [["index", pymongo.DESCENDING]] + else: + query["index"] = job_index + if not query: + raise ValueError("At least one among db_id and job_id should be specified") + return query, sort def get_job_info( self, - job_id: str | None, - db_id: int | None, + job_id: str | None = None, + db_id: int | None = None, job_index: int | None = None, - full: bool = False, ) -> JobInfo | None: - query, sort = self.rlpad.generate_id_query(db_id, job_id, job_index) + query, sort = self.generate_job_id_query(db_id, job_id, job_index) - if full: - proj = dict(job_info_projection) - proj.update( - { - "launch.action.stored_data": 1, - f"{REMOTE_DOC_PATH}.error": 1, - } - ) - if sort: - # needs to be converted when used in an aggregation - sort = dict(sort) - data = list( - self.rlpad.get_fw_launch_remote_run_data( - query=query, projection=proj, sort=sort, limit=1 - ) - ) - else: - data = list( - self.rlpad.fireworks.find( - query, projection=job_info_projection, sort=sort, limit=1 - ) - ) + data = list( + self.jobs.find(query, projection=projection_job_info, sort=sort, limit=1) + ) if not data: return None - return JobInfo.from_fw_dict(data[0]) + return JobInfo.from_query_output(data[0]) + + def _many_jobs_action( + self, + method: Callable, + action_description: str, + job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, + db_ids: int | list[int] | None = None, + flow_ids: str | list[str] | None = None, + state: JobState | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, + name: str | None = None, + metadata: dict | None = None, + raise_on_error: bool = True, + **method_kwargs, + ) -> list[int]: + query = self._build_query_job( + job_ids=job_ids, + db_ids=db_ids, + flow_ids=flow_ids, + state=state, + start_date=start_date, + end_date=end_date, + name=name, + metadata=metadata, + ) + result = self.jobs.find(query, projection=["db_id"]) + + queried_dbs_ids = [r["db_id"] for r in result] + + updated_ids = set() + for db_id in queried_dbs_ids: + try: + job_updated_ids = method(db_id=db_id, **method_kwargs) + if job_updated_ids: + updated_ids.update(job_updated_ids) + except Exception: + if raise_on_error: + raise + logger.error( + f"Error while {action_description} for job {db_id}", exc_info=True + ) + + return list(updated_ids) def rerun_jobs( self, @@ -364,86 +367,698 @@ def rerun_jobs( db_ids: int | list[int] | None = None, flow_ids: str | list[str] | None = None, state: JobState | None = None, - remote_state: RemoteState | None = None, start_date: datetime | None = None, end_date: datetime | None = None, name: str | None = None, metadata: dict | None = None, - sort: dict | None = None, - limit: int = 0, + raise_on_error: bool = True, + force: bool = False, + wait: int | None = None, + break_lock: bool = False, ) -> list[int]: - query = self._build_query_fw( + return self._many_jobs_action( + method=self.rerun_job, + action_description="rerunning", job_ids=job_ids, db_ids=db_ids, flow_ids=flow_ids, state=state, - remote_state=remote_state, start_date=start_date, end_date=end_date, name=name, metadata=metadata, + raise_on_error=raise_on_error, + force=force, + wait=wait, + break_lock=break_lock, ) - fw_ids = self.rlpad.get_fw_ids(query=query, sort=sort, limit=limit) - for fw_id in fw_ids: - self.rlpad.rerun_fw(fw_id=fw_id) + def rerun_job( + self, + job_id: str | None = None, + db_id: int | None = None, + job_index: int | None = None, + force: bool = False, + wait: int | None = None, + break_lock: bool = False, + ) -> list[int]: + lock_filter, sort = self.generate_job_id_query(db_id, job_id, job_index) + sleep = None + if wait: + sleep = 10 + + modified_jobs: list[int] = [] + # the job to rerun is the last to be released since this prevents + # a checkout of the job while the flow is still locked + with self.lock_job( + filter=lock_filter, + break_lock=break_lock, + sort=sort, + projection=["uuid", "index", "db_id", "state"], + sleep=sleep, + max_wait=wait, + get_locked_doc=True, + ) as job_lock: + job_doc_dict = job_lock.locked_document + if not job_doc_dict: + if job_lock.unavailable_document: + raise JobLockedError.from_job_doc(job_lock.unavailable_document) + raise ValueError(f"No Job document matching criteria {lock_filter}") + job_state = JobState(job_doc_dict["state"]) + + if job_state in [JobState.READY]: + raise ValueError("The Job is in the READY state. No need to rerun.") + elif job_state in RESETTABLE_STATES: + # if in one of the resettable states no need to lock the flow or + # update children. + doc_update = self._reset_remote(job_doc_dict) + modified_jobs = [] + elif ( + job_state not in [JobState.FAILED, JobState.REMOTE_ERROR] and not force + ): + raise ValueError( + f"Job in state {job_doc_dict['state']} cannot be rerun. " + "Use the 'force' option to override this check." + ) + else: + # full restart required + doc_update, modified_jobs = self._full_rerun( + job_doc_dict, + sleep=sleep, + wait=wait, + break_lock=break_lock, + force=force, + ) - return fw_ids + modified_jobs.append(job_doc_dict["db_id"]) - def reset(self, reset_output: bool = False, max_limit: int = 25) -> bool: + set_doc = {"$set": doc_update} + job_lock.update_on_release = set_doc - password = datetime.now().strftime("%Y-%m-%d") if max_limit == 0 else None - try: - self.rlpad.reset( - password, require_password=False, max_reset_wo_password=max_limit + return modified_jobs + + def _full_rerun( + self, + doc: dict, + sleep: int | None = None, + wait: int | None = None, + break_lock: bool = False, + force: bool = False, + ) -> tuple[dict, list[int]]: + job_id = doc["uuid"] + job_index = doc["index"] + modified_jobs = [] + + flow_filter = {"jobs": job_id} + with self.lock_flow( + filter=flow_filter, + sleep=sleep, + max_wait=wait, + get_locked_doc=True, + break_lock=break_lock, + ) as flow_lock: + if not flow_lock.locked_document: + if flow_lock.unavailable_document: + raise FlowLockedError.from_flow_doc(flow_lock.unavailable_document) + raise ValueError(f"No Flow document matching criteria {flow_filter}") + + flow_doc = FlowDoc.model_validate(flow_lock.locked_document) + + # only the job with the largest index currently present in the db + # can be rerun to avoid inconsistencies. (rerunning a smaller index + # would still leave the job with larger indexes in the DB with no + # clear way of how to deal with them) + if max(flow_doc.ids_mapping[job_id]) > job_index: + raise ValueError( + f"Job {job_id} is not the highest index ({job_index}). " + "Rerunning it will lead to inconsistencies and is not allowed." + ) + + # check that the all the children only those with the largest index + # in the flow are present. + # If that is the case the rerun would lead to inconsistencies. + # If only the last one is among the children it is acceptable + # to rerun, but in case of a child with lower index a dynamical + # action that cannot be reverted has been already applied. + # Do not allow this even if force==True. + # if not force, only the first level children need to be checked + if not force: + descendants = flow_doc.children.get(job_id, []) + else: + descendants = flow_doc.descendants(job_id) + for dep_id, dep_index in descendants: + if max(flow_doc.ids_mapping[dep_id]) > dep_index: + raise ValueError( + f"Job {job_id} has a child job ({dep_id}) which is not the last index ({dep_index}. " + "Rerunning the Job will lead to inconsistencies and is not allowed." + ) + + # TODO should STOPPED be acceptable? + acceptable_child_states = [ + JobState.READY.value, + JobState.WAITING.value, + JobState.PAUSED.value, + ] + # Update the state of the descendants + with ExitStack() as stack: + # first acquire the lock on all the descendants and + # check their state if needed. Break immediately if + # the lock cannot be acquired on one of the children + # or if the states do not satisfy the requirements + children_locks = [] + for dep_id, dep_index in descendants: + # TODO consider using the db_id for the query. may be faster? + child_lock = stack.enter_context( + self.lock_job( + filter={"uuid": dep_id, "index": dep_index}, + break_lock=break_lock, + projection=["uuid", "index", "db_id", "state"], + sleep=sleep, + max_wait=wait, + get_locked_doc=True, + ) + ) + child_doc_dict = child_lock.locked_document + if not child_doc_dict: + if child_lock.unavailable_document: + raise JobLockedError.from_job_doc( + child_lock.unavailable_document, + f"The parent Job with uuid {job_id} cannot be rerun", + ) + raise ValueError( + f"The child of Job {job_id} to rerun with uuid {dep_id} and index {dep_index} could not be found in the database" + ) + + # check that the children have not been started yet. + # the only case being if some children allow failed parents. + # Put a lock on each of the children, so that if they are READY + # they will not be checked out + if ( + not force + and child_doc_dict["state"] not in acceptable_child_states + ): + msg = ( + f"The child of Job {job_id} to rerun with uuid {dep_id} and " + f"index {dep_index} has state {child_doc_dict['state']} which " + "is not acceptable. Use the 'force' option to override this check." + ) + raise ValueError(msg) + children_locks.append(child_lock) + + # Here all the descendants are locked and could be set to WAITING. + # Set the new state for all of them. + for child_lock in children_locks: + if child_lock.locked_document["state"] != JobState.WAITING.value: + modified_jobs.append(child_lock.locked_document["db_id"]) + child_doc_update = get_reset_job_base_dict() + child_doc_update["state"] = JobState.WAITING.value + child_lock.update_on_release = {"$set": child_doc_update} + + # if everything is fine here, update the state of the flow + # before releasing its lock and set the update for the original job + # pass explicitly the new state of the job, since it is not updated + # in the DB. The Job is the last lock to be released. + updated_states = {job_id: {job_index: JobState.READY}} + self.update_flow_state( + flow_uuid=flow_doc.uuid, updated_states=updated_states ) - except ValueError as e: - logger.info(f"database was not reset due to: {repr(e)}") - return False - # TODO it should just delete docs related to job removed in the rlpad.reset? - # what if the outputs are in other stores? Should take those as well - if reset_output: - self.jobstore.remove_docs({}) - return True + job_doc_update = get_reset_job_base_dict() + job_doc_update["state"] = JobState.READY.value + + return job_doc_update, modified_jobs + + def _reset_remote(self, doc: dict) -> dict: - def set_remote_state( + if doc["state"] in [JobState.SUBMITTED.value, JobState.RUNNING.value]: + # try cancelling the job submitted to the remote queue + try: + self._cancel_queue_process(doc) + except Exception: + logger.warning( + f"Failed cancelling the process for Job {doc['uuid']} {doc['index']}", + exc_info=True, + ) + + job_doc_update = get_reset_job_base_dict() + job_doc_update["state"] = JobState.CHECKED_OUT.value + + return job_doc_update + + def _set_job_properties( self, - state: RemoteState, - job_id: str | None, - db_id: int | None, + values: dict, + db_id: int | None = None, + job_id: str | None = None, job_index: int | None = None, - ) -> bool: + wait: int | None = None, + break_lock: bool = False, + acceptable_states: list[JobState] | None = None, + ) -> list[int]: + sleep = None + if wait: + sleep = 10 + lock_filter, sort = self.generate_job_id_query(db_id, job_id, job_index) + projection = ["db_id", "uuid", "index", "state"] + with self.lock_job( + filter=lock_filter, + break_lock=break_lock, + sort=sort, + sleep=sleep, + max_wait=wait, + projection=projection, + ) as lock: + doc = lock.locked_document + if doc: + if ( + acceptable_states + and JobState(doc["state"]) not in acceptable_states + ): + raise ValueError( + f"Job in state {doc['state']}. The action cannot be performed" + ) + values = dict(values) + # values["updated_on"] = datetime.utcnow() + lock.update_on_release = {"$set": values} + return [doc["db_id"]] + + return [] + + def set_job_state( + self, + state: JobState, + job_id: str | None = None, + db_id: int | None = None, + job_index: int | None = None, + wait: int | None = None, + break_lock: bool = False, + ) -> list[int]: + values = { "state": state.value, - "step_attempts": 0, - "retry_time_limit": None, + "remote.step_attempts": 0, + "remote.retry_time_limit": None, "previous_state": None, - "queue_state": None, + "remote.queue_state": None, + "remote.error": None, "error": None, } - return self.rlpad.set_remote_values( - values=values, job_id=job_id, fw_id=db_id, job_index=job_index + return self._set_job_properties( + values=values, + job_id=job_id, + db_id=db_id, + job_index=job_index, + wait=wait, + break_lock=break_lock, ) - def reset_remote_attempts( - self, job_id: str | None, db_id: int | None, job_index: int | None = None - ) -> bool: - values = { - "step_attempts": 0, - "retry_time_limit": None, - } - return self.rlpad.set_remote_values( - values=values, job_id=job_id, fw_id=db_id, job_index=job_index + def retry_jobs( + self, + job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, + db_ids: int | list[int] | None = None, + flow_ids: str | list[str] | None = None, + state: JobState | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, + name: str | None = None, + metadata: dict | None = None, + raise_on_error: bool = True, + wait: int | None = None, + break_lock: bool = False, + ): + return self._many_jobs_action( + method=self.retry_job, + action_description="rerunning", + job_ids=job_ids, + db_ids=db_ids, + flow_ids=flow_ids, + state=state, + start_date=start_date, + end_date=end_date, + name=name, + metadata=metadata, + raise_on_error=raise_on_error, + wait=wait, + break_lock=break_lock, ) - def reset_failed_state( - self, job_id: str | None, db_id: int | None, job_index: int | None = None - ) -> bool: - return self.rlpad.reset_failed_state( - job_id=job_id, fw_id=db_id, job_index=job_index + def retry_job( + self, + job_id: str | None = None, + db_id: int | None = None, + job_index: int | None = None, + wait: int | None = None, + break_lock: bool = False, + ) -> list[int]: + lock_filter, sort = self.generate_job_id_query(db_id, job_id, job_index) + sleep = None + if wait: + sleep = 10 + + with self.lock_job( + filter=lock_filter, + sort=sort, + get_locked_doc=True, + sleep=sleep, + max_wait=wait, + break_lock=break_lock, + ) as lock: + doc = lock.locked_document + if not doc: + if lock.unavailable_document: + raise JobLockedError( + f"The Job matching criteria {lock_filter} is locked." + ) + raise ValueError(f"No Job matching criteria {lock_filter}") + state = JobState(doc["state"]) + if state != JobState.REMOTE_ERROR: + + previous_state = doc["previous_state"] + try: + JobState(previous_state) + except ValueError: + raise ValueError( + f"The registered previous state: {previous_state} is not a valid state" + ) + set_dict = get_reset_job_base_dict() + set_dict["state"] = previous_state + + lock.update_on_release = {"$set": set_dict} + elif state in RUNNING_STATES: + set_dict = { + "remote.step_attempts": 0, + "remote.retry_time_limit": None, + } + lock.update_on_release = {"$set": set_dict} + else: + raise ValueError(f"Job in state {state.value} cannot be retried.") + return [doc["db_id"]] + + def pause_jobs( + self, + job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, + db_ids: int | list[int] | None = None, + flow_ids: str | list[str] | None = None, + state: JobState | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, + name: str | None = None, + metadata: dict | None = None, + raise_on_error: bool = True, + wait: int | None = None, + ) -> list[int]: + return self._many_jobs_action( + method=self.pause_job, + action_description="pausing", + job_ids=job_ids, + db_ids=db_ids, + flow_ids=flow_ids, + state=state, + start_date=start_date, + end_date=end_date, + name=name, + metadata=metadata, + raise_on_error=raise_on_error, + wait=wait, + ) + + def cancel_jobs( + self, + job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, + db_ids: int | list[int] | None = None, + flow_ids: str | list[str] | None = None, + state: JobState | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, + name: str | None = None, + metadata: dict | None = None, + raise_on_error: bool = True, + wait: int | None = None, + break_lock: bool = False, + ) -> list[int]: + return self._many_jobs_action( + method=self.cancel_job, + action_description="cancelling", + job_ids=job_ids, + db_ids=db_ids, + flow_ids=flow_ids, + state=state, + start_date=start_date, + end_date=end_date, + name=name, + metadata=metadata, + raise_on_error=raise_on_error, + wait=wait, + break_lock=break_lock, + ) + + def cancel_job( + self, + job_id: str | None = None, + db_id: int | None = None, + job_index: int | None = None, + wait: int | None = None, + break_lock: bool = False, + ) -> list[int]: + + job_lock_kwargs = dict( + projection=["uuid", "index", "db_id", "state", "remote", "worker"] + ) + flow_lock_kwargs = dict(projection=["uuid"]) + with self.lock_job_flow( + acceptable_states=[JobState.READY] + RUNNING_STATES, + job_id=job_id, + db_id=db_id, + job_index=job_index, + wait=wait, + break_lock=break_lock, + job_lock_kwargs=job_lock_kwargs, + flow_lock_kwargs=flow_lock_kwargs, + ) as (job_lock, flow_lock): + job_doc = job_lock.locked_document + job_state = JobState(job_doc["state"]) + if job_state in [JobState.SUBMITTED.value, JobState.RUNNING.value]: + # try cancelling the job submitted to the remote queue + try: + self._cancel_queue_process(job_doc) + except Exception: + logger.warning( + f"Failed cancelling the process for Job {job_doc['uuid']} {job_doc['index']}", + exc_info=True, + ) + updated_states = {job_id: {job_index: JobState.CANCELLED}} + self.update_flow_state( + flow_uuid=flow_lock.locked_document["uuid"], + updated_states=updated_states, + ) + job_lock.update_on_release = {"$set": {"state": JobState.CANCELLED.value}} + return [job_lock.locked_document["db_id"]] + + def pause_job( + self, + job_id: str | None = None, + db_id: int | None = None, + job_index: int | None = None, + wait: int | None = None, + ) -> list[int]: + + job_lock_kwargs = dict(projection=["uuid", "index", "db_id", "state"]) + flow_lock_kwargs = dict(projection=["uuid"]) + with self.lock_job_flow( + acceptable_states=PAUSABLE_STATES, + job_id=job_id, + db_id=db_id, + job_index=job_index, + wait=wait, + break_lock=False, + job_lock_kwargs=job_lock_kwargs, + flow_lock_kwargs=flow_lock_kwargs, + ) as (job_lock, flow_lock): + updated_states = {job_id: {job_index: JobState.PAUSED}} + self.update_flow_state( + flow_uuid=flow_lock.locked_document["uuid"], + updated_states=updated_states, + ) + job_lock.update_on_release = {"$set": {"state": JobState.PAUSED.value}} + return [job_lock.locked_document["db_id"]] + + def play_jobs( + self, + job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, + db_ids: int | list[int] | None = None, + flow_ids: str | list[str] | None = None, + state: JobState | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, + name: str | None = None, + metadata: dict | None = None, + raise_on_error: bool = True, + wait: int | None = None, + break_lock: bool = False, + ) -> list[int]: + return self._many_jobs_action( + method=self.play_job, + action_description="playing", + job_ids=job_ids, + db_ids=db_ids, + flow_ids=flow_ids, + state=state, + start_date=start_date, + end_date=end_date, + name=name, + metadata=metadata, + raise_on_error=raise_on_error, + wait=wait, + break_lock=break_lock, ) + def play_job( + self, + job_id: str | None = None, + db_id: int | None = None, + job_index: int | None = None, + wait: int | None = None, + break_lock: bool = False, + ) -> list[int]: + + job_lock_kwargs = dict( + projection=["uuid", "index", "db_id", "state", "job.config", "parents"] + ) + flow_lock_kwargs = dict(projection=["uuid"]) + with self.lock_job_flow( + acceptable_states=[JobState.PAUSED], + job_id=job_id, + db_id=db_id, + job_index=job_index, + wait=wait, + break_lock=break_lock, + job_lock_kwargs=job_lock_kwargs, + flow_lock_kwargs=flow_lock_kwargs, + ) as (job_lock, flow_lock): + job_doc = job_lock.locked_document + on_missing = job_doc["job"]["config"]["on_missing_references"] + allow_failed = on_missing != OnMissing.ERROR.value + + # in principle the lock on each of the parent jobs is not needed + # since a parent Job cannot change to COMPLETED or FAILED while + # the flow is locked + for parent in self.jobs.find( + {"uuid": {"$in": job_doc["parents"]}}, projection=["state"] + ): + parent_state = JobState(parent["state"]) + if parent_state != JobState.COMPLETED: + if parent_state == JobState.FAILED and allow_failed: + continue + final_state = JobState.WAITING + break + else: + final_state = JobState.READY + + updated_states = {job_id: {job_index: final_state}} + self.update_flow_state( + flow_uuid=flow_lock.locked_document["uuid"], + updated_states=updated_states, + ) + job_lock.update_on_release = {"$set": {"state": final_state.value}} + return [job_lock.locked_document["db_id"]] + + def set_job_run_properties( + self, + worker: str | None = None, + exec_config: str | ExecutionConfig | dict | None = None, + resources: dict | QResources | None = None, + update: bool = True, + job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, + db_ids: int | list[int] | None = None, + flow_ids: str | list[str] | None = None, + state: JobState | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, + name: str | None = None, + metadata: dict | None = None, + raise_on_error: bool = True, + ) -> list[int]: + set_dict = {} + if worker: + if worker not in self.project.workers: + raise ValueError(f"worker {worker} is not present in the project") + set_dict["worker"] = worker + + if exec_config: + if ( + isinstance(exec_config, str) + and exec_config not in self.project.exec_config + ): + raise ValueError( + f"exec_config {exec_config} is not present in the project" + ) + elif isinstance(exec_config, ExecutionConfig): + exec_config = exec_config.dict() + + if update and isinstance(exec_config, dict): + for k, v in exec_config.items(): + set_dict[f"exec_config.{k}"] = v + else: + set_dict["exec_config"] = exec_config + + if resources: + if isinstance(resources, QResources): + resources = resources.as_dict() + if update: + for k, v in resources.items(): + set_dict[f"resources.{k}"] = v + else: + set_dict["resources"] = resources + + return self._many_jobs_action( + method=self._set_job_properties, + action_description="setting", + job_ids=job_ids, + db_ids=db_ids, + flow_ids=flow_ids, + state=state, + start_date=start_date, + end_date=end_date, + name=name, + metadata=metadata, + raise_on_error=raise_on_error, + values=set_dict, + acceptable_states=[JobState.READY, JobState.WAITING], + ) + + def get_flow_job_aggreg( + self, + query: dict | None = None, + projection: dict | None = None, + sort: list[tuple] | None = None, + limit: int = 0, + ) -> list[dict]: + + pipeline: list[dict] = [ + { + "$lookup": { + "from": self.jobs_collection, + "localField": "jobs", + "foreignField": "uuid", + "as": "jobs_list", + } + } + ] + + if query: + pipeline.append({"$match": query}) + + if projection: + pipeline.append({"$project": projection}) + + if sort: + pipeline.append({"$sort": {k: v for (k, v) in sort}}) + + if limit: + pipeline.append({"$limit": limit}) + + return list(self.flows.aggregate(pipeline)) + def get_flows_info( self, job_ids: str | list[str] | None = None, @@ -455,8 +1070,9 @@ def get_flows_info( name: str | None = None, sort: list[tuple] | None = None, limit: int = 0, + full: bool = False, ) -> list[FlowInfo]: - query = self._build_query_wf( + query = self._build_query_flow( job_ids=job_ids, db_ids=db_ids, flow_ids=flow_ids, @@ -466,9 +1082,20 @@ def get_flows_info( name=name, ) - data = self.rlpad.get_wf_fw_data( - query=query, sort=sort, limit=limit, projection=flow_info_projection - ) + # Only use the full aggregation if more job details are needed. + # The single flow document is enough for basic information + if full: + # TODO reduce the projection to the bare minimum to reduce the amount of + # fecthed data? + projection = {f"jobs_list.{f}": 1 for f in projection_job_info} + for k in FlowDoc.model_fields.keys(): + projection[k] = 1 + + data = self.get_flow_job_aggreg( + query=query, sort=sort, limit=limit, projection=projection + ) + else: + data = list(self.flows.find(query, sort=sort, limit=limit)) jobs_data = [] for d in data: @@ -478,50 +1105,58 @@ def get_flows_info( def delete_flows( self, - job_ids: str | list[str] | None = None, - db_ids: int | list[int] | None = None, - ): - if (job_ids is None) == (db_ids is None): + flow_ids: str | list[str] | None = None, + confirm: bool = False, + delete_output: bool = False, + ) -> int: + + if isinstance(flow_ids, str): + flow_ids = [flow_ids] + + if flow_ids is None: + flow_ids = [f["uuid"] for f in self.flows.find({}, projection=["uuid"])] + + if len(flow_ids) > 10 and not confirm: raise ValueError( - "One and only one among job_ids and db_ids should be defined" + "Deleting more than 10 flows requires explicit confirmation" ) + deleted = 0 + for fid in flow_ids: + # TODO should it catch errors? + if self.delete_flow(fid, delete_output): + deleted += 1 - if job_ids: - ids_list: str | int | list = job_ids - arg = "job_id" - else: - ids_list = db_ids - arg = "fw_id" + return deleted - if not isinstance(ids_list, (list, tuple)): - ids_list = [ids_list] - for jid in ids_list: - try: - # the fireworks launchpad has "print" in it for the out. Capture it - # to avoid exposing Fireworks output - with redirect_stdout(io.StringIO()): - self.rlpad.delete_wf(**{arg: jid}) - except ValueError as e: - logger.warning(f"Error while deleting flow: {getattr(e, 'message', e)}") + def delete_flow(self, flow_id: str, delete_output: bool = False): + # TODO should this lock anything (FW does not lock)? + flow = self.get_flow_info_by_flow_uuid(flow_id) + if not flow: + return False + job_ids = flow["jobs"] + if delete_output: + self.jobstore.remove_docs({"uuid": {"$in": job_ids}}) + + self.jobs.delete_many({"uuid": {"$in": job_ids}}) + self.flows.delete_one({"uuid": flow_id}) + return True - def remove_lock( + def remove_lock_job( self, job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, db_ids: int | list[int] | None = None, flow_ids: str | list[str] | None = None, state: JobState | None = None, - remote_state: RemoteState | None = None, start_date: datetime | None = None, end_date: datetime | None = None, name: str | None = None, metadata: dict | None = None, ) -> int: - query = self._build_query_fw( + query = self._build_query_job( job_ids=job_ids, db_ids=db_ids, flow_ids=flow_ids, state=state, - remote_state=remote_state, start_date=start_date, end_date=end_date, locked=True, @@ -529,4 +1164,812 @@ def remove_lock( metadata=metadata, ) - return self.rlpad.remove_lock(query=query) + result = self.jobs.update_many( + filter=query, + update={"$set": {"lock_id": None, "lock_time": None}}, + ) + return result.modified_count + + def reset(self, reset_output: bool = False, max_limit: int = 25): + # TODO should it just delete docs related to job removed in the reset? + # what if the outputs are in other stores? Should take those as well + if max_limit: + n_flows = self.flows.count_documents({}) + if n_flows >= max_limit: + logger.warning( + f"The database contains {n_flows} flows and will not be reset. " + "Increase the max_limit value or set it to 0" + ) + return False + + if reset_output: + self.jobstore.remove_docs({}) + + self.jobs.drop() + self.flows.drop() + self.id_generator.drop() + self.id_generator.insert_one({"next_id": 1}) + self.build_indexes() + return True + + def build_indexes( + self, + background=True, + job_custom_indexes: list[str | list] | None = None, + flow_custom_indexes: list[str | list] | None = None, + ): + """ + Build indexes + """ + + self.jobs.create_index("db_id", unique=True, background=background) + + job_indexes = [ + "uuid", + "index", + "state", + "updated_on", + "name", + "worker", + [("priority", pymongo.DESCENDING)], + ["state", "remote.retry_time_limit"], + ] + for f in job_indexes: + self.jobs.create_index(f, background=background) + + if job_custom_indexes: + for idx in job_custom_indexes: + self.jobs.create_index(idx, background=background) + + flow_indexes = [ + "uuid", + "name", + "state", + "updated_on", + "ids", + "jobs", + ] + + for idx in flow_indexes: + self.flows.create_index(idx, background=background) + + if flow_custom_indexes: + for idx in flow_custom_indexes: + self.flows.create_index(idx, background=background) + + def compact(self): + self.db.command({"compact": self.jobs_collection}) + self.db.command({"compact": self.flows_collection}) + + def get_flow_info_by_flow_uuid( + self, flow_uuid: str, projection: list | dict | None = None + ): + return self.flows.find_one({"uuid": flow_uuid}, projection=projection) + + def get_flow_info_by_job_uuid( + self, job_uuid: str, projection: list | dict | None = None + ): + return self.flows.find_one({"jobs": job_uuid}, projection=projection) + + def get_job_info_by_job_uuid( + self, + job_uuid: str, + job_index: int | str = "last", + projection: list | dict | None = None, + ): + query: dict[str, Any] = {"uuid": job_uuid} + sort = None + if isinstance(job_index, int): + query["index"] = job_index + elif job_index == "last": + sort = {"index": -1} + else: + raise ValueError(f"job_index value: {job_index} is not supported") + return self.jobs.find_one(query, projection=projection, sort=sort) + + def get_job_doc_by_job_uuid(self, job_uuid: str, job_index: int | str = "last"): + query: dict[str, Any] = {"uuid": job_uuid} + sort = None + if isinstance(job_index, int): + query["index"] = job_index + elif job_index == "last": + sort = [("index", -1)] + else: + raise ValueError(f"job_index value: {job_index} is not supported") + doc = self.jobs.find_one(query, sort=sort) + if doc: + return JobDoc.model_validate(doc) + return None + + def get_jobs(self, query, projection: list | dict | None = None): + return list(self.jobs.find(query, projection=projection)) + + def get_jobs_info_by_flow_uuid( + self, flow_uuid, projection: list | dict | None = None + ): + query = {"job.hosts": flow_uuid} + return list(self.jobs.find(query, projection=projection)) + + # TODO exec_config and resources can be removed and taken from the job. + # The value could be set in each job by the submit_job or by the user. + # Doing the same for the worker? In this way it could be set dynamically + def add_flow( + self, + flow: jobflow.Flow | jobflow.Job | list[jobflow.Job], + worker: str, + allow_external_references: bool = False, + exec_config: ExecutionConfig | None = None, + resources: dict | QResources | None = None, + ) -> list[int]: + from jobflow.core.flow import get_flow + + flow = get_flow(flow, allow_external_references=allow_external_references) + + jobs_list = list(flow.iterflow()) + job_dicts = [] + n_jobs = len(jobs_list) + first_id = self.id_generator.find_one_and_update( + {}, {"$inc": {"next_id": n_jobs}} + )["next_id"] + db_ids = [] + for (job, parents), db_id in zip(jobs_list, range(first_id, first_id + n_jobs)): + db_ids.append(db_id) + job_dicts.append( + get_initial_job_doc_dict( + job, + parents, + db_id, + worker=worker, + exec_config=exec_config, + resources=resources, + ) + ) + + flow_doc = get_initial_flow_doc_dict(flow, job_dicts) + + # inserting first the flow document and, iteratively, all the jobs + # should not lead to inconsistencies in the states, even if one of + # the jobs is checked out in the meanwhile. The opposite could lead + # to errors. + self.flows.insert_one(flow_doc) + self.jobs.insert_many(job_dicts) + + logger.info(f"Added flow ({flow.uuid}) with jobs: {flow.job_uuids}") + + return db_ids + + def _append_flow( + self, + job_doc: JobDoc, + flow_dict: dict, + new_flow: jobflow.Flow | jobflow.Job | list[jobflow.Job], + worker: str, + response_type: DynamicResponseType, + exec_config: ExecutionConfig | None = None, + resources: QResources | None = None, + ): + from jobflow.core.flow import get_flow + + new_flow = get_flow(new_flow, allow_external_references=True) + + # get job parents and set the previous hosts + if response_type == DynamicResponseType.REPLACE: + job_parents = job_doc.parents + else: + job_parents = [(job_doc.uuid, job_doc.index)] + if job_doc.job.hosts: + new_flow.add_hosts_uuids(job_doc.job.hosts) + + # add new jobs to flow + flow_dict = dict(flow_dict) + flow_dict["jobs"].extend(new_flow.job_uuids) + + # add new jobs + jobs_list = list(new_flow.iterflow()) + n_new_jobs = len(jobs_list) + first_id = self.id_generator.find_one_and_update( + {}, {"$inc": {"next_id": n_new_jobs}} + )["next_id"] + job_dicts = [] + for (job, parents), db_id in zip( + jobs_list, range(first_id, first_id + n_new_jobs) + ): + # inherit the parents of the job to which we are appending + parents = parents if parents else job_parents + job_dicts.append( + get_initial_job_doc_dict( + job, + parents, + db_id, + worker=worker, + exec_config=exec_config, + resources=resources, + ) + ) + if job.index > 1: + flow_dict["parents"][job.uuid][str(job.index)] = parents + else: + flow_dict["parents"][job.uuid] = {str(job.index): parents} + flow_dict["ids"].append((job_dicts[-1]["db_id"], job.uuid, job.index)) + + if response_type == DynamicResponseType.DETOUR: + # if detour, update the parents of the child jobs + leaf_uuids = [v for v, d in new_flow.graph.out_degree() if d == 0] + self.jobs.update_many( + {"parents": job_doc.uuid}, {"$push": {"parents": {"$each": leaf_uuids}}} + ) + + flow_dict["updated_on"] = datetime.utcnow() + + # TODO, this could be replaced by the actual change, instead of the replace + self.flows.find_one_and_replace(flow_dict, key="uuid") + self.jobs.insert_many(job_dicts) + + logger.info(f"Appended flow ({new_flow.uuid}) with jobs: {new_flow.job_uuids}") + + def checkout_job( + self, + query=None, + flow_uuid: str = None, + sort: list[tuple[str, int]] | None = None, + ) -> tuple[str, int] | None: + """ + Check out one job. + + Set the job state from READY to CHECKED_OUT with an atomic update. + Flow state is also updated if needed. + + NB: flow is not locked during the checkout at any time. + Does not require lock of the Job document. + """ + # comment on locking: lock during check out may serve two purposes: + # 1) update the state of the Flow object. With the conditional set + # this should be fine even without locking + # 2) to prevent checking out jobs while other processes may be working + # on the same flow. (e.g. while rerunning a parent of a READY child, + # it would be necessary that the job is not started in the meanwhile). + # Without a full Flow lock this case may show up. + # For the time being do not lock the flow and check if issues are arising. + + query = {} if query is None else dict(query) + query.update({"state": JobState.READY.value}) + + if flow_uuid is not None: + # if flow uuid provided, only include job ids in that flow + job_uuids = self.get_flow_info_by_flow_uuid(flow_uuid, ["jobs"])["jobs"] + query["uuid"] = {"$in": job_uuids} + + if sort is None: + sort = [("priority", pymongo.DESCENDING), ("created_on", pymongo.ASCENDING)] + + result = self.jobs.find_one_and_update( + query, + { + "$set": { + "state": JobState.CHECKED_OUT.value, + "updated_on": datetime.utcnow(), + } + }, + projection=["uuid", "index"], + sort=sort, + # return_document=ReturnDocument.AFTER, + ) + + if not result: + return None + + reserved_uuid = result["uuid"] + reserved_index = result["index"] + + # update flow state. If it is READY switch its state, otherwise no change + # to the state. The operation is atomic. + # Filtering on the index is not needed + state_cond = { + "$cond": { + "if": {"$eq": ["$state", "READY"]}, + "then": "RUNNING", + "else": "$state", + } + } + updated_cond = { + "$cond": { + "if": {"$eq": ["$state", "READY"]}, + "then": datetime.utcnow(), + "else": "$updated_on", + } + } + self.flows.find_one_and_update( + {"jobs": reserved_uuid}, + [{"$set": {"state": state_cond, "updated_on": updated_cond}}], + ) + + return reserved_uuid, reserved_index + + def complete_job( + self, job_doc: JobDoc, local_path: Path | str, store: JobStore + ) -> bool: + # Don't sleep if the flow is locked. Only the Runner should call this, + # and it will handle the fact of having a locked Flow. + # Lock before reading the data. locks the Flow for a longer time, but + # avoids parsing (potentially large) files to discover that the flow is + # already locked. + with self.lock_flow( + filter={"jobs": job_doc.uuid}, get_locked_doc=True + ) as flow_lock: + if flow_lock.locked_document: + local_path = Path(local_path) + out_path = local_path / OUT_FILENAME + if not out_path.exists(): + msg = ( + f"The output file {OUT_FILENAME} was not present in the download " + f"folder {local_path} and it is required to complete the job" + ) + self.checkin_job( + job_doc, flow_lock.locked_document, response=None, error=msg + ) + self.update_flow_state(job_doc.job.hosts[-1]) + return True + + # Do not deserialize the Response to avoid deserializing the + # stored_data + out = loadfn(out_path) + doc_update = {"start_time": out["start_time"]} + # update the time of the JobDoc, will be used in the checkin + end_time = out.get("end_time") + if end_time: + doc_update["end_time"] = end_time + + error = out.get("error") + if error: + self.checkin_job( + job_doc, + flow_lock.locked_document, + response=None, + error=error, + doc_update=doc_update, + ) + self.update_flow_state(job_doc.job.hosts[-1]) + return True + + response = out.get("response") + if not response: + msg = ( + f"The output file {OUT_FILENAME} was downloaded, but it does " + "not contain the response. The job was likely killed " + "before completing" + ) + self.checkin_job( + job_doc, + flow_lock.locked_document, + response=None, + error=msg, + doc_update=doc_update, + ) + self.update_flow_state(job_doc.job.hosts[-1]) + return True + + save = { + k: "output" if v is True else v + for k, v in job_doc.job._kwargs.items() + } + + remote_store = get_remote_store(store, local_path) + remote_store.connect() + update_store(store, remote_store, save) + self.checkin_job( + job_doc, + flow_lock.locked_document, + response=response, + doc_update=doc_update, + ) + self.update_flow_state(job_doc.job.hosts[-1]) + return True + elif flow_lock.unavailable_document: + # raising the error if the lock could not be acquired leaves + # the caller handle the issue. In general, it should be the + # runner, that will retry at a later time. + raise FlowLockedError.from_flow_doc( + flow_lock.unavailable_document, "Could not complete the job" + ) + + return False + + def checkin_job( + self, + job_doc: JobDoc, + flow_dict: dict, + response: jobflow.Response | None, + error: str | None = None, + doc_update: dict | None = None, + ): + + stored_data = None + if response is None: + new_state = JobState.FAILED.value + # handle response + else: + new_state = JobState.COMPLETED.value + if response.replace is not None: + self._append_flow( + job_doc, + flow_dict, + response.replace, + response_type=DynamicResponseType.REPLACE, + worker=job_doc.worker, + ) + + if response.addition is not None: + self._append_flow( + job_doc, + flow_dict, + response.addition, + response_type=DynamicResponseType.ADDITION, + worker=job_doc.worker, + ) + + if response.detour is not None: + self._append_flow( + job_doc, + flow_dict, + response.detour, + response_type=DynamicResponseType.DETOUR, + worker=job_doc.worker, + ) + + if response.stored_data is not None: + from monty.json import jsanitize + + stored_data = jsanitize( + response.stored_data, strict=True, enum_values=True + ) + + if response.stop_children: + self.stop_children(job_doc.uuid) + + if response.stop_jobflow: + self.stop_jobflow(job_uuid=job_doc.uuid) + + if not doc_update: + doc_update = {} + doc_update.update( + {"state": new_state, "stored_data": stored_data, "error": error} + ) + + result = self.jobs.update_one( + {"uuid": job_doc.uuid, "index": job_doc.index}, {"$set": doc_update} + ) + if result.modified_count == 0: + raise RuntimeError( + f"The job {job_doc.uuid} has not been updated in the database" + ) + + job_uuids = self.get_flow_info_by_job_uuid(job_doc.uuid, ["jobs"])["jobs"] + return len(self.refresh_children(job_uuids)) + 1 + + # TODO should this refresh all the kind of states? Or just set to ready? + def refresh_children(self, job_uuids): + # go through and look for jobs whose state we can update to ready + # need to ensure that all parent uuids with all indices are completed + # first find state of all jobs; ensure larger indices are returned last + children = self.jobs.find( + {"uuid": {"$in": job_uuids}}, + sort=[("index", 1)], + projection=["uuid", "index", "parents", "state", "job.config"], + ) + mapping = {r["uuid"]: r for r in children} + + # Now find jobs that are queued and whose parents are all completed + # and ready them. Assume that none of the children can be in a running + # state and thus no need to lock them. + to_ready = [] + for uuid, job in mapping.items(): + allowed_states = [JobState.COMPLETED.value] + on_missing_ref = ( + job.get("job", {}).get("config", {}).get("on_missing_references", None) + ) + if on_missing_ref == jobflow.OnMissing.NONE.value: + allowed_states.extend((JobState.FAILED.value, JobState.CANCELLED.value)) + if job["state"] == JobState.WAITING.value and all( + [mapping[p]["state"] in allowed_states for p in job["parents"]] + ): + to_ready.append(uuid) + + # Here it is assuming that there will be only one job with each uuid, as + # it should be when switching state to READY the first time. + # The code forbids rerunning a job that have children with index larger than 1, + # to this should always be consistent. + if len(to_ready) > 0: + self.jobs.update_many( + {"uuid": {"$in": to_ready}}, {"$set": {"state": JobState.READY.value}} + ) + return to_ready + + def stop_children(self, job_uuid: str) -> int: + result = self.jobs.update_many( + {"parents": job_uuid, "state": JobState.WAITING.value}, + {"$set": {"state": JobState.STOPPED.value}}, + ) + return result.modified_count + + def stop_jobflow(self, job_uuid: str = None, flow_uuid: str = None) -> int: + if job_uuid is None and flow_uuid is None: + raise ValueError("Either job_uuid or flow_uuid must be set.") + + if job_uuid is not None and flow_uuid is not None: + raise ValueError("Only one of job_uuid and flow_uuid should be set.") + + if job_uuid is not None: + criteria = {"jobs": job_uuid} + else: + criteria = {"uuid": flow_uuid} + + # get uuids of jobs in the flow + flow_dict = self.flows.find_one(criteria, projection=["jobs"]) + if not flow_dict: + return 0 + job_uuids = flow_dict["jobs"] + + result = self.jobs.update_many( + {"uuid": {"$in": job_uuids}, "state": JobState.WAITING.value}, + {"$set": {"state": JobState.STOPPED.value}}, + ) + return result.modified_count + + def get_job_uuids(self, flow_uuids: list[str]) -> list[str]: + job_uuids = [] + for flow in self.flows.find_one( + {"uuid": {"$in": flow_uuids}}, projection=["jobs"] + ): + job_uuids.extend(flow["jobs"]) + return job_uuids + + def get_flow_jobs_data( + self, + query: dict | None = None, + projection: dict | None = None, + sort: dict | None = None, + limit: int = 0, + ) -> list[dict]: + + pipeline: list[dict] = [ + { + "$lookup": { + "from": self.jobs_collection, + "localField": "jobs", + "foreignField": "uuid", + "as": "jobs", + } + } + ] + + if query: + pipeline.append({"$match": query}) + + if projection: + pipeline.append({"$project": projection}) + + if sort: + pipeline.append({"$sort": {k: v for (k, v) in sort}}) + + if limit: + pipeline.append({"$limit": limit}) + + return list(self.flows.aggregate(pipeline)) + + def update_flow_state( + self, + flow_uuid: str, + updated_states: dict[str, dict[int, JobState]] | None = None, + ): + updated_states = updated_states or {} + projection = ["uuid", "index", "parents", "state"] + flow_jobs = self.get_jobs_info_by_flow_uuid( + flow_uuid=flow_uuid, projection=projection + ) + + jobs_states = [ + updated_states.get(j["uuid"], {}).get(j["index"], JobState(j["state"])) + for j in flow_jobs + ] + leafs = get_flow_leafs(flow_jobs) + leaf_states = [JobState(j["state"]) for j in leafs] + flow_state = FlowState.from_jobs_states( + jobs_states=jobs_states, leaf_states=leaf_states + ) + set_state = {"$set": {"state": flow_state.value}} + self.flows.find_one_and_update({"uuid": flow_uuid}, set_state) + + @contextlib.contextmanager + def lock_job(self, **lock_kwargs): + with MongoLock(collection=self.jobs, **lock_kwargs) as lock: + yield lock + + @contextlib.contextmanager + def lock_flow(self, **lock_kwargs): + with MongoLock(collection=self.flows, **lock_kwargs) as lock: + yield lock + + @contextlib.contextmanager + def lock_job_for_update( + self, + states, + max_step_attempts, + delta_retry, + additional_filter=None, + **kwargs, + ): + if not isinstance(states, (list, tuple)): + states = [states] + + db_filter = { + "state": {"$in": states}, + "remote.retry_time_limit": {"$not": {"$gt": datetime.utcnow()}}, + } + if additional_filter: + db_filter = deep_merge_dict(db_filter, additional_filter) + + if "sort" not in kwargs: + kwargs["sort"] = [ + ("priority", pymongo.DESCENDING), + ("created_on", pymongo.ASCENDING), + ] + + with self.lock_job( + filter=db_filter, + **kwargs, + ) as lock: + doc = lock.locked_document + + no_retry = False + error = None + try: + yield lock + except ConfigError: + error = traceback.format_exc() + warnings.warn(error) + no_retry = True + except RemoteError as e: + error = f"Remote error: {e.msg}" + no_retry = e.no_retry + except Exception: + error = traceback.format_exc() + warnings.warn(error) + + set_output = lock.update_on_release + + if lock.locked_document: + if not error: + succeeded_update = { + "$set": { + "remote.step_attempts": 0, + "remote.retry_time_limit": None, + "remote.error": None, + } + } + update_on_release = deep_merge_dict( + succeeded_update, set_output or {} + ) + else: + step_attempts = doc["remote"]["step_attempts"] + no_retry = no_retry or step_attempts >= max_step_attempts + if no_retry: + update_on_release = { + "$set": { + "state": JobState.REMOTE_ERROR.value, + "previous_state": doc["state"], + "remote.error": error, + } + } + else: + step_attempts += 1 + ind = min(step_attempts, len(delta_retry)) - 1 + delta = delta_retry[ind] + retry_time_limit = datetime.utcnow() + timedelta(seconds=delta) + update_on_release = { + "$set": { + "remote.step_attempts": step_attempts, + "remote.retry_time_limit": retry_time_limit, + "remote.error": error, + } + } + if "$set" in update_on_release: + update_on_release["$set"]["updated_on"] = datetime.utcnow() + + lock.update_on_release = update_on_release + + @contextlib.contextmanager + def lock_job_flow( + self, + job_id: str | None = None, + db_id: int | None = None, + job_index: int | None = None, + wait: int | None = None, + break_lock: bool = False, + acceptable_states: list[JobState] | None = None, + job_lock_kwargs: dict | None = None, + flow_lock_kwargs: dict | None = None, + ): + lock_filter, sort = self.generate_job_id_query(db_id, job_id, job_index) + sleep = None + if wait: + sleep = 10 + job_lock_kwargs = job_lock_kwargs or {} + flow_lock_kwargs = flow_lock_kwargs or {} + with self.lock_job( + filter=lock_filter, + break_lock=break_lock, + sort=sort, + sleep=sleep, + max_wait=wait, + get_locked_doc=True, + **job_lock_kwargs, + ) as job_lock: + job_doc_dict = job_lock.locked_document + if not job_doc_dict: + if job_lock.unavailable_document: + raise JobLockedError.from_job_doc(job_lock.unavailable_document) + raise ValueError(f"No Job document matching criteria {lock_filter}") + job_state = JobState(job_doc_dict["state"]) + if acceptable_states and job_state not in acceptable_states: + raise ValueError( + f"Job in state {job_doc_dict['state']}. The action cannot be performed" + ) + + flow_filter = {"jobs": job_doc_dict["uuid"]} + with self.lock_flow( + filter=flow_filter, + sleep=sleep, + max_wait=wait, + get_locked_doc=True, + break_lock=break_lock, + **flow_lock_kwargs, + ) as flow_lock: + if not flow_lock.locked_document: + if flow_lock.unavailable_document: + raise FlowLockedError.from_flow_doc( + flow_lock.unavailable_document + ) + raise ValueError( + f"No Flow document matching criteria {flow_filter}" + ) + + yield job_lock, flow_lock + + def ping_flow_doc(self, uuid: str): + self.flows.find_one_and_update( + {"nodes": uuid}, {"$set": {"updated_on": datetime.utcnow()}} + ) + + def _cancel_queue_process(self, job_doc: dict): + queue_process_id = job_doc["remote"]["process_id"] + if not queue_process_id: + raise ValueError("The process id is not defined in the job document") + worker = self.project.workers[job_doc["worker"]] + host = worker.get_host() + try: + host.connect() + queue_manager = QueueManager(worker.get_scheduler_io(), host) + cancel_result = queue_manager.cancel(queue_process_id) + if cancel_result.status != CancelStatus.SUCCESSFUL: + raise RuntimeError( + f"Cancelling queue process {queue_process_id} failed. stdout: {cancel_result.stdout}. stderr: {cancel_result.stderr}" + ) + finally: + try: + host.close() + except Exception: + logger.warning( + f"The connection to host {host} could not be closed.", exc_info=True + ) + + +def get_flow_leafs(job_docs: list[dict]) -> list[dict]: + # first sort the list, so that only the largest indexes are kept in the dictionary + job_docs = sorted(job_docs, key=lambda j: j["index"]) + d = {j["uuid"]: j for j in job_docs} + for j in job_docs: + if j["parents"]: + for parent_id in j["parents"]: + d.pop(parent_id, None) + + return list(d.values()) diff --git a/src/jobflow_remote/jobs/run.py b/src/jobflow_remote/jobs/run.py new file mode 100644 index 00000000..ad17d2ac --- /dev/null +++ b/src/jobflow_remote/jobs/run.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import datetime +import glob +import os +import traceback +from pathlib import Path + +from jobflow import JobStore, initialize_logger +from jobflow.core.job import Job +from monty.os import cd +from monty.serialization import dumpfn, loadfn +from monty.shutil import decompress_file + +from jobflow_remote.jobs.data import IN_FILENAME, OUT_FILENAME +from jobflow_remote.remote.data import ( + default_orjson_serializer, + get_remote_store_filenames, +) + + +def run_remote_job(run_dir: str | Path = "."): + """Run the job""" + + start_time = datetime.datetime.utcnow() + with cd(run_dir): + + error = None + try: + dumpfn({"start_time": start_time}, OUT_FILENAME) + in_data = loadfn(IN_FILENAME) + + job: Job = in_data["job"] + store = in_data["store"] + + # needs to be set here again since it does not get properly serialized. + # it is possible to serialize the default function before serializing, but + # avoided that to avoid that any refactoring of the + # default_orjson_serializer breaks the deserialization of old Fireworks + store.docs_store.serialization_default = default_orjson_serializer + for additional_store in store.additional_stores.values(): + additional_store.serialization_default = default_orjson_serializer + + store.connect() + + initialize_logger() + try: + response = job.run(store=store) + finally: + # some jobs may have compressed the FW files while being executed, + # try to decompress them if that is the case. + decompress_files(store) + + # The output of the response has already been stored in the store. + response.output = None + output = { + "response": response, + "error": error, + "start_time": start_time, + "end_time": datetime.datetime.utcnow(), + } + dumpfn(output, OUT_FILENAME) + except Exception: + # replicate the dump to catch potential errors in + # serializing/dumping the response. + error = traceback.format_exc() + output = { + "response": None, + "error": error, + "start_time": start_time, + "end_time": datetime.datetime.utcnow(), + } + dumpfn(output, OUT_FILENAME) + + +def decompress_files(store: JobStore): + file_names = [OUT_FILENAME] + file_names.extend(get_remote_store_filenames(store)) + + for fn in file_names: + # If the file is already present do not decompress it, even if + # a compressed version is present. + if os.path.isfile(fn): + continue + for f in glob.glob(fn + ".*"): + decompress_file(f) diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index df22c082..a84e0838 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -7,16 +7,15 @@ import time import traceback import uuid -import warnings from collections import defaultdict, namedtuple -from datetime import datetime, timedelta +from datetime import datetime from pathlib import Path -from fireworks import Firework, FWorker +from fireworks import FWorker from monty.os import makedirs_p -from monty.serialization import loadfn from qtoolkit.core.data_objects import QState, SubmissionStatus +from jobflow_remote import JobController from jobflow_remote.config.base import ( ConfigError, ExecutionConfig, @@ -26,26 +25,16 @@ WorkerBase, ) from jobflow_remote.config.manager import ConfigManager -from jobflow_remote.fireworks.launcher import rapidfire_checkout -from jobflow_remote.fireworks.launchpad import ( - FW_UUID_PATH, - REMOTE_DOC_PATH, - RemoteLaunchPad, - get_job_doc, - get_remote_doc, -) -from jobflow_remote.fireworks.tasks import RemoteJobFiretask -from jobflow_remote.jobs.state import RemoteState +from jobflow_remote.jobs.data import IN_FILENAME, OUT_FILENAME, JobDoc, RemoteError +from jobflow_remote.jobs.state import JobState from jobflow_remote.remote.data import ( get_job_path, - get_remote_files, + get_remote_in_file, get_remote_store, get_remote_store_filenames, ) from jobflow_remote.remote.host import BaseHost from jobflow_remote.remote.queue import ERR_FNAME, OUT_FNAME, QueueManager, set_name_out -from jobflow_remote.utils.data import deep_merge_dict -from jobflow_remote.utils.db import MongoLock from jobflow_remote.utils.log import initialize_runner_logger logger = logging.getLogger(__name__) @@ -69,7 +58,7 @@ def __init__( self.config_manager: ConfigManager = ConfigManager() self.project_name = project_name self.project: Project = self.config_manager.get_project(project_name) - self.rlpad: RemoteLaunchPad = self.project.get_launchpad() + self.job_controller: JobController = JobController.from_project(self.project) self.fworker: FWorker = FWorker() self.workers: dict[str, WorkerBase] = self.project.workers # Build the dictionary of hosts. The reference is the worker name. @@ -89,6 +78,10 @@ def __init__( log_folder=self.project.log_dir, level=log_level.to_logging(), ) + # TODO it could be better to create a pool of stores that are connected + # How to deal with cases where the connection gets closed? + # how to deal with file based stores? + self.jobstore = self.project.get_jobstore() @property def runner_options(self) -> RunnerOptions: @@ -119,28 +112,8 @@ def get_queue_manager(self, worker_name: str) -> QueueManager: ) return self.queue_managers[worker_name] - def get_fw_data(self, fw_doc: dict) -> JobFWData: - # remove the launches to be able to create the FW instance without - # accessing the DB again - fw_doc_no_launches = dict(fw_doc) - fw_doc_no_launches["launches"] = [] - fw_doc_no_launches["archived_launches"] = [] - fw = Firework.from_dict(fw_doc_no_launches) - task = fw.tasks[0] - if len(fw.tasks) != 1 and not isinstance(task, RemoteJobFiretask): - raise RuntimeError(f"jobflow-remote cannot handle task {task}") - job = task.get("job") - store = task.get("store") - original_store = store - if store is None: - store = self.project.get_jobstore() - worker_name = task["worker"] - worker = self.get_worker(worker_name) - host = self.get_host(worker_name) - - return JobFWData( - fw, task, job, store, worker_name, worker, host, original_store - ) + def get_store(self, job_doc: JobDoc): + return job_doc.store or self.jobstore def run(self): signal.signal(signal.SIGTERM, self.handle_signal) @@ -170,13 +143,7 @@ def run(self): or last_advance_status + self.runner_options.delay_advance_status < now ): - states = [ - RemoteState.CHECKED_OUT.value, - RemoteState.UPLOADED.value, - RemoteState.TERMINATED.value, - RemoteState.DOWNLOADED.value, - ] - updated = self.lock_and_update(states) + updated = self.advance_state() wait_advance_status = not updated if not updated: last_advance_status = time.time() @@ -185,148 +152,48 @@ def run(self): finally: self.cleanup() - def lock_and_update( - self, - states, - job_id=None, - additional_filter=None, - update=None, - timeout=None, - **kwargs, - ): - if not isinstance(states, (list, tuple)): - states = tuple(states) + def advance_state(self): + states = [ + JobState.CHECKED_OUT.value, + JobState.UPLOADED.value, + JobState.TERMINATED.value, + JobState.DOWNLOADED.value, + ] states_methods = { - RemoteState.CHECKED_OUT: self.upload, - RemoteState.UPLOADED: self.submit, - RemoteState.TERMINATED: self.download, - RemoteState.DOWNLOADED: self.complete_launch, + JobState.CHECKED_OUT: self.upload, + JobState.UPLOADED: self.submit, + JobState.TERMINATED: self.download, + JobState.DOWNLOADED: self.complete_job, } - db_filter = { - f"{REMOTE_DOC_PATH}.state": {"$in": states}, - f"{REMOTE_DOC_PATH}.retry_time_limit": {"$not": {"$gt": datetime.utcnow()}}, - } - if job_id is not None: - db_filter[FW_UUID_PATH] = job_id - if additional_filter: - db_filter = deep_merge_dict(db_filter, additional_filter) - - collection = self.rlpad.fireworks - with MongoLock( - collection=collection, - filter=db_filter, - update=update, - timeout=timeout, - lock_id=self.runner_id, - lock_subdoc=REMOTE_DOC_PATH, - **kwargs, + with self.job_controller.lock_job_for_update( + states=states, + max_step_attempts=self.runner_options.max_step_attempts, + delta_retry=self.runner_options.delta_retry, ) as lock: doc = lock.locked_document if not doc: return False - remote_doc = get_remote_doc(doc) - if not remote_doc: - return False - - state = RemoteState(remote_doc["state"]) - - function = states_methods[state] - - fail_now = False - set_output = None - try: - error, fail_now, set_output = function(doc) - except ConfigError: - error = traceback.format_exc() - warnings.warn(error) - fail_now = True - except Exception: - error = traceback.format_exc() - warnings.warn(error) - lock.update_on_release = self._prepare_lock_update( - doc, error, fail_now, set_output, state.next - ) + state = JobState(doc["state"]) - return True + states_methods[state](lock) + return True - def _prepare_lock_update( - self, - doc: dict, - error: str, - fail_now: bool, - set_output: dict | None, - next_state: RemoteState, - ): - """ - Helper function for preparing the update_on_release for the lock. - Handle the different cases of failures and the retry attempts. - - Parameters - ---------- - doc - error - fail_now - set_output - next_state - - Returns - ------- - - """ - update_on_release = {} - if not error: - # the state.next.value is correct as SUBMITTED is not dealt with here. - succeeded_update = { - "$set": { - f"{REMOTE_DOC_PATH}.state": next_state.value, - f"{REMOTE_DOC_PATH}.step_attempts": 0, - f"{REMOTE_DOC_PATH}.retry_time_limit": None, - f"{REMOTE_DOC_PATH}.error": None, - } - } - update_on_release = deep_merge_dict(succeeded_update, set_output or {}) - else: - remote_doc = get_remote_doc(doc) - step_attempts = remote_doc["step_attempts"] - fail_now = ( - fail_now or step_attempts >= self.runner_options.max_step_attempts - ) - if fail_now: - update_on_release = { - "$set": { - f"{REMOTE_DOC_PATH}.state": RemoteState.FAILED.value, - f"{REMOTE_DOC_PATH}.previous_state": remote_doc["state"], - f"{REMOTE_DOC_PATH}.error": error, - } - } - else: - step_attempts += 1 - delta = self.runner_options.get_delta_retry(step_attempts) - retry_time_limit = datetime.utcnow() + timedelta(seconds=delta) - update_on_release = { - "$set": { - f"{REMOTE_DOC_PATH}.step_attempts": step_attempts, - f"{REMOTE_DOC_PATH}.retry_time_limit": retry_time_limit, - f"{REMOTE_DOC_PATH}.error": error, - } - } - if "$set" in update_on_release: - update_on_release["$set"]["updated_on"] = datetime.utcnow().isoformat() - self.ping_wf_doc(doc["fw_id"]) - - return update_on_release + def upload(self, lock): + doc = lock.locked_document + db_id = doc["db_id"] + logger.debug(f"upload db_id: {db_id}") - def upload(self, doc): - fw_id = doc["fw_id"] - remote_doc = get_remote_doc(doc) - logger.debug(f"upload fw_id: {fw_id}") - fw_job_data = self.get_fw_data(doc) + job_doc = JobDoc(**doc) + job = job_doc.job - job = fw_job_data.job - store = fw_job_data.store + worker = self.get_worker(job_doc.worker) + host = self.get_host(job_doc.worker) + store = self.get_store(job_doc) + # TODO would it be better/feasible to keep a pool of the required + # Stores already connected, to avoid opening and closing them? store.connect() try: job.resolve_args(store=store, inplace=True) @@ -336,63 +203,62 @@ def upload(self, doc): except Exception: logging.error(f"error while closing the store {store}", exc_info=True) - remote_path = get_job_path(job.uuid, fw_job_data.worker.work_dir) + remote_path = get_job_path(job.uuid, worker.work_dir) # Set the value of the original store for dynamical workflow. Usually it # will be None don't add the serializer, at this stage the default_orjson # serializer could undergo refactoring and this could break deserialization # of older FWs. It is set in the FireTask at runtime. - fw = fw_job_data.fw remote_store = get_remote_store( store=store, launch_dir=remote_path, add_orjson_serializer=False ) - fw.tasks[0]["store"] = remote_store - fw.tasks[0]["original_store"] = fw_job_data.original_store - files = get_remote_files(fw, remote_doc["launch_id"]) - self.rlpad.lpad.change_launch_dir(remote_doc["launch_id"], remote_path) - - created = fw_job_data.host.mkdir(remote_path) + created = host.mkdir(remote_path) if not created: err_msg = ( - f"Could not create remote directory {remote_path} for fw_id {fw_id}" + f"Could not create remote directory {remote_path} for db_id {db_id}" ) logger.error(err_msg) - return err_msg, False, None + raise RemoteError(err_msg, no_retry=False) + + serialized_input = get_remote_in_file(job, remote_store, job_doc.store) + + path_file = Path(remote_path, IN_FILENAME) + host.put(serialized_input, str(path_file)) - for fname, fcontent in files.items(): - path_file = Path(remote_path, fname) - fw_job_data.host.write_text_file(path_file, fcontent) + set_output = { + "$set": {"run_dir": remote_path, "state": JobState.UPLOADED.value} + } + lock.update_on_release = set_output - set_output = {"$set": {f"{REMOTE_DOC_PATH}.run_dir": remote_path}} + def submit(self, lock): + doc = lock.locked_document + logger.debug(f"submit db_id: {doc['db_id']}") - return None, False, set_output + job_doc = JobDoc(**doc) + job = job_doc.job - def submit(self, doc): - logger.debug(f"submit fw_id: {doc['fw_id']}") - remote_doc = get_remote_doc(doc) - fw_job_data = self.get_fw_data(doc) + worker = self.get_worker(job_doc.worker) - remote_path = Path(remote_doc["run_dir"]) + remote_path = Path(job_doc.run_dir) - script_commands = ["rlaunch singleshot --offline"] + script_commands = [f"jf execution run {remote_path}"] - worker = fw_job_data.worker - queue_manager = self.get_queue_manager(fw_job_data.worker_name) - resources = fw_job_data.task.get("resources") or worker.resources or {} + queue_manager = self.get_queue_manager(job_doc.worker) + resources = job_doc.resources or worker.resources or {} qout_fpath = remote_path / OUT_FNAME qerr_fpath = remote_path / ERR_FNAME - set_name_out( - resources, fw_job_data.job.name, out_fpath=qout_fpath, err_fpath=qerr_fpath - ) - exec_config = fw_job_data.task.get("exec_config") + set_name_out(resources, job.name, out_fpath=qout_fpath, err_fpath=qerr_fpath) + + exec_config = job_doc.exec_config if isinstance(exec_config, str): exec_config = self.config_manager.get_exec_config( exec_config_name=exec_config, project_name=self.project_name ) elif isinstance(exec_config, dict): - exec_config = ExecutionConfig.parse_obj(exec_config) + exec_config = ExecutionConfig.parse_obj(job_doc.exec_config) + # define an empty default if it is not set exec_config = exec_config or ExecutionConfig() pre_run = worker.pre_run or "" @@ -415,124 +281,115 @@ def submit(self, doc): if submit_result.status == SubmissionStatus.FAILED: err_msg = f"submission failed. {repr(submit_result)}" - return err_msg, False, None + raise RemoteError(err_msg, False) elif submit_result.status == SubmissionStatus.JOB_ID_UNKNOWN: err_msg = f"submission succeeded but ID not known. Job may be running but status cannot be checked. {repr(submit_result)}" - return err_msg, True, None + raise RemoteError(err_msg, True) elif submit_result.status == SubmissionStatus.SUCCESSFUL: - set_output = { - "$set": {f"{REMOTE_DOC_PATH}.process_id": str(submit_result.job_id)} + lock.update_on_release = { + "$set": { + "remote.process_id": str(submit_result.job_id), + "state": JobState.SUBMITTED.value, + } } + else: + raise RemoteError( + f"unhandled submission status {submit_result.status}", True + ) - return None, False, set_output - - raise RuntimeError(f"unhandled submission status {submit_result.status}") - - def download(self, doc): - remote_doc = get_remote_doc(doc) - logger.debug(f"download fw_id: {doc['fw_id']}") - fw_job_data = self.get_fw_data(doc) - job = fw_job_data.job - - remote_path = remote_doc["run_dir"] - loca_base_dir = Path(self.project.tmp_dir, "download") - local_path = get_job_path(job.uuid, loca_base_dir) - - makedirs_p(local_path) - - store = fw_job_data.store - - fnames = ["FW_offline.json"] - fnames.extend(get_remote_store_filenames(store)) - - for fname in fnames: - # in principle fabric should work by just passing the destination folder, - # but it fails - remote_file_path = str(Path(remote_path, fname)) - try: - fw_job_data.host.get(remote_file_path, str(Path(local_path, fname))) - except FileNotFoundError: - # if files are missing it should not retry - err_msg = f"file {remote_file_path} for job {job.uuid} does not exist" - logger.error(err_msg) - return err_msg, True, None + def download(self, lock): + doc = lock.locked_document + logger.debug(f"download db_id: {doc['db_id']}") + + job_doc = JobDoc(**doc) + job = job_doc.job + + # If the worker is local do not copy the files in the temporary folder + # TODO it could be possible to go directly from + # SUBMITTED/RUNNING to DOWNLOADED instead + worker = self.get_worker(job_doc.worker) + if worker.type != "local": + host = self.get_host(job_doc.worker) + store = self.get_store(job_doc) + + remote_path = job_doc.run_dir + local_base_dir = Path(self.project.tmp_dir, "download") + local_path = get_job_path(job.uuid, local_base_dir) + + makedirs_p(local_path) + + fnames = [OUT_FILENAME] + fnames.extend(get_remote_store_filenames(store)) + + for fname in fnames: + # in principle fabric should work by just passing the + # destination folder, but it fails + remote_file_path = str(Path(remote_path, fname)) + try: + host.get(remote_file_path, str(Path(local_path, fname))) + except FileNotFoundError: + # if files are missing it should not retry + err_msg = ( + f"file {remote_file_path} for job {job.uuid} does not exist" + ) + logger.error(err_msg) + raise RemoteError(err_msg, True) - return None, False, None + lock.update_on_release = {"$set": {"state": JobState.DOWNLOADED.value}} - def complete_launch(self, doc): - remote_doc = get_remote_doc(doc) - logger.debug(f"complete launch fw_id: {doc['fw_id']}") - fw_job_data = self.get_fw_data(doc) + def complete_job(self, lock): + doc = lock.locked_document + logger.debug(f"complete job db_id: {doc['db_id']}") - loca_base_dir = Path(self.project.tmp_dir, "download") - local_path = get_job_path(fw_job_data.job.uuid, loca_base_dir) + # if the worker is local the files were not copied to the temporary + # folder, but the files could be directly updated + worker = self.get_worker(doc["worker"]) + worker_is_local = worker.type == "local" + if worker_is_local: + local_path = doc["run_dir"] + else: + local_base_dir = Path(self.project.tmp_dir, "download") + local_path = get_job_path(doc["uuid"], local_base_dir) try: - remote_data = loadfn(Path(local_path, "FW_offline.json"), cls=None) - - store = fw_job_data.store - save = { - k: "output" if v is True else v - for k, v in fw_job_data.job._kwargs.items() - } + job_doc = JobDoc(**doc) + store = self.get_store(job_doc) + completed = self.job_controller.complete_job(job_doc, local_path, store) - # TODO add ping data? - remote_store = get_remote_store(store, local_path) - remote_store.connect() - launch, completed = self.rlpad.recover_remote( - remote_status=remote_data, - store=store, - remote_store=remote_store, - save=save, - launch_id=remote_doc["launch_id"], - terminated=True, - ) - - set_output = { - "$set": { - f"{REMOTE_DOC_PATH}.start_time": launch.time_start or None, - f"{REMOTE_DOC_PATH}.end_time": launch.time_end or None, - } - } except json.JSONDecodeError: # if an empty file is copied this error can appear, do not retry err_msg = traceback.format_exc() - return err_msg, True, None + raise RemoteError(err_msg, True) # remove local folder with downloaded files if successfully completed - if completed and self.runner_options.delete_tmp_folder: + if completed and self.runner_options.delete_tmp_folder and not worker_is_local: shutil.rmtree(local_path, ignore_errors=True) if not completed: err_msg = "the parsed output does not contain the required information to complete the job" - return err_msg, True, None - - return None, False, set_output + raise RemoteError(err_msg, True) def check_run_status(self): logger.debug("check_run_status") # check for jobs that could have changed state workers_ids_docs = defaultdict(dict) db_filter = { - f"{REMOTE_DOC_PATH}.state": { - "$in": [RemoteState.SUBMITTED.value, RemoteState.RUNNING.value] - }, - f"{REMOTE_DOC_PATH}.{MongoLock.LOCK_KEY}": {"$exists": False}, - f"{REMOTE_DOC_PATH}.retry_time_limit": {"$not": {"$gt": datetime.utcnow()}}, + "state": {"$in": [JobState.SUBMITTED.value, JobState.RUNNING.value]}, + "lock_id": None, + "remote.retry_time_limit": {"$not": {"$gt": datetime.utcnow()}}, } projection = [ - "fw_id", - f"{REMOTE_DOC_PATH}.launch_id", - FW_UUID_PATH, - f"{REMOTE_DOC_PATH}.process_id", - f"{REMOTE_DOC_PATH}.state", - f"{REMOTE_DOC_PATH}.step_attempts", - "spec._tasks.worker", + "db_id", + "uuid", + "index", + "remote", + "worker", + "state", ] - for doc in self.rlpad.fireworks.find(db_filter, projection): - worker_name = doc["spec"]["_tasks"][0]["worker"] - remote_doc = get_remote_doc(doc) - workers_ids_docs[worker_name][remote_doc["process_id"]] = (doc, remote_doc) + for doc in self.job_controller.get_jobs(db_filter, projection): + worker_name = doc["worker"] + remote_doc = doc["remote"] + workers_ids_docs[worker_name][remote_doc["process_id"]] = doc for worker_name, ids_docs in workers_ids_docs.items(): error = None @@ -552,24 +409,24 @@ def check_run_status(self): ) error = traceback.format_exc() - for doc_id, (doc, remote_doc) in ids_docs.items(): + for doc_id, doc in ids_docs.items(): # TODO if failed should maybe be handled differently? + remote_doc = doc["remote"] qjob = qjobs_dict.get(doc_id) qstate = qjob.state if qjob else None - collection = self.rlpad.fireworks next_state = None start_time = None if ( qstate == QState.RUNNING - and remote_doc["state"] == RemoteState.SUBMITTED.value + and doc["state"] == JobState.SUBMITTED.value ): - next_state = RemoteState.RUNNING + next_state = JobState.RUNNING start_time = datetime.utcnow() logger.debug( f"remote job with id {remote_doc['process_id']} is running" ) elif qstate in [None, QState.DONE, QState.FAILED]: - next_state = RemoteState.TERMINATED + next_state = JobState.TERMINATED logger.debug( f"terminated remote job with id {remote_doc['process_id']}" ) @@ -577,40 +434,45 @@ def check_run_status(self): # reset the step attempts if succeeding in case there was # an error earlier. Setting the state to the same as the # current triggers the update that cleans the state - next_state = RemoteState(remote_doc["state"]) + next_state = JobState(remote_doc["state"]) # the document needs to be updated only in case of error or if a - # next state has been set + # next state has been set. + # Only update if the state did not change in the meanwhile if next_state or error: - lock_filter = { - f"{REMOTE_DOC_PATH}.state": remote_doc["state"], - FW_UUID_PATH: get_job_doc(doc)["uuid"], - } - with MongoLock( - collection=collection, - filter=lock_filter, - lock_subdoc=REMOTE_DOC_PATH, + lock_filter = {"uuid": doc["uuid"], "index": doc["index"]} + with self.job_controller.lock_job_for_update( + states=doc["state"], + additional_filter=lock_filter, + max_step_attempts=self.runner_options.max_step_attempts, + delta_retry=self.runner_options.delta_retry, ) as lock: if lock.locked_document: + if error: + raise RemoteError(error, False) set_output = { "$set": { - f"{REMOTE_DOC_PATH}.queue_state": qstate.value + "remote.queue_state": qstate.value if qstate - else None + else None, + "state": next_state.value, } } if start_time: - set_output["$set"][ - f"{REMOTE_DOC_PATH}.start_time" - ] = start_time - lock.update_on_release = self._prepare_lock_update( - doc, error, False, set_output, next_state - ) + set_output["$set"]["start_time"] = start_time + lock.update_on_release = set_output def checkout(self): - logger.debug("checkout rapidfire") - n = rapidfire_checkout(self.rlpad, self.fworker) - logger.debug(f"checked out {n} jobs") + logger.debug("checkout jobs") + n_checked_out = 0 + while True: + reserved = self.job_controller.checkout_job() + if not reserved: + break + + n_checked_out += 1 + + logger.debug(f"checked out {n_checked_out} jobs") def cleanup(self): for worker_name, host in self.hosts.items(): @@ -621,8 +483,9 @@ def cleanup(self): f"error while closing connection to worker {worker_name}" ) - def ping_wf_doc(self, db_id: int): - # in the WF document the date is a real Date - self.rlpad.workflows.find_one_and_update( - {"nodes": db_id}, {"$set": {"updated_on": datetime.utcnow().isoformat()}} - ) + try: + self.jobstore.close() + except Exception: + logging.exception("error while closing connection to jobstore") + + self.job_controller.close() diff --git a/src/jobflow_remote/jobs/state.py b/src/jobflow_remote/jobs/state.py index fae80141..df73f924 100644 --- a/src/jobflow_remote/jobs/state.py +++ b/src/jobflow_remote/jobs/state.py @@ -3,123 +3,102 @@ from enum import Enum -class RemoteState(Enum): - CHECKED_OUT = "CHECKED_OUT" +class JobState(Enum): + WAITING = "WAITING" + READY = "READY" + CHECKED_OUT = "CHECKED_OUT" # TODO should it be RESERVED? UPLOADED = "UPLOADED" SUBMITTED = "SUBMITTED" RUNNING = "RUNNING" TERMINATED = "TERMINATED" DOWNLOADED = "DOWNLOADED" + REMOTE_ERROR = "REMOTE_ERROR" COMPLETED = "COMPLETED" FAILED = "FAILED" - KILLED = "KILLED" PAUSED = "PAUSED" + STOPPED = "STOPPED" + CANCELLED = "CANCELLED" @property - def next(self): - try: - return remote_states_order[remote_states_order.index(self) + 1] - except Exception: - pass - raise RuntimeError(f"No next state for state {self.name}") - - @property - def previous(self): - try: - prev_index = remote_states_order.index(self) - 1 - if prev_index >= 0: - return remote_states_order[prev_index] - except ValueError: - raise RuntimeError(f"No previous state for state {self.name}") - - -remote_states_order = [ - RemoteState.CHECKED_OUT, - RemoteState.UPLOADED, - RemoteState.SUBMITTED, - RemoteState.RUNNING, - RemoteState.TERMINATED, - RemoteState.DOWNLOADED, - RemoteState.COMPLETED, + def short_value(self) -> str: + return short_state_mapping[self] + + +short_state_mapping = { + JobState.WAITING: "W", + JobState.READY: "R", + JobState.CHECKED_OUT: "CE", + JobState.UPLOADED: "U", + JobState.SUBMITTED: "SU", + JobState.RUNNING: "RU", + JobState.TERMINATED: "T", + JobState.DOWNLOADED: "D", + JobState.REMOTE_ERROR: "RERR", + JobState.COMPLETED: "C", + JobState.FAILED: "F", + JobState.PAUSED: "P", + JobState.STOPPED: "ST", + JobState.CANCELLED: "CA", +} + + +PAUSABLE_STATES = [ + JobState.READY, + JobState.WAITING, ] +PAUSABLE_STATES_V = [s.value for s in PAUSABLE_STATES] -class JobState(Enum): - WAITING = "WAITING" - READY = "READY" - ONGOING = "ONGOING" - REMOTE_ERROR = "REMOTE_ERROR" - COMPLETED = "COMPLETED" - FAILED = "FAILED" - PAUSED = "PAUSED" # Not yet used - STOPPED = "STOPPED" - CANCELLED = "CANCELLED" # Not yet used +RUNNING_STATES = [ + JobState.CHECKED_OUT, + JobState.UPLOADED, + JobState.SUBMITTED, + JobState.RUNNING, + JobState.TERMINATED, + JobState.DOWNLOADED, +] - @classmethod - def from_states( - cls, fw_state: str, remote_state: RemoteState | None = None - ) -> JobState: - if fw_state in ("WAITING", "READY", "COMPLETED", "PAUSED"): - return JobState(fw_state) - elif fw_state in ("RESERVED", "RUNNING"): - if remote_state == RemoteState.FAILED: - return JobState.REMOTE_ERROR - else: - return JobState.ONGOING - elif fw_state == "FIZZLED": - return JobState.FAILED - # When stop_jobflow or stop_children is used in Response, the Firework with - # the corresponding job is set to a DEFUSED state. - elif fw_state == "DEFUSED": - return JobState.STOPPED - - raise ValueError(f"Unsupported FW state {fw_state}") - - def to_states(self) -> tuple[list[str], list[RemoteState] | None]: - if self in (JobState.WAITING, JobState.READY): - return [self.value], None - elif self in (JobState.COMPLETED, JobState.PAUSED): - return [self.value], [RemoteState(self.value)] - elif self == JobState.ONGOING: - return ["RESERVED", "RUNNING"], list(remote_states_order) - elif self == JobState.REMOTE_ERROR: - return ["RESERVED", "RUNNING"], [RemoteState.FAILED] - elif self == JobState.FAILED: - return ["FIZZLED"], [RemoteState.COMPLETED] - elif self == JobState.STOPPED: - return ["DEFUSED"], None - - raise ValueError(f"Unhandled state {self}") +RUNNING_STATES_V = [s.value for s in RUNNING_STATES] - @property - def short_value(self) -> str: - if self == JobState.REMOTE_ERROR: - return "RE" - return self.value[0] +RESETTABLE_STATES = RUNNING_STATES + +RESETTABLE_STATES_V = RUNNING_STATES_V class FlowState(Enum): WAITING = "WAITING" READY = "READY" - ONGOING = "ONGOING" + RUNNING = "RUNNING" COMPLETED = "COMPLETED" FAILED = "FAILED" PAUSED = "PAUSED" STOPPED = "STOPPED" + CANCELLED = "CANCELLED" @classmethod - def from_jobs_states(cls, jobs_states: list[JobState]) -> FlowState: + def from_jobs_states( + cls, jobs_states: list[JobState], leaf_states: list[JobState] + ) -> FlowState: if all(js == JobState.WAITING for js in jobs_states): return cls.WAITING elif all(js in (JobState.WAITING, JobState.READY) for js in jobs_states): return cls.READY - elif all(js == JobState.COMPLETED for js in jobs_states): + # only need to check the leaf states to determine if it is completed, + # in case some intermediate Job failed but children allow missing + # references. + elif all(js == JobState.COMPLETED for js in leaf_states): return cls.COMPLETED - elif any(js in (JobState.FAILED, JobState.REMOTE_ERROR) for js in jobs_states): + # REMOTE_ERROR state does not lead to a failed Flow. Two main reasons: + # 1) it might be a temporary problem and not a final failure of the Flow + # 2) Changing the state of the flow would require locking the Flow + # when applying the change in the remote state. + elif any(js == JobState.FAILED for js in jobs_states): return cls.FAILED - elif all(js == JobState.PAUSED for js in jobs_states): - return cls.PAUSED elif any(js == JobState.STOPPED for js in jobs_states): return cls.STOPPED + elif any(js == JobState.CANCELLED for js in jobs_states): + return cls.CANCELLED + elif any(js == JobState.PAUSED for js in jobs_states): + return cls.PAUSED else: - return cls.ONGOING + return cls.RUNNING diff --git a/src/jobflow_remote/jobs/submit.py b/src/jobflow_remote/jobs/submit.py index 1efc8718..c57b3c26 100644 --- a/src/jobflow_remote/jobs/submit.py +++ b/src/jobflow_remote/jobs/submit.py @@ -5,7 +5,6 @@ from jobflow_remote.config.base import ConfigError, ExecutionConfig from jobflow_remote.config.manager import ConfigManager -from jobflow_remote.fireworks.convert import flow_to_workflow def submit_flow( @@ -63,14 +62,12 @@ def submit_flow( exec_config_name=exec_config, project_name=project ) - wf = flow_to_workflow( - flow, + jc = proj_obj.get_job_controller() + + jc.add_flow( + flow=flow, worker=worker, - store=store, exec_config=exec_config, resources=resources, allow_external_references=allow_external_references, ) - - rlpad = proj_obj.get_launchpad() - rlpad.add_wf(wf) diff --git a/src/jobflow_remote/remote/data.py b/src/jobflow_remote/remote/data.py index 65d5e53d..7f292ebd 100644 --- a/src/jobflow_remote/remote/data.py +++ b/src/jobflow_remote/remote/data.py @@ -1,12 +1,15 @@ from __future__ import annotations +import io import logging import os from pathlib import Path from typing import Any +import orjson from jobflow.core.store import JobStore from maggma.stores.mongolike import JSONStore +from monty.json import jsanitize from jobflow_remote.utils.data import uuid_to_path @@ -21,14 +24,14 @@ def get_job_path(job_id: str, base_path: str | Path | None = None) -> str: return str(base_path / relative_path) -def get_remote_files(fw, launch_id): - files = { - # TODO handle binary data? - "FW.json": fw.to_format(f_format="json"), - "FW_offline.json": f'{{"launch_id": {launch_id}}}', - } - - return files +def get_remote_in_file(job, remote_store, original_store): + d = jsanitize( + {"job": job, "store": remote_store, "original_store": original_store}, + strict=True, + allow_bson=True, + enum_values=True, + ) + return io.BytesIO(orjson.dumps(d, default=default_orjson_serializer)) def default_orjson_serializer(obj: Any) -> Any: diff --git a/src/jobflow_remote/remote/host/base.py b/src/jobflow_remote/remote/host/base.py index 0f57667a..47e59141 100644 --- a/src/jobflow_remote/remote/host/base.py +++ b/src/jobflow_remote/remote/host/base.py @@ -27,8 +27,6 @@ def execute( path where the command will be executed. """ - # TODO: define a common error that is raised or a returned in case the procedure - # fails to avoid handling different kind of errors for the different hosts raise NotImplementedError @abc.abstractmethod @@ -36,15 +34,11 @@ def mkdir( self, directory: str | Path, recursive: bool = True, exist_ok: bool = True ) -> bool: """Create directory on the host.""" - # TODO: define a common error that is raised or a returned in case the procedure - # fails to avoid handling different kind of errors for the different hosts raise NotImplementedError @abc.abstractmethod def write_text_file(self, filepath, content): """Write content to a file on the host.""" - # TODO: define a common error that is raised or a returned in case the procedure - # fails to avoid handling different kind of errors for the different hosts raise NotImplementedError @abc.abstractmethod @@ -85,21 +79,6 @@ def test(self) -> str | None: return msg - def _check_connected(self) -> bool: - """ - Helper method to determine if a connection is open or raise otherwise. - - Returns - ------- - True if the connection is open. - """ - - if not self.is_connected: - raise HostError( - "The host should be connected before executing this operation" - ) - return True - class HostError(Exception): pass diff --git a/src/jobflow_remote/remote/host/remote.py b/src/jobflow_remote/remote/host/remote.py index 5a225673..05f75b11 100644 --- a/src/jobflow_remote/remote/host/remote.py +++ b/src/jobflow_remote/remote/host/remote.py @@ -226,3 +226,24 @@ def _execute_remote_func(self, remote_cmd, *args, **kwargs): self._create_connection() self.connect() return remote_cmd(*args, **kwargs) + + def _check_connected(self) -> bool: + """ + Helper method to determine if fabric consider the connection open and + open it otherwise + + Since many operations requiring connections happen in the runner, + if the connection drops there are cases where the host may not be + reconnected. To avoid this issue always try to reconnect automatically + if the connection is not open. + + Returns + ------- + True if the connection is open. + """ + + if not self.is_connected: + # Note: raising here instead of reconnecting demonstrated to be a + # problem for how the queue managers are handled in the Runner. + self.connect() + return True diff --git a/src/jobflow_remote/remote/queue.py b/src/jobflow_remote/remote/queue.py index bae572f9..84e554ad 100644 --- a/src/jobflow_remote/remote/queue.py +++ b/src/jobflow_remote/remote/queue.py @@ -107,7 +107,7 @@ def get_pre_run(self, pre_run: str | list[str] | None) -> str: return "\n".join(pre_run) return pre_run - def get_export(self, exports: dict | None) -> str: + def get_export(self, exports: dict | None) -> str | None: if not exports: return None exports_str = [] @@ -115,7 +115,7 @@ def get_export(self, exports: dict | None) -> str: exports_str.append(f"export {k}={v}") return "\n".join(exports_str) - def get_modules(self, modules: list[str] | None) -> str: + def get_modules(self, modules: list[str] | None) -> str | None: if not modules: return None modules_str = [] diff --git a/src/jobflow_remote/utils/db.py b/src/jobflow_remote/utils/db.py index cc1a46ca..8b9ee8ac 100644 --- a/src/jobflow_remote/utils/db.py +++ b/src/jobflow_remote/utils/db.py @@ -2,9 +2,13 @@ import copy import logging +import time import warnings from collections import defaultdict -from datetime import datetime, timedelta +from datetime import datetime + +from jobflow.utils import suuid +from pymongo import ReturnDocument from jobflow_remote.utils.data import deep_merge_dict @@ -13,105 +17,128 @@ class MongoLock: - LOCK_KEY = "_lock_id" - LOCK_TIME_KEY = "_lock_time" + LOCK_KEY = "lock_id" + LOCK_TIME_KEY = "lock_time" def __init__( self, collection, filter, update=None, - timeout=None, break_lock=False, lock_id=None, - lock_subdoc="", + sleep=None, + max_wait=600, + projection=None, + get_locked_doc=False, **kwargs, ): self.collection = collection self.filter = filter or {} self.update = update - self.timeout = timeout self.break_lock = break_lock self.locked_document = None - self.lock_id = lock_id or str(id(self)) - if lock_subdoc and not lock_subdoc.endswith("."): - lock_subdoc = lock_subdoc + "." - self.lock_subdoc = lock_subdoc + self.unavailable_document = None + self.lock_id = lock_id or suuid() self.kwargs = kwargs self.update_on_release: dict = {} - - @property - def lock_key(self) -> str: - return f"{self.lock_subdoc}{self.LOCK_KEY}" - - @property - def lock_time_key(self) -> str: - return f"{self.lock_subdoc}{self.LOCK_TIME_KEY}" + self.sleep = sleep + self.max_wait = max_wait + self.projection = projection + self.get_locked_doc = get_locked_doc def get_lock_time(self, d: dict): - keys = self.lock_time_key.split(".") - for k in keys: - d = d.get(k, {}) - return d + return d.get(self.LOCK_TIME_KEY) def get_lock_id(self, d: dict): - keys = self.lock_id.split(".") - for k in keys: - d = d.get(k, {}) - return d + return d.get(self.LOCK_KEY) def acquire(self): # Set the lock expiration time now = datetime.utcnow() db_filter = copy.deepcopy(self.filter) - lock_limit = None - if not self.break_lock: - lock_filter = {self.lock_key: {"$exists": False}} - if self.timeout: - lock_limit = now - timedelta(seconds=self.timeout) - time_filter = {self.lock_time_key: {"$lt": lock_limit}} - combined_filter = {"$or": [lock_filter, time_filter]} - if "$or" in db_filter: - db_filter["$and"] = [db_filter, combined_filter] - else: - db_filter.update(combined_filter) - else: - db_filter.update(lock_filter) + projection = self.projection + # if projecting always get the lock as well + if projection: + projection = list(projection) + projection.extend([self.LOCK_KEY, self.lock_id]) + + # Modify the filter if the document should not be fetched if + # the lock cannot be acquired. Otherwise, keep the original filter. + if not self.break_lock and not self.sleep and not self.get_locked_doc: + db_filter.update({self.LOCK_KEY: None}) - lock_set = {self.lock_key: self.lock_id, self.lock_time_key: now} + # Prepare the update to be performed when acquiring the lock. + # A combination of the input update and the setting of the lock. + lock_set = {self.LOCK_KEY: self.lock_id, self.LOCK_TIME_KEY: now} update = defaultdict(dict) if self.update: update.update(copy.deepcopy(self.update)) update["$set"].update(lock_set) + # If the document should be fetched even if the lock could not be acquired + # the updates should be made conditional. + # Note: sleep needs to fetch the document, otherwise it is impossible to + # determine if the filter did not return any document or if the document + # was locked. + if (self.sleep or self.get_locked_doc) and not self.break_lock: + for operation, dict_vals in update.items(): + for k, v in dict_vals.items(): + cond = { + "$cond": { + "if": {"$gt": [f"${self.LOCK_KEY}", None]}, + "then": f"${k}", + "else": v, + } + } + update[operation][k] = cond + update = [dict(update)] + # Try to acquire the lock by updating the document with a unique identifier # and the lock expiration time - logger.debug(f"acquire lock with filter: {db_filter}") - result = self.collection.find_one_and_update( - db_filter, update, upsert=False, **self.kwargs - ) - - if result: - if lock_limit and self.get_lock_time(result) > lock_limit: - msg = ( - f"The lock was broken. Previous lock id: {self.get_lock_id(result)}" - ) - warnings.warn(msg) - - self.locked_document = result + logger.debug(f"try acquiring lock with filter: {db_filter}") + t0 = time.time() + while True: + result = self.collection.find_one_and_update( + db_filter, + update, + upsert=False, + return_document=ReturnDocument.AFTER, + **self.kwargs, + ) + + if result: + lock_acquired = self.get_lock_id(result) == self.lock_id + if lock_acquired: + self.locked_document = result + break + else: + # if the lock could not be acquired optionally sleep or + # exit if waited for enough time. + if self.sleep and (time.time() - t0) < self.max_wait: + logger.debug("sleeping") + time.sleep(self.sleep) + else: + self.unavailable_document = result + break + else: + # If no document the conditions could not be met. + # Either the requested filter does not find match a document + # or those fitting are locked. + break def release(self, exc_type, exc_val, exc_tb): # Release the lock by removing the unique identifier and lock expiration time - update = {"$unset": {self.lock_key: "", self.lock_time_key: ""}} + update = {"$set": {self.LOCK_KEY: None, self.LOCK_TIME_KEY: None}} # TODO maybe set on release only if no exception was raised? if self.update_on_release: update = deep_merge_dict(update, self.update_on_release) logger.debug(f"release lock with update: {update}") # TODO if failed to release the lock maybe retry before failing result = self.collection.update_one( - {"_id": self.locked_document["_id"], self.lock_key: self.lock_id}, + {"_id": self.locked_document["_id"], self.LOCK_KEY: self.lock_id}, update, upsert=False, ) @@ -131,3 +158,33 @@ def __exit__(self, exc_type, exc_val, exc_tb): if self.locked_document: self.release(exc_type, exc_val, exc_tb) + + +class LockedDocumentError(Exception): + """ + Exception to signal a problem when locking the document + """ + + +class JobLockedError(LockedDocumentError): + @classmethod + def from_job_doc(cls, doc: dict, additional_msg: str | None = None): + lock_id = doc[MongoLock.LOCK_KEY] + lock_date = doc[MongoLock.LOCK_TIME_KEY] + date_str = lock_date.isoformat(timespec="seconds") if lock_date else None + msg = f"Job with db_id {doc['db_id']} is locked with lock_id {lock_id} since {date_str} UTC." + if additional_msg: + msg += " " + additional_msg + return cls(msg) + + +class FlowLockedError(LockedDocumentError): + @classmethod + def from_flow_doc(cls, doc: dict, additional_msg: str | None = None): + lock_id = doc[MongoLock.LOCK_KEY] + lock_date = doc[MongoLock.LOCK_TIME_KEY] + date_str = lock_date.isoformat(timespec="seconds") if lock_date else None + msg = f"Flow with uuid {doc['uuid']} is locked with lock_id {lock_id} since {date_str} UTC." + if additional_msg: + msg += " " + additional_msg + return cls(msg) diff --git a/src/jobflow_remote/utils/log.py b/src/jobflow_remote/utils/log.py index 9dec2ec3..6aa9a46e 100644 --- a/src/jobflow_remote/utils/log.py +++ b/src/jobflow_remote/utils/log.py @@ -4,26 +4,24 @@ import logging.config from pathlib import Path +from monty.os import makedirs_p + def initialize_runner_logger(log_folder: str | Path, level: int = logging.INFO): - """Initialize the default logger. + """ + Initialize the runner logger. Parameters ---------- level The log level. - - Returns - ------- - Logger - A logging instance with customized formatter and handlers. """ - # TODO expose other configuration options? - - # TODO if the directory it is not present it does not initialize the logger with - # an unclear error message. It may be worth leaving this: - # makedirs_p(log_folder) + # If the directory it is not present it does not initialize the logger with + # an unclear error message. Since this is only for the runner logger + # it should not be a problem to check that the directory exists when the + # runner is started. + makedirs_p(log_folder) config = { "version": 1, @@ -60,3 +58,56 @@ def initialize_runner_logger(log_folder: str | Path, level: int = logging.INFO): } logging.config.dictConfig(config) + + +def initialize_cli_logger(level: int = logging.WARNING, full_exc_info: bool = True): + """ + Initialize the logger for the CLI based on rich. + + Parameters + ---------- + level + The log level. + """ + + config = { + "version": 1, + "disable_existing_loggers": True, + "formatters": { + # "standard": {"format": "%(message)s", "datefmt": "[%X]"}, + "cli_formatter": { + "()": lambda: CLIFormatter( + log_exception_trace=full_exc_info, datefmt="[%X]" + ), + } + }, + "handlers": { + "rich": { + "class": "rich.logging.RichHandler", + "level": level, + "formatter": "cli_formatter", + "show_path": False, + }, + }, + "loggers": { + "jobflow_remote": { # root logger + "handlers": ["rich"], + "level": level, + "propagate": False, + }, + }, + } + + logging.config.dictConfig(config) + + +class CLIFormatter(logging.Formatter): + def __init__(self, log_exception_trace: bool = True, **kwargs): + super().__init__(**kwargs) + self.log_exception_trace = log_exception_trace + + def formatException(self, ei): + if self.log_exception_trace: + return super().formatException(ei) + else: + return f"{ei[0].__name__}: {str(ei[1])}" From 20b9c3323c203cab8079bf8605e73d749abbf73c Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Fri, 10 Nov 2023 16:17:31 +0100 Subject: [PATCH 04/18] fix retry --- src/jobflow_remote/jobs/jobcontroller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index d9496d72..20ecb09b 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -727,7 +727,7 @@ def retry_job( ) raise ValueError(f"No Job matching criteria {lock_filter}") state = JobState(doc["state"]) - if state != JobState.REMOTE_ERROR: + if state == JobState.REMOTE_ERROR: previous_state = doc["previous_state"] try: JobState(previous_state) From 7751ed3381f54c7f3fc035a698b9986170c54256 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Tue, 14 Nov 2023 09:42:46 +0100 Subject: [PATCH 05/18] fix py3.9 --- pyproject.toml | 1 - src/jobflow_remote/cli/flow.py | 6 +-- src/jobflow_remote/jobs/data.py | 72 ++++++++++++++++----------------- 3 files changed, 38 insertions(+), 41 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2107d4a0..d5bfa8e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,6 @@ requires-python = ">=3.9" dependencies =[ "jobflow[strict]", "pydantic>=2.0.1", - "fireworks", "fabric", "tomlkit", "qtoolkit", diff --git a/src/jobflow_remote/cli/flow.py b/src/jobflow_remote/cli/flow.py index 1cfecd26..5c649d47 100644 --- a/src/jobflow_remote/cli/flow.py +++ b/src/jobflow_remote/cli/flow.py @@ -143,14 +143,14 @@ def delete( if not confirmed: raise typer.Exit(0) - to_delete = [fi.db_ids[0] for fi in flows_info] + to_delete = [fi.flow_id for fi in flows_info] with loading_spinner(False) as progress: progress.add_task(description="Deleting...", total=None) - jc.delete_flows(db_ids=to_delete) + jc.delete_flows(flow_ids=to_delete) out_console.print( - f"Deleted Flow(s) with db_id: {', '.join(str(i) for i in to_delete)}" + f"Deleted Flow(s) with id: {', '.join(str(i) for i in to_delete)}" ) diff --git a/src/jobflow_remote/jobs/data.py b/src/jobflow_remote/jobs/data.py index 96f273fe..4fe7e175 100644 --- a/src/jobflow_remote/jobs/data.py +++ b/src/jobflow_remote/jobs/data.py @@ -1,9 +1,8 @@ -from __future__ import annotations - from collections import defaultdict from datetime import datetime, timezone from enum import Enum from functools import cached_property +from typing import Optional, Union from jobflow import Flow, Job, JobStore from monty.json import jsanitize @@ -19,11 +18,11 @@ def get_initial_job_doc_dict( job: Job, - parents: list[str] | None, + parents: Optional[list[str]], db_id: int, worker: str, - exec_config: ExecutionConfig | None, - resources: dict | QResources | None, + exec_config: Optional[ExecutionConfig], + resources: Optional[Union[dict, QResources]], ): from monty.json import jsanitize @@ -50,7 +49,6 @@ def get_initial_job_doc_dict( def get_initial_flow_doc_dict(flow: Flow, job_dicts: list[dict]): - jobs = [j["uuid"] for j in job_dicts] ids = [(j["db_id"], j["uuid"], j["index"]) for j in job_dicts] parents = {j["uuid"]: {"1": j["parents"]} for j in job_dicts} @@ -69,10 +67,10 @@ def get_initial_flow_doc_dict(flow: Flow, job_dicts: list[dict]): class RemoteInfo(BaseModel): step_attempts: int = 0 - queue_state: QState | None = None - process_id: str | None = None - retry_time_limit: datetime | None = None - error: str | None = None + queue_state: Optional[QState] = None + process_id: Optional[str] = None + retry_time_limit: Optional[datetime] = None + error: Optional[str] = None class JobInfo(BaseModel): @@ -83,32 +81,32 @@ class JobInfo(BaseModel): name: str state: JobState remote: RemoteInfo = RemoteInfo() - parents: list[str] | None = None - previous_state: JobState | None = None - error: str | None = None - lock_id: str | None = None - lock_time: datetime | None = None - run_dir: str | None = None - start_time: datetime | None = None - end_time: datetime | None = None + parents: Optional[list[str]] = None + previous_state: Optional[JobState] = None + error: Optional[str] = None + lock_id: Optional[str] = None + lock_time: Optional[datetime] = None + run_dir: Optional[str] = None + start_time: Optional[datetime] = None + end_time: Optional[datetime] = None created_on: datetime = datetime.utcnow() updated_on: datetime = datetime.utcnow() priority: int = 0 - metadata: dict | None = None + metadata: Optional[dict] = None @property def is_locked(self) -> bool: return self.lock_id is not None @property - def run_time(self) -> float | None: + def run_time(self) -> Optional[float]: if self.start_time and self.end_time: return (self.end_time - self.start_time).total_seconds() return None @property - def estimated_run_time(self) -> float | None: + def estimated_run_time(self) -> Optional[float]: if self.start_time: return ( datetime.now(tz=self.start_time.tzinfo) - self.start_time @@ -117,7 +115,7 @@ def estimated_run_time(self) -> float | None: return None @classmethod - def from_query_output(cls, d) -> JobInfo: + def from_query_output(cls, d) -> "JobInfo": job = d.pop("job") for k in ["name", "metadata"]: d[k] = job[k] @@ -151,23 +149,23 @@ class JobDoc(BaseModel): # among the parents, all the index will still be parents. # Note that for just the uuid this condition is not true: JobDocs with # the same uuid but different indexes may have different parents - parents: list[str] | None = None - previous_state: JobState | None = None - error: str | None = None # TODO is there a better way to serialize it? - lock_id: str | None = None - lock_time: datetime | None = None - run_dir: str | None = None - start_time: datetime | None = None - end_time: datetime | None = None + parents: Optional[list[str]] = None + previous_state: Optional[JobState] = None + error: Optional[str] = None # TODO is there a better way to serialize it? + lock_id: Optional[str] = None + lock_time: Optional[datetime] = None + run_dir: Optional[str] = None + start_time: Optional[datetime] = None + end_time: Optional[datetime] = None created_on: datetime = datetime.utcnow() updated_on: datetime = datetime.utcnow() priority: int = 0 - store: JobStore | None = None - exec_config: ExecutionConfig | str | None = None - resources: QResources | dict | None = None + store: Optional[JobStore] = None + exec_config: Optional[Union[ExecutionConfig, str]] = None + resources: Optional[Union[QResources, dict]] = None - stored_data: dict | None = None - history: list[str] | None = None # ? + stored_data: Optional[dict] = None + history: Optional[list[str]] = None # ? def as_db_dict(self): # required since the resources are not serialized otherwise @@ -189,8 +187,8 @@ class FlowDoc(BaseModel): jobs: list[str] state: FlowState name: str - lock_id: str | None = None - lock_time: datetime | None = None + lock_id: Optional[str] = None + lock_time: Optional[datetime] = None created_on: datetime = datetime.utcnow() updated_on: datetime = datetime.utcnow() metadata: dict = Field(default_factory=dict) From d01d71c66645246ebdb2aad08c7825875efc8c9e Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Wed, 15 Nov 2023 11:07:39 +0100 Subject: [PATCH 06/18] split runner execution --- src/jobflow_remote/cli/runner.py | 119 ++++++++++++++++++++---- src/jobflow_remote/jobs/daemon.py | 149 +++++++++++++++++++++++++----- src/jobflow_remote/jobs/run.py | 1 - src/jobflow_remote/jobs/runner.py | 48 ++++++---- src/jobflow_remote/utils/log.py | 11 ++- 5 files changed, 267 insertions(+), 61 deletions(-) diff --git a/src/jobflow_remote/cli/runner.py b/src/jobflow_remote/cli/runner.py index 0bfbbb10..939eeb91 100644 --- a/src/jobflow_remote/cli/runner.py +++ b/src/jobflow_remote/cli/runner.py @@ -7,10 +7,11 @@ from jobflow_remote.cli.jf import app from jobflow_remote.cli.jfr_typer import JFRTyper -from jobflow_remote.cli.types import log_level_opt, runner_num_procs_opt +from jobflow_remote.cli.types import log_level_opt from jobflow_remote.cli.utils import ( exit_with_error_msg, exit_with_warning_msg, + get_config_manager, loading_spinner, out_console, ) @@ -34,7 +35,39 @@ def run( "-pid", help="Set the runner id to the current process pid", ), - ] = True, + ] = False, + transfer: Annotated[ + bool, + typer.Option( + "--transfer", + "-t", + help="Enable the transfer option in the runner", + ), + ] = False, + complete: Annotated[ + bool, + typer.Option( + "--complete", + "-com", + help="Enable the complete option in the runner", + ), + ] = False, + slurm: Annotated[ + bool, + typer.Option( + "--slurm", + "-s", + help="Enable the slurm option in the runner", + ), + ] = False, + checkout: Annotated[ + bool, + typer.Option( + "--checkout", + "-cho", + help="Enable the checkout option in the runner", + ), + ] = False, ): """ Execute the Runner in the foreground. @@ -43,23 +76,54 @@ def run( """ runner_id = os.getpid() if set_pid else None runner = Runner(log_level=log_level, runner_id=str(runner_id)) - runner.run() + if not (transfer or complete or slurm or checkout): + transfer = complete = slurm = checkout = True + + runner.run(transfer=transfer, complete=complete, slurm=slurm, checkout=checkout) @app_runner.command() def start( - num_procs: runner_num_procs_opt = 1, + transfer: Annotated[ + int, + typer.Option( + "--transfer", + "-t", + help="The number of processes dedicated to completing jobs", + ), + ] = 1, + complete: Annotated[ + int, + typer.Option( + "--complete", + "-com", + help="The number of processes dedicated to completing jobs", + ), + ] = 1, + single: Annotated[ + bool, + typer.Option( + "--single", + "-s", + help="Use a single process for the runner", + ), + ] = False, log_level: log_level_opt = LogLevel.INFO.value, ): """ Start the Runner as a daemon """ - dm = DaemonManager() + cm = get_config_manager() + dm = DaemonManager.from_project(cm.get_project()) with loading_spinner(False) as progress: progress.add_task(description="Starting the daemon...", total=None) try: dm.start( - log_level=log_level.value, num_procs=num_procs, raise_on_error=True + num_procs_transfer=transfer, + num_procs_complete=complete, + single=single, + log_level=log_level.value, + raise_on_error=True, ) except DaemonError as e: exit_with_error_msg( @@ -85,7 +149,8 @@ def stop( Each of the Runner processes will stop when finished the task being executed. By default, return immediately """ - dm = DaemonManager() + cm = get_config_manager() + dm = DaemonManager.from_project(cm.get_project()) with loading_spinner(False) as progress: progress.add_task(description="Stopping the daemon...", total=None) try: @@ -109,7 +174,8 @@ def kill(): Send a kill signal to the Runner processes. Return immediately, does not wait for processes to be killed. """ - dm = DaemonManager() + cm = get_config_manager() + dm = DaemonManager.from_project(cm.get_project()) with loading_spinner(False) as progress: progress.add_task(description="Killing the daemon...", total=None) try: @@ -126,7 +192,8 @@ def shutdown(): Shuts down the supervisord process. Note that if the daemon is running it will wait for the daemon to stop. """ - dm = DaemonManager() + cm = get_config_manager() + dm = DaemonManager.from_project(cm.get_project()) with loading_spinner(False) as progress: progress.add_task(description="Shutting down supervisor...", total=None) try: @@ -142,7 +209,10 @@ def status(): """ Fetch the status of the daemon runner """ - dm = DaemonManager() + from jobflow_remote import SETTINGS + + cm = get_config_manager() + dm = DaemonManager.from_project(cm.get_project()) with loading_spinner(): try: current_status = dm.check_status() @@ -154,36 +224,47 @@ def status(): DaemonStatus.STOPPED: "red", DaemonStatus.STOPPING: "gold1", DaemonStatus.SHUT_DOWN: "red", + DaemonStatus.PARTIALLY_RUNNING: "gold1", DaemonStatus.RUNNING: "green", }[current_status] text = Text() text.append("Daemon status: ") text.append(current_status.value.lower(), style=color) out_console.print(text) + if current_status == DaemonStatus.PARTIALLY_RUNNING and SETTINGS.cli_suggestions: + out_console.print( + f"The {current_status.value.lower()} may be present due to the " + "runner stopping or signal a problem with one of the processes " + "of the runner. If the state should be RUNNING, check the detailed" + " status with the 'info' command and consider restarting the runner.", + style="yellow", + ) @app_runner.command() -def pids(): +def info(): """ - Fetch the process ids of the daemon. - Both the supervisord process and the processing running the Runner. + Fetch the information about the process of the daemon. + Contain the supervisord process and the processes running the Runner. """ - dm = DaemonManager() - pids_dict = None + cm = get_config_manager() + dm = DaemonManager.from_project(cm.get_project()) + procs_info_dict = None try: with loading_spinner(): - pids_dict = dm.get_pids() + procs_info_dict = dm.get_processes_info() except DaemonError as e: exit_with_error_msg( f"Error while stopping the daemon: {getattr(e, 'message', e)}" ) - if not pids_dict: + if not procs_info_dict: exit_with_warning_msg("Daemon is not running") table = Table() table.add_column("Process") table.add_column("PID") + table.add_column("State") - for name, pid in pids_dict.items(): - table.add_row(name, str(pid)) + for name, proc_info in procs_info_dict.items(): + table.add_row(name, str(proc_info["pid"]), str(proc_info["state"])) out_console.print(table) diff --git a/src/jobflow_remote/jobs/daemon.py b/src/jobflow_remote/jobs/daemon.py index 029b5195..3930b9dd 100644 --- a/src/jobflow_remote/jobs/daemon.py +++ b/src/jobflow_remote/jobs/daemon.py @@ -37,15 +37,71 @@ [program:runner_daemon] priority=100 -command=jf -p $project runner run +command=jf -p $project runner run -pid --single -log $loglevel autostart=true autorestart=false -numprocs=$num_procs +numprocs=1 process_name=run_jobflow%(process_num)s stopwaitsecs=86400 """ +supervisord_conf_str_split = """ +[unix_http_server] +file=$sock_file + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisord] +logfile=$log_file +logfile_maxbytes=10MB +logfile_backups=5 +loglevel=$loglevel +pidfile=$pid_file +nodaemon=$nodaemon + +[supervisorctl] +serverurl=unix://$sock_file + +[program:runner_daemon_checkout] +priority=100 +command=jf -p $project runner run -pid --checkout -log $loglevel +autostart=true +autorestart=false +numprocs=1 +process_name=run_jobflow_checkout +stopwaitsecs=86400 + +[program:runner_daemon_transfer] +priority=100 +command=jf -p $project runner run -pid --transfer -log $loglevel +autostart=true +autorestart=false +numprocs=$num_procs_transfer +process_name=run_jobflow_transfer%(process_num)s +stopwaitsecs=86400 + +[program:runner_daemon_slurm] +priority=100 +command=jf -p $project runner run -pid --slurm -log $loglevel +autostart=true +autorestart=false +numprocs=1 +process_name=run_jobflow_slurm +stopwaitsecs=86400 + +[program:runner_daemon_complete] +priority=100 +command=jf -p $project runner run -pid --complete -log $loglevel +autostart=true +autorestart=false +numprocs=$num_procs_complete +process_name=run_jobflow_complete%(process_num)s +stopwaitsecs=86400 +""" + + class DaemonError(Exception): pass @@ -54,11 +110,13 @@ class DaemonStatus(Enum): SHUT_DOWN = "SHUT_DOWN" STOPPED = "STOPPED" STOPPING = "STOPPING" + PARTIALLY_RUNNING = "PARTIALLY_RUNNING" RUNNING = "RUNNING" class DaemonManager: - conf_template = Template(supervisord_conf_str) + conf_template_single = Template(supervisord_conf_str) + conf_template_split = Template(supervisord_conf_str_split) def __init__( self, @@ -77,7 +135,7 @@ def from_project(cls, project: Project): return cls(daemon_dir, log_dir, project) @classmethod - def from_project_name(cls, project_name: str): + def from_project_name(cls, project_name: str | None = None): config_manager = ConfigManager() project = config_manager.get_project(project_name) return cls.from_project(project) @@ -180,9 +238,12 @@ def check_status(self) -> DaemonStatus: "supervisord process is running but no daemon process is present" ) - if any(pi.get("state") in RUNNING_STATES for pi in proc_info): + if all(pi.get("state") in RUNNING_STATES for pi in proc_info): return DaemonStatus.RUNNING + if any(pi.get("state") in RUNNING_STATES for pi in proc_info): + return DaemonStatus.PARTIALLY_RUNNING + if all(pi.get("state") in STOPPED_STATES for pi in proc_info): return DaemonStatus.STOPPED @@ -194,13 +255,13 @@ def check_status(self) -> DaemonStatus: raise DaemonError("Could not determine the current status of the daemon") - def get_pids(self) -> dict[str, int] | None: + def get_processes_info(self) -> dict[str, dict] | None: process_active = self.check_supervisord_process() if not process_active: return None - pids = {"supervisord": self.get_supervisord_pid()} + pids = {"supervisord": {"pid": self.get_supervisord_pid(), "state": "RUNNING"}} if not self.sock_filepath.is_socket(): raise DaemonError( @@ -215,31 +276,58 @@ def get_pids(self) -> dict[str, int] | None: ) for pi in proc_info: - pids[pi.get("name")] = pi.get("pid") + pids[pi.get("name")] = {"pid": pi.get("pid"), "state": pi.get("statename")} return pids def write_config( - self, num_procs: int = 1, log_level: str = "info", nodaemon: bool = False + self, + num_procs_transfer: int = 1, + num_procs_complete: int = 1, + single: bool = True, + log_level: str = "info", + nodaemon: bool = False, ): - conf = self.conf_template.substitute( - sock_file=str(self.sock_filepath), - pid_file=str(self.pid_filepath), - log_file=str(self.log_filepath), - num_procs=num_procs, - nodaemon="true" if nodaemon else "false", - project=self.project.name, - loglevel=log_level, - ) + if single: + conf = self.conf_template_single.substitute( + sock_file=str(self.sock_filepath), + pid_file=str(self.pid_filepath), + log_file=str(self.log_filepath), + nodaemon="true" if nodaemon else "false", + project=self.project.name, + loglevel=log_level, + ) + else: + conf = self.conf_template_split.substitute( + sock_file=str(self.sock_filepath), + pid_file=str(self.pid_filepath), + log_file=str(self.log_filepath), + num_procs_transfer=num_procs_transfer, + num_procs_complete=num_procs_complete, + nodaemon="true" if nodaemon else "false", + project=self.project.name, + loglevel=log_level, + ) with open(self.conf_filepath, "w") as f: f.write(conf) def start_supervisord( - self, num_procs: int = 1, log_level: str = "info", nodaemon: bool = False + self, + num_procs_transfer: int = 1, + num_procs_complete: int = 1, + single: bool = True, + log_level: str = "info", + nodaemon: bool = False, ) -> str | None: makedirs_p(self.daemon_dir) makedirs_p(self.log_dir) - self.write_config(num_procs=num_procs, log_level=log_level, nodaemon=nodaemon) + self.write_config( + num_procs_transfer=num_procs_transfer, + num_procs_complete=num_procs_complete, + single=single, + log_level=log_level, + nodaemon=nodaemon, + ) cp = subprocess.run( f"supervisord -c {str(self.conf_filepath)}", shell=True, @@ -271,16 +359,31 @@ def start_processes(self) -> str | None: return None def start( - self, num_procs: int = 1, log_level: str = "info", raise_on_error: bool = False + self, + num_procs_transfer: int = 1, + num_procs_complete: int = 1, + single: bool = True, + log_level: str = "info", + raise_on_error: bool = False, ) -> bool: status = self.check_status() if status == DaemonStatus.RUNNING: error = "Daemon process is already running" elif status == DaemonStatus.SHUT_DOWN: - error = self.start_supervisord(num_procs=num_procs, log_level=log_level) + error = self.start_supervisord( + num_procs_transfer=num_procs_transfer, + num_procs_complete=num_procs_complete, + single=single, + log_level=log_level, + ) elif status == DaemonStatus.STOPPED: self.shut_down(raise_on_error=raise_on_error) - error = self.start_supervisord(num_procs=num_procs, log_level=log_level) + error = self.start_supervisord( + num_procs_transfer=num_procs_transfer, + num_procs_complete=num_procs_complete, + single=single, + log_level=log_level, + ) # else: # error = self.start_processes() elif status == DaemonStatus.STOPPING: diff --git a/src/jobflow_remote/jobs/run.py b/src/jobflow_remote/jobs/run.py index ad17d2ac..ceb80167 100644 --- a/src/jobflow_remote/jobs/run.py +++ b/src/jobflow_remote/jobs/run.py @@ -24,7 +24,6 @@ def run_remote_job(run_dir: str | Path = "."): start_time = datetime.datetime.utcnow() with cd(run_dir): - error = None try: dumpfn({"start_time": start_time}, OUT_FILENAME) diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index 972bcb9f..3b188b4e 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -77,6 +77,7 @@ def __init__( initialize_runner_logger( log_folder=self.project.log_dir, level=log_level.to_logging(), + runner_id=runner_id, ) # TODO it could be better to create a pool of stores that are connected # How to deal with cases where the connection gets closed? @@ -115,12 +116,31 @@ def get_queue_manager(self, worker_name: str) -> QueueManager: def get_store(self, job_doc: JobDoc): return job_doc.store or self.jobstore - def run(self): + def run( + self, + transfer: bool = True, + complete: bool = True, + slurm: bool = True, + checkout: bool = True, + ): signal.signal(signal.SIGTERM, self.handle_signal) - last_checkout_time = 0 - last_check_run_status_time = 0 + last_checkout_time = 0.0 + last_check_run_status_time = 0.0 wait_advance_status = False - last_advance_status = 0 + last_advance_status = 0.0 + + states = [] + if transfer: + states.append(JobState.CHECKED_OUT.value) + states.append(JobState.TERMINATED.value) + if complete: + states.append(JobState.DOWNLOADED.value) + if slurm: + states.append(JobState.UPLOADED.value) + + logger.info( + f"Runner run options: transfer: {transfer} complete: {complete} slurm: {slurm} checkout: {checkout}" + ) try: while True: @@ -128,22 +148,25 @@ def run(self): logger.info("stopping due to sigterm") break now = time.time() - if last_checkout_time + self.runner_options.delay_checkout < now: + if ( + checkout + and last_checkout_time + self.runner_options.delay_checkout < now + ): self.checkout() last_checkout_time = time.time() - elif ( + elif slurm and ( last_check_run_status_time + self.runner_options.delay_check_run_status < now ): self.check_run_status() last_check_run_status_time = time.time() - elif ( + elif (transfer or complete or slurm) and ( not wait_advance_status or last_advance_status + self.runner_options.delay_advance_status < now ): - updated = self.advance_state() + updated = self.advance_state(states) wait_advance_status = not updated if not updated: last_advance_status = time.time() @@ -152,14 +175,7 @@ def run(self): finally: self.cleanup() - def advance_state(self): - states = [ - JobState.CHECKED_OUT.value, - JobState.UPLOADED.value, - JobState.TERMINATED.value, - JobState.DOWNLOADED.value, - ] - + def advance_state(self, states: list[str]): states_methods = { JobState.CHECKED_OUT: self.upload, JobState.UPLOADED: self.submit, diff --git a/src/jobflow_remote/utils/log.py b/src/jobflow_remote/utils/log.py index 4fb41a8c..880b46e5 100644 --- a/src/jobflow_remote/utils/log.py +++ b/src/jobflow_remote/utils/log.py @@ -9,7 +9,9 @@ from monty.os import makedirs_p -def initialize_runner_logger(log_folder: str | Path, level: int = logging.INFO): +def initialize_runner_logger( + log_folder: str | Path, level: int = logging.INFO, runner_id: str | None = None +): """ Initialize the runner logger. @@ -25,11 +27,16 @@ def initialize_runner_logger(log_folder: str | Path, level: int = logging.INFO): # runner is started. makedirs_p(log_folder) + if runner_id: + msg_format = f"%(asctime)s [%(levelname)s] ID {runner_id} %(name)s: %(message)s" + else: + msg_format = "%(asctime)s [%(levelname)s] %(name)s: %(message)s" + config = { "version": 1, "disable_existing_loggers": True, "formatters": { - "standard": {"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"}, + "standard": {"format": msg_format}, }, "handlers": { "default": { From d21d8ad729d020f455adfc844f6bf97623567b31 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Fri, 17 Nov 2023 00:10:29 +0100 Subject: [PATCH 07/18] implement max_jobs per worker. switch to schedule package. Fix for split and pydantic2 --- pyproject.toml | 3 +- src/jobflow_remote/cli/jf.py | 23 ++++ src/jobflow_remote/config/base.py | 30 ++-- src/jobflow_remote/jobs/jobcontroller.py | 17 +-- src/jobflow_remote/jobs/runner.py | 166 +++++++++++++++++------ 5 files changed, 170 insertions(+), 69 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d5bfa8e6..a592e732 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,8 @@ dependencies =[ "rich", "psutil", "supervisor", - "ruamel.yaml" + "ruamel.yaml", + "schedule" ] [project.optional-dependencies] diff --git a/src/jobflow_remote/cli/jf.py b/src/jobflow_remote/cli/jf.py index 99b087c2..0137d047 100644 --- a/src/jobflow_remote/cli/jf.py +++ b/src/jobflow_remote/cli/jf.py @@ -42,6 +42,16 @@ def main( is_eager=True, ), ] = False, + profile: Annotated[ + bool, + typer.Option( + "--profile", + "-prof", + help="Profile the command execution and provide a report at the end. For developers", + is_eager=True, + hidden=True, + ), + ] = False, ): """ The controller CLI for jobflow-remote @@ -51,6 +61,12 @@ def main( if full_exc: SETTINGS.cli_full_exc = True + if profile: + from cProfile import Profile + + profiler = Profile() + profiler.enable() + initialize_cli_logger( level=SETTINGS.cli_log_level.to_logging(), full_exc_info=SETTINGS.cli_full_exc ) @@ -75,3 +91,10 @@ def main( out_console.print(text) except ConfigError as e: out_console.print(f"Current project could not be determined: {e}", style="red") + + if profile: + profiler.disable() + import pstats + + stats = pstats.Stats(profiler).sort_stats("cumtime") + stats.print_stats() diff --git a/src/jobflow_remote/config/base.py b/src/jobflow_remote/config/base.py index 563fdccd..f89339a0 100644 --- a/src/jobflow_remote/config/base.py +++ b/src/jobflow_remote/config/base.py @@ -7,7 +7,7 @@ from jobflow import JobStore from maggma.stores import MongoStore -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator from qtoolkit.io import BaseSchedulerIO, scheduler_mapping from jobflow_remote.remote.host import BaseHost, LocalHost, RemoteHost @@ -34,6 +34,11 @@ class RunnerOptions(BaseModel): 30, description="Delay between subsequent advancement of the job's remote state (seconds)", ) + delay_refresh_limited: int = Field( + 600, + description="Delay between subsequent refresh from the DB of the number of submitted " + "and running jobs (seconds). Only use if a worker with max_jobs is present", + ) lock_timeout: Optional[int] = Field( 86400, description="Time to consider the lock on a document expired and can be overridden (seconds)", @@ -137,11 +142,12 @@ class WorkerBase(BaseModel): max_jobs: Optional[int] = Field( None, description="The maximum number of jobs that can be submitted to the queue.", + ge=0, ) model_config = ConfigDict(extra="forbid") @field_validator("scheduler_type") - def check_scheduler_type(cls, scheduler_type: str, values: dict) -> str: + def check_scheduler_type(cls, scheduler_type: str) -> str: """ Validator to set the default of scheduler_type """ @@ -437,45 +443,45 @@ def get_job_controller(self): return JobController.from_project(self) @field_validator("base_dir") - def check_base_dir(cls, base_dir: str, values: dict) -> str: + def check_base_dir(cls, base_dir: str, info: ValidationInfo) -> str: """ Validator to set the default of base_dir based on the project name """ if not base_dir: from jobflow_remote import SETTINGS - return str(Path(SETTINGS.projects_folder, values["name"])) + return str(Path(SETTINGS.projects_folder, info.data["name"])) return base_dir @field_validator("tmp_dir") - def check_tmp_dir(cls, tmp_dir: str, values: dict) -> str: + def check_tmp_dir(cls, tmp_dir: str, info: ValidationInfo) -> str: """ Validator to set the default of tmp_dir based on the base_dir """ if not tmp_dir: - return str(Path(values["base_dir"], "tmp")) + return str(Path(info.data["base_dir"], "tmp")) return tmp_dir @field_validator("log_dir") - def check_log_dir(cls, log_dir: str, values: dict) -> str: + def check_log_dir(cls, log_dir: str, info: ValidationInfo) -> str: """ Validator to set the default of log_dir based on the base_dir """ if not log_dir: - return str(Path(values["base_dir"], "log")) + return str(Path(info.data["base_dir"], "log")) return log_dir @field_validator("daemon_dir") - def check_daemon_dir(cls, daemon_dir: str, values: dict) -> str: + def check_daemon_dir(cls, daemon_dir: str, info: ValidationInfo) -> str: """ Validator to set the default of daemon_dir based on the base_dir """ if not daemon_dir: - return str(Path(values["base_dir"], "daemon")) + return str(Path(info.data["base_dir"], "daemon")) return daemon_dir @field_validator("jobstore") - def check_jobstore(cls, jobstore: dict, values: dict) -> dict: + def check_jobstore(cls, jobstore: dict) -> dict: """ Check that the jobstore configuration could be converted to a JobStore. """ @@ -492,7 +498,7 @@ def check_jobstore(cls, jobstore: dict, values: dict) -> dict: return jobstore @field_validator("queue") - def check_queue(cls, queue: dict, values: dict) -> dict: + def check_queue(cls, queue: dict) -> dict: """ Check that the queue configuration could be converted to a Store. """ diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index 20ecb09b..fa8d64df 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -1274,6 +1274,9 @@ def get_job_doc_by_job_uuid(self, job_uuid: str, job_index: int | str = "last"): def get_jobs(self, query, projection: list | dict | None = None): return list(self.jobs.find(query, projection=projection)) + def count_jobs(self, query): + return self.jobs.count_documents(query) + def get_jobs_info_by_flow_uuid( self, flow_uuid, projection: list | dict | None = None ): @@ -1780,21 +1783,13 @@ def lock_flow(self, **lock_kwargs): @contextlib.contextmanager def lock_job_for_update( self, - states, + query, max_step_attempts, delta_retry, - additional_filter=None, **kwargs, ): - if not isinstance(states, (list, tuple)): - states = [states] - - db_filter = { - "state": {"$in": states}, - "remote.retry_time_limit": {"$not": {"$gt": datetime.utcnow()}}, - } - if additional_filter: - db_filter = deep_merge_dict(db_filter, additional_filter) + db_filter = dict(query) + db_filter["remote.retry_time_limit"] = {"$not": {"$gt": datetime.utcnow()}} if "sort" not in kwargs: kwargs["sort"] = [ diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index 3b188b4e..f6b53078 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -11,6 +11,7 @@ from datetime import datetime from pathlib import Path +import schedule from fireworks import FWorker from monty.os import makedirs_p from qtoolkit.core.data_objects import QState, SubmissionStatus @@ -72,6 +73,11 @@ def __init__( break else: self.hosts[wname] = new_host + self.limited_workers = { + name: {"max": w.max_jobs, "current": 0} + for name, w in self.workers.items() + if w.max_jobs + } self.queue_managers: dict = {} log_level = log_level if log_level is not None else self.project.log_level initialize_runner_logger( @@ -124,10 +130,6 @@ def run( checkout: bool = True, ): signal.signal(signal.SIGTERM, self.handle_signal) - last_checkout_time = 0.0 - last_check_run_status_time = 0.0 - wait_advance_status = False - last_advance_status = 0.0 states = [] if transfer: @@ -142,39 +144,78 @@ def run( f"Runner run options: transfer: {transfer} complete: {complete} slurm: {slurm} checkout: {checkout}" ) + # run a first call for each case, since schedule will wait for the delay + # to make the first execution. + if checkout: + self.checkout() + schedule.every(self.runner_options.delay_checkout).seconds.do(self.checkout) + + if transfer or slurm or complete: + self.advance_state(states) + schedule.every(self.runner_options.delay_advance_status).seconds.do( + self.advance_state, states=states + ) + + if slurm: + self.check_run_status() + schedule.every(self.runner_options.delay_check_run_status).seconds.do( + self.check_run_status + ) + # Limited workers will only affect the process interacting with the queue + # manager. When a job is submitted or terminated the count in the + # limited_workers can be directly updated, since by construction only one + # process will take care of the queue state. + # The refresh can be run on a relatively high delay since it should only + # account for actions from the user (e.g. rerun, cancel), that can alter + # the number of submitted/running jobs. + if self.limited_workers: + self.refresh_num_current_jobs() + schedule.every(self.runner_options.delay_refresh_limited).seconds.do( + self.refresh_num_current_jobs + ) + + if complete: + self.advance_state(states) + schedule.every(self.runner_options.delay_advance_status).seconds.do( + self.advance_state, states=states + ) + + logger.debug(f"ALL JOBS: {schedule.get_jobs()}") + try: while True: if self.stop_signal: logger.info("stopping due to sigterm") break - now = time.time() - if ( - checkout - and last_checkout_time + self.runner_options.delay_checkout < now - ): - self.checkout() - last_checkout_time = time.time() - elif slurm and ( - last_check_run_status_time - + self.runner_options.delay_check_run_status - < now - ): - self.check_run_status() - last_check_run_status_time = time.time() - elif (transfer or complete or slurm) and ( - not wait_advance_status - or last_advance_status + self.runner_options.delay_advance_status - < now - ): - updated = self.advance_state(states) - wait_advance_status = not updated - if not updated: - last_advance_status = time.time() - + schedule.run_pending() time.sleep(1) finally: self.cleanup() + def _get_limited_worker_query(self, states: list[str]) -> dict | None: + states = [s for s in states if s != JobState.UPLOADED.value] + + available_workers = [w for w in self.workers if w not in self.limited_workers] + for worker, status in self.limited_workers.items(): + if status["current"] < status["max"]: + available_workers.append(worker) + + states_query = {"state": {"$in": states}} + uploaded_query = { + "state": JobState.UPLOADED.value, + "worker": {"$in": available_workers}, + } + + if states and available_workers: + query = {"$or": [states_query, uploaded_query]} + return query + elif states: + return states_query + elif available_workers: + return uploaded_query + + return None + def advance_state(self, states: list[str]): states_methods = { JobState.CHECKED_OUT: self.upload, @@ -183,19 +224,29 @@ def advance_state(self, states: list[str]): JobState.DOWNLOADED: self.complete_job, } - with self.job_controller.lock_job_for_update( - states=states, - max_step_attempts=self.runner_options.max_step_attempts, - delta_retry=self.runner_options.delta_retry, - ) as lock: - doc = lock.locked_document - if not doc: - return False + while True: + # handle the case of workers with limited number of jobs + if self.limited_workers and JobState.UPLOADED.value in states: + logger.debug(f"LIMITED WORKER: {self.limited_workers}") + query = self._get_limited_worker_query(states=states) + logger.debug(f"QUERY: {query}") + if not query: + return + else: + query = {"state": {"$in": states}} - state = JobState(doc["state"]) + with self.job_controller.lock_job_for_update( + query=query, + max_step_attempts=self.runner_options.max_step_attempts, + delta_retry=self.runner_options.delta_retry, + ) as lock: + doc = lock.locked_document + if not doc: + return - states_methods[state](lock) - return True + state = JobState(doc["state"]) + + states_methods[state](lock) def upload(self, lock): doc = lock.locked_document @@ -308,6 +359,8 @@ def submit(self, lock): "state": JobState.SUBMITTED.value, } } + if job_doc.worker in self.limited_workers: + self.limited_workers[job_doc.worker]["current"] += 1 else: raise RemoteError( f"unhandled submission status {submit_result.status}", True @@ -321,8 +374,8 @@ def download(self, lock): job = job_doc.job # If the worker is local do not copy the files in the temporary folder - # TODO it could be possible to go directly from - # SUBMITTED/RUNNING to DOWNLOADED instead + # It should not arrive to this point, since it should go directly + # from SUBMITTED/RUNNING to DOWNLOADED in case of local worker worker = self.get_worker(job_doc.worker) if worker.type != "local": host = self.get_host(job_doc.worker) @@ -442,7 +495,13 @@ def check_run_status(self): f"remote job with id {remote_doc['process_id']} is running" ) elif qstate in [None, QState.DONE, QState.FAILED]: - next_state = JobState.TERMINATED + worker = self.get_worker(worker_name) + # if the worker is local go directly to DOWNLOADED, as files + # are not copied locally + if worker.type != "local": + next_state = JobState.TERMINATED + else: + next_state = JobState.DOWNLOADED logger.debug( f"terminated remote job with id {remote_doc['process_id']}" ) @@ -456,10 +515,13 @@ def check_run_status(self): # next state has been set. # Only update if the state did not change in the meanwhile if next_state or error: - lock_filter = {"uuid": doc["uuid"], "index": doc["index"]} + lock_filter = { + "uuid": doc["uuid"], + "index": doc["index"], + "state": doc["state"], + } with self.job_controller.lock_job_for_update( - states=doc["state"], - additional_filter=lock_filter, + query=lock_filter, max_step_attempts=self.runner_options.max_step_attempts, delta_retry=self.runner_options.delta_retry, ) as lock: @@ -477,6 +539,12 @@ def check_run_status(self): if start_time: set_output["$set"]["start_time"] = start_time lock.update_on_release = set_output + # decrease the amount of jobs running if it is a limited worker + if ( + next_state in (JobState.TERMINATED, JobState.DOWNLOADED) + and worker_name in self.limited_workers + ): + self.limited_workers[doc["worker"]]["current"] -= 1 def checkout(self): logger.debug("checkout jobs") @@ -490,6 +558,14 @@ def checkout(self): logger.debug(f"checked out {n_checked_out} jobs") + def refresh_num_current_jobs(self): + for name, state in self.limited_workers.items(): + query = { + "state": {"$in": [JobState.SUBMITTED.value, JobState.RUNNING.value]}, + "worker": name, + } + state["current"] = self.job_controller.count_jobs(query) + def cleanup(self): for worker_name, host in self.hosts.items(): try: From 13dc947b3498b51eb57da1e9caed9559019d4bcf Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Tue, 21 Nov 2023 14:34:45 +0100 Subject: [PATCH 08/18] first implementation of batch submission --- src/jobflow_remote/cli/execution.py | 66 ++++- src/jobflow_remote/config/base.py | 28 +++ src/jobflow_remote/jobs/batch.py | 112 +++++++++ src/jobflow_remote/jobs/jobcontroller.py | 40 ++- src/jobflow_remote/jobs/run.py | 74 ++++++ src/jobflow_remote/jobs/runner.py | 294 +++++++++++++++++++---- src/jobflow_remote/jobs/state.py | 2 + src/jobflow_remote/remote/data.py | 4 +- src/jobflow_remote/remote/host/base.py | 8 + src/jobflow_remote/remote/host/local.py | 6 + src/jobflow_remote/remote/host/remote.py | 10 + src/jobflow_remote/remote/queue.py | 48 +++- src/jobflow_remote/utils/data.py | 8 +- src/jobflow_remote/utils/log.py | 37 ++- 14 files changed, 671 insertions(+), 66 deletions(-) create mode 100644 src/jobflow_remote/jobs/batch.py diff --git a/src/jobflow_remote/cli/execution.py b/src/jobflow_remote/cli/execution.py index 69596927..4e703d89 100644 --- a/src/jobflow_remote/cli/execution.py +++ b/src/jobflow_remote/cli/execution.py @@ -5,7 +5,7 @@ from jobflow_remote.cli.jf import app from jobflow_remote.cli.jfr_typer import JFRTyper -from jobflow_remote.jobs.run import run_remote_job +from jobflow_remote.jobs.run import run_batch_jobs, run_remote_job app_execution = JFRTyper( name="execution", @@ -21,7 +21,7 @@ def run( run_dir: Annotated[ Optional[str], typer.Argument( - help="The path to the folder where the files to job will be executed", + help="The path to the folder where the files of the job to run will be executed", ), ] = ".", ): @@ -29,3 +29,65 @@ def run( Run the Job in the selected folder based on the """ run_remote_job(run_dir) + + +@app_execution.command() +def run_batch( + base_run_dir: Annotated[ + str, + typer.Argument( + help="The path to the base folder where the jobs will be executed", + ), + ], + files_dir: Annotated[ + str, + typer.Argument( + help="The path to the folder where files for handling the batch jobs will be stored", + ), + ], + process_uuid: Annotated[ + str, + typer.Argument( + help="A uuid representing the batch process", + ), + ], + max_time: Annotated[ + Optional[int], + typer.Option( + "--max-time", + "-mt", + help=( + "The maximum time after which no more jobs will be executed (seconds)" + ), + ), + ] = None, + max_wait: Annotated[ + Optional[int], + typer.Option( + "--max-wait", + "-mw", + help=( + "The maximum time the job will wait before stopping if no jobs are available to run (seconds)" + ), + ), + ] = 60, + max_jobs: Annotated[ + Optional[int], + typer.Option( + "--max-jobs", + "-mj", + help=("The maximum number of jobs that will be executed by the batch job"), + ), + ] = None, +): + """ + Run Jobs in batch mode + """ + run_batch_jobs( + base_run_dir, + files_dir, + process_uuid, + max_time=max_time, + max_wait=max_wait, + max_jobs=max_jobs, + ) diff --git a/src/jobflow_remote/config/base.py b/src/jobflow_remote/config/base.py index f89339a0..5baa5bcf 100644 --- a/src/jobflow_remote/config/base.py +++ b/src/jobflow_remote/config/base.py @@ -105,6 +105,26 @@ def to_logging(self) -> int: }[self] +class BatchConfig(BaseModel): + jobs_handle_dir: Path = Field( + description="Absolute path to a folder that will be used to store information to share with the jobs being executed" + ) + work_dir: Path = Field( + description="Absolute path to a folder where the batch jobs will be executed" + ) + max_jobs: Optional[int] = Field( + None, description="Maximum number of jobs executed in a single run in the queue" + ) + max_wait: Optional[int] = Field( + 60, + description="Maximum time to wait before stopping if no new jobs are available to run (seconds)", + ) + max_time: Optional[int] = Field( + None, + description="Maximum time after which a job will not submit more jobs (seconds). To help avoid hitting the walltime", + ) + + class WorkerBase(BaseModel): """ Base class defining the common field for the different types of Worker. @@ -144,6 +164,10 @@ class WorkerBase(BaseModel): description="The maximum number of jobs that can be submitted to the queue.", ge=0, ) + batch: Optional[BatchConfig] = Field( + None, + description="Options for batch execution. If define the worker will be considered a batch worker", + ) model_config = ConfigDict(extra="forbid") @field_validator("scheduler_type") @@ -191,6 +215,10 @@ def cli_info(self) -> dict: A dictionary with the Worker short information. """ + @property + def is_local(self) -> bool: + return self.type == "local" + class LocalWorker(WorkerBase): """ diff --git a/src/jobflow_remote/jobs/batch.py b/src/jobflow_remote/jobs/batch.py new file mode 100644 index 00000000..ecac73ca --- /dev/null +++ b/src/jobflow_remote/jobs/batch.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import logging +import os +import random +from pathlib import Path + +from flufl.lock import Lock, LockError + +from jobflow_remote.remote.host import BaseHost + +logger = logging.getLogger(__name__) + + +LOCK_DIR = "lock" + +TERMINATED_DIR = "terminated" + +RUNNING_DIR = "running" + +SUBMITTED_DIR = "submitted" + + +class RemoteBatchManager: + """ + + Attributes + ---------- + host : BaseHost + Host where the command should be executed. + """ + + def __init__( + self, + host: BaseHost, + files_dir: str | Path, + ): + self.host = host + self.files_dir = Path(files_dir) + self.submitted_dir = self.files_dir / SUBMITTED_DIR + self.running_dir = self.files_dir / RUNNING_DIR + self.terminated_dir = self.files_dir / TERMINATED_DIR + self.lock_dir = self.files_dir / LOCK_DIR + self._init_files_dir() + + def _init_files_dir(self): + self.host.connect() + self.host.mkdir(self.files_dir) + self.host.mkdir(self.submitted_dir) + self.host.mkdir(self.running_dir) + self.host.mkdir(self.terminated_dir) + self.host.mkdir(self.lock_dir) + + def submit_job(self, job_id: str, index: int): + self.host.write_text_file(self.submitted_dir / f"{job_id}_{index}", "") + + def get_submitted(self) -> int: + return self.host.listdir(self.submitted_dir) + + def get_terminated(self) -> list[tuple[str, int, str]]: + terminated = [] + for i in self.host.listdir(self.terminated_dir): + job_id, index, process_uuid = i.split("_") + index = int(index) + terminated.append((job_id, index, process_uuid)) + return terminated + + def get_running(self) -> list[tuple[str, int, str]]: + running = [] + for i in self.host.listdir(self.running_dir): + job_id, index, process_uuid = i.split("_") + index = int(index) + running.append((job_id, index, process_uuid)) + return running + + def delete_terminated(self, ids: list[tuple[str, int, str]]): + for job_id, index, process_uuid in ids: + self.host.remove(self.terminated_dir / f"{job_id}_{index}_{process_uuid}") + + +class LocalBatchManager: + def __init__(self, files_dir: str | Path, process_id: str): + self.process_id = process_id + self.files_dir = Path(files_dir) + self.submitted_dir = self.files_dir / SUBMITTED_DIR + self.running_dir = self.files_dir / RUNNING_DIR + self.terminated_dir = self.files_dir / TERMINATED_DIR + self.lock_dir = self.files_dir / LOCK_DIR + + def get_job(self) -> str | None: + files = os.listdir(self.submitted_dir) + + while files: + selected = random.choice(files) + try: + with Lock( + str(self.lock_dir / selected), lifetime=60, default_timeout=0 + ): + os.remove(self.submitted_dir / selected) + (self.running_dir / f"{selected}_{self.process_id}").touch() + return selected + except (LockError, FileNotFoundError): + logger.error( + f"Error while locking file {selected}. Will be ignored", + exc_info=True, + ) + files.remove(selected) + return None + + def terminate_job(self, job_id: str, index: int): + os.remove(self.running_dir / f"{job_id}_{index}_{self.process_id}") + (self.terminated_dir / f"{job_id}_{index}_{self.process_id}").touch() diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index fa8d64df..b5dfc6bb 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -53,21 +53,21 @@ def __init__( queue_store: MongoStore, jobstore: JobStore, flows_collection: str = "flows", - id_generator_collection: str = "job_id_generator", + auxiliary_collection: str = "jf_auxiliary", project: Project | None = None, ): self.queue_store = queue_store self.jobstore = jobstore self.jobs_collection = self.queue_store.collection_name self.flows_collection = flows_collection - self.id_generator_collection = id_generator_collection + self.auxiliary_collection = auxiliary_collection # TODO should it connect here? Or the passed stored should be connected? self.queue_store.connect() self.jobstore.connect() self.db = self.queue_store._collection.database self.jobs = self.queue_store._collection self.flows = self.db[self.flows_collection] - self.id_generator = self.db[self.id_generator_collection] + self.auxiliary = self.db[self.auxiliary_collection] self.project = project @classmethod @@ -1177,8 +1177,8 @@ def reset(self, reset_output: bool = False, max_limit: int = 25): self.jobs.drop() self.flows.drop() - self.id_generator.drop() - self.id_generator.insert_one({"next_id": 1}) + self.auxiliary.drop() + self.auxiliary.insert_one({"next_id": 1}) self.build_indexes() return True @@ -1301,8 +1301,10 @@ def add_flow( jobs_list = list(flow.iterflow()) job_dicts = [] n_jobs = len(jobs_list) - first_id = self.id_generator.find_one_and_update( - {}, {"$inc": {"next_id": n_jobs}} + # TODO check if output is None. In that case the DB has not been reset + # raise an error to signal it and propose the solution. + first_id = self.auxiliary.find_one_and_update( + {"next_id": {"$exists": True}}, {"$inc": {"next_id": n_jobs}} )["next_id"] db_ids = [] for (job, parents), db_id in zip(jobs_list, range(first_id, first_id + n_jobs)): @@ -1360,8 +1362,8 @@ def _append_flow( # add new jobs jobs_list = list(new_flow.iterflow()) n_new_jobs = len(jobs_list) - first_id = self.id_generator.find_one_and_update( - {}, {"$inc": {"next_id": n_new_jobs}} + first_id = self.auxiliary.find_one_and_update( + {"next_id": {"$exists": True}}, {"$inc": {"next_id": n_new_jobs}} )["next_id"] job_dicts = [] for (job, parents), db_id in zip( @@ -1945,6 +1947,26 @@ def _cancel_queue_process(self, job_doc: dict): f"The connection to host {host} could not be closed.", exc_info=True ) + def get_batch_processes(self, worker: str) -> dict[str, str]: + result = self.auxiliary.find_one({"batch_processes": {"$exists": True}}) + if result: + return result["batch_processes"].get(worker) + return {} + + def add_batch_process(self, process_id: str, process_uuid: str, worker: str): + self.auxiliary.find_one_and_update( + {"batch_processes": {"$exists": True}}, + {"$push": {f"batch_processes.{worker}.{process_id}": process_uuid}}, + upsert=True, + ) + + def remove_batch_process(self, process_id: str, worker: str): + self.auxiliary.find_one_and_update( + {"batch_processes": {"$exists": True}}, + {"$unset": {f"batch_processes.{worker}.{process_id}": ""}}, + upsert=True, + ) + def get_flow_leafs(job_docs: list[dict]) -> list[dict]: # first sort the list, so that only the largest indexes are kept in the dictionary diff --git a/src/jobflow_remote/jobs/run.py b/src/jobflow_remote/jobs/run.py index ceb80167..0b78696e 100644 --- a/src/jobflow_remote/jobs/run.py +++ b/src/jobflow_remote/jobs/run.py @@ -2,7 +2,10 @@ import datetime import glob +import logging import os +import subprocess +import time import traceback from pathlib import Path @@ -12,16 +15,23 @@ from monty.serialization import dumpfn, loadfn from monty.shutil import decompress_file +from jobflow_remote.jobs.batch import LocalBatchManager from jobflow_remote.jobs.data import IN_FILENAME, OUT_FILENAME from jobflow_remote.remote.data import ( default_orjson_serializer, + get_job_path, get_remote_store_filenames, ) +from jobflow_remote.utils.log import initialize_remote_run_log + +logger = logging.getLogger(__name__) def run_remote_job(run_dir: str | Path = "."): """Run the job""" + initialize_remote_run_log() + start_time = datetime.datetime.utcnow() with cd(run_dir): error = None @@ -72,6 +82,70 @@ def run_remote_job(run_dir: str | Path = "."): dumpfn(output, OUT_FILENAME) +def run_batch_jobs( + base_run_dir: str | Path, + files_dir: str | Path, + process_uuid: str, + max_time: int | None = None, + max_wait: int = 60, + max_jobs: int | None = None, +): + initialize_remote_run_log() + + # TODO the ID should be somehow linked to the queue job + bm = LocalBatchManager(files_dir=files_dir, process_id=process_uuid) + + t0 = time.time() + wait = 0 + sleep_time = 10 + count = 0 + while True: + if max_time and max_time < time.time() - t0: + logger.info("Stopping due to max_time") + return + + if max_wait and wait > max_wait: + logger.info(f"No jobs available for more than {max_wait}. Stopping.") + return + + if max_jobs and count >= max_jobs: + logger.info(f"Maximum number of jobs reached ({max_jobs}). Stopping.") + return + + job_str = bm.get_job() + if not job_str: + time.sleep(sleep_time) + wait += sleep_time + else: + wait = 0 + count += 1 + job_id, index = job_str.split("_") + index = int(index) + logger.info(f"Starting job with id {job_id} and index {index}") + job_path = get_job_path(job_id=job_id, index=index, base_path=base_run_dir) + try: + with cd(job_path): + result = subprocess.run( + ["bash", "submit.sh"], + check=True, + text=True, + capture_output=True, + ) + if result.returncode: + logger.warning( + f"Process for job with id {job_id} and index {index} finished with an error" + ) + # run_remote_job(job_path) + bm.terminate_job(job_id, index) + except Exception: + logger.error( + "Error while running job with id {job_id} and index {index}", + exc_info=True, + ) + else: + logger.info(f"Completed job with id {job_id} and index {index}") + + def decompress_files(store: JobStore): file_names = [OUT_FILENAME] file_names.extend(get_remote_store_filenames(store)) diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index f6b53078..7c975473 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -13,6 +13,7 @@ import schedule from fireworks import FWorker +from jobflow.utils import suuid from monty.os import makedirs_p from qtoolkit.core.data_objects import QState, SubmissionStatus @@ -26,6 +27,7 @@ WorkerBase, ) from jobflow_remote.config.manager import ConfigManager +from jobflow_remote.jobs.batch import RemoteBatchManager from jobflow_remote.jobs.data import IN_FILENAME, OUT_FILENAME, JobDoc, RemoteError from jobflow_remote.jobs.state import JobState from jobflow_remote.remote.data import ( @@ -76,8 +78,14 @@ def __init__( self.limited_workers = { name: {"max": w.max_jobs, "current": 0} for name, w in self.workers.items() - if w.max_jobs + if w.max_jobs and not w.batch } + self.batch_workers = {} + for wname, w in self.workers.items(): + if w.batch is not None: + self.batch_workers[wname] = RemoteBatchManager( + self.hosts[wname], w.batch.jobs_handle_dir + ) self.queue_managers: dict = {} log_level = log_level if log_level is not None else self.project.log_level initialize_runner_logger( @@ -173,6 +181,11 @@ def run( schedule.every(self.runner_options.delay_refresh_limited).seconds.do( self.refresh_num_current_jobs ) + if self.batch_workers: + self.update_batch_jobs() + schedule.every(self.runner_options.delay_check_run_status).seconds.do( + self.update_batch_jobs + ) if complete: self.advance_state(states) @@ -180,8 +193,6 @@ def run( self.advance_state, states=states ) - logger.debug(f"ALL JOBS: {schedule.get_jobs()}") - try: while True: if self.stop_signal: @@ -227,9 +238,7 @@ def advance_state(self, states: list[str]): while True: # handle the case of workers with limited number of jobs if self.limited_workers and JobState.UPLOADED.value in states: - logger.debug(f"LIMITED WORKER: {self.limited_workers}") query = self._get_limited_worker_query(states=states) - logger.debug(f"QUERY: {query}") if not query: return else: @@ -312,10 +321,8 @@ def submit(self, lock): script_commands = [f"jf execution run {remote_path}"] queue_manager = self.get_queue_manager(job_doc.worker) - resources = job_doc.resources or worker.resources or {} qout_fpath = remote_path / OUT_FNAME qerr_fpath = remote_path / ERR_FNAME - set_name_out(resources, job.name, out_fpath=qout_fpath, err_fpath=qerr_fpath) exec_config = job_doc.exec_config if isinstance(exec_config, str): @@ -328,44 +335,75 @@ def submit(self, lock): # define an empty default if it is not set exec_config = exec_config or ExecutionConfig() - pre_run = worker.pre_run or "" - if exec_config.pre_run: - pre_run += "\n" + exec_config.pre_run - post_run = worker.post_run or "" - if exec_config.post_run: - post_run += "\n" + exec_config.post_run - - submit_result = queue_manager.submit( - commands=script_commands, - pre_run=pre_run, - post_run=post_run, - options=resources, - export=exec_config.export, - modules=exec_config.modules, - work_dir=remote_path, - create_submit_dir=False, - ) + if job_doc.worker in self.batch_workers: + resources = {} + set_name_out( + resources, job.name, out_fpath=qout_fpath, err_fpath=qerr_fpath + ) + shell_manager = queue_manager.get_shell_manager() + shell_manager.write_submission_script( + commands=script_commands, + pre_run=exec_config.pre_run, + post_run=exec_config.post_run, + options=resources, + export=exec_config.export, + modules=exec_config.modules, + work_dir=remote_path, + create_submit_dir=False, + ) - if submit_result.status == SubmissionStatus.FAILED: - err_msg = f"submission failed. {repr(submit_result)}" - raise RemoteError(err_msg, False) - elif submit_result.status == SubmissionStatus.JOB_ID_UNKNOWN: - err_msg = f"submission succeeded but ID not known. Job may be running but status cannot be checked. {repr(submit_result)}" - raise RemoteError(err_msg, True) - elif submit_result.status == SubmissionStatus.SUCCESSFUL: + self.batch_workers[job_doc.worker].submit_job( + job_id=job_doc.uuid, index=job_doc.index + ) lock.update_on_release = { "$set": { - "remote.process_id": str(submit_result.job_id), - "state": JobState.SUBMITTED.value, + "state": JobState.BATCH_SUBMITTED.value, } } - if job_doc.worker in self.limited_workers: - self.limited_workers[job_doc.worker]["current"] += 1 else: - raise RemoteError( - f"unhandled submission status {submit_result.status}", True + resources = job_doc.resources or worker.resources or {} + set_name_out( + resources, job.name, out_fpath=qout_fpath, err_fpath=qerr_fpath + ) + + pre_run = worker.pre_run or "" + if exec_config.pre_run: + pre_run += "\n" + exec_config.pre_run + post_run = worker.post_run or "" + if exec_config.post_run: + post_run += "\n" + exec_config.post_run + + submit_result = queue_manager.submit( + commands=script_commands, + pre_run=pre_run, + post_run=post_run, + options=resources, + export=exec_config.export, + modules=exec_config.modules, + work_dir=remote_path, + create_submit_dir=False, ) + if submit_result.status == SubmissionStatus.FAILED: + err_msg = f"submission failed. {repr(submit_result)}" + raise RemoteError(err_msg, False) + elif submit_result.status == SubmissionStatus.JOB_ID_UNKNOWN: + err_msg = f"submission succeeded but ID not known. Job may be running but status cannot be checked. {repr(submit_result)}" + raise RemoteError(err_msg, True) + elif submit_result.status == SubmissionStatus.SUCCESSFUL: + lock.update_on_release = { + "$set": { + "remote.process_id": str(submit_result.job_id), + "state": JobState.SUBMITTED.value, + } + } + if job_doc.worker in self.limited_workers: + self.limited_workers[job_doc.worker]["current"] += 1 + else: + raise RemoteError( + f"unhandled submission status {submit_result.status}", True + ) + def download(self, lock): doc = lock.locked_document logger.debug(f"download db_id: {doc['db_id']}") @@ -377,7 +415,7 @@ def download(self, lock): # It should not arrive to this point, since it should go directly # from SUBMITTED/RUNNING to DOWNLOADED in case of local worker worker = self.get_worker(job_doc.worker) - if worker.type != "local": + if not worker.is_local: host = self.get_host(job_doc.worker) store = self.get_store(job_doc) @@ -413,8 +451,7 @@ def complete_job(self, lock): # if the worker is local the files were not copied to the temporary # folder, but the files could be directly updated worker = self.get_worker(doc["worker"]) - worker_is_local = worker.type == "local" - if worker_is_local: + if worker.is_local: local_path = doc["run_dir"] else: local_base_dir = Path(self.project.tmp_dir, "download") @@ -431,7 +468,7 @@ def complete_job(self, lock): raise RemoteError(err_msg, True) # remove local folder with downloaded files if successfully completed - if completed and self.runner_options.delete_tmp_folder and not worker_is_local: + if completed and self.runner_options.delete_tmp_folder and not worker.is_local: shutil.rmtree(local_path, ignore_errors=True) if not completed: @@ -498,7 +535,7 @@ def check_run_status(self): worker = self.get_worker(worker_name) # if the worker is local go directly to DOWNLOADED, as files # are not copied locally - if worker.type != "local": + if not worker.is_local: next_state = JobState.TERMINATED else: next_state = JobState.DOWNLOADED @@ -566,6 +603,179 @@ def refresh_num_current_jobs(self): } state["current"] = self.job_controller.count_jobs(query) + def update_batch_jobs(self): + logger.debug("update batch jobs") + for worker_name, batch_manager in self.batch_workers.items(): + worker = self.get_worker(worker_name) + try: + # first check the processes that are running from the folder + # and set them to running if needed + running_jobs = batch_manager.get_running() + for job_id, job_index, process_running_uuid in running_jobs: + lock_filter = { + "uuid": job_id, + "index": job_index, + "state": JobState.BATCH_SUBMITTED.value, + } + with self.job_controller.lock_job_for_update( + query=lock_filter, + max_step_attempts=self.runner_options.max_step_attempts, + delta_retry=self.runner_options.delta_retry, + ) as lock: + if lock.locked_document: + set_output = { + "$set": { + "state": JobState.BATCH_RUNNING.value, + "start_time": datetime.utcnow(), + "remote.process_id": process_running_uuid, + } + } + lock.update_on_release = set_output + # Check the processes that should be running on the remote queue + # and update the state in the DB if something changed + batch_processes_data = self.job_controller.get_batch_processes( + worker_name + ) + processes = list(batch_processes_data.keys()) + queue_manager = self.get_queue_manager(worker_name) + if processes: + qjobs = queue_manager.get_jobs_list(processes) + running_processes = {qjob.job_id for qjob in qjobs} + stopped_processes = set(processes) - running_processes + for pid in stopped_processes: + self.job_controller.remove_batch_process(pid, worker_name) + # check if there are jobs that were in the running folder of a + # process that finished and set them to remote error + for job_id, job_index, process_running_uuid in running_jobs: + if batch_processes_data[pid] == process_running_uuid: + lock_filter = { + "uuid": job_id, + "index": job_index, + "state": { + "$in": ( + JobState.BATCH_SUBMITTED.value, + JobState.BATCH_RUNNING.value, + ) + }, + } + with self.job_controller.lock_job_for_update( + query=lock_filter, + max_step_attempts=self.runner_options.max_step_attempts, + delta_retry=self.runner_options.delta_retry, + ) as lock: + if lock.locked_document: + raise RuntimeError( + f"The batch process that was running the job (process_id: {pid}, uuid: {process_running_uuid} was likely killed before terminating the job execution" + ) + + processes = list(running_processes) + # check that enough processes are submitted and submit the required + # amount to reach max_jobs, if needed. + n_jobs = self.job_controller.count_jobs( + { + "state": { + "$in": ( + JobState.BATCH_SUBMITTED.value, + JobState.BATCH_RUNNING.value, + ) + } + } + ) + n_processes = len(processes) + n_jobs_to_submit = min( + max(worker.max_jobs - n_processes, 0), max(n_jobs - n_processes, 0) + ) + logger.debug(f"submitting {n_jobs_to_submit} batch jobs") + for _ in range(n_jobs_to_submit): + resources = worker.resources or {} + process_running_uuid = suuid() + remote_path = Path( + get_job_path(process_running_uuid, None, worker.batch.work_dir) + ) + qout_fpath = remote_path / OUT_FNAME + qerr_fpath = remote_path / ERR_FNAME + set_name_out( + resources, + f"batch_{process_running_uuid}", + out_fpath=qout_fpath, + err_fpath=qerr_fpath, + ) + + # note that here the worker.work_dir needs to be passed, + # not the worker.batch.work_dir + command = f"jf execution run-batch {worker.work_dir} {worker.batch.jobs_handle_dir} {process_running_uuid}" + if worker.batch.max_jobs: + command += f" -mj {worker.batch.max_jobs}" + if worker.batch.max_time: + command += f" -mt {worker.batch.max_time}" + if worker.batch.max_wait: + command += f" -mw {worker.batch.max_wait}" + + submit_result = queue_manager.submit( + commands=[command], + pre_run=worker.pre_run, + post_run=worker.post_run, + options=resources, + work_dir=remote_path, + create_submit_dir=True, + ) + + if submit_result.status == SubmissionStatus.FAILED: + logger.error(f"submission failed. {repr(submit_result)}") + elif submit_result.status == SubmissionStatus.JOB_ID_UNKNOWN: + logger.error( + f"submission succeeded but ID not known. Job may be running but status cannot be checked. {repr(submit_result)}" + ) + + elif submit_result.status == SubmissionStatus.SUCCESSFUL: + self.job_controller.add_batch_process( + submit_result.job_id, process_running_uuid, worker_name + ) + else: + logger.error( + f"unhandled submission status {submit_result.status}", True + ) + + # check for jobs that have terminated in the batch runner and + # update the DB state accordingly + terminated_jobs = batch_manager.get_terminated() + for job_id, job_index, process_running_uuid in terminated_jobs: + lock_filter = { + "uuid": job_id, + "index": job_index, + "state": { + "$in": ( + JobState.BATCH_SUBMITTED.value, + JobState.BATCH_RUNNING.value, + ) + }, + } + with self.job_controller.lock_job_for_update( + query=lock_filter, + max_step_attempts=self.runner_options.max_step_attempts, + delta_retry=self.runner_options.delta_retry, + ) as lock: + if lock.locked_document: + if not worker.is_local: + next_state = JobState.TERMINATED + else: + next_state = JobState.DOWNLOADED + set_output = { + "$set": { + "state": next_state.value, + "remote.process_id": process_running_uuid, + } + } + lock.update_on_release = set_output + batch_manager.delete_terminated( + [(job_id, job_index, process_running_uuid)] + ) + except Exception: + logger.error( + f"error while handling the batch submission for: {worker_name}", + exc_info=True, + ) + def cleanup(self): for worker_name, host in self.hosts.items(): try: diff --git a/src/jobflow_remote/jobs/state.py b/src/jobflow_remote/jobs/state.py index df73f924..0e6c72ff 100644 --- a/src/jobflow_remote/jobs/state.py +++ b/src/jobflow_remote/jobs/state.py @@ -18,6 +18,8 @@ class JobState(Enum): PAUSED = "PAUSED" STOPPED = "STOPPED" CANCELLED = "CANCELLED" + BATCH_SUBMITTED = "BATCH_SUBMITTED" + BATCH_RUNNING = "BATCH_RUNNING" @property def short_value(self) -> str: diff --git a/src/jobflow_remote/remote/data.py b/src/jobflow_remote/remote/data.py index c7a1911f..accd1180 100644 --- a/src/jobflow_remote/remote/data.py +++ b/src/jobflow_remote/remote/data.py @@ -14,7 +14,9 @@ from jobflow_remote.utils.data import uuid_to_path -def get_job_path(job_id: str, index: int, base_path: str | Path | None = None) -> str: +def get_job_path( + job_id: str, index: int | None, base_path: str | Path | None = None +) -> str: if base_path: base_path = Path(base_path) else: diff --git a/src/jobflow_remote/remote/host/base.py b/src/jobflow_remote/remote/host/base.py index 47e59141..a95d1df1 100644 --- a/src/jobflow_remote/remote/host/base.py +++ b/src/jobflow_remote/remote/host/base.py @@ -79,6 +79,14 @@ def test(self) -> str | None: return msg + @abc.abstractmethod + def listdir(self, path: str | Path): + raise NotImplementedError + + @abc.abstractmethod + def remove(self, path: str | Path): + raise NotImplementedError + class HostError(Exception): pass diff --git a/src/jobflow_remote/remote/host/local.py b/src/jobflow_remote/remote/host/local.py index fb17ccdb..ff578c68 100644 --- a/src/jobflow_remote/remote/host/local.py +++ b/src/jobflow_remote/remote/host/local.py @@ -112,3 +112,9 @@ def get(self, src, dst): def copy(self, src, dst): shutil.copy(src, dst) + + def listdir(self, path: str | Path): + return os.listdir(path) + + def remove(self, path: str | Path): + os.remove(path) diff --git a/src/jobflow_remote/remote/host/remote.py b/src/jobflow_remote/remote/host/remote.py index 05f75b11..1e9e41a3 100644 --- a/src/jobflow_remote/remote/host/remote.py +++ b/src/jobflow_remote/remote/host/remote.py @@ -227,6 +227,16 @@ def _execute_remote_func(self, remote_cmd, *args, **kwargs): self.connect() return remote_cmd(*args, **kwargs) + def listdir(self, path: str | Path): + self._check_connected() + + self._execute_remote_func(self.connection.sftp().listdir, str(path)) + + def remove(self, path: str | Path): + self._check_connected() + + self._execute_remote_func(self.connection.sftp().remove, str(path)) + def _check_connected(self) -> bool: """ Helper method to determine if fabric consider the connection open and diff --git a/src/jobflow_remote/remote/queue.py b/src/jobflow_remote/remote/queue.py index 84e554ad..d3ea6fde 100644 --- a/src/jobflow_remote/remote/queue.py +++ b/src/jobflow_remote/remote/queue.py @@ -4,6 +4,7 @@ from qtoolkit.core.data_objects import CancelResult, QJob, QResources, SubmissionResult from qtoolkit.io.base import BaseSchedulerIO +from qtoolkit.io.shell import ShellIO from jobflow_remote.remote.host import BaseHost @@ -146,10 +147,10 @@ def submit( export: dict | None = None, modules: list[str] | None = None, script_fname="submit.sh", - create_submit_dir=False, + create_submit_dir: bool = False, timeout: int | None = None, ) -> SubmissionResult: - script_str = self.get_submission_script( + script_fpath = self.write_submission_script( commands=commands, options=options, work_dir=work_dir, @@ -157,8 +158,38 @@ def submit( post_run=post_run, export=export, modules=modules, + script_fname=script_fname, + create_submit_dir=create_submit_dir, + ) + submit_cmd = self.scheduler_io.get_submit_cmd(script_fpath) + stdout, stderr, returncode = self.execute_cmd( + submit_cmd, work_dir, timeout=timeout + ) + return self.scheduler_io.parse_submit_output( + exit_code=returncode, stdout=stdout, stderr=stderr ) + def write_submission_script( + self, + commands: str | list[str] | None, + options=None, + work_dir=None, + pre_run: str | list[str] | None = None, + post_run: str | list[str] | None = None, + export: dict | None = None, + modules: list[str] | None = None, + script_fname="submit.sh", + create_submit_dir: bool = False, + ): + script_str = self.get_submission_script( + commands=commands, + options=options, + work_dir=work_dir, + pre_run=pre_run, + post_run=post_run, + export=export, + modules=modules, + ) if create_submit_dir and work_dir: created = self.host.mkdir(work_dir, recursive=True, exist_ok=True) if not created: @@ -168,13 +199,7 @@ def submit( else: script_fpath = Path(script_fname) self.host.write_text_file(script_fpath, script_str) - submit_cmd = self.scheduler_io.get_submit_cmd(script_fpath) - stdout, stderr, returncode = self.execute_cmd( - submit_cmd, work_dir, timeout=timeout - ) - return self.scheduler_io.parse_submit_output( - exit_code=returncode, stdout=stdout, stderr=stderr - ) + return script_fpath def cancel(self, job: QJob | int | str, timeout: int | None = None) -> CancelResult: cancel_cmd = self.scheduler_io.get_cancel_cmd(job) @@ -201,3 +226,8 @@ def get_jobs_list( return self.scheduler_io.parse_jobs_list_output( exit_code=returncode, stdout=stdout, stderr=stderr ) + + def get_shell_manager(self): + return QueueManager( + scheduler_io=ShellIO(), host=self.host, timeout_exec=self.timeout_exec + ) diff --git a/src/jobflow_remote/utils/data.py b/src/jobflow_remote/utils/data.py index 0acd0cb5..5adca3fc 100644 --- a/src/jobflow_remote/utils/data.py +++ b/src/jobflow_remote/utils/data.py @@ -77,7 +77,9 @@ def check_dict_keywords(obj: Any, keywords: list[str]) -> bool: return False -def uuid_to_path(uuid: str, index: int = 1, num_subdirs: int = 3, subdir_len: int = 2): +def uuid_to_path( + uuid: str, index: int | None = 1, num_subdirs: int = 3, subdir_len: int = 2 +): u = UUID(uuid) u_hex = u.hex @@ -88,7 +90,9 @@ def uuid_to_path(uuid: str, index: int = 1, num_subdirs: int = 3, subdir_len: in ] # add the index to the final dir name - dir_name = f"{uuid}_{index}" + dir_name = f"{uuid}" + if index is not None: + dir_name += f"_{index}" # Combine root directory and subdirectories to form the final path return os.path.join(*subdirs, dir_name) diff --git a/src/jobflow_remote/utils/log.py b/src/jobflow_remote/utils/log.py index 880b46e5..d5fbf42a 100644 --- a/src/jobflow_remote/utils/log.py +++ b/src/jobflow_remote/utils/log.py @@ -83,7 +83,6 @@ def initialize_cli_logger(level: int = logging.WARNING, full_exc_info: bool = Tr "version": 1, "disable_existing_loggers": True, "formatters": { - # "standard": {"format": "%(message)s", "datefmt": "[%X]"}, "cli_formatter": { "()": lambda: CLIFormatter( log_exception_trace=full_exc_info, datefmt="[%X]" @@ -110,6 +109,42 @@ def initialize_cli_logger(level: int = logging.WARNING, full_exc_info: bool = Tr logging.config.dictConfig(config) +def initialize_remote_run_log(level: int = logging.INFO): + """ + Initialize the logger for the execution of the jobs. + + Parameters + ---------- + level + The log level. + """ + + config = { + "version": 1, + "disable_existing_loggers": True, + "formatters": { + "standard": {"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"}, + }, + "handlers": { + "stream": { + "level": level, + "formatter": "standard", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", # Default is stderr + }, + }, + "loggers": { + "jobflow_remote": { # root logger + "handlers": ["stream"], + "level": level, + "propagate": False, + }, + }, + } + + logging.config.dictConfig(config) + + class CLIFormatter(logging.Formatter): def __init__(self, log_exception_trace: bool = True, **kwargs): super().__init__(**kwargs) From e050ba06e38d65b49f04895dd7e46bab99fd54bd Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Wed, 22 Nov 2023 12:03:06 +0100 Subject: [PATCH 09/18] fixes and updates --- src/jobflow_remote/cli/formatting.py | 34 ++- src/jobflow_remote/cli/job.py | 57 ++++ src/jobflow_remote/config/base.py | 5 + src/jobflow_remote/jobs/batch.py | 11 +- src/jobflow_remote/jobs/data.py | 10 +- src/jobflow_remote/jobs/jobcontroller.py | 19 +- src/jobflow_remote/jobs/run.py | 5 +- src/jobflow_remote/jobs/runner.py | 344 +++++++++++------------ src/jobflow_remote/jobs/submit.py | 6 - src/jobflow_remote/remote/data.py | 4 +- src/jobflow_remote/remote/host/base.py | 2 +- src/jobflow_remote/remote/host/local.py | 7 +- src/jobflow_remote/remote/host/remote.py | 5 +- src/jobflow_remote/utils/data.py | 17 ++ src/jobflow_remote/utils/log.py | 1 + src/jobflow_remote/utils/schedule.py | 58 ++++ 16 files changed, 373 insertions(+), 212 deletions(-) create mode 100644 src/jobflow_remote/utils/schedule.py diff --git a/src/jobflow_remote/cli/formatting.py b/src/jobflow_remote/cli/formatting.py index 9887ce23..4f2419a9 100644 --- a/src/jobflow_remote/cli/formatting.py +++ b/src/jobflow_remote/cli/formatting.py @@ -1,5 +1,8 @@ from __future__ import annotations +import datetime +import time + from monty.json import jsanitize from rich.scope import render_scope from rich.table import Table @@ -9,9 +12,12 @@ from jobflow_remote.config.base import ExecutionConfig, WorkerBase from jobflow_remote.jobs.data import FlowInfo, JobInfo from jobflow_remote.jobs.state import JobState +from jobflow_remote.utils.data import convert_utc_time def get_job_info_table(jobs_info: list[JobInfo], verbosity: int): + time_zone_str = f" [{time.tzname[0]}]" + table = Table(title="Jobs info") table.add_column("DB id") table.add_column("Name") @@ -19,19 +25,19 @@ def get_job_info_table(jobs_info: list[JobInfo], verbosity: int): table.add_column("Job id (Index)") table.add_column("Worker") - table.add_column("Last updated") + table.add_column("Last updated" + time_zone_str) if verbosity >= 1: table.add_column("Queue id") table.add_column("Run time") - table.add_column("Retry time") + table.add_column("Retry time" + time_zone_str) table.add_column("Prev state") if verbosity < 2: table.add_column("Locked") if verbosity >= 2: table.add_column("Lock id") - table.add_column("Lock time") + table.add_column("Lock time" + time_zone_str) for ji in jobs_info: state = ji.state.name @@ -45,7 +51,7 @@ def get_job_info_table(jobs_info: list[JobInfo], verbosity: int): Text.from_markup(state), f"{ji.uuid} ({ji.index})", ji.worker, - ji.updated_on.strftime(fmt_datetime), + convert_utc_time(ji.updated_on).strftime(fmt_datetime), ] if verbosity >= 1: @@ -63,7 +69,7 @@ def get_job_info_table(jobs_info: list[JobInfo], verbosity: int): else: row.append("") row.append( - ji.remote.retry_time_limit.strftime(fmt_datetime) + convert_utc_time(ji.remote.retry_time_limit).strftime(fmt_datetime) if ji.remote.retry_time_limit else None ) @@ -73,7 +79,11 @@ def get_job_info_table(jobs_info: list[JobInfo], verbosity: int): if verbosity >= 2: row.append(str(ji.lock_id)) - row.append(ji.lock_time.strftime(fmt_datetime) if ji.lock_time else None) + row.append( + convert_utc_time(ji.lock_time).strftime(fmt_datetime) + if ji.lock_time + else None + ) table.add_row(*row) @@ -81,13 +91,15 @@ def get_job_info_table(jobs_info: list[JobInfo], verbosity: int): def get_flow_info_table(flows_info: list[FlowInfo], verbosity: int): + time_zone_str = f" [{time.tzname[0]}]" + table = Table(title="Flows info") table.add_column("DB id") table.add_column("Name") table.add_column("State") table.add_column("Flow id") table.add_column("Num Jobs") - table.add_column("Last updated") + table.add_column("Last updated" + time_zone_str) if verbosity >= 1: table.add_column("Workers") @@ -104,7 +116,7 @@ def get_flow_info_table(flows_info: list[FlowInfo], verbosity: int): fi.state.name, fi.flow_id, str(len(fi.job_ids)), - fi.updated_on.strftime(fmt_datetime), + convert_utc_time(fi.updated_on).strftime(fmt_datetime), ] if verbosity >= 1: @@ -119,7 +131,11 @@ def get_flow_info_table(flows_info: list[FlowInfo], verbosity: int): def format_job_info(job_info: JobInfo, show_none: bool = False): - d = job_info.dict(exclude_none=True) + d = job_info.dict(exclude_none=not show_none) + + for k, v in d.items(): + if isinstance(v, datetime.datetime): + d[k] = convert_utc_time(v) d = jsanitize(d, allow_bson=False, enum_values=True) error = d.get("error") diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index dd7539a2..308fedf7 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -2,6 +2,8 @@ from pathlib import Path import typer +from monty.json import jsanitize +from monty.serialization import dumpfn from qtoolkit.core.data_objects import QResources from typing_extensions import Annotated @@ -719,3 +721,58 @@ def resources( resources=resources_value, update=not replace, ) + + +@app_job.command(name="dump", hidden=True) +def job_dump( + job_db_id: job_db_id_arg = None, + job_index: job_index_arg = None, + job_id: job_ids_indexes_opt = None, + db_id: db_ids_opt = None, + flow_id: flow_ids_opt = None, + state: job_state_opt = None, + start_date: start_date_opt = None, + end_date: end_date_opt = None, + name: name_opt = None, + metadata: metadata_opt = None, + days: days_opt = None, + hours: hours_opt = None, + file_path: Annotated[ + str, + typer.Option( + "--path", + "-p", + help="Path to where the file should be dumped", + ), + ] = "jobs_dump.json", +): + """ + Dump to json the documents of the selected Jobs from the DB. For debugging. + """ + + check_incompatible_opt({"start_date": start_date, "days": days, "hours": hours}) + check_incompatible_opt({"end_date": end_date, "days": days, "hours": hours}) + metadata_dict = str_to_dict(metadata) + + job_ids_indexes = get_job_ids_indexes(job_id) + + jc = get_job_controller() + + start_date = get_start_date(start_date, days, hours) + + with loading_spinner(): + jobs_doc = jc.get_jobs_doc( + job_ids=job_ids_indexes, + db_ids=db_id, + flow_ids=flow_id, + state=state, + start_date=start_date, + end_date=end_date, + name=name, + metadata=metadata_dict, + ) + if jobs_doc: + dumpfn(jsanitize(jobs_doc, strict=True, enum_values=True), file_path) + + if not jobs_doc: + exit_with_error_msg("No data matching the request") diff --git a/src/jobflow_remote/config/base.py b/src/jobflow_remote/config/base.py index 5baa5bcf..a03f8d5b 100644 --- a/src/jobflow_remote/config/base.py +++ b/src/jobflow_remote/config/base.py @@ -39,6 +39,11 @@ class RunnerOptions(BaseModel): description="Delay between subsequent refresh from the DB of the number of submitted " "and running jobs (seconds). Only use if a worker with max_jobs is present", ) + delay_update_batch: int = Field( + 60, + description="Delay between subsequent refresh from the DB of the number of submitted " + "and running jobs (seconds). Only use if a worker with max_jobs is present", + ) lock_timeout: Optional[int] = Field( 86400, description="Time to consider the lock on a document expired and can be overridden (seconds)", diff --git a/src/jobflow_remote/jobs/batch.py b/src/jobflow_remote/jobs/batch.py index ecac73ca..3d37ab29 100644 --- a/src/jobflow_remote/jobs/batch.py +++ b/src/jobflow_remote/jobs/batch.py @@ -45,6 +45,11 @@ def __init__( def _init_files_dir(self): self.host.connect() + # Note that the check of the creation of the folders on a remote host + # slows down the start of the runner by a few seconds. + # If this proves to be an issue the folder creation should be moved + # somewhere else and guaranteed in some other way (e.g. a CLI command + # for the user?). self.host.mkdir(self.files_dir) self.host.mkdir(self.submitted_dir) self.host.mkdir(self.running_dir) @@ -54,7 +59,7 @@ def _init_files_dir(self): def submit_job(self, job_id: str, index: int): self.host.write_text_file(self.submitted_dir / f"{job_id}_{index}", "") - def get_submitted(self) -> int: + def get_submitted(self) -> list[str]: return self.host.listdir(self.submitted_dir) def get_terminated(self) -> list[tuple[str, int, str]]: @@ -67,8 +72,8 @@ def get_terminated(self) -> list[tuple[str, int, str]]: def get_running(self) -> list[tuple[str, int, str]]: running = [] - for i in self.host.listdir(self.running_dir): - job_id, index, process_uuid = i.split("_") + for filename in self.host.listdir(self.running_dir): + job_id, index, process_uuid = filename.split("_") index = int(index) running.append((job_id, index, process_uuid)) return running diff --git a/src/jobflow_remote/jobs/data.py b/src/jobflow_remote/jobs/data.py index 4fe7e175..d845a1a5 100644 --- a/src/jobflow_remote/jobs/data.py +++ b/src/jobflow_remote/jobs/data.py @@ -1,10 +1,10 @@ from collections import defaultdict -from datetime import datetime, timezone +from datetime import datetime from enum import Enum from functools import cached_property from typing import Optional, Union -from jobflow import Flow, Job, JobStore +from jobflow import Flow, Job from monty.json import jsanitize from pydantic import BaseModel, Field from qtoolkit.core.data_objects import QResources, QState @@ -160,12 +160,12 @@ class JobDoc(BaseModel): created_on: datetime = datetime.utcnow() updated_on: datetime = datetime.utcnow() priority: int = 0 - store: Optional[JobStore] = None + # store: Optional[JobStore] = None exec_config: Optional[Union[ExecutionConfig, str]] = None resources: Optional[Union[QResources, dict]] = None stored_data: Optional[dict] = None - history: Optional[list[str]] = None # ? + # history: Optional[list[str]] = None def as_db_dict(self): # required since the resources are not serialized otherwise @@ -273,9 +273,7 @@ class FlowInfo(BaseModel): @classmethod def from_query_dict(cls, d): - # the dates should be in utc time. Convert them to the system time updated_on = d["updated_on"] - updated_on = updated_on.replace(tzinfo=timezone.utc).astimezone(tz=None) flow_id = d["uuid"] db_ids, job_ids, job_indexes = list(zip(*d["ids"])) diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index b5dfc6bb..bece836a 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -235,7 +235,7 @@ def get_jobs_info( ) return self.get_jobs_info_query(query=query, sort=sort, limit=limit) - def get_jobs_doc_query(self, query: dict = None, **kwargs) -> list[JobInfo]: + def get_jobs_doc_query(self, query: dict = None, **kwargs) -> list[JobDoc]: data = self.jobs.find(query, **kwargs) jobs_data = [] @@ -257,7 +257,7 @@ def get_jobs_doc( locked: bool = False, sort: list[tuple] | None = None, limit: int = 0, - ) -> list[JobInfo]: + ) -> list[JobDoc]: query = self._build_query_job( job_ids=job_ids, db_ids=db_ids, @@ -1301,11 +1301,16 @@ def add_flow( jobs_list = list(flow.iterflow()) job_dicts = [] n_jobs = len(jobs_list) - # TODO check if output is None. In that case the DB has not been reset - # raise an error to signal it and propose the solution. - first_id = self.auxiliary.find_one_and_update( + + doc_next_id = self.auxiliary.find_one_and_update( {"next_id": {"$exists": True}}, {"$inc": {"next_id": n_jobs}} - )["next_id"] + ) + if doc_next_id is None: + raise ValueError( + "It seems that the database has not been initialised. If that is the" + " case run `jf admin reset` or use the reset() method of JobController" + ) + first_id = doc_next_id["next_id"] db_ids = [] for (job, parents), db_id in zip(jobs_list, range(first_id, first_id + n_jobs)): db_ids.append(db_id) @@ -1950,7 +1955,7 @@ def _cancel_queue_process(self, job_doc: dict): def get_batch_processes(self, worker: str) -> dict[str, str]: result = self.auxiliary.find_one({"batch_processes": {"$exists": True}}) if result: - return result["batch_processes"].get(worker) + return result["batch_processes"].get(worker, {}) return {} def add_batch_process(self, process_id: str, process_uuid: str, worker: str): diff --git a/src/jobflow_remote/jobs/run.py b/src/jobflow_remote/jobs/run.py index 0b78696e..aa391bf2 100644 --- a/src/jobflow_remote/jobs/run.py +++ b/src/jobflow_remote/jobs/run.py @@ -105,7 +105,9 @@ def run_batch_jobs( return if max_wait and wait > max_wait: - logger.info(f"No jobs available for more than {max_wait}. Stopping.") + logger.info( + f"No jobs available for more than {max_wait} seconds. Stopping." + ) return if max_jobs and count >= max_jobs: @@ -135,7 +137,6 @@ def run_batch_jobs( logger.warning( f"Process for job with id {job_id} and index {index} finished with an error" ) - # run_remote_job(job_path) bm.terminate_job(job_id, index) except Exception: logger.error( diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index 7c975473..44587a2f 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -11,7 +11,6 @@ from datetime import datetime from pathlib import Path -import schedule from fireworks import FWorker from jobflow.utils import suuid from monty.os import makedirs_p @@ -39,6 +38,7 @@ from jobflow_remote.remote.host import BaseHost from jobflow_remote.remote.queue import ERR_FNAME, OUT_FNAME, QueueManager, set_name_out from jobflow_remote.utils.log import initialize_runner_logger +from jobflow_remote.utils.schedule import SafeScheduler logger = logging.getLogger(__name__) @@ -91,7 +91,7 @@ def __init__( initialize_runner_logger( log_folder=self.project.log_dir, level=log_level.to_logging(), - runner_id=runner_id, + runner_id=self.runner_id, ) # TODO it could be better to create a pool of stores that are connected # How to deal with cases where the connection gets closed? @@ -127,9 +127,6 @@ def get_queue_manager(self, worker_name: str) -> QueueManager: ) return self.queue_managers[worker_name] - def get_store(self, job_doc: JobDoc): - return job_doc.store or self.jobstore - def run( self, transfer: bool = True, @@ -152,21 +149,25 @@ def run( f"Runner run options: transfer: {transfer} complete: {complete} slurm: {slurm} checkout: {checkout}" ) + scheduler = SafeScheduler(seconds_after_failure=120) + # run a first call for each case, since schedule will wait for the delay # to make the first execution. if checkout: self.checkout() - schedule.every(self.runner_options.delay_checkout).seconds.do(self.checkout) + scheduler.every(self.runner_options.delay_checkout).seconds.do( + self.checkout + ) if transfer or slurm or complete: self.advance_state(states) - schedule.every(self.runner_options.delay_advance_status).seconds.do( + scheduler.every(self.runner_options.delay_advance_status).seconds.do( self.advance_state, states=states ) if slurm: self.check_run_status() - schedule.every(self.runner_options.delay_check_run_status).seconds.do( + scheduler.every(self.runner_options.delay_check_run_status).seconds.do( self.check_run_status ) # Limited workers will only affect the process interacting with the queue @@ -178,18 +179,18 @@ def run( # the number of submitted/running jobs. if self.limited_workers: self.refresh_num_current_jobs() - schedule.every(self.runner_options.delay_refresh_limited).seconds.do( + scheduler.every(self.runner_options.delay_refresh_limited).seconds.do( self.refresh_num_current_jobs ) if self.batch_workers: self.update_batch_jobs() - schedule.every(self.runner_options.delay_check_run_status).seconds.do( + scheduler.every(self.runner_options.delay_update_batch).seconds.do( self.update_batch_jobs ) if complete: self.advance_state(states) - schedule.every(self.runner_options.delay_advance_status).seconds.do( + scheduler.every(self.runner_options.delay_advance_status).seconds.do( self.advance_state, states=states ) @@ -198,7 +199,7 @@ def run( if self.stop_signal: logger.info("stopping due to sigterm") break - schedule.run_pending() + scheduler.run_pending() time.sleep(1) finally: self.cleanup() @@ -267,7 +268,7 @@ def upload(self, lock): worker = self.get_worker(job_doc.worker) host = self.get_host(job_doc.worker) - store = self.get_store(job_doc) + store = self.jobstore # TODO would it be better/feasible to keep a pool of the required # Stores already connected, to avoid opening and closing them? store.connect() @@ -297,7 +298,7 @@ def upload(self, lock): logger.error(err_msg) raise RemoteError(err_msg, no_retry=False) - serialized_input = get_remote_in_file(job, remote_store, job_doc.store) + serialized_input = get_remote_in_file(job, remote_store) path_file = Path(remote_path, IN_FILENAME) host.put(serialized_input, str(path_file)) @@ -417,7 +418,7 @@ def download(self, lock): worker = self.get_worker(job_doc.worker) if not worker.is_local: host = self.get_host(job_doc.worker) - store = self.get_store(job_doc) + store = self.jobstore remote_path = job_doc.run_dir local_base_dir = Path(self.project.tmp_dir, "download") @@ -459,7 +460,7 @@ def complete_job(self, lock): try: job_doc = JobDoc(**doc) - store = self.get_store(job_doc) + store = self.jobstore completed = self.job_controller.complete_job(job_doc, local_path, store) except json.JSONDecodeError: @@ -607,173 +608,170 @@ def update_batch_jobs(self): logger.debug("update batch jobs") for worker_name, batch_manager in self.batch_workers.items(): worker = self.get_worker(worker_name) - try: - # first check the processes that are running from the folder - # and set them to running if needed - running_jobs = batch_manager.get_running() - for job_id, job_index, process_running_uuid in running_jobs: - lock_filter = { - "uuid": job_id, - "index": job_index, - "state": JobState.BATCH_SUBMITTED.value, - } - with self.job_controller.lock_job_for_update( - query=lock_filter, - max_step_attempts=self.runner_options.max_step_attempts, - delta_retry=self.runner_options.delta_retry, - ) as lock: - if lock.locked_document: - set_output = { - "$set": { - "state": JobState.BATCH_RUNNING.value, - "start_time": datetime.utcnow(), - "remote.process_id": process_running_uuid, - } + # first check the processes that are running from the folder + # and set them to running if needed + running_jobs = batch_manager.get_running() + for job_id, job_index, process_running_uuid in running_jobs: + lock_filter = { + "uuid": job_id, + "index": job_index, + "state": JobState.BATCH_SUBMITTED.value, + } + with self.job_controller.lock_job_for_update( + query=lock_filter, + max_step_attempts=self.runner_options.max_step_attempts, + delta_retry=self.runner_options.delta_retry, + ) as lock: + if lock.locked_document: + set_output = { + "$set": { + "state": JobState.BATCH_RUNNING.value, + "start_time": datetime.utcnow(), + "remote.process_id": process_running_uuid, } - lock.update_on_release = set_output - # Check the processes that should be running on the remote queue - # and update the state in the DB if something changed - batch_processes_data = self.job_controller.get_batch_processes( - worker_name - ) - processes = list(batch_processes_data.keys()) - queue_manager = self.get_queue_manager(worker_name) - if processes: - qjobs = queue_manager.get_jobs_list(processes) - running_processes = {qjob.job_id for qjob in qjobs} - stopped_processes = set(processes) - running_processes - for pid in stopped_processes: - self.job_controller.remove_batch_process(pid, worker_name) - # check if there are jobs that were in the running folder of a - # process that finished and set them to remote error - for job_id, job_index, process_running_uuid in running_jobs: - if batch_processes_data[pid] == process_running_uuid: - lock_filter = { - "uuid": job_id, - "index": job_index, - "state": { - "$in": ( - JobState.BATCH_SUBMITTED.value, - JobState.BATCH_RUNNING.value, - ) - }, - } - with self.job_controller.lock_job_for_update( - query=lock_filter, - max_step_attempts=self.runner_options.max_step_attempts, - delta_retry=self.runner_options.delta_retry, - ) as lock: - if lock.locked_document: - raise RuntimeError( - f"The batch process that was running the job (process_id: {pid}, uuid: {process_running_uuid} was likely killed before terminating the job execution" - ) - - processes = list(running_processes) - # check that enough processes are submitted and submit the required - # amount to reach max_jobs, if needed. - n_jobs = self.job_controller.count_jobs( - { - "state": { - "$in": ( - JobState.BATCH_SUBMITTED.value, - JobState.BATCH_RUNNING.value, - ) } - } + lock.update_on_release = set_output + + # Check the processes that should be running on the remote queue + # and update the state in the DB if something changed + batch_processes_data = self.job_controller.get_batch_processes(worker_name) + processes = list(batch_processes_data.keys()) + queue_manager = self.get_queue_manager(worker_name) + if processes: + qjobs = queue_manager.get_jobs_list(processes) + running_processes = {qjob.job_id for qjob in qjobs} + stopped_processes = set(processes) - running_processes + for pid in stopped_processes: + self.job_controller.remove_batch_process(pid, worker_name) + # check if there are jobs that were in the running folder of a + # process that finished and set them to remote error + for job_id, job_index, process_running_uuid in running_jobs: + if batch_processes_data[pid] == process_running_uuid: + lock_filter = { + "uuid": job_id, + "index": job_index, + "state": { + "$in": ( + JobState.BATCH_SUBMITTED.value, + JobState.BATCH_RUNNING.value, + ) + }, + } + with self.job_controller.lock_job_for_update( + query=lock_filter, + max_step_attempts=self.runner_options.max_step_attempts, + delta_retry=self.runner_options.delta_retry, + ) as lock: + if lock.locked_document: + raise RuntimeError( + f"The batch process that was running the job (process_id: {pid}, uuid: {process_running_uuid} was likely killed before terminating the job execution" + ) + + processes = list(running_processes) + + # check that enough processes are submitted and submit the required + # amount to reach max_jobs, if needed. + n_jobs = self.job_controller.count_jobs( + { + "state": { + "$in": ( + JobState.BATCH_SUBMITTED.value, + JobState.BATCH_RUNNING.value, + ) + }, + "worker": worker_name, + } + ) + n_processes = len(processes) + n_jobs_to_submit = min( + max(worker.max_jobs - n_processes, 0), max(n_jobs - n_processes, 0) + ) + logger.debug( + f"submitting {n_jobs_to_submit} batch jobs for worker {worker_name}" + ) + for _ in range(n_jobs_to_submit): + resources = worker.resources or {} + process_running_uuid = suuid() + remote_path = Path( + get_job_path(process_running_uuid, None, worker.batch.work_dir) ) - n_processes = len(processes) - n_jobs_to_submit = min( - max(worker.max_jobs - n_processes, 0), max(n_jobs - n_processes, 0) + qout_fpath = remote_path / OUT_FNAME + qerr_fpath = remote_path / ERR_FNAME + set_name_out( + resources, + f"batch_{process_running_uuid}", + out_fpath=qout_fpath, + err_fpath=qerr_fpath, + ) + + # note that here the worker.work_dir needs to be passed, + # not the worker.batch.work_dir + command = f"jf execution run-batch {worker.work_dir} {worker.batch.jobs_handle_dir} {process_running_uuid}" + if worker.batch.max_jobs: + command += f" -mj {worker.batch.max_jobs}" + if worker.batch.max_time: + command += f" -mt {worker.batch.max_time}" + if worker.batch.max_wait: + command += f" -mw {worker.batch.max_wait}" + + submit_result = queue_manager.submit( + commands=[command], + pre_run=worker.pre_run, + post_run=worker.post_run, + options=resources, + work_dir=remote_path, + create_submit_dir=True, ) - logger.debug(f"submitting {n_jobs_to_submit} batch jobs") - for _ in range(n_jobs_to_submit): - resources = worker.resources or {} - process_running_uuid = suuid() - remote_path = Path( - get_job_path(process_running_uuid, None, worker.batch.work_dir) - ) - qout_fpath = remote_path / OUT_FNAME - qerr_fpath = remote_path / ERR_FNAME - set_name_out( - resources, - f"batch_{process_running_uuid}", - out_fpath=qout_fpath, - err_fpath=qerr_fpath, - ) - # note that here the worker.work_dir needs to be passed, - # not the worker.batch.work_dir - command = f"jf execution run-batch {worker.work_dir} {worker.batch.jobs_handle_dir} {process_running_uuid}" - if worker.batch.max_jobs: - command += f" -mj {worker.batch.max_jobs}" - if worker.batch.max_time: - command += f" -mt {worker.batch.max_time}" - if worker.batch.max_wait: - command += f" -mw {worker.batch.max_wait}" - - submit_result = queue_manager.submit( - commands=[command], - pre_run=worker.pre_run, - post_run=worker.post_run, - options=resources, - work_dir=remote_path, - create_submit_dir=True, + if submit_result.status == SubmissionStatus.FAILED: + logger.error(f"submission failed. {repr(submit_result)}") + elif submit_result.status == SubmissionStatus.JOB_ID_UNKNOWN: + logger.error( + f"submission succeeded but ID not known. Job may be running but status cannot be checked. {repr(submit_result)}" ) - if submit_result.status == SubmissionStatus.FAILED: - logger.error(f"submission failed. {repr(submit_result)}") - elif submit_result.status == SubmissionStatus.JOB_ID_UNKNOWN: - logger.error( - f"submission succeeded but ID not known. Job may be running but status cannot be checked. {repr(submit_result)}" - ) + elif submit_result.status == SubmissionStatus.SUCCESSFUL: + self.job_controller.add_batch_process( + submit_result.job_id, process_running_uuid, worker_name + ) + else: + logger.error( + f"unhandled submission status {submit_result.status}", True + ) - elif submit_result.status == SubmissionStatus.SUCCESSFUL: - self.job_controller.add_batch_process( - submit_result.job_id, process_running_uuid, worker_name - ) - else: - logger.error( - f"unhandled submission status {submit_result.status}", True + # check for jobs that have terminated in the batch runner and + # update the DB state accordingly + terminated_jobs = batch_manager.get_terminated() + for job_id, job_index, process_running_uuid in terminated_jobs: + lock_filter = { + "uuid": job_id, + "index": job_index, + "state": { + "$in": ( + JobState.BATCH_SUBMITTED.value, + JobState.BATCH_RUNNING.value, ) - - # check for jobs that have terminated in the batch runner and - # update the DB state accordingly - terminated_jobs = batch_manager.get_terminated() - for job_id, job_index, process_running_uuid in terminated_jobs: - lock_filter = { - "uuid": job_id, - "index": job_index, - "state": { - "$in": ( - JobState.BATCH_SUBMITTED.value, - JobState.BATCH_RUNNING.value, - ) - }, - } - with self.job_controller.lock_job_for_update( - query=lock_filter, - max_step_attempts=self.runner_options.max_step_attempts, - delta_retry=self.runner_options.delta_retry, - ) as lock: - if lock.locked_document: - if not worker.is_local: - next_state = JobState.TERMINATED - else: - next_state = JobState.DOWNLOADED - set_output = { - "$set": { - "state": next_state.value, - "remote.process_id": process_running_uuid, - } + }, + } + with self.job_controller.lock_job_for_update( + query=lock_filter, + max_step_attempts=self.runner_options.max_step_attempts, + delta_retry=self.runner_options.delta_retry, + ) as lock: + if lock.locked_document: + if not worker.is_local: + next_state = JobState.TERMINATED + else: + next_state = JobState.DOWNLOADED + set_output = { + "$set": { + "state": next_state.value, + "remote.process_id": process_running_uuid, } - lock.update_on_release = set_output - batch_manager.delete_terminated( - [(job_id, job_index, process_running_uuid)] - ) - except Exception: - logger.error( - f"error while handling the batch submission for: {worker_name}", - exc_info=True, + } + lock.update_on_release = set_output + batch_manager.delete_terminated( + [(job_id, job_index, process_running_uuid)] ) def cleanup(self): diff --git a/src/jobflow_remote/jobs/submit.py b/src/jobflow_remote/jobs/submit.py index c57b3c26..99997108 100644 --- a/src/jobflow_remote/jobs/submit.py +++ b/src/jobflow_remote/jobs/submit.py @@ -10,7 +10,6 @@ def submit_flow( flow: jobflow.Flow | jobflow.Job | list[jobflow.Job], worker: str | None = None, - store: str | jobflow.JobStore | None = None, project: str | None = None, exec_config: str | ExecutionConfig | None = None, resources: dict | QResources | None = None, @@ -29,11 +28,6 @@ def submit_flow( worker The name of the Worker where the calculation will be submitted. If None, use the first configured worker for this project. - store - A job store. Alternatively, if set to None, :obj:`JobflowSettings.JOB_STORE` - will be used. Note, this could be different on the computer that submits the - workflow and the computer which runs the workflow. The value of ``JOB_STORE`` on - the computer that runs the workflow will be used. project the name of the project to which the Flow should be submitted. If None the current project will be used. diff --git a/src/jobflow_remote/remote/data.py b/src/jobflow_remote/remote/data.py index accd1180..a8ad9539 100644 --- a/src/jobflow_remote/remote/data.py +++ b/src/jobflow_remote/remote/data.py @@ -26,9 +26,9 @@ def get_job_path( return str(base_path / relative_path) -def get_remote_in_file(job, remote_store, original_store): +def get_remote_in_file(job, remote_store): d = jsanitize( - {"job": job, "store": remote_store, "original_store": original_store}, + {"job": job, "store": remote_store}, strict=True, allow_bson=True, enum_values=True, diff --git a/src/jobflow_remote/remote/host/base.py b/src/jobflow_remote/remote/host/base.py index a95d1df1..ef28102c 100644 --- a/src/jobflow_remote/remote/host/base.py +++ b/src/jobflow_remote/remote/host/base.py @@ -80,7 +80,7 @@ def test(self) -> str | None: return msg @abc.abstractmethod - def listdir(self, path: str | Path): + def listdir(self, path: str | Path) -> list[str]: raise NotImplementedError @abc.abstractmethod diff --git a/src/jobflow_remote/remote/host/local.py b/src/jobflow_remote/remote/host/local.py index ff578c68..1ec38eaf 100644 --- a/src/jobflow_remote/remote/host/local.py +++ b/src/jobflow_remote/remote/host/local.py @@ -113,8 +113,11 @@ def get(self, src, dst): def copy(self, src, dst): shutil.copy(src, dst) - def listdir(self, path: str | Path): - return os.listdir(path) + def listdir(self, path: str | Path) -> list[str]: + try: + return os.listdir(path) + except FileNotFoundError: + return [] def remove(self, path: str | Path): os.remove(path) diff --git a/src/jobflow_remote/remote/host/remote.py b/src/jobflow_remote/remote/host/remote.py index 1e9e41a3..15a6d7bc 100644 --- a/src/jobflow_remote/remote/host/remote.py +++ b/src/jobflow_remote/remote/host/remote.py @@ -230,7 +230,10 @@ def _execute_remote_func(self, remote_cmd, *args, **kwargs): def listdir(self, path: str | Path): self._check_connected() - self._execute_remote_func(self.connection.sftp().listdir, str(path)) + try: + return self._execute_remote_func(self.connection.sftp().listdir, str(path)) + except FileNotFoundError: + return [] def remove(self, path: str | Path): self._check_connected() diff --git a/src/jobflow_remote/utils/data.py b/src/jobflow_remote/utils/data.py index 5adca3fc..8315f05a 100644 --- a/src/jobflow_remote/utils/data.py +++ b/src/jobflow_remote/utils/data.py @@ -3,6 +3,7 @@ import os from collections.abc import Mapping, MutableMapping from copy import deepcopy +from datetime import datetime, timezone from typing import Any from uuid import UUID @@ -129,3 +130,19 @@ def convert_store(spec_dict: dict, valid_stores) -> Store: if isinstance(v, dict) and "type" in v: _spec_dict[k] = convert_store(v, valid_stores) return valid_stores[store_type](**_spec_dict) + + +def convert_utc_time(datetime_value: datetime) -> datetime: + """ + Convert a time in UTC (used in the DB) to the time zone of the + system where the code is being executed. + + Parameters + ---------- + datetime_value + a datetime object in UTC + Returns + ------- + The datetime in the zone of the current system + """ + return datetime_value.replace(tzinfo=timezone.utc).astimezone(tz=None) diff --git a/src/jobflow_remote/utils/log.py b/src/jobflow_remote/utils/log.py index d5fbf42a..2c5703fa 100644 --- a/src/jobflow_remote/utils/log.py +++ b/src/jobflow_remote/utils/log.py @@ -27,6 +27,7 @@ def initialize_runner_logger( # runner is started. makedirs_p(log_folder) + print("!!!", runner_id) if runner_id: msg_format = f"%(asctime)s [%(levelname)s] ID {runner_id} %(name)s: %(message)s" else: diff --git a/src/jobflow_remote/utils/schedule.py b/src/jobflow_remote/utils/schedule.py new file mode 100644 index 00000000..47cb25f6 --- /dev/null +++ b/src/jobflow_remote/utils/schedule.py @@ -0,0 +1,58 @@ +""" +Scheduling tools based on the schedule module +""" +from __future__ import annotations + +import logging +from datetime import datetime, timedelta + +from schedule import Scheduler + +logger = logging.getLogger(__name__) + + +# TODO consider making this with an exponential backoff strategy +# with a failure at the end +class SafeScheduler(Scheduler): + """ + An implementation of Scheduler that catches jobs that fail, logs their + exception tracebacks as errors, optionally reschedules the jobs for their + next run time, and keeps going. + + Adapted from https://gist.github.com/mplewis/8483f1c24f2d6259aef6 + """ + + def __init__( + self, reschedule_on_failure: bool = True, seconds_after_failure: int = 0 + ): + """ + If reschedule_on_failure is True, jobs will be rescheduled for their + next run as if they had completed successfully. If False, they'll run + on the next run_pending() tick. + """ + self.reschedule_on_failure = reschedule_on_failure + self.seconds_after_failure = seconds_after_failure + super().__init__() + + def _run_job(self, job): + try: + super()._run_job(job) + except Exception: + task_name = job.job_func.__name__ + logger.error(f"Error while running task {task_name}", exc_info=True) + if self.reschedule_on_failure: + if self.seconds_after_failure: + logger.warning( + f"Task {task_name} rescheduled in {self.seconds_after_failure} seconds" + ) + job.last_run = None + job.next_run = datetime.now() + timedelta( + seconds=self.seconds_after_failure + ) + else: + logger.warning(f"Task {task_name} rescheduled") + job.last_run = datetime.now() + job._schedule_next_run() + else: + logger.warning(f"Task {task_name} canceled.") + self.cancel_job(job) From 4c81cdea810576fc98deac5a2ffbcea33549e535 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Thu, 23 Nov 2023 11:58:32 +0100 Subject: [PATCH 10/18] fix export typing --- pyproject.toml | 3 ++- src/jobflow_remote/config/base.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a592e732..244c15c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,8 @@ dependencies =[ "psutil", "supervisor", "ruamel.yaml", - "schedule" + "schedule", + "flufl.lock" ] [project.optional-dependencies] diff --git a/src/jobflow_remote/config/base.py b/src/jobflow_remote/config/base.py index a03f8d5b..e920bc94 100644 --- a/src/jobflow_remote/config/base.py +++ b/src/jobflow_remote/config/base.py @@ -3,7 +3,7 @@ import traceback from enum import Enum from pathlib import Path -from typing import Annotated, Literal, Optional, Union +from typing import Annotated, Any, Literal, Optional, Union from jobflow import JobStore from maggma.stores import MongoStore @@ -373,7 +373,7 @@ class ExecutionConfig(BaseModel): modules: Optional[list[str]] = Field( None, description="list of modules to be loaded" ) - export: Optional[dict[str, str]] = Field( + export: Optional[dict[str, Any]] = Field( None, description="dictionary with variable to be exported" ) pre_run: Optional[str] = Field( From 79a05dee1f77017b780ffe3f737abb7dc0e20d12 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Tue, 28 Nov 2023 00:50:55 +0100 Subject: [PATCH 11/18] fix dynamic flow. Add graph utils --- src/jobflow_remote/cli/flow.py | 59 +++++++++++- src/jobflow_remote/cli/formatting.py | 2 +- src/jobflow_remote/cli/job.py | 59 +++++++++++- src/jobflow_remote/cli/utils.py | 6 -- src/jobflow_remote/jobs/data.py | 56 ++++++++++-- src/jobflow_remote/jobs/graph.py | 32 +++++++ src/jobflow_remote/jobs/jobcontroller.py | 111 ++++++++++++++++++----- src/jobflow_remote/jobs/runner.py | 4 + src/jobflow_remote/jobs/submit.py | 9 +- 9 files changed, 291 insertions(+), 47 deletions(-) create mode 100644 src/jobflow_remote/jobs/graph.py diff --git a/src/jobflow_remote/cli/flow.py b/src/jobflow_remote/cli/flow.py index 5c649d47..088331e0 100644 --- a/src/jobflow_remote/cli/flow.py +++ b/src/jobflow_remote/cli/flow.py @@ -1,4 +1,7 @@ +from typing import Annotated, Optional + import typer +from jobflow.utils.graph import draw_graph from rich.prompt import Confirm from rich.text import Text @@ -35,6 +38,7 @@ loading_spinner, out_console, ) +from jobflow_remote.jobs.graph import get_graph app_flow = JFRTyper( name="flow", help="Commands for managing the flows", no_args_is_help=True @@ -68,7 +72,7 @@ def flows_list( start_date = get_start_date(start_date, days, hours) - sort = [(sort.query_field, 1 if reverse_sort else -1)] + sort = [(sort.value, 1 if reverse_sort else -1)] with loading_spinner(): flows_info = jc.get_flows_info( @@ -185,3 +189,56 @@ def flow_info( exit_with_error_msg("No data matching the request") out_console.print(format_flow_info(flows_info[0])) + + +@app_flow.command() +def graph( + flow_db_id: flow_db_id_arg, + job_id_flag: job_flow_id_flag_opt = False, + label: Annotated[ + Optional[str], + typer.Option( + "--label", + "-l", + help="The label used to identify the nodes", + ), + ] = "name", + file_path: Annotated[ + Optional[str], + typer.Option( + "--path", + "-p", + help="If defined, the graph will be dumped to a file", + ), + ] = None, +): + """ + Provide detailed information on a Flow + """ + db_id, jf_id = get_job_db_ids(flow_db_id, None) + db_ids = job_ids = flow_ids = None + if db_id is not None: + db_ids = [db_id] + elif job_id_flag: + job_ids = [jf_id] + else: + flow_ids = [jf_id] + + with loading_spinner(): + jc = get_job_controller() + + flows_info = jc.get_flows_info( + job_ids=job_ids, + db_ids=db_ids, + flow_ids=flow_ids, + limit=1, + full=True, + ) + if not flows_info: + exit_with_error_msg("No data matching the request") + + plt = draw_graph(get_graph(flows_info[0], label=label)) + if file_path: + plt.savefig(file_path) + else: + plt.show() diff --git a/src/jobflow_remote/cli/formatting.py b/src/jobflow_remote/cli/formatting.py index 4f2419a9..42f7aacd 100644 --- a/src/jobflow_remote/cli/formatting.py +++ b/src/jobflow_remote/cli/formatting.py @@ -107,7 +107,7 @@ def get_flow_info_table(flows_info: list[FlowInfo], verbosity: int): table.add_column("Job states") for fi in flows_info: - # show the smallest fw_id as db_id + # show the smallest Job db_id as db_id db_id = min(fi.db_ids) row = [ diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index 308fedf7..c5e05e61 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -1,10 +1,12 @@ import io from pathlib import Path +from typing import Optional import typer from monty.json import jsanitize from monty.serialization import dumpfn from qtoolkit.core.data_objects import QResources +from rich.pretty import pprint from typing_extensions import Annotated from jobflow_remote import SETTINGS @@ -91,7 +93,7 @@ def jobs_list( start_date = get_start_date(start_date, days, hours) - sort = [(sort.query_field, 1 if reverse_sort else -1)] + sort = [(sort.value, 1 if reverse_sort else -1)] with loading_spinner(): if custom_query: @@ -725,8 +727,6 @@ def resources( @app_job.command(name="dump", hidden=True) def job_dump( - job_db_id: job_db_id_arg = None, - job_index: job_index_arg = None, job_id: job_ids_indexes_opt = None, db_id: db_ids_opt = None, flow_id: flow_ids_opt = None, @@ -776,3 +776,56 @@ def job_dump( if not jobs_doc: exit_with_error_msg("No data matching the request") + + +@app_job.command() +def output( + job_db_id: job_db_id_arg, + job_index: job_index_arg = None, + file_path: Annotated[ + Optional[str], + typer.Option( + "--path", + "-p", + help="If defined, the output will be dumped to this file based on the extension (json or yaml)", + ), + ] = None, + load: Annotated[ + bool, + typer.Option( + "--load", + "-", + help="If enabled all the data from additional stores are also loaded ", + ), + ] = False, +): + """ + Detail information on a specific job + """ + + db_id, job_id = get_job_db_ids(job_db_id, job_index) + + with loading_spinner(): + jc = get_job_controller() + + if db_id: + job_info = jc.get_job_info( + job_id=job_id, + job_index=job_index, + db_id=db_id, + ) + if job_info: + job_id = job_info.uuid + job_index = job_info.index + + job_output = None + if job_id: + job_output = jc.jobstore.get_output(job_id, job_index or "last", load=load) + + if not job_output: + exit_with_error_msg("No data matching the request") + + if file_path: + dumpfn(job_output, file_path) + else: + pprint(job_output) diff --git a/src/jobflow_remote/cli/utils.py b/src/jobflow_remote/cli/utils.py index fd8cf58c..9b7a8bcc 100644 --- a/src/jobflow_remote/cli/utils.py +++ b/src/jobflow_remote/cli/utils.py @@ -71,12 +71,6 @@ class SortOption(Enum): UPDATED_ON = "updated_on" DB_ID = "db_id" - @property - def query_field(self) -> str: - if self == SortOption.DB_ID: - return "fw_id" - return self.value - class SerializeFileFormat(Enum): JSON = "json" diff --git a/src/jobflow_remote/jobs/data.py b/src/jobflow_remote/jobs/data.py index d845a1a5..992da360 100644 --- a/src/jobflow_remote/jobs/data.py +++ b/src/jobflow_remote/jobs/data.py @@ -197,10 +197,10 @@ class FlowDoc(BaseModel): # be parents of the job with index=i+1, but will not be parents of # the job with index i. # index is stored as string, since mongodb needs string keys + # This dictionary include {job uuid: {job index: [parent's uuids]}} parents: dict[str, dict[str, list[str]]] = Field(default_factory=dict) # ids correspond to db_id, uuid, index for each JobDoc ids: list[tuple[int, str, int]] = Field(default_factory=list) - # jobs_states: dict[str, FlowState] def as_db_dict(self): d = jsanitize( @@ -270,23 +270,37 @@ class FlowInfo(BaseModel): workers: list[str] job_states: list[JobState] job_names: list[str] + parents: list[list[str]] @classmethod def from_query_dict(cls, d): updated_on = d["updated_on"] flow_id = d["uuid"] - - db_ids, job_ids, job_indexes = list(zip(*d["ids"])) - jobs_data = d.get("jobs_list") or [] + workers = [] job_states = [] job_names = [] - for job_doc in jobs_data: - job_names.append(job_doc["job"]["name"]) - state = job_doc["state"] - job_states.append(JobState(state)) - workers.append(job_doc["worker"]) + parents = [] + + if jobs_data: + db_ids = [] + job_ids = [] + job_indexes = [] + for job_doc in jobs_data: + db_ids.append(job_doc["db_id"]) + job_ids.append(job_doc["uuid"]) + job_indexes.append(job_doc["index"]) + job_names.append(job_doc["job"]["name"]) + state = job_doc["state"] + job_states.append(JobState(state)) + workers.append(job_doc["worker"]) + parents.append(job_doc["parents"] or []) + else: + db_ids, job_ids, job_indexes = list(zip(*d["ids"])) + # parents could be determined in this case as well from the Flow document. + # However, to match the correct order it would require lopping over them. + # To keep the generation faster add this only if a use case shows up. state = FlowState(d["state"]) @@ -301,8 +315,32 @@ def from_query_dict(cls, d): workers=workers, job_states=job_states, job_names=job_names, + parents=parents, ) + @cached_property + def ids_mapping(self) -> dict[str, dict[int, int]]: + d: dict = defaultdict(dict) + + for db_id, job_id, index in zip(self.db_ids, self.job_ids, self.job_indexes): + d[job_id][int(index)] = db_id + + return dict(d) + + def iter_job_prop(self): + n_jobs = len(self.job_ids) + for i in range(n_jobs): + d = { + "db_id": self.db_ids[i], + "uuid": self.job_ids[i], + "index": self.job_indexes[i], + } + if self.job_names: + d["name"] = self.job_names[i] + d["state"] = self.job_states[i] + d["parents"] = self.parents[i] + yield d + class DynamicResponseType(Enum): REPLACE = "replace" diff --git a/src/jobflow_remote/jobs/graph.py b/src/jobflow_remote/jobs/graph.py new file mode 100644 index 00000000..7adceb6f --- /dev/null +++ b/src/jobflow_remote/jobs/graph.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from networkx import DiGraph + + from jobflow_remote.jobs.data import FlowInfo + + +def get_graph(flow: FlowInfo, label: str = "name") -> DiGraph: + import networkx as nx + + graph = nx.DiGraph() + + ids_mapping = flow.ids_mapping + + # Add nodes + for job_prop in flow.iter_job_prop(): + db_id = job_prop["db_id"] + job_prop["label"] = job_prop[label] + # change this as the "name" is used in jobflow's graph plotting util + job_prop["job_name"] = job_prop.pop("name") + graph.add_node(db_id, **job_prop) + + # Add edges based on parents + for child_node, parents in zip(flow.db_ids, flow.parents): + for parent_uuid in parents: + for parent_node in ids_mapping[parent_uuid].values(): + graph.add_edge(parent_node, child_node) + + return graph diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index bece836a..cd3df6d0 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -61,7 +61,7 @@ def __init__( self.jobs_collection = self.queue_store.collection_name self.flows_collection = flows_collection self.auxiliary_collection = auxiliary_collection - # TODO should it connect here? Or the passed stored should be connected? + # TODO should it connect here? Or the passed stores should be connected? self.queue_store.connect() self.jobstore.connect() self.db = self.queue_store._collection.database @@ -1274,9 +1274,56 @@ def get_job_doc_by_job_uuid(self, job_uuid: str, job_index: int | str = "last"): def get_jobs(self, query, projection: list | dict | None = None): return list(self.jobs.find(query, projection=projection)) - def count_jobs(self, query): + def count_jobs( + self, + query: dict | None = None, + job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, + db_ids: int | list[int] | None = None, + flow_ids: str | list[str] | None = None, + state: JobState | None = None, + locked: bool = False, + start_date: datetime | None = None, + end_date: datetime | None = None, + name: str | None = None, + metadata: dict | None = None, + ): + if query is None: + query = self._build_query_job( + job_ids=job_ids, + db_ids=db_ids, + flow_ids=flow_ids, + state=state, + locked=locked, + start_date=start_date, + end_date=end_date, + name=name, + metadata=metadata, + ) return self.jobs.count_documents(query) + def count_flows( + self, + query: dict | None = None, + job_ids: str | list[str] | None = None, + db_ids: int | list[int] | None = None, + flow_ids: str | None = None, + state: FlowState | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, + name: str | None = None, + ): + if not query: + query = self._build_query_flow( + job_ids=job_ids, + db_ids=db_ids, + flow_ids=flow_ids, + state=state, + start_date=start_date, + end_date=end_date, + name=name, + ) + return self.flows.count_documents(query) + def get_jobs_info_by_flow_uuid( self, flow_uuid, projection: list | dict | None = None ): @@ -1360,9 +1407,12 @@ def _append_flow( if job_doc.job.hosts: new_flow.add_hosts_uuids(job_doc.job.hosts) + flow_updates: dict[str, dict[str, Any]] = {} + # add new jobs to flow flow_dict = dict(flow_dict) - flow_dict["jobs"].extend(new_flow.job_uuids) + # flow_dict["jobs"].extend(new_flow.job_uuids) + flow_updates = {"$push": {"jobs": {"$each": new_flow.job_uuids}}} # add new jobs jobs_list = list(new_flow.iterflow()) @@ -1371,6 +1421,8 @@ def _append_flow( {"next_id": {"$exists": True}}, {"$inc": {"next_id": n_new_jobs}} )["next_id"] job_dicts = [] + flow_updates["$set"] = {} + ids_to_push = [] for (job, parents), db_id in zip( jobs_list, range(first_id, first_id + n_new_jobs) ): @@ -1386,11 +1438,14 @@ def _append_flow( resources=resources, ) ) - if job.index > 1: - flow_dict["parents"][job.uuid][str(job.index)] = parents - else: - flow_dict["parents"][job.uuid] = {str(job.index): parents} - flow_dict["ids"].append((job_dicts[-1]["db_id"], job.uuid, job.index)) + # if job.index > 1: + # flow_dict["parents"][job.uuid][str(job.index)] = parents + # else: + # flow_dict["parents"][job.uuid] = {str(job.index): parents} + flow_updates["$set"][f"parents.{job.uuid}.{job.index}"] = parents + # flow_dict["ids"].append((job_dicts[-1]["db_id"], job.uuid, job.index)) + ids_to_push.append((job_dicts[-1]["db_id"], job.uuid, job.index)) + flow_updates["$push"]["ids"] = {"$each": ids_to_push} if response_type == DynamicResponseType.DETOUR: # if detour, update the parents of the child jobs @@ -1399,10 +1454,11 @@ def _append_flow( {"parents": job_doc.uuid}, {"$push": {"parents": {"$each": leaf_uuids}}} ) - flow_dict["updated_on"] = datetime.utcnow() + # flow_dict["updated_on"] = datetime.utcnow() + flow_updates["$set"]["updated_on"] = datetime.utcnow() # TODO, this could be replaced by the actual change, instead of the replace - self.flows.find_one_and_replace(flow_dict, key="uuid") + self.flows.update_one({"uuid": flow_dict["uuid"]}, flow_updates) self.jobs.insert_many(job_dicts) logger.info(f"Appended flow ({new_flow.uuid}) with jobs: {new_flow.job_uuids}") @@ -1485,6 +1541,8 @@ def checkout_job( return reserved_uuid, reserved_index + # TODO if jobstore is not an option anymore, the "store" argument + # can be removed and just use self.jobstore. def complete_job( self, job_doc: JobDoc, local_path: Path | str, store: JobStore ) -> bool: @@ -1510,8 +1568,6 @@ def complete_job( self.update_flow_state(job_doc.job.hosts[-1]) return True - # Do not deserialize the Response to avoid deserializing the - # stored_data out = loadfn(out_path) doc_update = {"start_time": out["start_time"]} # update the time of the JobDoc, will be used in the checkin @@ -1639,29 +1695,32 @@ def checkin_job( ) if result.modified_count == 0: raise RuntimeError( - f"The job {job_doc.uuid} has not been updated in the database" + f"The job {job_doc.uuid} index {job_doc.index} has not been updated in the database" ) + # TODO it should be fine to replace this query by constructing the list of + # job uuids from the original + those added. Should be verified. job_uuids = self.get_flow_info_by_job_uuid(job_doc.uuid, ["jobs"])["jobs"] return len(self.refresh_children(job_uuids)) + 1 # TODO should this refresh all the kind of states? Or just set to ready? def refresh_children(self, job_uuids): - # go through and look for jobs whose state we can update to ready - # need to ensure that all parent uuids with all indices are completed - # first find state of all jobs; ensure larger indices are returned last - children = self.jobs.find( + # go through and look for jobs whose state we can update to ready. + # Need to ensure that all parent uuids with all indices are completed + # first find state of all jobs; ensure larger indices are returned last. + flow_jobs = self.jobs.find( {"uuid": {"$in": job_uuids}}, sort=[("index", 1)], - projection=["uuid", "index", "parents", "state", "job.config"], + projection=["uuid", "index", "parents", "state", "job.config", "db_id"], ) - mapping = {r["uuid"]: r for r in children} + # the mapping only contains jobs with the larger index + jobs_mapping = {j["uuid"]: j for j in flow_jobs} # Now find jobs that are queued and whose parents are all completed - # and ready them. Assume that none of the children can be in a running - # state and thus no need to lock them. + # (or allowed to fail) and ready them. Assume that none of the children + # can be in a running state and thus no need to lock them. to_ready = [] - for uuid, job in mapping.items(): + for _, job in jobs_mapping.items(): allowed_states = [JobState.COMPLETED.value] on_missing_ref = ( job.get("job", {}).get("config", {}).get("on_missing_references", None) @@ -1669,9 +1728,11 @@ def refresh_children(self, job_uuids): if on_missing_ref == jobflow.OnMissing.NONE.value: allowed_states.extend((JobState.FAILED.value, JobState.CANCELLED.value)) if job["state"] == JobState.WAITING.value and all( - [mapping[p]["state"] in allowed_states for p in job["parents"]] + [jobs_mapping[p]["state"] in allowed_states for p in job["parents"]] ): - to_ready.append(uuid) + # Use the db_id to identify the children, since the uuid alone is not + # enough in some cases. + to_ready.append(job["db_id"]) # Here it is assuming that there will be only one job with each uuid, as # it should be when switching state to READY the first time. @@ -1679,7 +1740,7 @@ def refresh_children(self, job_uuids): # to this should always be consistent. if len(to_ready) > 0: self.jobs.update_many( - {"uuid": {"$in": to_ready}}, {"$set": {"state": JobState.READY.value}} + {"db_id": {"$in": to_ready}}, {"$set": {"state": JobState.READY.value}} ) return to_ready diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index 44587a2f..7aab0a39 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -280,6 +280,10 @@ def upload(self, lock): except Exception: logging.error(f"error while closing the store {store}", exc_info=True) + # set the db_id in the job's metadata, so that it is available in the outputs + if "db_id" not in job.metadata: + job.metadata["db_id"] = db_id + remote_path = get_job_path(job.uuid, job.index, worker.work_dir) # Set the value of the original store for dynamical workflow. Usually it diff --git a/src/jobflow_remote/jobs/submit.py b/src/jobflow_remote/jobs/submit.py index 99997108..bcee9223 100644 --- a/src/jobflow_remote/jobs/submit.py +++ b/src/jobflow_remote/jobs/submit.py @@ -14,7 +14,7 @@ def submit_flow( exec_config: str | ExecutionConfig | None = None, resources: dict | QResources | None = None, allow_external_references: bool = False, -): +) -> list[int]: """ Submit a flow for calculation to the selected Worker. @@ -40,6 +40,11 @@ def submit_flow( allow_external_references If False all the references to other outputs should be from other Jobs of the Flow. + + Returns + ------- + List of int + The list of db_ids of the submitted Jobs. """ config_manager = ConfigManager() @@ -58,7 +63,7 @@ def submit_flow( jc = proj_obj.get_job_controller() - jc.add_flow( + return jc.add_flow( flow=flow, worker=worker, exec_config=exec_config, From 2b3295106fa4a5ef69bb8cf116f22a0e2350b74c Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Wed, 29 Nov 2023 10:52:23 +0100 Subject: [PATCH 12/18] some fix and test dash graph app --- src/jobflow_remote/cli/flow.py | 21 +++- src/jobflow_remote/jobs/data.py | 5 + src/jobflow_remote/jobs/graph.py | 144 +++++++++++++++++++++++ src/jobflow_remote/jobs/jobcontroller.py | 12 +- src/jobflow_remote/jobs/state.py | 2 + 5 files changed, 172 insertions(+), 12 deletions(-) diff --git a/src/jobflow_remote/cli/flow.py b/src/jobflow_remote/cli/flow.py index 088331e0..b5eac7f8 100644 --- a/src/jobflow_remote/cli/flow.py +++ b/src/jobflow_remote/cli/flow.py @@ -38,7 +38,7 @@ loading_spinner, out_console, ) -from jobflow_remote.jobs.graph import get_graph +from jobflow_remote.jobs.graph import get_graph, plot_dash app_flow = JFRTyper( name="flow", help="Commands for managing the flows", no_args_is_help=True @@ -211,6 +211,14 @@ def graph( help="If defined, the graph will be dumped to a file", ), ] = None, + dash_plot: Annotated[ + bool, + typer.Option( + "--dash", + "-d", + help="Show the graph in a dash app", + ), + ] = False, ): """ Provide detailed information on a Flow @@ -237,8 +245,11 @@ def graph( if not flows_info: exit_with_error_msg("No data matching the request") - plt = draw_graph(get_graph(flows_info[0], label=label)) - if file_path: - plt.savefig(file_path) + if dash_plot: + plot_dash(flows_info[0]) else: - plt.show() + plt = draw_graph(get_graph(flows_info[0], label=label)) + if file_path: + plt.savefig(file_path) + else: + plt.show() diff --git a/src/jobflow_remote/jobs/data.py b/src/jobflow_remote/jobs/data.py index 992da360..afcec745 100644 --- a/src/jobflow_remote/jobs/data.py +++ b/src/jobflow_remote/jobs/data.py @@ -271,6 +271,7 @@ class FlowInfo(BaseModel): job_states: list[JobState] job_names: list[str] parents: list[list[str]] + hosts: list[list[str]] @classmethod def from_query_dict(cls, d): @@ -282,6 +283,7 @@ def from_query_dict(cls, d): job_states = [] job_names = [] parents = [] + job_hosts = [] if jobs_data: db_ids = [] @@ -296,6 +298,7 @@ def from_query_dict(cls, d): job_states.append(JobState(state)) workers.append(job_doc["worker"]) parents.append(job_doc["parents"] or []) + job_hosts.append(job_doc["job"]["hosts"] or []) else: db_ids, job_ids, job_indexes = list(zip(*d["ids"])) # parents could be determined in this case as well from the Flow document. @@ -316,6 +319,7 @@ def from_query_dict(cls, d): job_states=job_states, job_names=job_names, parents=parents, + hosts=job_hosts, ) @cached_property @@ -339,6 +343,7 @@ def iter_job_prop(self): d["name"] = self.job_names[i] d["state"] = self.job_states[i] d["parents"] = self.parents[i] + d["hosts"] = self.hosts[i] yield d diff --git a/src/jobflow_remote/jobs/graph.py b/src/jobflow_remote/jobs/graph.py index 7adceb6f..94622c5a 100644 --- a/src/jobflow_remote/jobs/graph.py +++ b/src/jobflow_remote/jobs/graph.py @@ -2,6 +2,8 @@ from typing import TYPE_CHECKING +from jobflow_remote.jobs.state import JobState + if TYPE_CHECKING: from networkx import DiGraph @@ -30,3 +32,145 @@ def get_graph(flow: FlowInfo, label: str = "name") -> DiGraph: graph.add_edge(parent_node, child_node) return graph + + +def get_graph_elements(flow: FlowInfo): + ids_mapping = flow.ids_mapping + + nodes = {} + for job_prop in flow.iter_job_prop(): + db_id = job_prop["db_id"] + nodes[db_id] = job_prop + + # edges based on parents + edges = [] + for child_node, parents in zip(flow.db_ids, flow.parents): + for parent_uuid in parents: + for parent_node in ids_mapping[parent_uuid].values(): + edges.append((parent_node, child_node)) + + # group of nodes based on hosts + # from collections import defaultdict + # groups = defaultdict(list) + hosts = {} + # for job_prop in flow.iter_job_prop(): + # for host in job_prop["hosts"]: + # groups[host].append(job_prop["db_id"]) + for job_prop in flow.iter_job_prop(): + hosts[job_prop["db_id"]] = job_prop["hosts"] + + return nodes, edges, hosts + + +def plot_dash(flow: FlowInfo): + nodes, edges, hosts = get_graph_elements(flow) + + import dash_cytoscape as cyto + from dash import Dash, Input, Output, callback, html + + app = Dash(f"{flow.name} - {flow.flow_id}") + + elements = [] + + # parent elements + hosts_hierarchy = {} + jobs_inner_hosts = {} + hosts_set = set() + for db_id, job_hosts in hosts.items(): + job_hosts = list(reversed(job_hosts)) + if len(job_hosts) < 2: + continue + for i, host in enumerate(job_hosts[1:-1], 1): + hosts_hierarchy[job_hosts[i + 1]] = host + + hosts_set.update(job_hosts[1:]) + jobs_inner_hosts[db_id] = job_hosts[-1] + + for host in hosts_set: + elements.append({"data": {"id": host, "parent": hosts_hierarchy.get(host)}}) + + for db_id, node_info in nodes.items(): + node_info["id"] = str(db_id) + node_info["label"] = node_info["name"] + node_info["parent"] = jobs_inner_hosts.get(db_id) + elements.append( + { + "data": node_info, + } + ) + + for edge in edges: + elements.append({"data": {"source": str(edge[0]), "target": str(edge[1])}}) + + stylesheet: list[dict] = [ + { + "selector": f'[state = "{state}"]', + "style": { + "background-color": color, + }, + } + for state, color in COLOR_MAPPING.items() + ] + stylesheet.append( + { + "selector": "node", + "style": { + "label": "data(name)", + }, + } + ) + stylesheet.append( + { + "selector": "node:parent", + "style": { + "background-opacity": 0.2, + "background-color": "#2B65EC", + "border-color": "#2B65EC", + }, + } + ) + + app.layout = html.Div( + [ + cyto.Cytoscape( + id="flow-graph", + layout={"name": "breadthfirst", "directed": True}, + # layout={'name': 'cose'}, + style={"width": "100%", "height": "500px"}, + elements=elements, + stylesheet=stylesheet, + ), + html.P(id="job-info-output"), + ] + ) + + @callback( + Output("job-info-output", "children"), Input("flow-graph", "mouseoverNodeData") + ) + def displayTapNodeData(data): + if data: + return str(data) + + app.run(debug=True) + + +BLUE_COLOR = "#5E6BFF" +RED_COLOR = "#fC3737" +COLOR_MAPPING = { + JobState.WAITING.value: "grey", + JobState.READY.value: "#DAF7A6", + JobState.CHECKED_OUT.value: BLUE_COLOR, + JobState.UPLOADED.value: BLUE_COLOR, + JobState.SUBMITTED.value: BLUE_COLOR, + JobState.RUNNING.value: BLUE_COLOR, + JobState.TERMINATED.value: BLUE_COLOR, + JobState.DOWNLOADED.value: BLUE_COLOR, + JobState.REMOTE_ERROR.value: RED_COLOR, + JobState.COMPLETED.value: "#47bf00", + JobState.FAILED.value: RED_COLOR, + JobState.PAUSED.value: "#EAE200", + JobState.STOPPED.value: RED_COLOR, + JobState.CANCELLED.value: RED_COLOR, + JobState.BATCH_SUBMITTED.value: BLUE_COLOR, + JobState.BATCH_RUNNING.value: BLUE_COLOR, +} diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index cd3df6d0..25e24009 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -1079,6 +1079,7 @@ def get_flows_info( # TODO reduce the projection to the bare minimum to reduce the amount of # fecthed data? projection = {f"jobs_list.{f}": 1 for f in projection_job_info} + projection["jobs_list.job.hosts"] = 1 for k in FlowDoc.model_fields.keys(): projection[k] = 1 @@ -1399,20 +1400,17 @@ def _append_flow( new_flow = get_flow(new_flow, allow_external_references=True) - # get job parents and set the previous hosts + # get job parents if response_type == DynamicResponseType.REPLACE: job_parents = job_doc.parents else: job_parents = [(job_doc.uuid, job_doc.index)] - if job_doc.job.hosts: - new_flow.add_hosts_uuids(job_doc.job.hosts) - - flow_updates: dict[str, dict[str, Any]] = {} # add new jobs to flow flow_dict = dict(flow_dict) - # flow_dict["jobs"].extend(new_flow.job_uuids) - flow_updates = {"$push": {"jobs": {"$each": new_flow.job_uuids}}} + flow_updates: dict[str, dict[str, Any]] = { + "$push": {"jobs": {"$each": new_flow.job_uuids}} + } # add new jobs jobs_list = list(new_flow.iterflow()) diff --git a/src/jobflow_remote/jobs/state.py b/src/jobflow_remote/jobs/state.py index 0e6c72ff..63f8ff45 100644 --- a/src/jobflow_remote/jobs/state.py +++ b/src/jobflow_remote/jobs/state.py @@ -41,6 +41,8 @@ def short_value(self) -> str: JobState.PAUSED: "P", JobState.STOPPED: "ST", JobState.CANCELLED: "CA", + JobState.BATCH_SUBMITTED: "BS", + JobState.BATCH_RUNNING: "BR", } From 138a7b1763d12927e0e741d550e9dd9e750f588b Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Thu, 7 Dec 2023 15:16:27 +0100 Subject: [PATCH 13/18] fix dynamics flow and more graphs --- src/jobflow_remote/cli/flow.py | 13 +++++ src/jobflow_remote/cli/formatting.py | 18 +++++-- src/jobflow_remote/cli/jf.py | 8 +-- src/jobflow_remote/cli/job.py | 23 ++++++--- src/jobflow_remote/jobs/graph.py | 63 +++++++++++++++++++++++- src/jobflow_remote/jobs/jobcontroller.py | 37 +++++++------- 6 files changed, 129 insertions(+), 33 deletions(-) diff --git a/src/jobflow_remote/cli/flow.py b/src/jobflow_remote/cli/flow.py index b5eac7f8..3bdae217 100644 --- a/src/jobflow_remote/cli/flow.py +++ b/src/jobflow_remote/cli/flow.py @@ -219,6 +219,14 @@ def graph( help="Show the graph in a dash app", ), ] = False, + print_mermaid: Annotated[ + bool, + typer.Option( + "--mermaid", + "-m", + help="Print the mermaid graph", + ), + ] = False, ): """ Provide detailed information on a Flow @@ -245,6 +253,11 @@ def graph( if not flows_info: exit_with_error_msg("No data matching the request") + if print_mermaid: + from jobflow_remote.jobs.graph import get_mermaid + + print(get_mermaid(flows_info[0])) + if dash_plot: plot_dash(flows_info[0]) else: diff --git a/src/jobflow_remote/cli/formatting.py b/src/jobflow_remote/cli/formatting.py index 42f7aacd..6398d0be 100644 --- a/src/jobflow_remote/cli/formatting.py +++ b/src/jobflow_remote/cli/formatting.py @@ -10,7 +10,7 @@ from jobflow_remote.cli.utils import ReprStr, fmt_datetime from jobflow_remote.config.base import ExecutionConfig, WorkerBase -from jobflow_remote.jobs.data import FlowInfo, JobInfo +from jobflow_remote.jobs.data import FlowInfo, JobDoc, JobInfo from jobflow_remote.jobs.state import JobState from jobflow_remote.utils.data import convert_utc_time @@ -130,14 +130,24 @@ def get_flow_info_table(flows_info: list[FlowInfo], verbosity: int): return table -def format_job_info(job_info: JobInfo, show_none: bool = False): +def format_job_info( + job_info: JobInfo | JobDoc, verbosity: int, show_none: bool = False +): d = job_info.dict(exclude_none=not show_none) + if verbosity == 1: + d.pop("job", None) + # convert dates at the first level and for the remote error for k, v in d.items(): if isinstance(v, datetime.datetime): - d[k] = convert_utc_time(v) + d[k] = convert_utc_time(v).strftime(fmt_datetime) + + if d["remote"]["retry_time_limit"]: + d["remote"]["retry_time_limit"] = convert_utc_time( + d["remote"]["retry_time_limit"] + ).strftime(fmt_datetime) - d = jsanitize(d, allow_bson=False, enum_values=True) + d = jsanitize(d, allow_bson=True, enum_values=True, strict=True) error = d.get("error") if error: d["error"] = ReprStr(error) diff --git a/src/jobflow_remote/cli/jf.py b/src/jobflow_remote/cli/jf.py index 0137d047..f86e434c 100644 --- a/src/jobflow_remote/cli/jf.py +++ b/src/jobflow_remote/cli/jf.py @@ -86,11 +86,13 @@ def main( try: project_data = cm.get_project_data() text = Text.from_markup( - f"The selected project is [green]{project_data.project.name}[/green] from config file [green]{project_data.filepath}[/green]" + f"The selected project is [green]{project_data.project.name}[/green] " + f"from config file [green]{project_data.filepath}[/green]" ) out_console.print(text) - except ConfigError as e: - out_console.print(f"Current project could not be determined: {e}", style="red") + except ConfigError: + # no warning printed if not needed as this seems to be confusing for the user + pass if profile: profiler.disable() diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index c5e05e61..f6a50464 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -155,6 +155,7 @@ def job_info( help="Show the data whose values are None. Usually hidden", ), ] = False, + verbosity: verbosity_opt = 0, ): """ Detail information on a specific job @@ -171,15 +172,23 @@ def job_info( with loading_spinner(): jc = get_job_controller() - job_info = jc.get_job_info( - job_id=job_id, - job_index=job_index, - db_id=db_id, - ) - if not job_info: + if verbosity > 0: + job_data = jc.get_job_doc( + job_id=job_id, + job_index=job_index, + db_id=db_id, + ) + else: + job_data = jc.get_job_info( + job_id=job_id, + job_index=job_index, + db_id=db_id, + ) + + if not job_data: exit_with_error_msg("No data matching the request") - out_console.print(format_job_info(job_info, show_none=show_none)) + out_console.print(format_job_info(job_data, verbosity, show_none=show_none)) @app_job.command() diff --git a/src/jobflow_remote/jobs/graph.py b/src/jobflow_remote/jobs/graph.py index 94622c5a..2fadfc7b 100644 --- a/src/jobflow_remote/jobs/graph.py +++ b/src/jobflow_remote/jobs/graph.py @@ -154,10 +154,71 @@ def displayTapNodeData(data): app.run(debug=True) +def get_mermaid(flow: FlowInfo, show_subflows: bool = True): + nodes, edges, hosts = get_graph_elements(flow) + from monty.collections import tree + + hosts_hierarchy = tree() + for db_id, job_hosts in hosts.items(): + d = hosts_hierarchy + for host in reversed(job_hosts): + d = d[host] + d[db_id] = None + + lines = ["flowchart TD"] + + # add style classes + for state, color in COLOR_MAPPING.items(): + # this could be optimised by compressing in one line and using a + # same class for states with the same color + lines.append(f" classDef {state} fill:{color}") + + # add edges + for parent_db_id, child_db_id in edges: + parent = nodes[parent_db_id] + child = nodes[child_db_id] + line = ( + f" {parent_db_id}({parent['name']}) --> {child_db_id}({child['name']})" + ) + lines.append(line) + + subgraph_styles = [] + + # add subgraphs + def add_subgraph(nested_hosts_hierarchy, indent_level=0): + if show_subflows: + prefix = " " * indent_level + else: + prefix = " " + + for ref_id in sorted(nested_hosts_hierarchy, key=lambda x: str(x)): + subhosts = nested_hosts_hierarchy[ref_id] + if subhosts: + if indent_level > 0 and show_subflows: + # don't put any title + lines.append(f"{prefix}subgraph {ref_id}['']") + subgraph_styles.append( + f" style {ref_id} fill:#2B65EC,opacity:0.2" + ) + + add_subgraph(subhosts, indent_level=indent_level + 1) + + if indent_level > 0 and show_subflows: + lines.append(f"{prefix}end") + else: + job = nodes[ref_id] + lines.append(f"{prefix}{ref_id}:::{job['state'].value}") + + add_subgraph(hosts_hierarchy) + lines.extend(subgraph_styles) + + return "\n".join(lines) + + BLUE_COLOR = "#5E6BFF" RED_COLOR = "#fC3737" COLOR_MAPPING = { - JobState.WAITING.value: "grey", + JobState.WAITING.value: "#aaaaaa", JobState.READY.value: "#DAF7A6", JobState.CHECKED_OUT.value: BLUE_COLOR, JobState.UPLOADED.value: BLUE_COLOR, diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index 25e24009..0dfe8e29 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -1258,19 +1258,19 @@ def get_job_info_by_job_uuid( raise ValueError(f"job_index value: {job_index} is not supported") return self.jobs.find_one(query, projection=projection, sort=sort) - def get_job_doc_by_job_uuid(self, job_uuid: str, job_index: int | str = "last"): - query: dict[str, Any] = {"uuid": job_uuid} - sort = None - if isinstance(job_index, int): - query["index"] = job_index - elif job_index == "last": - sort = [("index", -1)] - else: - raise ValueError(f"job_index value: {job_index} is not supported") - doc = self.jobs.find_one(query, sort=sort) - if doc: - return JobDoc.model_validate(doc) - return None + def get_job_doc( + self, + job_id: str | None = None, + db_id: int | None = None, + job_index: int | None = None, + ) -> JobDoc | None: + query, sort = self.generate_job_id_query(db_id, job_id, job_index) + + data = list(self.jobs.find(query, sort=sort, limit=1)) + if not data: + return None + + return JobDoc.model_validate(data[0]) def get_jobs(self, query, projection: list | dict | None = None): return list(self.jobs.find(query, projection=projection)) @@ -1436,12 +1436,7 @@ def _append_flow( resources=resources, ) ) - # if job.index > 1: - # flow_dict["parents"][job.uuid][str(job.index)] = parents - # else: - # flow_dict["parents"][job.uuid] = {str(job.index): parents} flow_updates["$set"][f"parents.{job.uuid}.{job.index}"] = parents - # flow_dict["ids"].append((job_dicts[-1]["db_id"], job.uuid, job.index)) ids_to_push.append((job_dicts[-1]["db_id"], job.uuid, job.index)) flow_updates["$push"]["ids"] = {"$each": ids_to_push} @@ -1649,6 +1644,8 @@ def checkin_job( response.replace, response_type=DynamicResponseType.REPLACE, worker=job_doc.worker, + exec_config=job_doc.exec_config, + resources=job_doc.resources, ) if response.addition is not None: @@ -1658,6 +1655,8 @@ def checkin_job( response.addition, response_type=DynamicResponseType.ADDITION, worker=job_doc.worker, + exec_config=job_doc.exec_config, + resources=job_doc.resources, ) if response.detour is not None: @@ -1667,6 +1666,8 @@ def checkin_job( response.detour, response_type=DynamicResponseType.DETOUR, worker=job_doc.worker, + exec_config=job_doc.exec_config, + resources=job_doc.resources, ) if response.stored_data is not None: From d6ec17cb4f14cbbf7ed211101e8dd4fe1f723d8e Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Fri, 8 Dec 2023 17:42:52 +0100 Subject: [PATCH 14/18] fix cli bug and add docstrings --- src/jobflow_remote/cli/formatting.py | 2 +- src/jobflow_remote/cli/jf.py | 27 +- src/jobflow_remote/cli/utils.py | 27 +- src/jobflow_remote/jobs/daemon.py | 14 +- src/jobflow_remote/jobs/jobcontroller.py | 1017 +++++++++++++++++++++- 5 files changed, 1066 insertions(+), 21 deletions(-) diff --git a/src/jobflow_remote/cli/formatting.py b/src/jobflow_remote/cli/formatting.py index 6398d0be..de8b33fa 100644 --- a/src/jobflow_remote/cli/formatting.py +++ b/src/jobflow_remote/cli/formatting.py @@ -142,7 +142,7 @@ def format_job_info( if isinstance(v, datetime.datetime): d[k] = convert_utc_time(v).strftime(fmt_datetime) - if d["remote"]["retry_time_limit"]: + if d["remote"].get("retry_time_limit"): d["remote"]["retry_time_limit"] = convert_utc_time( d["remote"]["retry_time_limit"] ).strftime(fmt_datetime) diff --git a/src/jobflow_remote/cli/jf.py b/src/jobflow_remote/cli/jf.py index f86e434c..b2cf0269 100644 --- a/src/jobflow_remote/cli/jf.py +++ b/src/jobflow_remote/cli/jf.py @@ -5,10 +5,12 @@ from jobflow_remote.cli.jfr_typer import JFRTyper from jobflow_remote.cli.utils import ( cleanup_job_controller, + complete_profiling, exit_with_error_msg, get_config_manager, initialize_config_manager, out_console, + start_profiling, ) from jobflow_remote.config import ConfigError from jobflow_remote.utils.log import initialize_cli_logger @@ -22,7 +24,18 @@ ) -@app.callback(result_callback=cleanup_job_controller) +def main_result_callback(*args, **kwargs): + """ + Callback executed after the main command is completed. + Allowing to make cleanup and other final actions. + """ + cleanup_job_controller() + profile = kwargs.get("profile", False) + if profile: + complete_profiling() + + +@app.callback(result_callback=main_result_callback) def main( project: Annotated[ str, @@ -62,10 +75,7 @@ def main( SETTINGS.cli_full_exc = True if profile: - from cProfile import Profile - - profiler = Profile() - profiler.enable() + start_profiling() initialize_cli_logger( level=SETTINGS.cli_log_level.to_logging(), full_exc_info=SETTINGS.cli_full_exc @@ -93,10 +103,3 @@ def main( except ConfigError: # no warning printed if not needed as this seems to be confusing for the user pass - - if profile: - profiler.disable() - import pstats - - stats = pstats.Stats(profiler).sort_stats("cumtime") - stats.print_stats() diff --git a/src/jobflow_remote/cli/utils.py b/src/jobflow_remote/cli/utils.py index 9b7a8bcc..7d5b76fa 100644 --- a/src/jobflow_remote/cli/utils.py +++ b/src/jobflow_remote/cli/utils.py @@ -7,7 +7,7 @@ from contextlib import contextmanager from datetime import datetime, timedelta from enum import Enum -from typing import Callable +from typing import TYPE_CHECKING, Callable import typer from click import ClickException @@ -21,6 +21,9 @@ from jobflow_remote.jobs.daemon import DaemonError, DaemonManager, DaemonStatus from jobflow_remote.jobs.state import JobState +if TYPE_CHECKING: + from cProfile import Profile + logger = logging.getLogger(__name__) @@ -37,6 +40,8 @@ _shared_config_manager: ConfigManager | None = None _shared_job_controller: JobController | None = None +_profiler: Profile | None = None + def initialize_config_manager(*args, **kwargs): global _shared_config_manager @@ -60,12 +65,30 @@ def get_job_controller(): return _shared_job_controller -def cleanup_job_controller(*args, **kwargs): +def cleanup_job_controller(): global _shared_job_controller if _shared_job_controller is not None: _shared_job_controller.close() +def start_profiling(): + global _profiler + from cProfile import Profile + + _profiler = Profile() + _profiler.enable() + + +def complete_profiling(): + global _profiler + + _profiler.disable() + import pstats + + stats = pstats.Stats(_profiler).sort_stats("cumtime") + stats.print_stats() + + class SortOption(Enum): CREATED_ON = "created_on" UPDATED_ON = "updated_on" diff --git a/src/jobflow_remote/jobs/daemon.py b/src/jobflow_remote/jobs/daemon.py index 3930b9dd..09e053ab 100644 --- a/src/jobflow_remote/jobs/daemon.py +++ b/src/jobflow_remote/jobs/daemon.py @@ -5,6 +5,7 @@ from enum import Enum from pathlib import Path from string import Template +from xmlrpc.client import Fault import psutil from monty.os import makedirs_p @@ -232,7 +233,18 @@ def check_status(self) -> DaemonStatus: ) interface = self.get_interface() - proc_info = interface.supervisor.getAllProcessInfo() + try: + proc_info = interface.supervisor.getAllProcessInfo() + except Fault as e: + # catch this exception as it may be raised if the status is queried while + # the supervisord process is shutting down. The error is quite cryptic, so + # replace with one that is clearer. Also see a related issue in supervisord: + # https://github.com/Supervisor/supervisor/issues/48 + if e.faultString == "SHUTDOWN_STATE": + raise DaemonError( + "The daemon is likely shutting down and the actual state cannot be determined" + ) + raise if not proc_info: raise DaemonError( "supervisord process is running but no daemon process is present" diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index 0dfe8e29..ea2ba117 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -48,6 +48,17 @@ class JobController: + """ + Main entry point for all the interactions with the Stores. + + Maintains a connection to both the queue Store and the results JobStore. + It is required that the queue Store is a MongoStore, as it will access + the database, and work with different collections. + + The main functionalities are those for updating the state of the database + and querying the Jobs and Flows status information. + """ + def __init__( self, queue_store: MongoStore, @@ -56,6 +67,24 @@ def __init__( auxiliary_collection: str = "jf_auxiliary", project: Project | None = None, ): + """ + Parameters + ---------- + queue_store + The Store used to save information about the status of the Jobs. + Should be a MongoStore and other collections are used from the same + database. + jobstore + The JobStore containing the output of the jobflow Flows. + flows_collection + The name of the collection used to store the Flows data. + Uses the DB defined in the queue_store. + auxiliary_collection + The name of the collection used to store other auxiliary data. + Uses the DB defined in the queue_store. + project + The project where the Stores were defined. + """ self.queue_store = queue_store self.jobstore = jobstore self.jobs_collection = self.queue_store.collection_name @@ -71,7 +100,18 @@ def __init__( self.project = project @classmethod - def from_project_name(cls, project_name: str | None = None): + def from_project_name(cls, project_name: str | None = None) -> JobController: + """ + Generate an instance of JobController from the project name. + + Parameters + ---------- + project_name + The name of the project. If None the default project will be used. + Returns + ------- + An instance of JobController associated with the project. + """ config_manager: ConfigManager = ConfigManager() project: Project = config_manager.get_project(project_name) queue_store = project.get_queue_store() @@ -79,12 +119,27 @@ def from_project_name(cls, project_name: str | None = None): return cls(queue_store=queue_store, jobstore=jobstore, project=project) @classmethod - def from_project(cls, project: Project): + def from_project(cls, project: Project) -> JobController: + """ + Generate an instance of JobController from a Project object. + + Parameters + ---------- + project + The project used to generate the JobController. If None the default + project will be used. + Returns + ------- + An instance of JobController associated with the project. + """ queue_store = project.get_queue_store() jobstore = project.get_jobstore() return cls(queue_store=queue_store, jobstore=jobstore, project=project) def close(self): + """ + Close the connections to all the Stores in JobController. + """ try: self.queue_store.close() except Exception: @@ -111,6 +166,40 @@ def _build_query_job( name: str | None = None, metadata: dict | None = None, ) -> dict: + """ + Build a query to search for Jobs, based on standard parameters. + The Jobs will need to satisfy all the defined conditions. + + Parameters + ---------- + job_ids + One or more tuples, each containing the (uuid, index) pair of the + Jobs to retrieve. + db_ids + One or more db_ids of the Jobs to retrieve. + flow_ids + One or more Flow uuids to which the Jobs to retrieve belong. + state + The state of the Jobs. + locked + If True only locked Jobs will be selected. + start_date + Filter Jobs that were updated_on after this date. + Should be in the machine local time zone. It will be converted to UTC. + end_date + Filter Jobs that were updated_on before this date. + Should be in the machine local time zone. It will be converted to UTC. + name + Pattern matching the name of Job. Default is an exact match, but all + conventions from python fnmatch can be used (e.g. *test*) + metadata + A dictionary of the values of the metadata to match. Should be an + exact match for all the values provided. + Returns + ------- + A dictionary with the query to be applied to a collection + containing JobDocs. + """ if job_ids and not any(isinstance(ji, (list, tuple)) for ji in job_ids): # without these cast mypy is confused about the type job_ids = cast(list[tuple[str, int]], [job_ids]) @@ -147,6 +236,8 @@ def _build_query_job( query["lock_id"] = {"$ne": None} if name: + # Add the beginning of the line, so that it will match the string + # exactly if no wildcard is given. Otherwise will match substrings. mongo_regex = "^" + fnmatch.translate(name).replace("\\\\", "\\") query["name"] = {"$regex": mongo_regex} @@ -165,7 +256,39 @@ def _build_query_flow( start_date: datetime | None = None, end_date: datetime | None = None, name: str | None = None, + locked: bool = False, ) -> dict: + """ + Build a query to search for Flows, based on standard parameters. + The Flows will need to satisfy all the defined conditions. + + Parameters + ---------- + job_ids + One or more strings with uuids of Jobs belonging to the Flow. + db_ids + One or more db_ids of Jobs belonging to the Flow. + flow_ids + One or more Flow uuids. + state + The state of the Flows. + start_date + Filter Flows that were updated_on after this date. + Should be in the machine local time zone. It will be converted to UTC. + end_date + Filter Flows that were updated_on before this date. + Should be in the machine local time zone. It will be converted to UTC. + name + Pattern matching the name of Flow. Default is an exact match, but all + conventions from python fnmatch can be used (e.g. *test*) + locked + If True only locked Flows will be selected. + + Returns + ------- + A dictionary with the query to be applied to a collection + containing FlowDocs. + """ if job_ids is not None and not isinstance(job_ids, (list, tuple)): job_ids = [job_ids] if db_ids is not None and not isinstance(db_ids, (list, tuple)): @@ -197,6 +320,9 @@ def _build_query_flow( mongo_regex = "^" + fnmatch.translate(name).replace("\\\\", "\\") query["name"] = {"$regex": mongo_regex} + if locked: + query["lock_id"] = {"$ne": None} + return query def get_jobs_info_query(self, query: dict = None, **kwargs) -> list[JobInfo]: @@ -222,6 +348,44 @@ def get_jobs_info( sort: list[tuple] | None = None, limit: int = 0, ) -> list[JobInfo]: + """ + Query for Jobs based on standard parameters and return a list of JobInfo. + + Parameters + ---------- + job_ids + One or more tuples, each containing the (uuid, index) pair of the + Jobs to retrieve. + db_ids + One or more db_ids of the Jobs to retrieve. + flow_ids + One or more Flow uuids to which the Jobs to retrieve belong. + state + The state of the Jobs. + locked + If True only locked Jobs will be selected. + start_date + Filter Jobs that were updated_on after this date. + Should be in the machine local time zone. It will be converted to UTC. + end_date + Filter Jobs that were updated_on before this date. + Should be in the machine local time zone. It will be converted to UTC. + name + Pattern matching the name of Job. Default is an exact match, but all + conventions from python fnmatch can be used (e.g. *test*) + metadata + A dictionary of the values of the metadata to match. Should be an + exact match for all the values provided. + sort + A list of (key, direction) pairs specifying the sort order for this + query. Follows pymongo conventions. + limit + Maximum number of entries to retrieve. 0 means no limit. + + Returns + ------- + A list of JobInfo objects for the Jobs matching the criteria. + """ query = self._build_query_job( job_ids=job_ids, db_ids=db_ids, @@ -236,6 +400,19 @@ def get_jobs_info( return self.get_jobs_info_query(query=query, sort=sort, limit=limit) def get_jobs_doc_query(self, query: dict = None, **kwargs) -> list[JobDoc]: + """ + Query for Jobs based on a generic filter and return a list of JobDoc. + + Parameters + ---------- + query + A dictionary representing the filter. + kwargs + All arguments passed to pymongo's Collection.find() method. + Returns + ------- + A list of JobDoc objects for the Jobs matching the criteria. + """ data = self.jobs.find(query, **kwargs) jobs_data = [] @@ -258,6 +435,44 @@ def get_jobs_doc( sort: list[tuple] | None = None, limit: int = 0, ) -> list[JobDoc]: + """ + Query for Jobs based on standard parameters and return a list of JobDoc. + + Parameters + ---------- + job_ids + One or more tuples, each containing the (uuid, index) pair of the + Jobs to retrieve. + db_ids + One or more db_ids of the Jobs to retrieve. + flow_ids + One or more Flow uuids to which the Jobs to retrieve belong. + state + The state of the Jobs. + locked + If True only locked Jobs will be selected. + start_date + Filter Jobs that were updated_on after this date. + Should be in the machine local time zone. It will be converted to UTC. + end_date + Filter Jobs that were updated_on before this date. + Should be in the machine local time zone. It will be converted to UTC. + name + Pattern matching the name of Job. Default is an exact match, but all + conventions from python fnmatch can be used (e.g. *test*) + metadata + A dictionary of the values of the metadata to match. Should be an + exact match for all the values provided. + sort + A list of (key, direction) pairs specifying the sort order for this + query. Follows pymongo conventions. + limit + Maximum number of entries to retrieve. 0 means no limit. + + Returns + ------- + A list of JobDoc objects for the Jobs matching the criteria. + """ query = self._build_query_job( job_ids=job_ids, db_ids=db_ids, @@ -277,6 +492,24 @@ def generate_job_id_query( job_id: str | None = None, job_index: int | None = None, ) -> tuple[dict, list | None]: + """ + Generate a query for a single Job based on db_id or uuid+index. + Only one among db_id and job_id should be defined. + + Parameters + ---------- + db_id + The db_id of the Job. + job_id + The uuid of the Job. + job_index + The index of the Job. If None the Job the sorting will be + added to get the highest index. + Returns + ------- + A dict and an optional list to be used as query and sort, + respectively, in a query for a single Job. + """ query: dict = {} sort: list | None = None @@ -305,6 +538,24 @@ def get_job_info( db_id: int | None = None, job_index: int | None = None, ) -> JobInfo | None: + """ + Get the JobInfo for a single Job based on db_id or uuid+index. + Only one among db_id and job_id should be defined. + + Parameters + ---------- + db_id + The db_id of the Job. + job_id + The uuid of the Job. + job_index + The index of the Job. If None the Job with the largest index + will be selected. + + Returns + ------- + A JobInfo, or None if no Job matches the criteria. + """ query, sort = self.generate_job_id_query(db_id, job_id, job_index) data = list( @@ -330,6 +581,51 @@ def _many_jobs_action( raise_on_error: bool = True, **method_kwargs, ) -> list[int]: + """ + Helper method to query Jobs based on criteria and sequentially apply an + action on all those retrieved. + + Used to provide a common interface between all the methods that + should be applied on a list of jobs sequentially. + + Parameters + ---------- + method + The function that should be applied on a single Job. + action_description + A description of the action being performed. For logging purposes. + job_ids + One or more tuples, each containing the (uuid, index) pair of the + Jobs to retrieve. + db_ids + One or more db_ids of the Jobs to retrieve. + flow_ids + One or more Flow uuids to which the Jobs to retrieve belong. + state + The state of the Jobs. + locked + If True only locked Jobs will be selected. + start_date + Filter Jobs that were updated_on after this date. + Should be in the machine local time zone. It will be converted to UTC. + end_date + Filter Jobs that were updated_on before this date. + Should be in the machine local time zone. It will be converted to UTC. + name + Pattern matching the name of Job. Default is an exact match, but all + conventions from python fnmatch can be used (e.g. *test*) + metadata + A dictionary of the values of the metadata to match. Should be an + exact match for all the values provided. + raise_on_error + If True raise in case of error on one job error and stop the loop. + Otherwise, just log the error and proceed. + method_kwargs + Kwargs passed to the method called on each Job + Returns + ------- + List of db_ids of the updated Jobs. + """ query = self._build_query_job( job_ids=job_ids, db_ids=db_ids, @@ -374,6 +670,51 @@ def rerun_jobs( wait: int | None = None, break_lock: bool = False, ) -> list[int]: + """ + Rerun a list of selected Jobs, i.e. bring their state back to READY. + See the docs of `rerun_job` for more details. + + Parameters + ---------- + job_ids + One or more tuples, each containing the (uuid, index) pair of the + Jobs to retrieve. + db_ids + One or more db_ids of the Jobs to retrieve. + flow_ids + One or more Flow uuids to which the Jobs to retrieve belong. + state + The state of the Jobs. + start_date + Filter Jobs that were updated_on after this date. + Should be in the machine local time zone. It will be converted to UTC. + end_date + Filter Jobs that were updated_on before this date. + Should be in the machine local time zone. It will be converted to UTC. + name + Pattern matching the name of Job. Default is an exact match, but all + conventions from python fnmatch can be used (e.g. *test*) + metadata + A dictionary of the values of the metadata to match. Should be an + exact match for all the values provided. + raise_on_error + If True raise in case of error on one job error and stop the loop. + Otherwise, just log the error and proceed. + force + Bypass the limitation that only failed Jobs can be rerun. + wait + In case the Flow or Jobs that need to be updated are locked, + wait this time (in seconds) for the lock to be released. + Raise an error if lock is not released. + break_lock + Forcibly break the lock on locked documents. Use with care and + verify that the lock has been set by a process that is not running + anymore. Doing otherwise will likely lead to inconsistencies in the DB. + + Returns + ------- + List of db_ids of the updated Jobs. + """ return self._many_jobs_action( method=self.rerun_job, action_description="rerunning", @@ -400,6 +741,50 @@ def rerun_job( wait: int | None = None, break_lock: bool = False, ) -> list[int]: + """ + Rerun a single Job, i.e. bring its state back to READY. + Selected by db_id or uuid+index. Only one among db_id + and job_id should be defined. + + By default, only Jobs in one of the running states (CHECKED_OUT, + UPLOADED, ...), in the REMOTE_ERROR state or FAILED with + children in the READY or WAITING state can be rerun. + This should guarantee that no unexpected inconsistencies due to + dynamic Jobs generation should appear. This limitation can be bypassed + with the `force` option. + In any case, no Job with children with index > 1 can be rerun, as there + is no sensible way of handling it. + + Rerunning a Job in a REMOTE_ERROR or on an intermediate STATE also + results in a reset of the remote attempts and errors. + When rerunning a Job in a SUBMITTED or RUNNING state the system also + tries to cancel the process in the worker. + Rerunning a FAILED Job also lead to change of state in its children. + The full list of modified Jobs is returned. + + Parameters + ---------- + db_id + The db_id of the Job. + job_id + The uuid of the Job. + job_index + The index of the Job. If None: the Job with the highest index. + force + Bypass the limitation that only Jobs in a certain state can be rerun. + wait + In case the Flow or Jobs that need to be updated are locked, + wait this time (in seconds) for the lock to be released. + Raise an error if lock is not released. + break_lock + Forcibly break the lock on locked documents. Use with care and + verify that the lock has been set by a process that is not running + anymore. Doing otherwise will likely lead to inconsistencies in the DB. + + Returns + ------- + List of db_ids of the updated Jobs. + """ lock_filter, sort = self.generate_job_id_query(db_id, job_id, job_index) sleep = None if wait: @@ -463,6 +848,32 @@ def _full_rerun( break_lock: bool = False, force: bool = False, ) -> tuple[dict, list[int]]: + """ + Perform the full rerun of Job, in case a Job is FAILED or in one of the + usually not admissible states. This requires actions on the original + Job's children and will need to acquire the lock on all of them as well + as on the Flow. + + Parameters + ---------- + doc + The dict of the JobDoc associated to the Job to rerun. + Just the "uuid", "index", "db_id", "state" values are required. + sleep + Amounts of seconds to wait between checks that the lock has been released. + wait + In case the Flow or Jobs that need to be updated are locked, + wait this time (in seconds) for the lock to be released. + Raise an error if lock is not released. + break_lock + Forcibly break the lock on locked documents. + force + Bypass the limitation that only Jobs in a certain state can be rerun. + Returns + ------- + Updates to be set on the rerun Job upon lock release and the list + of db_ids of the modified Jobs. + """ job_id = doc["uuid"] job_index = doc["index"] modified_jobs = [] @@ -587,6 +998,20 @@ def _full_rerun( return job_doc_update, modified_jobs def _reset_remote(self, doc: dict) -> dict: + """ + Simple reset of a Job in a running state or REMOTE_ERROR. + Does not require additional locking on the Flow or other Jobs. + + Parameters + ---------- + doc + The dict of the JobDoc associated to the Job to rerun. + Just the "uuid", "index", "state" values are required. + + Returns + ------- + + """ if doc["state"] in [JobState.SUBMITTED.value, JobState.RUNNING.value]: # try cancelling the job submitted to the remote queue try: @@ -612,6 +1037,37 @@ def _set_job_properties( break_lock: bool = False, acceptable_states: list[JobState] | None = None, ) -> list[int]: + """ + Helper to set multiple values in a JobDoc while locking the Job. + Selected by db_id or uuid+index. Only one among db_id + and job_id should be defined. + + Parameters + ---------- + values + Dictionary with the values to be set. Will be passed to a pymongo + `find_one_and_update` method. + db_id + The db_id of the Job. + job_id + The uuid of the Job. + job_index + The index of the Job. If None the Job with the largest index + will be selected. + wait + In case the Flow or Jobs that need to be updated are locked, + wait this time (in seconds) for the lock to be released. + Raise an error if lock is not released. + break_lock + Forcibly break the lock on locked documents. + acceptable_states + List of JobState for which the Job values can be changed. + If None all states are acceptable. + Returns + ------- + List of db_ids of updated Jobs. Could be an empty list or a list + with a single element. + """ sleep = None if wait: sleep = 10 @@ -650,6 +1106,34 @@ def set_job_state( wait: int | None = None, break_lock: bool = False, ) -> list[int]: + """ + Set the state of a Job to an arbitrary JobState. + Selected by db_id or uuid+index. Only one among db_id + and job_id should be defined. + + No check is performed! Any job can be set to any state. + Only for advanced users or for debugging purposes. + + Parameters + ---------- + db_id + The db_id of the Job. + job_id + The uuid of the Job. + job_index + The index of the Job. If None the Job with the largest index + will be selected. + wait + In case the Flow or Jobs that need to be updated are locked, + wait this time (in seconds) for the lock to be released. + Raise an error if lock is not released. + break_lock + Forcibly break the lock on locked documents. + Returns + ------- + List of db_ids of updated Jobs. Could be an empty list or a list + with a single element. + """ values = { "state": state.value, "remote.step_attempts": 0, @@ -682,6 +1166,49 @@ def retry_jobs( wait: int | None = None, break_lock: bool = False, ): + """ + Retry selected Jobs, i.e. bring them back to its previous state if REMOTE_ERROR, + or reset the remote attempts and time of retry if in another running state. + + Parameters + ---------- + job_ids + One or more tuples, each containing the (uuid, index) pair of the + Jobs to retrieve. + db_ids + One or more db_ids of the Jobs to retrieve. + flow_ids + One or more Flow uuids to which the Jobs to retrieve belong. + state + The state of the Jobs. + start_date + Filter Jobs that were updated_on after this date. + Should be in the machine local time zone. It will be converted to UTC. + end_date + Filter Jobs that were updated_on before this date. + Should be in the machine local time zone. It will be converted to UTC. + name + Pattern matching the name of Job. Default is an exact match, but all + conventions from python fnmatch can be used (e.g. *test*) + metadata + A dictionary of the values of the metadata to match. Should be an + exact match for all the values provided. + raise_on_error + If True raise in case of error on one job error and stop the loop. + Otherwise, just log the error and proceed. + wait + In case the Flow or Jobs that need to be updated are locked, + wait this time (in seconds) for the lock to be released. + Raise an error if lock is not released. + break_lock + Forcibly break the lock on locked documents. Use with care and + verify that the lock has been set by a process that is not running + anymore. Doing otherwise will likely lead to inconsistencies in the DB. + + Returns + ------- + List of db_ids of the updated Jobs. + """ return self._many_jobs_action( method=self.retry_job, action_description="rerunning", @@ -706,6 +1233,36 @@ def retry_job( wait: int | None = None, break_lock: bool = False, ) -> list[int]: + """ + Retry a single Job, i.e. bring it back to its previous state if REMOTE_ERROR, + or reset the remote attempts and time of retry if in another running state. + Jobs in other states cannot be retried. + The Job is selected by db_id or uuid+index. Only one among db_id + and job_id should be defined. + + Only locking of the retried Job is required. + + Parameters + ---------- + db_id + The db_id of the Job. + job_id + The uuid of the Job. + job_index + The index of the Job. If None: the Job with the highest index. + wait + In case the Flow or Jobs that need to be updated are locked, + wait this time (in seconds) for the lock to be released. + Raise an error if lock is not released. + break_lock + Forcibly break the lock on locked documents. Use with care and + verify that the lock has been set by a process that is not running + anymore. Doing otherwise will likely lead to inconsistencies in the DB. + + Returns + ------- + List containing the db_id of the updated Job. + """ lock_filter, sort = self.generate_job_id_query(db_id, job_id, job_index) sleep = None if wait: @@ -743,6 +1300,7 @@ def retry_job( set_dict = { "remote.step_attempts": 0, "remote.retry_time_limit": None, + "remote.error": None, } lock.update_on_release = {"$set": set_dict} else: @@ -762,6 +1320,45 @@ def pause_jobs( raise_on_error: bool = True, wait: int | None = None, ) -> list[int]: + """ + Pause selected Jobs. Only READY and WAITING Jobs can be paused. + The action is reversible. + + Parameters + ---------- + job_ids + One or more tuples, each containing the (uuid, index) pair of the + Jobs to retrieve. + db_ids + One or more db_ids of the Jobs to retrieve. + flow_ids + One or more Flow uuids to which the Jobs to retrieve belong. + state + The state of the Jobs. + start_date + Filter Jobs that were updated_on after this date. + Should be in the machine local time zone. It will be converted to UTC. + end_date + Filter Jobs that were updated_on before this date. + Should be in the machine local time zone. It will be converted to UTC. + name + Pattern matching the name of Job. Default is an exact match, but all + conventions from python fnmatch can be used (e.g. *test*) + metadata + A dictionary of the values of the metadata to match. Should be an + exact match for all the values provided. + raise_on_error + If True raise in case of error on one job error and stop the loop. + Otherwise, just log the error and proceed. + wait + In case the Flow or Jobs that need to be updated are locked, + wait this time (in seconds) for the lock to be released. + Raise an error if lock is not released. + + Returns + ------- + List of db_ids of the updated Jobs. + """ return self._many_jobs_action( method=self.pause_job, action_description="pausing", @@ -791,6 +1388,50 @@ def cancel_jobs( wait: int | None = None, break_lock: bool = False, ) -> list[int]: + """ + Cancel selected Jobs. Only Jobs in the READY and all the running states + can be cancelled. + The action is not reversible. + + Parameters + ---------- + job_ids + One or more tuples, each containing the (uuid, index) pair of the + Jobs to retrieve. + db_ids + One or more db_ids of the Jobs to retrieve. + flow_ids + One or more Flow uuids to which the Jobs to retrieve belong. + state + The state of the Jobs. + start_date + Filter Jobs that were updated_on after this date. + Should be in the machine local time zone. It will be converted to UTC. + end_date + Filter Jobs that were updated_on before this date. + Should be in the machine local time zone. It will be converted to UTC. + name + Pattern matching the name of Job. Default is an exact match, but all + conventions from python fnmatch can be used (e.g. *test*) + metadata + A dictionary of the values of the metadata to match. Should be an + exact match for all the values provided. + raise_on_error + If True raise in case of error on one job error and stop the loop. + Otherwise, just log the error and proceed. + wait + In case the Flow or Jobs that need to be updated are locked, + wait this time (in seconds) for the lock to be released. + Raise an error if lock is not released. + break_lock + Forcibly break the lock on locked documents. Use with care and + verify that the lock has been set by a process that is not running + anymore. Doing otherwise will likely lead to inconsistencies in the DB. + + Returns + ------- + List of db_ids of the updated Jobs. + """ return self._many_jobs_action( method=self.cancel_job, action_description="cancelling", @@ -815,6 +1456,34 @@ def cancel_job( wait: int | None = None, break_lock: bool = False, ) -> list[int]: + """ + Cancel a single Job. Only Jobs in the READY and all the running states + can be cancelled. + Selected by db_id or uuid+index. Only one among db_id + and job_id should be defined. + The action is not reversible. + + Parameters + ---------- + db_id + The db_id of the Job. + job_id + The uuid of the Job. + job_index + The index of the Job. If None: the Job with the highest index. + wait + In case the Flow or Jobs that need to be updated are locked, + wait this time (in seconds) for the lock to be released. + Raise an error if lock is not released. + break_lock + Forcibly break the lock on locked documents. Use with care and + verify that the lock has been set by a process that is not running + anymore. Doing otherwise will likely lead to inconsistencies in the DB. + + Returns + ------- + List of db_ids of the updated Jobs. + """ job_lock_kwargs = dict( projection=["uuid", "index", "db_id", "state", "remote", "worker"] ) @@ -855,6 +1524,29 @@ def pause_job( job_index: int | None = None, wait: int | None = None, ) -> list[int]: + """ + Pause a single Job. Only READY and WAITING Jobs can be paused. + Selected by db_id or uuid+index. Only one among db_id + and job_id should be defined. + The action is reversible. + + Parameters + ---------- + db_id + The db_id of the Job. + job_id + The uuid of the Job. + job_index + The index of the Job. If None: the Job with the highest index. + wait + In case the Flow or Jobs that need to be updated are locked, + wait this time (in seconds) for the lock to be released. + Raise an error if lock is not released. + + Returns + ------- + List of db_ids of the updated Jobs. + """ job_lock_kwargs = dict(projection=["uuid", "index", "db_id", "state"]) flow_lock_kwargs = dict(projection=["uuid"]) with self.lock_job_flow( @@ -889,6 +1581,48 @@ def play_jobs( wait: int | None = None, break_lock: bool = False, ) -> list[int]: + """ + Restart selected Jobs that were previously paused. + + Parameters + ---------- + job_ids + One or more tuples, each containing the (uuid, index) pair of the + Jobs to retrieve. + db_ids + One or more db_ids of the Jobs to retrieve. + flow_ids + One or more Flow uuids to which the Jobs to retrieve belong. + state + The state of the Jobs. + start_date + Filter Jobs that were updated_on after this date. + Should be in the machine local time zone. It will be converted to UTC. + end_date + Filter Jobs that were updated_on before this date. + Should be in the machine local time zone. It will be converted to UTC. + name + Pattern matching the name of Job. Default is an exact match, but all + conventions from python fnmatch can be used (e.g. *test*) + metadata + A dictionary of the values of the metadata to match. Should be an + exact match for all the values provided. + raise_on_error + If True raise in case of error on one job error and stop the loop. + Otherwise, just log the error and proceed. + wait + In case the Flow or Jobs that need to be updated are locked, + wait this time (in seconds) for the lock to be released. + Raise an error if lock is not released. + break_lock + Forcibly break the lock on locked documents. Use with care and + verify that the lock has been set by a process that is not running + anymore. Doing otherwise will likely lead to inconsistencies in the DB. + + Returns + ------- + List of db_ids of the updated Jobs. + """ return self._many_jobs_action( method=self.play_job, action_description="playing", @@ -913,6 +1647,32 @@ def play_job( wait: int | None = None, break_lock: bool = False, ) -> list[int]: + """ + Restart a single Jobs that was previously paused. + Selected by db_id or uuid+index. Only one among db_id + and job_id should be defined. + + Parameters + ---------- + db_id + The db_id of the Job. + job_id + The uuid of the Job. + job_index + The index of the Job. If None: the Job with the highest index. + wait + In case the Flow or Jobs that need to be updated are locked, + wait this time (in seconds) for the lock to be released. + Raise an error if lock is not released. + break_lock + Forcibly break the lock on locked documents. Use with care and + verify that the lock has been set by a process that is not running + anymore. Doing otherwise will likely lead to inconsistencies in the DB. + + Returns + ------- + List of db_ids of the updated Jobs. + """ job_lock_kwargs = dict( projection=["uuid", "index", "db_id", "state", "job.config", "parents"] ) @@ -970,6 +1730,52 @@ def set_job_run_properties( metadata: dict | None = None, raise_on_error: bool = True, ) -> list[int]: + """ + Set execution properties for selected Jobs: + worker, exec_config and resources. + + Parameters + ---------- + worker + The name of the worker to set. + exec_config + The name of the exec_config to set or an explicit value of + ExecutionConfig or dict. + resources + The resources to be set, either as a dict or a QResources instance. + update + If True, when setting exec_config and resources a passed dictionary + will be used to update already existing values. + If False it will replace the original values. + job_ids + One or more tuples, each containing the (uuid, index) pair of the + Jobs to retrieve. + db_ids + One or more db_ids of the Jobs to retrieve. + flow_ids + One or more Flow uuids to which the Jobs to retrieve belong. + state + The state of the Jobs. + start_date + Filter Jobs that were updated_on after this date. + Should be in the machine local time zone. It will be converted to UTC. + end_date + Filter Jobs that were updated_on before this date. + Should be in the machine local time zone. It will be converted to UTC. + name + Pattern matching the name of Job. Default is an exact match, but all + conventions from python fnmatch can be used (e.g. *test*) + metadata + A dictionary of the values of the metadata to match. Should be an + exact match for all the values provided. + raise_on_error + If True raise in case of error on one job error and stop the loop. + Otherwise, just log the error and proceed. + + Returns + ------- + List of db_ids of the updated Jobs. + """ set_dict = {} if worker: if worker not in self.project.workers: @@ -1025,6 +1831,26 @@ def get_flow_job_aggreg( sort: list[tuple] | None = None, limit: int = 0, ) -> list[dict]: + """ + Retrieve data about Flows and all their Jobs through an aggregation. + + In the aggregation the list of Jobs are identified as `jobs_list`. + + Parameters + ---------- + query + A dictionary representing the filter. + projection + Projection of the fields passed to the aggregation. + sort + A list of (key, direction) pairs specifying the sort order for this + query. Follows pymongo conventions. + limit + Maximum number of entries to retrieve. 0 means no limit. + Returns + ------- + The list of dictionaries resulting from the query. + """ pipeline: list[dict] = [ { "$lookup": { @@ -1063,6 +1889,42 @@ def get_flows_info( limit: int = 0, full: bool = False, ) -> list[FlowInfo]: + """ + Query for Flows based on standard parameters and return a list of JobFlows. + + Parameters + ---------- + job_ids + One or more strings with uuids of Jobs belonging to the Flow. + db_ids + One or more db_ids of Jobs belonging to the Flow. + flow_ids + One or more Flow uuids. + state + The state of the Flows. + start_date + Filter Flows that were updated_on after this date. + Should be in the machine local time zone. It will be converted to UTC. + end_date + Filter Flows that were updated_on before this date. + Should be in the machine local time zone. It will be converted to UTC. + name + Pattern matching the name of Flow. Default is an exact match, but all + conventions from python fnmatch can be used (e.g. *test*) + sort + A list of (key, direction) pairs specifying the sort order for this + query. Follows pymongo conventions. + limit + Maximum number of entries to retrieve. 0 means no limit. + full + If True data is fetched from both the Flow collection and Job collection + with an aggregate. Otherwise, only the Job information in the Flow + document will be used. + + Returns + ------- + A list of JobFlows. + """ query = self._build_query_flow( job_ids=job_ids, db_ids=db_ids, @@ -1101,6 +1963,22 @@ def delete_flows( confirm: bool = False, delete_output: bool = False, ) -> int: + """ + Delete a list of Flows based on the flow uuids. + + Parameters + ---------- + flow_ids + One or more Flow uuids. + confirm + If False only a maximum of 10 Flows can be deleted. + delete_output + If True also delete the associated output in the JobStore. + + Returns + ------- + Number of delete Flows. + """ if isinstance(flow_ids, str): flow_ids = [flow_ids] @@ -1120,6 +1998,16 @@ def delete_flows( return deleted def delete_flow(self, flow_id: str, delete_output: bool = False): + """ + Delete a single Flow based on the uuid. + + Parameters + ---------- + flow_id + One or more Flow uuids. + delete_output + If True also delete the associated output in the JobStore. + """ # TODO should this lock anything (FW does not lock)? flow = self.get_flow_info_by_flow_uuid(flow_id) if not flow: @@ -1143,6 +2031,39 @@ def remove_lock_job( name: str | None = None, metadata: dict | None = None, ) -> int: + """ + Forcibly remove the lock on a locked Job document. + This should be used only if a lock is a leftover of a process that is not + running anymore. Doing otherwise may result in inconsistencies. + + Parameters + ---------- + job_ids + One or more tuples, each containing the (uuid, index) pair of the + Jobs to retrieve. + db_ids + One or more db_ids of the Jobs to retrieve. + flow_ids + One or more Flow uuids to which the Jobs to retrieve belong. + state + The state of the Jobs. + start_date + Filter Jobs that were updated_on after this date. + Should be in the machine local time zone. It will be converted to UTC. + end_date + Filter Jobs that were updated_on before this date. + Should be in the machine local time zone. It will be converted to UTC. + name + Pattern matching the name of Job. Default is an exact match, but all + conventions from python fnmatch can be used (e.g. *test*) + metadata + A dictionary of the values of the metadata to match. Should be an + exact match for all the values provided. + + Returns + ------- + Number of modified Jobs. + """ query = self._build_query_job( job_ids=job_ids, db_ids=db_ids, @@ -1161,7 +2082,80 @@ def remove_lock_job( ) return result.modified_count + def remove_lock_flow( + self, + job_ids: str | list[str] | None = None, + db_ids: int | list[int] | None = None, + flow_ids: str | None = None, + state: FlowState | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, + name: str | None = None, + ) -> list[FlowInfo]: + """ + Forcibly remove the lock on a locked Flow document. + This should be used only if a lock is a leftover of a process that is not + running anymore. Doing otherwise may result in inconsistencies. + + Parameters + ---------- + job_ids + One or more strings with uuids of Jobs belonging to the Flow. + db_ids + One or more db_ids of Jobs belonging to the Flow. + flow_ids + One or more Flow uuids. + state + The state of the Flows. + start_date + Filter Flows that were updated_on after this date. + Should be in the machine local time zone. It will be converted to UTC. + end_date + Filter Flows that were updated_on before this date. + Should be in the machine local time zone. It will be converted to UTC. + name + Pattern matching the name of Flow. Default is an exact match, but all + conventions from python fnmatch can be used (e.g. *test*) + + Returns + ------- + Number of modified Flows. + """ + query = self._build_query_flow( + job_ids=job_ids, + db_ids=db_ids, + flow_ids=flow_ids, + state=state, + start_date=start_date, + end_date=end_date, + locked=True, + name=name, + ) + + result = self.flows.update_many( + filter=query, + update={"$set": {"lock_id": None, "lock_time": None}}, + ) + return result.modified_count + def reset(self, reset_output: bool = False, max_limit: int = 25): + """ + Reset the content of the queue database and builds the indexes. + Optionally deletes the content of the JobStore with the outputs. + In this case all the data contained in the JobStore will be removed, + not just those associated to the data in the queue. + + Parameters + ---------- + reset_output + If True also reset the JobStore containing the outputs. + max_limit + Maximum number of Flows present in the DB. If number is larger + the database will not be reset. Set 0 for not limit. + Returns + ------- + True if the database was reset, False otherwise. + """ # TODO should it just delete docs related to job removed in the reset? # what if the outputs are in other stores? Should take those as well if max_limit: @@ -1185,14 +2179,24 @@ def reset(self, reset_output: bool = False, max_limit: int = 25): def build_indexes( self, - background=True, + background: bool = True, job_custom_indexes: list[str | list] | None = None, flow_custom_indexes: list[str | list] | None = None, ): """ - Build indexes + Build indexes in the database + + Parameters + ---------- + background + If True, the indexes should be created in the background. + job_custom_indexes + List of custom indexes for the jobs collection. Each element is passed + to pymongo's create_index, thus following those conventions. + flow_custom_indexes + List of custom indexes for the flows collection. + Same as job_custom_indexes. """ - self.jobs.create_index("db_id", unique=True, background=background) job_indexes = [ @@ -1229,6 +2233,9 @@ def build_indexes( self.flows.create_index(idx, background=background) def compact(self): + """ + Compact jobs and flows collections in MongoDB. + """ self.db.command({"compact": self.jobs_collection}) self.db.command({"compact": self.flows_collection}) From 1a664cc06b4dc721c3318f845c5cad6fc2d98ff8 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Tue, 12 Dec 2023 16:22:38 +0100 Subject: [PATCH 15/18] fix date inizialization --- pyproject.toml | 2 +- src/jobflow_remote/cli/types.py | 6 ++- src/jobflow_remote/jobs/data.py | 15 +++--- src/jobflow_remote/jobs/runner.py | 81 ++++++++++++++++++++++++++++--- src/jobflow_remote/utils/log.py | 1 - 5 files changed, 89 insertions(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 244c15c6..baf3abd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ ] requires-python = ">=3.9" dependencies =[ - "jobflow[strict]", + "jobflow", "pydantic>=2.0.1", "fabric", "tomlkit", diff --git a/src/jobflow_remote/cli/types.py b/src/jobflow_remote/cli/types.py index 4501038e..48e3535d 100644 --- a/src/jobflow_remote/cli/types.py +++ b/src/jobflow_remote/cli/types.py @@ -143,7 +143,11 @@ verbosity_opt = Annotated[ int, typer.Option( - "--verbosity", "-v", help="Set the verbosity of the output", count=True + "--verbosity", + "-v", + help="Set the verbosity of the output. Multiple -v options " + "increase the verbosity. (e.g. -vvv)", + count=True, ), ] diff --git a/src/jobflow_remote/jobs/data.py b/src/jobflow_remote/jobs/data.py index afcec745..ffcde5bb 100644 --- a/src/jobflow_remote/jobs/data.py +++ b/src/jobflow_remote/jobs/data.py @@ -80,6 +80,8 @@ class JobInfo(BaseModel): worker: str name: str state: JobState + created_on: datetime + updated_on: datetime remote: RemoteInfo = RemoteInfo() parents: Optional[list[str]] = None previous_state: Optional[JobState] = None @@ -89,8 +91,6 @@ class JobInfo(BaseModel): run_dir: Optional[str] = None start_time: Optional[datetime] = None end_time: Optional[datetime] = None - created_on: datetime = datetime.utcnow() - updated_on: datetime = datetime.utcnow() priority: int = 0 metadata: Optional[dict] = None @@ -157,8 +157,8 @@ class JobDoc(BaseModel): run_dir: Optional[str] = None start_time: Optional[datetime] = None end_time: Optional[datetime] = None - created_on: datetime = datetime.utcnow() - updated_on: datetime = datetime.utcnow() + created_on: datetime = Field(default_factory=datetime.utcnow) + updated_on: datetime = Field(default_factory=datetime.utcnow) priority: int = 0 # store: Optional[JobStore] = None exec_config: Optional[Union[ExecutionConfig, str]] = None @@ -189,8 +189,8 @@ class FlowDoc(BaseModel): name: str lock_id: Optional[str] = None lock_time: Optional[datetime] = None - created_on: datetime = datetime.utcnow() - updated_on: datetime = datetime.utcnow() + created_on: datetime = Field(default_factory=datetime.utcnow) + updated_on: datetime = Field(default_factory=datetime.utcnow) metadata: dict = Field(default_factory=dict) # parents need to include both the uuid and the index. # When dynamically replacing a Job with a Flow some new Jobs will @@ -266,6 +266,7 @@ class FlowInfo(BaseModel): flow_id: str state: FlowState name: str + created_on: datetime updated_on: datetime workers: list[str] job_states: list[JobState] @@ -275,6 +276,7 @@ class FlowInfo(BaseModel): @classmethod def from_query_dict(cls, d): + created_on = d["created_on"] updated_on = d["updated_on"] flow_id = d["uuid"] jobs_data = d.get("jobs_list") or [] @@ -314,6 +316,7 @@ def from_query_dict(cls, d): flow_id=flow_id, state=state, name=d["name"], + created_on=created_on, updated_on=updated_on, workers=workers, job_states=job_states, diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index 7aab0a39..95aad8c2 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -1,3 +1,5 @@ +"""The Runner orchestrating the Jobs execution""" + from __future__ import annotations import json @@ -7,7 +9,7 @@ import time import traceback import uuid -from collections import defaultdict, namedtuple +from collections import defaultdict from datetime import datetime from pathlib import Path @@ -43,19 +45,42 @@ logger = logging.getLogger(__name__) -JobFWData = namedtuple( - "JobFWData", - ["fw", "task", "job", "store", "worker_name", "worker", "host", "original_store"], -) +class Runner: + """ + Object orchestrating the execution of all the Jobs. + Advances the status of the Jobs, handles the communication with the workers + and updates the queue and output databases. + + The main entry point is the `run` method. It is mainly supposed to be executed + if a daemon, but can also be run directly for testing purposes. + It allows to run all the steps required to advance the Job's states or even + a subset of them, to parallelize the different tasks. + + The runner instantiates a pool of workers and hosts given in the project + definition. A single connection will be opened if multiple workers share + the same host. + """ -class Runner: def __init__( self, project_name: str | None = None, log_level: LogLevel | None = None, runner_id: str | None = None, ): + """ + Parameters + ---------- + project_name + Name of the project. Used to retrieve all the configurations required + to execute the runner. + log_level + Logging level of the Runner. + runner_id + A unique identifier for the Runner process. Used to identify the + runner process in logging and in the DB locks. + If None a uuid will be generated. + """ self.stop_signal = False self.runner_id: str = runner_id or str(uuid.uuid4()) self.config_manager: ConfigManager = ConfigManager() @@ -100,26 +125,68 @@ def __init__( @property def runner_options(self) -> RunnerOptions: + """ + The Runner options defined in the project. + """ return self.project.runner def handle_signal(self, signum, frame): + """ + Handle the SIGTERM signal in the Runner. + Sets a variable that will stop the Runner loop. + """ logger.info(f"Received signal: {signum}") self.stop_signal = True def get_worker(self, worker_name: str) -> WorkerBase: + """ + Get the worker from the pool of workers instantiated by the Runner. + + Parameters + ---------- + worker_name + The name of the worker. + + Returns + ------- + An instance of the corresponding worker. + """ if worker_name not in self.workers: raise ConfigError( f"No worker {worker_name} is defined in project {self.project_name}" ) return self.workers[worker_name] - def get_host(self, worker_name: str): + def get_host(self, worker_name: str) -> BaseHost: + """ + Get the host associated to a worker from the pool of hosts instantiated + by the Runner. + + Parameters + ---------- + worker_name + The name of the worker. + Returns + ------- + An instance of the Host associated to the worker. + """ host = self.hosts[worker_name] if not host.is_connected: host.connect() return host def get_queue_manager(self, worker_name: str) -> QueueManager: + """ + Get an instance of the queue manager associated to a worker, based on its host. + + Parameters + ---------- + worker_name + The name of the worker. + Returns + ------- + An instance of the QueueManager associated to the worker. + """ if worker_name not in self.queue_managers: worker = self.get_worker(worker_name) self.queue_managers[worker_name] = QueueManager( diff --git a/src/jobflow_remote/utils/log.py b/src/jobflow_remote/utils/log.py index 2c5703fa..d5fbf42a 100644 --- a/src/jobflow_remote/utils/log.py +++ b/src/jobflow_remote/utils/log.py @@ -27,7 +27,6 @@ def initialize_runner_logger( # runner is started. makedirs_p(log_folder) - print("!!!", runner_id) if runner_id: msg_format = f"%(asctime)s [%(levelname)s] ID {runner_id} %(name)s: %(message)s" else: From 74507b7b2d391dbf3cb354346077f271a978a76a Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Tue, 12 Dec 2023 17:17:29 +0100 Subject: [PATCH 16/18] fix db_id in output's metadata --- src/jobflow_remote/jobs/jobcontroller.py | 2 +- src/jobflow_remote/jobs/runner.py | 4 ---- src/jobflow_remote/remote/data.py | 7 ++++++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index ea2ba117..b8245131 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -2611,7 +2611,7 @@ def complete_job( remote_store = get_remote_store(store, local_path) remote_store.connect() - update_store(store, remote_store, save) + update_store(store, remote_store, save, job_doc.db_id) self.checkin_job( job_doc, flow_lock.locked_document, diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index 95aad8c2..9003f795 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -347,10 +347,6 @@ def upload(self, lock): except Exception: logging.error(f"error while closing the store {store}", exc_info=True) - # set the db_id in the job's metadata, so that it is available in the outputs - if "db_id" not in job.metadata: - job.metadata["db_id"] = db_id - remote_path = get_job_path(job.uuid, job.index, worker.work_dir) # Set the value of the original store for dynamical workflow. Usually it diff --git a/src/jobflow_remote/remote/data.py b/src/jobflow_remote/remote/data.py index a8ad9539..6240a163 100644 --- a/src/jobflow_remote/remote/data.py +++ b/src/jobflow_remote/remote/data.py @@ -80,12 +80,17 @@ def get_remote_store_filenames(store: JobStore) -> list[str]: return filenames -def update_store(store, remote_store, save): +def update_store(store, remote_store, save, db_id): # TODO is it correct? data = list(remote_store.query(load=save)) if len(data) > 1: raise RuntimeError("something wrong with the remote store") + # Set the db_id here and not directly in the Job's metadata to avoid + # that it gets passed down to its children/replacements. + if "db_id" not in data[0]["metadata"]: + data[0]["metadata"]["db_id"] = db_id + store.connect() try: for d in data: From 33ba56550ca8da2ac878cb0d881e3627aae1e62d Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Wed, 13 Dec 2023 16:30:56 +0100 Subject: [PATCH 17/18] more docstrings --- src/jobflow_remote/cli/runner.py | 14 +- src/jobflow_remote/config/manager.py | 20 +- src/jobflow_remote/jobs/batch.py | 51 +++- src/jobflow_remote/jobs/daemon.py | 6 +- src/jobflow_remote/jobs/data.py | 140 +++++++++- src/jobflow_remote/jobs/jobcontroller.py | 316 +++++++++++++++++++++-- src/jobflow_remote/jobs/runner.py | 126 ++++++++- src/jobflow_remote/jobs/state.py | 24 ++ src/jobflow_remote/utils/db.py | 151 ++++++++++- 9 files changed, 776 insertions(+), 72 deletions(-) diff --git a/src/jobflow_remote/cli/runner.py b/src/jobflow_remote/cli/runner.py index 939eeb91..856e90bf 100644 --- a/src/jobflow_remote/cli/runner.py +++ b/src/jobflow_remote/cli/runner.py @@ -52,12 +52,12 @@ def run( help="Enable the complete option in the runner", ), ] = False, - slurm: Annotated[ + queue: Annotated[ bool, typer.Option( - "--slurm", - "-s", - help="Enable the slurm option in the runner", + "--queue", + "-q", + help="Enable the queue option in the runner", ), ] = False, checkout: Annotated[ @@ -76,10 +76,10 @@ def run( """ runner_id = os.getpid() if set_pid else None runner = Runner(log_level=log_level, runner_id=str(runner_id)) - if not (transfer or complete or slurm or checkout): - transfer = complete = slurm = checkout = True + if not (transfer or complete or queue or checkout): + transfer = complete = queue = checkout = True - runner.run(transfer=transfer, complete=complete, slurm=slurm, checkout=checkout) + runner.run(transfer=transfer, complete=complete, queue=queue, checkout=checkout) @app_runner.command() diff --git a/src/jobflow_remote/config/manager.py b/src/jobflow_remote/config/manager.py index ed4f1254..25ea6bea 100644 --- a/src/jobflow_remote/config/manager.py +++ b/src/jobflow_remote/config/manager.py @@ -75,7 +75,8 @@ def projects(self) -> dict[str, Project]: """ Returns ------- - Dictionary with project name as key and Project as value. + dict + Dictionary with project name as key and Project as value. """ return {name: pd.project for name, pd in self.projects_data.items()} @@ -85,7 +86,8 @@ def load_projects_data(self) -> dict[str, ProjectData]: Returns ------- - Dictionary with project name as key and ProjectData as value. + dict + Dictionary with project name as key and ProjectData as value. """ projects_data: dict[str, ProjectData] = {} @@ -122,7 +124,8 @@ def select_project_name(self, project_name: str | None = None) -> str: The name of the project or None to use the value from the settings Returns ------- - The name of the selected project. + str + The name of the selected project. """ from jobflow_remote import SETTINGS @@ -145,7 +148,8 @@ def get_project_data(self, project_name: str | None = None) -> ProjectData: The name of the project or None to use the value from the settings Returns ------- - The selected ProjectData + ProjectData + The selected ProjectData """ project_name = self.select_project_name(project_name) @@ -164,7 +168,8 @@ def get_project(self, project_name: str | None = None) -> Project: The name of the project or None to use the value from the settings Returns ------- - The selected Project + Project + The selected Project """ return self.get_project_data(project_name).project @@ -264,7 +269,8 @@ def project_names_from_files(self) -> list[str]: Returns ------- - List of project names. + list + List of project names. """ project_names = [] for ext in self.projects_ext: @@ -344,6 +350,7 @@ def get_worker( use the one from the settings. Returns ------- + WorkerBase The selected Worker. """ project = self.get_project(project_name) @@ -447,6 +454,7 @@ def get_exec_config( or None to use the one from the settings. Returns ------- + ExecutionConfig The selected ExecutionConfig """ project = self.get_project(project_name) diff --git a/src/jobflow_remote/jobs/batch.py b/src/jobflow_remote/jobs/batch.py index 3d37ab29..67bb7ed1 100644 --- a/src/jobflow_remote/jobs/batch.py +++ b/src/jobflow_remote/jobs/batch.py @@ -23,11 +23,10 @@ class RemoteBatchManager: """ + Manager of remote files containing information about Jobs to be handled by + a batch worker. - Attributes - ---------- - host : BaseHost - Host where the command should be executed. + Used by the Runner. """ def __init__( @@ -35,6 +34,15 @@ def __init__( host: BaseHost, files_dir: str | Path, ): + """ + + Parameters + ---------- + host + The host where the files are. + files_dir + The full path to directory where the files are stored. + """ self.host = host self.files_dir = Path(files_dir) self.submitted_dir = self.files_dir / SUBMITTED_DIR @@ -44,6 +52,9 @@ def __init__( self._init_files_dir() def _init_files_dir(self): + """ + Initialize the file directory, creating all the subdiretories. + """ self.host.connect() # Note that the check of the creation of the folders on a remote host # slows down the start of the runner by a few seconds. @@ -57,12 +68,37 @@ def _init_files_dir(self): self.host.mkdir(self.lock_dir) def submit_job(self, job_id: str, index: int): + """ + Submit a Job by uploading the corresponding file. + + Parameters + ---------- + job_id + Uuid of the Job. + index + Index of the Job. + """ self.host.write_text_file(self.submitted_dir / f"{job_id}_{index}", "") def get_submitted(self) -> list[str]: + """ + Get a list of files present in the submitted directory. + + Returns + ------- + The list of file names in the directory. + """ return self.host.listdir(self.submitted_dir) def get_terminated(self) -> list[tuple[str, int, str]]: + """ + Get job ids and process ids of the terminated jobs from the corresponding + directory. + + Returns + ------- + + """ terminated = [] for i in self.host.listdir(self.terminated_dir): job_id, index, process_uuid = i.split("_") @@ -84,6 +120,13 @@ def delete_terminated(self, ids: list[tuple[str, int, str]]): class LocalBatchManager: + """ + Manager of local files containing information about Jobs to be handled by + a batch worker. + + Used in the worker to executes the batch Jobs. + """ + def __init__(self, files_dir: str | Path, process_id: str): self.process_id = process_id self.files_dir = Path(files_dir) diff --git a/src/jobflow_remote/jobs/daemon.py b/src/jobflow_remote/jobs/daemon.py index 09e053ab..da36d280 100644 --- a/src/jobflow_remote/jobs/daemon.py +++ b/src/jobflow_remote/jobs/daemon.py @@ -83,13 +83,13 @@ process_name=run_jobflow_transfer%(process_num)s stopwaitsecs=86400 -[program:runner_daemon_slurm] +[program:runner_daemon_queue] priority=100 -command=jf -p $project runner run -pid --slurm -log $loglevel +command=jf -p $project runner run -pid --queue -log $loglevel autostart=true autorestart=false numprocs=1 -process_name=run_jobflow_slurm +process_name=run_jobflow_queue stopwaitsecs=86400 [program:runner_daemon_complete] diff --git a/src/jobflow_remote/jobs/data.py b/src/jobflow_remote/jobs/data.py index ffcde5bb..8be6de2a 100644 --- a/src/jobflow_remote/jobs/data.py +++ b/src/jobflow_remote/jobs/data.py @@ -23,7 +23,29 @@ def get_initial_job_doc_dict( worker: str, exec_config: Optional[ExecutionConfig], resources: Optional[Union[dict, QResources]], -): +) -> dict: + """ + Generate an instance of JobDoc for initial insertion in the DB. + + Parameters + ---------- + job: + The Job of the JobDoc. + parents + The parents of the Job. + db_id + The db_id. + worker + The worker where the Job should be executed. + exec_config + The ExecutionConfig used for execution. + resources + The resources used to run the Job. + Returns + ------- + JobDoc + A new JobDoc. + """ from monty.json import jsanitize # take the resources either from the job, if they are defined @@ -48,7 +70,21 @@ def get_initial_job_doc_dict( return job_doc.as_db_dict() -def get_initial_flow_doc_dict(flow: Flow, job_dicts: list[dict]): +def get_initial_flow_doc_dict(flow: Flow, job_dicts: list[dict]) -> dict: + """ + Generate a serialized FlowDoc for initial insertion in the DB. + + Parameters + ---------- + flow + The Flow used to generate the FlowDoc. + job_dicts + The dictionaries of the Jobs composing the Flow. + Returns + ------- + dict + A serialized version of a new FlowDoc. + """ jobs = [j["uuid"] for j in job_dicts] ids = [(j["db_id"], j["uuid"], j["index"]) for j in job_dicts] parents = {j["uuid"]: {"1": j["parents"]} for j in job_dicts} @@ -66,6 +102,10 @@ def get_initial_flow_doc_dict(flow: Flow, job_dicts: list[dict]): class RemoteInfo(BaseModel): + """ + Model with data describing the remote state of a Job. + """ + step_attempts: int = 0 queue_state: Optional[QState] = None process_id: Optional[str] = None @@ -74,6 +114,11 @@ class RemoteInfo(BaseModel): class JobInfo(BaseModel): + """ + Model with information extracted from a JobDoc. + Mainly for visualization purposes. + """ + uuid: str index: int db_id: int @@ -100,6 +145,14 @@ def is_locked(self) -> bool: @property def run_time(self) -> Optional[float]: + """ + Calculate the run time based on start and end time. + + Returns + ------- + float + The run time in seconds + """ if self.start_time and self.end_time: return (self.end_time - self.start_time).total_seconds() @@ -107,6 +160,14 @@ def run_time(self) -> Optional[float]: @property def estimated_run_time(self) -> Optional[float]: + """ + Estimate the current run time based on the start time and the current time. + + Returns + ------- + float + The estimated run time in seconds. + """ if self.start_time: return ( datetime.now(tz=self.start_time.tzinfo) - self.start_time @@ -116,6 +177,18 @@ def estimated_run_time(self) -> Optional[float]: @classmethod def from_query_output(cls, d) -> "JobInfo": + """ + Generate an instance from the output of a query to the JobDoc collection. + + Parameters + ---------- + d + The dictionary with the queried data. + Returns + ------- + JobInfo + The instance of JobInfo based on the data + """ job = d.pop("job") for k in ["name", "metadata"]: d[k] = job[k] @@ -123,6 +196,14 @@ def from_query_output(cls, d) -> "JobInfo": def _projection_db_info() -> list[str]: + """ + Generate a list of fields used for projection, depending on the JobInfo model. + + Returns + ------- + list + The list of fields to use in a query. + """ projection = list(JobInfo.model_fields.keys()) projection.remove("name") projection.append("job.name") @@ -130,10 +211,15 @@ def _projection_db_info() -> list[str]: return projection +# generate the list only once. projection_job_info = _projection_db_info() class JobDoc(BaseModel): + """ + Model for the standard representation of a Job in the queue database. + """ + # TODO consider defining this as a dict and provide a get_job() method to # get the real Job. This would avoid (de)serializing jobs if this document # is used often to interact with the DB. @@ -167,22 +253,32 @@ class JobDoc(BaseModel): stored_data: Optional[dict] = None # history: Optional[list[str]] = None - def as_db_dict(self): - # required since the resources are not serialized otherwise - if isinstance(self.resources, QResources): - resources_dict = self.resources.as_dict() + def as_db_dict(self) -> dict: + """ + Generate a dict representation suitable to be inserted in the database. + + Returns + ------- + dict + The dict representing the JobDoc. + """ d = jsanitize( self.model_dump(mode="python"), strict=True, allow_bson=True, enum_values=True, ) + # required since the resources are not serialized otherwise if isinstance(self.resources, QResources): - d["resources"] = resources_dict + d["resources"] = self.resources.as_dict() return d class FlowDoc(BaseModel): + """ + Model for the standard representation of a Flow in the queue database. + """ + uuid: str jobs: list[str] state: FlowState @@ -202,7 +298,15 @@ class FlowDoc(BaseModel): # ids correspond to db_id, uuid, index for each JobDoc ids: list[tuple[int, str, int]] = Field(default_factory=list) - def as_db_dict(self): + def as_db_dict(self) -> dict: + """ + Generate a dict representation suitable to be inserted in the database. + + Returns + ------- + dict + The dict representing the FlowDoc. + """ d = jsanitize( self.model_dump(mode="python"), strict=True, @@ -254,12 +358,21 @@ def ids_mapping(self) -> dict[str, dict[int, int]]: class RemoteError(RuntimeError): + """ + An exception signaling errors during the update of the remote states. + """ + def __init__(self, msg, no_retry=False): self.msg = msg self.no_retry = no_retry class FlowInfo(BaseModel): + """ + Model with information extracted from a FlowDoc. + Mainly for visualization purposes. + """ + db_ids: list[int] job_ids: list[str] job_indexes: list[int] @@ -275,7 +388,7 @@ class FlowInfo(BaseModel): hosts: list[list[str]] @classmethod - def from_query_dict(cls, d): + def from_query_dict(cls, d) -> "FlowInfo": created_on = d["created_on"] updated_on = d["updated_on"] flow_id = d["uuid"] @@ -351,6 +464,10 @@ def iter_job_prop(self): class DynamicResponseType(Enum): + """ + Types of dynamic responses in jobflow. + """ + REPLACE = "replace" DETOUR = "detour" ADDITION = "addition" @@ -358,11 +475,12 @@ class DynamicResponseType(Enum): def get_reset_job_base_dict() -> dict: """ - Return a dictionary with the basic properties to update in case of reset. + Generate a dictionary with the basic properties to update in case of reset. Returns ------- - + dict + Data to be reset. """ d = { "remote.step_attempts": 0, diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index b8245131..eb37389a 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -8,7 +8,7 @@ from contextlib import ExitStack from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Callable, cast +from typing import TYPE_CHECKING, Any, Callable, cast import jobflow import pymongo @@ -44,6 +44,10 @@ from jobflow_remote.utils.data import deep_merge_dict from jobflow_remote.utils.db import FlowLockedError, JobLockedError, MongoLock +if TYPE_CHECKING: + from collections.abc import Generator + + logger = logging.getLogger(__name__) @@ -110,6 +114,7 @@ def from_project_name(cls, project_name: str | None = None) -> JobController: The name of the project. If None the default project will be used. Returns ------- + JobController An instance of JobController associated with the project. """ config_manager: ConfigManager = ConfigManager() @@ -130,6 +135,7 @@ def from_project(cls, project: Project) -> JobController: project will be used. Returns ------- + JobController An instance of JobController associated with the project. """ queue_store = project.get_queue_store() @@ -197,6 +203,7 @@ def _build_query_job( exact match for all the values provided. Returns ------- + dict A dictionary with the query to be applied to a collection containing JobDocs. """ @@ -286,6 +293,7 @@ def _build_query_flow( Returns ------- + dict A dictionary with the query to be applied to a collection containing FlowDocs. """ @@ -326,6 +334,21 @@ def _build_query_flow( return query def get_jobs_info_query(self, query: dict = None, **kwargs) -> list[JobInfo]: + """ + Get a list of JobInfo based on a generic query. + + Parameters + ---------- + query + The query to be performed. + kwargs + arguments passed to MongoDB find(). + + Returns + ------- + list + A list of JobInfo matching the criteria. + """ data = self.jobs.find(query, projection=projection_job_info, **kwargs) jobs_data = [] @@ -384,6 +407,7 @@ def get_jobs_info( Returns ------- + list A list of JobInfo objects for the Jobs matching the criteria. """ query = self._build_query_job( @@ -411,6 +435,7 @@ def get_jobs_doc_query(self, query: dict = None, **kwargs) -> list[JobDoc]: All arguments passed to pymongo's Collection.find() method. Returns ------- + list A list of JobDoc objects for the Jobs matching the criteria. """ data = self.jobs.find(query, **kwargs) @@ -471,6 +496,7 @@ def get_jobs_doc( Returns ------- + list A list of JobDoc objects for the Jobs matching the criteria. """ query = self._build_query_job( @@ -507,6 +533,7 @@ def generate_job_id_query( added to get the highest index. Returns ------- + dict, list A dict and an optional list to be used as query and sort, respectively, in a query for a single Job. """ @@ -554,6 +581,7 @@ def get_job_info( Returns ------- + JobInfo A JobInfo, or None if no Job matches the criteria. """ query, sort = self.generate_job_id_query(db_id, job_id, job_index) @@ -624,6 +652,7 @@ def _many_jobs_action( Kwargs passed to the method called on each Job Returns ------- + list List of db_ids of the updated Jobs. """ query = self._build_query_job( @@ -713,6 +742,7 @@ def rerun_jobs( Returns ------- + list List of db_ids of the updated Jobs. """ return self._many_jobs_action( @@ -783,6 +813,7 @@ def rerun_job( Returns ------- + list List of db_ids of the updated Jobs. """ lock_filter, sort = self.generate_job_id_query(db_id, job_id, job_index) @@ -871,6 +902,7 @@ def _full_rerun( Bypass the limitation that only Jobs in a certain state can be rerun. Returns ------- + dict, list Updates to be set on the rerun Job upon lock release and the list of db_ids of the modified Jobs. """ @@ -1010,7 +1042,8 @@ def _reset_remote(self, doc: dict) -> dict: Returns ------- - + dict + Updates to be set on the Job upon lock release. """ if doc["state"] in [JobState.SUBMITTED.value, JobState.RUNNING.value]: # try cancelling the job submitted to the remote queue @@ -1065,6 +1098,7 @@ def _set_job_properties( If None all states are acceptable. Returns ------- + list List of db_ids of updated Jobs. Could be an empty list or a list with a single element. """ @@ -1131,6 +1165,7 @@ def set_job_state( Forcibly break the lock on locked documents. Returns ------- + list List of db_ids of updated Jobs. Could be an empty list or a list with a single element. """ @@ -1165,7 +1200,7 @@ def retry_jobs( raise_on_error: bool = True, wait: int | None = None, break_lock: bool = False, - ): + ) -> list[int]: """ Retry selected Jobs, i.e. bring them back to its previous state if REMOTE_ERROR, or reset the remote attempts and time of retry if in another running state. @@ -1207,6 +1242,7 @@ def retry_jobs( Returns ------- + list List of db_ids of the updated Jobs. """ return self._many_jobs_action( @@ -1261,6 +1297,7 @@ def retry_job( Returns ------- + list List containing the db_id of the updated Job. """ lock_filter, sort = self.generate_job_id_query(db_id, job_id, job_index) @@ -1357,6 +1394,7 @@ def pause_jobs( Returns ------- + list List of db_ids of the updated Jobs. """ return self._many_jobs_action( @@ -1430,6 +1468,7 @@ def cancel_jobs( Returns ------- + list List of db_ids of the updated Jobs. """ return self._many_jobs_action( @@ -1482,6 +1521,7 @@ def cancel_job( Returns ------- + list List of db_ids of the updated Jobs. """ job_lock_kwargs = dict( @@ -1545,6 +1585,7 @@ def pause_job( Returns ------- + list List of db_ids of the updated Jobs. """ job_lock_kwargs = dict(projection=["uuid", "index", "db_id", "state"]) @@ -1621,6 +1662,7 @@ def play_jobs( Returns ------- + list List of db_ids of the updated Jobs. """ return self._many_jobs_action( @@ -1671,6 +1713,7 @@ def play_job( Returns ------- + list List of db_ids of the updated Jobs. """ job_lock_kwargs = dict( @@ -1774,6 +1817,7 @@ def set_job_run_properties( Returns ------- + list List of db_ids of the updated Jobs. """ set_dict = {} @@ -1849,6 +1893,7 @@ def get_flow_job_aggreg( Maximum number of entries to retrieve. 0 means no limit. Returns ------- + list The list of dictionaries resulting from the query. """ pipeline: list[dict] = [ @@ -1923,6 +1968,7 @@ def get_flows_info( Returns ------- + list A list of JobFlows. """ query = self._build_query_flow( @@ -1977,6 +2023,7 @@ def delete_flows( Returns ------- + int Number of delete Flows. """ if isinstance(flow_ids, str): @@ -2062,6 +2109,7 @@ def remove_lock_job( Returns ------- + int Number of modified Jobs. """ query = self._build_query_job( @@ -2091,7 +2139,7 @@ def remove_lock_flow( start_date: datetime | None = None, end_date: datetime | None = None, name: str | None = None, - ) -> list[FlowInfo]: + ) -> int: """ Forcibly remove the lock on a locked Flow document. This should be used only if a lock is a leftover of a process that is not @@ -2119,6 +2167,7 @@ def remove_lock_flow( Returns ------- + int Number of modified Flows. """ query = self._build_query_flow( @@ -2138,7 +2187,7 @@ def remove_lock_flow( ) return result.modified_count - def reset(self, reset_output: bool = False, max_limit: int = 25): + def reset(self, reset_output: bool = False, max_limit: int = 25) -> bool: """ Reset the content of the queue database and builds the indexes. Optionally deletes the content of the JobStore with the outputs. @@ -2154,6 +2203,7 @@ def reset(self, reset_output: bool = False, max_limit: int = 25): the database will not be reset. Set 0 for not limit. Returns ------- + bool True if the database was reset, False otherwise. """ # TODO should it just delete docs related to job removed in the reset? @@ -2710,7 +2760,19 @@ def checkin_job( return len(self.refresh_children(job_uuids)) + 1 # TODO should this refresh all the kind of states? Or just set to ready? - def refresh_children(self, job_uuids): + def refresh_children(self, job_uuids: list[str]) -> list[int]: + """ + Set the state of Jobs children to READY following the completion of a Job. + + Parameters + ---------- + job_uuids + List of Jobs uuids belonging to a Flow. + + Returns + ------- + List of db_ids of modified Jobs. + """ # go through and look for jobs whose state we can update to ready. # Need to ensure that all parent uuids with all indices are completed # first find state of all jobs; ensure larger indices are returned last. @@ -2743,7 +2805,7 @@ def refresh_children(self, job_uuids): # Here it is assuming that there will be only one job with each uuid, as # it should be when switching state to READY the first time. # The code forbids rerunning a job that have children with index larger than 1, - # to this should always be consistent. + # so this should always be consistent. if len(to_ready) > 0: self.jobs.update_many( {"db_id": {"$in": to_ready}}, {"$set": {"state": JobState.READY.value}} @@ -2751,6 +2813,17 @@ def refresh_children(self, job_uuids): return to_ready def stop_children(self, job_uuid: str) -> int: + """ + Stop the direct children of a Job in the WAITING state. + + Parameters + ---------- + job_uuid + The uuid of the Job. + Returns + ------- + The number of modified Jobs. + """ result = self.jobs.update_many( {"parents": job_uuid, "state": JobState.WAITING.value}, {"$set": {"state": JobState.STOPPED.value}}, @@ -2758,6 +2831,19 @@ def stop_children(self, job_uuid: str) -> int: return result.modified_count def stop_jobflow(self, job_uuid: str = None, flow_uuid: str = None) -> int: + """ + Stop all the WAITING Jobs in a Flow. + + Parameters + ---------- + job_uuid + The uuid of Job to identify the Flow. Incompatible with flow_uuid. + flow_uuid + The Flow uuid. Incompatible with job_uuid. + Returns + ------- + The number of modified Jobs. + """ if job_uuid is None and flow_uuid is None: raise ValueError("Either job_uuid or flow_uuid must be set.") @@ -2782,6 +2868,17 @@ def stop_jobflow(self, job_uuid: str = None, flow_uuid: str = None) -> int: return result.modified_count def get_job_uuids(self, flow_uuids: list[str]) -> list[str]: + """ + Get the list of Jobs belonging to Flows, based on their uuid. + + Parameters + ---------- + flow_uuids + A list of Flow uuids. + Returns + ------- + A list of uuids of Jobs belong to the selected Flows. + """ job_uuids = [] for flow in self.flows.find_one( {"uuid": {"$in": flow_uuids}}, projection=["jobs"] @@ -2796,6 +2893,25 @@ def get_flow_jobs_data( sort: dict | None = None, limit: int = 0, ) -> list[dict]: + """ + Get the data of Flows and their Jobs from the DB using an aggregation. + + In the aggregation the Jobs are identified as "jobs". + + Parameters + ---------- + query + The query to filter the Flow. + projection + The projection for the Flow and Job data. + sort + Sorting passed to the aggregation. + limit + The maximum number of results returned. + Returns + ------- + A list of dictionaries with the result of the query. + """ pipeline: list[dict] = [ { "$lookup": { @@ -2826,6 +2942,19 @@ def update_flow_state( flow_uuid: str, updated_states: dict[str, dict[int, JobState]] | None = None, ): + """ + Update the state of a Flow in the DB based on the Job's states. + + The Flow should be locked while performing this operation. + + Parameters + ---------- + flow_uuid + The uuid of the Flow to update. + updated_states + A dictionary with the updated states of Jobs that have not been + stored in the DB yet. In the form {job_uuid: JobState value}. + """ updated_states = updated_states or {} projection = ["uuid", "index", "parents", "state"] flow_jobs = self.get_jobs_info_by_flow_uuid( @@ -2845,12 +2974,40 @@ def update_flow_state( self.flows.find_one_and_update({"uuid": flow_uuid}, set_state) @contextlib.contextmanager - def lock_job(self, **lock_kwargs): + def lock_job(self, **lock_kwargs) -> Generator[MongoLock, None, None]: + """ + Lock a Job document. + + See MongoLock context manager for more details about the locking options. + + Parameters + ---------- + lock_kwargs + Kwargs passed to the MongoLock context manager. + Returns + ------- + MongoLock + An instance of MongoLock. + """ with MongoLock(collection=self.jobs, **lock_kwargs) as lock: yield lock @contextlib.contextmanager - def lock_flow(self, **lock_kwargs): + def lock_flow(self, **lock_kwargs) -> Generator[MongoLock, None, None]: + """ + Lock a Flow document. + + See MongoLock context manager for more details about the locking options. + + Parameters + ---------- + lock_kwargs + Kwargs passed to the MongoLock context manager. + Returns + ------- + MongoLock + An instance of MongoLock. + """ with MongoLock(collection=self.flows, **lock_kwargs) as lock: yield lock @@ -2861,7 +3018,30 @@ def lock_job_for_update( max_step_attempts, delta_retry, **kwargs, - ): + ) -> Generator[MongoLock, None, None]: + """ + Lock a Job document for state update by the Runner. + + See MongoLock context manager for more details about the locking options. + + Parameters + ---------- + query + The query used to select the Job document to lock. + max_step_attempts + The maximum number of attempts for a single step after which + the Job should be set to the REMOTE_ERROR state. + delta_retry + List of increasing delay between subsequent attempts when the + advancement of a remote step fails. Used to set the retry time. + kwargs + Kwargs passed to the MongoLock context manager. + + Returns + ------- + MongoLock + An instance of MongoLock. + """ db_filter = dict(query) db_filter["remote.retry_time_limit"] = {"$not": {"$gt": datetime.utcnow()}} @@ -2945,7 +3125,36 @@ def lock_job_flow( acceptable_states: list[JobState] | None = None, job_lock_kwargs: dict | None = None, flow_lock_kwargs: dict | None = None, - ): + ) -> Generator[tuple[MongoLock, MongoLock], None, None]: + """ + Lock one Job document and the Flow document the Job belongs to. + + See MongoLock context manager for more details about the locking options. + + Parameters + ---------- + job_id + The uuid of the Job to lock. + db_id + The db_id of the Job to lock. + job_index + The index of the Job to lock. + wait + The amount of seconds to wait for a lock to be released. + break_lock + True if the context manager is allowed to forcibly break a lock. + acceptable_states + A list of JobStates. If not among these a ValueError exception is + raised. + job_lock_kwargs + Kwargs passed to MongoLock for the Job lock. + flow_lock_kwargs + Kwargs passed to MongoLock for the Flow lock. + Returns + ------- + MongoLock, MongoLock + An instance of MongoLock. + """ lock_filter, sort = self.generate_job_id_query(db_id, job_id, job_index) sleep = None if wait: @@ -2993,11 +3202,27 @@ def lock_job_flow( yield job_lock, flow_lock def ping_flow_doc(self, uuid: str): + """ + Ping a Flow document to update its "updated_on" value. + + Parameters + ---------- + uuid + The uuid of the Flow to update. + """ self.flows.find_one_and_update( {"nodes": uuid}, {"$set": {"updated_on": datetime.utcnow()}} ) def _cancel_queue_process(self, job_doc: dict): + """ + Cancel the process in the remote queue. + + Parameters + ---------- + job_doc + The dict of the JobDoc with the Job to be cancelled. + """ queue_process_id = job_doc["remote"]["process_id"] if not queue_process_id: raise ValueError("The process id is not defined in the job document") @@ -3020,20 +3245,69 @@ def _cancel_queue_process(self, job_doc: dict): ) def get_batch_processes(self, worker: str) -> dict[str, str]: + """ + Get the batch processes associated with a given worker. + + Parameters + ---------- + worker + The worker name. + Returns + ------- + dict + A dictionary with the {process_id: process_uuid} of the batch + jobs running on the selected worker. + """ result = self.auxiliary.find_one({"batch_processes": {"$exists": True}}) if result: return result["batch_processes"].get(worker, {}) return {} - def add_batch_process(self, process_id: str, process_uuid: str, worker: str): - self.auxiliary.find_one_and_update( + def add_batch_process( + self, process_id: str, process_uuid: str, worker: str + ) -> dict: + """ + Add a batch process to the list of running processes. + + Two IDs are defined, one to keep track of the actual process number and one + to be associated to the Jobs that are being executed. The need for two IDs + originates from the fact that the former may not be known at runtime. + + Parameters + ---------- + process_id + The ID of the processes obtained from the QueueManager. + process_uuid + A unique ID to identify the processes. + worker + The worker where the process is being executed. + Returns + ------- + dict + The updated document. + """ + return self.auxiliary.find_one_and_update( {"batch_processes": {"$exists": True}}, {"$push": {f"batch_processes.{worker}.{process_id}": process_uuid}}, upsert=True, ) - def remove_batch_process(self, process_id: str, worker: str): - self.auxiliary.find_one_and_update( + def remove_batch_process(self, process_id: str, worker: str) -> dict: + """ + Remove a process from the list of running batch processes. + + Parameters + ---------- + process_id + The ID of the processes obtained from the QueueManager. + worker + The worker where the process was being executed. + Returns + ------- + dict + The updated document. + """ + return self.auxiliary.find_one_and_update( {"batch_processes": {"$exists": True}}, {"$unset": {f"batch_processes.{worker}.{process_id}": ""}}, upsert=True, @@ -3041,6 +3315,18 @@ def remove_batch_process(self, process_id: str, worker: str): def get_flow_leafs(job_docs: list[dict]) -> list[dict]: + """ + Get the leaf jobs from a list of serilized representation of JobDoc. + + Parameters + ---------- + job_docs + The list of serialized JobDocs in the Flow + Returns + ------- + list + The list of serialized JobDocs that are leafs of the Flow. + """ # first sort the list, so that only the largest indexes are kept in the dictionary job_docs = sorted(job_docs, key=lambda j: j["index"]) d = {j["uuid"]: j for j in job_docs} diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index 9003f795..3fa9ff13 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -12,8 +12,8 @@ from collections import defaultdict from datetime import datetime from pathlib import Path +from typing import TYPE_CHECKING -from fireworks import FWorker from jobflow.utils import suuid from monty.os import makedirs_p from qtoolkit.core.data_objects import QState, SubmissionStatus @@ -42,6 +42,9 @@ from jobflow_remote.utils.log import initialize_runner_logger from jobflow_remote.utils.schedule import SafeScheduler +if TYPE_CHECKING: + from jobflow_remote.utils.db import MongoLock + logger = logging.getLogger(__name__) @@ -52,14 +55,18 @@ class Runner: Advances the status of the Jobs, handles the communication with the workers and updates the queue and output databases. - The main entry point is the `run` method. It is mainly supposed to be executed - if a daemon, but can also be run directly for testing purposes. + The main entry point is the `run` method. It is supposed to be executed + by a daemon, but can also be run directly for testing purposes. It allows to run all the steps required to advance the Job's states or even a subset of them, to parallelize the different tasks. The runner instantiates a pool of workers and hosts given in the project definition. A single connection will be opened if multiple workers share the same host. + + The Runner schedules the execution of the specific tasks at regular intervals + and relies on objects like QueueManager, BaseHost and JobController to + interact with workers and databases. """ def __init__( @@ -87,7 +94,6 @@ def __init__( self.project_name = project_name self.project: Project = self.config_manager.get_project(project_name) self.job_controller: JobController = JobController.from_project(self.project) - self.fworker: FWorker = FWorker() self.workers: dict[str, WorkerBase] = self.project.workers # Build the dictionary of hosts. The reference is the worker name. # If two hosts match, use the same instance @@ -198,9 +204,25 @@ def run( self, transfer: bool = True, complete: bool = True, - slurm: bool = True, + queue: bool = True, checkout: bool = True, ): + """ + Start the runner. + + Which actions are being performed can be tuned by the arguments. + + Parameters + ---------- + transfer + If True actions related to file transfer are performed by the runner. + complete + If True Job completion is performed by the runner. + queue + If True interactions with the queue manager are handled by the Runner. + checkout + If True the checkout of Jobs is performed by the Runner. + """ signal.signal(signal.SIGTERM, self.handle_signal) states = [] @@ -209,11 +231,11 @@ def run( states.append(JobState.TERMINATED.value) if complete: states.append(JobState.DOWNLOADED.value) - if slurm: + if queue: states.append(JobState.UPLOADED.value) logger.info( - f"Runner run options: transfer: {transfer} complete: {complete} slurm: {slurm} checkout: {checkout}" + f"Runner run options: transfer: {transfer} complete: {complete} queue: {queue} checkout: {checkout}" ) scheduler = SafeScheduler(seconds_after_failure=120) @@ -226,13 +248,13 @@ def run( self.checkout ) - if transfer or slurm or complete: + if transfer or queue or complete: self.advance_state(states) scheduler.every(self.runner_options.delay_advance_status).seconds.do( self.advance_state, states=states ) - if slurm: + if queue: self.check_run_status() scheduler.every(self.runner_options.delay_check_run_status).seconds.do( self.check_run_status @@ -272,6 +294,18 @@ def run( self.cleanup() def _get_limited_worker_query(self, states: list[str]) -> dict | None: + """ + Generate the query to be used for fetching Jobs for workers with limited + number of Jobs allowed. + + Parameters + ---------- + states + The states to be used in the query. + Returns + ------- + A dictionary with the query. + """ states = [s for s in states if s != JobState.UPLOADED.value] available_workers = [w for w in self.workers if w not in self.limited_workers] @@ -296,6 +330,14 @@ def _get_limited_worker_query(self, states: list[str]) -> dict | None: return None def advance_state(self, states: list[str]): + """ + Acquire the lock and advance the state of a single job. + + Parameters + ---------- + states + The state of the Jobs that can be queried. + """ states_methods = { JobState.CHECKED_OUT: self.upload, JobState.UPLOADED: self.submit, @@ -325,7 +367,16 @@ def advance_state(self, states: list[str]): states_methods[state](lock) - def upload(self, lock): + def upload(self, lock: MongoLock): + """ + Upload files for a locked Job in the CHECKED_OUT state. + If successful set the state to UPLOADED. + + Parameters + ---------- + lock + The MongoLock with the locked Job document. + """ doc = lock.locked_document db_id = doc["db_id"] logger.debug(f"upload db_id: {db_id}") @@ -375,7 +426,16 @@ def upload(self, lock): } lock.update_on_release = set_output - def submit(self, lock): + def submit(self, lock: MongoLock): + """ + Submit to the queue for a locked Job in the UPLOADED state. + If successful set the state to SUBMITTED. + + Parameters + ---------- + lock + The MongoLock with the locked Job document. + """ doc = lock.locked_document logger.debug(f"submit db_id: {doc['db_id']}") @@ -404,7 +464,7 @@ def submit(self, lock): exec_config = exec_config or ExecutionConfig() if job_doc.worker in self.batch_workers: - resources = {} + resources: dict = {} set_name_out( resources, job.name, out_fpath=qout_fpath, err_fpath=qerr_fpath ) @@ -473,6 +533,15 @@ def submit(self, lock): ) def download(self, lock): + """ + Download the final files for a locked Job in the TERMINATED state. + If successful set the state to DOWNLOADED. + + Parameters + ---------- + lock + The MongoLock with the locked Job document. + """ doc = lock.locked_document logger.debug(f"download db_id: {doc['db_id']}") @@ -513,6 +582,15 @@ def download(self, lock): lock.update_on_release = {"$set": {"state": JobState.DOWNLOADED.value}} def complete_job(self, lock): + """ + Complete a locked Job in the DOWNLOADED state. + If successful set the state to COMPLETED, otherwise to FAILED. + + Parameters + ---------- + lock + The MongoLock with the locked Job document. + """ doc = lock.locked_document logger.debug(f"complete job db_id: {doc['db_id']}") @@ -544,6 +622,13 @@ def complete_job(self, lock): raise RemoteError(err_msg, True) def check_run_status(self): + """ + Check the status of all the jobs submitted to a queue. + + If Jobs started update their state from SUBMITTED to RUNNING. + If Jobs terminated set their state to TERMINATED if running on a remote + host. If on a local host set them directly to DOWNLOADED. + """ logger.debug("check_run_status") # check for jobs that could have changed state workers_ids_docs = defaultdict(dict) @@ -652,6 +737,9 @@ def check_run_status(self): self.limited_workers[doc["worker"]]["current"] -= 1 def checkout(self): + """ + Checkout READY Jobs. + """ logger.debug("checkout jobs") n_checked_out = 0 while True: @@ -664,6 +752,10 @@ def checkout(self): logger.debug(f"checked out {n_checked_out} jobs") def refresh_num_current_jobs(self): + """ + Update the number of jobs currently running for worker with limited + number of Jobs. + """ for name, state in self.limited_workers.items(): query = { "state": {"$in": [JobState.SUBMITTED.value, JobState.RUNNING.value]}, @@ -672,6 +764,13 @@ def refresh_num_current_jobs(self): state["current"] = self.job_controller.count_jobs(query) def update_batch_jobs(self): + """ + Update the status of batch jobs. + + Includes submitting to the remote queue, checking the status of + running jobs in the queue and handle the files with the Jobs information + about their status. + """ logger.debug("update batch jobs") for worker_name, batch_manager in self.batch_workers.items(): worker = self.get_worker(worker_name) @@ -842,6 +941,9 @@ def update_batch_jobs(self): ) def cleanup(self): + """ + Close all the connections after stopping the Runner. + """ for worker_name, host in self.hosts.items(): try: host.close() diff --git a/src/jobflow_remote/jobs/state.py b/src/jobflow_remote/jobs/state.py index 63f8ff45..4d84188a 100644 --- a/src/jobflow_remote/jobs/state.py +++ b/src/jobflow_remote/jobs/state.py @@ -4,6 +4,10 @@ class JobState(Enum): + """ + States of a Job + """ + WAITING = "WAITING" READY = "READY" CHECKED_OUT = "CHECKED_OUT" # TODO should it be RESERVED? @@ -70,6 +74,10 @@ def short_value(self) -> str: class FlowState(Enum): + """ + States of a Flow. + """ + WAITING = "WAITING" READY = "READY" RUNNING = "RUNNING" @@ -83,6 +91,22 @@ class FlowState(Enum): def from_jobs_states( cls, jobs_states: list[JobState], leaf_states: list[JobState] ) -> FlowState: + """ + Generate the state of the Flow based on the states of the Jobs + composing it, and in particular the states of the leaf Jobs. + + Parameters + ---------- + jobs_states + List of JobStates of all the Jobs in the Flow. + leaf_states + List of JobStates of the leaf Jobs in the Flow. + + Returns + ------- + FlowState + The state of the Flow. + """ if all(js == JobState.WAITING for js in jobs_states): return cls.WAITING elif all(js in (JobState.WAITING, JobState.READY) for js in jobs_states): diff --git a/src/jobflow_remote/utils/db.py b/src/jobflow_remote/utils/db.py index 21e27bf7..f84504fb 100644 --- a/src/jobflow_remote/utils/db.py +++ b/src/jobflow_remote/utils/db.py @@ -6,32 +6,133 @@ import warnings from collections import defaultdict from datetime import datetime +from typing import TYPE_CHECKING, Any, Iterable, Mapping from jobflow.utils import suuid from pymongo import ReturnDocument from jobflow_remote.utils.data import deep_merge_dict +if TYPE_CHECKING: + from pymongo.collection import Collection + logger = logging.getLogger(__name__) class MongoLock: + """ + Context manager to lock a document in a MongoDB database. + + Main characteristics and functionalities: + * Lock is acquired by setting a lock_id and lock_time value in the + locked document. + * Filter the document to select based on a query and sorting. + It uses find_one_and_update, thus resulting in a single document locked. + * Can wait for a lock to be released. + * Can forcibly break an existing lock + * Can return the locked document even if the lock could not be acquired + (useful for determining if a document is locked or no document matches + the query) + * Accepts all the arguments that can be passed to find_one_and_update + * A custom update can be performed on the document when acquiring the lock. + * Allows to pass properties that will be set in the document at the moment + of releasing the lock. + * Lock id value can be customized. If not a randomly generated uuid is used. + + Examples + -------- + + Trying to acquire the lock on a document based on the state + >>> with MongoLock(collection, {"state": "READY"}) as lock: + ... print(lock.locked_document["state"]) + READY + + If lock cannot be acquired (no document matching filter or that + document is locked) the `lock.locked_document` is None. + + >>> with MongoLock(collection, {"state": "READY"}) as lock: + ... print(lock.locked_document) + None + + Wait for 60 seconds in case the required document is already locked. + Check the status every 10 seconds. If lock cannot be acquired + locked_document is None + + >>> with MongoLock( + collection, + {"uuid": "5b84228b-d019-47fe-b0a0-564b36aa85ed"}, + sleep=10, + max_wait=60, + ) as lock: + ... print(lock.locked_document) + None + + In case lock cannot be acquired expose the locked document + + >>> with MongoLock( + collection, + {"uuid": "5b84228b-d019-47fe-b0a0-564b36aa85ed"}, + get_locked_doc=True, + ) as lock: + ... print(lock.locked_document) + None + ... print(lock.unavailable_document['lock_id']) + 8d68404f-c77a-461b-859c-40bb0af1979f + + Set values in the document upon lock release + + >>> with MongoLock(collection, {"state": "READY"}) as lock: + ... if lock.locked_document: + ... # Perform some operations based on the job... + ... lock.update_on_release = {"$set": {"state": "CHECKED_OUT"}} + + """ + LOCK_KEY = "lock_id" LOCK_TIME_KEY = "lock_time" def __init__( self, - collection, - filter, - update=None, - break_lock=False, - lock_id=None, - sleep=None, - max_wait=600, - projection=None, - get_locked_doc=False, + collection: Collection, + filter: Mapping[str, Any], + update: Mapping[str, Any] | None = None, + break_lock: bool = False, + lock_id: str | None = None, + sleep: int | None = None, + max_wait: int = 600, + projection: Mapping[str, Any] | Iterable[str] | None = None, + get_locked_doc: bool = False, **kwargs, ): + """ + Parameters + ---------- + collection + The MongoDB collection containing the document to lock. + filter + A MongoDB query to select the document. + update + A dictionary that will be passed to find_one_and_update to update the + locked document at the moment of acquiring the lock. + break_lock + True if the context manager is allowed to forcibly break a lock. + lock_id + The is used for the lock in the document. If None a randomly generated + uuid will be used. + sleep + The amount of second to sleep between consecutive checks while waiting + for a lock to be released. + max_wait + The amount of seconds to wait for a lock to be released. + projection + The projection passed to the find_one_and_update that locks the document. + get_locked_doc + If True, if the lock cannot be acquired because the document matching + the filter is already locked, the locked document will be fetched and + set in the unavailable_document attribute. + kwargs + All the other args are passed to find_one_and_update. + """ self.collection = collection self.filter = filter or {} self.update = update @@ -46,13 +147,24 @@ def __init__( self.projection = projection self.get_locked_doc = get_locked_doc - def get_lock_time(self, d: dict): - return d.get(self.LOCK_TIME_KEY) + @classmethod + def get_lock_time(cls, d: dict): + """ + Get the time the document was locked on a dictionary. + """ + return d.get(cls.LOCK_TIME_KEY) - def get_lock_id(self, d: dict): - return d.get(self.LOCK_KEY) + @classmethod + def get_lock_id(cls, d: dict): + """ + Get the lock id on a dictionary. + """ + return d.get(cls.LOCK_KEY) def acquire(self): + """ + Acquire the lock + """ # Set the lock expiration time now = datetime.utcnow() db_filter = copy.deepcopy(self.filter) @@ -129,6 +241,9 @@ def acquire(self): break def release(self, exc_type, exc_val, exc_tb): + """ + Release the lock. + """ # Release the lock by removing the unique identifier and lock expiration time update = {"$set": {self.LOCK_KEY: None, self.LOCK_TIME_KEY: None}} # TODO maybe set on release only if no exception was raised? @@ -160,11 +275,15 @@ def __exit__(self, exc_type, exc_val, exc_tb): class LockedDocumentError(Exception): """ - Exception to signal a problem when locking the document + Exception to signal a problem when locking the document. """ class JobLockedError(LockedDocumentError): + """ + Exception to signal a problem when locking a Job document. + """ + @classmethod def from_job_doc(cls, doc: dict, additional_msg: str | None = None): lock_id = doc[MongoLock.LOCK_KEY] @@ -177,6 +296,10 @@ def from_job_doc(cls, doc: dict, additional_msg: str | None = None): class FlowLockedError(LockedDocumentError): + """ + Exception to signal a problem when locking a Flow document. + """ + @classmethod def from_flow_doc(cls, doc: dict, additional_msg: str | None = None): lock_id = doc[MongoLock.LOCK_KEY] From 478ada9bfcbc8dc7b651c266420d7c08e99a828a Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Wed, 20 Dec 2023 14:37:33 +0100 Subject: [PATCH 18/18] Documentation initial version --- doc/source/_static/code/project_simple.yaml | 36 +++ doc/source/_static/img/configs_1split.png | Bin 0 -> 120472 bytes doc/source/_static/img/configs_allinone.png | Bin 0 -> 101739 bytes doc/source/_static/img/configs_fullsplit.png | Bin 0 -> 143959 bytes doc/source/_static/img/project_erdantic.png | Bin 0 -> 266090 bytes doc/source/_static/project_schema.html | 1 + doc/source/conf.py | 5 + doc/source/user/index.rst | 3 +- doc/source/user/install.rst | 230 ++++++++++++++++-- doc/source/user/introduction.rst | 35 +++ doc/source/user/projectconf.rst | 94 ++++++++ doc/source/user/quickstart.rst | 238 ++++++++++++++++++- doc/source/user/troubleshooting.rst | 5 + doc/source/user/tuning.rst | 5 + pyproject.toml | 2 + src/jobflow_remote/utils/examples.py | 84 +++++++ 16 files changed, 713 insertions(+), 25 deletions(-) create mode 100644 doc/source/_static/code/project_simple.yaml create mode 100644 doc/source/_static/img/configs_1split.png create mode 100644 doc/source/_static/img/configs_allinone.png create mode 100644 doc/source/_static/img/configs_fullsplit.png create mode 100644 doc/source/_static/img/project_erdantic.png create mode 100644 doc/source/_static/project_schema.html create mode 100644 doc/source/user/introduction.rst create mode 100644 doc/source/user/projectconf.rst create mode 100644 doc/source/user/troubleshooting.rst create mode 100644 doc/source/user/tuning.rst create mode 100644 src/jobflow_remote/utils/examples.py diff --git a/doc/source/_static/code/project_simple.yaml b/doc/source/_static/code/project_simple.yaml new file mode 100644 index 00000000..d73335ab --- /dev/null +++ b/doc/source/_static/code/project_simple.yaml @@ -0,0 +1,36 @@ +name: std +workers: + example_worker: + type: remote + scheduler_type: slurm + work_dir: /path/to/run/folder + pre_run: source /path/to/python/environment/activate + timeout_execute: 60 + host: remote.host.net + user: bob +queue: + type: MongoStore + host: localhost + database: db_name + username: bob + password: secret_password + collection_name: jobs +exec_config: {} +jobstore: + docs_store: + type: MongoStore + database: db_name + host: host.mongodb.com + port: 27017 + username: bob + password: secret_password + collection_name: outputs + additional_stores: + data: + type: GridFSStore + database: db_name + host: host.mongodb.com + port: 27017 + username: bob + password: secret_password + collection_name: outputs_blobs diff --git a/doc/source/_static/img/configs_1split.png b/doc/source/_static/img/configs_1split.png new file mode 100644 index 0000000000000000000000000000000000000000..2d7b6860d99bbf026dd254d99e11d13b8c8483e5 GIT binary patch literal 120472 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 o#2@( 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 literal 0 HcmV?d00001 diff --git a/doc/source/_static/img/configs_allinone.png b/doc/source/_static/img/configs_allinone.png new file mode 100644 index 0000000000000000000000000000000000000000..26b4f8bc3678bf49370490d5a6ac365382ec0b35 GIT binary patch literal 101739 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

BsZH)l;kCS#6*^D^9jB(3eRoGOM*MAX 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 z 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�J|!}>KS3n2Ir<2TIzJp9K+mS*Gc)kZMEgcz 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 literal 0 HcmV?d00001 diff --git a/doc/source/_static/img/configs_fullsplit.png b/doc/source/_static/img/configs_fullsplit.png new file mode 100644 index 0000000000000000000000000000000000000000..e68c4defd5cf197d239479413bd26c3e33598539 GIT binary patch literal 143959 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

86_aKIolqs9!O9d;~Gx7RZcmr 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 literal 0 HcmV?d00001 diff --git a/doc/source/_static/img/project_erdantic.png b/doc/source/_static/img/project_erdantic.png new file mode 100644 index 0000000000000000000000000000000000000000..b8fc3fbe2856dfccfdda615895c7d6ce8de4c752 GIT binary patch literal 266090 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

v*t$|+f`r)U&!#k@b%4pQjZ4JT4(_TU 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 literal 0 HcmV?d00001 diff --git a/doc/source/_static/project_schema.html b/doc/source/_static/project_schema.html new file mode 100644 index 00000000..e1bfd259 --- /dev/null +++ b/doc/source/_static/project_schema.html @@ -0,0 +1 @@ + Project

Project

Type: object

The configurations of a Project.

No Additional Properties

Default: null

The base directory containing the project related files. Default is a folder with the project name inside the projects folder

Type: string
Type: null

Default: null

Folder where remote files are copied. Default a 'tmp' folder in base_dir

Type: string
Type: null

Default: null

Folder containing all the logs. Default a 'log' folder in base_dir

Type: string
Type: null

Default: null

Folder containing daemon related files. Default to a 'daemon' folder in base_dir

Type: string
Type: null

Type: enum (of string) Default: "info"

The level set for logging

Must be one of:

  • "error"
  • "warn"
  • "info"
  • "debug"

Type: object

The options for the Runner

No Additional Properties

Type: integer Default: 30

Delay between subsequent execution of the checkout from database (seconds)

Type: integer Default: 30

Delay between subsequent execution of the checking the status of jobs that are submitted to the scheduler (seconds)

Type: integer Default: 30

Delay between subsequent advancement of the job's remote state (seconds)

Type: integer Default: 600

Delay between subsequent refresh from the DB of the number of submitted and running jobs (seconds). Only use if a worker with max_jobs is present

Type: integer Default: 60

Delay between subsequent refresh from the DB of the number of submitted and running jobs (seconds). Only use if a worker with max_jobs is present

Default: 86400

Time to consider the lock on a document expired and can be overridden (seconds)

Type: integer
Type: null

Type: boolean Default: true

Whether to delete the local temporary folder after a job has completed

Type: integer Default: 3

Maximum number of attempt performed before failing an advancement of a remote state

Type: array of integer Default: [30, 300, 1200]

List of increasing delay between subsequent attempts when the advancement of a remote step fails

No Additional Items

Each item of this array must be:

Type: object

A dictionary with the worker name as keys and the worker configuration as values

Each additional property must conform to the following schema


Type: object

Worker representing the local host.

Executes command directly.

No Additional Properties

Type: const Default: "local"

The discriminator field to determine the worker type

Specific value: "local"

Type: string

Type of the scheduler. Depending on the values supported by QToolKit

Type: stringFormat: path

Absolute path of the directory of the worker where subfolders for executing the calculation will be created

Default: null

A dictionary defining the default resources requested to the scheduler. Used to fill in the QToolKit template

Default: null

String with commands that will be executed before the execution of the Job

Default: null

String with commands that will be executed after the execution of the Job

Type: integer Default: 60

Timeout for the execution of the commands in the worker (e.g. submitting a job)

Default: null

The maximum number of jobs that can be submitted to the queue.

Default: null

Options for batch execution. If define the worker will be considered a batch worker

Type: object

Worker representing a remote host reached through an SSH connection.

Uses a Fabric Connection. Check Fabric documentation for more datails on the
options defininf a Connection.

No Additional Properties

Type: const Default: "remote"

The discriminator field to determine the worker type

Specific value: "remote"

Type: string

Type of the scheduler. Depending on the values supported by QToolKit

Type: stringFormat: path

Absolute path of the directory of the worker where subfolders for executing the calculation will be created

Default: null

A dictionary defining the default resources requested to the scheduler. Used to fill in the QToolKit template

Default: null

String with commands that will be executed before the execution of the Job

Default: null

String with commands that will be executed after the execution of the Job

Type: integer Default: 60

Timeout for the execution of the commands in the worker (e.g. submitting a job)

Default: null

The maximum number of jobs that can be submitted to the queue.

Default: null

Options for batch execution. If define the worker will be considered a batch worker

Type: string

The host to which to connect

Default: null

The filename, or list of filenames, of optional private key(s) and/or certs to try for authentication

Default: null

A shell command string to use as a proxy or gateway

Default: null

Other keyword arguments passed to paramiko.client.SSHClient.connect

Default: null

Whether to send environment variables 'inline' as prefixes in front of command strings

Default: 60

Keepalive value in seconds passed to paramiko's transport

Default: "bash"

The shell command used to execute the command remotely. If None the command is executed directly

Type: boolean Default: true

Whether to use a login shell when executing the command

Type: object

Dictionary describing a maggma Store used for the queue data. Can contain the monty serialized dictionary or a dictionary with a 'type' specifying the Store subclass. Should be subclass of a MongoStore, as it requires to perform MongoDB actions.

Type: object

A dictionary with the ExecutionConfig name as keys and the ExecutionConfig configuration as values

Each additional property must conform to the following schema

Type: object

Configuration to be set before and after the execution of a Job.

No Additional Properties

Default: null

list of modules to be loaded

Type: array of string
No Additional Items

Each item of this array must be:

Default: null

dictionary with variable to be exported

Default: null

Other commands to be executed before the execution of a job

Default: null

Commands to be executed after the execution of a job

Type: object

The JobStore used for the input. Can contain the monty serialized dictionary or the Store int the Jobflow format

Default: null

A dictionary with metadata associated to the project

Type: object
Type: null
diff --git a/doc/source/conf.py b/doc/source/conf.py index 9b9fa745..3f9e254c 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -59,6 +59,8 @@ "IPython.sphinxext.ipython_directive", "sphinx.ext.mathjax", "sphinx_design", + "sphinx_copybutton", + "sphinxcontrib.autodoc_pydantic", ] # Add any paths that contain templates here, relative to this directory. @@ -214,3 +216,6 @@ # To print the content of the docstring of the __init__ method as well. autoclass_content = "both" + +autodoc_pydantic_model_show_json = True +# autodoc_pydantic_model_erdantic_figure = True diff --git a/doc/source/user/index.rst b/doc/source/user/index.rst index 84f12f1f..baddba9b 100644 --- a/doc/source/user/index.rst +++ b/doc/source/user/index.rst @@ -11,8 +11,9 @@ details are found in :ref:`reference`. :caption: Getting started :maxdepth: 1 - whatisjobflowremote + introduction install + projectconf quickstart .. toctree:: diff --git a/doc/source/user/install.rst b/doc/source/user/install.rst index c71429e1..cfbc7bf2 100644 --- a/doc/source/user/install.rst +++ b/doc/source/user/install.rst @@ -1,21 +1,213 @@ .. _install: -************************* -Installing Jobflow-Remote -************************* - -Jobflow-Remote depends on the following prerequisite packages: - -- jobflow -- fireworks -- fabric -- tomlkit -- qtoolkit -- typer -- rich -- psutil -- supervisor -- ruamel.yaml - -All these package are automatically installed by 'conda' or 'pip' or while -installing from source. +********************** +Setup and installation +********************** + +Introduction +============ + +In order to properly set up ``jobflow-remote`` it is important to understand +the elements composing its structure. + +There is a `MongoDB `_ database that +is used to store the state of the Jobs and their outputs. + +We can then consider three environments involved in the Flows execution + +* **USER**: The machine where the user creates new Flows and adds them to the DB. + Also allows to check the state of the Jobs and analyse/fix failed ones. +* **RUNNER**: The machine where runs the ``runner`` daemon, taking care of advancing the state + of the Jobs by copying files, submitting Jobs to workers and retrieving outputs. +* **WORKER**: The computing center, where the Jobs are actually executed. + +All of these should have a python environment with at least jobflow-remote installed. +However, only **USER** and **RUNNER** need to have access to the database. If not overlapping +with the other **RUNNER** only needs ``jobflow-remote`` and its dependencies to be installed. + +Setup options +============= + +Depending on your resources and limitations imposed by computing centers you can +consider choosing among these three configurations: + +.. _allinone config: + +All-in-one +---------- + +**USER**, **RUNNER** and **WORKER** are the same machine. + +If your database can be reached from the computing center and the daemon can +be executed on one of the front-end nodes, this is the simplest option. + +.. image:: ../_static/img/configs_allinone.png + :width: 450 + :alt: All-in-one configuration + :align: center + +.. _userworkstation config: + +User-Workstation +---------------- + +**USER** and **RUNNER** are on a workstation external to the computing center with access +to the database, **WORKER** should be reachable with a passwordless connection from the workstation. + +This is the most convenient option if the computing center does not have access to +the database. + +.. image:: ../_static/img/configs_1split.png + :width: 450 + :alt: All-in-one configuration + :align: center + + +.. _fullsplit config: + +Full-split +---------- + +**USER** can be the user's laptop/workstation. The **RUNNER** runs on a server that can keep +running and has access to the computing center (**WORKER**). + +If preferring to work on a local laptop to generate new Flows and analyze outputs, but +couldn't let the daemon running on the same machine this could be a convenient solution. + +.. image:: ../_static/img/configs_fullsplit.png + :width: 450 + :alt: All-in-one configuration + :align: center + + +Install +======= + +``jobflow-remote`` is a Python 3.9+ library and can be installed using pip:: + + pip install jobflow-remote + +or, for the development version:: + + pip install git+https://github.com/Matgenix/jobflow-remote.git + +Environments +============ + +If the chosen configuration corresponds to :ref:`allinone config` a single python +environment can be created. A common way of doing so it to use an environment manager like `conda `_ +or `miniconda `_, running:: + + conda create -n jobflow python=3.10 + +and installing ``jobflow-remote`` and all the other packages containing the Flows to execute + +For the :ref:`userworkstation config` and :ref:`fullsplit config` configurations the +environments need to be created on multiple machines. A convenient option consists in creating a conda +environment on one of the machines, like above. Then extracting all the installed +packages by running:: + + conda env export > jobflow_env.yaml + +And then use this list to generate equivalent environment(s) on the other machine(s):: + + conda env create -n env_name --file jobflow_env.yaml + +.. warning:: + It is important that the packages version match between the different machines, + especially for the packages containing the implemented Flows and Makers. + + +.. _minimal project config: + +Configuration +============= + +Jobflow-remote offers many configuration options, to customize both the daemon and the Job +execution. A full description of all the options can be found in the :ref:`projectconf` section. +Here we provide a minimal working example configuration to get started. + +.. warning:: + Standard jobflow execution requires to define the out ``JobStore`` in the ``JOBFLOW_CONFIG_FILE``. + Here, all the jobflow related configuration are given in the ``jobflow-remote`` configuration + file and the content of the ``JOBFLOW_CONFIG_FILE`` will be **ignored**. + +By default, jobflow-remote will search the projects configuration files in the ``~/.jfremote``. +In many cases a single project and thus configuration file would be enough, so +here we will not enter into the details of how to deal with multiple projects +configuration and other advanced settings. + +You can get an initial setup configuration by running:: + + jf project generate YOUR_PROJECT_NAME + +For the sake of simplicity in the following the project name will be ``std``, +but there are no limitations on the name. This will create a file ``std.yaml`` in +your ``~/.jfremote`` folder with the following content: + +.. literalinclude:: ../_static/code/project_simple.yaml + :language: yaml + +You can now edit the yaml file to reflect you actual configuration. + +.. note:: + + Consider that the configuration file should be accessible by the **USER** and the **RUNNER** + defined above. If these are in two different machines be sure to also share the configuration + file on both of them. + +Workers +------- + +Workers are the computational units that will actually execute the jobflow Jobs. If you are +in an :ref:`allinone config` configuration the worker ``type`` can be ``local`` and you do +not need to provide a host. Otherwise, all the information for an SSH connection should be +provided. In the example it is assumed that a passwordless connection can be established +based on the content of the ``~/.ssh/config`` file. The remote connection is based on +`Fabric `_, so all of its functionalities can be used. + +It is also important to specify a ``work_dir``, where all the folders for the Jobs execution +will be created. + +.. _queue simple config: + +Queue +----- + +The connection details for the database that will contain all the information about the +state of Jobs and Flows. It can be defined in a way similar to the one used in ``jobflow``'s +configuration file. Three collections will be used for this purpose. + +Jobstore +-------- + +The ``jobstore`` used for ``jobflow``. Its definition is equivalent to the one used in +``jobflow``'s configuration file. See `Jobflows documentation `_ +for more details. It can be the same as in the :ref:`queue simple config` or a different one. + +Check +----- + +After all the configuration have been set, you can verify that all the connections +can be established by running:: + + jf project check --errors + +If everything if fine you should see something like:: + + ✓ Worker example_worker + ✓ Jobstore + ✓ Queue store + +Otherwise the python errors should also show up for the connections that failed. + +As a last step you should reset the database with the command:: + + jf admin reset + +.. warning:: + + This will also delete the content of the database. If are reusing an existing database + and do not want to erase your data skip this step. + +You are now ready to start running workflows with jobflow-remote! diff --git a/doc/source/user/introduction.rst b/doc/source/user/introduction.rst new file mode 100644 index 00000000..bc7ca356 --- /dev/null +++ b/doc/source/user/introduction.rst @@ -0,0 +1,35 @@ +.. _introduction: + +************ +Introduction +************ + +Jobflow-remote is a free, open-source library serving as a manager for the execution +of `jobflow `_ workflows. While jobflow is +not bound to be executed with a specific manager and some adapter has already been +developed (*e.g.* `Fireworks `_), +jobflow-remote has been designed to take full advantage of and adapt to jobflow's +functionalities and interact with the typical high performance computing center +accessible by researchers. + +Jobflow's Jobs functions are executed directly on the computing resources, however, +differently from `Fireworks `_, all the +interactions with the output Stores are handled by a daemon process, called ``runner``. +This allows to bypass the problem of computing center not having direct access to the +user's database. +Given the relatively small requirements, this gives the freedom to run jobflow-remote's +daemon + +* on a workstation that has access to the computing resource +* or directly on the front-end of the cluster + +Following a short list of basic features + +* Fully compatible with `jobflow `_ +* Data storage based on mongo-like `maggma `_ Stores. +* Simple single file configuration as a starting point. Can scale to handle different projects with different configurations +* Fully configurable submission options +* Management through python API and command line interface +* Parallelized daemon execution +* Limit number of jobs submitted per worker +* Batch submission (experimental) diff --git a/doc/source/user/projectconf.rst b/doc/source/user/projectconf.rst new file mode 100644 index 00000000..46a24d26 --- /dev/null +++ b/doc/source/user/projectconf.rst @@ -0,0 +1,94 @@ +.. _projectconf: + +********************** +Projects configuration +********************** + +Jobflow-remote allows to handle multiple configurations, defined projects. Since +for most of the users a single project is enough let us first consider the configuration +of a single project. The handling of multiple projects will be described below. + +The configurations allow to control the behaviour of the Job execution, as well as +the other objects in jobflow-remote. Here a full description of the project's +configuration file will be given. If you are looking for a minimal example with its +description you can find it in the :ref:`minimal project config` section. + +The specifications of the project's attributes are given by the ``Project`` pydantic +model, that serves the purpose of parsing and validating the configuration files, as +well as giving access to the associated objects (e.g. the ``JobStore``). +A graphical representation of the ``Project`` model and thus of the options available +in the configuration file is given below (generated with `erdantic `_) + +.. image:: ../_static/img/project_erdantic.png + :width: 100% + :alt: All-in-one configuration + :align: center + +A description for all the types and keys of the project file is given in the :ref:`project detailed specs` +section below, while an example for a full configuration file can be generated running:: + + jf project generate --full YOUR_PROJECT_NAME + +Note that, while the default file format is YAML, JSON and TOML are also acceptable format. +You can generate the example in the other formats using the ``--format`` option. + +Project options +=============== + +Name and folders +---------------- + +The project name is given by the ``name`` attribute. The name will be used to create +a subfolder containing + +* files with the parsed outputs copied from the remote workers +* logs +* files used by the daemon + +For all these folders the paths are set with defaults, but can be customised setting + +``tmp_dir``, ``log_dir`` and ``daemon_dir``. + +.. warning:: + The project name does not take into consideration the configuration file name. + For coherence it would be better to give use the project name as file name. + +Workers +------- + +Multiple workers can be defined in a project. In the configuration file they are given +with their name as keyword, and their properties in the contained dictionary. + +Several defining properties should be set in the configuration of each workers. +First it should be specified the ``type``. At the moment the possible worker types are + +* ``local``: a worker running on the same system as the ``Runner``. No connection is + needed for the ``Runner`` to reach the queueing system. +* ``remote``: a worker on a different machine than the ``Runner``, requiring an SSH + connection to reach it. + +Since the ``Runner`` needs to constantly interact with the workers, for the latter +type all the credentials to connect automatically should be provided. The best option +would be to set up a passwordless connection and define it in the ``~/.ssh/config`` +file. + +The other key property of the workers is the ``scheduler_type``. + +.. note:: + + If a single worker is defined it will be used as default in the submission + of new Flows. + + +Multiple Projects +================= + +asdsd + +.. _project detailed specs: + +Project specs +============= + +.. raw:: html + :file: ../_static/project_schema.html diff --git a/doc/source/user/quickstart.rst b/doc/source/user/quickstart.rst index cb5fc0e1..4151633d 100644 --- a/doc/source/user/quickstart.rst +++ b/doc/source/user/quickstart.rst @@ -4,11 +4,239 @@ Jobflow-Remote quickstart ========================= -Prerequisites -============= -You need python +After completing the :ref:`install`, it is possible to start submitting +``Flow`` for execution. If you are not familiar with the concept of ``Job`` +and ``Flow`` in jobflow you can start checking its +`tutorials `_. -The Basics +Any jobflow's ``Flow`` can be executed with jobflow-remote, +but, at variance with jobflow simple examples, the Job functions should +be serializable and accessible by the runner. Simple custom examples based +on functions defined on the fly cannot thus be used. +For this reason a few simple ``Job`` s have been prepared for +test purposes in the ``jobflow_remote.utils.examples`` module. + +For the execution of the following tutorial it may be convenient to define +a simple worker with ``type: local`` and ``scheduler_type: shell`` to speed up +the execution, but any worker is acceptable. + +Submit a ``Flow`` +================= + +To run a workflow with jobflow-remote the first step is to insert it into the +database. A ``Flow`` can be created following the standard jobflow procedure. +Then it should be passed to the ``submit_flow`` function: + +.. code-block:: python + + from jobflow_remote.utils.examples import add + from jobflow_remote import submit_flow + from jobflow import Flow + + job1 = add(1, 2) + job2 = add(job1.output, 2) + + flow = Flow([job1, job2]) + + print(submit_flow(flow, worker="local_shell")) + + +This code will print an integer unique id associated to the submitted ``Job`` s. + +.. note:: + + In addition to the uuid, the standard jobflow's identifier for Jobs, + jobflow-remote also defines an incremental ``db_id``, to help quickly + identify different Jobs. A ``db_id`` uniquely identifies each Job entry + in jobflow-remote's queue database. The same entry is also uniquely + identified by the ``uuid``, ``index`` pair. + +.. note:: + + On the worker selection: + * The worker should match the name of one of the workers defined in the project. + * In this way all the ``Job`` s will be assigned to the same worker. + * If only one worker is defined, the argument can be omitted. + * In any case the worker is determined when the ``Job`` is inserted in the database. + +It is now possible to use the ``jf`` command line interface (CLI):: + + jf job list + +to display the list of ``Job`` s in the database:: + + Jobs info + ┏━━━━━━━┳━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┓ + ┃ DB id ┃ Name ┃ State ┃ Job id (Index) ┃ Worker ┃ Last updated [CET] ┃ + ┡━━━━━━━╇━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━┩ + │ 2 │ add │ WAITING │ 8b7a7841-37c7-4446-853b-ad3c00eb5227 (1) │ local_shell │ 2023-12-19 16:33 │ + │ 1 │ add │ READY │ ae020c67-72f0-4805-858e-fe48644e4bb0 (1) │ local_shell │ 2023-12-19 16:33 │ + └───────┴──────┴─────────┴───────────────────────────────────────────┴─────────────┴────────────────────┘ + +.. note:: + + It is possible to use the ``-v`` flag to increase the **verbosity** of the output. + Use ``-vv`` or ``-vvv`` to further increase the the verbosity. + + It is also possible to **filter** and *sort** the results. run ``jf job list -h`` + to see the available options. + +One of the Jobs is in the ``READY`` state, signaling that it is ready to be executed. +The second Job is instead in the ``WAITING`` state since it will not start until the +first reaches the ``COMPLETED`` state. At this point nothing will happen, since the +process to handle the Jobs has not been started yet. + +The Runner ========== -Create an easy script +Jobflow-remote's ``Runner`` is an object that takes care of handling the +submitted ``Job`` s. It performs several actions to advance the state of the +workflows. For each ``Job`` it: + +* Copies files to and from the **WORKER** (i.e. ``Job`` 's inputs and outputs) +* Interacts with the **WORKER**'s queue manager (e.g. SLURM, PBS, ...), + submitting jobs and checking their state +* Updates the content of the database + +Only the actual execution of the Jobs in the Workers are disconnected +from the ``Runner``. In all the other cases, the state of the ``Job`` s +can change only if the ``Runner`` is running. + +The standard way to execute the ``Runner`` is through a daemon process +that can be started with the ``jf`` CLI:: + + jf runner start + +Since the process starts in the background, you can check that it properly +started with the command:: + + jf runner status + +If the ``Runner`` started correctly you should get:: + + Daemon status: running + +During the execution of the Job it is possible to check their status as +done before:: + + Jobs info + ┏━━━━━━━┳━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┓ + ┃ DB id ┃ Name ┃ State ┃ Job id (Index) ┃ Worker ┃ Last updated [CET] ┃ + ┡━━━━━━━╇━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━┩ + │ 2 │ add │ RUNNING │ 8b7a7841-37c7-4446-853b-ad3c00eb5227 (1) │ local_shell │ 2023-12-19 16:44 │ + │ 1 │ add │ COMPLETED │ ae020c67-72f0-4805-858e-fe48644e4bb0 (1) │ local_shell │ 2023-12-19 16:44 │ + └───────┴──────┴───────────┴───────────────────────────────────────────┴─────────────┴────────────────────┘ + +The ``Runner`` will keep checking the database for the submission of new Jobs +and will update the state of each Job as soon as the previous action is completed. +If you plan to keep submitting workflows you can keep the daemon running, otherwise +you can stop the process with:: + + jf runner stop + +.. note:: + + By default the daemon will spawn several processes, each taking care of some + of the actions listed above. + +Results +======= + +As in standard jobflow execution, when a ``Job`` is ``COMPLETED`` its output is +stored in the defined ``JobStore``. For simple cases like the one used in this +example the outputs can be fetched directly using the CLI:: + + jf job output 2 + +That should print the expected result:: + + 5 + +.. note:: + + The CLI commands that accept a single Job id, both the ``uuid`` or the ``db_id`` + can be passed. The code will automatically determine the + +For more advanced workflows, the best way to obtain the results is using the +``JobStore``, as done with `usual jobflow's outputs `_. +For jobflow-remote, a convenient way to access the ``JobStore`` in python is +to use the ``get_jobstore`` helper function. + +.. code-block:: python + + from jobflow_remote import get_jobstore + + js = get_jobstore() + js.connect() + + print(js.get_output("8b7a7841-37c7-4446-853b-ad3c00eb5227")) + +CLI +=== + +On top of the CLI commands shown above a full list of the commands, sub-commands options +available is accessible through the ``-h`` flag. Here we present a few more of them +that can be useful to get started. + +Job info +-------- + +Detailed information from a Job can be obtained running the command:: + + jf job info 2 + +that prints a summary of the content of the Job document in the DB:: + + ╭─────────────────────────────────────────────────────────────────────────────────────────────╮ + │ created_on = '2023-12-19 16:33' │ + │ db_id = 2 │ + │ end_time = '2023-12-19 16:44' │ + │ index = 1 │ + │ metadata = {} │ + │ name = 'add' │ + │ parents = ['ae020c67-72f0-4805-858e-fe48644e4bb0'] │ + │ priority = 0 │ + │ remote = {'step_attempts': 0, 'process_id': '89838'} │ + │ run_dir = '/path/to/run/folder/8b/7a/78/8b7a7841-37c7-4446-853b-ad3c00eb5227_1' │ + │ start_time = '2023-12-19 16:44' │ + │ state = 'COMPLETED' │ + │ updated_on = '2023-12-19 16:44' │ + │ uuid = '8b7a7841-37c7-4446-853b-ad3c00eb5227' │ + │ worker = 'local_shell' │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────╯ + +.. note:: + + This will also contain the tracked error in case of failure of the Job. + Dealing with failed Jobs will be dealt with in the troubleshooting section. + +Flow list +--------- + +Similarly to the list of Jobs a list of Flows and their states can be obtained with:: + + jf flow list + +that returns:: + + Flows info + ┏━━━━━━━┳━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┓ + ┃ DB id ┃ Name ┃ State ┃ Flow id ┃ Num Jobs ┃ Last updated [CET] ┃ + ┡━━━━━━━╇━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━┩ + │ 1 │ Flow │ COMPLETED │ 959ffe14-7061-4b74-a3ad-10c3c12715ad │ 2 │ 2023-12-19 16:43 │ + └───────┴──────┴───────────┴──────────────────────────────────────┴──────────┴────────────────────┘ + +.. note:: + + A Flow has its own uuid, while the DB id corresponds to the lowest DB id among the + Jobs belonging to the Flow + +Delete Flows +------------ + +In case you need to delete some Flows, without resetting the whole database, +you can use the command:: + + jf flow delete -did 1 + +where filters similar to the ones of the ``list`` command can be used. diff --git a/doc/source/user/troubleshooting.rst b/doc/source/user/troubleshooting.rst new file mode 100644 index 00000000..396ded4e --- /dev/null +++ b/doc/source/user/troubleshooting.rst @@ -0,0 +1,5 @@ +.. _troubleshooting: + +*************** +Troubleshooting +*************** diff --git a/doc/source/user/tuning.rst b/doc/source/user/tuning.rst new file mode 100644 index 00000000..282de0aa --- /dev/null +++ b/doc/source/user/tuning.rst @@ -0,0 +1,5 @@ +.. _tuning: + +******************** +Tuning Job execution +******************** diff --git a/pyproject.toml b/pyproject.toml index baf3abd1..0dd980b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,8 @@ docs = [ "sphinx", "sphinx_design", "pydata-sphinx-theme", + "sphinx-copybutton", + "autodoc_pydantic>=2.0.0", ] strict = [] diff --git a/src/jobflow_remote/utils/examples.py b/src/jobflow_remote/utils/examples.py new file mode 100644 index 00000000..1dad8e5d --- /dev/null +++ b/src/jobflow_remote/utils/examples.py @@ -0,0 +1,84 @@ +from random import randint + +from jobflow import Flow, Response, job + + +@job +def add(a, b): + """ + A Job adding to numbers + """ + return a + b + + +@job +def add_many(*to_sum): + """ + A job adding numbers + """ + return sum(to_sum) + + +@job +def sleep(s): + """ + A Job sleeping. + """ + import time + + time.sleep(s) + return s + + +@job +def add_raise(a, b): + """ + A Job raising a RuntimeError + """ + raise RuntimeError("An error for a and b") + + +@job +def make_list(a, length=None): + """ + A Job generating a list of numbers + """ + if not length: + length = randint(2, 5) + return [a] * length + + +@job +def add_distributed(list_a): + """ + A Job generating a new Flow to add a list of numbers + """ + jobs = [] + for val in list_a: + jobs.append(add(val, 1)) + + flow = Flow(jobs) + return Response(replace=flow) + + +@job +def value(n): + """ + A Job returning the input value + """ + return n + + +@job +def conditional_sum_replace(numbers, min_n=10): + """ + A Job creating a replace and adding number until a value is reached. + """ + s = sum(numbers) + if s < min_n: + j1 = value(s) + j2 = value(5) + c = conditional_sum_replace([j1.output, j2.output], min_n=min_n) + return Response(replace=Flow([j1, j2, c], output=c.output)) + else: + return s

Type: string

The name of the project