From 2aa2bbbcc31e01bff1e8536ce412e2adc397b01a Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Wed, 17 May 2023 15:13:34 +0200 Subject: [PATCH 01/89] First minimal working implementation --- README.md | 24 +- pyproject.toml | 5 +- src/__init__.py | 0 src/jobflow_remote/__init__.py | 3 + src/jobflow_remote/config/__init__.py | 0 src/jobflow_remote/config/entities.py | 94 +++++ src/jobflow_remote/config/manager.py | 282 +++++++++++++ src/jobflow_remote/config/settings.py | 15 + src/jobflow_remote/fireworks/__init__.py | 0 src/jobflow_remote/fireworks/convert.py | 139 ++++++ src/jobflow_remote/fireworks/launcher.py | 78 ++++ src/jobflow_remote/fireworks/launchpad.py | 205 +++++++++ src/jobflow_remote/fireworks/tasks.py | 105 +++++ src/jobflow_remote/remote/__init__.py | 0 src/jobflow_remote/remote/data.py | 72 ++++ src/jobflow_remote/remote/host/__init__.py | 3 + src/jobflow_remote/remote/host/base.py | 70 +++ src/jobflow_remote/remote/host/local.py | 103 +++++ src/jobflow_remote/remote/host/remote.py | 134 ++++++ src/jobflow_remote/remote/queue.py | 185 ++++++++ src/jobflow_remote/run/__init__.py | 0 src/jobflow_remote/run/runner.py | 467 +++++++++++++++++++++ src/jobflow_remote/run/state.py | 42 ++ src/jobflow_remote/utils/__init__.py | 0 src/jobflow_remote/utils/data.py | 61 +++ src/jobflow_remote/utils/db.py | 98 +++++ src/jobflow_remote/utils/log.py | 62 +++ 27 files changed, 2232 insertions(+), 15 deletions(-) create mode 100644 src/__init__.py create mode 100644 src/jobflow_remote/config/__init__.py create mode 100644 src/jobflow_remote/config/entities.py create mode 100644 src/jobflow_remote/config/manager.py create mode 100644 src/jobflow_remote/config/settings.py create mode 100644 src/jobflow_remote/fireworks/__init__.py create mode 100644 src/jobflow_remote/fireworks/convert.py create mode 100644 src/jobflow_remote/fireworks/launcher.py create mode 100644 src/jobflow_remote/fireworks/launchpad.py create mode 100644 src/jobflow_remote/fireworks/tasks.py create mode 100644 src/jobflow_remote/remote/__init__.py create mode 100644 src/jobflow_remote/remote/data.py create mode 100644 src/jobflow_remote/remote/host/__init__.py create mode 100644 src/jobflow_remote/remote/host/base.py create mode 100644 src/jobflow_remote/remote/host/local.py create mode 100644 src/jobflow_remote/remote/host/remote.py create mode 100644 src/jobflow_remote/remote/queue.py create mode 100644 src/jobflow_remote/run/__init__.py create mode 100644 src/jobflow_remote/run/runner.py create mode 100644 src/jobflow_remote/run/state.py create mode 100644 src/jobflow_remote/utils/__init__.py create mode 100644 src/jobflow_remote/utils/data.py create mode 100644 src/jobflow_remote/utils/db.py create mode 100644 src/jobflow_remote/utils/log.py diff --git a/README.md b/README.md index f64fb63a..2f0f8203 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,7 @@ **[Full Documentation][docs]** -jobflow-remote is a Python software for ... Features of jobflow-remote include - -- Feature A -- Feature B -- ... +jobflow-remote is a Python package to run jobflow workflows on remote resources. ## Quick start @@ -60,12 +56,12 @@ jobflow-remote is developed and maintained by Matgenix SRL. A full list of all contributors can be found [here][contributors]. -[help-forum]: https://https://github.com//Matgenix/jobflow_remote/issues -[issues]: https://https://github.com//Matgenix/jobflow_remote/issues -[installation]: https://https://github.com//Matgenix/jobflow_remote/blob/main/INSTALL.md -[contributing]: https://github.com/Matgenix/jobflow_remote/blob/main/CONTRIBUTING.md -[codeofconduct]: https://github.com/Matgenix/jobflow_remote/blob/main/CODE_OF_CONDUCT.md -[changelog]: https://https://github.com//Matgenix/jobflow_remote/blob/main/CHANGELOG.md -[contributors]: https://Matgenix.github.io/jobflow_remote/graphs/contributors -[license]: https://raw.githubusercontent.com/Matgenix/jobflow_remote/main/LICENSE -[docs]: https://Matgenix.github.io/jobflow_remote/ +[help-forum]: https://https://github.com//Matgenix/jobflow-remote/issues +[issues]: https://https://github.com//Matgenix/jobflow-remote/issues +[installation]: https://https://github.com//Matgenix/jobflow-remote/blob/main/INSTALL.md +[contributing]: https://github.com/Matgenix/jobflow-remote/blob/main/CONTRIBUTING.md +[codeofconduct]: https://github.com/Matgenix/jobflow-remote/blob/main/CODE_OF_CONDUCT.md +[changelog]: https://https://github.com//Matgenix/jobflow-remote/blob/main/CHANGELOG.md +[contributors]: https://Matgenix.github.io/jobflow-remote/graphs/contributors +[license]: https://raw.githubusercontent.com/Matgenix/jobflow-remote/main/LICENSE +[docs]: https://Matgenix.github.io/jobflow-remote/ diff --git a/pyproject.toml b/pyproject.toml index b7a3b961..3ce8483f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,10 @@ classifiers = [ "Topic :: Scientific/Engineering", ] requires-python = ">=3.8" -dependencies =[] +dependencies =[ + "jobflow", + "fireworks" +] [project.optional-dependencies] dev = [ diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/jobflow_remote/__init__.py b/src/jobflow_remote/__init__.py index bdad37c0..374607a1 100644 --- a/src/jobflow_remote/__init__.py +++ b/src/jobflow_remote/__init__.py @@ -1,3 +1,6 @@ """jobflow-remote is a python package to run jobflow workflows on remote resources""" from jobflow_remote._version import __version__ +from jobflow_remote.config.settings import JobflowRemoteSettings + +SETTINGS = JobflowRemoteSettings() diff --git a/src/jobflow_remote/config/__init__.py b/src/jobflow_remote/config/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/jobflow_remote/config/entities.py b/src/jobflow_remote/config/entities.py new file mode 100644 index 00000000..74a873c9 --- /dev/null +++ b/src/jobflow_remote/config/entities.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import logging + +# from pydantic.dataclasses import dataclass +from dataclasses import dataclass, field +from uuid import uuid4 + +from monty.json import MSONable +from qtoolkit.core.data_objects import QResources +from qtoolkit.io import BaseSchedulerIO + +from jobflow_remote.remote.host import BaseHost + + +@dataclass +class ProjectOptions(MSONable): + max_step_attempts: int = 3 + delta_retry: tuple[int, ...] = (30, 300, 1200) + + def get_delta_retry(self, step_attempts: int): + ind = min(step_attempts, len(self.delta_retry)) - 1 + return self.delta_retry[ind] + + +@dataclass +class RunnerOptions(MSONable): + delay_checkout: int = 30 + delay_check_run_status: int = 30 + delay_advance_status: int = 30 + lock_timeout: int | None = 7200 + delete_tmp_folder: bool = True + + +@dataclass +class Project(MSONable): + + project_id: str + name: str + folder: str | None = None + folder_tmp: str | None = None + log_level: int = logging.INFO + options: ProjectOptions = field(default_factory=ProjectOptions) + runner_options: RunnerOptions = field(default_factory=RunnerOptions) + + @classmethod + def from_uuid_id(cls, **kwargs): + project_id = str(uuid4()) + return cls(project_id=project_id, **kwargs) + + +@dataclass +class ProjectsData(MSONable): + + projects: dict[str, Project] = field(default_factory=dict) + default_project_name: str = None + hosts: dict[str, BaseHost] = field(default_factory=dict) + + @property + def default_project(self): + return self.projects[self.default_project_name] + + +@dataclass +class Machine(MSONable): + + machine_id: str + scheduler_io: BaseSchedulerIO + host_id: str + work_dir: str + default_qtk_options: dict | QResources | None = None + pre_run: str | None = None + post_run: str | None = None + queue_exec_timeout: int | None = 30 + + +@dataclass +class LaunchPadConfig(MSONable): + host: str | None = None + port: int | None = None + name: str | None = None + username: str | None = None + password: str | None = None + logdir: str | None = None + strm_lvl: str = "CRITICAL" + user_indices: list[str] | None = None + wf_user_indices: list[str] | None = None + authsource: str | None = None + uri_mode: bool = False + mongoclient_kwargs: dict | None = None + + +class ConfigError(Exception): + pass diff --git a/src/jobflow_remote/config/manager.py b/src/jobflow_remote/config/manager.py new file mode 100644 index 00000000..9cb0a829 --- /dev/null +++ b/src/jobflow_remote/config/manager.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +from monty.os import makedirs_p +from monty.serialization import dumpfn, loadfn + +from jobflow_remote import SETTINGS +from jobflow_remote.config.entities import ConfigError, Machine, Project, ProjectsData +from jobflow_remote.fireworks.launchpad import RemoteLaunchPad +from jobflow_remote.remote.host.base import BaseHost + + +class ConfigManager: + projects_filename = "projects.json" + machines_filename = "machines.json" + jobflow_settings_filename = "jobflow.json" + launchpad_filename = "launchpad.json" + log_folder = "logs" + + def __init__(self): + self.projects_folder = Path(SETTINGS.projects_folder) + makedirs_p(self.projects_folder) + self.projects_data = self._load_projects_data() + + @property + def projects_config_filepath(self) -> Path: + return self.projects_folder / self.projects_filename + + @property + def projects(self) -> dict[str, Project]: + return dict(self.projects_data.projects) + + @property + def hosts(self) -> dict[str, BaseHost]: + return dict(self.projects_data.hosts) + + def _base_get_project_path( + self, subpath: str | Path, project: str | Project | None = None + ): + if isinstance(project, Project): + project = project.name + project = self.load_project(project) + return Path(project.folder) / subpath + + def get_machines_config_filepath( + self, project: str | Project | None = None + ) -> Path: + return self._base_get_project_path(self.machines_filename, project) + + def get_jobflow_settings_filepath( + self, project: str | Project | None = None + ) -> Path: + return self._base_get_project_path(self.jobflow_settings_filename, project) + + def get_launchpad_filepath(self, project: str | Project | None = None) -> Path: + return self._base_get_project_path(self.launchpad_filename, project) + + def get_logs_folder_path(self, project: str | Project | None = None) -> Path: + return self._base_get_project_path(self.log_folder, project) + + def _load_projects_data(self) -> ProjectsData: + filepath = self.projects_config_filepath + if not Path(filepath).exists(): + pd = ProjectsData() + else: + pd = loadfn(filepath) + + return pd + + def load_project(self, project_name: str | None = None) -> Project: + if not project_name: + return self.load_current_project() + + pd = self.projects_data + try: + return pd.projects[project_name] + except ValueError: + raise ConfigError( + f"No project with name {project_name} present in the configuration" + ) + + def load_project_fom_id(self, project_id: str) -> Project: + pd = self.projects_data + for p in pd.projects.values(): + if p.project_id == project_id: + return p + + raise ConfigError( + f"No project with id {project_id} present in the configuration" + ) + + def load_default_project(self) -> Project: + if not self.projects_data.default_project_name: + raise ConfigError("default project has not been defined") + + try: + return self.projects_data.projects[self.projects_data.default_project_name] + except ValueError: + raise ConfigError( + f"Could not find the project {self.projects_data.default_project_name}" + ) + + def load_current_project(self) -> Project: + project_name = ( + SETTINGS.current_project or self.projects_data.default_project_name + ) + if not project_name: + raise ConfigError( + "current project and default project have not been defined" + ) + + try: + return self.projects_data.projects[project_name] + except ValueError: + raise ConfigError(f"Could not find the project {project_name}") + + def dump_projects_data(self, projects_data: ProjectsData | None = None): + projects_data = projects_data or self.projects_data + makedirs_p(self.projects_folder) + dumpfn(projects_data, self.projects_config_filepath, indent=2) + self.projects_data = projects_data + + def create_project(self, project: Project): + if project.name in self.projects_data.projects: + raise ConfigError(f"Project with name {project.name} already exists") + self.projects_data.projects[project.name] = project + if not project.folder: + project_folder = str(self.projects_folder / project.name) + project.folder = project_folder + if not project.folder_tmp: + tmp_folder = str(Path(project.folder) / "tmp_files") + project.folder_tmp = tmp_folder + + makedirs_p(project.folder) + makedirs_p(project.folder_tmp) + makedirs_p(self.get_logs_folder_path(project.name)) + self.dump_projects_data(self.projects_data) + + def remove_project(self, project_name: str): + if project_name not in self.projects_data.projects: + return + project = self.projects_data.projects.pop(project_name) + shutil.rmtree(project.folder, ignore_errors=True) + shutil.rmtree(project.folder_tmp, ignore_errors=True) + + def set_default_project(self, project: str | Project): + if isinstance(project, Project): + project = project.name + + if project not in self.projects_data.projects: + raise ConfigError( + f"Cannot set current project as no project named {project} has been defined" + ) + + self.projects_data.default_project_name = project + + self.dump_projects_data() + + def load_machines_data(self, project_name: str | None = None) -> dict[str, Machine]: + filepath = self.get_machines_config_filepath(project_name) + if not filepath.exists(): + return {} + + return loadfn(filepath) + + def dump_machines_data( + self, machines_data: dict, project_name: str | None = None + ) -> None: + filepath = self.get_machines_config_filepath(project_name) + dumpfn(machines_data, filepath, indent=2) + + def dump_jobflow_settings_data( + self, settings: dict, project_name: str | None = None + ) -> None: + filepath = self.get_jobflow_settings_filepath(project_name) + dumpfn(settings, filepath, indent=2) + + def dump_launchpad_data( + self, config: dict, project_name: str | None = None + ) -> None: + filepath = self.get_launchpad_filepath(project_name) + dumpfn(config, filepath, indent=2) + + def set_machine( + self, machine: Machine, project_name: str | None = None, replace: bool = False + ): + machines_data = self.load_machines_data(project_name) + if not replace and machine.machine_id in machines_data: + raise ConfigError( + f"Machine with id {machine.machine_id} is already defined" + ) + machines_data[machine.machine_id] = machine + if machine.host_id not in self.hosts: + raise ValueError(f"host {machine.host_id} is not defined") + self.dump_machines_data(machines_data) + + def remove_machine(self, machine_id: str, project_name: str | None = None): + machines_data = self.load_machines_data(project_name) + machines_data.pop(machine_id) + self.dump_machines_data(machines_data) + + def load_machine(self, machine_id: str, project_name: str | None = None) -> Machine: + machines_data = self.load_machines_data(project_name) + if machine_id not in machines_data: + raise ConfigError(f"Machine with id {machine_id} is not defined") + return machines_data[machine_id] + + def set_host(self, host_id: str, host: BaseHost, replace: bool = False): + if not replace and host_id in self.projects_data.hosts: + raise ConfigError(f"Host with id {host_id} is already defined") + self.projects_data.hosts[host_id] = host + self.dump_projects_data() + + def remove_host(self, host_id: str): + for project_name in self.projects.keys(): + for machine_id, machine in self.load_machines_data(project_name).items(): + if machine.host_id == host_id: + raise ValueError( + f"Host is used in the {machine_id} machine. Will not be removed." + ) + self.projects_data.hosts.pop(host_id) + self.dump_projects_data() + + def load_host(self, host_id: str) -> BaseHost: + if host_id not in self.projects_data.hosts: + raise ConfigError(f"Host with id {host_id} is not defined") + + def set_jobflow_settings( + self, settings: dict, project_name: str | None = None, update: bool = False + ): + project = self.load_project(project_name) + filepath = self.get_jobflow_settings_filepath(project) + settings["CONFIG_FILE"] = str(filepath) + if update and filepath.exists(): + old = loadfn(filepath) + old.update(settings) + settings = old + + self.dump_jobflow_settings_data(settings, project.name) + + def load_jobflow_settings(self, project_name: str): + filepath = self.get_jobflow_settings_filepath(project_name) + if not filepath.exists(): + return {} + else: + return loadfn(filepath) + + def activate_jobflow_settings(self, project_name: str | None = None): + project_settings = self.load_jobflow_settings(project_name) + from jobflow import SETTINGS + + for k, v in project_settings.items(): + setattr(SETTINGS, k, v) + + def activate_project(self, project_name: str): + self.activate_jobflow_settings(project_name) + + def set_launchpad_config( + self, config: dict, project_name: str | None = None, update: bool = False + ): + project = self.load_project(project_name) + filepath = self.get_launchpad_filepath(project) + if update and filepath.exists(): + old = loadfn(filepath) + old.update(config) + config = old + + self.dump_launchpad_data(config, project.name) + + def load_launchpad_config(self, project_name: str | None = None): + filepath = self.get_launchpad_filepath(project_name) + if not filepath.exists(): + return {} + else: + return loadfn(filepath, cls=None) + + def load_launchpad(self, project_name: str | None = None): + # from fireworks import LaunchPad + config = self.load_launchpad_config(project_name) + return RemoteLaunchPad(**config) diff --git a/src/jobflow_remote/config/settings.py b/src/jobflow_remote/config/settings.py new file mode 100644 index 00000000..b0847d68 --- /dev/null +++ b/src/jobflow_remote/config/settings.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from pathlib import Path + +from pydantic import BaseSettings + + +class JobflowRemoteSettings(BaseSettings): + projects_folder: str = Path("~/.jfremote").expanduser().as_posix() + current_project: str = None + + class Config: + """Pydantic config settings.""" + + env_prefix = "jfremote_" diff --git a/src/jobflow_remote/fireworks/__init__.py b/src/jobflow_remote/fireworks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/jobflow_remote/fireworks/convert.py b/src/jobflow_remote/fireworks/convert.py new file mode 100644 index 00000000..99e16677 --- /dev/null +++ b/src/jobflow_remote/fireworks/convert.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import typing + +from fireworks import Firework, Workflow +from qtoolkit.core.data_objects import QResources + +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], + machine: str, + store: jobflow.JobStore | None = None, + exports: dict | None = None, + qtk_options: dict | QResources | None = None, + # profile: str | None = None, + **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. + 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. + **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 = [] + + flow = get_flow(flow) + + for job, parents in flow.iterflow(): + fw = job_to_firework( + job, + machine=machine, + store=store, + parents=parents, + parent_mapping=parent_mapping, + exports=exports, + qtk_options=qtk_options, + ) + fireworks.append(fw) + + return Workflow(fireworks, name=flow.name, **kwargs) + + +def job_to_firework( + job: jobflow.Job, + machine: str, + store: jobflow.JobStore | None = None, + parents: Sequence[str] | None = None, + parent_mapping: dict[str, Firework] | None = None, + exports: dict | None = None, + qtk_options: 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.") + + task = RemoteJobFiretask( + job=job, store=store, machine=machine, exports=exports, qtk_options=qtk_options + ) + + 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(job.config.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 new file mode 100644 index 00000000..0bd2fa09 --- /dev/null +++ b/src/jobflow_remote/fireworks/launcher.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +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, + launcher_dir: str | Path = ".", + strm_lvl: str = "INFO", + fw_id: int = None, +): + """ + Submit a single job to the queue. + + Args: + rlpad + fworker + launcher_dir: The directory where to submit the job + strm_lvl: level at which to stream log messages + fw_id: specific fw_id to reserve (reservation mode only) + """ + fworker = fworker if fworker else FWorker() + # launcher_dir = os.path.abspath(launcher_dir) + + fw, launch_id = None, None + + launch_id = None + try: + + fw, launch_id = rlpad.lpad.reserve_fw(fworker, launcher_dir, 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}") + + # TODO launcher dir should be set according to remote settings + # maybe set it later when actually copied? + # rlpad.change_launch_dir(launch_id, launcher_dir) + + fw.tasks[0].get("job").uuid + + 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(launch_id, rlpad) + except Exception: + logger.exception(f"Error unreserving FW with fw_id {fw.fw_id}") + + return None, None + + +def rapidfire_checkout(rlpad, 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 new file mode 100644 index 00000000..089d724e --- /dev/null +++ b/src/jobflow_remote/fireworks/launchpad.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import datetime +import traceback + +from fireworks import FWAction, Launch, LaunchPad +from fireworks.utilities.fw_serializers import reconstitute_dates + +from jobflow_remote.remote.data import update_store +from jobflow_remote.run.state import RemoteState + + +class RemoteLaunchPad: + def __init__(self, **kwargs): + self.lpad = LaunchPad(**kwargs) + self.remote_runs = self.db.remote_runs + + @property + def db(self): + return self.lpad.db + + def reset(self, password, require_password=True, max_reset_wo_password=25): + self.lpad.reset(password, require_password, max_reset_wo_password) + self.remote_runs.delete_many({}) + + def forget_remote(self, launchid_or_fwid, launch_mode=True): + """ + Unmark the offline run 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 = ( + {"launch_id": launchid_or_fwid} + if launch_mode + else {"fw_id": launchid_or_fwid} + ) + self.db.remote_runs.update_many(q, {"$set": {"deprecated": True}}) + + def add_remote_run(self, launch_id, fw): + """ + Add the launch and firework to the offline_run collection. + + Args: + launch_id (int): launch id + fw_id (id): firework id + name (str) + """ + task = fw.tasks[0] + machine_id = task.get("machine") + job = task.get("job") + job_id = job.uuid + d = {"fw_id": fw.fw_id} + d["launch_id"] = launch_id + d["name"] = fw.name + d["created_on"] = datetime.datetime.utcnow().isoformat() + d["updated_on"] = datetime.datetime.utcnow().isoformat() + d["deprecated"] = False + d["state"] = RemoteState.CHECKED_OUT.value + d["completed"] = False + d["job_id"] = job_id + d["step_attempts"] = 0 + d["retry_time_limit"] = None + d["failed_state"] = None + d["queue_state"] = None + d["machine_id"] = machine_id + self.db.remote_runs.insert_one(d) + + def get_fw_by_job_id(self, job_id): + pass + + def get_job_by_uuid(self, job_id): + self.get_fw_by_job_id(job_id) + + 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 + + if not already_running: + m_launch.state = "RUNNING" # this should also add a history item + + remote_status["checkpoint"] if "checkpoint" in remote_status else None + + 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}}, + ) + + self.lpad.offline_runs.update_one( + {"launch_id": launch_id}, {"$set": {"completed": True}} + ) + 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(), + } + }, + ) + if f: + self.lpad._refresh_wf(fw_id) + + # update the updated_on + self.remote_runs.update_one( + {"launch_id": launch_id}, + {"$set": {"updated_on": datetime.datetime.utcnow().isoformat()}}, + ) + # return None + + 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") + self.remote_runs.update_one( + {"launch_id": launch_id}, {"$set": {"completed": True}} + ) + completed = True + return m_launch.fw_id, completed diff --git a/src/jobflow_remote/fireworks/tasks.py b/src/jobflow_remote/fireworks/tasks.py new file mode 100644 index 00000000..545a5f5b --- /dev/null +++ b/src/jobflow_remote/fireworks/tasks.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import glob +import os + +from fireworks import FiretaskBase, FWAction, explicit_serialize +from monty.shutil import decompress_file + + +@explicit_serialize +class RemoteJobFiretask(FiretaskBase): + """ + A firetask that will run any job. + + 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 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. + """ + + required_params = ["job", "store", "machine"] + optional_params = ["exports", "qtk_options"] + + 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 + from jobflow.core.store import JobStore + from maggma.stores.mongolike import JSONStore + + job: Job = self.get("job") + original_store = self.get("store") + + docs_store = JSONStore("remote_job_data.json", read_only=False) + additional_stores = {} + for k in original_store.additional_stores.keys(): + additional_stores[k] = JSONStore( + f"additional_store_{k}.json", read_only=False + ) + store = JobStore( + docs_store=docs_store, + additional_stores=additional_stores, + save=original_store.save, + load=original_store.load, + ) + store.connect() + + if hasattr(self, "fw_id"): + job.metadata.update({"fw_id": self.fw_id}) + + initialize_logger() + + response = job.run(store=store) + + # some jobs may have compressed the FW files while being executed, + # try to decompress them if that is the case. + self.decompress_files() + + detours = None + additions = None + # in case of dynamic Flow set the same parameters as the current Job + kwargs_dynamic = { + "machine": self.get("machine"), + "store": original_store, + "exports": self.get("exports"), + "qtk_options": self.get("qtk_options"), + } + 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): + file_names = ["FW.json", "FW_offline.json"] + + for fn in file_names: + if os.path.isfile(fn): + continue + for f in glob.glob(fn + ".*"): + decompress_file(f) diff --git a/src/jobflow_remote/remote/__init__.py b/src/jobflow_remote/remote/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/jobflow_remote/remote/data.py b/src/jobflow_remote/remote/data.py new file mode 100644 index 00000000..1f0eaeca --- /dev/null +++ b/src/jobflow_remote/remote/data.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import logging +import os +from pathlib import Path + +from jobflow.core.store import JobStore +from maggma.stores.mongolike import JSONStore + +from jobflow_remote.utils.data import uuid_to_path + + +def get_job_path(job_id: str, base_path: str | Path | None = None) -> Path: + if base_path: + base_path = Path(base_path) + else: + base_path = Path() + + relative_path = uuid_to_path(job_id) + return 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_store(store, launch_dir): + + docs_store = JSONStore( + os.path.join(launch_dir, "remote_job_data.json"), read_only=False + ) + additional_stores = {} + for k in store.additional_stores.keys(): + additional_stores[k] = JSONStore( + os.path.join(launch_dir, f"additional_store_{k}.json"), read_only=False + ) + remote_store = JobStore( + docs_store=docs_store, + additional_stores=additional_stores, + save=store.save, + load=store.load, + ) + + remote_store.connect() + + return remote_store + + +def update_store(store, remote_store, save): + + # TODO is it correct? + data = list(remote_store.query(load=save)) + if len(data) > 1: + raise RuntimeError("something wrong with the remote store") + + store.connect() + try: + for d in data: + data = dict(d) + data.pop("_id") + store.update(data, key=["uuid", "index"], save=save) + finally: + try: + store.close() + except Exception: + logging.error(f"error while closing the store {store}", exc_info=True) diff --git a/src/jobflow_remote/remote/host/__init__.py b/src/jobflow_remote/remote/host/__init__.py new file mode 100644 index 00000000..67c7c21c --- /dev/null +++ b/src/jobflow_remote/remote/host/__init__.py @@ -0,0 +1,3 @@ +from jobflow_remote.remote.host.base import BaseHost +from jobflow_remote.remote.host.local import LocalHost +from jobflow_remote.remote.host.remote import RemoteHost diff --git a/src/jobflow_remote/remote/host/base.py b/src/jobflow_remote/remote/host/base.py new file mode 100644 index 00000000..406767f0 --- /dev/null +++ b/src/jobflow_remote/remote/host/base.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import abc +from pathlib import Path + +from monty.json import MSONable + + +class BaseHost(MSONable): + """Base Host class.""" + + @abc.abstractmethod + def execute( + self, + command: str | list[str], + workdir: str | Path | None = None, + timeout: int | None = None, + ): + """Execute the given command on the host + + Parameters + ---------- + command: str or list of str + Command to execute, as a str or list of str + workdir: str or None + 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 + def mkdir(self, directory, 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 + def connect(self): + raise NotImplementedError + + @abc.abstractmethod + def close(self) -> bool: + raise NotImplementedError + + @property + @abc.abstractmethod + def is_connected(self) -> bool: + raise NotImplementedError + + @abc.abstractmethod + def put(self, src, dst): + raise NotImplementedError + + @abc.abstractmethod + def get(self, src, dst): + raise NotImplementedError + + @abc.abstractmethod + def copy(self, src, dst): + raise NotImplementedError diff --git a/src/jobflow_remote/remote/host/local.py b/src/jobflow_remote/remote/host/local.py new file mode 100644 index 00000000..db1c2683 --- /dev/null +++ b/src/jobflow_remote/remote/host/local.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +from monty.os import cd + +from jobflow_remote.remote.host.base import BaseHost + + +class LocalHost(BaseHost): + def __init__(self, timeout_execute: int = None): + self.timeout_execute = timeout_execute + + def execute( + self, + command: str | list[str], + workdir: str | Path | None = None, + timeout: int | None = None, + ): + """Execute the given command on the host + + Note that the command is executed with shell=True, so commands can + be exposed to command injection. Consider whether to escape part of + the input if it comes from external users. + + Parameters + ---------- + command: str or list of str + Command to execute, as a str or list of str + + Returns + ------- + stdout : str + Standard output of the command + stderr : str + Standard error of the command + exit_code : int + Exit code of the command. + """ + if isinstance(command, (list, tuple)): + command = " ".join(command) + if not workdir: + workdir = Path.cwd() + else: + workdir = str(workdir) + timeout = timeout or self.timeout_execute + with cd(workdir): + proc = subprocess.run( + command, capture_output=True, shell=True, timeout=timeout + ) + return proc.stdout.decode(), proc.stderr.decode(), proc.returncode + + def mkdir(self, directory, recursive=True, exist_ok=True) -> bool: + try: + Path(directory).mkdir(parents=recursive, exist_ok=exist_ok) + except OSError: + return False + return True + + def write_text_file(self, filepath, content) -> None: + Path(filepath).write_text(content) + + def connect(self): + pass + + def close(self) -> bool: + return True + + @property + def is_connected(self) -> bool: + return True + + def put(self, src, dst): + is_file_like = hasattr(src, "read") and callable(src.read) + + if is_file_like: + src_base = getattr(src, "name", None) + else: + src_base = os.path.basename(src) + + if Path(dst).is_dir(): + if src_base: + dst = Path(dst, src_base) + else: + if is_file_like: + raise ValueError( + "could not determine the file name and dst is a folder" + ) + + if is_file_like: + with open(dst, "wb") as f: + f.write(src.read()) + else: + shutil.copy(src, dst) + + def get(self, src, dst): + self.copy(src, dst) + + def copy(self, src, dst): + shutil.copy(src, dst) diff --git a/src/jobflow_remote/remote/host/remote.py b/src/jobflow_remote/remote/host/remote.py new file mode 100644 index 00000000..cc7daba2 --- /dev/null +++ b/src/jobflow_remote/remote/host/remote.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import io +from pathlib import Path + +import fabric + +from jobflow_remote.remote.host.base import BaseHost + +# from fabric import Connection, Config + + +class RemoteHost(BaseHost): + """ + Execute commands on a remote host. + For some commands assumes the remote can run unix + """ + + def __init__( + self, + host, + user=None, + port=None, + config=None, + gateway=None, + forward_agent=None, + connect_timeout=None, + connect_kwargs=None, + inline_ssh_env=None, + timeout_execute=None, + ): + self.host = host + self.user = user + self.port = port + self.config = config + self.gateway = gateway + self.forward_agent = forward_agent + self.connect_timeout = connect_timeout + self.connect_kwargs = connect_kwargs + self.inline_ssh_env = inline_ssh_env + self.timeout_execute = timeout_execute + self._connection = fabric.Connection( + host=self.host, + user=self.user, + port=self.port, + config=self.config, + gateway=self.gateway, + forward_agent=self.forward_agent, + connect_timeout=self.connect_timeout, + connect_kwargs=self.connect_kwargs, + inline_ssh_env=self.inline_ssh_env, + ) + + @property + def connection(self): + return self._connection + + def execute( + self, + command: str | list[str], + workdir: str | Path | None = None, + timeout: int | None = None, + ): + """Execute the given command on the host + + Parameters + ---------- + command: str or list of str + Command to execute, as a str or list of str. + workdir: str or None + path where the command will be executed. + + Returns + ------- + stdout : str + Standard output of the command + stderr : str + Standard error of the command + exit_code : int + Exit code of the command. + """ + + # TODO: check if this works: + if not workdir: + workdir = "." + else: + workdir = str(workdir) + timeout = timeout or self.timeout_execute + with self.connection.cd(workdir): + out = self.connection.run(command, hide=True, warn=True, timeout=timeout) + + return out.stdout, out.stderr, out.exited + + def mkdir(self, directory, recursive: bool = True, exist_ok: bool = True) -> bool: + """Create directory on the host.""" + command = "mkdir " + if recursive: + command += "-p " + command += str(directory) + try: + stdout, stderr, returncode = self.execute(command) + return returncode == 0 + except Exception: + return False + + def write_text_file(self, filepath: str | Path, content: str): + """Write content to a file on the host.""" + f = io.StringIO(content) + + self.connection.put(f, str(filepath)) + + def connect(self): + self.connection.open() + + def close(self) -> bool: + try: + self.connection.close() + except Exception: + return False + return True + + @property + def is_connected(self) -> bool: + return self.connection.is_connected + + def put(self, src, dst): + self.connection.put(src, dst) + + def get(self, src, dst): + self.connection.get(src, dst) + + def copy(self, src, dst): + cmd = ["cp", str(src), str(dst)] + self.execute(cmd) diff --git a/src/jobflow_remote/remote/queue.py b/src/jobflow_remote/remote/queue.py new file mode 100644 index 00000000..5a82315f --- /dev/null +++ b/src/jobflow_remote/remote/queue.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +from pathlib import Path + +from qtoolkit.core.data_objects import CancelResult, QJob, QResources, SubmissionResult +from qtoolkit.io.base import BaseSchedulerIO + +from jobflow_remote.config.entities import Machine +from jobflow_remote.config.manager import ConfigManager +from jobflow_remote.remote.host import BaseHost + + +class QueueManager: + """Base class for job queues. + + Attributes + ---------- + scheduler_io : str + Name of the queue + host : BaseHost + Host where the command should be executed. + """ + + def __init__( + self, + scheduler_io: BaseSchedulerIO, + host: BaseHost, + timeout_exec: int | None = None, + ): + self.scheduler_io = scheduler_io + self.host = host + self.timeout_exec = timeout_exec + + def execute_cmd( + self, cmd: str, workdir: str | Path | None = None, timeout: int | None = None + ): + """Execute a command. + + Parameters + ---------- + cmd : str + Command to be executed + workdir: str or None + path where the command will be executed. + + Returns + ------- + stdout : str + stderr : str + exit_code : int + """ + timeout = timeout if timeout is not None else self.timeout_exec + return self.host.execute(cmd, workdir, timeout) + + def get_submission_script( + self, + commands: str | list[str] | None, + options: dict | QResources | None = None, + work_dir: str | Path | None = None, + pre_run: str | list[str] | None = None, + post_run: str | list[str] | None = None, + exports: dict | None = None, + ) -> str: + """ """ + commands_list = [] + if change_dir := self.get_change_dir(work_dir): + commands_list.append(change_dir) + if pre_run := self.get_pre_run(pre_run): + commands_list.append(pre_run) + if exports_str := self.get_exports(exports): + commands_list.append(exports_str) + if run_commands := self.get_run_commands(commands): + commands_list.append(run_commands) + if post_run := self.get_post_run(post_run): + commands_list.append(post_run) + return self.scheduler_io.get_submission_script(commands_list, options) + + def get_change_dir(self, dir_path: str | Path | None) -> str: + if dir_path: + return f"cd {dir_path}" + return "" + + def get_pre_run(self, pre_run: str | list[str] | None) -> str: + if isinstance(pre_run, (list, tuple)): + return "\n".join(pre_run) + return pre_run + + def get_exports(self, exports: dict | None) -> str: + if not exports: + return None + exports_str = [] + for k, v in exports.items(): + exports_str.append(f"export {k}={v}") + return "\n".join(exports_str) + + def get_run_commands(self, commands) -> str: + if isinstance(commands, str): + return commands + elif isinstance(commands, list): + return "\n".join(commands) + else: + raise ValueError("commands should be a str or a list of str.") + + def get_post_run(self, post_run: str | list[str] | None) -> str: + if isinstance(post_run, (list, tuple)): + return "\n".join(post_run) + return post_run + + def submit( + 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, + exports: dict | None = None, + script_fname="submit.sh", + create_submit_dir=False, + timeout: int | None = None, + ) -> SubmissionResult: + script_str = self.get_submission_script( + commands=commands, + options=options, + work_dir=work_dir, + pre_run=pre_run, + post_run=post_run, + exports=exports, + ) + + if create_submit_dir and work_dir: + created = self.host.mkdir(work_dir, recursive=True, exist_ok=True) + if not created: + raise RuntimeError("failed to create directory") + if work_dir: + script_fpath = Path(work_dir, script_fname) + 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 + ) + + def cancel(self, job: QJob | int | str, timeout: int | None = None) -> CancelResult: + cancel_cmd = self.scheduler_io.get_cancel_cmd(job) + stdout, stderr, returncode = self.execute_cmd(cancel_cmd, timeout=timeout) + return self.scheduler_io.parse_cancel_output( + exit_code=returncode, stdout=stdout, stderr=stderr + ) + + def get_job(self, job: QJob | int | str, timeout: int | None = None) -> QJob | None: + job_cmd = self.scheduler_io.get_job_cmd(job) + stdout, stderr, returncode = self.execute_cmd(job_cmd, timeout=timeout) + return self.scheduler_io.parse_job_output( + exit_code=returncode, stdout=stdout, stderr=stderr + ) + + def get_jobs_list( + self, + jobs: list[QJob | int | str] | None = None, + user: str | None = None, + timeout: int | None = None, + ) -> list[QJob]: + job_cmd = self.scheduler_io.get_jobs_list_cmd(jobs, user) + stdout, stderr, returncode = self.execute_cmd(job_cmd, timeout=timeout) + return self.scheduler_io.parse_jobs_list_output( + exit_code=returncode, stdout=stdout, stderr=stderr + ) + + @classmethod + def from_machine( + cls, + machine: str | Machine, + config_manager: ConfigManager | None = None, + project_name: str | None = None, + ): + if not config_manager: + config_manager = ConfigManager() + if isinstance(machine, str): + machine = config_manager.load_machine(machine, project_name) + host = config_manager.load_host(machine.host_id) + return cls(machine.scheduler_io, host, timeout_exec=machine.queue_exec_timeout) diff --git a/src/jobflow_remote/run/__init__.py b/src/jobflow_remote/run/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/jobflow_remote/run/runner.py b/src/jobflow_remote/run/runner.py new file mode 100644 index 00000000..fe4297f8 --- /dev/null +++ b/src/jobflow_remote/run/runner.py @@ -0,0 +1,467 @@ +from __future__ import annotations + +import logging +import shutil +import signal +import time +import traceback +import uuid +import warnings +from collections import defaultdict, namedtuple +from datetime import datetime, timedelta +from pathlib import Path + +from fireworks import FWorker +from jobflow import SETTINGS +from monty.os import makedirs_p +from monty.serialization import loadfn +from qtoolkit.core.data_objects import QState, SubmissionStatus + +from jobflow_remote.config.entities import ( + ConfigError, + Machine, + Project, + ProjectOptions, + ProjectsData, + RunnerOptions, +) +from jobflow_remote.config.manager import ConfigManager +from jobflow_remote.fireworks.launcher import rapidfire_checkout +from jobflow_remote.fireworks.launchpad import RemoteLaunchPad +from jobflow_remote.fireworks.tasks import RemoteJobFiretask +from jobflow_remote.remote.data import get_job_path, get_remote_files, get_remote_store +from jobflow_remote.remote.host import BaseHost +from jobflow_remote.remote.queue import QueueManager +from jobflow_remote.run.state import RemoteState +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__) + +# lpad = LaunchPad.auto_load() +# fworker = FWorker() + +# c = RemoteConfig(host="slurmtest", root_dir="/data") +# rh = RemoteHost(c) +# queue = QueueManager(SlurmIO(get_job_executable="scontrol"), host=rh) +# +# launch_base_dir = "/data/run_jobflow" + + +JobFWData = namedtuple("JobFWData", ["fw", "task", "job", "store", "machine", "host"]) + + +class Runner: + def __init__(self, project_name: str | None = None, log_level: int | None = None): + self.stop_signal = False + self.runner_id: str = str(uuid.uuid4()) + self.config_manager: ConfigManager = ConfigManager() + self.project_name = project_name + self.project: Project = self.config_manager.load_project(project_name) + self.rlpad: RemoteLaunchPad = self.config_manager.load_launchpad(project_name) + self.fworker: FWorker = FWorker() + self.machines: dict[str, Machine] = {} + self.queue_managers: dict = {} + log_level = log_level if log_level is not None else self.project.log_level + initialize_runner_logger( + log_folder=self.config_manager.get_logs_folder_path(project_name), + level=log_level, + ) + + @property + def projects_data(self) -> ProjectsData: + return self.config_manager.projects_data + + @property + def hosts(self) -> dict[str, BaseHost]: + return self.projects_data.hosts + + @property + def runner_options(self) -> RunnerOptions: + return self.project.runner_options + + @property + def project_options(self) -> ProjectOptions: + return self.project.options + + def handle_signal(self, signum, frame): + logger.info(f"Received signal: {signum}") + self.stop_signal = True + + def get_machine(self, machine_id: str) -> Machine: + if machine_id not in self.machines: + self.machines[machine_id] = self.config_manager.load_machine( + machine_id, project_name=self.project_name + ) + return self.machines[machine_id] + + def get_queue_manager(self, machine_id: str) -> QueueManager: + if machine_id not in self.queue_managers: + machine = self.get_machine(machine_id) + self.queue_managers[machine_id] = QueueManager( + machine.scheduler_io, self.hosts[machine.host_id] + ) + return self.queue_managers[machine_id] + + def get_fw_data(self, fw_id: int) -> JobFWData: + fw = self.rlpad.lpad.get_fw_by_id(fw_id) + 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") + if store is None: + store = SETTINGS.JOB_STORE + task["store"] = store + machine = self.get_machine(task["machine"]) + host = self.hosts[machine.host_id] + + return JobFWData(fw, task, job, store, machine, host) + + def run(self): + signal.signal(signal.SIGTERM, self.handle_signal) + last_checkout_time = 0 + last_check_run_status_time = 0 + wait_advance_status = False + last_advance_status = 0 + + try: + while True: + if self.stop_signal: + logger.info("stopping due to sigterm") + break + now = time.time() + if last_checkout_time + self.runner_options.delay_checkout < now: + self.checkout() + last_checkout_time = time.time() + elif ( + 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 ( + not wait_advance_status + 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, + ] + collection = self.rlpad.remote_runs + updated = self.lock_and_update(states, collection) + wait_advance_status = not updated + if not updated: + last_advance_status = time.time() + + time.sleep(1) + finally: + self.cleanup() + + def lock_and_update( + self, + states, + collection, + job_id=None, + additional_filter=None, + update=None, + timeout=None, + **kwargs, + ): + if not isinstance(states, (list, tuple)): + states = tuple(states) + + states_methods = { + RemoteState.CHECKED_OUT: self.upload, + RemoteState.UPLOADED: self.submit, + RemoteState.TERMINATED: self.download, + RemoteState.DOWNLOADED: self.complete_launch, + } + + db_filter = { + "state": {"$in": states}, + "retry_time_limit": {"$not": {"$gt": datetime.utcnow()}}, + } + if job_id is not None: + db_filter["job_id"] = job_id + if additional_filter: + db_filter = deep_merge_dict(db_filter, additional_filter) + + with MongoLock( + collection=collection, + filter=db_filter, + update=update, + timeout=timeout, + lock_id=self.runner_id, + **kwargs, + ) as lock: + doc = lock.locked_document + if not doc: + return False + error = None + + state = RemoteState(doc["state"]) + + function = states_methods[state] + + fail_now = False + try: + succeeded, fail_now, set_output = function(doc) + except ConfigError: + error = traceback.format_exc() + warnings.warn(error) + succeeded = False + fail_now = True + except Exception: + error = traceback.format_exc() + warnings.warn(error) + succeeded = False + + if succeeded: + # new_state = states_evolution[state] + succeeded_update = { + "$set": { + "state": state.next.value, + "step_attempts": 0, + "retry_time_limit": None, + "error": None, + } + } + lock.update_on_release = deep_merge_dict( + succeeded_update, set_output or {} + ) + else: + step_attempts = doc["step_attempts"] + fail_now = ( + fail_now or step_attempts >= self.project_options.max_step_attempts + ) + if fail_now: + lock.update_on_release = { + "$set": { + "state": RemoteState.FAILED.value, + "failed_state": state, + "error": error, + } + } + else: + step_attempts += 1 + delta = self.project_options.get_delta_retry(step_attempts) + retry_time_limit = datetime.utcnow() + timedelta(seconds=delta) + lock.update_on_release = { + "$set": { + "step_attempts": step_attempts, + "retry_time_limit": retry_time_limit, + "error": error, + } + } + + return True + + def upload(self, doc): + fw_id = doc["fw_id"] + logger.debug(f"upload fw_id: {fw_id}") + fw_job_data = self.get_fw_data(fw_id) + + job = fw_job_data.job + store = fw_job_data.store + store.connect() + try: + job.resolve_args(store=store, inplace=True) + finally: + try: + store.close() + except Exception: + logging.error(f"error while closing the store {store}", exc_info=True) + + files = get_remote_files(fw_job_data.fw, doc["launch_id"]) + remote_path = get_job_path(job.uuid, fw_job_data.machine.work_dir) + + created = fw_job_data.host.mkdir(remote_path) + if not created: + logger.error( + f"Could not create remote directory {remote_path} for fw_id {fw_id}" + ) + return False, False, None + + for fname, fcontent in files.items(): + path_file = Path(remote_path, fname) + fw_job_data.host.write_text_file(path_file, fcontent) + + return True, False, None + + def submit(self, doc): + fw_id = doc["fw_id"] + logger.debug(f"submit fw_id: {doc['fw_id']}") + fw_job_data = self.get_fw_data(fw_id) + job = fw_job_data.job + + get_job_path( + doc["job_id"], + ) + remote_path = get_job_path(job.uuid, fw_job_data.machine.work_dir) + + fw_job_data.machine.pre_run + fw_job_data.machine.post_run + + script_commands = ["rlaunch singleshot --offline"] + + machine = fw_job_data.machine + queue_manager = self.get_queue_manager(machine.machine_id) + qtk_options = fw_job_data.task.get("qtk_options") or machine.default_qtk_options + exports = fw_job_data.task.get("exports") + submit_result = queue_manager.submit( + commands=script_commands, + pre_run=machine.pre_run, + post_run=machine.post_run, + options=qtk_options, + exports=exports, + work_dir=remote_path, + create_submit_dir=False, + ) + + if submit_result.status == SubmissionStatus.FAILED: + return False, False, None + elif submit_result.status == SubmissionStatus.JOB_ID_UNKNOWN: + raise RuntimeError("job id unknown") + elif submit_result.status == SubmissionStatus.SUCCESSFUL: + + set_output = {"$set": {"process_id": str(submit_result.job_id)}} + + return True, False, set_output + + raise RuntimeError(f"unhandled submission status {submit_result.status}") + + def download(self, doc): + fw_id = doc["fw_id"] + logger.debug(f"download fw_id: {doc['fw_id']}") + fw_job_data = self.get_fw_data(fw_id) + job = fw_job_data.job + + remote_path = get_job_path(job.uuid, fw_job_data.machine.work_dir) + loca_base_dir = Path(self.project.folder_tmp, "download") + local_path = get_job_path(job.uuid, loca_base_dir) + + makedirs_p(local_path) + + store = fw_job_data.store + + # TODO check if the file exists + fnames = ["FW_offline.json", "remote_job_data.json"] + for k in store.additional_stores.keys(): + fnames.append(f"additional_store_{k}.json") + + 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 + logger.error( + f"file {remote_file_path} for job {job.uuid} does not exist" + ) + return False, True, None + + return True, False, None + + def complete_launch(self, doc): + fw_id = doc["fw_id"] + logger.debug(f"complete launch fw_id: {doc['fw_id']}") + fw_job_data = self.get_fw_data(fw_id) + + loca_base_dir = Path(self.project.folder_tmp, "download") + local_path = get_job_path(fw_job_data.job.uuid, loca_base_dir) + + 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() + } + + # TODO add ping data? + remote_store = get_remote_store(store, local_path) + fw_id, completed = self.rlpad.recover_remote( + remote_status=remote_data, + store=store, + remote_store=remote_store, + save=save, + launch_id=doc["launch_id"], + terminated=True, + ) + + # remove local folder with downloaded files if successfully completed + if completed and self.runner_options.delete_tmp_folder: + shutil.rmtree(local_path, ignore_errors=True) + + return completed, False, None + + def check_run_status(self): + logger.debug("check_run_status") + # check for jobs that could have changed state + machines_ids_docs = defaultdict(dict) + db_filter = {"state": {"$in": [RemoteState.SUBMITTED.value]}} + projection = [ + "fw_id", + "launch_id", + "job_id", + "process_id", + "state", + "machine_id", + ] + for doc in self.rlpad.remote_runs.find(db_filter, projection): + machines_ids_docs[doc["machine_id"]][doc["process_id"]] = doc + + for machine_id, ids_docs in machines_ids_docs.items(): + + if not ids_docs: + continue + try: + ids_list = list(ids_docs.keys()) + queue = self.get_queue_manager(machine_id) + qjobs = queue.get_jobs_list(ids_list) + except Exception: + logger.warning( + f"error trying to get jobs list for machine: {machine_id}", + exc_info=True, + ) + continue + + qjobs_dict = {qjob.job_id: qjob for qjob in qjobs} + + for doc_id, doc in ids_docs.items(): + # TODO if failed should maybe be handled differently? + qjob = qjobs_dict.get(doc_id) + qstate = qjob.state if qjob else None + collection = self.rlpad.remote_runs + if qstate in [None, QState.DONE, QState.FAILED]: + lock_filter = {"state": doc["state"], "job_id": doc["job_id"]} + with MongoLock(collection=collection, filter=lock_filter) as lock: + if lock.locked_document: + lock.update_on_release = { + "$set": { + "state": RemoteState.TERMINATED.value, + "queue_state": qstate, + } + } + logger.debug( + f"terminated remote job with id {doc['process_id']}" + ) + + def checkout(self): + logger.debug("checkout rapidfire") + n = rapidfire_checkout(self.rlpad, self.fworker) + logger.debug(f"checked out {n} jobs") + + def cleanup(self): + for host_id, host in self.hosts.items(): + try: + host.close() + except Exception: + logging.exception(f"error while closing host {host_id}") diff --git a/src/jobflow_remote/run/state.py b/src/jobflow_remote/run/state.py new file mode 100644 index 00000000..d49fd9cb --- /dev/null +++ b/src/jobflow_remote/run/state.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from enum import Enum + + +class RemoteState(Enum): + CHECKED_OUT = "CHECKED_OUT" + UPLOADED = "UPLOADED" + SUBMITTED = "SUBMITTED" + TERMINATED = "TERMINATED" + DOWNLOADED = "DOWNLOADED" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + KILLED = "KILLED" + PAUSED = "PAUSED" + + @property + def next(self): + try: + return states_order[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 = states_order.index(self) - 1 + if prev_index >= 0: + return states_order[prev_index] + except ValueError: + raise RuntimeError(f"No previous state for state {self.name}") + + +states_order = [ + RemoteState.CHECKED_OUT, + RemoteState.UPLOADED, + RemoteState.SUBMITTED, + RemoteState.TERMINATED, + RemoteState.DOWNLOADED, + RemoteState.COMPLETED, +] diff --git a/src/jobflow_remote/utils/__init__.py b/src/jobflow_remote/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/jobflow_remote/utils/data.py b/src/jobflow_remote/utils/data.py new file mode 100644 index 00000000..8f176783 --- /dev/null +++ b/src/jobflow_remote/utils/data.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import os +from collections.abc import Mapping, MutableMapping +from copy import deepcopy +from uuid import UUID + + +def deep_merge_dict( + d1: MutableMapping, + d2: Mapping, + path: list[str] | None = None, + raise_on_conflicts: bool = True, + inplace: bool = True, +) -> MutableMapping: + """ + Merge a dictionary d2 into a dictionary d1 recursively. + + Parameters + ---------- + d1 + d2 + path + raise_on_conflicts + inplace + + Returns + ------- + + """ + if not inplace: + d1 = deepcopy(d1) + if path is None: + path = [] + for key in d2: + if key in d1: + if isinstance(d1[key], Mapping) and isinstance(d2[key], Mapping): + deep_merge_dict(d1[key], d2[key], path + [str(key)]) + elif d1[key] == d2[key]: + pass # same leaf value + elif raise_on_conflicts: + raise ValueError("Conflict at %s" % ".".join(path + [str(key)])) + else: + d1[key] = d2[key] + else: + d1[key] = d2[key] + return d1 + + +def uuid_to_path(uuid: str, num_subdirs: int = 3, subdir_len: int = 2): + u = UUID(uuid) + u_hex = u.hex + + # Split the digest into groups of "subdir_len" characters + subdirs = [ + u_hex[i : i + subdir_len] + for i in range(0, num_subdirs * subdir_len, subdir_len) + ] + + # Combine root directory and subdirectories to form the final path + return os.path.join(*subdirs, uuid) diff --git a/src/jobflow_remote/utils/db.py b/src/jobflow_remote/utils/db.py new file mode 100644 index 00000000..d190103b --- /dev/null +++ b/src/jobflow_remote/utils/db.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import copy +import logging +import warnings +from collections import defaultdict +from datetime import datetime, timedelta + +from jobflow_remote.utils.data import deep_merge_dict + +logger = logging.getLogger(__name__) + + +class MongoLock: + + LOCK_KEY = "_lock_id" + LOCK_TIME_KEY = "_lock_time" + + def __init__( + self, collection, filter, update=None, timeout=None, lock_id=None, **kwargs + ): + self.collection = collection + self.filter = filter or {} + self.update = update + self.timeout = timeout + self.locked_document = None + self.lock_id = lock_id or id(self) + self.kwargs = kwargs + self.update_on_release = None + + def acquire(self): + # Set the lock expiration time + now = datetime.utcnow() + db_filter = copy.deepcopy(self.filter) + + lock_filter = {self.LOCK_KEY: {"$exists": False}} + lock_limit = None + 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) + + 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) + + # 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 result[self.LOCK_TIME_KEY] > lock_limit: + msg = f"The lock was broken. Previous lock id: {result[self.LOCK_KEY]}" + warnings.warn(msg) + + self.locked_document = result + + 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: ""}} + # TODO maybe set on release only if not 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}, + update, + upsert=False, + ) + + # Check if the lock was successfully released + if result.modified_count == 0: + msg = f"Could not release lock for document {self.locked_document['_id']}" + warnings.warn(msg) + + self.locked_document = None + + def __enter__(self): + self.acquire() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + + if self.locked_document: + self.release(exc_type, exc_val, exc_tb) diff --git a/src/jobflow_remote/utils/log.py b/src/jobflow_remote/utils/log.py new file mode 100644 index 00000000..9dec2ec3 --- /dev/null +++ b/src/jobflow_remote/utils/log.py @@ -0,0 +1,62 @@ +"""Tools for logging.""" + +import logging +import logging.config +from pathlib import Path + + +def initialize_runner_logger(log_folder: str | Path, level: int = logging.INFO): + """Initialize the default 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) + + config = { + "version": 1, + "disable_existing_loggers": True, + "formatters": { + "standard": {"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"}, + }, + "handlers": { + "default": { + "class": "logging.handlers.RotatingFileHandler", + "level": level, + "formatter": "standard", + "filename": str(Path(log_folder) / "runner.log"), + "mode": "a", + "backupCount": 5, + "encoding": "utf8", + "maxBytes": 5000000, + }, + "stream": { + "level": level, + "formatter": "standard", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", # Default is stderr + }, + }, + "loggers": { + "jobflow_remote": { # root logger + # 'handlers': ['default'], + "handlers": ["default", "stream"], + "level": level, + "propagate": False, + }, + }, + } + + logging.config.dictConfig(config) From e7be6ca1d353c2dfa3230c25ca1b2469b450bd47 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Thu, 18 May 2023 00:22:27 +0200 Subject: [PATCH 02/89] add submit function --- README.md | 8 ++-- src/jobflow_remote/config/manager.py | 2 +- src/jobflow_remote/fireworks/convert.py | 8 +++- src/jobflow_remote/fireworks/launcher.py | 30 +++++-------- src/jobflow_remote/fireworks/launchpad.py | 3 ++ src/jobflow_remote/fireworks/tasks.py | 15 +++++-- src/jobflow_remote/remote/data.py | 4 +- src/jobflow_remote/run/runner.py | 5 +-- src/jobflow_remote/run/submit.py | 54 +++++++++++++++++++++++ 9 files changed, 95 insertions(+), 34 deletions(-) create mode 100644 src/jobflow_remote/run/submit.py diff --git a/README.md b/README.md index 2f0f8203..ef0cd6be 100644 --- a/README.md +++ b/README.md @@ -56,12 +56,12 @@ jobflow-remote is developed and maintained by Matgenix SRL. A full list of all contributors can be found [here][contributors]. -[help-forum]: https://https://github.com//Matgenix/jobflow-remote/issues -[issues]: https://https://github.com//Matgenix/jobflow-remote/issues +[help-forum]: https://github.com//Matgenix/jobflow-remote/issues +[issues]: https://github.com//Matgenix/jobflow-remote/issues [installation]: https://https://github.com//Matgenix/jobflow-remote/blob/main/INSTALL.md [contributing]: https://github.com/Matgenix/jobflow-remote/blob/main/CONTRIBUTING.md [codeofconduct]: https://github.com/Matgenix/jobflow-remote/blob/main/CODE_OF_CONDUCT.md -[changelog]: https://https://github.com//Matgenix/jobflow-remote/blob/main/CHANGELOG.md +[changelog]: https://github.com//Matgenix/jobflow-remote/blob/main/CHANGELOG.md [contributors]: https://Matgenix.github.io/jobflow-remote/graphs/contributors -[license]: https://raw.githubusercontent.com/Matgenix/jobflow-remote/main/LICENSE +[license]: https://raw.githubusercontent.com/Matgenix/jobflow-remote/blob/main/LICENSE [docs]: https://Matgenix.github.io/jobflow-remote/ diff --git a/src/jobflow_remote/config/manager.py b/src/jobflow_remote/config/manager.py index 9cb0a829..8996dde1 100644 --- a/src/jobflow_remote/config/manager.py +++ b/src/jobflow_remote/config/manager.py @@ -81,7 +81,7 @@ def load_project(self, project_name: str | None = None) -> Project: f"No project with name {project_name} present in the configuration" ) - def load_project_fom_id(self, project_id: str) -> Project: + def load_project_from_id(self, project_id: str) -> Project: pd = self.projects_data for p in pd.projects.values(): if p.project_id == project_id: diff --git a/src/jobflow_remote/fireworks/convert.py b/src/jobflow_remote/fireworks/convert.py index 99e16677..76aaf1ee 100644 --- a/src/jobflow_remote/fireworks/convert.py +++ b/src/jobflow_remote/fireworks/convert.py @@ -21,7 +21,6 @@ def flow_to_workflow( store: jobflow.JobStore | None = None, exports: dict | None = None, qtk_options: dict | QResources | None = None, - # profile: str | None = None, **kwargs, ) -> Workflow: """ @@ -35,11 +34,18 @@ def flow_to_workflow( ---------- flow A flow or job. + machine + The id of the Machine 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. + exports + pairs of key-values that will be exported in the submission script + qtk_options + information passed to qtoolkit to require the resources for the submission + to the queue. **kwargs Keyword arguments passed to Workflow init method. diff --git a/src/jobflow_remote/fireworks/launcher.py b/src/jobflow_remote/fireworks/launcher.py index 0bd2fa09..bc40bfbb 100644 --- a/src/jobflow_remote/fireworks/launcher.py +++ b/src/jobflow_remote/fireworks/launcher.py @@ -1,7 +1,6 @@ from __future__ import annotations import logging -from pathlib import Path from fireworks.core.fworker import FWorker @@ -13,38 +12,33 @@ def checkout_remote( rlpad: RemoteLaunchPad, fworker: FWorker | None = None, - launcher_dir: str | Path = ".", - strm_lvl: str = "INFO", fw_id: int = None, ): """ - Submit a single job to the queue. - - Args: - rlpad - fworker - launcher_dir: The directory where to submit the job - strm_lvl: level at which to stream log messages - fw_id: specific fw_id to reserve (reservation mode only) + + Parameters + ---------- + rlpad + fworker + fw_id + + Returns + ------- + """ fworker = fworker if fworker else FWorker() - # launcher_dir = os.path.abspath(launcher_dir) fw, launch_id = None, None launch_id = None try: - fw, launch_id = rlpad.lpad.reserve_fw(fworker, launcher_dir, fw_id=fw_id) + 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}") - # TODO launcher dir should be set according to remote settings - # maybe set it later when actually copied? - # rlpad.change_launch_dir(launch_id, launcher_dir) - fw.tasks[0].get("job").uuid rlpad.add_remote_run(launch_id, fw) @@ -66,7 +60,7 @@ def checkout_remote( return None, None -def rapidfire_checkout(rlpad, fworker): +def rapidfire_checkout(rlpad: RemoteLaunchPad, fworker: FWorker): n_checked_out = 0 while True: fw, launch_id = checkout_remote(rlpad, fworker) diff --git a/src/jobflow_remote/fireworks/launchpad.py b/src/jobflow_remote/fireworks/launchpad.py index 089d724e..d5df84f6 100644 --- a/src/jobflow_remote/fireworks/launchpad.py +++ b/src/jobflow_remote/fireworks/launchpad.py @@ -203,3 +203,6 @@ def recover_remote( ) completed = True return m_launch.fw_id, completed + + def add_wf(self, wf): + return self.lpad.add_wf(wf) diff --git a/src/jobflow_remote/fireworks/tasks.py b/src/jobflow_remote/fireworks/tasks.py index 545a5f5b..7227a877 100644 --- a/src/jobflow_remote/fireworks/tasks.py +++ b/src/jobflow_remote/fireworks/tasks.py @@ -10,7 +10,7 @@ @explicit_serialize class RemoteJobFiretask(FiretaskBase): """ - A firetask that will run any job. + A firetask that will run any job, tailored for the execution on a remote resource. Other Parameters ---------------- @@ -18,9 +18,16 @@ class RemoteJobFiretask(FiretaskBase): A serialized job. store : JobStore 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. + 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. + machine: Str + The id of the Machine where the calculation will be submitted + exports: Dict + pairs of key-values that will be exported in the submission script + qtk_options: Dict or QResources + information passed to qtoolkit to require the resources for the submission + to the queue. """ required_params = ["job", "store", "machine"] diff --git a/src/jobflow_remote/remote/data.py b/src/jobflow_remote/remote/data.py index 1f0eaeca..add4433c 100644 --- a/src/jobflow_remote/remote/data.py +++ b/src/jobflow_remote/remote/data.py @@ -10,14 +10,14 @@ from jobflow_remote.utils.data import uuid_to_path -def get_job_path(job_id: str, base_path: str | Path | None = None) -> Path: +def get_job_path(job_id: str, base_path: str | Path | None = None) -> str: if base_path: base_path = Path(base_path) else: base_path = Path() relative_path = uuid_to_path(job_id) - return base_path / relative_path + return str(base_path / relative_path) def get_remote_files(fw, launch_id): diff --git a/src/jobflow_remote/run/runner.py b/src/jobflow_remote/run/runner.py index fe4297f8..c5504cbd 100644 --- a/src/jobflow_remote/run/runner.py +++ b/src/jobflow_remote/run/runner.py @@ -280,6 +280,7 @@ def upload(self, doc): files = get_remote_files(fw_job_data.fw, doc["launch_id"]) remote_path = get_job_path(job.uuid, fw_job_data.machine.work_dir) + self.rlpad.lpad.change_launch_dir(doc["launch_id"], remote_path) created = fw_job_data.host.mkdir(remote_path) if not created: @@ -300,9 +301,6 @@ def submit(self, doc): fw_job_data = self.get_fw_data(fw_id) job = fw_job_data.job - get_job_path( - doc["job_id"], - ) remote_path = get_job_path(job.uuid, fw_job_data.machine.work_dir) fw_job_data.machine.pre_run @@ -350,7 +348,6 @@ def download(self, doc): store = fw_job_data.store - # TODO check if the file exists fnames = ["FW_offline.json", "remote_job_data.json"] for k in store.additional_stores.keys(): fnames.append(f"additional_store_{k}.json") diff --git a/src/jobflow_remote/run/submit.py b/src/jobflow_remote/run/submit.py new file mode 100644 index 00000000..bdd2ca7f --- /dev/null +++ b/src/jobflow_remote/run/submit.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import jobflow +from qtoolkit.core.data_objects import QResources + +from jobflow_remote.config.manager import ConfigManager +from jobflow_remote.fireworks.convert import flow_to_workflow + + +def submit_flow( + flow: jobflow.Flow | jobflow.Job | list[jobflow.Job], + machine: str, + store: jobflow.JobStore | None = None, + project: str | None = None, + exports: dict | None = None, + qtk_options: dict | QResources | None = None, +): + """ + Submit a flow for calculation to the selected Machine. + + This will not start the calculation but just add to the database of the + calculation to be executed. + + Parameters + ---------- + flow + A flow or job. + machine + The id of the Machine 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. + project + the name of the project to which the Flow should be submitted. If None the + current project will be used. + exports + pairs of key-values that will be exported in the submission script + qtk_options + information passed to qtoolkit to require the resources for the submission + to the queue. + """ + wf = flow_to_workflow( + flow, machine=machine, store=store, exports=exports, qtk_options=qtk_options + ) + + config_manager = ConfigManager() + + # try to load the machine to check that the project and the machine are well defined + machine = config_manager.load_machine(machine_id=machine, project_name=project) + + rlpad = config_manager.load_launchpad(project) + rlpad.add_wf(wf) From b0216c204a7b97655900251f30a6070708618964 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Mon, 22 May 2023 11:09:21 +0200 Subject: [PATCH 03/89] revised configurations --- src/jobflow_remote/config/entities.py | 285 +++++++++++++--- src/jobflow_remote/config/manager.py | 398 ++++++++++------------- src/jobflow_remote/config/settings.py | 14 +- src/jobflow_remote/fireworks/convert.py | 26 +- src/jobflow_remote/fireworks/launcher.py | 4 +- src/jobflow_remote/fireworks/tasks.py | 9 +- src/jobflow_remote/remote/queue.py | 29 +- src/jobflow_remote/run/runner.py | 72 ++-- src/jobflow_remote/run/submit.py | 19 +- src/jobflow_remote/utils/data.py | 13 + 10 files changed, 512 insertions(+), 357 deletions(-) diff --git a/src/jobflow_remote/config/entities.py b/src/jobflow_remote/config/entities.py index 74a873c9..40b15108 100644 --- a/src/jobflow_remote/config/entities.py +++ b/src/jobflow_remote/config/entities.py @@ -1,82 +1,74 @@ from __future__ import annotations import logging +import traceback +from pathlib import Path +from typing import Annotated, Literal -# from pydantic.dataclasses import dataclass -from dataclasses import dataclass, field +# from dataclasses import dataclass, field from uuid import uuid4 -from monty.json import MSONable -from qtoolkit.core.data_objects import QResources -from qtoolkit.io import BaseSchedulerIO +from jobflow import JobStore -from jobflow_remote.remote.host import BaseHost +# from pydantic.dataclasses import dataclass +from pydantic import BaseModel, Extra, Field, validator +from qtoolkit.io import BaseSchedulerIO, scheduler_mapping +from jobflow_remote import SETTINGS +from jobflow_remote.fireworks.launchpad import RemoteLaunchPad +from jobflow_remote.remote.host import BaseHost, LocalHost, RemoteHost -@dataclass -class ProjectOptions(MSONable): - max_step_attempts: int = 3 - delta_retry: tuple[int, ...] = (30, 300, 1200) +DEFAULT_JOBSTORE = {"docs_store": {"type": "MemoryStore"}} - def get_delta_retry(self, step_attempts: int): - ind = min(step_attempts, len(self.delta_retry)) - 1 - return self.delta_retry[ind] - -@dataclass -class RunnerOptions(MSONable): +class RunnerOptions(BaseModel): delay_checkout: int = 30 delay_check_run_status: int = 30 delay_advance_status: int = 30 lock_timeout: int | None = 7200 delete_tmp_folder: bool = True + max_step_attempts: int = 3 + delta_retry: tuple[int, ...] = (30, 300, 1200) + def get_delta_retry(self, step_attempts: int): + ind = min(step_attempts, len(self.delta_retry)) - 1 + return self.delta_retry[ind] -@dataclass -class Project(MSONable): - - project_id: str - name: str - folder: str | None = None - folder_tmp: str | None = None - log_level: int = logging.INFO - options: ProjectOptions = field(default_factory=ProjectOptions) - runner_options: RunnerOptions = field(default_factory=RunnerOptions) - - @classmethod - def from_uuid_id(cls, **kwargs): - project_id = str(uuid4()) - return cls(project_id=project_id, **kwargs) - - -@dataclass -class ProjectsData(MSONable): - - projects: dict[str, Project] = field(default_factory=dict) - default_project_name: str = None - hosts: dict[str, BaseHost] = field(default_factory=dict) - - @property - def default_project(self): - return self.projects[self.default_project_name] + class Config: + extra = Extra.forbid -@dataclass -class Machine(MSONable): +class Machine(BaseModel): machine_id: str - scheduler_io: BaseSchedulerIO + scheduler_type: str host_id: str work_dir: str - default_qtk_options: dict | QResources | None = None + resources: dict | None = None pre_run: str | None = None post_run: str | None = None queue_exec_timeout: int | None = 30 + class Config: + extra = Extra.forbid -@dataclass -class LaunchPadConfig(MSONable): - host: str | None = None + @validator("scheduler_type", always=True) + def check_scheduler_type(cls, scheduler_type: str, values: dict) -> str: + """ + Validator to set the default of scheduler_type + """ + if scheduler_type not in scheduler_mapping: + raise ValueError(f"Unknown scheduler type {scheduler_type}") + return scheduler_type + + def get_scheduler_io(self) -> BaseSchedulerIO: + if self.scheduler_type not in scheduler_mapping: + raise ConfigError(f"Unknown scheduler type {self.scheduler_type}") + return scheduler_mapping[self.scheduler_type]() + + +class LaunchPadConfig(BaseModel): + host: str | None = "localhost" port: int | None = None name: str | None = None username: str | None = None @@ -89,6 +81,199 @@ class LaunchPadConfig(MSONable): uri_mode: bool = False mongoclient_kwargs: dict | None = None + class Config: + extra = Extra.forbid + + +class RemoteHostConfig(BaseModel): + host_type: Literal["remote"] = "remote" + host_id: str + host: str + user: str = None + port: int = None + gateway: str = None + forward_agent: bool = None + connect_timeout: int = None + connect_kwargs: dict = None + inline_ssh_env: bool = None + timeout_execute: int = 60 + + class Config: + extra = Extra.forbid + + def get_host(self) -> BaseHost: + return RemoteHost( + host=self.host, + user=self.user, + port=self.port, + gateway=self.gateway, + forward_agent=self.forward_agent, + connect_timeout=self.connect_timeout, + connect_kwargs=self.connect_kwargs, + inline_ssh_env=self.inline_ssh_env, + timeout_execute=self.timeout_execute, + ) + + +class LocalHostConfig(BaseModel): + host_type: Literal["local"] = "local" + host_id: str + timeout_execute: int = 60 + + class Config: + extra = Extra.forbid + + def get_host(self) -> BaseHost: + return LocalHost(timeout_execute=self.timeout_execute) + + +HostConfig = Annotated[ + LocalHostConfig | RemoteHostConfig, Field(discriminator="host_type") +] + + +class ExecutionConfig(BaseModel): + exec_config_id: str + modules: list[str] | None = None + export: dict[str, str] | None = None + pre_run: str | None + post_run: str | None + + class Config: + extra = Extra.forbid + + +class Project(BaseModel): + name: str + unique_id: str + base_dir: str | None = None + tmp_dir: str | None = None + log_dir: str | None = None + log_level: int = logging.INFO + runner: RunnerOptions = Field(default_factory=RunnerOptions) + hosts: list[HostConfig] = Field(default_factory=list) + machines: list[Machine] = Field(default_factory=list) + run_db: LaunchPadConfig = Field(default_factory=LaunchPadConfig) + exec_config: list[ExecutionConfig] = Field(default_factory=list) + jobstore: dict = Field(default_factory=lambda: dict(DEFAULT_JOBSTORE)) + + @classmethod + def from_uuid_id(cls, **kwargs): + unique_id = str(uuid4()) + return cls(unique_id=unique_id, **kwargs) + + def get_machines_dict(self) -> dict[str, Machine]: + return {m.machine_id: m for m in self.machines} + + def get_machines_ids(self) -> list[str]: + return [m.machine_id for m in self.machines] + + def get_hosts_config_dict(self) -> dict[str, LocalHostConfig | RemoteHostConfig]: + return {h.host_id: h for h in self.hosts} + + def get_hosts_ids(self) -> list[str]: + return [h.host_id for h in self.hosts] + + def get_hosts_dict(self) -> dict[str, BaseHost]: + return {h.host_id: h.get_host() for h in self.hosts} + + def get_exec_config_dict(self) -> dict[str, ExecutionConfig]: + return {ec.exec_config_id: ec for ec in self.exec_config} + + def get_exec_config_ids(self) -> list[str]: + return [ec.exec_config_id for ec in self.exec_config] + + def get_jobstore(self) -> JobStore | None: + if not self.jobstore: + return None + elif self.jobstore.get("@class") == "JobStore": + return JobStore.from_dict(self.jobstore) + else: + return JobStore.from_dict_spec(self.jobstore) + + def get_launchpad(self) -> RemoteLaunchPad: + return RemoteLaunchPad(**self.run_db.dict()) + + @validator("base_dir", always=True) + def check_base_dir(cls, base_dir: str, values: dict) -> str: + """ + Validator to set the default of base_dir based on the project name + """ + if not base_dir: + return str(Path(SETTINGS.projects_folder, values["name"])) + return base_dir + + @validator("tmp_dir", always=True) + def check_tmp_dir(cls, tmp_dir: str, values: dict) -> 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 tmp_dir + + @validator("log_dir", always=True) + def check_log_dir(cls, log_dir: str, values: dict) -> str: + """ + Validator to set the default of tmp_dir based on the base_dir + """ + if not log_dir: + return str(Path(values["base_dir"], "log")) + return log_dir + + @validator("machines", always=True) + def check_machines(cls, machines: list[Machine], values: dict) -> list[Machine]: + if "hosts" not in values: + raise ValueError("hosts should be defined to define a Machine") + + hosts_ids = [h.host_id for h in values["hosts"]] + mids: list[Machine] = [] + for m in machines: + if m.machine_id in mids: + raise ValueError(f"Repeated Machine with id {m.machine_id}") + if m.host_id not in hosts_ids: + raise ValueError( + f"Host with id {m.host_id} defined in Machine {m.machine_id} is not defined" + ) + return machines + + @validator("hosts", always=True) + def check_hosts(cls, hosts: list[HostConfig], values: dict) -> list[HostConfig]: + hids: list[HostConfig] = [] + for h in hosts: + if h.host_id in hids: + raise ValueError(f"Repeated Host with id {h.host_id}") + + return hosts + + @validator("exec_config", always=True) + def check_exec_config( + cls, exec_config: list[ExecutionConfig], values: dict + ) -> list[ExecutionConfig]: + ecids: list[ExecutionConfig] = [] + for ec in exec_config: + if ec.exec_config_id in ecids: + raise ValueError(f"Repeated Host with id {ec.exec_config_id}") + + return exec_config + + @validator("jobstore", always=True) + def check_jobstore(cls, jobstore: dict, values: dict) -> dict: + if jobstore: + try: + if jobstore.get("@class") == "JobStore": + JobStore.from_dict(jobstore) + else: + JobStore.from_dict_spec(jobstore) + except Exception as e: + raise ValueError( + f"error while converting jobstore to JobStore. Error: {traceback.format_exc()}" + ) from e + return jobstore + + class Config: + extra = Extra.forbid + class ConfigError(Exception): pass diff --git a/src/jobflow_remote/config/manager.py b/src/jobflow_remote/config/manager.py index 8996dde1..49ba37f3 100644 --- a/src/jobflow_remote/config/manager.py +++ b/src/jobflow_remote/config/manager.py @@ -1,282 +1,222 @@ from __future__ import annotations +import glob +import logging +import os import shutil +import traceback +from collections import namedtuple from pathlib import Path +import tomlkit +from jobflow import JobStore from monty.os import makedirs_p from monty.serialization import dumpfn, loadfn from jobflow_remote import SETTINGS -from jobflow_remote.config.entities import ConfigError, Machine, Project, ProjectsData -from jobflow_remote.fireworks.launchpad import RemoteLaunchPad +from jobflow_remote.config.entities import ( + ConfigError, + ExecutionConfig, + LaunchPadConfig, + LocalHostConfig, + Machine, + Project, + RemoteHostConfig, +) from jobflow_remote.remote.host.base import BaseHost +from jobflow_remote.utils.data import deep_merge_dict, remove_none + +logger = logging.getLogger(__name__) + +ProjectData = namedtuple("ProjectData", ["filepath", "project", "ext"]) class ConfigManager: - projects_filename = "projects.json" - machines_filename = "machines.json" - jobflow_settings_filename = "jobflow.json" - launchpad_filename = "launchpad.json" - log_folder = "logs" + projects_ext = ["json", "yaml", "toml"] def __init__(self): self.projects_folder = Path(SETTINGS.projects_folder) makedirs_p(self.projects_folder) - self.projects_data = self._load_projects_data() - - @property - def projects_config_filepath(self) -> Path: - return self.projects_folder / self.projects_filename + self.projects_data = self.load_projects_data() @property - def projects(self) -> dict[str, Project]: - return dict(self.projects_data.projects) - - @property - def hosts(self) -> dict[str, BaseHost]: - return dict(self.projects_data.hosts) - - def _base_get_project_path( - self, subpath: str | Path, project: str | Project | None = None - ): - if isinstance(project, Project): - project = project.name - project = self.load_project(project) - return Path(project.folder) / subpath - - def get_machines_config_filepath( - self, project: str | Project | None = None - ) -> Path: - return self._base_get_project_path(self.machines_filename, project) - - def get_jobflow_settings_filepath( - self, project: str | Project | None = None - ) -> Path: - return self._base_get_project_path(self.jobflow_settings_filename, project) - - def get_launchpad_filepath(self, project: str | Project | None = None) -> Path: - return self._base_get_project_path(self.launchpad_filename, project) - - def get_logs_folder_path(self, project: str | Project | None = None) -> Path: - return self._base_get_project_path(self.log_folder, project) - - def _load_projects_data(self) -> ProjectsData: - filepath = self.projects_config_filepath - if not Path(filepath).exists(): - pd = ProjectsData() - else: - pd = loadfn(filepath) + def projects(self): + return {name: pd.project for name, pd in self.projects_data.items()} + + def load_projects_data(self) -> dict[str, ProjectData]: + projects_data: dict[str, ProjectData] = {} + for ext in self.projects_ext: + for filepath in glob.glob(str(self.projects_folder / f"*.{ext}")): + try: + if ext in ["json", "yaml"]: + d = loadfn(filepath) + else: + with open(filepath) as f: + d = tomlkit.parse(f.read()) + project = Project.parse_obj(d) + except Exception: + logger.warning( + f"File {filepath} could not be parsed as a Project. Error: {traceback.format_exc()}" + ) + continue + if project.name in projects_data: + msg = f"Two projects with the same name '{project.name}' have been defined: {filepath}, {projects_data[project.name].filepath}" + raise ConfigError(msg) + projects_data[project.name] = ProjectData(filepath, project, ext) - return pd + return projects_data - def load_project(self, project_name: str | None = None) -> Project: + def get_project_data(self, project_name: str | None) -> ProjectData: + project_name = project_name or SETTINGS.project if not project_name: - return self.load_current_project() - - pd = self.projects_data - try: - return pd.projects[project_name] - except ValueError: raise ConfigError( - f"No project with name {project_name} present in the configuration" + "A project name should be defined at least in the config to be loaded" ) - def load_project_from_id(self, project_id: str) -> Project: - pd = self.projects_data - for p in pd.projects.values(): - if p.project_id == project_id: - return p + return self.projects_data[project_name] - raise ConfigError( - f"No project with id {project_id} present in the configuration" - ) + def get_project(self, project_name: str | None) -> Project: + return self.get_project_data(project_name).project - def load_default_project(self) -> Project: - if not self.projects_data.default_project_name: - raise ConfigError("default project has not been defined") + def dump_project(self, project_data: ProjectData): + d = project_data.project.dict() + if project_data.ext in ["json", "yaml"]: + dumpfn(d, project_data.filepath) + elif project_data.ext == "toml": + d = remove_none(d) + with open(project_data.filepath, "w") as f: + tomlkit.dump(d, f) - try: - return self.projects_data.projects[self.projects_data.default_project_name] - except ValueError: - raise ConfigError( - f"Could not find the project {self.projects_data.default_project_name}" - ) - - def load_current_project(self) -> Project: - project_name = ( - SETTINGS.current_project or self.projects_data.default_project_name - ) - if not project_name: - raise ConfigError( - "current project and default project have not been defined" - ) - - try: - return self.projects_data.projects[project_name] - except ValueError: - raise ConfigError(f"Could not find the project {project_name}") - - def dump_projects_data(self, projects_data: ProjectsData | None = None): - projects_data = projects_data or self.projects_data - makedirs_p(self.projects_folder) - dumpfn(projects_data, self.projects_config_filepath, indent=2) - self.projects_data = projects_data - - def create_project(self, project: Project): - if project.name in self.projects_data.projects: + def create_project(self, project: Project, ext="yaml"): + if project.name in self.projects_data: raise ConfigError(f"Project with name {project.name} already exists") - self.projects_data.projects[project.name] = project - if not project.folder: - project_folder = str(self.projects_folder / project.name) - project.folder = project_folder - if not project.folder_tmp: - tmp_folder = str(Path(project.folder) / "tmp_files") - project.folder_tmp = tmp_folder - - makedirs_p(project.folder) - makedirs_p(project.folder_tmp) - makedirs_p(self.get_logs_folder_path(project.name)) - self.dump_projects_data(self.projects_data) + + makedirs_p(project.base_dir) + makedirs_p(project.tmp_dir) + makedirs_p(project.log_dir) + filepath = self.projects_folder / f"{project.name}.{ext}" + project_data = ProjectData(filepath, project, ext) + self.dump_project(project_data) + self.projects_data[project.name] = project_data def remove_project(self, project_name: str): - if project_name not in self.projects_data.projects: + if project_name not in self.projects_data: return - project = self.projects_data.projects.pop(project_name) - shutil.rmtree(project.folder, ignore_errors=True) - shutil.rmtree(project.folder_tmp, ignore_errors=True) - - def set_default_project(self, project: str | Project): - if isinstance(project, Project): - project = project.name - - if project not in self.projects_data.projects: - raise ConfigError( - f"Cannot set current project as no project named {project} has been defined" - ) - - self.projects_data.default_project_name = project - - self.dump_projects_data() - - def load_machines_data(self, project_name: str | None = None) -> dict[str, Machine]: - filepath = self.get_machines_config_filepath(project_name) - if not filepath.exists(): - return {} - - return loadfn(filepath) - - def dump_machines_data( - self, machines_data: dict, project_name: str | None = None - ) -> None: - filepath = self.get_machines_config_filepath(project_name) - dumpfn(machines_data, filepath, indent=2) - - def dump_jobflow_settings_data( - self, settings: dict, project_name: str | None = None - ) -> None: - filepath = self.get_jobflow_settings_filepath(project_name) - dumpfn(settings, filepath, indent=2) - - def dump_launchpad_data( - self, config: dict, project_name: str | None = None - ) -> None: - filepath = self.get_launchpad_filepath(project_name) - dumpfn(config, filepath, indent=2) + project_data = self.projects_data.pop(project_name) + shutil.rmtree(project_data.project.base_dir, ignore_errors=True) + os.remove(project_data.filepath) + + def update_project(self, config: dict, project_name: str): + project_data = self.projects_data.pop(project_name) + proj_dict = project_data.project.dict() + new_project = Project.parse_obj(deep_merge_dict(proj_dict, config)) + project_data = ProjectData(project_data.filepath, new_project, project_data.ext) + self.dump_project(project_data) + self.projects_data[project_data.project.name] = project_data def set_machine( self, machine: Machine, project_name: str | None = None, replace: bool = False ): - machines_data = self.load_machines_data(project_name) + project_data = self.get_project_data(project_name) + machines_data = project_data.project.get_machines_dict() if not replace and machine.machine_id in machines_data: raise ConfigError( f"Machine with id {machine.machine_id} is already defined" ) + if machine.host_id not in project_data.project.get_hosts_ids(): + raise ConfigError(f"host {machine.host_id} is not defined") machines_data[machine.machine_id] = machine - if machine.host_id not in self.hosts: - raise ValueError(f"host {machine.host_id} is not defined") - self.dump_machines_data(machines_data) + project_data.project.machines = list(machines_data.values()) + + self.dump_project(project_data) def remove_machine(self, machine_id: str, project_name: str | None = None): - machines_data = self.load_machines_data(project_name) + project_data = self.get_project_data(project_name) + machines_data = project_data.project.get_machines_dict() machines_data.pop(machine_id) - self.dump_machines_data(machines_data) + project_data.project.machines = list(machines_data.values()) + self.dump_project(project_data) def load_machine(self, machine_id: str, project_name: str | None = None) -> Machine: - machines_data = self.load_machines_data(project_name) + project = self.get_project(project_name) + machines_data = project.get_machines_dict() if machine_id not in machines_data: raise ConfigError(f"Machine with id {machine_id} is not defined") return machines_data[machine_id] - def set_host(self, host_id: str, host: BaseHost, replace: bool = False): - if not replace and host_id in self.projects_data.hosts: - raise ConfigError(f"Host with id {host_id} is already defined") - self.projects_data.hosts[host_id] = host - self.dump_projects_data() - - def remove_host(self, host_id: str): - for project_name in self.projects.keys(): - for machine_id, machine in self.load_machines_data(project_name).items(): - if machine.host_id == host_id: - raise ValueError( - f"Host is used in the {machine_id} machine. Will not be removed." - ) - self.projects_data.hosts.pop(host_id) - self.dump_projects_data() - - def load_host(self, host_id: str) -> BaseHost: - if host_id not in self.projects_data.hosts: + def set_host( + self, + host: LocalHostConfig | RemoteHostConfig, + project_name: str | None = None, + replace: bool = False, + ): + project_data = self.get_project_data(project_name) + hosts_data = project_data.project.get_hosts_config_dict() + if not replace and host.host_id in hosts_data: + raise ConfigError(f"Host with id {host.host_id} is already defined") + if any(host.host_id == m.host_id for m in project_data.project.machines): + raise ConfigError( + f"host {host.host_id} is used in one of the Machines, will not remove." + ) + hosts_data[host.host_id] = host + project_data.project.hosts = list(hosts_data.values()) + self.dump_project(project_data) + + def remove_host(self, host_id: str, project_name: str | None = None): + project_data = self.get_project_data(project_name) + hosts_data = project_data.project.get_hosts_config_dict() + hosts_data.pop(host_id) + project_data.project.hosts = list(hosts_data.values()) + self.dump_project(project_data) + + def load_host(self, host_id: str, project_name: str | None = None) -> BaseHost: + project = self.get_project(project_name) + hosts_data = project.get_hosts_config_dict() + if host_id not in hosts_data: raise ConfigError(f"Host with id {host_id} is not defined") + return hosts_data[host_id].get_host() - def set_jobflow_settings( - self, settings: dict, project_name: str | None = None, update: bool = False - ): - project = self.load_project(project_name) - filepath = self.get_jobflow_settings_filepath(project) - settings["CONFIG_FILE"] = str(filepath) - if update and filepath.exists(): - old = loadfn(filepath) - old.update(settings) - settings = old - - self.dump_jobflow_settings_data(settings, project.name) - - def load_jobflow_settings(self, project_name: str): - filepath = self.get_jobflow_settings_filepath(project_name) - if not filepath.exists(): - return {} - else: - return loadfn(filepath) - - def activate_jobflow_settings(self, project_name: str | None = None): - project_settings = self.load_jobflow_settings(project_name) - from jobflow import SETTINGS - - for k, v in project_settings.items(): - setattr(SETTINGS, k, v) - - def activate_project(self, project_name: str): - self.activate_jobflow_settings(project_name) - - def set_launchpad_config( - self, config: dict, project_name: str | None = None, update: bool = False + def set_run_db(self, config: LaunchPadConfig, project_name: str | None = None): + project_data = self.get_project_data(project_name) + project_data.project.run_db = config + + self.dump_project(project_data) + + def set_jobstore(self, jobstore: JobStore, project_name: str | None = None): + project_data = self.get_project_data(project_name) + project_data.project.jobstore = jobstore.as_dict() + self.dump_project(project_data) + + def set_exec_config( + self, + exec_config: ExecutionConfig, + project_name: str | None = None, + replace: bool = False, ): - project = self.load_project(project_name) - filepath = self.get_launchpad_filepath(project) - if update and filepath.exists(): - old = loadfn(filepath) - old.update(config) - config = old - - self.dump_launchpad_data(config, project.name) - - def load_launchpad_config(self, project_name: str | None = None): - filepath = self.get_launchpad_filepath(project_name) - if not filepath.exists(): - return {} - else: - return loadfn(filepath, cls=None) - - def load_launchpad(self, project_name: str | None = None): - # from fireworks import LaunchPad - config = self.load_launchpad_config(project_name) - return RemoteLaunchPad(**config) + project_data = self.get_project_data(project_name) + exec_config_data = project_data.project.get_exec_config_dict() + if not replace and exec_config.exec_config_id in exec_config_data: + raise ConfigError( + f"Host with id {exec_config.exec_config_id} is already defined" + ) + exec_config_data[exec_config.exec_config_id] = exec_config + project_data.project.hosts = list(exec_config_data.values()) + self.dump_project(project_data) + + def remove_exec_config(self, exec_config_id: str, project_name: str | None = None): + project_data = self.get_project_data(project_name) + exec_config_data = project_data.project.get_exec_config_dict() + exec_config_data.pop(exec_config_id) + project_data.project.hosts = list(exec_config_data.values()) + self.dump_project(project_data) + + def load_exec_config( + self, exec_config_id: str, project_name: str | None = None + ) -> ExecutionConfig: + project = self.get_project(project_name) + exec_config_data = project.get_exec_config_dict() + if exec_config_id not in exec_config_data: + raise ConfigError( + f"ExecutionConfig with id {exec_config_id} is not defined" + ) + return exec_config_data[exec_config_id] diff --git a/src/jobflow_remote/config/settings.py b/src/jobflow_remote/config/settings.py index b0847d68..44360210 100644 --- a/src/jobflow_remote/config/settings.py +++ b/src/jobflow_remote/config/settings.py @@ -2,12 +2,22 @@ from pathlib import Path -from pydantic import BaseSettings +from pydantic import BaseSettings, validator class JobflowRemoteSettings(BaseSettings): projects_folder: str = Path("~/.jfremote").expanduser().as_posix() - current_project: str = None + daemon_folder: str = "" + project: str = None + + @validator("daemon_folder", always=True) + def get_daemon_folder(cls, daemon_folder: str, values: dict) -> str: + """ + Validator to set the default of daemon_folder based on projects_folder + """ + if daemon_folder == "" and "projects_folder" in values: + return str(Path(values["projects_folder"], "daemon")) + return daemon_folder class Config: """Pydantic config settings.""" diff --git a/src/jobflow_remote/fireworks/convert.py b/src/jobflow_remote/fireworks/convert.py index 76aaf1ee..75863811 100644 --- a/src/jobflow_remote/fireworks/convert.py +++ b/src/jobflow_remote/fireworks/convert.py @@ -5,6 +5,7 @@ from fireworks import Firework, Workflow from qtoolkit.core.data_objects import QResources +from jobflow_remote.config.entities import ExecutionConfig from jobflow_remote.fireworks.tasks import RemoteJobFiretask if typing.TYPE_CHECKING: @@ -19,8 +20,8 @@ def flow_to_workflow( flow: jobflow.Flow | jobflow.Job | list[jobflow.Job], machine: str, store: jobflow.JobStore | None = None, - exports: dict | None = None, - qtk_options: dict | QResources | None = None, + exec_config: ExecutionConfig = None, + resources: dict | QResources | None = None, **kwargs, ) -> Workflow: """ @@ -41,9 +42,10 @@ def flow_to_workflow( 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. - exports - pairs of key-values that will be exported in the submission script - qtk_options + exec_config: ExecutionConfig + the options to set before the execution of the job in the submission script. + In addition to those defined in the Machine. + resources: Dict or QResources information passed to qtoolkit to require the resources for the submission to the queue. **kwargs @@ -69,8 +71,8 @@ def flow_to_workflow( store=store, parents=parents, parent_mapping=parent_mapping, - exports=exports, - qtk_options=qtk_options, + exec_config=exec_config, + resources=resources, ) fireworks.append(fw) @@ -83,8 +85,8 @@ def job_to_firework( store: jobflow.JobStore | None = None, parents: Sequence[str] | None = None, parent_mapping: dict[str, Firework] | None = None, - exports: dict | None = None, - qtk_options: dict | QResources | None = None, + exec_config: ExecutionConfig = None, + resources: dict | QResources | None = None, **kwargs, ) -> Firework: """ @@ -122,7 +124,11 @@ def job_to_firework( raise ValueError("Both or neither of parents and parent_mapping must be set.") task = RemoteJobFiretask( - job=job, store=store, machine=machine, exports=exports, qtk_options=qtk_options + job=job, + store=store, + machine=machine, + resources=resources, + exec_config=exec_config, ) job_parents = None diff --git a/src/jobflow_remote/fireworks/launcher.py b/src/jobflow_remote/fireworks/launcher.py index bc40bfbb..489d4fe0 100644 --- a/src/jobflow_remote/fireworks/launcher.py +++ b/src/jobflow_remote/fireworks/launcher.py @@ -39,8 +39,6 @@ def checkout_remote( return None, None logger.info(f"reserved FW with fw_id: {fw.fw_id}") - fw.tasks[0].get("job").uuid - rlpad.add_remote_run(launch_id, fw) return fw, launch_id @@ -53,7 +51,7 @@ def checkout_remote( f"Un-reserving FW with fw_id, launch_id: {fw.fw_id}, {launch_id}" ) rlpad.lpad.cancel_reservation(launch_id) - rlpad.forget_remote(launch_id, rlpad) + rlpad.forget_remote(launch_id) except Exception: logger.exception(f"Error unreserving FW with fw_id {fw.fw_id}") diff --git a/src/jobflow_remote/fireworks/tasks.py b/src/jobflow_remote/fireworks/tasks.py index 7227a877..775c30de 100644 --- a/src/jobflow_remote/fireworks/tasks.py +++ b/src/jobflow_remote/fireworks/tasks.py @@ -23,15 +23,16 @@ class RemoteJobFiretask(FiretaskBase): should be set before the Task is executed remotely. machine: Str The id of the Machine where the calculation will be submitted - exports: Dict - pairs of key-values that will be exported in the submission script - qtk_options: Dict or QResources + exec_config: ExecutionConfig + the options to set before the execution of the job in the submission script. + In addition to those defined in the Machine. + resources: Dict or QResources information passed to qtoolkit to require the resources for the submission to the queue. """ required_params = ["job", "store", "machine"] - optional_params = ["exports", "qtk_options"] + optional_params = ["exec_config", "resources"] def run_task(self, fw_spec): """Run the job and handle any dynamic firework submissions.""" diff --git a/src/jobflow_remote/remote/queue.py b/src/jobflow_remote/remote/queue.py index 5a82315f..f925a9e8 100644 --- a/src/jobflow_remote/remote/queue.py +++ b/src/jobflow_remote/remote/queue.py @@ -59,7 +59,8 @@ def get_submission_script( work_dir: str | Path | None = None, pre_run: str | list[str] | None = None, post_run: str | list[str] | None = None, - exports: dict | None = None, + export: dict | None = None, + modules: list[str] | None = None, ) -> str: """ """ commands_list = [] @@ -67,8 +68,10 @@ def get_submission_script( commands_list.append(change_dir) if pre_run := self.get_pre_run(pre_run): commands_list.append(pre_run) - if exports_str := self.get_exports(exports): - commands_list.append(exports_str) + if export_str := self.get_export(export): + commands_list.append(export_str) + if modules_str := self.get_modules(modules): + commands_list.append(modules_str) if run_commands := self.get_run_commands(commands): commands_list.append(run_commands) if post_run := self.get_post_run(post_run): @@ -85,7 +88,7 @@ def get_pre_run(self, pre_run: str | list[str] | None) -> str: return "\n".join(pre_run) return pre_run - def get_exports(self, exports: dict | None) -> str: + def get_export(self, exports: dict | None) -> str: if not exports: return None exports_str = [] @@ -93,6 +96,14 @@ def get_exports(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: + if not modules: + return None + modules_str = [] + for m in modules: + modules_str.append(f"module load {m}") + return "\n".join(modules_str) + def get_run_commands(self, commands) -> str: if isinstance(commands, str): return commands @@ -113,7 +124,8 @@ def submit( work_dir=None, pre_run: str | list[str] | None = None, post_run: str | list[str] | None = None, - exports: dict | None = None, + export: dict | None = None, + modules: list[str] | None = None, script_fname="submit.sh", create_submit_dir=False, timeout: int | None = None, @@ -124,7 +136,8 @@ def submit( work_dir=work_dir, pre_run=pre_run, post_run=post_run, - exports=exports, + export=export, + modules=modules, ) if create_submit_dir and work_dir: @@ -182,4 +195,6 @@ def from_machine( if isinstance(machine, str): machine = config_manager.load_machine(machine, project_name) host = config_manager.load_host(machine.host_id) - return cls(machine.scheduler_io, host, timeout_exec=machine.queue_exec_timeout) + return cls( + machine.get_scheduler_io(), host, timeout_exec=machine.queue_exec_timeout + ) diff --git a/src/jobflow_remote/run/runner.py b/src/jobflow_remote/run/runner.py index c5504cbd..e7ccc39d 100644 --- a/src/jobflow_remote/run/runner.py +++ b/src/jobflow_remote/run/runner.py @@ -19,10 +19,9 @@ from jobflow_remote.config.entities import ( ConfigError, + ExecutionConfig, Machine, Project, - ProjectOptions, - ProjectsData, RunnerOptions, ) from jobflow_remote.config.manager import ConfigManager @@ -39,15 +38,6 @@ logger = logging.getLogger(__name__) -# lpad = LaunchPad.auto_load() -# fworker = FWorker() - -# c = RemoteConfig(host="slurmtest", root_dir="/data") -# rh = RemoteHost(c) -# queue = QueueManager(SlurmIO(get_job_executable="scontrol"), host=rh) -# -# launch_base_dir = "/data/run_jobflow" - JobFWData = namedtuple("JobFWData", ["fw", "task", "job", "store", "machine", "host"]) @@ -58,32 +48,21 @@ def __init__(self, project_name: str | None = None, log_level: int | None = None self.runner_id: str = str(uuid.uuid4()) self.config_manager: ConfigManager = ConfigManager() self.project_name = project_name - self.project: Project = self.config_manager.load_project(project_name) - self.rlpad: RemoteLaunchPad = self.config_manager.load_launchpad(project_name) + self.project: Project = self.config_manager.get_project(project_name) + self.rlpad: RemoteLaunchPad = self.project.get_launchpad() self.fworker: FWorker = FWorker() - self.machines: dict[str, Machine] = {} + self.machines: dict[str, Machine] = self.project.get_machines_dict() + self.hosts: dict[str, BaseHost] = self.project.get_hosts_dict() self.queue_managers: dict = {} log_level = log_level if log_level is not None else self.project.log_level initialize_runner_logger( - log_folder=self.config_manager.get_logs_folder_path(project_name), + log_folder=self.project.log_dir, level=log_level, ) - @property - def projects_data(self) -> ProjectsData: - return self.config_manager.projects_data - - @property - def hosts(self) -> dict[str, BaseHost]: - return self.projects_data.hosts - @property def runner_options(self) -> RunnerOptions: - return self.project.runner_options - - @property - def project_options(self) -> ProjectOptions: - return self.project.options + return self.project.runner def handle_signal(self, signum, frame): logger.info(f"Received signal: {signum}") @@ -91,8 +70,8 @@ def handle_signal(self, signum, frame): def get_machine(self, machine_id: str) -> Machine: if machine_id not in self.machines: - self.machines[machine_id] = self.config_manager.load_machine( - machine_id, project_name=self.project_name + raise ConfigError( + f"No machine {machine_id} is defined in project {self.project_name}" ) return self.machines[machine_id] @@ -100,7 +79,7 @@ def get_queue_manager(self, machine_id: str) -> QueueManager: if machine_id not in self.queue_managers: machine = self.get_machine(machine_id) self.queue_managers[machine_id] = QueueManager( - machine.scheduler_io, self.hosts[machine.host_id] + machine.get_scheduler_io(), self.hosts[machine.host_id] ) return self.queue_managers[machine_id] @@ -238,7 +217,7 @@ def lock_and_update( else: step_attempts = doc["step_attempts"] fail_now = ( - fail_now or step_attempts >= self.project_options.max_step_attempts + fail_now or step_attempts >= self.runner_options.max_step_attempts ) if fail_now: lock.update_on_release = { @@ -250,7 +229,7 @@ def lock_and_update( } else: step_attempts += 1 - delta = self.project_options.get_delta_retry(step_attempts) + delta = self.runner_options.get_delta_retry(step_attempts) retry_time_limit = datetime.utcnow() + timedelta(seconds=delta) lock.update_on_release = { "$set": { @@ -303,21 +282,26 @@ def submit(self, doc): remote_path = get_job_path(job.uuid, fw_job_data.machine.work_dir) - fw_job_data.machine.pre_run - fw_job_data.machine.post_run - script_commands = ["rlaunch singleshot --offline"] machine = fw_job_data.machine queue_manager = self.get_queue_manager(machine.machine_id) - qtk_options = fw_job_data.task.get("qtk_options") or machine.default_qtk_options - exports = fw_job_data.task.get("exports") + resources = fw_job_data.task.get("resources") or machine.resources + exec_config = fw_job_data.task.get("exec_config") or ExecutionConfig( + exec_config_id="empty_config" + ) + pre_run = machine.pre_run or "" + pre_run += exec_config.pre_run or "" + post_run = machine.post_run or "" + post_run += exec_config.post_run or "" + submit_result = queue_manager.submit( commands=script_commands, - pre_run=machine.pre_run, - post_run=machine.post_run, - options=qtk_options, - exports=exports, + 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, ) @@ -341,7 +325,7 @@ def download(self, doc): job = fw_job_data.job remote_path = get_job_path(job.uuid, fw_job_data.machine.work_dir) - loca_base_dir = Path(self.project.folder_tmp, "download") + loca_base_dir = Path(self.project.tmp_dir, "download") local_path = get_job_path(job.uuid, loca_base_dir) makedirs_p(local_path) @@ -372,7 +356,7 @@ def complete_launch(self, doc): logger.debug(f"complete launch fw_id: {doc['fw_id']}") fw_job_data = self.get_fw_data(fw_id) - loca_base_dir = Path(self.project.folder_tmp, "download") + loca_base_dir = Path(self.project.tmp_dir, "download") local_path = get_job_path(fw_job_data.job.uuid, loca_base_dir) remote_data = loadfn(Path(local_path, "FW_offline.json"), cls=None) diff --git a/src/jobflow_remote/run/submit.py b/src/jobflow_remote/run/submit.py index bdd2ca7f..79ec3cca 100644 --- a/src/jobflow_remote/run/submit.py +++ b/src/jobflow_remote/run/submit.py @@ -3,6 +3,7 @@ import jobflow from qtoolkit.core.data_objects import QResources +from jobflow_remote.config.entities import ExecutionConfig from jobflow_remote.config.manager import ConfigManager from jobflow_remote.fireworks.convert import flow_to_workflow @@ -12,8 +13,8 @@ def submit_flow( machine: str, store: jobflow.JobStore | None = None, project: str | None = None, - exports: dict | None = None, - qtk_options: dict | QResources | None = None, + exec_config: ExecutionConfig | None = None, + resources: dict | QResources | None = None, ): """ Submit a flow for calculation to the selected Machine. @@ -35,20 +36,22 @@ def submit_flow( project the name of the project to which the Flow should be submitted. If None the current project will be used. - exports - pairs of key-values that will be exported in the submission script - qtk_options + exec_config: ExecutionConfig + the options to set before the execution of the job in the submission script. + In addition to those defined in the Machine. + resources: Dict or QResources information passed to qtoolkit to require the resources for the submission to the queue. """ wf = flow_to_workflow( - flow, machine=machine, store=store, exports=exports, qtk_options=qtk_options + flow, machine=machine, store=store, exec_config=exec_config, resources=resources ) config_manager = ConfigManager() # try to load the machine to check that the project and the machine are well defined - machine = config_manager.load_machine(machine_id=machine, project_name=project) + _ = config_manager.load_machine(machine_id=machine, project_name=project) - rlpad = config_manager.load_launchpad(project) + proj_obj = config_manager.get_project(project) + rlpad = proj_obj.get_launchpad() rlpad.add_wf(wf) diff --git a/src/jobflow_remote/utils/data.py b/src/jobflow_remote/utils/data.py index 8f176783..dd33ba9b 100644 --- a/src/jobflow_remote/utils/data.py +++ b/src/jobflow_remote/utils/data.py @@ -47,6 +47,19 @@ def deep_merge_dict( return d1 +def remove_none(obj): + if isinstance(obj, (list, tuple, set)): + return type(obj)(remove_none(x) for x in obj if x is not None) + elif isinstance(obj, dict): + return type(obj)( + (remove_none(k), remove_none(v)) + for k, v in obj.items() + if k is not None and v is not None + ) + else: + return obj + + def uuid_to_path(uuid: str, num_subdirs: int = 3, subdir_len: int = 2): u = UUID(uuid) u_hex = u.hex From a9c82abbb54ba2225247716bbd00be007e0d2489 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Wed, 24 May 2023 16:12:30 +0200 Subject: [PATCH 04/89] first daemon implementation based on supervisor --- src/jobflow_remote/config/entities.py | 12 +- src/jobflow_remote/run/daemon.py | 313 ++++++++++++++++++++++++++ 2 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 src/jobflow_remote/run/daemon.py diff --git a/src/jobflow_remote/config/entities.py b/src/jobflow_remote/config/entities.py index 40b15108..e23ea3c7 100644 --- a/src/jobflow_remote/config/entities.py +++ b/src/jobflow_remote/config/entities.py @@ -149,6 +149,7 @@ class Project(BaseModel): base_dir: str | None = None tmp_dir: str | None = None log_dir: str | None = None + daemon_dir: str | None = None log_level: int = logging.INFO runner: RunnerOptions = Field(default_factory=RunnerOptions) hosts: list[HostConfig] = Field(default_factory=list) @@ -215,12 +216,21 @@ def check_tmp_dir(cls, tmp_dir: str, values: dict) -> str: @validator("log_dir", always=True) def check_log_dir(cls, log_dir: str, values: dict) -> str: """ - Validator to set the default of tmp_dir based on the base_dir + 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 log_dir + @validator("daemon_dir", always=True) + def check_daemon_dir(cls, daemon_dir: str, values: dict) -> 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 daemon_dir + @validator("machines", always=True) def check_machines(cls, machines: list[Machine], values: dict) -> list[Machine]: if "hosts" not in values: diff --git a/src/jobflow_remote/run/daemon.py b/src/jobflow_remote/run/daemon.py new file mode 100644 index 00000000..acf62bdd --- /dev/null +++ b/src/jobflow_remote/run/daemon.py @@ -0,0 +1,313 @@ +import logging +import subprocess +from enum import Enum +from pathlib import Path +from string import Template + +import psutil +from supervisor import childutils +from supervisor.states import RUNNING_STATES, STOPPED_STATES, ProcessStates +from supervisor.xmlrpc import Faults + +logger = logging.getLogger(__name__) + + +supervisord_conf_str = """ +[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=info +pidfile=$pid_file +nodaemon=$nodaemon + +[supervisorctl] +serverurl=unix://$sock_file + +[program:myprogram] +priority=100 +command=TODO create executable +autostart=true +autorestart=false +numprocs=$num_procs +process_name=run_jobflow%(process_num)s +stopwaitsecs=86400 +""" + + +class DaemonError(Exception): + pass + + +class DaemonStatus(Enum): + SHUT_DOWN = "SHUT_DOWN" + STOPPED = "STOPPED" + STOPPING = "STOPPING" + RUNNING = "RUNNING" + + +class DaemonManager: + + conf_template = Template(supervisord_conf_str) + + def __init__(self, daemon_dir: str | Path, log_dir: str | Path | None = None): + self.daemon_dir = Path(daemon_dir).absolute() + self.log_dir = Path(log_dir).absolute() if log_dir else self.daemon_dir + + @property + def conf_filepath(self) -> Path: + return self.daemon_dir / "supervisord.conf" + + @property + def pid_filepath(self) -> Path: + return self.daemon_dir / "supervisord.pid" + + @property + def log_filepath(self) -> Path: + return self.log_dir / "supervisord.log" + + @property + def sock_filepath(self) -> Path: + path = self.daemon_dir / "s.sock" + if len(str(path)) > 97: + msg = f"socket path {path} is too long for UNIX systems. Set the daemon_dir value in the project configuration so that the socket path is shorter" + raise DaemonError(msg) + return path + + def clean_files(self): + self.pid_filepath.unlink(missing_ok=True) + self.sock_filepath.unlink(missing_ok=True) + + def get_interface(self): + env = { + "SUPERVISOR_SERVER_URL": f"unix://{str(self.sock_filepath)}", + "SUPERVISOR_USERNAME": "", + "SUPERVISOR_PASSWORD": "", + } + interface = childutils.getRPCInterface(env) + return interface + + def get_supervisord_pid(self) -> int | None: + pid_fp = self.pid_filepath + + if not pid_fp.is_file(): + return None + + try: + with open(pid_fp) as f: + pid = int(f.read().strip()) + except ValueError: + logger.warning(f"The pid file {pid_fp} could not be parsed") + return None + return pid + + def check_supervisord_process(self) -> bool: + pid = self.get_supervisord_pid() + + running = True + if pid is None: + running = False + + try: + process = psutil.Process(pid) + + for cmdline_element in process.cmdline(): + if cmdline_element.endswith("supervisord"): + break + else: + running = False + + if process.username() != psutil.Process().username(): + logger.warning( + f"pid {pid} is running supervisord, but belongs to a different user" + ) + running = False + except psutil.NoSuchProcess: + running = False + + if not running: + if pid is not None: + logger.warning( + f"Process with pid {pid} is not running but daemon files are present. Cleaning them up." + ) + self.clean_files() + + return running + + def check_status(self): + process_active = self.check_supervisord_process() + + if not process_active: + return DaemonStatus.SHUT_DOWN + + if not self.sock_filepath.is_socket(): + raise DaemonError( + "the supervisord process is alive, but the socket is missing" + ) + + interface = self.get_interface() + proc_info = interface.supervisor.getAllProcessInfo() + if not proc_info: + raise DaemonError( + "supervisord process is running but not daemon process is present" + ) + + if any(pi.get("state") in RUNNING_STATES for pi in proc_info): + return DaemonStatus.RUNNING + + if all(pi.get("state") in STOPPED_STATES for pi in proc_info): + return DaemonStatus.STOPPED + + if all( + pi.get("state") in (ProcessStates.STOPPED, ProcessStates.STOPPING) + for pi in proc_info + ): + return DaemonStatus.STOPPING + + raise DaemonError("Could not determine the current status of the daemon") + + def write_config(self, num_procs: int = 1, 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", + ) + with open(self.conf_filepath, "w") as f: + f.write(conf) + + def start_supervisord( + self, num_procs: int = 1, nodaemon: bool = False + ) -> str | None: + self.write_config(num_procs=num_procs, nodaemon=nodaemon) + cp = subprocess.run( + f"supervisord -c {str(self.conf_filepath)}", + shell=True, + capture_output=True, + text=True, + ) + if cp.returncode != 0: + return f"Error staring the supervisord process. stdout: {cp.stdout}. stderr: {cp.stderr}" + + # TODO check if actually started? + + return None + + def start_processes(self) -> str | None: + interface = self.get_interface() + result = interface.supervisor.startAllProcesses() + if not result: + return "No process started" + + failed = [r for r in result if r.get("status") == Faults.SUCCESS] + if len(failed) == 0: + return None + elif len(failed) != len(result): + msg = "Not all the daemon processes started correctly. Details: \n" + for f in failed: + msg += f" - {f.get('description')}\n" + return msg + + return None + + def start(self, raise_on_error: bool = False) -> bool: + status = self.check_status() + if status == DaemonStatus.RUNNING: + return True + + if status == DaemonStatus.SHUT_DOWN: + error = self.start_supervisord() + elif status == DaemonStatus.STOPPED: + error = self.start_processes() + elif status == DaemonStatus.STOPPING: + error = "Daemon process are stopping. Cannot start." + else: + error = f"Daemon status {status} could not be handled" + + if error is not None: + if raise_on_error: + raise DaemonError(error) + else: + logger.error(error) + return False + return True + + def stop(self, wait: bool = False, raise_on_error: bool = False) -> bool: + status = self.check_status() + if status in ( + DaemonStatus.STOPPED, + DaemonStatus.STOPPING, + DaemonStatus.SHUT_DOWN, + ): + return True + + if status == DaemonStatus.RUNNING: + interface = self.get_interface() + if wait: + result = interface.supervisor.stopAllProcesses() + else: + result = interface.supervisor.signalAllProcesses(15) + + error = self._verify_call_result(result, "stop", raise_on_error) + + return error is None + + raise DaemonError(f"Daemon status {status} could not be handled") + + def _verify_call_result( + self, result, action: str, raise_on_error: bool = False + ) -> str | None: + error = None + if not result: + error = f"The action {action} was not applied to the processes" + else: + failed = [r for r in result if r.get("status") == Faults.SUCCESS] + if len(failed) != len(result): + error = f"The action {action} was not applied to all the processes. Details: \n" + for f in failed: + error += f" - {f.get('description')}\n" + + if error is not None: + if raise_on_error: + raise DaemonError(error) + else: + logger.error(error) + return error + + return None + + def kill(self, raise_on_error: bool = False) -> bool: + status = self.check_status() + if status == DaemonStatus.SHUT_DOWN: + logger.info("supervisord is not running. No process is running") + return True + + if status in (DaemonStatus.RUNNING, DaemonStatus.STOPPING): + interface = self.get_interface() + result = interface.supervisor.signalAllProcesses(9) + error = self._verify_call_result(result, "kill", raise_on_error) + + return error is None + + raise DaemonError(f"Daemon status {status} could not be handled") + + def shut_down(self, raise_on_error: bool = False) -> bool: + status = self.check_status() + if status == DaemonStatus.SHUT_DOWN: + logger.info("supervisord is already shut down.") + return True + interface = self.get_interface() + try: + interface.supervisor.shutdown() + except Exception: + if raise_on_error: + raise + return False + return True From b96f9eac0738fd27c9a5b6f1a299b943b0436e6b Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Thu, 1 Jun 2023 15:18:08 +0200 Subject: [PATCH 05/89] refactor modules, JobController and CLI --- .pre-commit-config.yaml | 2 +- pyproject.toml | 1 + src/jobflow_remote/cli/__init__.py | 7 + src/jobflow_remote/cli/admin.py | 86 +++ src/jobflow_remote/cli/config.py | 64 ++ src/jobflow_remote/cli/formatting.py | 80 +++ src/jobflow_remote/cli/jf.py | 34 ++ src/jobflow_remote/cli/job.py | 118 ++++ src/jobflow_remote/cli/runner.py | 181 ++++++ src/jobflow_remote/cli/types.py | 121 ++++ src/jobflow_remote/cli/utils.py | 106 ++++ src/jobflow_remote/config/__init__.py | 10 + .../config/{entities.py => base.py} | 16 +- src/jobflow_remote/config/jobconfig.py | 28 + src/jobflow_remote/config/manager.py | 27 +- src/jobflow_remote/config/settings.py | 12 +- src/jobflow_remote/fireworks/convert.py | 20 +- src/jobflow_remote/fireworks/launchpad.py | 561 ++++++++++++++++-- src/jobflow_remote/fireworks/tasks.py | 57 +- src/jobflow_remote/{run => jobs}/__init__.py | 0 src/jobflow_remote/{run => jobs}/daemon.py | 81 ++- src/jobflow_remote/jobs/data.py | 119 ++++ src/jobflow_remote/jobs/jobcontroller.py | 294 +++++++++ src/jobflow_remote/{run => jobs}/runner.py | 176 ++++-- src/jobflow_remote/jobs/state.py | 108 ++++ src/jobflow_remote/{run => jobs}/submit.py | 25 +- src/jobflow_remote/remote/data.py | 33 +- src/jobflow_remote/remote/queue.py | 2 +- src/jobflow_remote/run/state.py | 42 -- src/jobflow_remote/utils/data.py | 13 + src/jobflow_remote/utils/db.py | 33 +- 31 files changed, 2218 insertions(+), 239 deletions(-) create mode 100644 src/jobflow_remote/cli/__init__.py create mode 100644 src/jobflow_remote/cli/admin.py create mode 100644 src/jobflow_remote/cli/config.py create mode 100644 src/jobflow_remote/cli/formatting.py create mode 100644 src/jobflow_remote/cli/jf.py create mode 100644 src/jobflow_remote/cli/job.py create mode 100644 src/jobflow_remote/cli/runner.py create mode 100644 src/jobflow_remote/cli/types.py create mode 100644 src/jobflow_remote/cli/utils.py rename src/jobflow_remote/config/{entities.py => base.py} (96%) create mode 100644 src/jobflow_remote/config/jobconfig.py rename src/jobflow_remote/{run => jobs}/__init__.py (100%) rename src/jobflow_remote/{run => jobs}/daemon.py (78%) create mode 100644 src/jobflow_remote/jobs/data.py create mode 100644 src/jobflow_remote/jobs/jobcontroller.py rename src/jobflow_remote/{run => jobs}/runner.py (73%) create mode 100644 src/jobflow_remote/jobs/state.py rename src/jobflow_remote/{run => jobs}/submit.py (77%) delete mode 100644 src/jobflow_remote/run/state.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c59ed269..3dc3212a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -62,7 +62,7 @@ repos: hooks: - id: codespell stages: [commit, commit-msg] - args: [--ignore-words-list, 'titel,statics,ba,nd,te'] + args: [--ignore-words-list, 'titel,statics,ba,nd,te,nin'] - repo: https://github.com/asottile/pyupgrade rev: v3.3.1 hooks: diff --git a/pyproject.toml b/pyproject.toml index 3ce8483f..30ac84fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ maintain = [ strict = [] [project.scripts] +jf = "jobflow_remote.cli.jf:app" [project.urls] homepage = "https://Matgenix.github.io/jobflow_remote/" diff --git a/src/jobflow_remote/cli/__init__.py b/src/jobflow_remote/cli/__init__.py new file mode 100644 index 00000000..7abab901 --- /dev/null +++ b/src/jobflow_remote/cli/__init__.py @@ -0,0 +1,7 @@ +import jobflow_remote.cli.admin +import jobflow_remote.cli.config +import jobflow_remote.cli.job +import jobflow_remote.cli.runner +from jobflow_remote.cli.jf import app + +# Import the submodules with a local app to register them to the main app diff --git a/src/jobflow_remote/cli/admin.py b/src/jobflow_remote/cli/admin.py new file mode 100644 index 00000000..5a15135e --- /dev/null +++ b/src/jobflow_remote/cli/admin.py @@ -0,0 +1,86 @@ +import typer +from rich.prompt import Confirm +from rich.text import Text +from typing_extensions import Annotated + +from jobflow_remote.cli.jf import app +from jobflow_remote.cli.utils import exit_with_error_msg, 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 = typer.Typer( + name="admin", help="Commands for administering the database", no_args_is_help=True +) +app.add_typer(app_admin) + + +@app_admin.command() +def reset( + reset_output: Annotated[ + bool, + typer.Option( + "--reset-output", + "-o", + help="Also delete all the documents in the current store", + ), + ] = False, + max_limit: Annotated[ + int, + typer.Option( + "--max-limit", + "-max", + help=( + "The database will be reset only if the number of Jobs is lower than the specified limit. 0 means no limit" + ), + ), + ] = 25, + force: Annotated[ + bool, + typer.Option( + "--force", + "-f", + help=("No confirmation will be asked before proceeding"), + ), + ] = False, +): + """ + Reset the jobflow database. + WARNING: deletes all the data. These could not be retrieved anymore. + """ + 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', 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" + ) + + if not force: + cm = ConfigManager() + project_name = cm.get_project_data().project.name + text = Text() + text.append("This operation will ", style="red") + text.append("delete all the Jobs data ", style="red bold") + text.append("for project ", style="red") + text.append(f"{project_name} ", style="red bold") + text.append("Proceed anyway?", style="red") + + confirmed = Confirm.ask(text) + if not confirmed: + raise typer.Exit(0) + with loading_spinner(False) as progress: + progress.add_task(description="Resetting the DB...", total=None) + jc = JobController() + done = jc.reset(reset_output=reset_output, max_limit=max_limit) + out_console.print(f"The database was {'' if done else 'NOT '}reset") diff --git a/src/jobflow_remote/cli/config.py b/src/jobflow_remote/cli/config.py new file mode 100644 index 00000000..566530c1 --- /dev/null +++ b/src/jobflow_remote/cli/config.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import typer +from rich.text import Text + +from jobflow_remote.cli.jf import app +from jobflow_remote.cli.utils import ( + exit_with_error_msg, + exit_with_warning_msg, + out_console, +) +from jobflow_remote.config import ConfigError, ConfigManager + +app_config = typer.Typer( + name="config", + help="Commands concerning the configuration of jobflow remote execution", + no_args_is_help=True, +) +app.add_typer(app_config) + +app_project = typer.Typer( + name="project", + help="Commands concerning the project definition", + no_args_is_help=True, +) +app_config.add_typer(app_project) + + +@app_project.command(name="list") +def list_projects(): + cm = ConfigManager() + + project_name = None + try: + project_data = cm.get_project_data() + project_name = project_data.project.name + except ConfigError: + pass + + if not cm.projects_data: + exit_with_warning_msg(f"No project available in {cm.projects_folder}") + + out_console.print(f"List of projects in {cm.projects_folder}") + for pn in sorted(cm.projects_data.keys()): + out_console.print(f" - {pn}", style="green" if pn == project_name else None) + + +@app_project.command(name="current") +def current_project(): + """ + Print the list of the project currently selected + """ + cm = ConfigManager() + + try: + project_data = cm.get_project_data() + text = Text() + text.append("The selected project is ") + text.append(project_data.project.name, style="green") + text.append(" from config file ") + text.append(project_data.filepath, style="green") + out_console.print(text) + except ConfigError as e: + exit_with_error_msg(f"Error loading the selected project: {e}") diff --git a/src/jobflow_remote/cli/formatting.py b/src/jobflow_remote/cli/formatting.py new file mode 100644 index 00000000..518cdf63 --- /dev/null +++ b/src/jobflow_remote/cli/formatting.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from dataclasses import asdict + +from monty.json import jsanitize +from rich.scope import render_scope +from rich.table import Table + +from jobflow_remote.cli.utils import Verbosity, fmt_datetime +from jobflow_remote.jobs.data import JobInfo +from jobflow_remote.jobs.state import JobState +from jobflow_remote.utils.data import remove_none + + +def get_job_info_table( + jobs_info: list[JobInfo], verbosity: Verbosity = Verbosity.NORMAL +): + table = Table(title="Jobs info") + table.add_column("DB id") + table.add_column("Name") + table.add_column("State [Remote]") + table.add_column("Job id") + + v = verbosity.to_int() + + if v >= 10: + table.add_column("Machine") + table.add_column("Last updated") + if v < 30: + table.add_column("Locked") + + if v >= 20: + table.add_column("Queue id") + table.add_column("Retry time") + table.add_column("Prev state") + + if v >= 30: + 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: + + state += f" [{ji.remote_state.name}]" + row = [str(ji.db_id), ji.name, state, ji.job_id] + if v >= 10: + row.append(ji.machine) + row.append(ji.last_updated.strftime(fmt_datetime)) + if v < 30: + row.append("*" if ji.lock_id is not None else None) + + if v >= 20: + row.append(ji.queue_job_id) + row.append( + ji.retry_time_limit.strftime(fmt_datetime) + if ji.retry_time_limit + else None + ) + row.append( + ji.remote_previous_state.name if ji.remote_previous_state else None + ) + + if v >= 30: + row.append(ji.lock_id) + row.append(ji.lock_time.strftime(fmt_datetime) if ji.lock_time else None) + + table.add_row(*row) + + return table + + +def format_job_info(job_info: JobInfo, show_none: bool = False): + d = asdict(job_info) + if not show_none: + d = remove_none(d) + + d = jsanitize(d, allow_bson=False, enum_values=True) + return render_scope(d) diff --git a/src/jobflow_remote/cli/jf.py b/src/jobflow_remote/cli/jf.py new file mode 100644 index 00000000..0be3b894 --- /dev/null +++ b/src/jobflow_remote/cli/jf.py @@ -0,0 +1,34 @@ +import typer +from typing_extensions import Annotated + +from jobflow_remote.cli.utils import exit_with_error_msg +from jobflow_remote.config import ConfigManager + +app = typer.Typer(name="jf", add_completion=False, no_args_is_help=True) + + +@app.callback() +def main( + project: Annotated[ + str, + typer.Option( + "--project", + "-p", + help="Select a project for the current execution", + is_eager=True, + ), + ] = None +): + """ + The controller CLI for jobflow-remote + """ + if project: + from jobflow_remote import SETTINGS + + cm = ConfigManager() + if project not in cm.projects_data: + exit_with_error_msg( + f"Project {project} is not defined in {SETTINGS.projects_folder}" + ) + + SETTINGS.project = project diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py new file mode 100644 index 00000000..e3e6cec6 --- /dev/null +++ b/src/jobflow_remote/cli/job.py @@ -0,0 +1,118 @@ +from datetime import datetime, timedelta + +import typer +from typing_extensions import Annotated + +from jobflow_remote.cli.formatting import format_job_info, get_job_info_table +from jobflow_remote.cli.jf import app +from jobflow_remote.cli.types import ( + days_opt, + db_id_flag_opt, + db_ids_opt, + end_date_opt, + job_id_arg, + job_ids_opt, + job_state_opt, + remote_state_opt, + start_date_opt, + verbosity_opt, +) +from jobflow_remote.cli.utils import ( + Verbosity, + check_incompatible_opt, + exit_with_error_msg, + loading_spinner, + out_console, +) +from jobflow_remote.jobs.jobcontroller import JobController + +app_job = typer.Typer( + name="job", help="Commands for managing the jobs", no_args_is_help=True +) +app.add_typer(app_job) + + +@app_job.command(name="list") +def jobs_list( + job_id: job_ids_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, + days: days_opt = None, + verbosity: verbosity_opt = Verbosity.NORMAL.value, +): + """ + 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}) + check_incompatible_opt({"end_date": end_date, "days": days}) + + jc = JobController() + + if days: + start_date = datetime.now() - timedelta(days=days) + + with loading_spinner(): + jobs_info = jc.get_jobs_info( + job_ids=job_id, + db_ids=db_id, + state=state, + remote_state=remote_state, + start_date=start_date, + end_date=end_date, + ) + + table = get_job_info_table(jobs_info, verbosity=verbosity) + + console = out_console + console.print(table) + + +@app_job.command(name="info") +def job_info( + job_id: job_id_arg, + db_id: db_id_flag_opt = False, + with_error: Annotated[ + bool, + typer.Option( + "--with-error", + "-err", + help="Also fetch and display information about errors", + ), + ] = False, + show_none: Annotated[ + bool, + typer.Option( + "--show-none", + "-n", + help="Show the data whose values are None. Usually hidden", + ), + ] = False, +): + + jc = JobController() + + if db_id: + try: + db_id_value = int(job_id) + except ValueError: + raise typer.BadParameter( + "if --db-id is selected the ID should be an integer" + ) + job_id_value = None + else: + job_id_value = job_id + db_id_value = None + + job_info = jc.get_job_info( + job_id=job_id_value, + db_id=db_id_value, + full=with_error, + ) + if not job_info: + exit_with_error_msg("No data matching the request") + + out_console.print(format_job_info(job_info, show_none=show_none)) diff --git a/src/jobflow_remote/cli/runner.py b/src/jobflow_remote/cli/runner.py new file mode 100644 index 00000000..d51f817a --- /dev/null +++ b/src/jobflow_remote/cli/runner.py @@ -0,0 +1,181 @@ +import os + +import typer +from rich.table import Table +from rich.text import Text +from typing_extensions import Annotated + +from jobflow_remote.cli.jf import app +from jobflow_remote.cli.types import log_level_opt, runner_num_procs_opt +from jobflow_remote.cli.utils import ( + LogLevel, + exit_with_error_msg, + exit_with_warning_msg, + loading_spinner, + out_console, +) +from jobflow_remote.jobs.daemon import DaemonError, DaemonManager, DaemonStatus +from jobflow_remote.jobs.runner import Runner + +app_runner = typer.Typer( + name="runner", help="Commands for handling the Runner", no_args_is_help=True +) +app.add_typer(app_runner) + + +@app_runner.command() +def run( + log_level: log_level_opt = LogLevel.INFO.value, + set_pid: Annotated[ + bool, + typer.Option( + "--set-pid", + "-pid", + help="Set the runner id to the current process pid", + ), + ] = True, +): + """ + Execute the Runner in the foreground. + Do NOT execute this to start as a daemon. + Should be used by the daemon or for testing purposes. + """ + runner_id = os.getpid() if set_pid else None + runner = Runner(log_level=log_level.to_logging(), runner_id=runner_id) + runner.run() + + +@app_runner.command() +def start( + num_procs: runner_num_procs_opt = 1, + log_level: log_level_opt = LogLevel.INFO.value, +): + """ + Start the Runner as a daemon + """ + dm = DaemonManager() + 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 + ) + except DaemonError as e: + exit_with_error_msg( + f"Error while starting the daemon: {getattr(e, 'message', e)}" + ) + + +@app_runner.command() +def stop( + wait: Annotated[ + bool, + typer.Option( + "--wait", + "-w", + help=( + "Wait until the daemon has stopped. NOTE: this may take a while if a large file is being transferred!" + ), + ), + ] = False +): + """ + Send a stop signal to the Runner processes. + Each of the Runner processes will stop when finished the task being executed. + By default, return immediately + """ + dm = DaemonManager() + with loading_spinner(False) as progress: + progress.add_task(description="Stopping the daemon...", total=None) + try: + dm.stop(wait=wait, raise_on_error=True) + except DaemonError as e: + exit_with_error_msg( + f"Error while stopping the daemon: {getattr(e, 'message', e)}" + ) + + +@app_runner.command() +def kill(): + """ + Send a kill signal to the Runner processes. + Return immediately, does not wait for processes to be killed. + """ + dm = DaemonManager() + with loading_spinner(False) as progress: + progress.add_task(description="Killing the daemon...", total=None) + try: + dm.kill(raise_on_error=True) + except DaemonError as e: + exit_with_error_msg( + f"Error while killing the daemon: {getattr(e, 'message', e)}" + ) + + +@app_runner.command() +def shut_down(): + """ + Shuts down the supervisord process. + Note that if the daemon is running it will wait for the daemon to stop. + """ + dm = DaemonManager() + with loading_spinner(False) as progress: + progress.add_task(description="Shutting down supervisor...", total=None) + try: + dm.shut_down(raise_on_error=True) + except DaemonError as e: + exit_with_error_msg( + f"Error while shutting down supervisor: {getattr(e, 'message', e)}" + ) + + +@app_runner.command() +def status(): + """ + Fetch the status of the daemon runner + """ + dm = DaemonManager() + with loading_spinner(): + try: + 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', e)}" + ) + color = { + DaemonStatus.STOPPED: "red", + DaemonStatus.STOPPING: "gold1", + DaemonStatus.SHUT_DOWN: "red", + DaemonStatus.RUNNING: "green", + }[current_status] + text = Text() + text.append("Daemon status: ") + text.append(current_status.value.lower(), style=color) + out_console.print(text) + + +@app_runner.command() +def pids(): + """ + Fetch the process ids of the daemon. + Both the supervisord process and the processing running the Runner. + """ + dm = DaemonManager() + with loading_spinner(): + try: + pids_dict = dm.get_pids() + if not pids_dict: + exit_with_warning_msg("Daemon is not running") + table = Table() + table.add_column("Process") + table.add_column("PID") + + for name, pid in pids_dict.items(): + table.add_row(name, str(pid)) + + except DaemonError as e: + exit_with_error_msg( + f"Error while stopping the daemon: {getattr(e, 'message', e)}" + ) + + out_console.print(table) diff --git a/src/jobflow_remote/cli/types.py b/src/jobflow_remote/cli/types.py new file mode 100644 index 00000000..6598f197 --- /dev/null +++ b/src/jobflow_remote/cli/types.py @@ -0,0 +1,121 @@ +from datetime import datetime +from typing import List, Optional + +import typer +from typing_extensions import Annotated + +from jobflow_remote.cli.utils import LogLevel, Verbosity +from jobflow_remote.jobs.state import JobState, RemoteState + +job_ids_opt = Annotated[ + Optional[List[str]], + typer.Option( + "--job-id", + "-jid", + help="One or more job ids (i.e. uuids)", + ), +] + + +db_ids_opt = Annotated[ + Optional[List[int]], + typer.Option( + "--db-id", + "-did", + help="One or more db ids", + ), +] + + +job_state_opt = Annotated[ + Optional[JobState], + typer.Option( + "--state", + "-s", + help="One of the Job states", + ), +] + + +remote_state_opt = Annotated[ + Optional[RemoteState], + typer.Option( + "--remote-state", + "-rs", + help="One of the remote states", + ), +] + + +start_date_opt = Annotated[ + Optional[datetime], + typer.Option( + "--start-date", + "-sdate", + help="Initial date for last update field", + ), +] + + +end_date_opt = Annotated[ + Optional[datetime], + typer.Option( + "--end-date", + "-edate", + help="Final date for last update field", + ), +] + + +days_opt = Annotated[ + Optional[int], + typer.Option( + "--days", + "-ds", + help="Last update field is in the last days", + ), +] + + +verbosity_opt = Annotated[ + Verbosity, + typer.Option( + "--verbosity", + "-v", + help="Set the verbosity of the output", + ), +] + + +log_level_opt = Annotated[ + LogLevel, + typer.Option( + "--log-level", + "-log", + help="Set the log level of the runner", + ), +] + +runner_num_procs_opt = Annotated[ + int, + typer.Option( + "--num-procs", + "-n", + help="The number of Runner processes started", + ), +] + + +job_id_arg = Annotated[str, typer.Argument(help="The ID of the job (i.e. the uuid)")] + + +db_id_flag_opt = Annotated[ + bool, + typer.Option( + "--db-id", + "-db", + help=( + "If set the id passed would be considered to be the DB id (i.e. an integer)" + ), + ), +] diff --git a/src/jobflow_remote/cli/utils.py b/src/jobflow_remote/cli/utils.py new file mode 100644 index 00000000..bc5320bb --- /dev/null +++ b/src/jobflow_remote/cli/utils.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import logging +from contextlib import contextmanager +from enum import Enum + +import typer +from rich.console import Console +from rich.progress import Progress, SpinnerColumn, TextColumn + +err_console = Console(stderr=True) +out_console = Console() + + +fmt_datetime = "%Y-%m-%d %H:%M" + + +class Verbosity(Enum): + MINIMAL = "minimal" + NORMAL = "normal" + DETAILED = "detailed" + DIAGNOSTIC = "diagnostic" + + def to_int(self) -> int: + return { + Verbosity.MINIMAL: 0, + Verbosity.NORMAL: 10, + Verbosity.DETAILED: 20, + Verbosity.DIAGNOSTIC: 30, + }[self] + + +class LogLevel(Enum): + ERROR = "error" + WARN = "warn" + INFO = "info" + DEBUG = "debug" + + def to_logging(self) -> int: + return { + LogLevel.ERROR: logging.ERROR, + LogLevel.WARN: logging.WARN, + LogLevel.INFO: logging.INFO, + LogLevel.DEBUG: logging.DEBUG, + }[self] + + +def exit_with_error_msg(message, code=1, **kwargs): + kwargs.setdefault("style", "red") + err_console.print(message, **kwargs) + raise typer.Exit(code) + + +def exit_with_warning_msg(message, code=0, **kwargs): + kwargs.setdefault("style", "gold1") + err_console.print(message, **kwargs) + raise typer.Exit(code) + + +def check_incompatible_opt(d: dict): + not_none = [] + for k, v in d.items(): + if v: + not_none.append(k) + + if len(not_none) > 1: + options_list = ", ".join(not_none) + exit_with_error_msg(f"Options {options_list} are incompatible") + + +def check_at_least_one_opt(d: dict): + not_none = [] + for k, v in d.items(): + if v: + not_none.append(k) + + if len(not_none) > 1: + options_list = ", ".join(d.keys()) + exit_with_error_msg( + f"At least one of the options {options_list} should be defined" + ) + + +def check_only_one_opt(d: dict): + not_none = [] + for k, v in d.items(): + if v: + not_none.append(k) + + if len(not_none) != 1: + options_list = ", ".join(d.keys()) + exit_with_error_msg( + f"One and only one of the options {options_list} should be defined" + ) + + +@contextmanager +def loading_spinner(processing: bool = True): + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + transient=True, + ) as progress: + if processing: + progress.add_task(description="Processing...", total=None) + yield progress diff --git a/src/jobflow_remote/config/__init__.py b/src/jobflow_remote/config/__init__.py index e69de29b..cda6b584 100644 --- a/src/jobflow_remote/config/__init__.py +++ b/src/jobflow_remote/config/__init__.py @@ -0,0 +1,10 @@ +from jobflow_remote.config.base import ( + ConfigError, + LaunchPadConfig, + LocalHostConfig, + Machine, + Project, + RemoteLaunchPad, + RunnerOptions, +) +from jobflow_remote.config.manager import ConfigManager, ProjectData diff --git a/src/jobflow_remote/config/entities.py b/src/jobflow_remote/config/base.py similarity index 96% rename from src/jobflow_remote/config/entities.py rename to src/jobflow_remote/config/base.py index e23ea3c7..d05e8249 100644 --- a/src/jobflow_remote/config/entities.py +++ b/src/jobflow_remote/config/base.py @@ -5,16 +5,10 @@ from pathlib import Path from typing import Annotated, Literal -# from dataclasses import dataclass, field -from uuid import uuid4 - from jobflow import JobStore - -# from pydantic.dataclasses import dataclass from pydantic import BaseModel, Extra, Field, validator from qtoolkit.io import BaseSchedulerIO, scheduler_mapping -from jobflow_remote import SETTINGS from jobflow_remote.fireworks.launchpad import RemoteLaunchPad from jobflow_remote.remote.host import BaseHost, LocalHost, RemoteHost @@ -133,7 +127,7 @@ def get_host(self) -> BaseHost: class ExecutionConfig(BaseModel): - exec_config_id: str + exec_config_id: str | None = None modules: list[str] | None = None export: dict[str, str] | None = None pre_run: str | None @@ -145,7 +139,6 @@ class Config: class Project(BaseModel): name: str - unique_id: str base_dir: str | None = None tmp_dir: str | None = None log_dir: str | None = None @@ -158,11 +151,6 @@ class Project(BaseModel): exec_config: list[ExecutionConfig] = Field(default_factory=list) jobstore: dict = Field(default_factory=lambda: dict(DEFAULT_JOBSTORE)) - @classmethod - def from_uuid_id(cls, **kwargs): - unique_id = str(uuid4()) - return cls(unique_id=unique_id, **kwargs) - def get_machines_dict(self) -> dict[str, Machine]: return {m.machine_id: m for m in self.machines} @@ -201,6 +189,8 @@ def check_base_dir(cls, base_dir: str, values: dict) -> 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 base_dir diff --git a/src/jobflow_remote/config/jobconfig.py b/src/jobflow_remote/config/jobconfig.py new file mode 100644 index 00000000..f6c5cd00 --- /dev/null +++ b/src/jobflow_remote/config/jobconfig.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Callable + +from jobflow import Flow, Job +from qtoolkit.core.data_objects import QResources + +from jobflow_remote.config.base import ExecutionConfig + + +def set_run_config( + flow_or_job: Flow | Job, + name_filter: str = None, + function_filter: Callable = None, + exec_config: str | ExecutionConfig | None = None, + resources: dict | QResources | None = None, +): + if not exec_config and not resources: + return + config: dict = {"manager_config": {}} + if exec_config: + config["manager_config"]["exec_config"] = exec_config + if resources: + config["manager_config"]["resources"] = resources + + flow_or_job.update_config( + config=config, name_filter=name_filter, function_filter=function_filter + ) diff --git a/src/jobflow_remote/config/manager.py b/src/jobflow_remote/config/manager.py index 49ba37f3..7ac43d7b 100644 --- a/src/jobflow_remote/config/manager.py +++ b/src/jobflow_remote/config/manager.py @@ -13,8 +13,7 @@ from monty.os import makedirs_p from monty.serialization import dumpfn, loadfn -from jobflow_remote import SETTINGS -from jobflow_remote.config.entities import ( +from jobflow_remote.config.base import ( ConfigError, ExecutionConfig, LaunchPadConfig, @@ -35,6 +34,8 @@ class ConfigManager: projects_ext = ["json", "yaml", "toml"] def __init__(self): + from jobflow_remote import SETTINGS + self.projects_folder = Path(SETTINGS.projects_folder) makedirs_p(self.projects_folder) self.projects_data = self.load_projects_data() @@ -66,16 +67,28 @@ def load_projects_data(self) -> dict[str, ProjectData]: return projects_data - def get_project_data(self, project_name: str | None) -> ProjectData: + def select_project_name(self, project_name: str | None = None) -> str: + from jobflow_remote import SETTINGS + project_name = project_name or SETTINGS.project if not project_name: - raise ConfigError( - "A project name should be defined at least in the config to be loaded" - ) + if len(self.projects_data) == 1: + project_name = next(iter(self.projects_data.keys())) + else: + raise ConfigError("A project name should be defined") + + return project_name + + def get_project_data(self, project_name: str | None = None) -> ProjectData: + + project_name = self.select_project_name(project_name) + + if project_name not in self.projects_data: + raise ConfigError(f"The selected project {project_name} does not exist") return self.projects_data[project_name] - def get_project(self, project_name: str | None) -> Project: + def get_project(self, project_name: str | None = None) -> Project: return self.get_project_data(project_name).project def dump_project(self, project_data: ProjectData): diff --git a/src/jobflow_remote/config/settings.py b/src/jobflow_remote/config/settings.py index 44360210..b4a32043 100644 --- a/src/jobflow_remote/config/settings.py +++ b/src/jobflow_remote/config/settings.py @@ -2,23 +2,13 @@ from pathlib import Path -from pydantic import BaseSettings, validator +from pydantic import BaseSettings class JobflowRemoteSettings(BaseSettings): projects_folder: str = Path("~/.jfremote").expanduser().as_posix() - daemon_folder: str = "" project: str = None - @validator("daemon_folder", always=True) - def get_daemon_folder(cls, daemon_folder: str, values: dict) -> str: - """ - Validator to set the default of daemon_folder based on projects_folder - """ - if daemon_folder == "" and "projects_folder" in values: - return str(Path(values["projects_folder"], "daemon")) - return daemon_folder - class Config: """Pydantic config settings.""" diff --git a/src/jobflow_remote/fireworks/convert.py b/src/jobflow_remote/fireworks/convert.py index 75863811..c3d1d32d 100644 --- a/src/jobflow_remote/fireworks/convert.py +++ b/src/jobflow_remote/fireworks/convert.py @@ -5,7 +5,7 @@ from fireworks import Firework, Workflow from qtoolkit.core.data_objects import QResources -from jobflow_remote.config.entities import ExecutionConfig +from jobflow_remote.config.base import ExecutionConfig from jobflow_remote.fireworks.tasks import RemoteJobFiretask if typing.TYPE_CHECKING: @@ -20,7 +20,7 @@ def flow_to_workflow( flow: jobflow.Flow | jobflow.Job | list[jobflow.Job], machine: str, store: jobflow.JobStore | None = None, - exec_config: ExecutionConfig = None, + exec_config: str | ExecutionConfig = None, resources: dict | QResources | None = None, **kwargs, ) -> Workflow: @@ -85,7 +85,7 @@ def job_to_firework( store: jobflow.JobStore | None = None, parents: Sequence[str] | None = None, parent_mapping: dict[str, Firework] | None = None, - exec_config: ExecutionConfig = None, + exec_config: str | ExecutionConfig = None, resources: dict | QResources | None = None, **kwargs, ) -> Firework: @@ -123,6 +123,18 @@ def job_to_firework( 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, @@ -140,7 +152,7 @@ def job_to_firework( 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(job.config.manager_config) + spec.update(manager_config) spec.update(job.metadata) # add metadata to spec fw = Firework([task], spec=spec, name=job.name, parents=job_parents, **kwargs) diff --git a/src/jobflow_remote/fireworks/launchpad.py b/src/jobflow_remote/fireworks/launchpad.py index d5df84f6..e866b0d8 100644 --- a/src/jobflow_remote/fireworks/launchpad.py +++ b/src/jobflow_remote/fireworks/launchpad.py @@ -1,31 +1,108 @@ from __future__ import annotations import datetime +import logging import traceback +from dataclasses import asdict, dataclass -from fireworks import FWAction, Launch, LaunchPad +from fireworks import Firework, FWAction, Launch, LaunchPad, Workflow +from fireworks.core.launchpad import get_action_from_gridfs from fireworks.utilities.fw_serializers import reconstitute_dates +from pymongo import ASCENDING +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.run.state import RemoteState +from jobflow_remote.utils.data import check_dict_keywords +from jobflow_remote.utils.db import MongoLock + +logger = logging.getLogger(__name__) + + +fw_uuid = "spec._tasks.job.uuid" + + +@dataclass +class RemoteRun: + fw_id: int + launch_id: int + name: str + job_id: str + machine_id: str + created_on: str = datetime.datetime.utcnow().isoformat() + updated_on: str = datetime.datetime.utcnow().isoformat() + 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 + + 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: + d["state"] = RemoteState(d["state"]) + d["previous_state"] = RemoteState(d["previous_state"]) + d["queue_state"] = QState(d["queue_state"]) + d.pop("_id", None) + 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, **kwargs): self.lpad = LaunchPad(**kwargs) self.remote_runs = self.db.remote_runs + 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.remote_runs.delete_many({}) + self.fireworks.create_index(fw_uuid, unique=True, background=True) + self.remote_runs.create_index("job_id", unique=True, background=True) + self.remote_runs.create_index("launch_id", unique=True, background=True) + self.remote_runs.create_index("fw_id", unique=True, background=True) def forget_remote(self, launchid_or_fwid, launch_mode=True): """ - Unmark the offline run for the given launch or firework id. + Delete the remote run document for the given launch or firework id. Args: launchid_or_fwid (int): launch od or firework id @@ -36,7 +113,7 @@ def forget_remote(self, launchid_or_fwid, launch_mode=True): if launch_mode else {"fw_id": launchid_or_fwid} ) - self.db.remote_runs.update_many(q, {"$set": {"deprecated": True}}) + self.db.remote_runs.delete_many(q) def add_remote_run(self, launch_id, fw): """ @@ -44,34 +121,18 @@ def add_remote_run(self, launch_id, fw): Args: launch_id (int): launch id - fw_id (id): firework id - name (str) """ task = fw.tasks[0] - machine_id = task.get("machine") job = task.get("job") - job_id = job.uuid - d = {"fw_id": fw.fw_id} - d["launch_id"] = launch_id - d["name"] = fw.name - d["created_on"] = datetime.datetime.utcnow().isoformat() - d["updated_on"] = datetime.datetime.utcnow().isoformat() - d["deprecated"] = False - d["state"] = RemoteState.CHECKED_OUT.value - d["completed"] = False - d["job_id"] = job_id - d["step_attempts"] = 0 - d["retry_time_limit"] = None - d["failed_state"] = None - d["queue_state"] = None - d["machine_id"] = machine_id - self.db.remote_runs.insert_one(d) - - def get_fw_by_job_id(self, job_id): - pass - - def get_job_by_uuid(self, job_id): - self.get_fw_by_job_id(job_id) + remote_run = RemoteRun( + fw_id=fw.fw_id, + launch_id=launch_id, + name=fw.name, + job_id=job.uuid, + machine_id=task.get("machine"), + ) + + self.db.remote_runs.insert_one(remote_run.as_db_dict()) def recover_remote( self, @@ -114,8 +175,6 @@ def recover_remote( if not already_running: m_launch.state = "RUNNING" # this should also add a history item - remote_status["checkpoint"] if "checkpoint" in remote_status else None - status = remote_status.get("state") if terminated and status not in ("COMPLETED", "FIZZLED"): raise RuntimeError( @@ -145,9 +204,6 @@ def recover_remote( {"$set": {"state_history": m_launch.state_history}}, ) - self.lpad.offline_runs.update_one( - {"launch_id": launch_id}, {"$set": {"completed": True}} - ) completed = True else: @@ -206,3 +262,440 @@ def recover_remote( 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): + """ + 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 = self._generate_id_query(fw_id, job_id) + fw_dict = self.fireworks.find_one(query) + 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) -> dict: + query: dict = {} + if fw_id: + query["fw_id"] = fw_id + if job_id: + query[fw_uuid] = job_id + if not query: + raise ValueError("At least one among fw_id and job_id should be specified") + return query + + def _check_ids(self, fw_id: int | None = None, job_id: str | None = None): + if job_id is None and fw_id is None: + raise ValueError("At least one among fw_id and job_id should be defined") + if job_id: + fw_id = self.get_fw_id_from_job_id(job_id) + return fw_id, job_id + + def get_fw(self, fw_id: int | None = None, job_id: str | 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)) + + def get_fw_id_from_job_id(self, job_id: str): + fw_dict = self.fireworks.find_one({fw_uuid: job_id}, projection=["fw_id"]) + 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, + rerun_duplicates: bool = True, + recover_launch: int | str | None = None, + recover_mode: str | None = None, + ): + fw_id, job_id = self._check_ids(fw_id, job_id) + rerun_fw_ids = self.lpad.rerun_fw( + fw_id, rerun_duplicates, recover_launch, recover_mode + ) + + to_archive = self.remote_runs.find({"fw_id": {"$in": rerun_fw_ids}}) + for doc in to_archive: + doc.pop("_id", None) + self.archived_remote_runs.insert(doc) + + self.remote_runs.delete_many({"fw_id": {"$in": rerun_fw_ids}}) + + def set_remote_state( + self, + state: RemoteState, + fw_id: int | None, + job_id: str | None = None, + break_lock: bool = False, + ): + lock_filter = self._generate_id_query(fw_id, job_id) + with MongoLock( + collection=self.remote_runs, filter=lock_filter, break_lock=break_lock + ) as lock: + if lock.locked_document: + lock.update_on_release = { + "$set": { + "state": state.value, + "updated_on": datetime.datetime.utcnow().isoformat(), + "completed": False, + "step_attempts": 0, + "retry_time_limit": None, + "previous_state": None, + "queue_state": None, + "error": None, + } + } + return True + + return False + + def remove_lock(self, fw_id: int | None = None, job_id: str | None = None): + query = self._generate_id_query(fw_id, job_id) + result = self.remote_runs.find_one_and_update( + query, + {"$unset": {MongoLock.LOCK_KEY: "", MongoLock.LOCK_TIME_KEY: ""}}, + projection=["fw_id"], + ) + if not result: + raise ValueError("No job matching id") + + def is_locked(self, fw_id: int | None = None, job_id: str | None = None): + query = self._generate_id_query(fw_id, job_id) + result = self.remote_runs.find_one(query, projection=[MongoLock.LOCK_KEY]) + if not result: + raise ValueError("No job matching id") + return MongoLock.LOCK_KEY in result + + def reset_failed_state(self, fw_id: int | None = None, job_id: str | None = None): + lock_filter = self._generate_id_query(fw_id, job_id) + with MongoLock(collection=self.remote_runs, filter=lock_filter) as lock: + doc = lock.locked_document + if doc: + state = doc["state"] + if state != RemoteState.FAILED.value: + raise ValueError("Job is not in a FAILED state") + previous_state = doc["previous_state"] + try: + RemoteState(previous_state) + except ValueError: + raise ValueError( + f"The registered previous state: {previous_state} is not a valid state" + ) + lock.update_on_release = { + "$set": { + "state": previous_state, + "updated_on": datetime.datetime.utcnow().isoformat(), + "completed": False, + "step_attempts": 0, + "retry_time_limit": None, + "previous_state": None, + "queue_state": None, + "error": None, + } + } + 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. + + """ + fw_id, job_id = self._check_ids(fw_id, job_id) + + links_dict = self.workflows.find_one({"nodes": fw_id}) + fw_ids = links_dict["nodes"] + self.lpad.delete_fws(fw_ids, delete_launch_dirs=False) + self.remote_runs.delete_many({"fw_id": {"$in": fw_ids}}) + 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 + ) -> RemoteRun: + query = self._generate_id_query(fw_id, job_id) + remote_run_dict = self.remote_runs.find_one(query) + if not remote_run_dict: + raise ValueError( + f"No Firework exists with fw id: {fw_id} or job_id {job_id}" + ) + + return RemoteRun.from_db_dict(remote_run_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_data( + self, + query: dict | None = None, + projection: dict | None = None, + sort: dict | None = None, + limit: int = 0, + ) -> list[dict]: + + pipeline: list[dict] = [ + { + "$lookup": { + "from": "remote_runs", + "localField": "fw_id", + "foreignField": "fw_id", + "as": "remote", + } + } + ] + + 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)) + + def get_fw_remote_run( + self, + query: dict | None = None, + projection: dict | None = None, + sort: dict | None = None, + limit: int = 0, + ) -> list[tuple[Firework, RemoteRun | None]]: + raw_data = self.get_fw_remote_run_data( + query=query, projection=projection, sort=sort, limit=limit + ) + + data = [] + for d in raw_data: + r = d.pop("remote", None) + if r: + if len(r) > 1: + raise RuntimeError( + f"error retrieving the remote_run document. {len(r)} found. Expected 1." + ) + remote_run = RemoteRun.from_db_dict(r[0]) + else: + remote_run = None + + fw = Firework.from_dict(d) + 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]: + remote_required = check_dict_keywords(query, ["remote."]) + if remote_required: + result = self.get_fw_remote_run_data( + query=query, sort=sort, limit=limit, projection={"fw_id": 1} + ) + else: + result = self.fireworks.find( + query, sort=sort, limit=limit, projection=["fw_id"] + ) + + 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 + ) -> tuple[Firework, RemoteRun] | None: + if fw_id is None and job_id is None: + raise ValueError("at least one among fw_id and job_id should be defined") + query: dict = {} + if fw_id: + query["fw_id"] = fw_id + if job_id: + query[fw_uuid] = job_id + results = self.get_fw_remote_run(query=query) + if not results: + return None + return results[0] + + def get_wf_fw_remote_run_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", + } + }, + { + "$lookup": { + "from": "remote_runs", + "localField": "nodes", + "foreignField": "fw_id", + "as": "remote", + } + }, + ] + + 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.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_remote_run_data(query=query, sort=sort, limit=limit) + + data = [] + for d in raw_data: + remotes = d.pop("remote", None) + + remotes_dict = {} + for r in remotes: + remotes_dict[r["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, ["remote.", "fws."]) + + if full_required: + result = self.get_wf_fw_remote_run_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 = [ + { + "$lookup": { + "from": "remote_runs", + "localField": "fw_id", + "foreignField": "fw_id", + "as": "remote", + } + }, + { + "$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 index 775c30de..a46123b0 100644 --- a/src/jobflow_remote/fireworks/tasks.py +++ b/src/jobflow_remote/fireworks/tasks.py @@ -4,8 +4,14 @@ 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): @@ -29,52 +35,50 @@ class RemoteJobFiretask(FiretaskBase): 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", "machine"] - optional_params = ["exec_config", "resources"] + 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 - from jobflow.core.store import JobStore - from maggma.stores.mongolike import JSONStore job: Job = self.get("job") - original_store = self.get("store") - - docs_store = JSONStore("remote_job_data.json", read_only=False) - additional_stores = {} - for k in original_store.additional_stores.keys(): - additional_stores[k] = JSONStore( - f"additional_store_{k}.json", read_only=False - ) - store = JobStore( - docs_store=docs_store, - additional_stores=additional_stores, - save=original_store.save, - load=original_store.load, - ) + 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({"fw_id": self.fw_id}) + job.metadata.update({"db_id": self.fw_id}) initialize_logger() - response = job.run(store=store) - - # some jobs may have compressed the FW files while being executed, - # try to decompress them if that is the case. - self.decompress_files() + 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 = { "machine": self.get("machine"), - "store": original_store, + "store": self.get("original_store"), "exports": self.get("exports"), "qtk_options": self.get("qtk_options"), } @@ -103,10 +107,13 @@ def run_task(self, fw_spec): ) return fwa - def decompress_files(self): + 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 + ".*"): diff --git a/src/jobflow_remote/run/__init__.py b/src/jobflow_remote/jobs/__init__.py similarity index 100% rename from src/jobflow_remote/run/__init__.py rename to src/jobflow_remote/jobs/__init__.py diff --git a/src/jobflow_remote/run/daemon.py b/src/jobflow_remote/jobs/daemon.py similarity index 78% rename from src/jobflow_remote/run/daemon.py rename to src/jobflow_remote/jobs/daemon.py index acf62bdd..7753812a 100644 --- a/src/jobflow_remote/run/daemon.py +++ b/src/jobflow_remote/jobs/daemon.py @@ -5,10 +5,13 @@ from string import Template import psutil +from monty.os import makedirs_p from supervisor import childutils from supervisor.states import RUNNING_STATES, STOPPED_STATES, ProcessStates from supervisor.xmlrpc import Faults +from jobflow_remote.config import ConfigManager + logger = logging.getLogger(__name__) @@ -23,16 +26,16 @@ logfile=$log_file logfile_maxbytes=10MB logfile_backups=5 -loglevel=info +loglevel=$loglevel pidfile=$pid_file nodaemon=$nodaemon [supervisorctl] serverurl=unix://$sock_file -[program:myprogram] +[program:runner_daemon] priority=100 -command=TODO create executable +command=jf -p $project runner run autostart=true autorestart=false numprocs=$num_procs @@ -56,9 +59,20 @@ class DaemonManager: conf_template = Template(supervisord_conf_str) - def __init__(self, daemon_dir: str | Path, log_dir: str | Path | None = None): + def __init__( + self, + daemon_dir: str | Path | None = None, + log_dir: str | Path | None = None, + project_name: str | None = None, + ): + config_manager = ConfigManager() + self.project = config_manager.get_project(project_name) + if not daemon_dir: + daemon_dir = self.project.daemon_dir self.daemon_dir = Path(daemon_dir).absolute() - self.log_dir = Path(log_dir).absolute() if log_dir else self.daemon_dir + if not log_dir: + log_dir = self.project.log_dir + self.log_dir = Path(log_dir).absolute() @property def conf_filepath(self) -> Path: @@ -140,7 +154,7 @@ def check_supervisord_process(self) -> bool: return running - def check_status(self): + def check_status(self) -> DaemonStatus: process_active = self.check_supervisord_process() if not process_active: @@ -155,7 +169,7 @@ def check_status(self): proc_info = interface.supervisor.getAllProcessInfo() if not proc_info: raise DaemonError( - "supervisord process is running but not daemon process is present" + "supervisord process is running but no daemon process is present" ) if any(pi.get("state") in RUNNING_STATES for pi in proc_info): @@ -172,21 +186,52 @@ def check_status(self): raise DaemonError("Could not determine the current status of the daemon") - def write_config(self, num_procs: int = 1, nodaemon: bool = False): + def get_pids(self) -> dict[str, int] | None: + process_active = self.check_supervisord_process() + + if not process_active: + return None + + pids = {"supervisord": self.get_supervisord_pid()} + + if not self.sock_filepath.is_socket(): + raise DaemonError( + "the supervisord process is alive, but the socket is missing" + ) + + interface = self.get_interface() + proc_info = interface.supervisor.getAllProcessInfo() + if not proc_info: + raise DaemonError( + "supervisord process is running but no daemon process is present" + ) + + for pi in proc_info: + pids[pi.get("name")] = pi.get("pid") + + return pids + + def write_config( + self, num_procs: int = 1, 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, ) with open(self.conf_filepath, "w") as f: f.write(conf) def start_supervisord( - self, num_procs: int = 1, nodaemon: bool = False + self, num_procs: int = 1, log_level: str = "info", nodaemon: bool = False ) -> str | None: - self.write_config(num_procs=num_procs, nodaemon=nodaemon) + makedirs_p(self.daemon_dir) + makedirs_p(self.log_dir) + self.write_config(num_procs=num_procs, log_level=log_level, nodaemon=nodaemon) cp = subprocess.run( f"supervisord -c {str(self.conf_filepath)}", shell=True, @@ -217,15 +262,19 @@ def start_processes(self) -> str | None: return None - def start(self, raise_on_error: bool = False) -> bool: + def start( + self, num_procs: int = 1, log_level: str = "info", raise_on_error: bool = False + ) -> bool: status = self.check_status() if status == DaemonStatus.RUNNING: - return True - - if status == DaemonStatus.SHUT_DOWN: - error = self.start_supervisord() + error = "Daemon process is already running" + elif status == DaemonStatus.SHUT_DOWN: + error = self.start_supervisord(num_procs=num_procs, log_level=log_level) elif status == DaemonStatus.STOPPED: - error = self.start_processes() + self.shut_down(raise_on_error=raise_on_error) + error = self.start_supervisord(num_procs=num_procs, log_level=log_level) + # else: + # error = self.start_processes() elif status == DaemonStatus.STOPPING: error = "Daemon process are stopping. Cannot start." else: diff --git a/src/jobflow_remote/jobs/data.py b/src/jobflow_remote/jobs/data.py new file mode 100644 index 00000000..73f66836 --- /dev/null +++ b/src/jobflow_remote/jobs/data.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone + +from jobflow import Job + +from jobflow_remote.fireworks.launchpad import fw_uuid +from jobflow_remote.jobs.state import JobState, RemoteState + + +@dataclass +class JobData: + job: Job + state: JobState + db_id: int + remote_state: RemoteState | None = None + output: dict | None = None + + +job_info_projection = { + "fw_id": 1, + fw_uuid: 1, + "state": 1, + "remote.state": 1, + "name": 1, + "updated_on": 1, + "remote.updated_on": 1, + "remote.previous_state": 1, + "remote.lock_id": 1, + "remote.lock_time": 1, + "remote.retry_time_limit": 1, + "remote.process_id": 1, + "remote.run_dir": 1, + "spec._tasks.machine": 1, +} + + +@dataclass +class JobInfo: + db_id: int + job_id: str + state: JobState + name: str + last_updated: datetime + machine: str + remote_state: RemoteState | None = None + remote_previous_state: RemoteState | None = None + lock_id: datetime | 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 + + @classmethod + def from_query_dict(cls, d): + remote = d.get("remote") or {} + if remote: + remote = remote[0] + 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) + # in FW the date is encoded in a string + fw_update_date = datetime.fromisoformat(d["updated_on"]) + remote_update_date = remote.get("updated_on") + if remote_update_date: + remote_update_date = datetime.fromisoformat(d["updated_on"]) + last_updated = max(fw_update_date, remote_update_date) + else: + last_updated = fw_update_date + # 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("state") + remote_previous_state = ( + RemoteState(remote_previous_state_val) + if remote_previous_state_val is not None + else None + ) + lock_id = remote.get("lock_id") + lock_time = remote.get("lock_time") + if lock_time is not None: + 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}" + + return cls( + db_id=d["fw_id"], + job_id=d["spec"]["_tasks"][0]["job"]["uuid"], + state=state, + name=d["name"], + last_updated=last_updated, + machine=d["spec"]["_tasks"][0]["machine"], + 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=str(remote.get("process_id")), + run_dir=remote.get("run_dir"), + error_remote=remote.get("error"), + error_job=error_job, + ) diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py new file mode 100644 index 00000000..417975e8 --- /dev/null +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +import logging +from datetime import datetime, timezone + +from fireworks import Firework +from jobflow import JobStore + +from jobflow_remote.config.base import Project +from jobflow_remote.config.manager import ConfigManager +from jobflow_remote.fireworks.launchpad import RemoteLaunchPad +from jobflow_remote.jobs.data import JobData, JobInfo, job_info_projection +from jobflow_remote.jobs.state import FlowState, JobState, RemoteState + +logger = logging.getLogger(__name__) + + +class JobController: + def __init__( + self, project_name: str | None = None, jobstore: JobStore | 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.jobstore = jobstore + self.jobstore.connect() + + def get_job_data( + self, + job_id: str | None = None, + db_id: str | 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 = 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 == RemoteState.COMPLETED: + output = jobstore.query_one({"uuid": job_id}, load=True) + + return JobData(job=job, state=state, db_id=fw.fw_id, output=output) + + def _build_query_fw( + self, + job_ids: str | list[str] | None = None, + db_ids: int | list[int] | None = None, + state: JobState | None = None, + remote_state: RemoteState | None = None, + start_date: datetime | None = None, + end_date: datetime | 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 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)): + db_ids = [db_ids] + + query: dict = {} + + if db_ids: + query["fw_id"] = {"$in": db_ids} + if job_ids: + query["spec._tasks.job.uuid"] = {"$in": job_ids} + + if state: + fw_states, remote_state = state.to_states() + query["state"] = {"$in": fw_states} + + if remote_state: + query["remote.state"] = {"$in": [rs.value for rs in remote_state]} + + if start_date: + start_date_str = start_date.astimezone(timezone.utc).isoformat() + query["updated_on"] = {"$gte": start_date_str} + if end_date: + end_date_str = end_date.astimezone(timezone.utc).isoformat() + query["updated_on"] = {"$lte": end_date_str} + + return query + + def _build_query_wf( + self, + job_ids: str | list[str] | None = None, + db_ids: int | list[int] | None = None, + state: FlowState | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, + ) -> dict: + + 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)): + db_ids = [db_ids] + + query: dict = {} + + if db_ids: + query["nodes"] = {"$in": db_ids} + if job_ids: + query["fws.spec._tasks.job.uuid"] = {"$in": job_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} + if 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": ["RUNNING", "RESERVED"]} + query["remote.state"] = { + "$nin": [JobState.FAILED.value, JobState.REMOTE_ERROR.value] + } + elif state == FlowState.FAILED: + query["$or"] = [ + {"state": "FIZZLED"}, + { + "remote.state": { + "$in": [JobState.FAILED.value, JobState.REMOTE_ERROR.value] + } + }, + ] + + if start_date: + start_date_str = start_date.astimezone(timezone.utc).isoformat() + query["updated_on"] = {"$gte": start_date_str} + if end_date: + end_date_str = end_date.astimezone(timezone.utc).isoformat() + query["updated_on"] = {"$lte": end_date_str} + + return query + + def get_jobs_data( + self, + job_ids: str | list[str] | None = None, + db_ids: int | list[int] | None = None, + state: JobState | None = None, + remote_state: RemoteState | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, + sort: dict | None = None, + limit: int = 0, + load_output: bool = False, + ) -> list[JobData]: + query = self._build_query_fw( + job_ids=job_ids, + db_ids=db_ids, + state=state, + remote_state=remote_state, + start_date=start_date, + end_date=end_date, + ) + + data = self.rlpad.get_fw_remote_run(query=query, sort=sort, limit=limit) + + jobs_data = [] + for fw, remote in data: + job = fw.tasks[0]["job"] + remote_state_job = remote.state if remote else None + state = JobState.from_states(fw.state, remote_state_job) + output = None + jobstore = fw.tasks[0].get("store") or self.jobstore + if state == RemoteState.COMPLETED and load_output: + output = jobstore.query_one({"uuid": job.uuid}, load=True) + jobs_data.append( + JobData( + job=job, + state=state, + db_id=fw.fw_id, + remote_state=remote_state, + output=output, + ) + ) + + return jobs_data + + def get_jobs_info( + self, + job_ids: str | list[str] | None = None, + db_ids: int | list[int] | None = None, + state: JobState | None = None, + remote_state: RemoteState | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, + sort: dict | None = None, + limit: int = 0, + ) -> list[JobInfo]: + query = self._build_query_fw( + job_ids=job_ids, + db_ids=db_ids, + state=state, + remote_state=remote_state, + start_date=start_date, + end_date=end_date, + ) + + data = self.rlpad.get_fw_remote_run_data( + query=query, sort=sort, limit=limit, projection=job_info_projection + ) + + jobs_data = [] + for d in data: + jobs_data.append(JobInfo.from_query_dict(d)) + + return jobs_data + + def get_job_info( + self, job_id: str | None, db_id: int | None, full: bool = False + ) -> JobInfo | 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" + ) + query = self._build_query_fw(job_ids=job_id, db_ids=db_id) + + if full: + proj = dict(job_info_projection) + proj.update( + { + "launch.action.stored_data": 1, + "remote.error": 1, + } + ) + data = self.rlpad.get_fw_launch_remote_run_data( + query=query, projection=proj + ) + else: + data = self.rlpad.get_fw_remote_run_data( + query=query, projection=job_info_projection + ) + if not data: + return None + + return JobInfo.from_query_dict(data[0]) + + def rerun_jobs( + self, + job_ids: str | list[str] | None = None, + db_ids: int | list[int] | None = None, + state: JobState | None = None, + remote_state: RemoteState | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, + sort: dict | None = None, + limit: int = 0, + ) -> list[int]: + query = self._build_query_fw( + job_ids=job_ids, + db_ids=db_ids, + state=state, + remote_state=remote_state, + start_date=start_date, + end_date=end_date, + ) + + 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) + + return fw_ids + + def reset(self, reset_output: bool = False, max_limit: int = 25) -> bool: + + 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 + ) + 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 diff --git a/src/jobflow_remote/run/runner.py b/src/jobflow_remote/jobs/runner.py similarity index 73% rename from src/jobflow_remote/run/runner.py rename to src/jobflow_remote/jobs/runner.py index e7ccc39d..62bc1e2a 100644 --- a/src/jobflow_remote/run/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import logging import shutil import signal @@ -12,12 +13,11 @@ from pathlib import Path from fireworks import FWorker -from jobflow import SETTINGS from monty.os import makedirs_p from monty.serialization import loadfn from qtoolkit.core.data_objects import QState, SubmissionStatus -from jobflow_remote.config.entities import ( +from jobflow_remote.config.base import ( ConfigError, ExecutionConfig, Machine, @@ -28,10 +28,15 @@ from jobflow_remote.fireworks.launcher import rapidfire_checkout from jobflow_remote.fireworks.launchpad import RemoteLaunchPad from jobflow_remote.fireworks.tasks import RemoteJobFiretask -from jobflow_remote.remote.data import get_job_path, get_remote_files, get_remote_store +from jobflow_remote.jobs.state import RemoteState +from jobflow_remote.remote.data import ( + get_job_path, + get_remote_files, + get_remote_store, + get_remote_store_filenames, +) from jobflow_remote.remote.host import BaseHost from jobflow_remote.remote.queue import QueueManager -from jobflow_remote.run.state import RemoteState 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 @@ -39,13 +44,20 @@ logger = logging.getLogger(__name__) -JobFWData = namedtuple("JobFWData", ["fw", "task", "job", "store", "machine", "host"]) +JobFWData = namedtuple( + "JobFWData", ["fw", "task", "job", "store", "machine", "host", "original_store"] +) class Runner: - def __init__(self, project_name: str | None = None, log_level: int | None = None): + def __init__( + self, + project_name: str | None = None, + log_level: int | None = None, + runner_id: str | None = None, + ): self.stop_signal = False - self.runner_id: str = str(uuid.uuid4()) + self.runner_id: str = runner_id or str(uuid.uuid4()) self.config_manager: ConfigManager = ConfigManager() self.project_name = project_name self.project: Project = self.config_manager.get_project(project_name) @@ -91,12 +103,12 @@ def get_fw_data(self, fw_id: int) -> JobFWData: job = task.get("job") store = task.get("store") if store is None: - store = SETTINGS.JOB_STORE + store = self.project.get_jobstore() task["store"] = store machine = self.get_machine(task["machine"]) host = self.hosts[machine.host_id] - return JobFWData(fw, task, job, store, machine, host) + return JobFWData(fw, task, job, store, machine, host, task.get("store")) def run(self): signal.signal(signal.SIGTERM, self.handle_signal) @@ -182,7 +194,6 @@ def lock_and_update( doc = lock.locked_document if not doc: return False - error = None state = RemoteState(doc["state"]) @@ -190,19 +201,17 @@ def lock_and_update( fail_now = False try: - succeeded, fail_now, set_output = function(doc) + error, fail_now, set_output = function(doc) except ConfigError: error = traceback.format_exc() warnings.warn(error) - succeeded = False fail_now = True except Exception: error = traceback.format_exc() warnings.warn(error) - succeeded = False - if succeeded: - # new_state = states_evolution[state] + if not error: + # the state.next.value is correct as SUBMITTED is not dealt with here. succeeded_update = { "$set": { "state": state.next.value, @@ -223,7 +232,7 @@ def lock_and_update( lock.update_on_release = { "$set": { "state": RemoteState.FAILED.value, - "failed_state": state, + "previous_state": state.value, "error": error, } } @@ -257,39 +266,61 @@ def upload(self, doc): except Exception: logging.error(f"error while closing the store {store}", exc_info=True) - files = get_remote_files(fw_job_data.fw, doc["launch_id"]) remote_path = get_job_path(job.uuid, fw_job_data.machine.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, doc["launch_id"]) self.rlpad.lpad.change_launch_dir(doc["launch_id"], remote_path) created = fw_job_data.host.mkdir(remote_path) if not created: - logger.error( + err_msg = ( f"Could not create remote directory {remote_path} for fw_id {fw_id}" ) - return False, False, None + logger.error(err_msg) + return err_msg, False, None for fname, fcontent in files.items(): path_file = Path(remote_path, fname) fw_job_data.host.write_text_file(path_file, fcontent) - return True, False, None + set_output = {"$set": {"run_dir": remote_path}} + + return None, False, set_output def submit(self, doc): fw_id = doc["fw_id"] logger.debug(f"submit fw_id: {doc['fw_id']}") fw_job_data = self.get_fw_data(fw_id) - job = fw_job_data.job + fw_job_data.job - remote_path = get_job_path(job.uuid, fw_job_data.machine.work_dir) + remote_path = doc["run_dir"] script_commands = ["rlaunch singleshot --offline"] machine = fw_job_data.machine queue_manager = self.get_queue_manager(machine.machine_id) resources = fw_job_data.task.get("resources") or machine.resources - exec_config = fw_job_data.task.get("exec_config") or ExecutionConfig( - exec_config_id="empty_config" - ) + exec_config = fw_job_data.task.get("exec_config") + if isinstance(exec_config, str): + exec_config = self.config_manager.load_exec_config( + exec_config_id=exec_config, project_name=self.project_name + ) + elif isinstance(exec_config, dict): + exec_config = ExecutionConfig.parse_obj(exec_config) + + exec_config = exec_config or ExecutionConfig() + pre_run = machine.pre_run or "" pre_run += exec_config.pre_run or "" post_run = machine.post_run or "" @@ -307,14 +338,16 @@ def submit(self, doc): ) if submit_result.status == SubmissionStatus.FAILED: - return False, False, None + err_msg = f"submission failed. {repr(submit_result)}" + return err_msg, False, None elif submit_result.status == SubmissionStatus.JOB_ID_UNKNOWN: - raise RuntimeError("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 elif submit_result.status == SubmissionStatus.SUCCESSFUL: set_output = {"$set": {"process_id": str(submit_result.job_id)}} - return True, False, set_output + return None, False, set_output raise RuntimeError(f"unhandled submission status {submit_result.status}") @@ -324,7 +357,7 @@ def download(self, doc): fw_job_data = self.get_fw_data(fw_id) job = fw_job_data.job - remote_path = get_job_path(job.uuid, fw_job_data.machine.work_dir) + remote_path = doc["run_dir"] loca_base_dir = Path(self.project.tmp_dir, "download") local_path = get_job_path(job.uuid, loca_base_dir) @@ -332,9 +365,8 @@ def download(self, doc): store = fw_job_data.store - fnames = ["FW_offline.json", "remote_job_data.json"] - for k in store.additional_stores.keys(): - fnames.append(f"additional_store_{k}.json") + 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, @@ -344,12 +376,11 @@ def download(self, doc): fw_job_data.host.get(remote_file_path, str(Path(local_path, fname))) except FileNotFoundError: # if files are missing it should not retry - logger.error( - f"file {remote_file_path} for job {job.uuid} does not exist" - ) - return False, True, None + err_msg = f"file {remote_file_path} for job {job.uuid} does not exist" + logger.error(err_msg) + return err_msg, True, None - return True, False, None + return None, False, None def complete_launch(self, doc): fw_id = doc["fw_id"] @@ -359,35 +390,48 @@ def complete_launch(self, doc): loca_base_dir = Path(self.project.tmp_dir, "download") local_path = get_job_path(fw_job_data.job.uuid, loca_base_dir) - 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() - } - - # TODO add ping data? - remote_store = get_remote_store(store, local_path) - fw_id, completed = self.rlpad.recover_remote( - remote_status=remote_data, - store=store, - remote_store=remote_store, - save=save, - launch_id=doc["launch_id"], - terminated=True, - ) + 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() + } + + # TODO add ping data? + remote_store = get_remote_store(store, local_path) + remote_store.connect() + fw_id, completed = self.rlpad.recover_remote( + remote_status=remote_data, + store=store, + remote_store=remote_store, + save=save, + launch_id=doc["launch_id"], + terminated=True, + ) + 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 # remove local folder with downloaded files if successfully completed if completed and self.runner_options.delete_tmp_folder: shutil.rmtree(local_path, ignore_errors=True) - return completed, False, None + 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, None def check_run_status(self): logger.debug("check_run_status") # check for jobs that could have changed state machines_ids_docs = defaultdict(dict) - db_filter = {"state": {"$in": [RemoteState.SUBMITTED.value]}} + db_filter = { + "state": {"$in": [RemoteState.SUBMITTED.value, RemoteState.RUNNING.value]} + } projection = [ "fw_id", "launch_id", @@ -421,14 +465,30 @@ def check_run_status(self): qjob = qjobs_dict.get(doc_id) qstate = qjob.state if qjob else None collection = self.rlpad.remote_runs - if qstate in [None, QState.DONE, QState.FAILED]: + if ( + qstate == QState.RUNNING + and doc["state"] == RemoteState.SUBMITTED.value + ): + lock_filter = {"state": doc["state"], "job_id": doc["job_id"]} + with MongoLock(collection=collection, filter=lock_filter) as lock: + if lock.locked_document: + lock.update_on_release = { + "$set": { + "state": RemoteState.RUNNING.value, + "queue_state": qstate.value, + } + } + logger.debug( + f"remote job with id {doc['process_id']} is running" + ) + elif qstate in [None, QState.DONE, QState.FAILED]: lock_filter = {"state": doc["state"], "job_id": doc["job_id"]} with MongoLock(collection=collection, filter=lock_filter) as lock: if lock.locked_document: lock.update_on_release = { "$set": { "state": RemoteState.TERMINATED.value, - "queue_state": qstate, + "queue_state": qstate.value if qstate else None, } } logger.debug( diff --git a/src/jobflow_remote/jobs/state.py b/src/jobflow_remote/jobs/state.py new file mode 100644 index 00000000..0515cb63 --- /dev/null +++ b/src/jobflow_remote/jobs/state.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from enum import Enum + + +class RemoteState(Enum): + CHECKED_OUT = "CHECKED_OUT" + UPLOADED = "UPLOADED" + SUBMITTED = "SUBMITTED" + RUNNING = "RUNNING" + TERMINATED = "TERMINATED" + DOWNLOADED = "DOWNLOADED" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + KILLED = "KILLED" + PAUSED = "PAUSED" + + @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, +] + + +class JobState(Enum): + WAITING = "WAITING" + READY = "READY" + ONGOING = "ONGOING" + REMOTE_ERROR = "REMOTE_ERROR" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + PAUSED = "PAUSED" + + @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 + + 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"], [RemoteState.FAILED] + elif self == JobState.REMOTE_ERROR: + return ["RESERVED", "RUNNING"], list(remote_states_order) + elif self == JobState.FAILED: + return ["FIZZLED"], [RemoteState.COMPLETED] + + raise ValueError(f"Unhandled state {self}") + + +class FlowState(Enum): + WAITING = "WAITING" + READY = "READY" + ONGOING = "ONGOING" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + PAUSED = "PAUSED" + + @classmethod + def from_jobs_states(cls, jobs_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): + return cls.COMPLETED + 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 + else: + return cls.ONGOING diff --git a/src/jobflow_remote/run/submit.py b/src/jobflow_remote/jobs/submit.py similarity index 77% rename from src/jobflow_remote/run/submit.py rename to src/jobflow_remote/jobs/submit.py index 79ec3cca..023ee280 100644 --- a/src/jobflow_remote/run/submit.py +++ b/src/jobflow_remote/jobs/submit.py @@ -3,7 +3,7 @@ import jobflow from qtoolkit.core.data_objects import QResources -from jobflow_remote.config.entities import ExecutionConfig +from jobflow_remote.config.base import ExecutionConfig from jobflow_remote.config.manager import ConfigManager from jobflow_remote.fireworks.convert import flow_to_workflow @@ -11,9 +11,9 @@ def submit_flow( flow: jobflow.Flow | jobflow.Job | list[jobflow.Job], machine: str, - store: jobflow.JobStore | None = None, + store: str | jobflow.JobStore | None = None, project: str | None = None, - exec_config: ExecutionConfig | None = None, + exec_config: str | ExecutionConfig | None = None, resources: dict | QResources | None = None, ): """ @@ -36,22 +36,27 @@ def submit_flow( project the name of the project to which the Flow should be submitted. If None the current project will be used. - exec_config: ExecutionConfig + exec_config: str or ExecutionConfig the options to set before the execution of the job in the submission script. In addition to those defined in the Machine. resources: Dict or QResources information passed to qtoolkit to require the resources for the submission to the queue. """ + config_manager = ConfigManager() + + proj_obj = config_manager.get_project(project) + + # try to load the machine and exec_config to check that the values are well defined + config_manager.load_machine(machine_id=machine, project_name=project) + if isinstance(exec_config, str): + config_manager.load_exec_config( + exec_config_id=exec_config, project_name=project + ) + wf = flow_to_workflow( flow, machine=machine, store=store, exec_config=exec_config, resources=resources ) - config_manager = ConfigManager() - - # try to load the machine to check that the project and the machine are well defined - _ = config_manager.load_machine(machine_id=machine, project_name=project) - - proj_obj = config_manager.get_project(project) 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 add4433c..65d5e53d 100644 --- a/src/jobflow_remote/remote/data.py +++ b/src/jobflow_remote/remote/data.py @@ -3,6 +3,7 @@ import logging import os from pathlib import Path +from typing import Any from jobflow.core.store import JobStore from maggma.stores.mongolike import JSONStore @@ -30,15 +31,31 @@ def get_remote_files(fw, launch_id): return files -def get_remote_store(store, launch_dir): +def default_orjson_serializer(obj: Any) -> Any: + type_obj = type(obj) + if type_obj != float and issubclass(type_obj, float): + return float(obj) + raise TypeError + + +def get_remote_store( + store: JobStore, launch_dir: str | Path, add_orjson_serializer: bool = True +) -> JobStore: + serialization_default = None + if add_orjson_serializer: + serialization_default = default_orjson_serializer docs_store = JSONStore( - os.path.join(launch_dir, "remote_job_data.json"), read_only=False + os.path.join(launch_dir, "remote_job_data.json"), + read_only=False, + serialization_default=serialization_default, ) additional_stores = {} for k in store.additional_stores.keys(): additional_stores[k] = JSONStore( - os.path.join(launch_dir, f"additional_store_{k}.json"), read_only=False + os.path.join(launch_dir, f"additional_store_{k}.json"), + read_only=False, + serialization_default=serialization_default, ) remote_store = JobStore( docs_store=docs_store, @@ -47,11 +64,17 @@ def get_remote_store(store, launch_dir): load=store.load, ) - remote_store.connect() - return remote_store +def get_remote_store_filenames(store: JobStore) -> list[str]: + filenames = ["remote_job_data.json"] + for k in store.additional_stores.keys(): + filenames.append(f"additional_store_{k}.json") + + return filenames + + def update_store(store, remote_store, save): # TODO is it correct? diff --git a/src/jobflow_remote/remote/queue.py b/src/jobflow_remote/remote/queue.py index f925a9e8..b2b3da15 100644 --- a/src/jobflow_remote/remote/queue.py +++ b/src/jobflow_remote/remote/queue.py @@ -5,7 +5,7 @@ from qtoolkit.core.data_objects import CancelResult, QJob, QResources, SubmissionResult from qtoolkit.io.base import BaseSchedulerIO -from jobflow_remote.config.entities import Machine +from jobflow_remote.config.base import Machine from jobflow_remote.config.manager import ConfigManager from jobflow_remote.remote.host import BaseHost diff --git a/src/jobflow_remote/run/state.py b/src/jobflow_remote/run/state.py deleted file mode 100644 index d49fd9cb..00000000 --- a/src/jobflow_remote/run/state.py +++ /dev/null @@ -1,42 +0,0 @@ -from __future__ import annotations - -from enum import Enum - - -class RemoteState(Enum): - CHECKED_OUT = "CHECKED_OUT" - UPLOADED = "UPLOADED" - SUBMITTED = "SUBMITTED" - TERMINATED = "TERMINATED" - DOWNLOADED = "DOWNLOADED" - COMPLETED = "COMPLETED" - FAILED = "FAILED" - KILLED = "KILLED" - PAUSED = "PAUSED" - - @property - def next(self): - try: - return states_order[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 = states_order.index(self) - 1 - if prev_index >= 0: - return states_order[prev_index] - except ValueError: - raise RuntimeError(f"No previous state for state {self.name}") - - -states_order = [ - RemoteState.CHECKED_OUT, - RemoteState.UPLOADED, - RemoteState.SUBMITTED, - RemoteState.TERMINATED, - RemoteState.DOWNLOADED, - RemoteState.COMPLETED, -] diff --git a/src/jobflow_remote/utils/data.py b/src/jobflow_remote/utils/data.py index dd33ba9b..b4ed505f 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 typing import Any from uuid import UUID @@ -60,6 +61,18 @@ def remove_none(obj): return obj +def check_dict_keywords(obj: Any, keywords: list[str]) -> bool: + if isinstance(obj, (list, tuple, set)): + return any(check_dict_keywords(x, keywords) for x in obj) + elif isinstance(obj, dict): + for k, v in obj.items(): + if isinstance(k, str) and any(k.startswith(kw) for kw in keywords): + return True + if check_dict_keywords(v, keywords): + return True + return False + + def uuid_to_path(uuid: str, num_subdirs: int = 3, subdir_len: int = 2): u = UUID(uuid) u_hex = u.hex diff --git a/src/jobflow_remote/utils/db.py b/src/jobflow_remote/utils/db.py index d190103b..5643cac7 100644 --- a/src/jobflow_remote/utils/db.py +++ b/src/jobflow_remote/utils/db.py @@ -17,12 +17,20 @@ class MongoLock: LOCK_TIME_KEY = "_lock_time" def __init__( - self, collection, filter, update=None, timeout=None, lock_id=None, **kwargs + self, + collection, + filter, + update=None, + timeout=None, + break_lock=False, + lock_id=None, + **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 id(self) self.kwargs = kwargs @@ -33,18 +41,19 @@ def acquire(self): now = datetime.utcnow() db_filter = copy.deepcopy(self.filter) - lock_filter = {self.LOCK_KEY: {"$exists": False}} - lock_limit = None - 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] + if not self.break_lock: + lock_filter = {self.LOCK_KEY: {"$exists": False}} + lock_limit = None + 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(combined_filter) - else: - db_filter.update(lock_filter) + db_filter.update(lock_filter) lock_set = {self.LOCK_KEY: self.lock_id, self.LOCK_TIME_KEY: now} update = defaultdict(dict) From 161872f9be3e12e2a322fc41605f3a0f94a59a14 Mon Sep 17 00:00:00 2001 From: David Waroquiers Date: Fri, 2 Jun 2023 12:02:22 +0200 Subject: [PATCH 06/89] Added dependencies in pyproject.toml. --- pyproject.toml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 30ac84fe..8eb15e4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,14 @@ classifiers = [ requires-python = ">=3.8" dependencies =[ "jobflow", - "fireworks" + "fireworks", + "fabric", + "tomlkit", +# "qtoolkit", # Should be added here when released + "typer", + "rich", + "psutil", + "supervisor", ] [project.optional-dependencies] From 7cb1abcd68e2d6cbc36dfdbcf214de7838d51ecd Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Fri, 2 Jun 2023 16:39:48 +0200 Subject: [PATCH 07/89] updates on queries and CLI --- src/jobflow_remote/cli/flow.py | 8 ++ src/jobflow_remote/cli/formatting.py | 43 ++++--- src/jobflow_remote/cli/job.py | 142 +++++++++++++++++++--- src/jobflow_remote/cli/runner.py | 32 ++--- src/jobflow_remote/cli/types.py | 13 +- src/jobflow_remote/cli/utils.py | 35 +++--- src/jobflow_remote/fireworks/launchpad.py | 34 ++---- src/jobflow_remote/jobs/data.py | 61 +++++++++- src/jobflow_remote/jobs/jobcontroller.py | 68 ++++++++++- src/jobflow_remote/jobs/runner.py | 6 +- src/jobflow_remote/remote/host/remote.py | 4 + src/jobflow_remote/remote/queue.py | 21 ++++ src/jobflow_remote/utils/db.py | 4 +- 13 files changed, 360 insertions(+), 111 deletions(-) create mode 100644 src/jobflow_remote/cli/flow.py diff --git a/src/jobflow_remote/cli/flow.py b/src/jobflow_remote/cli/flow.py new file mode 100644 index 00000000..219610c0 --- /dev/null +++ b/src/jobflow_remote/cli/flow.py @@ -0,0 +1,8 @@ +import typer + +from jobflow_remote.cli.jf import app + +app_flow = typer.Typer( + name="flow", help="Commands for managing the flows", no_args_is_help=True +) +app.add_typer(app_flow) diff --git a/src/jobflow_remote/cli/formatting.py b/src/jobflow_remote/cli/formatting.py index 518cdf63..1a634150 100644 --- a/src/jobflow_remote/cli/formatting.py +++ b/src/jobflow_remote/cli/formatting.py @@ -6,35 +6,30 @@ from rich.scope import render_scope from rich.table import Table -from jobflow_remote.cli.utils import Verbosity, fmt_datetime +from jobflow_remote.cli.utils import fmt_datetime from jobflow_remote.jobs.data import JobInfo from jobflow_remote.jobs.state import JobState from jobflow_remote.utils.data import remove_none -def get_job_info_table( - jobs_info: list[JobInfo], verbosity: Verbosity = Verbosity.NORMAL -): +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("Job id") - v = verbosity.to_int() + table.add_column("Machine") + table.add_column("Last updated") - if v >= 10: - table.add_column("Machine") - table.add_column("Last updated") - if v < 30: - table.add_column("Locked") - - if v >= 20: + if verbosity >= 1: table.add_column("Queue id") table.add_column("Retry time") table.add_column("Prev state") + if verbosity < 2: + table.add_column("Locked") - if v >= 30: + if verbosity >= 2: table.add_column("Lock id") table.add_column("Lock time") @@ -44,14 +39,16 @@ def get_job_info_table( if ji.remote_state is not None and ji.state not in excluded_states: state += f" [{ji.remote_state.name}]" - row = [str(ji.db_id), ji.name, state, ji.job_id] - if v >= 10: - row.append(ji.machine) - row.append(ji.last_updated.strftime(fmt_datetime)) - if v < 30: - row.append("*" if ji.lock_id is not None else None) - - if v >= 20: + row = [ + str(ji.db_id), + ji.name, + state, + ji.job_id, + ji.machine, + ji.last_updated.strftime(fmt_datetime), + ] + + if verbosity >= 1: row.append(ji.queue_job_id) row.append( ji.retry_time_limit.strftime(fmt_datetime) @@ -61,8 +58,10 @@ def get_job_info_table( row.append( ji.remote_previous_state.name if ji.remote_previous_state else None ) + if verbosity < 2: + row.append("*" if ji.lock_id is not None else None) - if v >= 30: + if verbosity >= 2: row.append(ji.lock_id) row.append(ji.lock_time.strftime(fmt_datetime) if ji.lock_time else None) diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index e3e6cec6..5021f071 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -13,16 +13,18 @@ job_id_arg, job_ids_opt, job_state_opt, + remote_state_arg, remote_state_opt, start_date_opt, verbosity_opt, ) from jobflow_remote.cli.utils import ( - Verbosity, check_incompatible_opt, exit_with_error_msg, + get_job_db_ids, loading_spinner, out_console, + print_success_msg, ) from jobflow_remote.jobs.jobcontroller import JobController @@ -41,7 +43,7 @@ def jobs_list( start_date: start_date_opt = None, end_date: end_date_opt = None, days: days_opt = None, - verbosity: verbosity_opt = Verbosity.NORMAL.value, + verbosity: verbosity_opt = 0, ): """ Get the list of Jobs in the database @@ -92,27 +94,127 @@ def job_info( ), ] = False, ): + """ + Detail information on a specific job + """ - jc = JobController() + with loading_spinner(): + + jc = JobController() - if db_id: - try: - db_id_value = int(job_id) - except ValueError: - raise typer.BadParameter( - "if --db-id is selected the ID should be an integer" - ) - job_id_value = None - else: - job_id_value = job_id - db_id_value = None - - job_info = jc.get_job_info( - job_id=job_id_value, - db_id=db_id_value, - full=with_error, - ) + db_id_value, job_id_value = get_job_db_ids(db_id, job_id) + + job_info = jc.get_job_info( + job_id=job_id_value, + db_id=db_id_value, + full=with_error, + ) if not job_info: exit_with_error_msg("No data matching the request") out_console.print(format_job_info(job_info, show_none=show_none)) + + +@app_job.command() +def reset_failed( + job_id: job_id_arg, + db_id: db_id_flag_opt = False, +): + """ + For a job with a FAILED remote state reset it to the previous state + """ + with loading_spinner(): + jc = JobController() + + db_id_value, job_id_value = get_job_db_ids(db_id, job_id) + + succeeded = jc.reset_failed_state( + job_id=job_id_value, + db_id=db_id_value, + ) + + if not succeeded: + exit_with_error_msg("Could not reset failed state") + + print_success_msg() + + +@app_job.command() +def reset_remote_attempts( + job_id: job_id_arg, + db_id: db_id_flag_opt = False, +): + """ + Resets the number of attempts to perform a remote action and eliminates + the delay in retrying. This will not restore a Jon from its failed state. + """ + with loading_spinner(): + jc = JobController() + + db_id_value, job_id_value = get_job_db_ids(db_id, job_id) + + succeeded = jc.reset_remote_attempts( + job_id=job_id_value, + db_id=db_id_value, + ) + + if not succeeded: + exit_with_error_msg("Could not reset the remote attempts") + + print_success_msg() + + +@app_job.command() +def set_remote_state( + job_id: job_id_arg, + state: remote_state_arg, + db_id: db_id_flag_opt = False, +): + """ + Sets the remote state to an arbitrary value. + WARNING: this can lead to inconsistencies in the DB. Use with care + """ + with loading_spinner(): + jc = JobController() + + db_id_value, job_id_value = get_job_db_ids(db_id, job_id) + + succeeded = jc.set_remote_state( + state=state, + job_id=job_id_value, + db_id=db_id_value, + ) + + if not succeeded: + exit_with_error_msg("Could not reset the remote attempts") + + print_success_msg() + + +@app_job.command() +def rerun( + job_id: job_ids_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, +): + """ + Rerun Jobs + """ + check_incompatible_opt({"state": state, "remote-state": remote_state}) + + jc = JobController() + + with loading_spinner(): + fw_ids = jc.rerun_jobs( + job_ids=job_id, + 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}") diff --git a/src/jobflow_remote/cli/runner.py b/src/jobflow_remote/cli/runner.py index d51f817a..266279f2 100644 --- a/src/jobflow_remote/cli/runner.py +++ b/src/jobflow_remote/cli/runner.py @@ -113,7 +113,7 @@ def kill(): @app_runner.command() -def shut_down(): +def shutdown(): """ Shuts down the supervisord process. Note that if the daemon is running it will wait for the daemon to stop. @@ -161,21 +161,21 @@ def pids(): Both the supervisord process and the processing running the Runner. """ dm = DaemonManager() - with loading_spinner(): - try: + pids_dict = None + try: + with loading_spinner(): pids_dict = dm.get_pids() - if not pids_dict: - exit_with_warning_msg("Daemon is not running") - table = Table() - table.add_column("Process") - table.add_column("PID") - - for name, pid in pids_dict.items(): - table.add_row(name, str(pid)) - - except DaemonError as e: - exit_with_error_msg( - f"Error while stopping the daemon: {getattr(e, 'message', e)}" - ) + except DaemonError as e: + exit_with_error_msg( + f"Error while stopping the daemon: {getattr(e, 'message', e)}" + ) + if not pids_dict: + exit_with_warning_msg("Daemon is not running") + table = Table() + table.add_column("Process") + table.add_column("PID") + + for name, pid in pids_dict.items(): + table.add_row(name, str(pid)) out_console.print(table) diff --git a/src/jobflow_remote/cli/types.py b/src/jobflow_remote/cli/types.py index 6598f197..c0f0fd7f 100644 --- a/src/jobflow_remote/cli/types.py +++ b/src/jobflow_remote/cli/types.py @@ -4,7 +4,7 @@ import typer from typing_extensions import Annotated -from jobflow_remote.cli.utils import LogLevel, Verbosity +from jobflow_remote.cli.utils import LogLevel from jobflow_remote.jobs.state import JobState, RemoteState job_ids_opt = Annotated[ @@ -47,6 +47,11 @@ ] +remote_state_arg = Annotated[ + RemoteState, typer.Argument(help="One of the remote states") +] + + start_date_opt = Annotated[ Optional[datetime], typer.Option( @@ -78,11 +83,9 @@ verbosity_opt = Annotated[ - Verbosity, + int, typer.Option( - "--verbosity", - "-v", - help="Set the verbosity of the output", + "--verbosity", "-v", help="Set the verbosity of the output", count=True ), ] diff --git a/src/jobflow_remote/cli/utils.py b/src/jobflow_remote/cli/utils.py index bc5320bb..8a198031 100644 --- a/src/jobflow_remote/cli/utils.py +++ b/src/jobflow_remote/cli/utils.py @@ -15,21 +15,6 @@ fmt_datetime = "%Y-%m-%d %H:%M" -class Verbosity(Enum): - MINIMAL = "minimal" - NORMAL = "normal" - DETAILED = "detailed" - DIAGNOSTIC = "diagnostic" - - def to_int(self) -> int: - return { - Verbosity.MINIMAL: 0, - Verbosity.NORMAL: 10, - Verbosity.DETAILED: 20, - Verbosity.DIAGNOSTIC: 30, - }[self] - - class LogLevel(Enum): ERROR = "error" WARN = "warn" @@ -57,6 +42,11 @@ def exit_with_warning_msg(message, code=0, **kwargs): raise typer.Exit(code) +def print_success_msg(message="operation completed", **kwargs): + kwargs.setdefault("style", "green") + out_console.print(message, **kwargs) + + def check_incompatible_opt(d: dict): not_none = [] for k, v in d.items(): @@ -104,3 +94,18 @@ def loading_spinner(processing: bool = True): if processing: progress.add_task(description="Processing...", total=None) yield progress + + +def get_job_db_ids(db_id, job_id): + if db_id: + try: + db_id_value = int(job_id) + except ValueError: + raise typer.BadParameter( + "if --db-id is selected the ID should be an integer" + ) + job_id_value = None + else: + job_id_value = job_id + db_id_value = None + return db_id_value, job_id_value diff --git a/src/jobflow_remote/fireworks/launchpad.py b/src/jobflow_remote/fireworks/launchpad.py index e866b0d8..701715fb 100644 --- a/src/jobflow_remote/fireworks/launchpad.py +++ b/src/jobflow_remote/fireworks/launchpad.py @@ -254,9 +254,9 @@ def recover_remote( exit=True, ) self.lpad.complete_launch(launch_id, m_action, "FIZZLED") - self.remote_runs.update_one( - {"launch_id": launch_id}, {"$set": {"completed": True}} - ) + # self.remote_runs.update_one( + # {"launch_id": launch_id}, {"$set": {"completed": True}} + # ) completed = True return m_launch.fw_id, completed @@ -373,30 +373,21 @@ def rerun_fw( self.remote_runs.delete_many({"fw_id": {"$in": rerun_fw_ids}}) - def set_remote_state( + def set_remote_values( self, - state: RemoteState, + values: dict, fw_id: int | None, job_id: str | None = None, break_lock: bool = False, - ): + ) -> bool: lock_filter = self._generate_id_query(fw_id, job_id) with MongoLock( collection=self.remote_runs, filter=lock_filter, break_lock=break_lock ) as lock: if lock.locked_document: - lock.update_on_release = { - "$set": { - "state": state.value, - "updated_on": datetime.datetime.utcnow().isoformat(), - "completed": False, - "step_attempts": 0, - "retry_time_limit": None, - "previous_state": None, - "queue_state": None, - "error": None, - } - } + values = dict(values) + values["updated_on"] = datetime.datetime.utcnow().isoformat() + lock.update_on_release = {"$set": values} return True return False @@ -411,14 +402,16 @@ def remove_lock(self, fw_id: int | None = None, job_id: str | None = None): if not result: raise ValueError("No job matching id") - def is_locked(self, fw_id: int | None = None, job_id: str | None = None): + def is_locked(self, fw_id: int | None = None, job_id: str | None = None) -> bool: query = self._generate_id_query(fw_id, job_id) result = self.remote_runs.find_one(query, projection=[MongoLock.LOCK_KEY]) if not result: raise ValueError("No job matching id") return MongoLock.LOCK_KEY in result - def reset_failed_state(self, fw_id: int | None = None, job_id: str | None = None): + def reset_failed_state( + self, fw_id: int | None = None, job_id: str | None = None + ) -> bool: lock_filter = self._generate_id_query(fw_id, job_id) with MongoLock(collection=self.remote_runs, filter=lock_filter) as lock: doc = lock.locked_document @@ -437,7 +430,6 @@ def reset_failed_state(self, fw_id: int | None = None, job_id: str | None = None "$set": { "state": previous_state, "updated_on": datetime.datetime.utcnow().isoformat(), - "completed": False, "step_attempts": 0, "retry_time_limit": None, "previous_state": None, diff --git a/src/jobflow_remote/jobs/data.py b/src/jobflow_remote/jobs/data.py index 73f66836..65f57f08 100644 --- a/src/jobflow_remote/jobs/data.py +++ b/src/jobflow_remote/jobs/data.py @@ -6,7 +6,7 @@ from jobflow import Job from jobflow_remote.fireworks.launchpad import fw_uuid -from jobflow_remote.jobs.state import JobState, RemoteState +from jobflow_remote.jobs.state import FlowState, JobState, RemoteState @dataclass @@ -74,7 +74,7 @@ def from_query_dict(cls, d): last_updated = fw_update_date # 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("state") + remote_previous_state_val = remote.get("previous_state") remote_previous_state = ( RemoteState(remote_previous_state_val) if remote_previous_state_val is not None @@ -117,3 +117,60 @@ def from_query_dict(cls, d): error_remote=remote.get("error"), error_job=error_job, ) + + +flow_info_projection = { + "fws.fw_id": 1, + f"fws.{fw_uuid}": 1, + "fws.state": 1, + "remote.state": 1, + "name": 1, + "updated_on": 1, + "fws.updated_on": 1, + "remote.updated_on": 1, + "fws.spec._tasks.machine": 1, +} + + +@dataclass +class FlowInfo: + db_ids: int + job_ids: str + state: FlowState + name: str + last_updated: datetime + machines: list[str] + job_states: list[JobState] + job_names: list[str] + + # @classmethod + # def from_query_dict(cls, d): + # fws = d.get("fws") or [] + # remotes = d.get("remote") or [] + # + # matched_data = defaultdict(list) + # for fw in fws: + # matched_data[fw.get(fw_uuid)].append(fw) + # for r in remotes: + # matched_data[r.get("job_id")].append(r) + # + # machines = [] + # job_states = [] + # job_names = [] + # last_updated_list = [] + # db_ids = [] + # job_ids = [] + # for job_id, (fw, r) in matched_data.items(): + # job_ids.append(job_id) + # db_ids.append(fw.get("fw_id")) + # + # last_updated_list.append(datetime.fromisoformat(d["updated_on"])) + # remote_update_date = remote.get("updated_on") + # if remote_update_date: + # remote_update_date = datetime.fromisoformat(d["updated_on"]) + # last_updated = max(fw_update_date, remote_update_date) + # else: + # last_updated = fw_update_date + # last_updated_list + # + # # for the last updated field, collect all the dates and take the latest one diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index 417975e8..4a35cb5a 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -9,7 +9,7 @@ from jobflow_remote.config.base import Project from jobflow_remote.config.manager import ConfigManager from jobflow_remote.fireworks.launchpad import RemoteLaunchPad -from jobflow_remote.jobs.data import JobData, JobInfo, job_info_projection +from jobflow_remote.jobs.data import FlowInfo, JobData, JobInfo, job_info_projection from jobflow_remote.jobs.state import FlowState, JobState, RemoteState logger = logging.getLogger(__name__) @@ -224,10 +224,7 @@ def get_jobs_info( def get_job_info( self, job_id: str | None, db_id: int | None, full: bool = False ) -> JobInfo | 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" - ) + self.check_ids(job_id, db_id) query = self._build_query_fw(job_ids=job_id, db_ids=db_id) if full: @@ -250,6 +247,13 @@ def get_job_info( return JobInfo.from_query_dict(data[0]) + @staticmethod + def check_ids(job_id: str | None, db_id: int | 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" + ) + def rerun_jobs( self, job_ids: str | list[str] | None = None, @@ -292,3 +296,57 @@ def reset(self, reset_output: bool = False, max_limit: int = 25) -> bool: self.jobstore.remove_docs({}) return True + + def set_remote_state( + self, state: RemoteState, job_id: str | None, db_id: int | None + ) -> bool: + self.check_ids(job_id, db_id) + values = { + "state": state.value, + "step_attempts": 0, + "retry_time_limit": None, + "previous_state": None, + "queue_state": None, + "error": None, + } + return self.rlpad.set_remote_values(values=values, job_id=job_id, fw_id=db_id) + + def reset_remote_attempts(self, job_id: str | None, db_id: int | None) -> bool: + self.check_ids(job_id, db_id) + values = { + "step_attempts": 0, + "retry_time_limit": None, + } + return self.rlpad.set_remote_values(values=values, job_id=job_id, fw_id=db_id) + + def reset_failed_state(self, job_id: str | None, db_id: int | None) -> bool: + self.check_ids(job_id, db_id) + return self.rlpad.reset_failed_state(job_id=job_id, fw_id=db_id) + + def get_flows_info( + self, + job_ids: str | list[str] | None = None, + db_ids: int | list[int] | None = None, + state: FlowState | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, + sort: dict | None = None, + limit: int = 0, + ) -> list[FlowInfo]: + query = self._build_query_wf( + job_ids=job_ids, + db_ids=db_ids, + state=state, + start_date=start_date, + end_date=end_date, + ) + + data = self.rlpad.get_wf_fw_remote_run_data( + query=query, sort=sort, limit=limit, projection=job_info_projection + ) + + jobs_data = [] + for d in data: + jobs_data.append(JobInfo.from_query_dict(d)) + + return jobs_data diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index 62bc1e2a..7d207487 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -36,7 +36,7 @@ get_remote_store_filenames, ) from jobflow_remote.remote.host import BaseHost -from jobflow_remote.remote.queue import QueueManager +from jobflow_remote.remote.queue import 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 @@ -302,7 +302,6 @@ def submit(self, doc): fw_id = doc["fw_id"] logger.debug(f"submit fw_id: {doc['fw_id']}") fw_job_data = self.get_fw_data(fw_id) - fw_job_data.job remote_path = doc["run_dir"] @@ -310,7 +309,8 @@ def submit(self, doc): machine = fw_job_data.machine queue_manager = self.get_queue_manager(machine.machine_id) - resources = fw_job_data.task.get("resources") or machine.resources + resources = fw_job_data.task.get("resources") or machine.resources or {} + set_name_out(resources, fw_job_data.job.name) exec_config = fw_job_data.task.get("exec_config") if isinstance(exec_config, str): exec_config = self.config_manager.load_exec_config( diff --git a/src/jobflow_remote/remote/host/remote.py b/src/jobflow_remote/remote/host/remote.py index cc7daba2..f48928a7 100644 --- a/src/jobflow_remote/remote/host/remote.py +++ b/src/jobflow_remote/remote/host/remote.py @@ -28,6 +28,7 @@ def __init__( connect_kwargs=None, inline_ssh_env=None, timeout_execute=None, + keepalive=60, ): self.host = host self.user = user @@ -39,6 +40,7 @@ def __init__( self.connect_kwargs = connect_kwargs self.inline_ssh_env = inline_ssh_env self.timeout_execute = timeout_execute + self.keepalive = keepalive self._connection = fabric.Connection( host=self.host, user=self.user, @@ -111,6 +113,8 @@ def write_text_file(self, filepath: str | Path, content: str): def connect(self): self.connection.open() + if self.keepalive: + self.connection.transport.set_keepalive(self.keepalive) def close(self) -> bool: try: diff --git a/src/jobflow_remote/remote/queue.py b/src/jobflow_remote/remote/queue.py index b2b3da15..5d30c887 100644 --- a/src/jobflow_remote/remote/queue.py +++ b/src/jobflow_remote/remote/queue.py @@ -9,6 +9,27 @@ from jobflow_remote.config.manager import ConfigManager from jobflow_remote.remote.host import BaseHost +OUT_FNAME = "queue.out" +ERR_FNAME = "queue.err" + + +def set_name_out( + resources: dict | QResources, + name: str, + out_fpath: str | Path = OUT_FNAME, + err_fpath: str | Path = ERR_FNAME, +): + # sanitize the name + name = name.replace(" ", "_") + if isinstance(resources, QResources): + resources.job_name = name + resources.output_filepath = out_fpath + resources.error_filepath = err_fpath + else: + resources["job_name"] = name + resources["qout_path"] = out_fpath + resources["qerr_path"] = err_fpath + class QueueManager: """Base class for job queues. diff --git a/src/jobflow_remote/utils/db.py b/src/jobflow_remote/utils/db.py index 5643cac7..c9a4f608 100644 --- a/src/jobflow_remote/utils/db.py +++ b/src/jobflow_remote/utils/db.py @@ -41,9 +41,9 @@ def acquire(self): now = datetime.utcnow() db_filter = copy.deepcopy(self.filter) + lock_limit = None if not self.break_lock: lock_filter = {self.LOCK_KEY: {"$exists": False}} - lock_limit = None if self.timeout: lock_limit = now - timedelta(seconds=self.timeout) time_filter = {self.LOCK_TIME_KEY: {"$lt": lock_limit}} @@ -79,7 +79,7 @@ def acquire(self): 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: ""}} - # TODO maybe set on release only if not exception was raised? + # 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}") From a370bee4967aef65b3fb1d1a732983ed005a03e8 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Mon, 5 Jun 2023 13:29:54 +0200 Subject: [PATCH 08/89] move remote document inside Firework document --- src/jobflow_remote/cli/job.py | 20 +- src/jobflow_remote/cli/types.py | 30 +- src/jobflow_remote/cli/utils.py | 12 + src/jobflow_remote/fireworks/convert.py | 9 +- src/jobflow_remote/fireworks/launcher.py | 2 +- src/jobflow_remote/fireworks/launchpad.py | 362 ++++++++++++---------- src/jobflow_remote/jobs/data.py | 125 ++++---- src/jobflow_remote/jobs/jobcontroller.py | 40 ++- src/jobflow_remote/jobs/runner.py | 167 ++++++---- src/jobflow_remote/jobs/state.py | 6 +- src/jobflow_remote/utils/db.py | 42 ++- 11 files changed, 512 insertions(+), 303 deletions(-) diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index 5021f071..a7876586 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -13,12 +13,16 @@ job_id_arg, job_ids_opt, job_state_opt, + max_results_opt, remote_state_arg, remote_state_opt, + reverse_sort_flag_opt, + sort_opt, start_date_opt, verbosity_opt, ) from jobflow_remote.cli.utils import ( + SortOption, check_incompatible_opt, exit_with_error_msg, get_job_db_ids, @@ -44,6 +48,9 @@ def jobs_list( end_date: end_date_opt = None, days: days_opt = None, verbosity: verbosity_opt = 0, + max_results: max_results_opt = 100, + sort: sort_opt = SortOption.UPDATED_ON.value, + reverse_sort: reverse_sort_flag_opt = False, ): """ Get the list of Jobs in the database @@ -57,6 +64,8 @@ def jobs_list( if days: start_date = datetime.now() - timedelta(days=days) + sort = [(sort.query_field, 1 if reverse_sort else -1)] + with loading_spinner(): jobs_info = jc.get_jobs_info( job_ids=job_id, @@ -65,12 +74,19 @@ def jobs_list( remote_state=remote_state, start_date=start_date, end_date=end_date, + limit=max_results, + sort=sort, ) table = get_job_info_table(jobs_info, verbosity=verbosity) - console = out_console - console.print(table) + if max_results and len(jobs_info) == max_results: + out_console.print( + f"The number of Jobs printed is limited by the maximum selected: {max_results}", + style="yellow", + ) + + out_console.print(table) @app_job.command(name="info") diff --git a/src/jobflow_remote/cli/types.py b/src/jobflow_remote/cli/types.py index c0f0fd7f..c7033ed9 100644 --- a/src/jobflow_remote/cli/types.py +++ b/src/jobflow_remote/cli/types.py @@ -4,7 +4,7 @@ import typer from typing_extensions import Annotated -from jobflow_remote.cli.utils import LogLevel +from jobflow_remote.cli.utils import LogLevel, SortOption from jobflow_remote.jobs.state import JobState, RemoteState job_ids_opt = Annotated[ @@ -108,6 +108,34 @@ ), ] +max_results_opt = Annotated[ + int, + typer.Option( + "--max-results", + "-m", + help="Limit the maximum number of returned results. Set 0 for no limit", + ), +] + + +sort_opt = Annotated[ + SortOption, + typer.Option( + "--sort", + help="The field on which the results will be sorted. In descending order", + ), +] + + +reverse_sort_flag_opt = Annotated[ + bool, + typer.Option( + "--reverse-sort", + "-revs", + help=("Reverse the sorting order"), + ), +] + job_id_arg = Annotated[str, typer.Argument(help="The ID of the job (i.e. the uuid)")] diff --git a/src/jobflow_remote/cli/utils.py b/src/jobflow_remote/cli/utils.py index 8a198031..b9e624b6 100644 --- a/src/jobflow_remote/cli/utils.py +++ b/src/jobflow_remote/cli/utils.py @@ -30,6 +30,18 @@ def to_logging(self) -> int: }[self] +class SortOption(Enum): + CREATED_ON = "created_on" + 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 + + def exit_with_error_msg(message, code=1, **kwargs): kwargs.setdefault("style", "red") err_console.print(message, **kwargs) diff --git a/src/jobflow_remote/fireworks/convert.py b/src/jobflow_remote/fireworks/convert.py index c3d1d32d..ca39124c 100644 --- a/src/jobflow_remote/fireworks/convert.py +++ b/src/jobflow_remote/fireworks/convert.py @@ -22,6 +22,7 @@ def flow_to_workflow( store: jobflow.JobStore | None = None, exec_config: str | ExecutionConfig = None, resources: dict | QResources | None = None, + metadata: dict | None = None, **kwargs, ) -> Workflow: """ @@ -48,6 +49,9 @@ def flow_to_workflow( 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". **kwargs Keyword arguments passed to Workflow init method. @@ -76,7 +80,10 @@ def flow_to_workflow( ) fireworks.append(fw) - return Workflow(fireworks, name=flow.name, **kwargs) + metadata = metadata or {} + metadata["flow_id"] = flow.uuid + + return Workflow(fireworks, name=flow.name, metadata=metadata, **kwargs) def job_to_firework( diff --git a/src/jobflow_remote/fireworks/launcher.py b/src/jobflow_remote/fireworks/launcher.py index 489d4fe0..0da92980 100644 --- a/src/jobflow_remote/fireworks/launcher.py +++ b/src/jobflow_remote/fireworks/launcher.py @@ -51,7 +51,7 @@ def checkout_remote( f"Un-reserving FW with fw_id, launch_id: {fw.fw_id}, {launch_id}" ) rlpad.lpad.cancel_reservation(launch_id) - rlpad.forget_remote(launch_id) + rlpad.forget_remote(fw.fw_id) except Exception: logger.exception(f"Error unreserving FW with fw_id {fw.fw_id}") diff --git a/src/jobflow_remote/fireworks/launchpad.py b/src/jobflow_remote/fireworks/launchpad.py index 701715fb..7b30a038 100644 --- a/src/jobflow_remote/fireworks/launchpad.py +++ b/src/jobflow_remote/fireworks/launchpad.py @@ -6,8 +6,8 @@ from dataclasses import asdict, dataclass from fireworks import Firework, FWAction, Launch, LaunchPad, Workflow -from fireworks.core.launchpad import get_action_from_gridfs -from fireworks.utilities.fw_serializers import reconstitute_dates +from fireworks.core.launchpad import WFLock, get_action_from_gridfs +from fireworks.utilities.fw_serializers import reconstitute_dates, recursive_dict from pymongo import ASCENDING from qtoolkit.core.data_objects import QState @@ -19,18 +19,25 @@ logger = logging.getLogger(__name__) -fw_uuid = "spec._tasks.job.uuid" +FW_UUID_PATH = "spec._tasks.job.uuid" +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: - fw_id: int launch_id: int - name: str - job_id: str - machine_id: str - created_on: str = datetime.datetime.utcnow().isoformat() - updated_on: str = datetime.datetime.utcnow().isoformat() state: RemoteState = RemoteState.CHECKED_OUT step_attempts: int = 0 retry_time_limit: datetime.datetime | None = None @@ -60,7 +67,6 @@ def from_db_dict(cls, d: dict) -> RemoteRun: d["state"] = RemoteState(d["state"]) d["previous_state"] = RemoteState(d["previous_state"]) d["queue_state"] = QState(d["queue_state"]) - d.pop("_id", None) d["lock_id"] = d.pop(MongoLock.LOCK_KEY, None) d["lock_time"] = d.pop(MongoLock.LOCK_TIME_KEY, None) return cls(**d) @@ -73,7 +79,6 @@ def is_locked(self) -> bool: class RemoteLaunchPad: def __init__(self, **kwargs): self.lpad = LaunchPad(**kwargs) - self.remote_runs = self.db.remote_runs self.archived_remote_runs = self.db.archived_remote_runs @property @@ -94,13 +99,9 @@ def launches(self): def reset(self, password, require_password=True, max_reset_wo_password=25): self.lpad.reset(password, require_password, max_reset_wo_password) - self.remote_runs.delete_many({}) - self.fireworks.create_index(fw_uuid, unique=True, background=True) - self.remote_runs.create_index("job_id", unique=True, background=True) - self.remote_runs.create_index("launch_id", unique=True, background=True) - self.remote_runs.create_index("fw_id", unique=True, background=True) + self.fireworks.create_index(FW_UUID_PATH, unique=True, background=True) - def forget_remote(self, launchid_or_fwid, launch_mode=True): + def forget_remote(self, fwid): """ Delete the remote run document for the given launch or firework id. @@ -108,12 +109,9 @@ def forget_remote(self, launchid_or_fwid, launch_mode=True): launchid_or_fwid (int): launch od or firework id launch_mode (bool): if True then launch id is given. """ - q = ( - {"launch_id": launchid_or_fwid} - if launch_mode - else {"fw_id": launchid_or_fwid} - ) - self.db.remote_runs.delete_many(q) + q = {"fw_id": fwid} + + self.db.fireworks.update_one(q, {"$unset": {"spec._remote": ""}}) def add_remote_run(self, launch_id, fw): """ @@ -123,16 +121,12 @@ def add_remote_run(self, launch_id, fw): launch_id (int): launch id """ task = fw.tasks[0] - job = task.get("job") - remote_run = RemoteRun( - fw_id=fw.fw_id, - launch_id=launch_id, - name=fw.name, - job_id=job.uuid, - machine_id=task.get("machine"), - ) + task.get("job") + remote_run = RemoteRun(launch_id) - self.db.remote_runs.insert_one(remote_run.as_db_dict()) + self.db.fireworks.update_one( + {"fw_id": fw.fw_id}, {"$set": {REMOTE_DOC_PATH: remote_run.as_db_dict()}} + ) def recover_remote( self, @@ -218,20 +212,13 @@ def recover_remote( { "$set": { "state": "RUNNING", - "updated_on": datetime.datetime.utcnow(), + "updated_on": datetime.datetime.utcnow().isoformat(), } }, ) if f: self.lpad._refresh_wf(fw_id) - # update the updated_on - self.remote_runs.update_one( - {"launch_id": launch_id}, - {"$set": {"updated_on": datetime.datetime.utcnow().isoformat()}}, - ) - # return None - if completed: update_store(store, remote_store, save) @@ -254,9 +241,7 @@ def recover_remote( exit=True, ) self.lpad.complete_launch(launch_id, m_action, "FIZZLED") - # self.remote_runs.update_one( - # {"launch_id": launch_id}, {"$set": {"completed": True}} - # ) + completed = True return m_launch.fw_id, completed @@ -316,7 +301,7 @@ def _generate_id_query(fw_id: int | None = None, job_id: str | None = None) -> d if fw_id: query["fw_id"] = fw_id if job_id: - query[fw_uuid] = job_id + query[FW_UUID_PATH] = job_id if not query: raise ValueError("At least one among fw_id and job_id should be specified") return query @@ -347,7 +332,7 @@ def get_fw(self, fw_id: int | None = None, job_id: str | None = None): return Firework.from_dict(self.get_fw_dict(fw_id, job_id)) def get_fw_id_from_job_id(self, job_id: str): - fw_dict = self.fireworks.find_one({fw_uuid: job_id}, projection=["fw_id"]) + fw_dict = self.fireworks.find_one({FW_UUID_PATH: job_id}, projection=["fw_id"]) if not fw_dict: raise ValueError(f"No Firework exists with id: {job_id}") @@ -357,21 +342,87 @@ def rerun_fw( self, fw_id: int | None = None, job_id: str | None = None, - rerun_duplicates: bool = True, recover_launch: int | str | None = None, recover_mode: str | None = None, ): - fw_id, job_id = self._check_ids(fw_id, job_id) - rerun_fw_ids = self.lpad.rerun_fw( - fw_id, rerun_duplicates, recover_launch, recover_mode - ) + """ + 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 - to_archive = self.remote_runs.find({"fw_id": {"$in": rerun_fw_ids}}) - for doc in to_archive: - doc.pop("_id", None) - self.archived_remote_runs.insert(doc) + Returns: + [int]: list of firework ids that were rerun + """ + if job_id is None and fw_id is None: + raise ValueError("At least one among fw_id and job_id should be defined") - self.remote_runs.delete_many({"fw_id": {"$in": rerun_fw_ids}}) + if job_id: + m_fw = self.fireworks.find_one( + {FW_UUID_PATH: job_id}, {"state": 1, "fw_id": 1} + ) + else: + m_fw = self.fireworks.find_one({"fw_id": fw_id}, {"state": 1, "fw_id": 1}) + + 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, @@ -382,10 +433,13 @@ def set_remote_values( ) -> bool: lock_filter = self._generate_id_query(fw_id, job_id) with MongoLock( - collection=self.remote_runs, filter=lock_filter, break_lock=break_lock + collection=self.fireworks, + filter=lock_filter, + break_lock=break_lock, + lock_subdoc=REMOTE_DOC_PATH, ) as lock: if lock.locked_document: - values = dict(values) + 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 @@ -394,9 +448,9 @@ def set_remote_values( def remove_lock(self, fw_id: int | None = None, job_id: str | None = None): query = self._generate_id_query(fw_id, job_id) - result = self.remote_runs.find_one_and_update( + result = self.fireworks.find_one_and_update( query, - {"$unset": {MongoLock.LOCK_KEY: "", MongoLock.LOCK_TIME_KEY: ""}}, + {"$unset": {REMOTE_LOCK_PATH: "", REMOTE_LOCK_TIME_PATH: ""}}, projection=["fw_id"], ) if not result: @@ -404,39 +458,45 @@ def remove_lock(self, fw_id: int | None = None, job_id: str | None = None): def is_locked(self, fw_id: int | None = None, job_id: str | None = None) -> bool: query = self._generate_id_query(fw_id, job_id) - result = self.remote_runs.find_one(query, projection=[MongoLock.LOCK_KEY]) + result = self.fireworks.find_one(query, projection=[REMOTE_LOCK_PATH]) if not result: raise ValueError("No job matching id") - return MongoLock.LOCK_KEY in result + return REMOTE_LOCK_PATH in result def reset_failed_state( self, fw_id: int | None = None, job_id: str | None = None ) -> bool: lock_filter = self._generate_id_query(fw_id, job_id) - with MongoLock(collection=self.remote_runs, filter=lock_filter) as lock: + with MongoLock( + collection=self.fireworks, filter=lock_filter, lock_subdoc=REMOTE_DOC_PATH + ) as lock: doc = lock.locked_document - if doc: - state = doc["state"] + 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 = doc["previous_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" ) - lock.update_on_release = { - "$set": { - "state": previous_state, - "updated_on": datetime.datetime.utcnow().isoformat(), - "step_attempts": 0, - "retry_time_limit": None, - "previous_state": None, - "queue_state": None, - "error": None, - } + 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 @@ -451,21 +511,32 @@ def delete_wf(self, fw_id: int | None = None, job_id: str | None = None): links_dict = self.workflows.find_one({"nodes": fw_id}) fw_ids = links_dict["nodes"] self.lpad.delete_fws(fw_ids, delete_launch_dirs=False) - self.remote_runs.delete_many({"fw_id": {"$in": fw_ids}}) 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 ) -> RemoteRun: - query = self._generate_id_query(fw_id, job_id) - remote_run_dict = self.remote_runs.find_one(query) - if not remote_run_dict: + query: dict = {} + if job_id: + query[FW_UUID_PATH] = job_id + if fw_id: + query["fw_id"] = fw_id + + if not query: + raise ValueError("At least one among fw_id and job_id should be defined") + + fw = self.fireworks.find_one(query) + if not fw: + raise ValueError(f"No Job exists with fw id: {fw_id} or job_id {job_id}") + + remote_dict = get_remote_doc(fw) + if not remote_dict: raise ValueError( - f"No Firework exists with fw id: {fw_id} or job_id {job_id}" + f"No Remote run exists with fw id: {fw_id} or job_id {job_id}" ) - return RemoteRun.from_db_dict(remote_run_dict) + return RemoteRun.from_db_dict(remote_dict) def get_fws( self, query: dict | None = None, sort: list[tuple] | None = None, limit: int = 0 @@ -477,38 +548,38 @@ def get_fws( fws.append(Firework.from_dict(doc)) return fws - def get_fw_remote_run_data( - self, - query: dict | None = None, - projection: dict | None = None, - sort: dict | None = None, - limit: int = 0, - ) -> list[dict]: - - pipeline: list[dict] = [ - { - "$lookup": { - "from": "remote_runs", - "localField": "fw_id", - "foreignField": "fw_id", - "as": "remote", - } - } - ] - - 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)) + # def get_fw_remote_run_data( + # self, + # query: dict | None = None, + # projection: dict | None = None, + # sort: dict | None = None, + # limit: int = 0, + # ) -> list[dict]: + # + # pipeline: list[dict] = [ + # { + # "$lookup": { + # "from": "remote_runs", + # "localField": "fw_id", + # "foreignField": "fw_id", + # "as": "remote", + # } + # } + # ] + # + # 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)) def get_fw_remote_run( self, @@ -517,23 +588,19 @@ def get_fw_remote_run( sort: dict | None = None, limit: int = 0, ) -> list[tuple[Firework, RemoteRun | None]]: - raw_data = self.get_fw_remote_run_data( + fws = self.fireworks.find( query=query, projection=projection, sort=sort, limit=limit ) data = [] - for d in raw_data: - r = d.pop("remote", None) + for fw_dict in fws: + r = get_remote_doc(fw_dict) if r: - if len(r) > 1: - raise RuntimeError( - f"error retrieving the remote_run document. {len(r)} found. Expected 1." - ) - remote_run = RemoteRun.from_db_dict(r[0]) + remote_run = RemoteRun.from_db_dict(r) else: remote_run = None - fw = Firework.from_dict(d) + fw = Firework.from_dict(fw_dict) data.append((fw, remote_run)) return data @@ -541,15 +608,9 @@ def get_fw_remote_run( def get_fw_ids( self, query: dict | None = None, sort: dict | None = None, limit: int = 0 ) -> list[int]: - remote_required = check_dict_keywords(query, ["remote."]) - if remote_required: - result = self.get_fw_remote_run_data( - query=query, sort=sort, limit=limit, projection={"fw_id": 1} - ) - else: - result = self.fireworks.find( - query, sort=sort, limit=limit, projection=["fw_id"] - ) + result = self.fireworks.find( + query=query, sort=sort, limit=limit, projection={"fw_id": 1} + ) fw_ids = [] for doc in result: @@ -566,13 +627,13 @@ def get_fw_remote_run_from_id( if fw_id: query["fw_id"] = fw_id if job_id: - query[fw_uuid] = job_id + query[FW_UUID_PATH] = job_id results = self.get_fw_remote_run(query=query) if not results: return None return results[0] - def get_wf_fw_remote_run_data( + def get_wf_fw_data( self, query: dict | None = None, projection: dict | None = None, @@ -588,15 +649,7 @@ def get_wf_fw_remote_run_data( "foreignField": "fw_id", "as": "fws", } - }, - { - "$lookup": { - "from": "remote_runs", - "localField": "nodes", - "foreignField": "fw_id", - "as": "remote", - } - }, + } ] if query: @@ -616,15 +669,16 @@ def get_wf_fw_remote_run_data( 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_remote_run_data(query=query, sort=sort, limit=limit) + raw_data = self.get_wf_fw_data(query=query, sort=sort, limit=limit) data = [] for d in raw_data: - remotes = d.pop("remote", None) - + fws = d["fws"] remotes_dict = {} - for r in remotes: - remotes_dict[r["fw_id"]] = RemoteRun.from_db_dict(r) + 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)) @@ -634,10 +688,10 @@ def get_wf_fw_remote_run( def get_wf_ids( self, query: dict | None = None, sort: dict | None = None, limit: int = 0 ) -> list[int]: - full_required = check_dict_keywords(query, ["remote.", "fws."]) + full_required = check_dict_keywords(query, ["fws."]) if full_required: - result = self.get_wf_fw_remote_run_data( + result = self.get_wf_fw_data( query=query, sort=sort, limit=limit, projection={"fw_id": 1} ) else: @@ -658,15 +712,7 @@ def get_fw_launch_remote_run_data( ) -> list[dict]: # only take the most recent launch - pipeline = [ - { - "$lookup": { - "from": "remote_runs", - "localField": "fw_id", - "foreignField": "fw_id", - "as": "remote", - } - }, + pipeline: list[dict] = [ { "$lookup": { "from": "launches", diff --git a/src/jobflow_remote/jobs/data.py b/src/jobflow_remote/jobs/data.py index 65f57f08..aef5f136 100644 --- a/src/jobflow_remote/jobs/data.py +++ b/src/jobflow_remote/jobs/data.py @@ -1,11 +1,16 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime, timezone from jobflow import Job -from jobflow_remote.fireworks.launchpad import fw_uuid +from jobflow_remote.fireworks.launchpad import ( + FW_UUID_PATH, + REMOTE_DOC_PATH, + get_job_doc, + get_remote_doc, +) from jobflow_remote.jobs.state import FlowState, JobState, RemoteState @@ -20,19 +25,20 @@ class JobData: job_info_projection = { "fw_id": 1, - fw_uuid: 1, + FW_UUID_PATH: 1, "state": 1, - "remote.state": 1, + f"{REMOTE_DOC_PATH}.state": 1, "name": 1, "updated_on": 1, - "remote.updated_on": 1, - "remote.previous_state": 1, - "remote.lock_id": 1, - "remote.lock_time": 1, - "remote.retry_time_limit": 1, - "remote.process_id": 1, - "remote.run_dir": 1, + f"{REMOTE_DOC_PATH}.updated_on": 1, + f"{REMOTE_DOC_PATH}.previous_state": 1, + f"{REMOTE_DOC_PATH}.lock_id": 1, + f"{REMOTE_DOC_PATH}.lock_time": 1, + f"{REMOTE_DOC_PATH}.retry_time_limit": 1, + f"{REMOTE_DOC_PATH}.process_id": 1, + f"{REMOTE_DOC_PATH}.run_dir": 1, "spec._tasks.machine": 1, + "spec._tasks.job.hosts": 1, } @@ -53,25 +59,18 @@ class JobInfo: 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()) @classmethod def from_query_dict(cls, d): - remote = d.get("remote") or {} - if remote: - remote = remote[0] + 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) # in FW the date is encoded in a string - fw_update_date = datetime.fromisoformat(d["updated_on"]) - remote_update_date = remote.get("updated_on") - if remote_update_date: - remote_update_date = datetime.fromisoformat(d["updated_on"]) - last_updated = max(fw_update_date, remote_update_date) - else: - last_updated = fw_update_date + last_updated = datetime.fromisoformat(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") @@ -116,26 +115,29 @@ def from_query_dict(cls, d): run_dir=remote.get("run_dir"), error_remote=remote.get("error"), error_job=error_job, + host_flows_ids=d["spec"]["_tasks"][0]["job"]["hosts"], ) flow_info_projection = { "fws.fw_id": 1, - f"fws.{fw_uuid}": 1, + f"fws.{FW_UUID_PATH}": 1, "fws.state": 1, - "remote.state": 1, + "fws.name": 1, + f"fws.{REMOTE_DOC_PATH}.state": 1, "name": 1, "updated_on": 1, "fws.updated_on": 1, - "remote.updated_on": 1, "fws.spec._tasks.machine": 1, + "metadata.flow_id": 1, } @dataclass class FlowInfo: - db_ids: int - job_ids: str + db_ids: list[int] + job_ids: list[str] + flow_id: str state: FlowState name: str last_updated: datetime @@ -143,34 +145,43 @@ class FlowInfo: job_states: list[JobState] job_names: list[str] - # @classmethod - # def from_query_dict(cls, d): - # fws = d.get("fws") or [] - # remotes = d.get("remote") or [] - # - # matched_data = defaultdict(list) - # for fw in fws: - # matched_data[fw.get(fw_uuid)].append(fw) - # for r in remotes: - # matched_data[r.get("job_id")].append(r) - # - # machines = [] - # job_states = [] - # job_names = [] - # last_updated_list = [] - # db_ids = [] - # job_ids = [] - # for job_id, (fw, r) in matched_data.items(): - # job_ids.append(job_id) - # db_ids.append(fw.get("fw_id")) - # - # last_updated_list.append(datetime.fromisoformat(d["updated_on"])) - # remote_update_date = remote.get("updated_on") - # if remote_update_date: - # remote_update_date = datetime.fromisoformat(d["updated_on"]) - # last_updated = max(fw_update_date, remote_update_date) - # else: - # last_updated = fw_update_date - # last_updated_list - # - # # for the last updated field, collect all the dates and take the latest one + @classmethod + def from_query_dict(cls, d): + # in FW the date is encoded in a string + last_updated = datetime.fromisoformat(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) + flow_id = d["metadata"].get("flow_id") + fws = d.get("fws") or [] + machines = [] + job_states = [] + job_names = [] + db_ids = [] + job_ids = [] + 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_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)) + machines.append(fw_doc["spec"]["_tasks"][0]["machine"]) + + state = FlowState.from_jobs_states(job_states) + + return cls( + db_ids=db_ids, + job_ids=job_ids, + flow_id=flow_id, + state=state, + name=d["name"], + last_updated=last_updated, + machines=machines, + job_states=job_states, + job_names=job_names, + ) diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index 4a35cb5a..0be895ad 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -8,8 +8,18 @@ from jobflow_remote.config.base import Project from jobflow_remote.config.manager import ConfigManager -from jobflow_remote.fireworks.launchpad import RemoteLaunchPad -from jobflow_remote.jobs.data import FlowInfo, JobData, JobInfo, job_info_projection +from jobflow_remote.fireworks.launchpad import ( + FW_UUID_PATH, + REMOTE_DOC_PATH, + RemoteLaunchPad, +) +from jobflow_remote.jobs.data import ( + FlowInfo, + JobData, + JobInfo, + flow_info_projection, + job_info_projection, +) from jobflow_remote.jobs.state import FlowState, JobState, RemoteState logger = logging.getLogger(__name__) @@ -70,14 +80,16 @@ def _build_query_fw( if db_ids: query["fw_id"] = {"$in": db_ids} if job_ids: - query["spec._tasks.job.uuid"] = {"$in": job_ids} + query[FW_UUID_PATH] = {"$in": job_ids} if state: fw_states, remote_state = state.to_states() query["state"] = {"$in": fw_states} if remote_state: - query["remote.state"] = {"$in": [rs.value for rs in remote_state]} + query[f"{REMOTE_DOC_PATH}.state"] = { + "$in": [rs.value for rs in remote_state] + } if start_date: start_date_str = start_date.astimezone(timezone.utc).isoformat() @@ -107,7 +119,7 @@ def _build_query_wf( if db_ids: query["nodes"] = {"$in": db_ids} if job_ids: - query["fws.spec._tasks.job.uuid"] = {"$in": job_ids} + query[f"fws.{FW_UUID_PATH}"] = {"$in": job_ids} if state: if state == FlowState.WAITING: @@ -125,19 +137,20 @@ def _build_query_wf( elif state == FlowState.ONGOING: query["state"] = "RUNNING" query["fws.state"] = {"$in": ["RUNNING", "RESERVED"]} - query["remote.state"] = { + query[f"fws.{REMOTE_DOC_PATH}.state"] = { "$nin": [JobState.FAILED.value, JobState.REMOTE_ERROR.value] } elif state == FlowState.FAILED: query["$or"] = [ {"state": "FIZZLED"}, { - "remote.state": { + f"fws.{REMOTE_DOC_PATH}.state": { "$in": [JobState.FAILED.value, JobState.REMOTE_ERROR.value] } }, ] + # TODO should this consider the dates of the fws? if start_date: start_date_str = start_date.astimezone(timezone.utc).isoformat() query["updated_on"] = {"$gte": start_date_str} @@ -210,9 +223,8 @@ def get_jobs_info( start_date=start_date, end_date=end_date, ) - - data = self.rlpad.get_fw_remote_run_data( - query=query, sort=sort, limit=limit, projection=job_info_projection + data = self.rlpad.fireworks.find( + query, sort=sort, limit=limit, projection=job_info_projection ) jobs_data = [] @@ -239,9 +251,7 @@ def get_job_info( query=query, projection=proj ) else: - data = self.rlpad.get_fw_remote_run_data( - query=query, projection=job_info_projection - ) + data = self.rlpad.fireworks.find(query, projection=job_info_projection) if not data: return None @@ -341,8 +351,8 @@ def get_flows_info( end_date=end_date, ) - data = self.rlpad.get_wf_fw_remote_run_data( - query=query, sort=sort, limit=limit, projection=job_info_projection + data = self.rlpad.get_wf_fw_data( + query=query, sort=sort, limit=limit, projection=flow_info_projection ) jobs_data = [] diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index 7d207487..205100fb 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -12,7 +12,7 @@ from datetime import datetime, timedelta from pathlib import Path -from fireworks import FWorker +from fireworks import Firework, FWorker from monty.os import makedirs_p from monty.serialization import loadfn from qtoolkit.core.data_objects import QState, SubmissionStatus @@ -26,7 +26,13 @@ ) from jobflow_remote.config.manager import ConfigManager from jobflow_remote.fireworks.launcher import rapidfire_checkout -from jobflow_remote.fireworks.launchpad import RemoteLaunchPad +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.remote.data import ( @@ -95,8 +101,13 @@ def get_queue_manager(self, machine_id: str) -> QueueManager: ) return self.queue_managers[machine_id] - def get_fw_data(self, fw_id: int) -> JobFWData: - fw = self.rlpad.lpad.get_fw_by_id(fw_id) + 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}") @@ -144,8 +155,7 @@ def run(self): RemoteState.TERMINATED.value, RemoteState.DOWNLOADED.value, ] - collection = self.rlpad.remote_runs - updated = self.lock_and_update(states, collection) + updated = self.lock_and_update(states) wait_advance_status = not updated if not updated: last_advance_status = time.time() @@ -157,7 +167,7 @@ def run(self): def lock_and_update( self, states, - collection, + # collection, job_id=None, additional_filter=None, update=None, @@ -175,27 +185,32 @@ def lock_and_update( } db_filter = { - "state": {"$in": states}, - "retry_time_limit": {"$not": {"$gt": datetime.utcnow()}}, + 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["job_id"] = job_id + 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, ) 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(doc["state"]) + state = RemoteState(remote_doc["state"]) function = states_methods[state] @@ -214,26 +229,26 @@ def lock_and_update( # the state.next.value is correct as SUBMITTED is not dealt with here. succeeded_update = { "$set": { - "state": state.next.value, - "step_attempts": 0, - "retry_time_limit": None, - "error": None, + f"{REMOTE_DOC_PATH}.state": state.next.value, + f"{REMOTE_DOC_PATH}.step_attempts": 0, + f"{REMOTE_DOC_PATH}.retry_time_limit": None, + f"{REMOTE_DOC_PATH}.error": None, } } lock.update_on_release = deep_merge_dict( succeeded_update, set_output or {} ) else: - step_attempts = doc["step_attempts"] + step_attempts = remote_doc["step_attempts"] fail_now = ( fail_now or step_attempts >= self.runner_options.max_step_attempts ) if fail_now: lock.update_on_release = { "$set": { - "state": RemoteState.FAILED.value, - "previous_state": state.value, - "error": error, + f"{REMOTE_DOC_PATH}.state": RemoteState.FAILED.value, + f"{REMOTE_DOC_PATH}.previous_state": state.value, + f"{REMOTE_DOC_PATH}.error": error, } } else: @@ -242,18 +257,25 @@ def lock_and_update( retry_time_limit = datetime.utcnow() + timedelta(seconds=delta) lock.update_on_release = { "$set": { - "step_attempts": step_attempts, - "retry_time_limit": retry_time_limit, - "error": error, + 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 lock.update_on_release: + lock.update_on_release["$set"][ + "updated_on" + ] = datetime.utcnow().isoformat() + self.ping_wf_doc(doc["fw_id"]) + return True 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(fw_id) + fw_job_data = self.get_fw_data(doc) job = fw_job_data.job store = fw_job_data.store @@ -279,8 +301,8 @@ def upload(self, doc): fw.tasks[0]["store"] = remote_store fw.tasks[0]["original_store"] = fw_job_data.original_store - files = get_remote_files(fw, doc["launch_id"]) - self.rlpad.lpad.change_launch_dir(doc["launch_id"], remote_path) + 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) if not created: @@ -294,16 +316,16 @@ def upload(self, doc): path_file = Path(remote_path, fname) fw_job_data.host.write_text_file(path_file, fcontent) - set_output = {"$set": {"run_dir": remote_path}} + set_output = {"$set": {f"{REMOTE_DOC_PATH}.run_dir": remote_path}} return None, False, set_output def submit(self, doc): - fw_id = doc["fw_id"] logger.debug(f"submit fw_id: {doc['fw_id']}") - fw_job_data = self.get_fw_data(fw_id) + remote_doc = get_remote_doc(doc) + fw_job_data = self.get_fw_data(doc) - remote_path = doc["run_dir"] + remote_path = remote_doc["run_dir"] script_commands = ["rlaunch singleshot --offline"] @@ -345,19 +367,21 @@ def submit(self, doc): return err_msg, True, None elif submit_result.status == SubmissionStatus.SUCCESSFUL: - set_output = {"$set": {"process_id": str(submit_result.job_id)}} + set_output = { + "$set": {f"{REMOTE_DOC_PATH}.process_id": str(submit_result.job_id)} + } return None, False, set_output raise RuntimeError(f"unhandled submission status {submit_result.status}") def download(self, doc): - fw_id = doc["fw_id"] + remote_doc = get_remote_doc(doc) logger.debug(f"download fw_id: {doc['fw_id']}") - fw_job_data = self.get_fw_data(fw_id) + fw_job_data = self.get_fw_data(doc) job = fw_job_data.job - remote_path = doc["run_dir"] + 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) @@ -383,9 +407,9 @@ def download(self, doc): return None, False, None def complete_launch(self, doc): - fw_id = doc["fw_id"] + remote_doc = get_remote_doc(doc) logger.debug(f"complete launch fw_id: {doc['fw_id']}") - fw_job_data = self.get_fw_data(fw_id) + fw_job_data = self.get_fw_data(doc) loca_base_dir = Path(self.project.tmp_dir, "download") local_path = get_job_path(fw_job_data.job.uuid, loca_base_dir) @@ -407,7 +431,7 @@ def complete_launch(self, doc): store=store, remote_store=remote_store, save=save, - launch_id=doc["launch_id"], + launch_id=remote_doc["launch_id"], terminated=True, ) except json.JSONDecodeError: @@ -430,18 +454,22 @@ def check_run_status(self): # check for jobs that could have changed state machines_ids_docs = defaultdict(dict) db_filter = { - "state": {"$in": [RemoteState.SUBMITTED.value, RemoteState.RUNNING.value]} + f"{REMOTE_DOC_PATH}.state": { + "$in": [RemoteState.SUBMITTED.value, RemoteState.RUNNING.value] + } } projection = [ "fw_id", - "launch_id", - "job_id", - "process_id", - "state", - "machine_id", + f"{REMOTE_DOC_PATH}.launch_id", + FW_UUID_PATH, + f"{REMOTE_DOC_PATH}.process_id", + f"{REMOTE_DOC_PATH}.state", + "spec._tasks.machine", ] - for doc in self.rlpad.remote_runs.find(db_filter, projection): - machines_ids_docs[doc["machine_id"]][doc["process_id"]] = doc + for doc in self.rlpad.fireworks.find(db_filter, projection): + machine_id = doc["spec"]["_tasks"][0]["machine"] + remote_doc = get_remote_doc(doc) + machines_ids_docs[machine_id][remote_doc["process_id"]] = (doc, remote_doc) for machine_id, ids_docs in machines_ids_docs.items(): @@ -460,39 +488,59 @@ def check_run_status(self): qjobs_dict = {qjob.job_id: qjob for qjob in qjobs} - for doc_id, doc in ids_docs.items(): + for doc_id, (doc, remote_doc) in ids_docs.items(): # TODO if failed should maybe be handled differently? qjob = qjobs_dict.get(doc_id) qstate = qjob.state if qjob else None - collection = self.rlpad.remote_runs + collection = self.rlpad.fireworks if ( qstate == QState.RUNNING - and doc["state"] == RemoteState.SUBMITTED.value + and remote_doc["state"] == RemoteState.SUBMITTED.value ): - lock_filter = {"state": doc["state"], "job_id": doc["job_id"]} - with MongoLock(collection=collection, filter=lock_filter) as lock: + 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, + ) as lock: if lock.locked_document: lock.update_on_release = { "$set": { - "state": RemoteState.RUNNING.value, - "queue_state": qstate.value, + f"{REMOTE_DOC_PATH}.state": RemoteState.RUNNING.value, + f"{REMOTE_DOC_PATH}.queue_state": qstate.value, + "updated_on": datetime.utcnow().isoformat(), } } + self.ping_wf_doc(doc["fw_id"]) logger.debug( - f"remote job with id {doc['process_id']} is running" + f"remote job with id {remote_doc['process_id']} is running" ) elif qstate in [None, QState.DONE, QState.FAILED]: - lock_filter = {"state": doc["state"], "job_id": doc["job_id"]} - with MongoLock(collection=collection, filter=lock_filter) as lock: + 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, + ) as lock: if lock.locked_document: lock.update_on_release = { "$set": { - "state": RemoteState.TERMINATED.value, - "queue_state": qstate.value if qstate else None, + f"{REMOTE_DOC_PATH}.state": RemoteState.TERMINATED.value, + f"{REMOTE_DOC_PATH}.queue_state": qstate.value + if qstate + else None, + "updated_on": datetime.utcnow().isoformat(), } } + self.ping_wf_doc(doc["fw_id"]) logger.debug( - f"terminated remote job with id {doc['process_id']}" + f"terminated remote job with id {remote_doc['process_id']}" ) def checkout(self): @@ -506,3 +554,8 @@ def cleanup(self): host.close() except Exception: logging.exception(f"error while closing host {host_id}") + + def ping_wf_doc(self, db_id: int): + self.rlpad.workflows.find_one_and_update( + {"nodes": db_id}, {"$set": {"updated_on": datetime.utcnow().isoformat()}} + ) diff --git a/src/jobflow_remote/jobs/state.py b/src/jobflow_remote/jobs/state.py index 0515cb63..f8e52864 100644 --- a/src/jobflow_remote/jobs/state.py +++ b/src/jobflow_remote/jobs/state.py @@ -75,9 +75,9 @@ def to_states(self) -> tuple[list[str], list[RemoteState] | None]: elif self in (JobState.COMPLETED, JobState.PAUSED): return [self.value], [RemoteState(self.value)] elif self == JobState.ONGOING: - return ["RESERVED", "RUNNING"], [RemoteState.FAILED] - elif self == JobState.REMOTE_ERROR: 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] @@ -100,7 +100,7 @@ def from_jobs_states(cls, jobs_states: list[JobState]) -> FlowState: return cls.READY elif all(js == JobState.COMPLETED for js in jobs_states): return cls.COMPLETED - elif any(js == JobState.FAILED for js in jobs_states): + elif any(js in (JobState.FAILED, JobState.REMOTE_ERROR) for js in jobs_states): return cls.FAILED elif all(js == JobState.PAUSED for js in jobs_states): return cls.PAUSED diff --git a/src/jobflow_remote/utils/db.py b/src/jobflow_remote/utils/db.py index c9a4f608..f2e3e8e7 100644 --- a/src/jobflow_remote/utils/db.py +++ b/src/jobflow_remote/utils/db.py @@ -24,6 +24,7 @@ def __init__( timeout=None, break_lock=False, lock_id=None, + lock_subdoc="", **kwargs, ): self.collection = collection @@ -33,8 +34,31 @@ def __init__( self.break_lock = break_lock self.locked_document = None self.lock_id = lock_id or id(self) + if lock_subdoc and not lock_subdoc.endswith("."): + lock_subdoc = lock_subdoc + "." + self.lock_subdoc = lock_subdoc self.kwargs = kwargs - self.update_on_release = None + 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}" + + def get_lock_time(self, d: dict): + keys = self.lock_time_key.split(".") + for k in keys: + d = d.get(k, {}) + return d + + def get_lock_id(self, d: dict): + keys = self.lock_id.split(".") + for k in keys: + d = d.get(k, {}) + return d def acquire(self): # Set the lock expiration time @@ -43,10 +67,10 @@ def acquire(self): lock_limit = None if not self.break_lock: - lock_filter = {self.LOCK_KEY: {"$exists": False}} + 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}} + 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] @@ -55,7 +79,7 @@ def acquire(self): else: db_filter.update(lock_filter) - lock_set = {self.LOCK_KEY: self.lock_id, self.LOCK_TIME_KEY: now} + 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)) @@ -70,22 +94,24 @@ def acquire(self): ) if result: - if lock_limit and result[self.LOCK_TIME_KEY] > lock_limit: - msg = f"The lock was broken. Previous lock id: {result[self.LOCK_KEY]}" + 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 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 = {"$unset": {self.lock_key: "", self.lock_time_key: ""}} # 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, ) From 761834151791b88fae096f0c972e7b6bd2e7387f Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Mon, 5 Jun 2023 23:54:32 +0200 Subject: [PATCH 09/89] more functionalities and bug fixes --- src/jobflow_remote/cli/__init__.py | 4 +- src/jobflow_remote/cli/admin.py | 12 +- src/jobflow_remote/cli/flow.py | 131 ++++++++++++++++++++++ src/jobflow_remote/cli/formatting.py | 40 ++++++- src/jobflow_remote/cli/jf.py | 7 +- src/jobflow_remote/cli/job.py | 89 +++++++++++++++ src/jobflow_remote/cli/types.py | 20 ++++ src/jobflow_remote/fireworks/launchpad.py | 57 +++------- src/jobflow_remote/jobs/data.py | 17 ++- src/jobflow_remote/jobs/jobcontroller.py | 88 +++++++++++---- src/jobflow_remote/jobs/runner.py | 9 +- src/jobflow_remote/jobs/state.py | 6 + src/jobflow_remote/remote/host/local.py | 10 +- 13 files changed, 407 insertions(+), 83 deletions(-) diff --git a/src/jobflow_remote/cli/__init__.py b/src/jobflow_remote/cli/__init__.py index 7abab901..3af4742e 100644 --- a/src/jobflow_remote/cli/__init__.py +++ b/src/jobflow_remote/cli/__init__.py @@ -1,7 +1,7 @@ +# Import the submodules with a local app to register them to the main app import jobflow_remote.cli.admin import jobflow_remote.cli.config +import jobflow_remote.cli.flow import jobflow_remote.cli.job import jobflow_remote.cli.runner from jobflow_remote.cli.jf import app - -# Import the submodules with a local app to register them to the main app diff --git a/src/jobflow_remote/cli/admin.py b/src/jobflow_remote/cli/admin.py index 5a15135e..5ef5f613 100644 --- a/src/jobflow_remote/cli/admin.py +++ b/src/jobflow_remote/cli/admin.py @@ -4,6 +4,7 @@ from typing_extensions import Annotated from jobflow_remote.cli.jf import app +from jobflow_remote.cli.types import force_opt from jobflow_remote.cli.utils import exit_with_error_msg, loading_spinner, out_console from jobflow_remote.config import ConfigManager from jobflow_remote.jobs.daemon import DaemonError, DaemonManager, DaemonStatus @@ -35,14 +36,7 @@ def reset( ), ), ] = 25, - force: Annotated[ - bool, - typer.Option( - "--force", - "-f", - help=("No confirmation will be asked before proceeding"), - ), - ] = False, + force: force_opt = False, ): """ Reset the jobflow database. @@ -76,7 +70,7 @@ def reset( text.append(f"{project_name} ", style="red bold") text.append("Proceed anyway?", style="red") - confirmed = Confirm.ask(text) + confirmed = Confirm.ask(text, default=False) if not confirmed: raise typer.Exit(0) with loading_spinner(False) as progress: diff --git a/src/jobflow_remote/cli/flow.py b/src/jobflow_remote/cli/flow.py index 219610c0..7992f025 100644 --- a/src/jobflow_remote/cli/flow.py +++ b/src/jobflow_remote/cli/flow.py @@ -1,8 +1,139 @@ +from datetime import datetime, timedelta + import typer +from rich.prompt import Confirm +from rich.text import Text +from jobflow_remote.cli.formatting import get_flow_info_table from jobflow_remote.cli.jf import app +from jobflow_remote.cli.types import ( + days_opt, + db_ids_opt, + end_date_opt, + flow_ids_opt, + force_opt, + job_ids_opt, + job_state_opt, + max_results_opt, + reverse_sort_flag_opt, + sort_opt, + start_date_opt, + verbosity_opt, +) +from jobflow_remote.cli.utils import ( + SortOption, + check_incompatible_opt, + exit_with_warning_msg, + loading_spinner, + out_console, +) +from jobflow_remote.jobs.jobcontroller import JobController app_flow = typer.Typer( name="flow", help="Commands for managing the flows", no_args_is_help=True ) app.add_typer(app_flow) + + +@app_flow.command(name="list") +def flows_list( + job_id: job_ids_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, + days: days_opt = None, + verbosity: verbosity_opt = 0, + max_results: max_results_opt = 100, + sort: sort_opt = SortOption.UPDATED_ON.value, + reverse_sort: reverse_sort_flag_opt = False, +): + """ + Get the list of Jobs in the database + """ + check_incompatible_opt({"start_date": start_date, "days": days}) + check_incompatible_opt({"end_date": end_date, "days": days}) + + jc = JobController() + + if days: + start_date = datetime.now() - timedelta(days=days) + + sort = [(sort.query_field, 1 if reverse_sort else -1)] + + with loading_spinner(): + flows_info = jc.get_flows_info( + job_ids=job_id, + db_ids=db_id, + flow_id=flow_id, + state=state, + start_date=start_date, + end_date=end_date, + limit=max_results, + sort=sort, + ) + + table = get_flow_info_table(flows_info, verbosity=verbosity) + + if max_results and len(flows_info) == max_results: + out_console.print( + f"The number of Flows printed is limited by the maximum selected: {max_results}", + style="yellow", + ) + + out_console.print(table) + + +@app_flow.command() +def delete( + job_id: job_ids_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, + days: days_opt = None, + force: force_opt = False, +): + """ + Permanently delete Flows from the database + """ + check_incompatible_opt({"start_date": start_date, "days": days}) + check_incompatible_opt({"end_date": end_date, "days": days}) + + jc = JobController() + + with loading_spinner(False) as progress: + progress.add_task(description="Fetching data...", total=None) + flows_info = jc.get_flows_info( + job_ids=job_id, + db_ids=db_id, + flow_id=flow_id, + state=state, + start_date=start_date, + end_date=end_date, + ) + + if not flows_info: + exit_with_warning_msg("No flows matching criteria") + + if flows_info and not force: + text = Text() + text.append("This operation will ", style="red") + text.append(f"delete {len(flows_info)} Flow(s)", style="red bold") + text.append(". Proceed anyway?", style="red") + + confirmed = Confirm.ask(text, default=False) + if not confirmed: + raise typer.Exit(0) + + to_delete = [fi.db_ids[0] 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) + + out_console.print( + f"Deleted Flow(s) with db_id: {', '.join(str(i) for i in to_delete)}" + ) diff --git a/src/jobflow_remote/cli/formatting.py b/src/jobflow_remote/cli/formatting.py index 1a634150..ad4c7043 100644 --- a/src/jobflow_remote/cli/formatting.py +++ b/src/jobflow_remote/cli/formatting.py @@ -7,7 +7,7 @@ from rich.table import Table from jobflow_remote.cli.utils import fmt_datetime -from jobflow_remote.jobs.data import JobInfo +from jobflow_remote.jobs.data import FlowInfo, JobInfo from jobflow_remote.jobs.state import JobState from jobflow_remote.utils.data import remove_none @@ -70,6 +70,44 @@ def get_job_info_table(jobs_info: list[JobInfo], verbosity: int): return table +def get_flow_info_table(flows_info: list[FlowInfo], verbosity: int): + 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") + + if verbosity >= 1: + table.add_column("Machines") + + table.add_column("Job states") + + for fi in flows_info: + # show the smallest fw_id as db_id + db_id = min(fi.db_ids) + + row = [ + str(db_id), + fi.name, + fi.state.name, + fi.flow_id, + str(len(fi.job_ids)), + fi.last_updated.strftime(fmt_datetime), + ] + + if verbosity >= 1: + machines = set(fi.machines) + row.append(", ".join(machines)) + job_states = "-".join(js.short_value for js in fi.job_states) + row.append(job_states) + + table.add_row(*row) + + return table + + def format_job_info(job_info: JobInfo, show_none: bool = False): d = asdict(job_info) if not show_none: diff --git a/src/jobflow_remote/cli/jf.py b/src/jobflow_remote/cli/jf.py index 0be3b894..c9325c3f 100644 --- a/src/jobflow_remote/cli/jf.py +++ b/src/jobflow_remote/cli/jf.py @@ -4,7 +4,12 @@ from jobflow_remote.cli.utils import exit_with_error_msg from jobflow_remote.config import ConfigManager -app = typer.Typer(name="jf", add_completion=False, no_args_is_help=True) +app = typer.Typer( + name="jf", + add_completion=False, + no_args_is_help=True, + context_settings={"help_option_names": ["-h", "--help"]}, +) @app.callback() diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index a7876586..3bf356fa 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -1,4 +1,6 @@ +import io from datetime import datetime, timedelta +from pathlib import Path import typer from typing_extensions import Annotated @@ -25,12 +27,16 @@ SortOption, check_incompatible_opt, exit_with_error_msg, + exit_with_warning_msg, get_job_db_ids, loading_spinner, out_console, print_success_msg, ) +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 = typer.Typer( name="job", help="Commands for managing the jobs", no_args_is_help=True @@ -234,3 +240,86 @@ def rerun( ) out_console.print(f"{len(fw_ids)} Jobs were rerun: {fw_ids}") + + +@app_job.command() +def queue_out( + job_id: job_id_arg, + db_id: db_id_flag_opt = False, +): + with loading_spinner() as progress: + progress.add_task(description="Retrieving info...", total=None) + jc = JobController() + + db_id_value, job_id_value = get_job_db_ids(db_id, job_id) + + job_data_list = jc.get_jobs_data( + job_ids=job_id_value, + db_ids=db_id_value, + ) + + if not job_data_list: + exit_with_error_msg("No data matching the request") + + job_data = job_data_list[0] + info = job_data.info + if info.remote_state not in ( + RemoteState.RUNNING, + RemoteState.TERMINATED, + RemoteState.DOWNLOADED, + RemoteState.COMPLETED, + RemoteState.FAILED, + ): + remote_state_str = f"[{info.remote_state.value}]" if info.remote_state else "" + exit_with_warning_msg( + f"The Job is in state {info.state.value}{remote_state_str} and the queue output will not be present" + ) + + remote_dir = info.run_dir + + out_path = Path(remote_dir, OUT_FNAME) + err_path = Path(remote_dir, ERR_FNAME) + out = None + err = None + out_error = None + err_error = None + with loading_spinner() as progress: + progress.add_task(description="Retrieving files...", total=None) + cm = ConfigManager() + machine = cm.load_machine(info.machine) + host = cm.load_host(machine.host_id) + + try: + host.connect() + try: + out_bytes = io.BytesIO() + host.get(out_path, out_bytes) + out = out_bytes.getvalue().decode("utf-8") + except Exception as e: + out_error = getattr(e, "message", str(e)) + try: + err_bytes = io.BytesIO() + host.get(err_path, err_bytes) + err = err_bytes.getvalue().decode("utf-8") + except Exception as e: + err_error = getattr(e, "message", str(e)) + finally: + host.close() + + if out_error: + out_console.print( + f"Error while fetching queue output from {str(out_path)}: {out_error}", + style="red", + ) + else: + out_console.print(f"Queue output from {str(out_path)}:\n") + out_console.print(out) + + if err_error: + out_console.print( + f"Error while fetching queue error from {str(err_path)}: {err_error}", + style="red", + ) + else: + out_console.print(f"Queue error from {str(err_path)}:\n") + out_console.print(err) diff --git a/src/jobflow_remote/cli/types.py b/src/jobflow_remote/cli/types.py index c7033ed9..c5518106 100644 --- a/src/jobflow_remote/cli/types.py +++ b/src/jobflow_remote/cli/types.py @@ -27,6 +27,16 @@ ] +flow_ids_opt = Annotated[ + Optional[List[str]], + typer.Option( + "--flow-id", + "-fid", + help="One or more flow ids", + ), +] + + job_state_opt = Annotated[ Optional[JobState], typer.Option( @@ -150,3 +160,13 @@ ), ), ] + + +force_opt = Annotated[ + bool, + typer.Option( + "--force", + "-f", + help=("No confirmation will be asked before proceeding"), + ), +] diff --git a/src/jobflow_remote/fireworks/launchpad.py b/src/jobflow_remote/fireworks/launchpad.py index 7b30a038..2cc617c1 100644 --- a/src/jobflow_remote/fireworks/launchpad.py +++ b/src/jobflow_remote/fireworks/launchpad.py @@ -64,9 +64,15 @@ def as_db_dict(self): @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"] = RemoteState(d["previous_state"]) - d["queue_state"] = QState(d["queue_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) @@ -509,6 +515,10 @@ def delete_wf(self, fw_id: int | None = None, job_id: str | None = None): 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}}) @@ -548,39 +558,6 @@ def get_fws( fws.append(Firework.from_dict(doc)) return fws - # def get_fw_remote_run_data( - # self, - # query: dict | None = None, - # projection: dict | None = None, - # sort: dict | None = None, - # limit: int = 0, - # ) -> list[dict]: - # - # pipeline: list[dict] = [ - # { - # "$lookup": { - # "from": "remote_runs", - # "localField": "fw_id", - # "foreignField": "fw_id", - # "as": "remote", - # } - # } - # ] - # - # 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)) - def get_fw_remote_run( self, query: dict | None = None, @@ -588,9 +565,7 @@ def get_fw_remote_run( sort: dict | None = None, limit: int = 0, ) -> list[tuple[Firework, RemoteRun | None]]: - fws = self.fireworks.find( - query=query, projection=projection, sort=sort, limit=limit - ) + fws = self.fireworks.find(query, projection=projection, sort=sort, limit=limit) data = [] for fw_dict in fws: @@ -600,6 +575,10 @@ def get_fw_remote_run( 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)) @@ -659,7 +638,7 @@ def get_wf_fw_data( pipeline.append({"$project": projection}) if sort: - pipeline.append({"$sort": sort}) + pipeline.append({"$sort": {k: v for (k, v) in sort}}) if limit: pipeline.append({"$limit": limit}) diff --git a/src/jobflow_remote/jobs/data.py b/src/jobflow_remote/jobs/data.py index aef5f136..7c0ed3ed 100644 --- a/src/jobflow_remote/jobs/data.py +++ b/src/jobflow_remote/jobs/data.py @@ -3,7 +3,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone -from jobflow import Job +from jobflow import Job, JobStore from jobflow_remote.fireworks.launchpad import ( FW_UUID_PATH, @@ -19,6 +19,8 @@ class JobData: job: Job state: JobState db_id: int + store: JobStore + info: JobInfo | None = None remote_state: RemoteState | None = None output: dict | None = None @@ -62,7 +64,7 @@ class JobInfo: host_flows_ids: list[str] = field(default_factory=lambda: list()) @classmethod - def from_query_dict(cls, d): + def from_fw_dict(cls, d): remote = get_remote_doc(d) remote_state_val = remote.get("state") remote_state = ( @@ -99,6 +101,11 @@ def from_query_dict(cls, d): 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) + return cls( db_id=d["fw_id"], job_id=d["spec"]["_tasks"][0]["job"]["uuid"], @@ -111,7 +118,7 @@ def from_query_dict(cls, d): lock_id=lock_id, lock_time=lock_time, retry_time_limit=retry_time_limit, - queue_job_id=str(remote.get("process_id")), + queue_job_id=queue_job_id, run_dir=remote.get("run_dir"), error_remote=remote.get("error"), error_job=error_job, @@ -147,10 +154,8 @@ class FlowInfo: @classmethod def from_query_dict(cls, d): - # in FW the date is encoded in a string - last_updated = datetime.fromisoformat(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) + last_updated = d["updated_on"].replace(tzinfo=timezone.utc).astimezone(tz=None) flow_id = d["metadata"].get("flow_id") fws = d.get("fws") or [] machines = [] diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index 0be895ad..d59bb13c 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -1,10 +1,13 @@ from __future__ import annotations +import io import logging +from contextlib import redirect_stdout from datetime import datetime, timezone from fireworks import Firework from jobflow import JobStore +from monty.json import MontyDecoder from jobflow_remote.config.base import Project from jobflow_remote.config.manager import ConfigManager @@ -12,6 +15,7 @@ FW_UUID_PATH, REMOTE_DOC_PATH, RemoteLaunchPad, + get_remote_doc, ) from jobflow_remote.jobs.data import ( FlowInfo, @@ -104,6 +108,7 @@ def _build_query_wf( self, job_ids: str | list[str] | None = None, db_ids: int | list[int] | None = None, + flow_id: str | None = None, state: FlowState | None = None, start_date: datetime | None = None, end_date: datetime | None = None, @@ -121,6 +126,9 @@ def _build_query_wf( if job_ids: query[f"fws.{FW_UUID_PATH}"] = {"$in": job_ids} + if flow_id: + query["metadata.flow_id"] = flow_id + if state: if state == FlowState.WAITING: not_in_states = list(Firework.STATE_RANKS.keys()) @@ -150,12 +158,12 @@ def _build_query_wf( }, ] - # TODO should this consider the dates of the fws? + # 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} return query @@ -181,23 +189,31 @@ def get_jobs_data( end_date=end_date, ) - data = self.rlpad.get_fw_remote_run(query=query, sort=sort, limit=limit) - + data = self.rlpad.fireworks.find(query, sort=sort, limit=limit) jobs_data = [] - for fw, remote in data: - job = fw.tasks[0]["job"] - remote_state_job = remote.state if remote else None - state = JobState.from_states(fw.state, remote_state_job) + 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 - jobstore = fw.tasks[0].get("store") or self.jobstore if state == RemoteState.COMPLETED and load_output: - output = jobstore.query_one({"uuid": job.uuid}, load=True) + output = store.query_one({"uuid": job.uuid}, load=True) jobs_data.append( JobData( job=job, state=state, - db_id=fw.fw_id, + db_id=fw_dict["fw_id"], remote_state=remote_state, + store=store, + info=info, output=output, ) ) @@ -212,7 +228,7 @@ def get_jobs_info( remote_state: RemoteState | None = None, start_date: datetime | None = None, end_date: datetime | None = None, - sort: dict | None = None, + sort: list[tuple] | None = None, limit: int = 0, ) -> list[JobInfo]: query = self._build_query_fw( @@ -229,7 +245,7 @@ def get_jobs_info( jobs_data = [] for d in data: - jobs_data.append(JobInfo.from_query_dict(d)) + jobs_data.append(JobInfo.from_fw_dict(d)) return jobs_data @@ -247,15 +263,17 @@ def get_job_info( "remote.error": 1, } ) - data = self.rlpad.get_fw_launch_remote_run_data( - query=query, projection=proj + data = list( + self.rlpad.get_fw_launch_remote_run_data(query=query, projection=proj) ) else: - data = self.rlpad.fireworks.find(query, projection=job_info_projection) + data = list( + self.rlpad.fireworks.find(query, projection=job_info_projection) + ) if not data: return None - return JobInfo.from_query_dict(data[0]) + return JobInfo.from_fw_dict(data[0]) @staticmethod def check_ids(job_id: str | None, db_id: int | None): @@ -337,15 +355,17 @@ def get_flows_info( self, job_ids: str | list[str] | None = None, db_ids: int | list[int] | None = None, + flow_id: str | None = None, state: FlowState | None = None, start_date: datetime | None = None, end_date: datetime | None = None, - sort: dict | None = None, + sort: list[tuple] | None = None, limit: int = 0, ) -> list[FlowInfo]: query = self._build_query_wf( job_ids=job_ids, db_ids=db_ids, + flow_id=flow_id, state=state, start_date=start_date, end_date=end_date, @@ -357,6 +377,34 @@ def get_flows_info( jobs_data = [] for d in data: - jobs_data.append(JobInfo.from_query_dict(d)) + jobs_data.append(FlowInfo.from_query_dict(d)) return jobs_data + + 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): + raise ValueError( + "One and only one among job_ids and db_ids should be defined" + ) + + if job_ids: + ids_list: str | int | list = job_ids + arg = "job_id" + else: + ids_list = db_ids + arg = "fw_id" + + 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)}") diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index 205100fb..656f06a5 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -344,9 +344,11 @@ def submit(self, doc): exec_config = exec_config or ExecutionConfig() pre_run = machine.pre_run or "" - pre_run += exec_config.pre_run or "" + if exec_config.pre_run: + pre_run += "\n" + exec_config.pre_run post_run = machine.post_run or "" - post_run += exec_config.post_run or "" + if exec_config.post_run: + post_run += "\n" + exec_config.post_run submit_result = queue_manager.submit( commands=script_commands, @@ -556,6 +558,7 @@ def cleanup(self): logging.exception(f"error while closing host {host_id}") 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()}} + {"nodes": db_id}, {"$set": {"updated_on": datetime.utcnow()}} ) diff --git a/src/jobflow_remote/jobs/state.py b/src/jobflow_remote/jobs/state.py index f8e52864..5db8c1a0 100644 --- a/src/jobflow_remote/jobs/state.py +++ b/src/jobflow_remote/jobs/state.py @@ -83,6 +83,12 @@ def to_states(self) -> tuple[list[str], list[RemoteState] | None]: raise ValueError(f"Unhandled state {self}") + @property + def short_value(self) -> str: + if self == JobState.REMOTE_ERROR: + return "RE" + return self.value[0] + class FlowState(Enum): WAITING = "WAITING" diff --git a/src/jobflow_remote/remote/host/local.py b/src/jobflow_remote/remote/host/local.py index db1c2683..2d4a08ca 100644 --- a/src/jobflow_remote/remote/host/local.py +++ b/src/jobflow_remote/remote/host/local.py @@ -94,10 +94,16 @@ def put(self, src, dst): with open(dst, "wb") as f: f.write(src.read()) else: - shutil.copy(src, dst) + self.copy(src, dst) def get(self, src, dst): - self.copy(src, dst) + is_file_like = hasattr(dst, "write") and callable(dst.write) + + if is_file_like: + with open(src, "rb") as f: + dst.write(f.read()) + else: + self.copy(src, dst) def copy(self, src, dst): shutil.copy(src, dst) From 0f43a33c2c3b4b42e3335294b583c204a2b7628b Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Tue, 6 Jun 2023 16:46:23 +0200 Subject: [PATCH 10/89] improve check_run_status and more CLI --- src/jobflow_remote/cli/admin.py | 78 +++++++++- src/jobflow_remote/cli/formatting.py | 2 +- src/jobflow_remote/cli/job.py | 32 ++-- src/jobflow_remote/cli/runner.py | 2 +- src/jobflow_remote/cli/types.py | 45 +++++- src/jobflow_remote/fireworks/launchpad.py | 13 +- src/jobflow_remote/jobs/data.py | 11 +- src/jobflow_remote/jobs/jobcontroller.py | 54 +++++-- src/jobflow_remote/jobs/runner.py | 169 +++++++++++++--------- src/jobflow_remote/utils/db.py | 2 +- 10 files changed, 297 insertions(+), 111 deletions(-) diff --git a/src/jobflow_remote/cli/admin.py b/src/jobflow_remote/cli/admin.py index 5ef5f613..7402bd54 100644 --- a/src/jobflow_remote/cli/admin.py +++ b/src/jobflow_remote/cli/admin.py @@ -4,8 +4,21 @@ from typing_extensions import Annotated from jobflow_remote.cli.jf import app -from jobflow_remote.cli.types import force_opt -from jobflow_remote.cli.utils import exit_with_error_msg, loading_spinner, out_console +from jobflow_remote.cli.types import ( + db_ids_opt, + end_date_opt, + force_opt, + job_ids_opt, + job_state_opt, + remote_state_opt, + start_date_opt, +) +from jobflow_remote.cli.utils import ( + check_incompatible_opt, + exit_with_error_msg, + 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 @@ -78,3 +91,64 @@ def reset( jc = JobController() done = jc.reset(reset_output=reset_output, max_limit=max_limit) out_console.print(f"The database was {'' if done else 'NOT '}reset") + + +@app_admin.command() +def remove_lock( + job_id: job_ids_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, +): + """ + 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}) + + jc = JobController() + if not force: + with loading_spinner(False) as progress: + progress.add_task( + description="Checking the number of locked documents...", total=None + ) + + jobs_info = jc.get_jobs_info( + job_ids=job_id, + db_ids=db_id, + state=state, + remote_state=remote_state, + start_date=start_date, + locked=True, + end_date=end_date, + ) + + text = Text() + text.append("This operation will ", style="red") + text.append("remove the lock ", style="red bold") + text.append("for (roughly) ", style="red") + text.append(f"{len(jobs_info)} Job(s). ", style="red bold") + text.append("Proceed anyway?", style="red") + confirmed = Confirm.ask(text, default=False) + + if not confirmed: + raise typer.Exit(0) + + with loading_spinner(False) as progress: + progress.add_task( + description="Checking the number of locked documents...", total=None + ) + + num_unlocked = jc.remove_lock( + job_ids=job_id, + db_ids=db_id, + state=state, + remote_state=remote_state, + start_date=start_date, + end_date=end_date, + ) + + out_console.print(f"{num_unlocked} jobs were unlocked") diff --git a/src/jobflow_remote/cli/formatting.py b/src/jobflow_remote/cli/formatting.py index ad4c7043..6106eced 100644 --- a/src/jobflow_remote/cli/formatting.py +++ b/src/jobflow_remote/cli/formatting.py @@ -62,7 +62,7 @@ def get_job_info_table(jobs_info: list[JobInfo], verbosity: int): row.append("*" if ji.lock_id is not None else None) if verbosity >= 2: - row.append(ji.lock_id) + row.append(str(ji.lock_id)) row.append(ji.lock_time.strftime(fmt_datetime) if ji.lock_time else None) table.add_row(*row) diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index 3bf356fa..c8c16574 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -15,7 +15,9 @@ job_id_arg, job_ids_opt, job_state_opt, + locked_opt, max_results_opt, + query_opt, remote_state_arg, remote_state_opt, reverse_sort_flag_opt, @@ -57,6 +59,8 @@ def jobs_list( max_results: max_results_opt = 100, sort: sort_opt = SortOption.UPDATED_ON.value, reverse_sort: reverse_sort_flag_opt = False, + locked: locked_opt = False, + custom_query: query_opt = None, ): """ Get the list of Jobs in the database @@ -73,16 +77,24 @@ def jobs_list( sort = [(sort.query_field, 1 if reverse_sort else -1)] with loading_spinner(): - jobs_info = jc.get_jobs_info( - job_ids=job_id, - db_ids=db_id, - state=state, - remote_state=remote_state, - start_date=start_date, - end_date=end_date, - limit=max_results, - sort=sort, - ) + if custom_query: + jobs_info = jc.get_jobs_info_query( + query=custom_query, + limit=max_results, + sort=sort, + ) + else: + jobs_info = jc.get_jobs_info( + job_ids=job_id, + db_ids=db_id, + state=state, + remote_state=remote_state, + start_date=start_date, + locked=locked, + end_date=end_date, + limit=max_results, + sort=sort, + ) table = get_job_info_table(jobs_info, verbosity=verbosity) diff --git a/src/jobflow_remote/cli/runner.py b/src/jobflow_remote/cli/runner.py index 266279f2..1c2a37ea 100644 --- a/src/jobflow_remote/cli/runner.py +++ b/src/jobflow_remote/cli/runner.py @@ -41,7 +41,7 @@ def run( Should be used by the daemon or for testing purposes. """ runner_id = os.getpid() if set_pid else None - runner = Runner(log_level=log_level.to_logging(), runner_id=runner_id) + runner = Runner(log_level=log_level.to_logging(), runner_id=str(runner_id)) runner.run() diff --git a/src/jobflow_remote/cli/types.py b/src/jobflow_remote/cli/types.py index c5518106..d8fa6af9 100644 --- a/src/jobflow_remote/cli/types.py +++ b/src/jobflow_remote/cli/types.py @@ -1,6 +1,8 @@ +import json from datetime import datetime from typing import List, Optional +import click import typer from typing_extensions import Annotated @@ -142,7 +144,7 @@ typer.Option( "--reverse-sort", "-revs", - help=("Reverse the sorting order"), + help="Reverse the sorting order", ), ] @@ -167,6 +169,45 @@ typer.Option( "--force", "-f", - help=("No confirmation will be asked before proceeding"), + help="No confirmation will be asked before proceeding", + ), +] + + +locked_opt = Annotated[ + bool, + typer.Option( + "--locked", + "-l", + help="Select locked Jobs", + ), +] + + +# as of typer version 0.9.0 the dict is not a supported type. Define a custom one +class DictType(dict): + pass + + +class DictTypeParser(click.ParamType): + name = "DictType" + + def convert(self, value, param, ctx): + try: + value = json.loads(value) + except Exception as e: + raise typer.BadParameter( + f"Error while converting JSON: {getattr(e, 'message', str(e))}" + ) + return DictType(value) + + +query_opt = Annotated[ + Optional[DictType], + typer.Option( + "--query", + "-q", + help="A JSON string representing a generic query in the form of a dictionary. Overrides all other query options. Requires knowledge of the internal structure of the DB. ", + click_type=DictTypeParser(), ), ] diff --git a/src/jobflow_remote/fireworks/launchpad.py b/src/jobflow_remote/fireworks/launchpad.py index 2cc617c1..fa4fb838 100644 --- a/src/jobflow_remote/fireworks/launchpad.py +++ b/src/jobflow_remote/fireworks/launchpad.py @@ -452,15 +452,12 @@ def set_remote_values( return False - def remove_lock(self, fw_id: int | None = None, job_id: str | None = None): - query = self._generate_id_query(fw_id, job_id) - result = self.fireworks.find_one_and_update( - query, - {"$unset": {REMOTE_LOCK_PATH: "", REMOTE_LOCK_TIME_PATH: ""}}, - projection=["fw_id"], + 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: ""}}, ) - if not result: - raise ValueError("No job matching id") + return result.modified_count def is_locked(self, fw_id: int | None = None, job_id: str | None = None) -> bool: query = self._generate_id_query(fw_id, job_id) diff --git a/src/jobflow_remote/jobs/data.py b/src/jobflow_remote/jobs/data.py index 7c0ed3ed..88b9c91a 100644 --- a/src/jobflow_remote/jobs/data.py +++ b/src/jobflow_remote/jobs/data.py @@ -12,6 +12,7 @@ get_remote_doc, ) from jobflow_remote.jobs.state import FlowState, JobState, RemoteState +from jobflow_remote.utils.db import MongoLock @dataclass @@ -34,8 +35,8 @@ class JobData: "updated_on": 1, f"{REMOTE_DOC_PATH}.updated_on": 1, f"{REMOTE_DOC_PATH}.previous_state": 1, - f"{REMOTE_DOC_PATH}.lock_id": 1, - f"{REMOTE_DOC_PATH}.lock_time": 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, @@ -54,7 +55,7 @@ class JobInfo: machine: str remote_state: RemoteState | None = None remote_previous_state: RemoteState | None = None - lock_id: datetime | None = None + lock_id: str | None = None lock_time: datetime | None = None retry_time_limit: datetime | None = None queue_job_id: str | None = None @@ -81,8 +82,8 @@ def from_fw_dict(cls, d): if remote_previous_state_val is not None else None ) - lock_id = remote.get("lock_id") - lock_time = remote.get("lock_time") + lock_id = remote.get(MongoLock.LOCK_KEY) + lock_time = remote.get(MongoLock.LOCK_TIME_KEY) if lock_time is not None: lock_time = lock_time.replace(tzinfo=timezone.utc).astimezone(tz=None) retry_time_limit = remote.get("retry_time_limit") diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index d59bb13c..31949a13 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -25,6 +25,7 @@ job_info_projection, ) from jobflow_remote.jobs.state import FlowState, JobState, RemoteState +from jobflow_remote.utils.db import MongoLock logger = logging.getLogger(__name__) @@ -66,6 +67,7 @@ def _build_query_fw( db_ids: int | list[int] | None = None, state: JobState | None = None, remote_state: RemoteState | None = None, + locked: bool = False, start_date: datetime | None = None, end_date: datetime | None = None, ) -> dict: @@ -102,6 +104,9 @@ def _build_query_fw( end_date_str = end_date.astimezone(timezone.utc).isoformat() query["updated_on"] = {"$lte": end_date_str} + if locked: + query[f"{REMOTE_DOC_PATH}.{MongoLock.LOCK_KEY}"] = {"$exists": True} + return query def _build_query_wf( @@ -220,6 +225,22 @@ def get_jobs_data( 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 + ) + + jobs_data = [] + for d in data: + jobs_data.append(JobInfo.from_fw_dict(d)) + + return jobs_data + def get_jobs_info( self, job_ids: str | list[str] | None = None, @@ -228,6 +249,7 @@ def get_jobs_info( remote_state: RemoteState | None = None, start_date: datetime | None = None, end_date: datetime | None = None, + locked: bool = False, sort: list[tuple] | None = None, limit: int = 0, ) -> list[JobInfo]: @@ -236,18 +258,11 @@ def get_jobs_info( db_ids=db_ids, state=state, remote_state=remote_state, + locked=locked, start_date=start_date, end_date=end_date, ) - data = self.rlpad.fireworks.find( - query, sort=sort, limit=limit, projection=job_info_projection - ) - - jobs_data = [] - for d in data: - jobs_data.append(JobInfo.from_fw_dict(d)) - - return jobs_data + return self.get_jobs_info_query(query=query, sort=sort, limit=limit) def get_job_info( self, job_id: str | None, db_id: int | None, full: bool = False @@ -408,3 +423,24 @@ def delete_flows( self.rlpad.delete_wf(**{arg: jid}) except ValueError as e: logger.warning(f"Error while deleting flow: {getattr(e, 'message', e)}") + + def remove_lock( + self, + job_ids: str | list[str] | None = None, + db_ids: int | list[int] | None = None, + state: JobState | None = None, + remote_state: RemoteState | None = None, + start_date: datetime | None = None, + end_date: datetime | None = None, + ) -> int: + query = self._build_query_fw( + job_ids=job_ids, + db_ids=db_ids, + state=state, + remote_state=remote_state, + start_date=start_date, + end_date=end_date, + locked=True, + ) + + return self.rlpad.remove_lock(query=query) diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index 656f06a5..b93dea9e 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -167,7 +167,6 @@ def run(self): def lock_and_update( self, states, - # collection, job_id=None, additional_filter=None, update=None, @@ -225,51 +224,78 @@ def lock_and_update( error = traceback.format_exc() warnings.warn(error) - if not error: - # the state.next.value is correct as SUBMITTED is not dealt with here. - succeeded_update = { + lock.update_on_release = self._prepare_lock_update( + doc, error, fail_now, set_output, state.next + ) + + 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": state.next.value, - f"{REMOTE_DOC_PATH}.step_attempts": 0, - f"{REMOTE_DOC_PATH}.retry_time_limit": None, - f"{REMOTE_DOC_PATH}.error": None, + f"{REMOTE_DOC_PATH}.state": RemoteState.FAILED.value, + f"{REMOTE_DOC_PATH}.previous_state": remote_doc["state"], + f"{REMOTE_DOC_PATH}.error": error, } } - lock.update_on_release = deep_merge_dict( - succeeded_update, set_output or {} - ) else: - step_attempts = remote_doc["step_attempts"] - fail_now = ( - fail_now or step_attempts >= self.runner_options.max_step_attempts - ) - if fail_now: - lock.update_on_release = { - "$set": { - f"{REMOTE_DOC_PATH}.state": RemoteState.FAILED.value, - f"{REMOTE_DOC_PATH}.previous_state": state.value, - 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) - lock.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, - } + 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"]) - if "$set" in lock.update_on_release: - lock.update_on_release["$set"][ - "updated_on" - ] = datetime.utcnow().isoformat() - self.ping_wf_doc(doc["fw_id"]) - - return True + return update_on_release def upload(self, doc): fw_id = doc["fw_id"] @@ -458,7 +484,9 @@ def check_run_status(self): 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()}}, } projection = [ "fw_id", @@ -466,6 +494,7 @@ def check_run_status(self): FW_UUID_PATH, f"{REMOTE_DOC_PATH}.process_id", f"{REMOTE_DOC_PATH}.state", + f"{REMOTE_DOC_PATH}.step_attempts", "spec._tasks.machine", ] for doc in self.rlpad.fireworks.find(db_filter, projection): @@ -475,52 +504,51 @@ def check_run_status(self): for machine_id, ids_docs in machines_ids_docs.items(): + error = None if not ids_docs: continue + + qjobs_dict = {} try: ids_list = list(ids_docs.keys()) queue = self.get_queue_manager(machine_id) qjobs = queue.get_jobs_list(ids_list) + qjobs_dict = {qjob.job_id: qjob for qjob in qjobs} except Exception: logger.warning( f"error trying to get jobs list for machine: {machine_id}", exc_info=True, ) - continue - - qjobs_dict = {qjob.job_id: qjob for qjob in qjobs} + error = traceback.format_exc() for doc_id, (doc, remote_doc) in ids_docs.items(): # TODO if failed should maybe be handled differently? qjob = qjobs_dict.get(doc_id) qstate = qjob.state if qjob else None collection = self.rlpad.fireworks + next_state = None if ( qstate == QState.RUNNING and remote_doc["state"] == RemoteState.SUBMITTED.value ): - 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, - ) as lock: - if lock.locked_document: - lock.update_on_release = { - "$set": { - f"{REMOTE_DOC_PATH}.state": RemoteState.RUNNING.value, - f"{REMOTE_DOC_PATH}.queue_state": qstate.value, - "updated_on": datetime.utcnow().isoformat(), - } - } - self.ping_wf_doc(doc["fw_id"]) - logger.debug( - f"remote job with id {remote_doc['process_id']} is running" - ) + next_state = RemoteState.RUNNING + 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 + logger.debug( + f"terminated remote job with id {remote_doc['process_id']}" + ) + elif not error and remote_doc["step_attempts"] > 0: + # 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"]) + + # the document needs to be updated only in case of error or if a + # next state has been set + if next_state or error: lock_filter = { f"{REMOTE_DOC_PATH}.state": remote_doc["state"], FW_UUID_PATH: get_job_doc(doc)["uuid"], @@ -531,18 +559,15 @@ def check_run_status(self): lock_subdoc=REMOTE_DOC_PATH, ) as lock: if lock.locked_document: - lock.update_on_release = { + set_output = { "$set": { - f"{REMOTE_DOC_PATH}.state": RemoteState.TERMINATED.value, f"{REMOTE_DOC_PATH}.queue_state": qstate.value if qstate - else None, - "updated_on": datetime.utcnow().isoformat(), + else None } } - self.ping_wf_doc(doc["fw_id"]) - logger.debug( - f"terminated remote job with id {remote_doc['process_id']}" + lock.update_on_release = self._prepare_lock_update( + doc, error, False, set_output, next_state ) def checkout(self): diff --git a/src/jobflow_remote/utils/db.py b/src/jobflow_remote/utils/db.py index f2e3e8e7..cc1a46ca 100644 --- a/src/jobflow_remote/utils/db.py +++ b/src/jobflow_remote/utils/db.py @@ -33,7 +33,7 @@ def __init__( self.timeout = timeout self.break_lock = break_lock self.locked_document = None - self.lock_id = lock_id or id(self) + 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 From cccf38c24353de08bc856cff694033d33bbcbe39 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Fri, 9 Jun 2023 22:20:13 +0200 Subject: [PATCH 11/89] helper to generate configuration --- src/jobflow_remote/cli/config.py | 40 +++++++- src/jobflow_remote/cli/types.py | 12 ++- src/jobflow_remote/cli/utils.py | 6 ++ src/jobflow_remote/config/helper.py | 124 +++++++++++++++++++++++++ src/jobflow_remote/config/manager.py | 18 +++- src/jobflow_remote/remote/host/base.py | 14 +++ 6 files changed, 207 insertions(+), 7 deletions(-) create mode 100644 src/jobflow_remote/config/helper.py diff --git a/src/jobflow_remote/cli/config.py b/src/jobflow_remote/cli/config.py index 566530c1..13b22f6d 100644 --- a/src/jobflow_remote/cli/config.py +++ b/src/jobflow_remote/cli/config.py @@ -1,15 +1,18 @@ -from __future__ import annotations - import typer from rich.text import Text +from typing_extensions import Annotated from jobflow_remote.cli.jf import app +from jobflow_remote.cli.types import serialize_file_format_opt from jobflow_remote.cli.utils import ( + SerializeFileFormat, exit_with_error_msg, exit_with_warning_msg, out_console, + print_success_msg, ) from jobflow_remote.config import ConfigError, ConfigManager +from jobflow_remote.config.helper import generate_dummy_project app_config = typer.Typer( name="config", @@ -62,3 +65,36 @@ def current_project(): out_console.print(text) except ConfigError as e: exit_with_error_msg(f"Error loading the selected project: {e}") + + +@app_project.command() +def generate( + name: Annotated[str, typer.Argument(help="Name of the project")], + file_format: serialize_file_format_opt = SerializeFileFormat.YAML.value, + full: Annotated[ + bool, + typer.Option( + "--full", + help="Generate a configuration file with all the fields and more elements", + ), + ] = False, +): + """ + Generate a project configuration file with dummy elements to be edited manually + """ + + cm = ConfigManager(exclude_unset=not full) + if name in cm.projects_data: + exit_with_error_msg(f"Project with name {name} already exists") + + filepath = cm.projects_folder / f"{name}.{file_format.value}" + if filepath.exists(): + exit_with_error_msg( + f"Project with name {name} does not exist, but file {str(filepath)} does and will not be overwritten" + ) + + project = generate_dummy_project(name=name, full=full) + cm.create_project(project, ext=file_format.value) + print_success_msg( + f"Configuration file for project {name} created in {str(filepath)}" + ) diff --git a/src/jobflow_remote/cli/types.py b/src/jobflow_remote/cli/types.py index d8fa6af9..ba1c6b42 100644 --- a/src/jobflow_remote/cli/types.py +++ b/src/jobflow_remote/cli/types.py @@ -6,7 +6,7 @@ import typer from typing_extensions import Annotated -from jobflow_remote.cli.utils import LogLevel, SortOption +from jobflow_remote.cli.utils import LogLevel, SerializeFileFormat, SortOption from jobflow_remote.jobs.state import JobState, RemoteState job_ids_opt = Annotated[ @@ -184,6 +184,16 @@ ] +serialize_file_format_opt = Annotated[ + SerializeFileFormat, + typer.Option( + "--format", + "-f", + help="File format", + ), +] + + # 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 b9e624b6..56d2d095 100644 --- a/src/jobflow_remote/cli/utils.py +++ b/src/jobflow_remote/cli/utils.py @@ -42,6 +42,12 @@ def query_field(self) -> str: return self.value +class SerializeFileFormat(Enum): + JSON = "json" + YAML = "yaml" + TOML = "toml" + + def exit_with_error_msg(message, code=1, **kwargs): kwargs.setdefault("style", "red") err_console.print(message, **kwargs) diff --git a/src/jobflow_remote/config/helper.py b/src/jobflow_remote/config/helper.py new file mode 100644 index 00000000..da56614a --- /dev/null +++ b/src/jobflow_remote/config/helper.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import logging + +from jobflow_remote.config.base import ( + ExecutionConfig, + LaunchPadConfig, + LocalHostConfig, + Machine, + Project, + RemoteHostConfig, +) + + +def generate_dummy_project(name: str, full: bool = False) -> Project: + + rh = generate_dummy_host("remote") + hosts = [rh] + remote_machine = generate_dummy_machine(scheduler_type="slurm", host_id=rh.host_id) + machines = [remote_machine] + exec_config = [] + if full: + lh = generate_dummy_host("local") + hosts.append(lh) + local_machine = generate_dummy_machine( + scheduler_type="shell", host_id=lh.host_id + ) + machines.append(local_machine) + exec_config = [generate_dummy_exec_config()] + + lpad_config = generate_dummy_launchpad_config() + + jobstore = generate_dummy_jobstore() + + p = Project( + name=name, + log_level=logging.DEBUG, + hosts=hosts, + jobstore=jobstore, + run_db=lpad_config, + machines=machines, + exec_config=exec_config, + ) + + return p + + +def generate_dummy_host(host_type: str) -> RemoteHostConfig | LocalHostConfig: + if host_type == "local": + return LocalHostConfig( + host_type="local", + host_id="test_local_host", + timeout_execute=60, + ) + elif host_type == "remote": + return RemoteHostConfig( + host_type="remote", + host_id="test_remote_host", + host="remote.host.net", + user="bob", + timeout_execute=60, + ) + else: + raise ValueError(f"Unknown host type {host_type}") + + +def generate_dummy_machine( + scheduler_type: str = "slurm", host_id: str = "test_remote_host" +) -> Machine: + return Machine( + machine_id=f"machine_{host_id}", + scheduler_type=scheduler_type, + host_id=host_id, + work_dir="/path/to/run/folder", + pre_run="source /path/to/python/environment/activate", + ) + + +def generate_dummy_jobstore() -> dict: + jobstore_dict = { + "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", + } + }, + } + + return jobstore_dict + + +def generate_dummy_exec_config() -> ExecutionConfig: + exec_config = ExecutionConfig( + exec_config_id="example_config", + modules=["GCC/10.2.0", "OpenMPI/4.0.5-GCC-10.2.0"], + export={"PATH": "/path/to/binaries:$PATH"}, + pre_run="conda activate env_name", + ) + return exec_config + + +def generate_dummy_launchpad_config() -> LaunchPadConfig: + lp_config = LaunchPadConfig( + host="localhost", + port=27017, + name="db_name", + username="bob", + password="secret_password", + ) + return lp_config diff --git a/src/jobflow_remote/config/manager.py b/src/jobflow_remote/config/manager.py index 7ac43d7b..45e2ce68 100644 --- a/src/jobflow_remote/config/manager.py +++ b/src/jobflow_remote/config/manager.py @@ -23,19 +23,23 @@ RemoteHostConfig, ) from jobflow_remote.remote.host.base import BaseHost -from jobflow_remote.utils.data import deep_merge_dict, remove_none +from jobflow_remote.utils.data import deep_merge_dict logger = logging.getLogger(__name__) ProjectData = namedtuple("ProjectData", ["filepath", "project", "ext"]) +WorkerData = namedtuple("WorkerData", ["name", "worker"]) + class ConfigManager: projects_ext = ["json", "yaml", "toml"] - def __init__(self): + def __init__(self, exclude_unset=False, exclude_none=False): from jobflow_remote import SETTINGS + self.exclude_unset = exclude_unset + self.exclude_none = exclude_none self.projects_folder = Path(SETTINGS.projects_folder) makedirs_p(self.projects_folder) self.projects_data = self.load_projects_data() @@ -92,11 +96,13 @@ def get_project(self, project_name: str | None = None) -> Project: return self.get_project_data(project_name).project def dump_project(self, project_data: ProjectData): - d = project_data.project.dict() + exclude_none = True if project_data.ext == "toml" else self.exclude_none + d = project_data.project.dict( + exclude_none=exclude_none, exclude_unset=self.exclude_unset + ) if project_data.ext in ["json", "yaml"]: dumpfn(d, project_data.filepath) elif project_data.ext == "toml": - d = remove_none(d) with open(project_data.filepath, "w") as f: tomlkit.dump(d, f) @@ -108,6 +114,10 @@ def create_project(self, project: Project, ext="yaml"): makedirs_p(project.tmp_dir) makedirs_p(project.log_dir) filepath = self.projects_folder / f"{project.name}.{ext}" + if filepath.exists(): + raise ConfigError( + f"Project with name {project.name} does not exist, but file {str(filepath)} does" + ) project_data = ProjectData(filepath, project, ext) self.dump_project(project_data) self.projects_data[project.name] = project_data diff --git a/src/jobflow_remote/remote/host/base.py b/src/jobflow_remote/remote/host/base.py index 406767f0..2213429f 100644 --- a/src/jobflow_remote/remote/host/base.py +++ b/src/jobflow_remote/remote/host/base.py @@ -1,6 +1,7 @@ from __future__ import annotations import abc +import traceback from pathlib import Path from monty.json import MSONable @@ -68,3 +69,16 @@ def get(self, src, dst): @abc.abstractmethod def copy(self, src, dst): raise NotImplementedError + + def test(self) -> str | None: + msg = None + try: + cmd = "echo 'test'" + stdout, stderr, returncode = self.execute(cmd) + if returncode != 0 or stdout.strip() != "test": + msg = f"Command was executed but some error occurred.\nstdoud: {stdout}\nstderr: {stderr}" + except Exception: + exc = traceback.format_exc() + msg = f"Error while executing command:\n {exc}" + + return msg From daa2ce325b2777d301d700dc1c605957ad87a5eb Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Tue, 13 Jun 2023 00:30:21 +0200 Subject: [PATCH 12/89] more refactoring of configuration --- src/jobflow_remote/cli/formatting.py | 10 +- src/jobflow_remote/cli/job.py | 4 +- src/jobflow_remote/cli/runner.py | 4 +- src/jobflow_remote/cli/types.py | 3 +- src/jobflow_remote/cli/utils.py | 16 --- src/jobflow_remote/config/__init__.py | 5 +- src/jobflow_remote/config/base.py | 137 ++++++++-------------- src/jobflow_remote/config/helper.py | 75 +++++------- src/jobflow_remote/config/manager.py | 89 ++++---------- src/jobflow_remote/fireworks/convert.py | 14 +-- src/jobflow_remote/fireworks/launchpad.py | 24 +++- src/jobflow_remote/fireworks/tasks.py | 10 +- src/jobflow_remote/jobs/data.py | 16 +-- src/jobflow_remote/jobs/jobcontroller.py | 2 +- src/jobflow_remote/jobs/runner.py | 86 ++++++++------ src/jobflow_remote/jobs/submit.py | 16 +-- src/jobflow_remote/remote/host/local.py | 3 + src/jobflow_remote/remote/host/remote.py | 5 + src/jobflow_remote/remote/queue.py | 18 --- src/jobflow_remote/utils/data.py | 37 ++++++ 20 files changed, 264 insertions(+), 310 deletions(-) diff --git a/src/jobflow_remote/cli/formatting.py b/src/jobflow_remote/cli/formatting.py index 6106eced..9b973dbb 100644 --- a/src/jobflow_remote/cli/formatting.py +++ b/src/jobflow_remote/cli/formatting.py @@ -19,7 +19,7 @@ def get_job_info_table(jobs_info: list[JobInfo], verbosity: int): table.add_column("State [Remote]") table.add_column("Job id") - table.add_column("Machine") + table.add_column("Worker") table.add_column("Last updated") if verbosity >= 1: @@ -44,7 +44,7 @@ def get_job_info_table(jobs_info: list[JobInfo], verbosity: int): ji.name, state, ji.job_id, - ji.machine, + ji.worker, ji.last_updated.strftime(fmt_datetime), ] @@ -80,7 +80,7 @@ def get_flow_info_table(flows_info: list[FlowInfo], verbosity: int): table.add_column("Last updated") if verbosity >= 1: - table.add_column("Machines") + table.add_column("Workers") table.add_column("Job states") @@ -98,8 +98,8 @@ def get_flow_info_table(flows_info: list[FlowInfo], verbosity: int): ] if verbosity >= 1: - machines = set(fi.machines) - row.append(", ".join(machines)) + workers = set(fi.workers) + row.append(", ".join(workers)) job_states = "-".join(js.short_value for js in fi.job_states) row.append(job_states) diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index c8c16574..aa19ddfa 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -298,8 +298,8 @@ def queue_out( with loading_spinner() as progress: progress.add_task(description="Retrieving files...", total=None) cm = ConfigManager() - machine = cm.load_machine(info.machine) - host = cm.load_host(machine.host_id) + worker = cm.load_worker(info.worker) + host = worker.get_host() try: host.connect() diff --git a/src/jobflow_remote/cli/runner.py b/src/jobflow_remote/cli/runner.py index 1c2a37ea..f71c88bd 100644 --- a/src/jobflow_remote/cli/runner.py +++ b/src/jobflow_remote/cli/runner.py @@ -8,12 +8,12 @@ from jobflow_remote.cli.jf import app from jobflow_remote.cli.types import log_level_opt, runner_num_procs_opt from jobflow_remote.cli.utils import ( - LogLevel, exit_with_error_msg, exit_with_warning_msg, loading_spinner, out_console, ) +from jobflow_remote.config.base import LogLevel from jobflow_remote.jobs.daemon import DaemonError, DaemonManager, DaemonStatus from jobflow_remote.jobs.runner import Runner @@ -41,7 +41,7 @@ def run( Should be used by the daemon or for testing purposes. """ runner_id = os.getpid() if set_pid else None - runner = Runner(log_level=log_level.to_logging(), runner_id=str(runner_id)) + runner = Runner(log_level=log_level, runner_id=str(runner_id)) runner.run() diff --git a/src/jobflow_remote/cli/types.py b/src/jobflow_remote/cli/types.py index ba1c6b42..33d2eccc 100644 --- a/src/jobflow_remote/cli/types.py +++ b/src/jobflow_remote/cli/types.py @@ -6,7 +6,8 @@ import typer from typing_extensions import Annotated -from jobflow_remote.cli.utils import LogLevel, SerializeFileFormat, SortOption +from jobflow_remote.cli.utils import SerializeFileFormat, SortOption +from jobflow_remote.config.base import LogLevel from jobflow_remote.jobs.state import JobState, RemoteState job_ids_opt = Annotated[ diff --git a/src/jobflow_remote/cli/utils.py b/src/jobflow_remote/cli/utils.py index 56d2d095..a4285e28 100644 --- a/src/jobflow_remote/cli/utils.py +++ b/src/jobflow_remote/cli/utils.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging from contextlib import contextmanager from enum import Enum @@ -15,21 +14,6 @@ fmt_datetime = "%Y-%m-%d %H:%M" -class LogLevel(Enum): - ERROR = "error" - WARN = "warn" - INFO = "info" - DEBUG = "debug" - - def to_logging(self) -> int: - return { - LogLevel.ERROR: logging.ERROR, - LogLevel.WARN: logging.WARN, - LogLevel.INFO: logging.INFO, - LogLevel.DEBUG: logging.DEBUG, - }[self] - - class SortOption(Enum): CREATED_ON = "created_on" UPDATED_ON = "updated_on" diff --git a/src/jobflow_remote/config/__init__.py b/src/jobflow_remote/config/__init__.py index cda6b584..adeb3a13 100644 --- a/src/jobflow_remote/config/__init__.py +++ b/src/jobflow_remote/config/__init__.py @@ -1,10 +1,9 @@ from jobflow_remote.config.base import ( ConfigError, - LaunchPadConfig, - LocalHostConfig, - Machine, + LocalWorker, Project, RemoteLaunchPad, + RemoteWorker, RunnerOptions, ) from jobflow_remote.config.manager import ConfigManager, ProjectData diff --git a/src/jobflow_remote/config/base.py b/src/jobflow_remote/config/base.py index d05e8249..7bbed82f 100644 --- a/src/jobflow_remote/config/base.py +++ b/src/jobflow_remote/config/base.py @@ -1,7 +1,9 @@ from __future__ import annotations +import abc import logging import traceback +from enum import Enum from pathlib import Path from typing import Annotated, Literal @@ -11,6 +13,7 @@ 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 DEFAULT_JOBSTORE = {"docs_store": {"type": "MemoryStore"}} @@ -32,16 +35,29 @@ class Config: extra = Extra.forbid -class Machine(BaseModel): +class LogLevel(str, Enum): + ERROR = "error" + WARN = "warn" + INFO = "info" + DEBUG = "debug" + + def to_logging(self) -> int: + return { + LogLevel.ERROR: logging.ERROR, + LogLevel.WARN: logging.WARN, + LogLevel.INFO: logging.INFO, + LogLevel.DEBUG: logging.DEBUG, + }[self] + + +class WorkerBase(BaseModel): - machine_id: str scheduler_type: str - host_id: str work_dir: str resources: dict | None = None pre_run: str | None = None post_run: str | None = None - queue_exec_timeout: int | None = 30 + timeout_execute: int = 60 class Config: extra = Extra.forbid @@ -60,28 +76,22 @@ def get_scheduler_io(self) -> BaseSchedulerIO: raise ConfigError(f"Unknown scheduler type {self.scheduler_type}") return scheduler_mapping[self.scheduler_type]() + @abc.abstractmethod + def get_host(self) -> BaseHost: + pass + -class LaunchPadConfig(BaseModel): - host: str | None = "localhost" - port: int | None = None - name: str | None = None - username: str | None = None - password: str | None = None - logdir: str | None = None - strm_lvl: str = "CRITICAL" - user_indices: list[str] | None = None - wf_user_indices: list[str] | None = None - authsource: str | None = None - uri_mode: bool = False - mongoclient_kwargs: dict | None = None +class LocalWorker(WorkerBase): + + type: Literal["local"] = "local" + + def get_host(self) -> BaseHost: + return LocalHost(timeout_execute=self.timeout_execute) - class Config: - extra = Extra.forbid +class RemoteWorker(WorkerBase): -class RemoteHostConfig(BaseModel): - host_type: Literal["remote"] = "remote" - host_id: str + type: Literal["remote"] = "remote" host: str user: str = None port: int = None @@ -90,10 +100,6 @@ class RemoteHostConfig(BaseModel): connect_timeout: int = None connect_kwargs: dict = None inline_ssh_env: bool = None - timeout_execute: int = 60 - - class Config: - extra = Extra.forbid def get_host(self) -> BaseHost: return RemoteHost( @@ -109,21 +115,7 @@ def get_host(self) -> BaseHost: ) -class LocalHostConfig(BaseModel): - host_type: Literal["local"] = "local" - host_id: str - timeout_execute: int = 60 - - class Config: - extra = Extra.forbid - - def get_host(self) -> BaseHost: - return LocalHost(timeout_execute=self.timeout_execute) - - -HostConfig = Annotated[ - LocalHostConfig | RemoteHostConfig, Field(discriminator="host_type") -] +WorkerConfig = Annotated[LocalWorker | RemoteWorker, Field(discriminator="type")] class ExecutionConfig(BaseModel): @@ -143,29 +135,13 @@ class Project(BaseModel): tmp_dir: str | None = None log_dir: str | None = None daemon_dir: str | None = None - log_level: int = logging.INFO + log_level: LogLevel = LogLevel.INFO runner: RunnerOptions = Field(default_factory=RunnerOptions) - hosts: list[HostConfig] = Field(default_factory=list) - machines: list[Machine] = Field(default_factory=list) - run_db: LaunchPadConfig = Field(default_factory=LaunchPadConfig) + workers: dict[str, WorkerConfig] = Field(default_factory=dict) + queue: dict = Field(default_factory=dict) exec_config: list[ExecutionConfig] = Field(default_factory=list) jobstore: dict = Field(default_factory=lambda: dict(DEFAULT_JOBSTORE)) - def get_machines_dict(self) -> dict[str, Machine]: - return {m.machine_id: m for m in self.machines} - - def get_machines_ids(self) -> list[str]: - return [m.machine_id for m in self.machines] - - def get_hosts_config_dict(self) -> dict[str, LocalHostConfig | RemoteHostConfig]: - return {h.host_id: h for h in self.hosts} - - def get_hosts_ids(self) -> list[str]: - return [h.host_id for h in self.hosts] - - def get_hosts_dict(self) -> dict[str, BaseHost]: - return {h.host_id: h.get_host() for h in self.hosts} - def get_exec_config_dict(self) -> dict[str, ExecutionConfig]: return {ec.exec_config_id: ec for ec in self.exec_config} @@ -180,8 +156,11 @@ def get_jobstore(self) -> JobStore | None: else: return JobStore.from_dict_spec(self.jobstore) + def get_queue_store(self): + return store_from_dict(self.queue) + def get_launchpad(self) -> RemoteLaunchPad: - return RemoteLaunchPad(**self.run_db.dict()) + return RemoteLaunchPad(self.get_queue_store()) @validator("base_dir", always=True) def check_base_dir(cls, base_dir: str, values: dict) -> str: @@ -221,31 +200,6 @@ def check_daemon_dir(cls, daemon_dir: str, values: dict) -> str: return str(Path(values["base_dir"], "daemon")) return daemon_dir - @validator("machines", always=True) - def check_machines(cls, machines: list[Machine], values: dict) -> list[Machine]: - if "hosts" not in values: - raise ValueError("hosts should be defined to define a Machine") - - hosts_ids = [h.host_id for h in values["hosts"]] - mids: list[Machine] = [] - for m in machines: - if m.machine_id in mids: - raise ValueError(f"Repeated Machine with id {m.machine_id}") - if m.host_id not in hosts_ids: - raise ValueError( - f"Host with id {m.host_id} defined in Machine {m.machine_id} is not defined" - ) - return machines - - @validator("hosts", always=True) - def check_hosts(cls, hosts: list[HostConfig], values: dict) -> list[HostConfig]: - hids: list[HostConfig] = [] - for h in hosts: - if h.host_id in hids: - raise ValueError(f"Repeated Host with id {h.host_id}") - - return hosts - @validator("exec_config", always=True) def check_exec_config( cls, exec_config: list[ExecutionConfig], values: dict @@ -271,6 +225,17 @@ def check_jobstore(cls, jobstore: dict, values: dict) -> dict: ) from e return jobstore + @validator("queue", always=True) + def check_queue(cls, queue: dict, values: dict) -> dict: + if queue: + try: + 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 + return queue + class Config: extra = Extra.forbid diff --git a/src/jobflow_remote/config/helper.py b/src/jobflow_remote/config/helper.py index da56614a..e08d2c9a 100644 --- a/src/jobflow_remote/config/helper.py +++ b/src/jobflow_remote/config/helper.py @@ -1,79 +1,61 @@ from __future__ import annotations -import logging - from jobflow_remote.config.base import ( ExecutionConfig, - LaunchPadConfig, - LocalHostConfig, - Machine, + LocalWorker, Project, - RemoteHostConfig, + RemoteWorker, + WorkerBase, ) def generate_dummy_project(name: str, full: bool = False) -> Project: - rh = generate_dummy_host("remote") - hosts = [rh] - remote_machine = generate_dummy_machine(scheduler_type="slurm", host_id=rh.host_id) - machines = [remote_machine] + remote_worker = generate_dummy_worker(scheduler_type="slurm", host_type="remote") + workers = {"example_worker": remote_worker} exec_config = [] if full: - lh = generate_dummy_host("local") - hosts.append(lh) - local_machine = generate_dummy_machine( - scheduler_type="shell", host_id=lh.host_id - ) - machines.append(local_machine) + local_worker = generate_dummy_worker(scheduler_type="shell", host_type="local") + workers["example_local"] = local_worker exec_config = [generate_dummy_exec_config()] - lpad_config = generate_dummy_launchpad_config() + queue = generate_dummy_queue() jobstore = generate_dummy_jobstore() p = Project( name=name, - log_level=logging.DEBUG, - hosts=hosts, jobstore=jobstore, - run_db=lpad_config, - machines=machines, + queue=queue, + workers=workers, exec_config=exec_config, ) return p -def generate_dummy_host(host_type: str) -> RemoteHostConfig | LocalHostConfig: +def generate_dummy_worker( + scheduler_type: str = "slurm", host_type: str = "remote" +) -> WorkerBase: + d: dict = dict( + scheduler_type=scheduler_type, + work_dir="/path/to/run/folder", + pre_run="source /path/to/python/environment/activate", + ) if host_type == "local": - return LocalHostConfig( - host_type="local", - host_id="test_local_host", + d.update( + type="local", timeout_execute=60, ) + return LocalWorker(**d) elif host_type == "remote": - return RemoteHostConfig( - host_type="remote", - host_id="test_remote_host", + d.update( + type="remote", host="remote.host.net", user="bob", timeout_execute=60, ) - else: - raise ValueError(f"Unknown host type {host_type}") - - -def generate_dummy_machine( - scheduler_type: str = "slurm", host_id: str = "test_remote_host" -) -> Machine: - return Machine( - machine_id=f"machine_{host_id}", - scheduler_type=scheduler_type, - host_id=host_id, - work_dir="/path/to/run/folder", - pre_run="source /path/to/python/environment/activate", - ) + return RemoteWorker(**d) def generate_dummy_jobstore() -> dict: @@ -113,12 +95,13 @@ def generate_dummy_exec_config() -> ExecutionConfig: return exec_config -def generate_dummy_launchpad_config() -> LaunchPadConfig: - lp_config = LaunchPadConfig( +def generate_dummy_queue() -> dict: + lp_config = dict( + type="MongoStore", host="localhost", - port=27017, - name="db_name", + database="db_name", username="bob", password="secret_password", + collection_name="jobs", ) return lp_config diff --git a/src/jobflow_remote/config/manager.py b/src/jobflow_remote/config/manager.py index 45e2ce68..3fc09d69 100644 --- a/src/jobflow_remote/config/manager.py +++ b/src/jobflow_remote/config/manager.py @@ -10,19 +10,12 @@ import tomlkit from jobflow import JobStore +from maggma.stores import MongoStore +from monty.json import jsanitize from monty.os import makedirs_p from monty.serialization import dumpfn, loadfn -from jobflow_remote.config.base import ( - ConfigError, - ExecutionConfig, - LaunchPadConfig, - LocalHostConfig, - Machine, - Project, - RemoteHostConfig, -) -from jobflow_remote.remote.host.base import BaseHost +from jobflow_remote.config.base import ConfigError, ExecutionConfig, Project, WorkerBase from jobflow_remote.utils.data import deep_merge_dict logger = logging.getLogger(__name__) @@ -97,8 +90,11 @@ def get_project(self, project_name: str | None = None) -> Project: def dump_project(self, project_data: ProjectData): exclude_none = True if project_data.ext == "toml" else self.exclude_none - d = project_data.project.dict( - exclude_none=exclude_none, exclude_unset=self.exclude_unset + d = jsanitize( + project_data.project.dict( + exclude_none=exclude_none, exclude_unset=self.exclude_unset + ), + enum_values=True, ) if project_data.ext in ["json", "yaml"]: dumpfn(d, project_data.filepath) @@ -137,71 +133,36 @@ def update_project(self, config: dict, project_name: str): self.dump_project(project_data) self.projects_data[project_data.project.name] = project_data - def set_machine( - self, machine: Machine, project_name: str | None = None, replace: bool = False - ): - project_data = self.get_project_data(project_name) - machines_data = project_data.project.get_machines_dict() - if not replace and machine.machine_id in machines_data: - raise ConfigError( - f"Machine with id {machine.machine_id} is already defined" - ) - if machine.host_id not in project_data.project.get_hosts_ids(): - raise ConfigError(f"host {machine.host_id} is not defined") - machines_data[machine.machine_id] = machine - project_data.project.machines = list(machines_data.values()) - - self.dump_project(project_data) - - def remove_machine(self, machine_id: str, project_name: str | None = None): - project_data = self.get_project_data(project_name) - machines_data = project_data.project.get_machines_dict() - machines_data.pop(machine_id) - project_data.project.machines = list(machines_data.values()) - self.dump_project(project_data) - - def load_machine(self, machine_id: str, project_name: str | None = None) -> Machine: - project = self.get_project(project_name) - machines_data = project.get_machines_dict() - if machine_id not in machines_data: - raise ConfigError(f"Machine with id {machine_id} is not defined") - return machines_data[machine_id] - - def set_host( + def set_worker( self, - host: LocalHostConfig | RemoteHostConfig, + name: str, + worker: WorkerBase, project_name: str | None = None, replace: bool = False, ): project_data = self.get_project_data(project_name) - hosts_data = project_data.project.get_hosts_config_dict() - if not replace and host.host_id in hosts_data: - raise ConfigError(f"Host with id {host.host_id} is already defined") - if any(host.host_id == m.host_id for m in project_data.project.machines): - raise ConfigError( - f"host {host.host_id} is used in one of the Machines, will not remove." - ) - hosts_data[host.host_id] = host - project_data.project.hosts = list(hosts_data.values()) + if not replace and name in project_data.project.workers: + raise ConfigError(f"Worker with name {name} is already defined") + + project_data.project.workers[name] = worker self.dump_project(project_data) - def remove_host(self, host_id: str, project_name: str | None = None): + def remove_worker(self, worker_name: str, project_name: str | None = None): project_data = self.get_project_data(project_name) - hosts_data = project_data.project.get_hosts_config_dict() - hosts_data.pop(host_id) - project_data.project.hosts = list(hosts_data.values()) + project_data.project.workers.pop(worker_name) self.dump_project(project_data) - def load_host(self, host_id: str, project_name: str | None = None) -> BaseHost: + def load_worker( + self, worker_name: str, project_name: str | None = None + ) -> WorkerBase: project = self.get_project(project_name) - hosts_data = project.get_hosts_config_dict() - if host_id not in hosts_data: - raise ConfigError(f"Host with id {host_id} is not defined") - return hosts_data[host_id].get_host() + if worker_name not in project.workers: + raise ConfigError(f"Worker with name {worker_name} is not defined") + return project.workers[worker_name] - def set_run_db(self, config: LaunchPadConfig, project_name: str | None = None): + def set_queue_db(self, store: MongoStore, project_name: str | None = None): project_data = self.get_project_data(project_name) - project_data.project.run_db = config + project_data.project.queue = store.as_dict() self.dump_project(project_data) diff --git a/src/jobflow_remote/fireworks/convert.py b/src/jobflow_remote/fireworks/convert.py index ca39124c..1f03fcbf 100644 --- a/src/jobflow_remote/fireworks/convert.py +++ b/src/jobflow_remote/fireworks/convert.py @@ -18,7 +18,7 @@ def flow_to_workflow( flow: jobflow.Flow | jobflow.Job | list[jobflow.Job], - machine: str, + worker: str, store: jobflow.JobStore | None = None, exec_config: str | ExecutionConfig = None, resources: dict | QResources | None = None, @@ -36,8 +36,8 @@ def flow_to_workflow( ---------- flow A flow or job. - machine - The id of the Machine where the calculation will be submitted + 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 @@ -45,7 +45,7 @@ def flow_to_workflow( 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 Machine. + 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. @@ -71,7 +71,7 @@ def flow_to_workflow( for job, parents in flow.iterflow(): fw = job_to_firework( job, - machine=machine, + worker=worker, store=store, parents=parents, parent_mapping=parent_mapping, @@ -88,7 +88,7 @@ def flow_to_workflow( def job_to_firework( job: jobflow.Job, - machine: str, + worker: str, store: jobflow.JobStore | None = None, parents: Sequence[str] | None = None, parent_mapping: dict[str, Firework] | None = None, @@ -145,7 +145,7 @@ def job_to_firework( task = RemoteJobFiretask( job=job, store=store, - machine=machine, + worker=worker, resources=resources, exec_config=exec_config, ) diff --git a/src/jobflow_remote/fireworks/launchpad.py b/src/jobflow_remote/fireworks/launchpad.py index fa4fb838..e606186a 100644 --- a/src/jobflow_remote/fireworks/launchpad.py +++ b/src/jobflow_remote/fireworks/launchpad.py @@ -8,6 +8,8 @@ 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 from qtoolkit.core.data_objects import QState @@ -83,8 +85,22 @@ def is_locked(self) -> bool: class RemoteLaunchPad: - def __init__(self, **kwargs): - self.lpad = LaunchPad(**kwargs) + 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() + self.lpad.db = store._coll.db + 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 @@ -417,7 +433,7 @@ def rerun_fw( remote_docs = [] for fw in wf.fws: if fw.fw_id in updated_ids: - remote_doc = fw.spec.pop("_remote") + remote_doc = fw.spec.pop("remote") if remote_doc: remote_docs.append(remote_doc) @@ -585,7 +601,7 @@ def get_fw_ids( self, query: dict | None = None, sort: dict | None = None, limit: int = 0 ) -> list[int]: result = self.fireworks.find( - query=query, sort=sort, limit=limit, projection={"fw_id": 1} + filter=query, sort=sort, limit=limit, projection={"fw_id": 1} ) fw_ids = [] diff --git a/src/jobflow_remote/fireworks/tasks.py b/src/jobflow_remote/fireworks/tasks.py index a46123b0..47246ce3 100644 --- a/src/jobflow_remote/fireworks/tasks.py +++ b/src/jobflow_remote/fireworks/tasks.py @@ -27,11 +27,11 @@ class RemoteJobFiretask(FiretaskBase): 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. - machine: Str - The id of the Machine where the calculation will be submitted + 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 Machine. + 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. @@ -40,7 +40,7 @@ class RemoteJobFiretask(FiretaskBase): a dynamical Flow. """ - required_params = ["job", "store", "machine"] + required_params = ["job", "store", "worker"] optional_params = ["exec_config", "resources", "original_store"] def run_task(self, fw_spec): @@ -77,7 +77,7 @@ def run_task(self, fw_spec): additions = None # in case of dynamic Flow set the same parameters as the current Job kwargs_dynamic = { - "machine": self.get("machine"), + "worker": self.get("worker"), "store": self.get("original_store"), "exports": self.get("exports"), "qtk_options": self.get("qtk_options"), diff --git a/src/jobflow_remote/jobs/data.py b/src/jobflow_remote/jobs/data.py index 88b9c91a..3ebc85cc 100644 --- a/src/jobflow_remote/jobs/data.py +++ b/src/jobflow_remote/jobs/data.py @@ -40,7 +40,7 @@ class JobData: f"{REMOTE_DOC_PATH}.retry_time_limit": 1, f"{REMOTE_DOC_PATH}.process_id": 1, f"{REMOTE_DOC_PATH}.run_dir": 1, - "spec._tasks.machine": 1, + "spec._tasks.worker": 1, "spec._tasks.job.hosts": 1, } @@ -52,7 +52,7 @@ class JobInfo: state: JobState name: str last_updated: datetime - machine: str + worker: str remote_state: RemoteState | None = None remote_previous_state: RemoteState | None = None lock_id: str | None = None @@ -113,7 +113,7 @@ def from_fw_dict(cls, d): state=state, name=d["name"], last_updated=last_updated, - machine=d["spec"]["_tasks"][0]["machine"], + worker=d["spec"]["_tasks"][0]["worker"], remote_state=remote_state, remote_previous_state=remote_previous_state, lock_id=lock_id, @@ -136,7 +136,7 @@ def from_fw_dict(cls, d): "name": 1, "updated_on": 1, "fws.updated_on": 1, - "fws.spec._tasks.machine": 1, + "fws.spec._tasks.worker": 1, "metadata.flow_id": 1, } @@ -149,7 +149,7 @@ class FlowInfo: state: FlowState name: str last_updated: datetime - machines: list[str] + workers: list[str] job_states: list[JobState] job_names: list[str] @@ -159,7 +159,7 @@ def from_query_dict(cls, d): last_updated = d["updated_on"].replace(tzinfo=timezone.utc).astimezone(tz=None) flow_id = d["metadata"].get("flow_id") fws = d.get("fws") or [] - machines = [] + workers = [] job_states = [] job_names = [] db_ids = [] @@ -176,7 +176,7 @@ def from_query_dict(cls, d): remote_state = None fw_state = fw_doc["state"] job_states.append(JobState.from_states(fw_state, remote_state)) - machines.append(fw_doc["spec"]["_tasks"][0]["machine"]) + workers.append(fw_doc["spec"]["_tasks"][0]["worker"]) state = FlowState.from_jobs_states(job_states) @@ -187,7 +187,7 @@ def from_query_dict(cls, d): state=state, name=d["name"], last_updated=last_updated, - machines=machines, + workers=workers, job_states=job_states, job_names=job_names, ) diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index 31949a13..89b9267c 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -275,7 +275,7 @@ def get_job_info( proj.update( { "launch.action.stored_data": 1, - "remote.error": 1, + f"{REMOTE_DOC_PATH}.error": 1, } ) data = list( diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index b93dea9e..d5f9dcec 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -20,9 +20,10 @@ from jobflow_remote.config.base import ( ConfigError, ExecutionConfig, - Machine, + LogLevel, Project, RunnerOptions, + WorkerBase, ) from jobflow_remote.config.manager import ConfigManager from jobflow_remote.fireworks.launcher import rapidfire_checkout @@ -51,7 +52,8 @@ JobFWData = namedtuple( - "JobFWData", ["fw", "task", "job", "store", "machine", "host", "original_store"] + "JobFWData", + ["fw", "task", "job", "store", "worker_name", "worker", "host", "original_store"], ) @@ -59,7 +61,7 @@ class Runner: def __init__( self, project_name: str | None = None, - log_level: int | None = None, + log_level: LogLevel | None = None, runner_id: str | None = None, ): self.stop_signal = False @@ -69,13 +71,23 @@ def __init__( self.project: Project = self.config_manager.get_project(project_name) self.rlpad: RemoteLaunchPad = self.project.get_launchpad() self.fworker: FWorker = FWorker() - self.machines: dict[str, Machine] = self.project.get_machines_dict() - self.hosts: dict[str, BaseHost] = self.project.get_hosts_dict() + 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 + self.hosts: dict[str, BaseHost] = {} + for wname, w in self.workers.items(): + new_host = w.get_host() + for host in self.hosts.values(): + if new_host == host: + self.hosts[wname] = host + break + else: + self.hosts[wname] = new_host self.queue_managers: dict = {} log_level = log_level if log_level is not None else self.project.log_level initialize_runner_logger( log_folder=self.project.log_dir, - level=log_level, + level=log_level.to_logging(), ) @property @@ -86,20 +98,20 @@ def handle_signal(self, signum, frame): logger.info(f"Received signal: {signum}") self.stop_signal = True - def get_machine(self, machine_id: str) -> Machine: - if machine_id not in self.machines: + def get_worker(self, worker_name: str) -> WorkerBase: + if worker_name not in self.workers: raise ConfigError( - f"No machine {machine_id} is defined in project {self.project_name}" + f"No worker {worker_name} is defined in project {self.project_name}" ) - return self.machines[machine_id] + return self.workers[worker_name] - def get_queue_manager(self, machine_id: str) -> QueueManager: - if machine_id not in self.queue_managers: - machine = self.get_machine(machine_id) - self.queue_managers[machine_id] = QueueManager( - machine.get_scheduler_io(), self.hosts[machine.host_id] + def get_queue_manager(self, worker_name: str) -> QueueManager: + if worker_name not in self.queue_managers: + worker = self.get_worker(worker_name) + self.queue_managers[worker_name] = QueueManager( + worker.get_scheduler_io(), self.hosts[worker_name] ) - return self.queue_managers[machine_id] + 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 @@ -116,10 +128,13 @@ def get_fw_data(self, fw_doc: dict) -> JobFWData: if store is None: store = self.project.get_jobstore() task["store"] = store - machine = self.get_machine(task["machine"]) - host = self.hosts[machine.host_id] + worker_name = task["worker"] + worker = self.get_worker(worker_name) + host = self.hosts[worker_name] - return JobFWData(fw, task, job, store, machine, host, task.get("store")) + return JobFWData( + fw, task, job, store, worker_name, worker, host, task.get("store") + ) def run(self): signal.signal(signal.SIGTERM, self.handle_signal) @@ -214,6 +229,7 @@ def lock_and_update( function = states_methods[state] fail_now = False + set_output = None try: error, fail_now, set_output = function(doc) except ConfigError: @@ -314,7 +330,7 @@ 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.machine.work_dir) + remote_path = get_job_path(job.uuid, fw_job_data.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 @@ -355,9 +371,9 @@ def submit(self, doc): script_commands = ["rlaunch singleshot --offline"] - machine = fw_job_data.machine - queue_manager = self.get_queue_manager(machine.machine_id) - resources = fw_job_data.task.get("resources") or machine.resources or {} + 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 {} set_name_out(resources, fw_job_data.job.name) exec_config = fw_job_data.task.get("exec_config") if isinstance(exec_config, str): @@ -369,10 +385,10 @@ def submit(self, doc): exec_config = exec_config or ExecutionConfig() - pre_run = machine.pre_run or "" + pre_run = worker.pre_run or "" if exec_config.pre_run: pre_run += "\n" + exec_config.pre_run - post_run = machine.post_run or "" + post_run = worker.post_run or "" if exec_config.post_run: post_run += "\n" + exec_config.post_run @@ -480,7 +496,7 @@ def complete_launch(self, doc): def check_run_status(self): logger.debug("check_run_status") # check for jobs that could have changed state - machines_ids_docs = defaultdict(dict) + workers_ids_docs = defaultdict(dict) db_filter = { f"{REMOTE_DOC_PATH}.state": { "$in": [RemoteState.SUBMITTED.value, RemoteState.RUNNING.value] @@ -495,14 +511,14 @@ def check_run_status(self): f"{REMOTE_DOC_PATH}.process_id", f"{REMOTE_DOC_PATH}.state", f"{REMOTE_DOC_PATH}.step_attempts", - "spec._tasks.machine", + "spec._tasks.worker", ] for doc in self.rlpad.fireworks.find(db_filter, projection): - machine_id = doc["spec"]["_tasks"][0]["machine"] + worker_name = doc["spec"]["_tasks"][0]["worker"] remote_doc = get_remote_doc(doc) - machines_ids_docs[machine_id][remote_doc["process_id"]] = (doc, remote_doc) + workers_ids_docs[worker_name][remote_doc["process_id"]] = (doc, remote_doc) - for machine_id, ids_docs in machines_ids_docs.items(): + for worker_name, ids_docs in workers_ids_docs.items(): error = None if not ids_docs: @@ -511,12 +527,12 @@ def check_run_status(self): qjobs_dict = {} try: ids_list = list(ids_docs.keys()) - queue = self.get_queue_manager(machine_id) + queue = self.get_queue_manager(worker_name) qjobs = queue.get_jobs_list(ids_list) qjobs_dict = {qjob.job_id: qjob for qjob in qjobs} except Exception: logger.warning( - f"error trying to get jobs list for machine: {machine_id}", + f"error trying to get jobs list for worker: {worker_name}", exc_info=True, ) error = traceback.format_exc() @@ -576,11 +592,13 @@ def checkout(self): logger.debug(f"checked out {n} jobs") def cleanup(self): - for host_id, host in self.hosts.items(): + for worker_name, host in self.hosts.items(): try: host.close() except Exception: - logging.exception(f"error while closing host {host_id}") + logging.exception( + 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 diff --git a/src/jobflow_remote/jobs/submit.py b/src/jobflow_remote/jobs/submit.py index 023ee280..6fea7fb3 100644 --- a/src/jobflow_remote/jobs/submit.py +++ b/src/jobflow_remote/jobs/submit.py @@ -10,14 +10,14 @@ def submit_flow( flow: jobflow.Flow | jobflow.Job | list[jobflow.Job], - machine: str, + worker: str, store: str | jobflow.JobStore | None = None, project: str | None = None, exec_config: str | ExecutionConfig | None = None, resources: dict | QResources | None = None, ): """ - Submit a flow for calculation to the selected Machine. + Submit a flow for calculation to the selected Worker. This will not start the calculation but just add to the database of the calculation to be executed. @@ -26,8 +26,8 @@ def submit_flow( ---------- flow A flow or job. - machine - The id of the Machine where the calculation will be submitted + 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 @@ -38,7 +38,7 @@ def submit_flow( current project will be used. exec_config: str or ExecutionConfig the options to set before the execution of the job in the submission script. - In addition to those defined in the Machine. + 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. @@ -47,15 +47,15 @@ def submit_flow( proj_obj = config_manager.get_project(project) - # try to load the machine and exec_config to check that the values are well defined - config_manager.load_machine(machine_id=machine, project_name=project) + # try to load the worker and exec_config to check that the values are well defined + config_manager.load_worker(worker_name=worker, project_name=project) if isinstance(exec_config, str): config_manager.load_exec_config( exec_config_id=exec_config, project_name=project ) wf = flow_to_workflow( - flow, machine=machine, store=store, exec_config=exec_config, resources=resources + flow, worker=worker, store=store, exec_config=exec_config, resources=resources ) rlpad = proj_obj.get_launchpad() diff --git a/src/jobflow_remote/remote/host/local.py b/src/jobflow_remote/remote/host/local.py index 2d4a08ca..b33810e5 100644 --- a/src/jobflow_remote/remote/host/local.py +++ b/src/jobflow_remote/remote/host/local.py @@ -14,6 +14,9 @@ class LocalHost(BaseHost): def __init__(self, timeout_execute: int = None): self.timeout_execute = timeout_execute + def __eq__(self, other): + return isinstance(other, LocalHost) + def execute( self, command: str | list[str], diff --git a/src/jobflow_remote/remote/host/remote.py b/src/jobflow_remote/remote/host/remote.py index f48928a7..bf4aa0ff 100644 --- a/src/jobflow_remote/remote/host/remote.py +++ b/src/jobflow_remote/remote/host/remote.py @@ -53,6 +53,11 @@ def __init__( inline_ssh_env=self.inline_ssh_env, ) + def __eq__(self, other): + if not isinstance(other, RemoteHost): + return False + return self.as_dict() == other.as_dict() + @property def connection(self): return self._connection diff --git a/src/jobflow_remote/remote/queue.py b/src/jobflow_remote/remote/queue.py index 5d30c887..bae572f9 100644 --- a/src/jobflow_remote/remote/queue.py +++ b/src/jobflow_remote/remote/queue.py @@ -5,8 +5,6 @@ from qtoolkit.core.data_objects import CancelResult, QJob, QResources, SubmissionResult from qtoolkit.io.base import BaseSchedulerIO -from jobflow_remote.config.base import Machine -from jobflow_remote.config.manager import ConfigManager from jobflow_remote.remote.host import BaseHost OUT_FNAME = "queue.out" @@ -203,19 +201,3 @@ def get_jobs_list( return self.scheduler_io.parse_jobs_list_output( exit_code=returncode, stdout=stdout, stderr=stderr ) - - @classmethod - def from_machine( - cls, - machine: str | Machine, - config_manager: ConfigManager | None = None, - project_name: str | None = None, - ): - if not config_manager: - config_manager = ConfigManager() - if isinstance(machine, str): - machine = config_manager.load_machine(machine, project_name) - host = config_manager.load_host(machine.host_id) - return cls( - machine.get_scheduler_io(), host, timeout_exec=machine.queue_exec_timeout - ) diff --git a/src/jobflow_remote/utils/data.py b/src/jobflow_remote/utils/data.py index b4ed505f..89a1dbd0 100644 --- a/src/jobflow_remote/utils/data.py +++ b/src/jobflow_remote/utils/data.py @@ -6,6 +6,10 @@ from typing import Any from uuid import UUID +import maggma.stores # required to enable subclass searching +from maggma.core.store import Store +from monty.json import MontyDecoder + def deep_merge_dict( d1: MutableMapping, @@ -85,3 +89,36 @@ def uuid_to_path(uuid: str, num_subdirs: int = 3, subdir_len: int = 2): # Combine root directory and subdirectories to form the final path return os.path.join(*subdirs, uuid) + + +def store_from_dict(store_dict: dict) -> Store: + if "@class" in store_dict and "@module" in store_dict: + store = MontyDecoder().process_decoded(store_dict) + if not isinstance(store, Store): + raise ValueError( + f"The converted object {store} is not an instance of a maggma Store" + ) + return store + else: + + def all_subclasses(cl): + return set(cl.__subclasses__()).union( + [s for c in cl.__subclasses__() for s in all_subclasses(c)] + ) + + all_stores = {s.__name__: s for s in all_subclasses(maggma.stores.Store)} + return convert_store(store_dict, all_stores) + + +def convert_store(spec_dict: dict, valid_stores) -> Store: + """ + Build a store based on the dict spec configuration from JobFlow + TODO expose the methods from jobflow and don't duplicate the code + """ + + _spec_dict = dict(spec_dict) + store_type = _spec_dict.pop("type") + for k, v in _spec_dict.items(): + if isinstance(v, dict) and "type" in v: + _spec_dict[k] = convert_store(v, valid_stores) + return valid_stores[store_type](**_spec_dict) From 28d5df7dc600deeb61d8505812c60d6e568f2f87 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Tue, 13 Jun 2023 23:02:36 +0200 Subject: [PATCH 13/89] refactor project cli --- src/jobflow_remote/cli/__init__.py | 2 +- src/jobflow_remote/cli/admin.py | 18 +- src/jobflow_remote/cli/config.py | 100 ----------- src/jobflow_remote/cli/flow.py | 7 +- src/jobflow_remote/cli/project.py | 195 ++++++++++++++++++++++ src/jobflow_remote/config/base.py | 1 + src/jobflow_remote/config/helper.py | 63 +++++++ src/jobflow_remote/fireworks/launchpad.py | 2 +- 8 files changed, 270 insertions(+), 118 deletions(-) delete mode 100644 src/jobflow_remote/cli/config.py create mode 100644 src/jobflow_remote/cli/project.py diff --git a/src/jobflow_remote/cli/__init__.py b/src/jobflow_remote/cli/__init__.py index 3af4742e..6a4dede2 100644 --- a/src/jobflow_remote/cli/__init__.py +++ b/src/jobflow_remote/cli/__init__.py @@ -1,7 +1,7 @@ # Import the submodules with a local app to register them to the main app import jobflow_remote.cli.admin -import jobflow_remote.cli.config import jobflow_remote.cli.flow import jobflow_remote.cli.job +import jobflow_remote.cli.project import jobflow_remote.cli.runner from jobflow_remote.cli.jf import app diff --git a/src/jobflow_remote/cli/admin.py b/src/jobflow_remote/cli/admin.py index 7402bd54..b75dd326 100644 --- a/src/jobflow_remote/cli/admin.py +++ b/src/jobflow_remote/cli/admin.py @@ -76,12 +76,9 @@ def reset( if not force: cm = ConfigManager() project_name = cm.get_project_data().project.name - text = Text() - text.append("This operation will ", style="red") - text.append("delete all the Jobs data ", style="red bold") - text.append("for project ", style="red") - text.append(f"{project_name} ", style="red bold") - text.append("Proceed anyway?", style="red") + text = Text.from_markup( + f"[red]This operation will [bold]delete all the Jobs data[/bold] for project [bold]{project_name}[/bold]. Proceed anyway?[/red]" + ) confirmed = Confirm.ask(text, default=False) if not confirmed: @@ -126,12 +123,9 @@ def remove_lock( end_date=end_date, ) - text = Text() - text.append("This operation will ", style="red") - text.append("remove the lock ", style="red bold") - text.append("for (roughly) ", style="red") - text.append(f"{len(jobs_info)} Job(s). ", style="red bold") - text.append("Proceed anyway?", style="red") + text = Text( + f"[red]This operation will [bold]remove the lock[/bold] for (roughly) [bold]{len(jobs_info)} Job(s)[/bold]. Proceed anyway?[/red]" + ) confirmed = Confirm.ask(text, default=False) if not confirmed: diff --git a/src/jobflow_remote/cli/config.py b/src/jobflow_remote/cli/config.py deleted file mode 100644 index 13b22f6d..00000000 --- a/src/jobflow_remote/cli/config.py +++ /dev/null @@ -1,100 +0,0 @@ -import typer -from rich.text import Text -from typing_extensions import Annotated - -from jobflow_remote.cli.jf import app -from jobflow_remote.cli.types import serialize_file_format_opt -from jobflow_remote.cli.utils import ( - SerializeFileFormat, - exit_with_error_msg, - exit_with_warning_msg, - out_console, - print_success_msg, -) -from jobflow_remote.config import ConfigError, ConfigManager -from jobflow_remote.config.helper import generate_dummy_project - -app_config = typer.Typer( - name="config", - help="Commands concerning the configuration of jobflow remote execution", - no_args_is_help=True, -) -app.add_typer(app_config) - -app_project = typer.Typer( - name="project", - help="Commands concerning the project definition", - no_args_is_help=True, -) -app_config.add_typer(app_project) - - -@app_project.command(name="list") -def list_projects(): - cm = ConfigManager() - - project_name = None - try: - project_data = cm.get_project_data() - project_name = project_data.project.name - except ConfigError: - pass - - if not cm.projects_data: - exit_with_warning_msg(f"No project available in {cm.projects_folder}") - - out_console.print(f"List of projects in {cm.projects_folder}") - for pn in sorted(cm.projects_data.keys()): - out_console.print(f" - {pn}", style="green" if pn == project_name else None) - - -@app_project.command(name="current") -def current_project(): - """ - Print the list of the project currently selected - """ - cm = ConfigManager() - - try: - project_data = cm.get_project_data() - text = Text() - text.append("The selected project is ") - text.append(project_data.project.name, style="green") - text.append(" from config file ") - text.append(project_data.filepath, style="green") - out_console.print(text) - except ConfigError as e: - exit_with_error_msg(f"Error loading the selected project: {e}") - - -@app_project.command() -def generate( - name: Annotated[str, typer.Argument(help="Name of the project")], - file_format: serialize_file_format_opt = SerializeFileFormat.YAML.value, - full: Annotated[ - bool, - typer.Option( - "--full", - help="Generate a configuration file with all the fields and more elements", - ), - ] = False, -): - """ - Generate a project configuration file with dummy elements to be edited manually - """ - - cm = ConfigManager(exclude_unset=not full) - if name in cm.projects_data: - exit_with_error_msg(f"Project with name {name} already exists") - - filepath = cm.projects_folder / f"{name}.{file_format.value}" - if filepath.exists(): - exit_with_error_msg( - f"Project with name {name} does not exist, but file {str(filepath)} does and will not be overwritten" - ) - - project = generate_dummy_project(name=name, full=full) - cm.create_project(project, ext=file_format.value) - print_success_msg( - f"Configuration file for project {name} created in {str(filepath)}" - ) diff --git a/src/jobflow_remote/cli/flow.py b/src/jobflow_remote/cli/flow.py index 7992f025..bb0f4e2d 100644 --- a/src/jobflow_remote/cli/flow.py +++ b/src/jobflow_remote/cli/flow.py @@ -119,10 +119,9 @@ def delete( exit_with_warning_msg("No flows matching criteria") if flows_info and not force: - text = Text() - text.append("This operation will ", style="red") - text.append(f"delete {len(flows_info)} Flow(s)", style="red bold") - text.append(". Proceed anyway?", style="red") + text = Text.from_markup( + f"[red]This operation will [bold]delete {len(flows_info)} Flow(s)[/bold]. Proceed anyway?[/red]" + ) confirmed = Confirm.ask(text, default=False) if not confirmed: diff --git a/src/jobflow_remote/cli/project.py b/src/jobflow_remote/cli/project.py new file mode 100644 index 00000000..78d49a8b --- /dev/null +++ b/src/jobflow_remote/cli/project.py @@ -0,0 +1,195 @@ +import typer +from rich.text import Text +from typing_extensions import Annotated + +from jobflow_remote.cli.jf import app +from jobflow_remote.cli.types import serialize_file_format_opt +from jobflow_remote.cli.utils import ( + SerializeFileFormat, + check_incompatible_opt, + exit_with_error_msg, + exit_with_warning_msg, + loading_spinner, + out_console, + print_success_msg, +) +from jobflow_remote.config import ConfigError, ConfigManager +from jobflow_remote.config.helper import ( + check_jobstore, + check_queue_store, + check_worker, + generate_dummy_project, +) + +app_project = typer.Typer( + name="project", + help="Commands concerning the project definition", + # no_args_is_help=True, +) +app.add_typer(app_project) + + +@app_project.command(name="list") +def list_projects(): + cm = ConfigManager() + + project_name = None + try: + project_data = cm.get_project_data() + project_name = project_data.project.name + except ConfigError: + pass + + if not cm.projects_data: + exit_with_warning_msg(f"No project available in {cm.projects_folder}") + + out_console.print(f"List of projects in {cm.projects_folder}") + for pn in sorted(cm.projects_data.keys()): + out_console.print(f" - {pn}", style="green" if pn == project_name else None) + + +@app_project.callback(invoke_without_command=True) +def current_project(ctx: typer.Context): + """ + Print the list of the project currently selected + """ + # only run if no other subcommand is executed + if ctx.invoked_subcommand is None: + cm = ConfigManager() + + 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]" + ) + out_console.print(text) + except ConfigError as e: + exit_with_error_msg(f"Error loading the selected project: {e}") + + +@app_project.command() +def generate( + name: Annotated[str, typer.Argument(help="Name of the project")], + file_format: serialize_file_format_opt = SerializeFileFormat.YAML.value, + full: Annotated[ + bool, + typer.Option( + "--full", + help="Generate a configuration file with all the fields and more elements", + ), + ] = False, +): + """ + Generate a project configuration file with dummy elements to be edited manually + """ + + cm = ConfigManager(exclude_unset=not full) + if name in cm.projects_data: + exit_with_error_msg(f"Project with name {name} already exists") + + filepath = cm.projects_folder / f"{name}.{file_format.value}" + if filepath.exists(): + exit_with_error_msg( + f"Project with name {name} does not exist, but file {str(filepath)} does and will not be overwritten" + ) + + project = generate_dummy_project(name=name, full=full) + cm.create_project(project, ext=file_format.value) + print_success_msg( + f"Configuration file for project {name} created in {str(filepath)}" + ) + + +@app_project.command() +def check( + jobstore: Annotated[ + bool, + typer.Option( + "--jobstore", + "-js", + help="Only check the jobstore connection", + ), + ] = False, + queue: Annotated[ + bool, + typer.Option( + "--queue", + "-q", + help="Only check the queue connection", + ), + ] = False, + worker: Annotated[ + str, + typer.Option( + "--worker", + "-w", + help="Only check the connection for the selected worker", + ), + ] = None, + print_errors: Annotated[ + bool, + typer.Option( + "--errors", + "-e", + help="Print the errors at the end of the checks", + ), + ] = False, +): + """ + Check that the connection to the different elements of the projects are working + """ + check_incompatible_opt({"jobstore": jobstore, "queue": queue, "worker": worker}) + + cm = ConfigManager() + project = cm.get_project() + + check_all = all(not v for v in (jobstore, worker, queue)) + + workers_to_test = [] + if check_all: + workers_to_test = project.workers.keys() + elif worker: + if worker not in project.workers: + exit_with_error_msg( + f"Worker {worker} does not exists in project {project.name}" + ) + workers_to_test = [worker] + + tick = "[bold green]✓[/] " + cross = "[bold red]x[/] " + errors = [] + with loading_spinner(False) as progress: + task_id = progress.add_task("Checking") + for worker_name in workers_to_test: + progress.update(task_id, description=f"Checking worker {worker_name}") + worker = project.workers[worker_name] + err = check_worker(worker) + header = tick + if err: + errors.append((f"Worker {worker_name}", err)) + header = cross + progress.print(Text.from_markup(header + f"Worker {worker_name}")) + + if check_all or jobstore: + progress.update(task_id, description="Checking jobstore") + err = check_jobstore(project.get_jobstore()) + header = tick + if err: + errors.append(("Jobstore", err)) + header = cross + progress.print(Text.from_markup(header + "Jobstore")) + + if check_all or queue: + progress.update(task_id, description="Checking queue store") + err = check_queue_store(project.get_queue_store()) + header = tick + if err: + errors.append(("Queue store", err)) + header = cross + progress.print(Text.from_markup(header + "Queue store")) + + if print_errors and errors: + out_console.print("Errors:", style="red bold") + for e in errors: + out_console.print(e[0], style="bold") + out_console.print(e[1]) diff --git a/src/jobflow_remote/config/base.py b/src/jobflow_remote/config/base.py index 7bbed82f..0633a17c 100644 --- a/src/jobflow_remote/config/base.py +++ b/src/jobflow_remote/config/base.py @@ -141,6 +141,7 @@ class Project(BaseModel): queue: dict = Field(default_factory=dict) exec_config: list[ExecutionConfig] = Field(default_factory=list) jobstore: dict = Field(default_factory=lambda: dict(DEFAULT_JOBSTORE)) + metadata: dict | None = None def get_exec_config_dict(self) -> dict[str, ExecutionConfig]: return {ec.exec_config_id: ec for ec in self.exec_config} diff --git a/src/jobflow_remote/config/helper.py b/src/jobflow_remote/config/helper.py index e08d2c9a..2dece328 100644 --- a/src/jobflow_remote/config/helper.py +++ b/src/jobflow_remote/config/helper.py @@ -1,5 +1,11 @@ from __future__ import annotations +import logging +import traceback + +from jobflow import JobStore +from maggma.core import Store + from jobflow_remote.config.base import ( ExecutionConfig, LocalWorker, @@ -8,6 +14,8 @@ WorkerBase, ) +logger = logging.getLogger(__name__) + def generate_dummy_project(name: str, full: bool = False) -> Project: @@ -105,3 +113,58 @@ def generate_dummy_queue() -> dict: collection_name="jobs", ) return lp_config + + +def check_worker(worker: WorkerBase) -> str | None: + host = worker.get_host() + try: + host.connect() + host_error = host.test() + if host_error: + return host_error + + from jobflow_remote.remote.queue import QueueManager + + qm = QueueManager(scheduler_io=worker.get_scheduler_io(), host=host) + qm.get_jobs_list() + except Exception: + exc = traceback.format_exc() + return f"Error while testing worker:\n {exc}" + finally: + try: + host.close() + except Exception: + logger.warning(f"error while closing connection to host {host}") + + return None + + +def _check_store(store: Store) -> str | None: + try: + store.connect() + store.query_one() + except Exception: + exc = traceback.format_exc() + return exc + finally: + store.close() + + return None + + +def check_queue_store(queue_store: Store): + err = _check_store(queue_store) + if err: + return f"Error while checking queue store:\n{err}" + return None + + +def check_jobstore(jobstore: JobStore) -> str | None: + err = _check_store(jobstore.docs_store) + if err: + return f"Error while checking docs_store store:\n{err}" + for store_name, store in jobstore.additional_stores.items(): + err = _check_store(store) + if err: + return f"Error while checking additional store {store_name}:\n{err}" + return None diff --git a/src/jobflow_remote/fireworks/launchpad.py b/src/jobflow_remote/fireworks/launchpad.py index e606186a..03237fb5 100644 --- a/src/jobflow_remote/fireworks/launchpad.py +++ b/src/jobflow_remote/fireworks/launchpad.py @@ -92,7 +92,7 @@ def __init__(self, store: Store): ) self.store = store self.store.connect() - self.lpad = LaunchPad() + self.lpad = LaunchPad(strm_lvl="CRITICAL") self.lpad.db = store._coll.db self.lpad.fireworks = self.db.fireworks self.lpad.launches = self.db.launches From 287f00d3de6c4b961df0d80c68d04230bc0026d3 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Fri, 16 Jun 2023 10:43:32 +0200 Subject: [PATCH 14/89] more CLI options --- .copier-answers.yml | 1 - src/jobflow_remote/cli/admin.py | 2 +- src/jobflow_remote/cli/project.py | 39 +++++++++++++++++++++++++++- src/jobflow_remote/config/base.py | 2 ++ src/jobflow_remote/config/manager.py | 5 ++-- tests/test_jobflow_remote.py | 2 +- 6 files changed, 45 insertions(+), 6 deletions(-) diff --git a/.copier-answers.yml b/.copier-answers.yml index aeece365..ab727cd0 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -15,4 +15,3 @@ repository_namespace: Matgenix repository_provider: https://github.com/ short_description: jobflow-remote is a python package to run jobflow workflows on remote resources - diff --git a/src/jobflow_remote/cli/admin.py b/src/jobflow_remote/cli/admin.py index b75dd326..49bc0a51 100644 --- a/src/jobflow_remote/cli/admin.py +++ b/src/jobflow_remote/cli/admin.py @@ -64,7 +64,7 @@ def reset( except DaemonError as e: exit_with_error_msg( - f"Error while checking the status of the daemon: {getattr(e, 'message', e)}" + f"Error while checking the status of the daemon: {getattr(e, 'message', str(e))}" ) if current_status not in (DaemonStatus.STOPPED, DaemonStatus.SHUT_DOWN): diff --git a/src/jobflow_remote/cli/project.py b/src/jobflow_remote/cli/project.py index 78d49a8b..2b952360 100644 --- a/src/jobflow_remote/cli/project.py +++ b/src/jobflow_remote/cli/project.py @@ -1,9 +1,10 @@ import typer +from rich.prompt import Confirm from rich.text import Text from typing_extensions import Annotated from jobflow_remote.cli.jf import app -from jobflow_remote.cli.types import serialize_file_format_opt +from jobflow_remote.cli.types import force_opt, serialize_file_format_opt from jobflow_remote.cli.utils import ( SerializeFileFormat, check_incompatible_opt, @@ -31,6 +32,9 @@ @app_project.command(name="list") def list_projects(): + """ + List of available projects + """ cm = ConfigManager() project_name = None @@ -193,3 +197,36 @@ def check( for e in errors: out_console.print(e[0], style="bold") out_console.print(e[1]) + + +@app_project.command() +def remove( + name: Annotated[str, typer.Argument(help="Name of the project")], + keep_folders: Annotated[ + bool, + typer.Option( + "--keep-folders", + "-k", + help="Project related folders are not deleted", + ), + ] = False, + force: force_opt = False, +): + """ + Remove a project from the projects' folder, including the related folders. + """ + cm = ConfigManager() + + if name not in cm.projects_data: + exit_with_warning_msg(f"Project {name} does not exist") + + p = cm.get_project(name) + + if not keep_folders and not force: + msg = f"This will delete also the folders:\n\t{p.base_dir}\n\t{p.log_dir}\n\t{p.tmp_dir}\n\t{p.daemon_dir}\nProceed anyway?" + if not Confirm.ask(msg): + raise typer.Exit(0) + + with loading_spinner(False) as progress: + progress.add_task("Deleting project") + cm.remove_project(project_name=name, remove_folders=not keep_folders) diff --git a/src/jobflow_remote/config/base.py b/src/jobflow_remote/config/base.py index 0633a17c..3a77ee0c 100644 --- a/src/jobflow_remote/config/base.py +++ b/src/jobflow_remote/config/base.py @@ -100,6 +100,7 @@ class RemoteWorker(WorkerBase): connect_timeout: int = None connect_kwargs: dict = None inline_ssh_env: bool = None + keepalive: int | None = 60 def get_host(self) -> BaseHost: return RemoteHost( @@ -112,6 +113,7 @@ def get_host(self) -> BaseHost: connect_kwargs=self.connect_kwargs, inline_ssh_env=self.inline_ssh_env, timeout_execute=self.timeout_execute, + keepalive=self.keepalive, ) diff --git a/src/jobflow_remote/config/manager.py b/src/jobflow_remote/config/manager.py index 3fc09d69..6df5cf1c 100644 --- a/src/jobflow_remote/config/manager.py +++ b/src/jobflow_remote/config/manager.py @@ -118,11 +118,12 @@ def create_project(self, project: Project, ext="yaml"): self.dump_project(project_data) self.projects_data[project.name] = project_data - def remove_project(self, project_name: str): + def remove_project(self, project_name: str, remove_folders: bool = True): if project_name not in self.projects_data: return project_data = self.projects_data.pop(project_name) - shutil.rmtree(project_data.project.base_dir, ignore_errors=True) + if remove_folders: + shutil.rmtree(project_data.project.base_dir, ignore_errors=True) os.remove(project_data.filepath) def update_project(self, config: dict, project_name: str): diff --git a/tests/test_jobflow_remote.py b/tests/test_jobflow_remote.py index 075478bf..fce15f0a 100644 --- a/tests/test_jobflow_remote.py +++ b/tests/test_jobflow_remote.py @@ -2,4 +2,4 @@ def test_version(): - assert __version__ == '0.1.0' + assert __version__ == "0.1.0" From 278fddb43d33a701dc891aefd1df9e412217681f Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Sun, 18 Jun 2023 23:47:21 +0200 Subject: [PATCH 15/89] refactor exec config and typer error handling --- src/jobflow_remote/cli/admin.py | 3 +- src/jobflow_remote/cli/flow.py | 3 +- src/jobflow_remote/cli/jf.py | 21 ++++++++++--- src/jobflow_remote/cli/jfr_typer.py | 34 ++++++++++++++++++++ src/jobflow_remote/cli/job.py | 7 +++-- src/jobflow_remote/cli/project.py | 3 +- src/jobflow_remote/cli/runner.py | 3 +- src/jobflow_remote/cli/utils.py | 28 +++++++++++++++++ src/jobflow_remote/config/base.py | 24 +++----------- src/jobflow_remote/config/helper.py | 5 ++- src/jobflow_remote/config/manager.py | 38 ++++++++++++----------- src/jobflow_remote/config/settings.py | 1 + src/jobflow_remote/fireworks/launchpad.py | 6 ++-- src/jobflow_remote/jobs/runner.py | 2 +- src/jobflow_remote/jobs/submit.py | 2 +- src/jobflow_remote/remote/host/base.py | 4 ++- src/jobflow_remote/remote/host/local.py | 4 ++- src/jobflow_remote/remote/host/remote.py | 12 +++++-- 18 files changed, 140 insertions(+), 60 deletions(-) create mode 100644 src/jobflow_remote/cli/jfr_typer.py diff --git a/src/jobflow_remote/cli/admin.py b/src/jobflow_remote/cli/admin.py index 49bc0a51..ac8fc790 100644 --- a/src/jobflow_remote/cli/admin.py +++ b/src/jobflow_remote/cli/admin.py @@ -4,6 +4,7 @@ from typing_extensions import Annotated from jobflow_remote.cli.jf import app +from jobflow_remote.cli.jfr_typer import JFRTyper from jobflow_remote.cli.types import ( db_ids_opt, end_date_opt, @@ -23,7 +24,7 @@ from jobflow_remote.jobs.daemon import DaemonError, DaemonManager, DaemonStatus from jobflow_remote.jobs.jobcontroller import JobController -app_admin = typer.Typer( +app_admin = JFRTyper( name="admin", help="Commands for administering the database", no_args_is_help=True ) app.add_typer(app_admin) diff --git a/src/jobflow_remote/cli/flow.py b/src/jobflow_remote/cli/flow.py index bb0f4e2d..e579bab6 100644 --- a/src/jobflow_remote/cli/flow.py +++ b/src/jobflow_remote/cli/flow.py @@ -6,6 +6,7 @@ from jobflow_remote.cli.formatting import get_flow_info_table from jobflow_remote.cli.jf import app +from jobflow_remote.cli.jfr_typer import JFRTyper from jobflow_remote.cli.types import ( days_opt, db_ids_opt, @@ -29,7 +30,7 @@ ) from jobflow_remote.jobs.jobcontroller import JobController -app_flow = typer.Typer( +app_flow = JFRTyper( name="flow", help="Commands for managing the flows", no_args_is_help=True ) app.add_typer(app_flow) diff --git a/src/jobflow_remote/cli/jf.py b/src/jobflow_remote/cli/jf.py index c9325c3f..3065d9be 100644 --- a/src/jobflow_remote/cli/jf.py +++ b/src/jobflow_remote/cli/jf.py @@ -1,10 +1,11 @@ import typer from typing_extensions import Annotated +from jobflow_remote.cli.jfr_typer import JFRTyper from jobflow_remote.cli.utils import exit_with_error_msg from jobflow_remote.config import ConfigManager -app = typer.Typer( +app = JFRTyper( name="jf", add_completion=False, no_args_is_help=True, @@ -22,14 +23,23 @@ def main( help="Select a project for the current execution", is_eager=True, ), - ] = None + ] = None, + full_exc: Annotated[ + bool, + typer.Option( + "--full-exc", + "-fe", + help="Print the full stack trace of exception when enabled", + is_eager=True, + ), + ] = False, ): """ The controller CLI for jobflow-remote """ - if project: - from jobflow_remote import SETTINGS + from jobflow_remote import SETTINGS + if project: cm = ConfigManager() if project not in cm.projects_data: exit_with_error_msg( @@ -37,3 +47,6 @@ def main( ) SETTINGS.project = project + + if full_exc: + SETTINGS.cli_full_exc = True diff --git a/src/jobflow_remote/cli/jfr_typer.py b/src/jobflow_remote/cli/jfr_typer.py new file mode 100644 index 00000000..b90f20a2 --- /dev/null +++ b/src/jobflow_remote/cli/jfr_typer.py @@ -0,0 +1,34 @@ +from typing import Callable + +import typer +from typer.models import CommandFunctionType + +from jobflow_remote.cli.utils import cli_error_handler + + +class JFRTyper(typer.Typer): + """ + Subclassing typer to intercept exceptions and print nicer error messages + """ + + def command( + self, *args, **kwargs + ) -> Callable[[CommandFunctionType], CommandFunctionType]: + typer_wrapper = super().command(*args, **kwargs) + + def wrapper(fn): + fn = cli_error_handler(fn) + return typer_wrapper(fn) + + return wrapper + + def callback( + self, *args, **kwargs + ) -> Callable[[CommandFunctionType], CommandFunctionType]: + typer_wrapper = super().callback(*args, **kwargs) + + def wrapper(fn): + fn = cli_error_handler(fn) + return typer_wrapper(fn) + + return wrapper diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index aa19ddfa..92fb026b 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -7,6 +7,7 @@ from jobflow_remote.cli.formatting import format_job_info, get_job_info_table from jobflow_remote.cli.jf import app +from jobflow_remote.cli.jfr_typer import JFRTyper from jobflow_remote.cli.types import ( days_opt, db_id_flag_opt, @@ -40,7 +41,7 @@ from jobflow_remote.jobs.state import RemoteState from jobflow_remote.remote.queue import ERR_FNAME, OUT_FNAME -app_job = typer.Typer( +app_job = JFRTyper( name="job", help="Commands for managing the jobs", no_args_is_help=True ) app.add_typer(app_job) @@ -259,7 +260,7 @@ def queue_out( job_id: job_id_arg, db_id: db_id_flag_opt = False, ): - with loading_spinner() as progress: + with loading_spinner(processing=False) as progress: progress.add_task(description="Retrieving info...", total=None) jc = JobController() @@ -295,7 +296,7 @@ def queue_out( err = None out_error = None err_error = None - with loading_spinner() as progress: + with loading_spinner(processing=False) as progress: progress.add_task(description="Retrieving files...", total=None) cm = ConfigManager() worker = cm.load_worker(info.worker) diff --git a/src/jobflow_remote/cli/project.py b/src/jobflow_remote/cli/project.py index 2b952360..97e15b4a 100644 --- a/src/jobflow_remote/cli/project.py +++ b/src/jobflow_remote/cli/project.py @@ -4,6 +4,7 @@ from typing_extensions import Annotated from jobflow_remote.cli.jf import app +from jobflow_remote.cli.jfr_typer import JFRTyper from jobflow_remote.cli.types import force_opt, serialize_file_format_opt from jobflow_remote.cli.utils import ( SerializeFileFormat, @@ -22,7 +23,7 @@ generate_dummy_project, ) -app_project = typer.Typer( +app_project = JFRTyper( name="project", help="Commands concerning the project definition", # no_args_is_help=True, diff --git a/src/jobflow_remote/cli/runner.py b/src/jobflow_remote/cli/runner.py index f71c88bd..edc3de88 100644 --- a/src/jobflow_remote/cli/runner.py +++ b/src/jobflow_remote/cli/runner.py @@ -6,6 +6,7 @@ from typing_extensions import Annotated 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.utils import ( exit_with_error_msg, @@ -17,7 +18,7 @@ from jobflow_remote.jobs.daemon import DaemonError, DaemonManager, DaemonStatus from jobflow_remote.jobs.runner import Runner -app_runner = typer.Typer( +app_runner = JFRTyper( name="runner", help="Commands for handling the Runner", no_args_is_help=True ) app.add_typer(app_runner) diff --git a/src/jobflow_remote/cli/utils.py b/src/jobflow_remote/cli/utils.py index a4285e28..25acc1ba 100644 --- a/src/jobflow_remote/cli/utils.py +++ b/src/jobflow_remote/cli/utils.py @@ -1,12 +1,16 @@ from __future__ import annotations +import functools from contextlib import contextmanager from enum import Enum import typer +from click import ClickException from rich.console import Console from rich.progress import Progress, SpinnerColumn, TextColumn +from jobflow_remote.config.base import ProjectUndefined + err_console = Console(stderr=True) out_console = Console() @@ -111,3 +115,27 @@ def get_job_db_ids(db_id, job_id): job_id_value = job_id db_id_value = None return db_id_value, job_id_value + + +def cli_error_handler(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except (typer.Exit, typer.Abort, ClickException): + raise # Do not capture click or typer exceptions + except ProjectUndefined: + exit_with_error_msg( + "The active project could not be determined and it is required to execute this command" + ) + except Exception as e: + from jobflow_remote import SETTINGS + + if SETTINGS.cli_full_exc: + raise # Reraise exceptions to print the full stacktrace + else: + exit_with_error_msg( + f"An Error occurred during the command execution: {e.__class__.__name__} {getattr(e, 'message', str(e))}" + ) + + return wrapper diff --git a/src/jobflow_remote/config/base.py b/src/jobflow_remote/config/base.py index 3a77ee0c..5dfe2c87 100644 --- a/src/jobflow_remote/config/base.py +++ b/src/jobflow_remote/config/base.py @@ -121,7 +121,6 @@ def get_host(self) -> BaseHost: class ExecutionConfig(BaseModel): - exec_config_id: str | None = None modules: list[str] | None = None export: dict[str, str] | None = None pre_run: str | None @@ -141,16 +140,10 @@ class Project(BaseModel): runner: RunnerOptions = Field(default_factory=RunnerOptions) workers: dict[str, WorkerConfig] = Field(default_factory=dict) queue: dict = Field(default_factory=dict) - exec_config: list[ExecutionConfig] = Field(default_factory=list) + exec_config: dict[str, ExecutionConfig] = Field(default_factory=dict) jobstore: dict = Field(default_factory=lambda: dict(DEFAULT_JOBSTORE)) metadata: dict | None = None - def get_exec_config_dict(self) -> dict[str, ExecutionConfig]: - return {ec.exec_config_id: ec for ec in self.exec_config} - - def get_exec_config_ids(self) -> list[str]: - return [ec.exec_config_id for ec in self.exec_config] - def get_jobstore(self) -> JobStore | None: if not self.jobstore: return None @@ -203,17 +196,6 @@ def check_daemon_dir(cls, daemon_dir: str, values: dict) -> str: return str(Path(values["base_dir"], "daemon")) return daemon_dir - @validator("exec_config", always=True) - def check_exec_config( - cls, exec_config: list[ExecutionConfig], values: dict - ) -> list[ExecutionConfig]: - ecids: list[ExecutionConfig] = [] - for ec in exec_config: - if ec.exec_config_id in ecids: - raise ValueError(f"Repeated Host with id {ec.exec_config_id}") - - return exec_config - @validator("jobstore", always=True) def check_jobstore(cls, jobstore: dict, values: dict) -> dict: if jobstore: @@ -245,3 +227,7 @@ class Config: class ConfigError(Exception): pass + + +class ProjectUndefined(ConfigError): + pass diff --git a/src/jobflow_remote/config/helper.py b/src/jobflow_remote/config/helper.py index 2dece328..d08e7869 100644 --- a/src/jobflow_remote/config/helper.py +++ b/src/jobflow_remote/config/helper.py @@ -21,11 +21,11 @@ def generate_dummy_project(name: str, full: bool = False) -> Project: remote_worker = generate_dummy_worker(scheduler_type="slurm", host_type="remote") workers = {"example_worker": remote_worker} - exec_config = [] + exec_config = {} if full: local_worker = generate_dummy_worker(scheduler_type="shell", host_type="local") workers["example_local"] = local_worker - exec_config = [generate_dummy_exec_config()] + exec_config = {"example_config": generate_dummy_exec_config()} queue = generate_dummy_queue() @@ -95,7 +95,6 @@ def generate_dummy_jobstore() -> dict: def generate_dummy_exec_config() -> ExecutionConfig: exec_config = ExecutionConfig( - exec_config_id="example_config", modules=["GCC/10.2.0", "OpenMPI/4.0.5-GCC-10.2.0"], export={"PATH": "/path/to/binaries:$PATH"}, pre_run="conda activate env_name", diff --git a/src/jobflow_remote/config/manager.py b/src/jobflow_remote/config/manager.py index 6df5cf1c..53302466 100644 --- a/src/jobflow_remote/config/manager.py +++ b/src/jobflow_remote/config/manager.py @@ -15,7 +15,13 @@ from monty.os import makedirs_p from monty.serialization import dumpfn, loadfn -from jobflow_remote.config.base import ConfigError, ExecutionConfig, Project, WorkerBase +from jobflow_remote.config.base import ( + ConfigError, + ExecutionConfig, + Project, + ProjectUndefined, + WorkerBase, +) from jobflow_remote.utils.data import deep_merge_dict logger = logging.getLogger(__name__) @@ -72,7 +78,7 @@ def select_project_name(self, project_name: str | None = None) -> str: if len(self.projects_data) == 1: project_name = next(iter(self.projects_data.keys())) else: - raise ConfigError("A project name should be defined") + raise ProjectUndefined("A project name should be defined") return project_name @@ -174,34 +180,30 @@ def set_jobstore(self, jobstore: JobStore, project_name: str | None = None): def set_exec_config( self, + exec_config_name: str, exec_config: ExecutionConfig, project_name: str | None = None, replace: bool = False, ): project_data = self.get_project_data(project_name) - exec_config_data = project_data.project.get_exec_config_dict() - if not replace and exec_config.exec_config_id in exec_config_data: - raise ConfigError( - f"Host with id {exec_config.exec_config_id} is already defined" - ) - exec_config_data[exec_config.exec_config_id] = exec_config - project_data.project.hosts = list(exec_config_data.values()) + if not replace and exec_config_name in project_data.project.exec_config: + raise ConfigError(f"Host with name {exec_config_name} is already defined") + project_data.project.exec_config[exec_config_name] = exec_config self.dump_project(project_data) - def remove_exec_config(self, exec_config_id: str, project_name: str | None = None): + def remove_exec_config( + self, exec_config_name: str, project_name: str | None = None + ): project_data = self.get_project_data(project_name) - exec_config_data = project_data.project.get_exec_config_dict() - exec_config_data.pop(exec_config_id) - project_data.project.hosts = list(exec_config_data.values()) + project_data.project.exec_config.pop(exec_config_name, None) self.dump_project(project_data) def load_exec_config( - self, exec_config_id: str, project_name: str | None = None + self, exec_config_name: str, project_name: str | None = None ) -> ExecutionConfig: project = self.get_project(project_name) - exec_config_data = project.get_exec_config_dict() - if exec_config_id not in exec_config_data: + if exec_config_name not in project.exec_config: raise ConfigError( - f"ExecutionConfig with id {exec_config_id} is not defined" + f"ExecutionConfig with id {exec_config_name} is not defined" ) - return exec_config_data[exec_config_id] + return project.exec_config[exec_config_name] diff --git a/src/jobflow_remote/config/settings.py b/src/jobflow_remote/config/settings.py index b4a32043..871df76a 100644 --- a/src/jobflow_remote/config/settings.py +++ b/src/jobflow_remote/config/settings.py @@ -8,6 +8,7 @@ class JobflowRemoteSettings(BaseSettings): projects_folder: str = Path("~/.jfremote").expanduser().as_posix() project: str = None + cli_full_exc: bool = False class Config: """Pydantic config settings.""" diff --git a/src/jobflow_remote/fireworks/launchpad.py b/src/jobflow_remote/fireworks/launchpad.py index 03237fb5..59841386 100644 --- a/src/jobflow_remote/fireworks/launchpad.py +++ b/src/jobflow_remote/fireworks/launchpad.py @@ -93,7 +93,7 @@ def __init__(self, store: Store): self.store = store self.store.connect() self.lpad = LaunchPad(strm_lvl="CRITICAL") - self.lpad.db = store._coll.db + 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 @@ -461,7 +461,7 @@ def set_remote_values( lock_subdoc=REMOTE_DOC_PATH, ) as lock: if lock.locked_document: - values = {f"{REMOTE_DOC_PATH}{k}": v for k, v in values.items()} + 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 @@ -515,7 +515,7 @@ def reset_failed_state( set_dict.pop(k) set_dict["updated_on"] = datetime.datetime.utcnow().isoformat() - lock.update_on_release = {"$set": {set_dict}} + lock.update_on_release = {"$set": set_dict} return True return False diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index d5f9dcec..3d0f46c9 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -378,7 +378,7 @@ def submit(self, doc): exec_config = fw_job_data.task.get("exec_config") if isinstance(exec_config, str): exec_config = self.config_manager.load_exec_config( - exec_config_id=exec_config, project_name=self.project_name + exec_config_name=exec_config, project_name=self.project_name ) elif isinstance(exec_config, dict): exec_config = ExecutionConfig.parse_obj(exec_config) diff --git a/src/jobflow_remote/jobs/submit.py b/src/jobflow_remote/jobs/submit.py index 6fea7fb3..3d994a45 100644 --- a/src/jobflow_remote/jobs/submit.py +++ b/src/jobflow_remote/jobs/submit.py @@ -51,7 +51,7 @@ def submit_flow( config_manager.load_worker(worker_name=worker, project_name=project) if isinstance(exec_config, str): config_manager.load_exec_config( - exec_config_id=exec_config, project_name=project + exec_config_name=exec_config, project_name=project ) wf = flow_to_workflow( diff --git a/src/jobflow_remote/remote/host/base.py b/src/jobflow_remote/remote/host/base.py index 2213429f..33bc500f 100644 --- a/src/jobflow_remote/remote/host/base.py +++ b/src/jobflow_remote/remote/host/base.py @@ -32,7 +32,9 @@ def execute( raise NotImplementedError @abc.abstractmethod - def mkdir(self, directory, recursive: bool = True, exist_ok: bool = True) -> bool: + 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 diff --git a/src/jobflow_remote/remote/host/local.py b/src/jobflow_remote/remote/host/local.py index b33810e5..fb17ccdb 100644 --- a/src/jobflow_remote/remote/host/local.py +++ b/src/jobflow_remote/remote/host/local.py @@ -56,7 +56,9 @@ def execute( ) return proc.stdout.decode(), proc.stderr.decode(), proc.returncode - def mkdir(self, directory, recursive=True, exist_ok=True) -> bool: + def mkdir( + self, directory: str | Path, recursive: bool = True, exist_ok: bool = True + ) -> bool: try: Path(directory).mkdir(parents=recursive, exist_ok=exist_ok) except OSError: diff --git a/src/jobflow_remote/remote/host/remote.py b/src/jobflow_remote/remote/host/remote.py index bf4aa0ff..e0dad94a 100644 --- a/src/jobflow_remote/remote/host/remote.py +++ b/src/jobflow_remote/remote/host/remote.py @@ -1,13 +1,14 @@ from __future__ import annotations import io +import logging from pathlib import Path import fabric from jobflow_remote.remote.host.base import BaseHost -# from fabric import Connection, Config +logger = logging.getLogger(__name__) class RemoteHost(BaseHost): @@ -98,7 +99,9 @@ def execute( return out.stdout, out.stderr, out.exited - def mkdir(self, directory, recursive: bool = True, exist_ok: bool = True) -> bool: + def mkdir( + self, directory: str | Path, recursive: bool = True, exist_ok: bool = True + ) -> bool: """Create directory on the host.""" command = "mkdir " if recursive: @@ -106,8 +109,13 @@ def mkdir(self, directory, recursive: bool = True, exist_ok: bool = True) -> boo command += str(directory) try: stdout, stderr, returncode = self.execute(command) + if returncode != 0: + logger.warning( + f"Error creating folder {directory}. stdout: {stdout}, stderr: {stderr}" + ) return returncode == 0 except Exception: + logger.warning(f"Error creating folder {directory}", exc_info=True) return False def write_text_file(self, filepath: str | Path, content: str): From 54ff0ea12edca1de3cd4a29842a02a412ae74149 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Wed, 21 Jun 2023 17:17:30 +0200 Subject: [PATCH 16/89] more CLI. fix FW task --- pyproject.toml | 1 + src/jobflow_remote/cli/formatting.py | 62 ++++++++++++++++++++++++++ src/jobflow_remote/cli/jf.py | 15 ++++++- src/jobflow_remote/cli/project.py | 60 ++++++++++++++++++++----- src/jobflow_remote/config/base.py | 20 +++++++++ src/jobflow_remote/config/jobconfig.py | 22 ++++++++- src/jobflow_remote/fireworks/tasks.py | 4 +- 7 files changed, 168 insertions(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8eb15e4a..f2a58b54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies =[ "rich", "psutil", "supervisor", + "ruamel.yaml" ] [project.optional-dependencies] diff --git a/src/jobflow_remote/cli/formatting.py b/src/jobflow_remote/cli/formatting.py index 9b973dbb..9d5383b0 100644 --- a/src/jobflow_remote/cli/formatting.py +++ b/src/jobflow_remote/cli/formatting.py @@ -5,8 +5,10 @@ from monty.json import jsanitize from rich.scope import render_scope from rich.table import Table +from rich.text import Text from jobflow_remote.cli.utils import 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 from jobflow_remote.utils.data import remove_none @@ -115,3 +117,63 @@ def format_job_info(job_info: JobInfo, show_none: bool = False): d = jsanitize(d, allow_bson=False, enum_values=True) return render_scope(d) + + +def get_exec_config_table(exec_config: dict[str, ExecutionConfig], verbosity: int = 0): + table = Table(title="Execution config", show_lines=verbosity > 0) + table.add_column("Name") + if verbosity > 0: + table.add_column("modules") + table.add_column("export") + table.add_column("pre_run") + table.add_column("post_run") + for name in sorted(exec_config.keys()): + row = [Text(name, style="bold")] + if verbosity > 0: + ec = exec_config[name] + from ruamel import yaml + + if ec.modules: + row.append(yaml.dump(ec.modules, default_flow_style=False)) + else: + row.append("") + if ec.export: + row.append(yaml.dump(ec.export, default_flow_style=False)) + else: + row.append("") + if ec.post_run: + row.append(ec.post_run) + else: + row.append("") + + table.add_row(*row) + + return table + + +def get_worker_table(workers: dict[str, WorkerBase], verbosity: int = 0): + table = Table(title="Workers", show_lines=verbosity > 1) + table.add_column("Name") + if verbosity > 0: + table.add_column("type") + if verbosity == 1: + table.add_column("info") + elif verbosity > 1: + table.add_column("details") + + for name in sorted(workers.keys()): + row = [Text(name, style="bold")] + worker = workers[name] + if verbosity > 0: + row.append(worker.type) + if verbosity == 1: + row.append(render_scope(worker.cli_info)) + elif verbosity > 1: + d = worker.dict() + d = remove_none(d) + d = jsanitize(d, allow_bson=False, enum_values=True) + row.append(render_scope(d)) + + table.add_row(*row) + + return table diff --git a/src/jobflow_remote/cli/jf.py b/src/jobflow_remote/cli/jf.py index 3065d9be..45dd68b8 100644 --- a/src/jobflow_remote/cli/jf.py +++ b/src/jobflow_remote/cli/jf.py @@ -1,9 +1,10 @@ import typer +from rich.text import Text from typing_extensions import Annotated from jobflow_remote.cli.jfr_typer import JFRTyper -from jobflow_remote.cli.utils import exit_with_error_msg -from jobflow_remote.config import ConfigManager +from jobflow_remote.cli.utils import exit_with_error_msg, out_console +from jobflow_remote.config import ConfigError, ConfigManager app = JFRTyper( name="jf", @@ -50,3 +51,13 @@ def main( if full_exc: SETTINGS.cli_full_exc = True + + cm = ConfigManager() + 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]" + ) + out_console.print(text) + except ConfigError as e: + out_console.print(f"Current project could not be determined: {e}", style="red") diff --git a/src/jobflow_remote/cli/project.py b/src/jobflow_remote/cli/project.py index 97e15b4a..346e1410 100644 --- a/src/jobflow_remote/cli/project.py +++ b/src/jobflow_remote/cli/project.py @@ -3,9 +3,10 @@ from rich.text import Text from typing_extensions import Annotated +from jobflow_remote.cli.formatting import get_exec_config_table, get_worker_table from jobflow_remote.cli.jf import app from jobflow_remote.cli.jfr_typer import JFRTyper -from jobflow_remote.cli.types import force_opt, serialize_file_format_opt +from jobflow_remote.cli.types import force_opt, serialize_file_format_opt, verbosity_opt from jobflow_remote.cli.utils import ( SerializeFileFormat, check_incompatible_opt, @@ -60,16 +61,7 @@ def current_project(ctx: typer.Context): """ # only run if no other subcommand is executed if ctx.invoked_subcommand is None: - cm = ConfigManager() - - 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]" - ) - out_console.print(text) - except ConfigError as e: - exit_with_error_msg(f"Error loading the selected project: {e}") + out_console.print("Run 'jf project -h' to get the list of available commands") @app_project.command() @@ -231,3 +223,49 @@ def remove( with loading_spinner(False) as progress: progress.add_task("Deleting project") cm.remove_project(project_name=name, remove_folders=not keep_folders) + + +##################################### +# Exec config app +##################################### + + +app_exec_config = JFRTyper( + name="exec_config", + help="Commands concerning the Execution configurations", + no_args_is_help=True, +) +app_project.add_typer(app_exec_config) + + +@app_exec_config.command(name="list") +def list_exec_config( + verbosity: verbosity_opt = 0, +): + cm = ConfigManager() + project = cm.get_project() + table = get_exec_config_table(project.exec_config, verbosity) + out_console.print(table) + + +##################################### +# Worker app +##################################### + + +app_worker = JFRTyper( + name="worker", + help="Commands concerning the workers", + no_args_is_help=True, +) +app_project.add_typer(app_worker) + + +@app_worker.command(name="list") +def list_worker( + verbosity: verbosity_opt = 0, +): + cm = ConfigManager() + project = cm.get_project() + table = get_worker_table(project.workers, verbosity) + out_console.print(table) diff --git a/src/jobflow_remote/config/base.py b/src/jobflow_remote/config/base.py index 5dfe2c87..4527b879 100644 --- a/src/jobflow_remote/config/base.py +++ b/src/jobflow_remote/config/base.py @@ -80,6 +80,11 @@ def get_scheduler_io(self) -> BaseSchedulerIO: def get_host(self) -> BaseHost: pass + @property + @abc.abstractmethod + def cli_info(self) -> dict: + pass + class LocalWorker(WorkerBase): @@ -88,6 +93,13 @@ class LocalWorker(WorkerBase): def get_host(self) -> BaseHost: return LocalHost(timeout_execute=self.timeout_execute) + @property + def cli_info(self) -> dict: + return dict( + scheduler_type=self.scheduler_type, + work_dir=self.work_dir, + ) + class RemoteWorker(WorkerBase): @@ -116,6 +128,14 @@ def get_host(self) -> BaseHost: keepalive=self.keepalive, ) + @property + def cli_info(self) -> dict: + return dict( + host=self.host, + scheduler_type=self.scheduler_type, + work_dir=self.work_dir, + ) + WorkerConfig = Annotated[LocalWorker | RemoteWorker, Field(discriminator="type")] diff --git a/src/jobflow_remote/config/jobconfig.py b/src/jobflow_remote/config/jobconfig.py index f6c5cd00..97452f5b 100644 --- a/src/jobflow_remote/config/jobconfig.py +++ b/src/jobflow_remote/config/jobconfig.py @@ -2,9 +2,10 @@ from typing import Callable -from jobflow import Flow, Job +from jobflow import Flow, Job, JobStore from qtoolkit.core.data_objects import QResources +from jobflow_remote.config import ConfigManager from jobflow_remote.config.base import ExecutionConfig @@ -26,3 +27,22 @@ def set_run_config( flow_or_job.update_config( config=config, name_filter=name_filter, function_filter=function_filter ) + + +def load_job_store(project: str | None = None) -> JobStore: + """ + Load the JobStore for the current project. + + Parameters + ---------- + project + + Returns + ------- + + """ + cm = ConfigManager() + p = cm.get_project(project) + job_store = p.get_jobstore() + + return job_store diff --git a/src/jobflow_remote/fireworks/tasks.py b/src/jobflow_remote/fireworks/tasks.py index 47246ce3..2cf7c12b 100644 --- a/src/jobflow_remote/fireworks/tasks.py +++ b/src/jobflow_remote/fireworks/tasks.py @@ -79,8 +79,8 @@ def run_task(self, fw_spec): kwargs_dynamic = { "worker": self.get("worker"), "store": self.get("original_store"), - "exports": self.get("exports"), - "qtk_options": self.get("qtk_options"), + "resources": self.get("resources"), + "exec_config": self.get("exec_config"), } from jobflow_remote.fireworks.convert import flow_to_workflow From 568a6dcb91c7b19671d58d1c31fe21978cc97f51 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Wed, 28 Jun 2023 17:24:58 +0200 Subject: [PATCH 17/89] update remote host --- src/jobflow_remote/jobs/data.py | 6 ++++++ src/jobflow_remote/jobs/runner.py | 4 ++-- src/jobflow_remote/remote/host/remote.py | 22 +++++++++++++++++++++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/jobflow_remote/jobs/data.py b/src/jobflow_remote/jobs/data.py index 3ebc85cc..ddf0d1ed 100644 --- a/src/jobflow_remote/jobs/data.py +++ b/src/jobflow_remote/jobs/data.py @@ -85,6 +85,12 @@ def from_fw_dict(cls, d): 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: diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index 3d0f46c9..1b9a720d 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -125,15 +125,15 @@ def get_fw_data(self, fw_doc: dict) -> JobFWData: 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() - task["store"] = store worker_name = task["worker"] worker = self.get_worker(worker_name) host = self.hosts[worker_name] return JobFWData( - fw, task, job, store, worker_name, worker, host, task.get("store") + fw, task, job, store, worker_name, worker, host, original_store ) def run(self): diff --git a/src/jobflow_remote/remote/host/remote.py b/src/jobflow_remote/remote/host/remote.py index e0dad94a..d02ae966 100644 --- a/src/jobflow_remote/remote/host/remote.py +++ b/src/jobflow_remote/remote/host/remote.py @@ -2,6 +2,7 @@ import io import logging +import shlex from pathlib import Path import fabric @@ -30,6 +31,8 @@ def __init__( inline_ssh_env=None, timeout_execute=None, keepalive=60, + shell_cmd="bash", + login_shell=True, ): self.host = host self.user = user @@ -42,6 +45,8 @@ def __init__( self.inline_ssh_env = inline_ssh_env self.timeout_execute = timeout_execute self.keepalive = keepalive + self.shell_cmd = shell_cmd + self.login_shell = login_shell self._connection = fabric.Connection( host=self.host, user=self.user, @@ -88,14 +93,29 @@ def execute( Exit code of the command. """ + if isinstance(command, (list, tuple)): + command = " ".join(command) + # TODO: check if this works: if not workdir: workdir = "." else: workdir = str(workdir) timeout = timeout or self.timeout_execute + + if self.shell_cmd: + shell_cmd = self.shell_cmd + if self.login_shell: + shell_cmd += " -l " + shell_cmd += " -c " + remote_command = shell_cmd + shlex.quote(command) + else: + remote_command = command + with self.connection.cd(workdir): - out = self.connection.run(command, hide=True, warn=True, timeout=timeout) + out = self.connection.run( + remote_command, hide=True, warn=True, timeout=timeout + ) return out.stdout, out.stderr, out.exited From 33828904cfec62fa9dcd3254155da47a8ce7bafd Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Fri, 30 Jun 2023 11:55:17 +0200 Subject: [PATCH 18/89] improve cli --- src/jobflow_remote/cli/flow.py | 12 ++++--- src/jobflow_remote/cli/formatting.py | 13 ++++++-- src/jobflow_remote/cli/job.py | 20 ++++++++---- src/jobflow_remote/cli/utils.py | 12 +++++++ src/jobflow_remote/config/settings.py | 46 ++++++++++++++++++++++++--- 5 files changed, 85 insertions(+), 18 deletions(-) diff --git a/src/jobflow_remote/cli/flow.py b/src/jobflow_remote/cli/flow.py index e579bab6..54d6cec6 100644 --- a/src/jobflow_remote/cli/flow.py +++ b/src/jobflow_remote/cli/flow.py @@ -4,6 +4,7 @@ from rich.prompt import Confirm from rich.text import Text +from jobflow_remote import SETTINGS from jobflow_remote.cli.formatting import get_flow_info_table from jobflow_remote.cli.jf import app from jobflow_remote.cli.jfr_typer import JFRTyper @@ -77,11 +78,12 @@ def flows_list( table = get_flow_info_table(flows_info, verbosity=verbosity) - if max_results and len(flows_info) == max_results: - out_console.print( - f"The number of Flows printed is limited by the maximum selected: {max_results}", - style="yellow", - ) + if SETTINGS.cli_suggestions: + if max_results and len(flows_info) == max_results: + out_console.print( + f"The number of Flows printed is limited by the maximum selected: {max_results}", + style="yellow", + ) out_console.print(table) diff --git a/src/jobflow_remote/cli/formatting.py b/src/jobflow_remote/cli/formatting.py index 9d5383b0..ea45938b 100644 --- a/src/jobflow_remote/cli/formatting.py +++ b/src/jobflow_remote/cli/formatting.py @@ -7,7 +7,7 @@ from rich.table import Table from rich.text import Text -from jobflow_remote.cli.utils import fmt_datetime +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 @@ -40,11 +40,15 @@ def get_job_info_table(jobs_info: list[JobInfo], verbosity: int): state = ji.state.name if ji.remote_state is not None and ji.state not in excluded_states: - state += f" [{ji.remote_state.name}]" + if ji.retry_time_limit is not None: + state += f" [[bold red]{ji.remote_state.name}[/]]" + else: + state += f" [{ji.remote_state.name}]" + row = [ str(ji.db_id), ji.name, - state, + Text.from_markup(state), ji.job_id, ji.worker, ji.last_updated.strftime(fmt_datetime), @@ -116,6 +120,9 @@ def format_job_info(job_info: JobInfo, show_none: bool = False): d = remove_none(d) d = jsanitize(d, allow_bson=False, enum_values=True) + error_remote = d.get("error_remote") + if error_remote: + d["error_remote"] = ReprStr(error_remote) return render_scope(d) diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index 92fb026b..cbae8f71 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -5,6 +5,7 @@ import typer from typing_extensions import Annotated +from jobflow_remote import SETTINGS from jobflow_remote.cli.formatting import format_job_info, get_job_info_table from jobflow_remote.cli.jf import app from jobflow_remote.cli.jfr_typer import JFRTyper @@ -99,13 +100,20 @@ def jobs_list( table = get_job_info_table(jobs_info, verbosity=verbosity) - if max_results and len(jobs_info) == max_results: - out_console.print( - f"The number of Jobs printed is limited by the maximum selected: {max_results}", - style="yellow", - ) - out_console.print(table) + if SETTINGS.cli_suggestions: + if max_results and len(jobs_info) == max_results: + out_console.print( + 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): + text = ( + "Some jobs (remote 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'" + ) + out_console.print(text, style="yellow") @app_job.command(name="info") diff --git a/src/jobflow_remote/cli/utils.py b/src/jobflow_remote/cli/utils.py index 25acc1ba..cf029def 100644 --- a/src/jobflow_remote/cli/utils.py +++ b/src/jobflow_remote/cli/utils.py @@ -36,6 +36,18 @@ class SerializeFileFormat(Enum): TOML = "toml" +class ReprStr(str): + """ + Helper class that overrides the standard __repr__ to return the string itself + and not its repr(). + Used mainly to allow printing of strings with newlines instead of '\n' when + repr is used in rich. + """ + + def __repr__(self): + return self + + def exit_with_error_msg(message, code=1, **kwargs): kwargs.setdefault("style", "red") err_console.print(message, **kwargs) diff --git a/src/jobflow_remote/config/settings.py b/src/jobflow_remote/config/settings.py index 871df76a..4b0610b1 100644 --- a/src/jobflow_remote/config/settings.py +++ b/src/jobflow_remote/config/settings.py @@ -2,15 +2,53 @@ from pathlib import Path -from pydantic import BaseSettings +from pydantic import BaseSettings, Field, root_validator + +DEFAULT_PROJECTS_FOLDER = Path("~/.jfremote").expanduser().as_posix() + +DEFAULT_CONFIG_FILE_PATH = Path("~/.jfremote.yaml").expanduser().as_posix() class JobflowRemoteSettings(BaseSettings): - projects_folder: str = Path("~/.jfremote").expanduser().as_posix() - project: str = None - cli_full_exc: bool = False + + config_file: str = Field( + DEFAULT_CONFIG_FILE_PATH, + description="Location of the config file for jobflow remote.", + ) + projects_folder: str = Field( + DEFAULT_PROJECTS_FOLDER, description="Location of the projects files." + ) + project: 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.", + ) + cli_suggestions: bool = Field( + True, description="If True prints some suggestions in the CLI commands." + ) class Config: """Pydantic config settings.""" env_prefix = "jfremote_" + + @root_validator(pre=True) + def load_default_settings(cls, values): + """ + Load settings from file or environment variables. + + Loads settings from a root file if available and uses that as defaults in + place of built-in defaults. + + This allows setting of the config file path through environment variables. + """ + from monty.serialization import loadfn + + config_file_path: str = values.get("config_file", DEFAULT_CONFIG_FILE_PATH) + + new_values = {} + if Path(config_file_path).expanduser().exists(): + new_values.update(loadfn(Path(config_file_path).expanduser())) + + new_values.update(values) + return new_values From 43498811ee4615568077912d64e3f2e577f063b3 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Mon, 3 Jul 2023 15:03:52 +0200 Subject: [PATCH 19/89] optional warnings in ConfigManager --- src/jobflow_remote/cli/jf.py | 3 +- src/jobflow_remote/cli/job.py | 2 +- src/jobflow_remote/cli/project.py | 35 +++- src/jobflow_remote/config/manager.py | 264 ++++++++++++++++++++++++++- src/jobflow_remote/jobs/runner.py | 2 +- src/jobflow_remote/jobs/submit.py | 4 +- 6 files changed, 292 insertions(+), 18 deletions(-) diff --git a/src/jobflow_remote/cli/jf.py b/src/jobflow_remote/cli/jf.py index 45dd68b8..4efe0259 100644 --- a/src/jobflow_remote/cli/jf.py +++ b/src/jobflow_remote/cli/jf.py @@ -40,8 +40,8 @@ def main( """ from jobflow_remote import SETTINGS + cm = ConfigManager() if project: - cm = ConfigManager() if project not in cm.projects_data: exit_with_error_msg( f"Project {project} is not defined in {SETTINGS.projects_folder}" @@ -52,7 +52,6 @@ def main( if full_exc: SETTINGS.cli_full_exc = True - cm = ConfigManager() try: project_data = cm.get_project_data() text = Text.from_markup( diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index cbae8f71..f613ab46 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -307,7 +307,7 @@ def queue_out( with loading_spinner(processing=False) as progress: progress.add_task(description="Retrieving files...", total=None) cm = ConfigManager() - worker = cm.load_worker(info.worker) + worker = cm.get_worker(info.worker) host = worker.get_host() try: diff --git a/src/jobflow_remote/cli/project.py b/src/jobflow_remote/cli/project.py index 346e1410..c1b9485d 100644 --- a/src/jobflow_remote/cli/project.py +++ b/src/jobflow_remote/cli/project.py @@ -33,11 +33,20 @@ @app_project.command(name="list") -def list_projects(): +def list_projects( + warn: Annotated[ + bool, + typer.Option( + "--warn", + "-w", + help="Print the warning for the files that could not be parsed", + ), + ] = False, +): """ List of available projects """ - cm = ConfigManager() + cm = ConfigManager(warn=warn) project_name = None try: @@ -46,13 +55,31 @@ def list_projects(): except ConfigError: pass - if not cm.projects_data: + full_project_list = cm.project_names_from_files() + + if not full_project_list: exit_with_warning_msg(f"No project available in {cm.projects_folder}") out_console.print(f"List of projects in {cm.projects_folder}") - for pn in sorted(cm.projects_data.keys()): + for pn in sorted(full_project_list): out_console.print(f" - {pn}", style="green" if pn == project_name else None) + not_parsed_projects = set(full_project_list).difference(cm.projects_data.keys()) + if not_parsed_projects: + out_console.print( + "The following project names exist in files in the project folder, " + "but could not properly parsed as projects: " + f"{', '.join(not_parsed_projects)}.", + style="yellow", + ) + from jobflow_remote import SETTINGS + + if SETTINGS.cli_suggestions: + out_console.print( + "Run the command with -w option to see the parsing errors", + style="yellow", + ) + @app_project.callback(invoke_without_command=True) def current_project(ctx: typer.Context): diff --git a/src/jobflow_remote/config/manager.py b/src/jobflow_remote/config/manager.py index 53302466..ed4f1254 100644 --- a/src/jobflow_remote/config/manager.py +++ b/src/jobflow_remote/config/manager.py @@ -32,22 +32,62 @@ class ConfigManager: + """ + A manager for the projects configuration files. + + Provides tool to parse project information from the selected projects folder as + well as methods to update the properties of each project. + """ + projects_ext = ["json", "yaml", "toml"] - def __init__(self, exclude_unset=False, exclude_none=False): + def __init__( + self, + exclude_unset: bool = False, + exclude_none: bool = False, + warn: bool = False, + ): + """ + + Parameters + ---------- + exclude_unset + when dumping projects determine whether fields which were not explicitly + set when creating the model should be excluded from the dictionary + exclude_none + when dumping projects determine whether fields which are equal to None + should be excluded from the dictionary + warn + if True print warnings related to the parsing of the files in the + projects folder + """ from jobflow_remote import SETTINGS self.exclude_unset = exclude_unset self.exclude_none = exclude_none + self.warn = warn self.projects_folder = Path(SETTINGS.projects_folder) makedirs_p(self.projects_folder) self.projects_data = self.load_projects_data() @property - def projects(self): + def projects(self) -> dict[str, Project]: + """ + Returns + ------- + Dictionary with project name as key and Project as value. + """ return {name: pd.project for name, pd in self.projects_data.items()} def load_projects_data(self) -> dict[str, ProjectData]: + """ + Load projects from the selected projects folder. + + Returns + ------- + Dictionary with project name as key and ProjectData as value. + """ + projects_data: dict[str, ProjectData] = {} for ext in self.projects_ext: for filepath in glob.glob(str(self.projects_folder / f"*.{ext}")): @@ -59,9 +99,10 @@ def load_projects_data(self) -> dict[str, ProjectData]: d = tomlkit.parse(f.read()) project = Project.parse_obj(d) except Exception: - logger.warning( - f"File {filepath} could not be parsed as a Project. Error: {traceback.format_exc()}" - ) + if self.warn: + logger.warning( + f"File {filepath} could not be parsed as a Project. Error: {traceback.format_exc()}" + ) continue if project.name in projects_data: msg = f"Two projects with the same name '{project.name}' have been defined: {filepath}, {projects_data[project.name].filepath}" @@ -71,6 +112,18 @@ def load_projects_data(self) -> dict[str, ProjectData]: return projects_data def select_project_name(self, project_name: str | None = None) -> str: + """ + Determine the project name to be used based on the passed value + and on the general settings. + + Parameters + ---------- + project_name + The name of the project or None to use the value from the settings + Returns + ------- + The name of the selected project. + """ from jobflow_remote import SETTINGS project_name = project_name or SETTINGS.project @@ -83,7 +136,17 @@ def select_project_name(self, project_name: str | None = None) -> str: return project_name def get_project_data(self, project_name: str | None = None) -> ProjectData: - + """ + Get the ProjectData object based from the project name. + + Parameters + ---------- + project_name + The name of the project or None to use the value from the settings + Returns + ------- + The selected ProjectData + """ project_name = self.select_project_name(project_name) if project_name not in self.projects_data: @@ -92,9 +155,28 @@ def get_project_data(self, project_name: str | None = None) -> ProjectData: return self.projects_data[project_name] def get_project(self, project_name: str | None = None) -> Project: + """ + Get the Project object based from the project name. + + Parameters + ---------- + project_name + The name of the project or None to use the value from the settings + Returns + ------- + The selected Project + """ return self.get_project_data(project_name).project def dump_project(self, project_data: ProjectData): + """ + Dump the project to filepath specified in the ProjectData. + + Parameters + ---------- + project_data + The project data to be dumped + """ exclude_none = True if project_data.ext == "toml" else self.exclude_none d = jsanitize( project_data.project.dict( @@ -109,6 +191,17 @@ def dump_project(self, project_data: ProjectData): tomlkit.dump(d, f) def create_project(self, project: Project, ext="yaml"): + """ + Create a new Project in the project folder by dumping the project to file. + + Parameters + ---------- + project + The data of the project to be created. + ext + The extension of the file to which the project will be dumped (yaml, json + or toml) + """ if project.name in self.projects_data: raise ConfigError(f"Project with name {project.name} already exists") @@ -125,6 +218,16 @@ def create_project(self, project: Project, ext="yaml"): self.projects_data[project.name] = project_data def remove_project(self, project_name: str, remove_folders: bool = True): + """ + Remove a project from the projects folder. + + Parameters + ---------- + project_name + Name of the project to be removed. + remove_folders + Optionally remove the folders related to the project (e.g. tmp, log). + """ if project_name not in self.projects_data: return project_data = self.projects_data.pop(project_name) @@ -133,6 +236,17 @@ def remove_project(self, project_name: str, remove_folders: bool = True): os.remove(project_data.filepath) def update_project(self, config: dict, project_name: str): + """ + Update the project values. + The passed dict with values will be recursively merged in the current project. + + Parameters + ---------- + config + Dictionary with the project values to be updated. + project_name + Name of the project to be updated + """ project_data = self.projects_data.pop(project_name) proj_dict = project_data.project.dict() new_project = Project.parse_obj(deep_merge_dict(proj_dict, config)) @@ -140,6 +254,34 @@ def update_project(self, config: dict, project_name: str): self.dump_project(project_data) self.projects_data[project_data.project.name] = project_data + def project_names_from_files(self) -> list[str]: + """ + Parses all the prasable files and only checks for the "name" attribute to + return a list of potential project file names. + + Useful in case some projects cannot be properly parsed, but the full list + needs to be returned. + + Returns + ------- + List of project names. + """ + project_names = [] + for ext in self.projects_ext: + for filepath in glob.glob(str(self.projects_folder / f"*.{ext}")): + try: + if ext in ["json", "yaml"]: + d = loadfn(filepath) + else: + with open(filepath) as f: + d = tomlkit.parse(f.read()) + if "name" in d: + project_names.append(d["name"]) + except Exception: + continue + + return project_names + def set_worker( self, name: str, @@ -147,6 +289,23 @@ def set_worker( project_name: str | None = None, replace: bool = False, ): + """ + Set a worker in the selected project. + Can add a new worker or replace an existing one. + + Parameters + ---------- + name + Name of the worker to be added or replaced. + worker + Worker to be set. + project_name + Name of the project where the Worker is set, or None to use the one + from the settings. + replace + Raise an exception if False and a Worker with the chosen name already + exists. + """ project_data = self.get_project_data(project_name) if not replace and name in project_data.project.workers: raise ConfigError(f"Worker with name {name} is already defined") @@ -155,25 +314,72 @@ def set_worker( self.dump_project(project_data) def remove_worker(self, worker_name: str, project_name: str | None = None): + """ + Remove a worker from the selected project. + + Parameters + ---------- + worker_name + Name of the worker to be removed + project_name + Name of the project from which the Worker should be removed, or None to + use the one from the settings. + """ project_data = self.get_project_data(project_name) project_data.project.workers.pop(worker_name) self.dump_project(project_data) - def load_worker( + def get_worker( self, worker_name: str, project_name: str | None = None ) -> WorkerBase: + """ + Return the worker object based on the name. + + Parameters + ---------- + worker_name + Name of the worker to retrieve. + project_name + Name of the project from which the Worker should be retrieved, or None to + use the one from the settings. + Returns + ------- + The selected Worker. + """ project = self.get_project(project_name) if worker_name not in project.workers: raise ConfigError(f"Worker with name {worker_name} is not defined") return project.workers[worker_name] def set_queue_db(self, store: MongoStore, project_name: str | None = None): + """ + Set the project specific store used for managing the queue. + + Parameters + ---------- + store + A maggma Store + project_name + Name of the project where the Store is set, or None to use the one + from the settings. + """ project_data = self.get_project_data(project_name) project_data.project.queue = store.as_dict() self.dump_project(project_data) def set_jobstore(self, jobstore: JobStore, project_name: str | None = None): + """ + Set the project specific store used for jobflow. + + Parameters + ---------- + jobstore + A maggma Store + project_name + Name of the project where the Store is set, or None to use the one + from the settings. + """ project_data = self.get_project_data(project_name) project_data.project.jobstore = jobstore.as_dict() self.dump_project(project_data) @@ -185,6 +391,23 @@ def set_exec_config( project_name: str | None = None, replace: bool = False, ): + """ + Set an ExecutionConfig in the selected project. + Can add a new ExecutionConfig or replace an existing one. + + Parameters + ---------- + exec_config_name + Name of the ExecutionConfig to be added or replaced. + exec_config + The ExecutionConfig. + project_name + Name of the project where the ExecutionConfig is set, or None to use + the one from the settings. + replace + Raise an exception if False and an ExecutionConfig with the chosen + name already exists. + """ project_data = self.get_project_data(project_name) if not replace and exec_config_name in project_data.project.exec_config: raise ConfigError(f"Host with name {exec_config_name} is already defined") @@ -194,13 +417,38 @@ def set_exec_config( def remove_exec_config( self, exec_config_name: str, project_name: str | None = None ): + """ + Remove an ExecutionConfig from the selected project. + + Parameters + ---------- + exec_config_name + Name of the ExecutionConfig to be removed + project_name + Name of the project from which the ExecutionConfig should be removed, or + None to use the one from the settings. + """ project_data = self.get_project_data(project_name) project_data.project.exec_config.pop(exec_config_name, None) self.dump_project(project_data) - def load_exec_config( + def get_exec_config( self, exec_config_name: str, project_name: str | None = None ) -> ExecutionConfig: + """ + Return the ExecutionConfig object based on the name. + + Parameters + ---------- + exec_config_name + Name of the ExecutionConfig. + project_name + Name of the project from which the ExecutionConfig should be retrieved, + or None to use the one from the settings. + Returns + ------- + The selected ExecutionConfig + """ project = self.get_project(project_name) if exec_config_name not in project.exec_config: raise ConfigError( diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index 1b9a720d..77a1b0e6 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -377,7 +377,7 @@ def submit(self, doc): set_name_out(resources, fw_job_data.job.name) exec_config = fw_job_data.task.get("exec_config") if isinstance(exec_config, str): - exec_config = self.config_manager.load_exec_config( + exec_config = self.config_manager.get_exec_config( exec_config_name=exec_config, project_name=self.project_name ) elif isinstance(exec_config, dict): diff --git a/src/jobflow_remote/jobs/submit.py b/src/jobflow_remote/jobs/submit.py index 3d994a45..b27d77f5 100644 --- a/src/jobflow_remote/jobs/submit.py +++ b/src/jobflow_remote/jobs/submit.py @@ -48,9 +48,9 @@ def submit_flow( proj_obj = config_manager.get_project(project) # try to load the worker and exec_config to check that the values are well defined - config_manager.load_worker(worker_name=worker, project_name=project) + config_manager.get_worker(worker_name=worker, project_name=project) if isinstance(exec_config, str): - config_manager.load_exec_config( + config_manager.get_exec_config( exec_config_name=exec_config, project_name=project ) From ded7c1355fafbf5e5adf4ffff667edc6c4f89628 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Tue, 4 Jul 2023 12:52:13 +0200 Subject: [PATCH 20/89] document configuration options and revise RemoteWorker --- src/jobflow_remote/config/base.py | 350 +++++++++++++++++++++++++----- 1 file changed, 301 insertions(+), 49 deletions(-) diff --git a/src/jobflow_remote/config/base.py b/src/jobflow_remote/config/base.py index 4527b879..e3bf30f4 100644 --- a/src/jobflow_remote/config/base.py +++ b/src/jobflow_remote/config/base.py @@ -19,15 +19,54 @@ class RunnerOptions(BaseModel): - delay_checkout: int = 30 - delay_check_run_status: int = 30 - delay_advance_status: int = 30 - lock_timeout: int | None = 7200 - delete_tmp_folder: bool = True - max_step_attempts: int = 3 - delta_retry: tuple[int, ...] = (30, 300, 1200) - - def get_delta_retry(self, step_attempts: int): + """ + Options to tune the execution of the Runner + """ + + delay_checkout: int = Field( + 30, + description="Delay between subsequent execution of the checkout from database (seconds)", + ) + delay_check_run_status: int = Field( + 30, + description="Delay between subsequent execution of the checking the status of jobs that are submitted to the scheduler (seconds)", + ) + delay_advance_status: int = Field( + 30, + description="Delay between subsequent advancement of the job's remote state (seconds)", + ) + lock_timeout: int | None = Field( + 86400, + description="Time to consider the lock on a document expired and can be overridden (seconds)", + ) + delete_tmp_folder: bool = Field( + True, + description="Whether to delete the local temporary folder after a job has completed", + ) + max_step_attempts: int = Field( + 3, + description="Maximum number of attempt performed before failing an advancement of a remote state", + ) + delta_retry: tuple[int, ...] = Field( + (30, 300, 1200), + description="List of increasing delay between subsequent attempts when the advancement of a remote step fails", + ) + + def get_delta_retry(self, step_attempts: int) -> int: + """ + The time to wait before retrying a failed advancement of the remote state, + based on the number of attempts. + + If exceeding the size of the list delta_retry, the last value is returned. + + Parameters + ---------- + step_attempts + The number of attempts advancing a remote state. + Returns + ------- + The delay in seconds. + """ ind = min(step_attempts, len(self.delta_retry)) - 1 return self.delta_retry[ind] @@ -36,12 +75,23 @@ class Config: class LogLevel(str, Enum): + """ + Enumeration of logging level. + """ + ERROR = "error" WARN = "warn" INFO = "info" DEBUG = "debug" def to_logging(self) -> int: + """ + Helper converter to python logging values. + + Returns + ------- + The int corresponding to python logging value + """ return { LogLevel.ERROR: logging.ERROR, LogLevel.WARN: logging.WARN, @@ -51,13 +101,35 @@ def to_logging(self) -> int: class WorkerBase(BaseModel): - - scheduler_type: str - work_dir: str - resources: dict | None = None - pre_run: str | None = None - post_run: str | None = None - timeout_execute: int = 60 + """ + Base class defining the common field for the different types of Worker. + """ + + scheduler_type: str = Field( + description="Type of the scheduler. Depending on the values supported by QToolKit" + ) + work_dir: str = Field( + description="Path to the directory of the worker where subfolders for " + "executing the calculation will be created" + ) + resources: dict | None = Field( + None, + description="A dictionary defining the default resources requested to the " + "scheduler. Used to fill in the QToolKit template", + ) + pre_run: str | None = Field( + None, + description="String with commands that will be executed before the execution of the Job", + ) + post_run: str | None = Field( + None, + description="String with commands that will be executed after the execution of the Job", + ) + timeout_execute: int = Field( + 60, + description="Timeout for the execution of the commands in the worker " + "(e.g. submitting a job)", + ) class Config: extra = Extra.forbid @@ -72,29 +144,67 @@ def check_scheduler_type(cls, scheduler_type: str, values: dict) -> str: return scheduler_type def get_scheduler_io(self) -> BaseSchedulerIO: + """ + Get the BaseSchedulerIO from QToolKit depending on scheduler_type. + + Returns + ------- + The instance of the scheduler_type. + """ if self.scheduler_type not in scheduler_mapping: raise ConfigError(f"Unknown scheduler type {self.scheduler_type}") return scheduler_mapping[self.scheduler_type]() @abc.abstractmethod def get_host(self) -> BaseHost: - pass + """ + Return the Host object used in the Worker. + """ @property @abc.abstractmethod def cli_info(self) -> dict: - pass + """ + Short information about the worker to be displayed in the command line + interface. + + Returns + ------- + A dictionary with the Worker short information. + """ class LocalWorker(WorkerBase): + """ + Worker representing the local host. + + Executes command directly. + """ - type: Literal["local"] = "local" + type: Literal["local"] = Field( + "local", description="The discriminator field to determine the worker type" + ) def get_host(self) -> BaseHost: + """ + Return the LocalHost. + + Returns + ------- + The LocalHost. + """ return LocalHost(timeout_execute=self.timeout_execute) @property def cli_info(self) -> dict: + """ + Short information about the worker to be displayed in the command line + interface. + + Returns + ------- + A dictionary with the Worker short information. + """ return dict( scheduler_type=self.scheduler_type, work_dir=self.work_dir, @@ -102,19 +212,71 @@ def cli_info(self) -> dict: class RemoteWorker(WorkerBase): - - type: Literal["remote"] = "remote" - host: str - user: str = None - port: int = None - gateway: str = None - forward_agent: bool = None - connect_timeout: int = None - connect_kwargs: dict = None - inline_ssh_env: bool = None - keepalive: int | None = 60 + """ + 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. + """ + + type: Literal["remote"] = Field( + "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") + password: str | None = Field(None, description="Login password") + key_filename: str | list[str] | None = Field( + None, + description="The filename, or list of filenames, of optional private key(s) " + "and/or certs to try for authentication", + ) + passphrase: str | None = Field( + None, description="Passphrase used for decrypting private keys" + ) + gateway: str = Field( + None, description="A shell command string to use as a proxy or gateway" + ) + forward_agent: bool = Field( + None, description="Whether to enable SSH agent forwarding" + ) + connect_timeout: int = Field(None, description="Connection timeout, in seconds") + connect_kwargs: dict = Field( + None, + description="Other keyword arguments passed to paramiko.client.SSHClient.connect", + ) + inline_ssh_env: bool = Field( + None, + description="Whether to send environment variables 'inline' as prefixes in " + "front of command strings", + ) + keepalive: int | None = Field( + 60, description="Keepalive value in seconds passed to paramiko's transport" + ) + shell_cmd: str | None = Field( + "bash", + description="The shell command used to execute the command remotely. If None " + "the command is executed directly", + ) + login_shell: bool = Field( + True, description="Whether to use a login shell when executing the command" + ) def get_host(self) -> BaseHost: + """ + Return the RemoteHost. + + Returns + ------- + The RemoteHost. + """ + connect_kwargs = dict(self.connect_kwargs) if self.connect_kwargs else {} + if self.password: + connect_kwargs["password"] = self.password + if self.key_filename: + connect_kwargs["key_filename"] = self.key_filename + if self.passphrase: + connect_kwargs["passphrase"] = self.passphrase return RemoteHost( host=self.host, user=self.user, @@ -122,14 +284,24 @@ def get_host(self) -> BaseHost: gateway=self.gateway, forward_agent=self.forward_agent, connect_timeout=self.connect_timeout, - connect_kwargs=self.connect_kwargs, + connect_kwargs=connect_kwargs, inline_ssh_env=self.inline_ssh_env, timeout_execute=self.timeout_execute, keepalive=self.keepalive, + shell_cmd=self.shell_cmd, + login_shell=self.login_shell, ) @property def cli_info(self) -> dict: + """ + Short information about the worker to be displayed in the command line + interface. + + Returns + ------- + A dictionary with the Worker short information. + """ return dict( host=self.host, scheduler_type=self.scheduler_type, @@ -141,30 +313,86 @@ def cli_info(self) -> dict: class ExecutionConfig(BaseModel): - modules: list[str] | None = None - export: dict[str, str] | None = None - pre_run: str | None - post_run: str | None + """ + Configuration to be set before and after the execution of a Job. + """ + + modules: list[str] | None = Field(None, description="list of modules to be loaded") + export: dict[str, str] | None = Field( + None, description="dictionary with variable to be exported" + ) + pre_run: str | None = Field( + None, description="Other commands to be executed before the execution of a job" + ) + post_run: str | None = Field( + None, description="Commands to be executed after the execution of a job" + ) class Config: extra = Extra.forbid class Project(BaseModel): - name: str - base_dir: str | None = None - tmp_dir: str | None = None - log_dir: str | None = None - daemon_dir: str | None = None - log_level: LogLevel = LogLevel.INFO - runner: RunnerOptions = Field(default_factory=RunnerOptions) - workers: dict[str, WorkerConfig] = Field(default_factory=dict) - queue: dict = Field(default_factory=dict) - exec_config: dict[str, ExecutionConfig] = Field(default_factory=dict) - jobstore: dict = Field(default_factory=lambda: dict(DEFAULT_JOBSTORE)) - metadata: dict | None = None + """ + The configurations of a Project. + """ + + name: str = Field(description="The name of the project") + base_dir: str | None = Field( + None, + description="The base directory containing the project related files. Default " + "is a folder with the project name inside the projects folder", + ) + tmp_dir: str | None = Field( + None, + description="Folder where remote files are copied. Default a 'tmp' folder in base_dir", + ) + log_dir: str | None = Field( + None, + description="Folder containing all the logs. Default a 'log' folder in base_dir", + ) + daemon_dir: str | None = Field( + None, + description="Folder containing daemon related files. Default to a 'daemon' " + "folder in base_dir", + ) + log_level: LogLevel = Field(LogLevel.INFO, description="The level set for logging") + runner: RunnerOptions = Field( + default_factory=RunnerOptions, description="The options for the Runner" + ) + workers: dict[str, WorkerConfig] = Field( + default_factory=dict, + description="A dictionary with the worker name as keys and the worker " + "configuration as values", + ) + queue: dict = Field( + 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", + ) + exec_config: dict[str, ExecutionConfig] = Field( + default_factory=dict, + description="A dictionary with the ExecutionConfig name as keys and the " + "ExecutionConfig configuration as values", + ) + jobstore: dict = Field( + 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", + ) + metadata: dict | None = Field( + None, description="A dictionary with metadata associated to the project" + ) def get_jobstore(self) -> JobStore | None: + """ + Generate an instance of the JobStore based on the configuration + + Returns + ------- + A JobStore + """ if not self.jobstore: return None elif self.jobstore.get("@class") == "JobStore": @@ -173,9 +401,23 @@ def get_jobstore(self) -> JobStore | None: return JobStore.from_dict_spec(self.jobstore) def get_queue_store(self): + """ + Generate an instance of a maggma Store based on the queue configuration. + + Returns + ------- + A maggma Store + """ return store_from_dict(self.queue) 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()) @validator("base_dir", always=True) @@ -218,6 +460,9 @@ def check_daemon_dir(cls, daemon_dir: str, values: dict) -> str: @validator("jobstore", always=True) def check_jobstore(cls, jobstore: dict, values: dict) -> dict: + """ + Check that the jobstore configuration could be converted to a JobStore. + """ if jobstore: try: if jobstore.get("@class") == "JobStore": @@ -232,6 +477,9 @@ def check_jobstore(cls, jobstore: dict, values: dict) -> dict: @validator("queue", always=True) def check_queue(cls, queue: dict, values: dict) -> dict: + """ + Check that the queue configuration could be converted to a Store. + """ if queue: try: store_from_dict(queue) @@ -246,8 +494,12 @@ class Config: class ConfigError(Exception): - pass + """ + A generic Exception related to the configuration + """ class ProjectUndefined(ConfigError): - pass + """ + Exception raised if the Project has not been defined or could not be determined. + """ From a42c05845d100f9a5f0cc5b5ecf6cbcb83dfbe1b Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Tue, 4 Jul 2023 15:51:44 +0200 Subject: [PATCH 21/89] get_jobstore function --- src/jobflow_remote/__init__.py | 4 ++++ src/jobflow_remote/config/base.py | 9 ++++++--- src/jobflow_remote/jobs/store.py | 23 +++++++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 src/jobflow_remote/jobs/store.py diff --git a/src/jobflow_remote/__init__.py b/src/jobflow_remote/__init__.py index 374607a1..31ffb336 100644 --- a/src/jobflow_remote/__init__.py +++ b/src/jobflow_remote/__init__.py @@ -1,6 +1,10 @@ """jobflow-remote is a python package to run jobflow workflows on remote resources""" from jobflow_remote._version import __version__ +from jobflow_remote.config.manager import ConfigManager from jobflow_remote.config.settings import JobflowRemoteSettings +from jobflow_remote.jobs.jobcontroller import JobController +from jobflow_remote.jobs.store import get_jobstore +from jobflow_remote.jobs.submit import submit_flow SETTINGS = JobflowRemoteSettings() diff --git a/src/jobflow_remote/config/base.py b/src/jobflow_remote/config/base.py index e3bf30f4..edaba3f9 100644 --- a/src/jobflow_remote/config/base.py +++ b/src/jobflow_remote/config/base.py @@ -29,7 +29,8 @@ class RunnerOptions(BaseModel): ) delay_check_run_status: int = Field( 30, - description="Delay between subsequent execution of the checking the status of jobs that are submitted to the scheduler (seconds)", + description="Delay between subsequent execution of the checking the status of " + "jobs that are submitted to the scheduler (seconds)", ) delay_advance_status: int = Field( 30, @@ -45,11 +46,13 @@ class RunnerOptions(BaseModel): ) max_step_attempts: int = Field( 3, - description="Maximum number of attempt performed before failing an advancement of a remote state", + description="Maximum number of attempt performed before failing an " + "advancement of a remote state", ) delta_retry: tuple[int, ...] = Field( (30, 300, 1200), - description="List of increasing delay between subsequent attempts when the advancement of a remote step fails", + description="List of increasing delay between subsequent attempts when the " + "advancement of a remote step fails", ) def get_delta_retry(self, step_attempts: int) -> int: diff --git a/src/jobflow_remote/jobs/store.py b/src/jobflow_remote/jobs/store.py new file mode 100644 index 00000000..dcab07eb --- /dev/null +++ b/src/jobflow_remote/jobs/store.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from jobflow.core.store import JobStore + +from jobflow_remote.config.manager import ConfigManager + + +def get_jobstore(project_name: str | None = None) -> JobStore: + """ + Helper function to get the jobstore in a project. + + Parameters + ---------- + project_name + Name of the project or None to use the one from the settings. + Returns + ------- + A JobStore + """ + + cm = ConfigManager(warn=False) + project = cm.get_project(project_name=project_name) + return project.get_jobstore() From a6235ccc9f7c72436d4a58e10ef9deffdfaca870 Mon Sep 17 00:00:00 2001 From: Matthew Evans Date: Sat, 8 Jul 2023 12:57:36 +0100 Subject: [PATCH 22/89] Temporarily add qtoolkit as git dependency --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f2a58b54..7d8a1930 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies =[ "fireworks", "fabric", "tomlkit", -# "qtoolkit", # Should be added here when released + "qtoolkit @ git+ssh://git@github.com/matgenix/qtoolkit", # TODO: Should be updated here when released "typer", "rich", "psutil", From eceb1a56b471c024169d3f25e75ff4c8f49c6fd6 Mon Sep 17 00:00:00 2001 From: Matthew Evans Date: Sat, 8 Jul 2023 13:03:59 +0100 Subject: [PATCH 23/89] Use default/first worker if not passed to submit function --- src/jobflow_remote/config/manager.py | 8 ++++++-- src/jobflow_remote/jobs/submit.py | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/jobflow_remote/config/manager.py b/src/jobflow_remote/config/manager.py index ed4f1254..750070e7 100644 --- a/src/jobflow_remote/config/manager.py +++ b/src/jobflow_remote/config/manager.py @@ -330,7 +330,7 @@ def remove_worker(self, worker_name: str, project_name: str | None = None): self.dump_project(project_data) def get_worker( - self, worker_name: str, project_name: str | None = None + self, worker_name: str | None = None, project_name: str | None = None ) -> WorkerBase: """ Return the worker object based on the name. @@ -338,7 +338,8 @@ def get_worker( Parameters ---------- worker_name - Name of the worker to retrieve. + Name of the worker to retrieve, or None to use the first one listed in the + project. project_name Name of the project from which the Worker should be retrieved, or None to use the one from the settings. @@ -347,6 +348,9 @@ def get_worker( The selected Worker. """ project = self.get_project(project_name) + if not worker_name: + worker_name = next(iter(project.workers.keys())) + if worker_name not in project.workers: raise ConfigError(f"Worker with name {worker_name} is not defined") return project.workers[worker_name] diff --git a/src/jobflow_remote/jobs/submit.py b/src/jobflow_remote/jobs/submit.py index b27d77f5..2d5425e9 100644 --- a/src/jobflow_remote/jobs/submit.py +++ b/src/jobflow_remote/jobs/submit.py @@ -10,7 +10,7 @@ def submit_flow( flow: jobflow.Flow | jobflow.Job | list[jobflow.Job], - worker: str, + worker: str | None = None, store: str | jobflow.JobStore | None = None, project: str | None = None, exec_config: str | ExecutionConfig | None = None, From f85d4c1a554027512c173d02eed21a77ed014b44 Mon Sep 17 00:00:00 2001 From: Matthew Evans Date: Sat, 8 Jul 2023 13:24:58 +0100 Subject: [PATCH 24/89] Add simple usage instructions (need checking) --- INSTALL.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/INSTALL.md b/INSTALL.md index 2dc2ebf8..dd5b8f44 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -18,3 +18,33 @@ pip install -e .[dev,tests] to perform an editable installation with additional development and test dependencies. You can then activate `pre-commit` in your local repository with `pre-commit install`. + +## Simple usage + +jobflow-remote requires several things to be set up before first use. + +Firstly, configure a project by adding a `.yaml` file into the `$HOME/.jfremote` +directory. +This should contain connection details on the FireServer to use to manage the workflows, details on any FireWorkers +that will be executing the workflows, and details of the job queue. +You should also configure a FireWorks launchpad on your machine. +Using the unique name provided in that config, you should be able to create a +test job: + +```python +from jobflow import job, Flow +from jobflow_remote.jobs.submit import submit_flow + +@job +def add(a, b): + return a + b + +add_first = add(1, 5) +add_second = add(add_first.output, 5) + +flow = Flow([add_first, add_second]) + +submit_flow(flow) +``` + +Any FireWorkers must also be configured to run jobs from the launchpad. From 4443d43c15542dd117001ec91bd0f367f262dfd6 Mon Sep 17 00:00:00 2001 From: Matthew Evans Date: Sat, 8 Jul 2023 13:27:59 +0100 Subject: [PATCH 25/89] Use strict jobflow deps to avoid pydantic errors --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7d8a1930..2cae6eb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ ] requires-python = ">=3.8" dependencies =[ - "jobflow", + "jobflow[strict]", "fireworks", "fabric", "tomlkit", From 5345de6fa69515a814f7ca1e2d1e253107a94757 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Mon, 10 Jul 2023 15:51:56 +0200 Subject: [PATCH 26/89] Add .DS_store to gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index a14d6d0d..a80233aa 100644 --- a/.gitignore +++ b/.gitignore @@ -154,3 +154,6 @@ cython_debug/ # PyCharm .idea/ + +# macOS +.DS_store From a814ca355de620b122861da81f5f4d144578c707 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Mon, 10 Jul 2023 16:03:29 +0200 Subject: [PATCH 27/89] enforce connecting of the remote host --- src/jobflow_remote/cli/job.py | 5 ++++- src/jobflow_remote/cli/runner.py | 7 +++++++ src/jobflow_remote/jobs/runner.py | 10 ++++++++-- src/jobflow_remote/remote/host/base.py | 19 +++++++++++++++++++ src/jobflow_remote/remote/host/remote.py | 7 +++++++ 5 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index f613ab46..dd5ed5ee 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -325,7 +325,10 @@ def queue_out( except Exception as e: err_error = getattr(e, "message", str(e)) finally: - host.close() + try: + host.close() + except Exception: + pass if out_error: out_console.print( diff --git a/src/jobflow_remote/cli/runner.py b/src/jobflow_remote/cli/runner.py index edc3de88..95298825 100644 --- a/src/jobflow_remote/cli/runner.py +++ b/src/jobflow_remote/cli/runner.py @@ -94,6 +94,13 @@ def stop( exit_with_error_msg( f"Error while stopping the daemon: {getattr(e, 'message', e)}" ) + from jobflow_remote import SETTINGS + + if SETTINGS.cli_suggestions: + out_console( + "The stop signal has been sent to the Runner. Run 'jf runner status' to verify if it stopped", + style="yellow", + ) @app_runner.command() diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index 77a1b0e6..087d87a0 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -105,11 +105,17 @@ def get_worker(self, worker_name: str) -> WorkerBase: ) return self.workers[worker_name] + def get_host(self, worker_name: str): + host = self.hosts[worker_name] + if not host.is_connected: + host.connect() + return host + def get_queue_manager(self, worker_name: str) -> QueueManager: if worker_name not in self.queue_managers: worker = self.get_worker(worker_name) self.queue_managers[worker_name] = QueueManager( - worker.get_scheduler_io(), self.hosts[worker_name] + worker.get_scheduler_io(), self.get_host(worker_name) ) return self.queue_managers[worker_name] @@ -130,7 +136,7 @@ def get_fw_data(self, fw_doc: dict) -> JobFWData: store = self.project.get_jobstore() worker_name = task["worker"] worker = self.get_worker(worker_name) - host = self.hosts[worker_name] + host = self.get_host(worker_name) return JobFWData( fw, task, job, store, worker_name, worker, host, original_store diff --git a/src/jobflow_remote/remote/host/base.py b/src/jobflow_remote/remote/host/base.py index 33bc500f..0f57667a 100644 --- a/src/jobflow_remote/remote/host/base.py +++ b/src/jobflow_remote/remote/host/base.py @@ -84,3 +84,22 @@ def test(self) -> str | None: msg = f"Error while executing command:\n {exc}" 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 d02ae966..4d8f8f02 100644 --- a/src/jobflow_remote/remote/host/remote.py +++ b/src/jobflow_remote/remote/host/remote.py @@ -92,6 +92,7 @@ def execute( exit_code : int Exit code of the command. """ + self._check_connected() if isinstance(command, (list, tuple)): command = " ".join(command) @@ -140,6 +141,8 @@ def mkdir( def write_text_file(self, filepath: str | Path, content: str): """Write content to a file on the host.""" + self._check_connected() + f = io.StringIO(content) self.connection.put(f, str(filepath)) @@ -161,9 +164,13 @@ def is_connected(self) -> bool: return self.connection.is_connected def put(self, src, dst): + self._check_connected() + self.connection.put(src, dst) def get(self, src, dst): + self._check_connected() + self.connection.get(src, dst) def copy(self, src, dst): From 80b130edc4dc2ab933da3cd69e50d2d33ac4b1dd Mon Sep 17 00:00:00 2001 From: Matthew Evans Date: Mon, 10 Jul 2023 15:28:37 +0100 Subject: [PATCH 28/89] Pin to the development jobflow --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2cae6eb5..11e1b2a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ ] requires-python = ">=3.8" dependencies =[ - "jobflow[strict]", + "jobflow[strict] @ git+ssh://git@github.com/materialsproject/jobflow", "fireworks", "fabric", "tomlkit", From 8c768fd664d6281f1255eff70876a37646f9d1e8 Mon Sep 17 00:00:00 2001 From: Matthew Evans Date: Mon, 10 Jul 2023 22:15:42 +0200 Subject: [PATCH 29/89] Remove incorrect usage docs --- INSTALL.md | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index dd5b8f44..2dc2ebf8 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -18,33 +18,3 @@ pip install -e .[dev,tests] to perform an editable installation with additional development and test dependencies. You can then activate `pre-commit` in your local repository with `pre-commit install`. - -## Simple usage - -jobflow-remote requires several things to be set up before first use. - -Firstly, configure a project by adding a `.yaml` file into the `$HOME/.jfremote` -directory. -This should contain connection details on the FireServer to use to manage the workflows, details on any FireWorkers -that will be executing the workflows, and details of the job queue. -You should also configure a FireWorks launchpad on your machine. -Using the unique name provided in that config, you should be able to create a -test job: - -```python -from jobflow import job, Flow -from jobflow_remote.jobs.submit import submit_flow - -@job -def add(a, b): - return a + b - -add_first = add(1, 5) -add_second = add(add_first.output, 5) - -flow = Flow([add_first, add_second]) - -submit_flow(flow) -``` - -Any FireWorkers must also be configured to run jobs from the launchpad. From e3bae86bb0fa48e3a6e37dcc63ba7e86218463e4 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Tue, 11 Jul 2023 12:26:01 +0200 Subject: [PATCH 30/89] restart connection for remote host if connection is dropped --- src/jobflow_remote/remote/host/remote.py | 61 ++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/src/jobflow_remote/remote/host/remote.py b/src/jobflow_remote/remote/host/remote.py index 4d8f8f02..cb9b1007 100644 --- a/src/jobflow_remote/remote/host/remote.py +++ b/src/jobflow_remote/remote/host/remote.py @@ -3,9 +3,11 @@ import io import logging import shlex +import traceback from pathlib import Path import fabric +from paramiko.ssh_exception import SSHException from jobflow_remote.remote.host.base import BaseHost @@ -33,6 +35,7 @@ def __init__( keepalive=60, shell_cmd="bash", login_shell=True, + retry_on_closed_connection=True, ): self.host = host self.user = user @@ -47,6 +50,10 @@ def __init__( self.keepalive = keepalive self.shell_cmd = shell_cmd self.login_shell = login_shell + self.retry_on_closed_connection = retry_on_closed_connection + self._create_connection() + + def _create_connection(self): self._connection = fabric.Connection( host=self.host, user=self.user, @@ -114,8 +121,12 @@ def execute( remote_command = command with self.connection.cd(workdir): - out = self.connection.run( - remote_command, hide=True, warn=True, timeout=timeout + out = self._execute_remote_func( + self.connection.run, + remote_command, + hide=True, + warn=True, + timeout=timeout, ) return out.stdout, out.stderr, out.exited @@ -145,7 +156,7 @@ def write_text_file(self, filepath: str | Path, content: str): f = io.StringIO(content) - self.connection.put(f, str(filepath)) + self._execute_remote_func(self.connection.put, f, str(filepath)) def connect(self): self.connection.open() @@ -166,13 +177,53 @@ def is_connected(self) -> bool: def put(self, src, dst): self._check_connected() - self.connection.put(src, dst) + self._execute_remote_func(self.connection.put, src, dst) def get(self, src, dst): self._check_connected() - self.connection.get(src, dst) + self._execute_remote_func(self.connection.get, src, dst) def copy(self, src, dst): cmd = ["cp", str(src), str(dst)] self.execute(cmd) + + def _execute_remote_func(self, remote_cmd, *args, **kwargs): + if self.retry_on_closed_connection: + try: + return remote_cmd(*args, **kwargs) + except OSError as e: + msg = getattr(e, "message", str(e)) + error = e + if "Socket is closed" not in msg: + raise e + except SSHException as e: + error = e + msg = getattr(e, "message", str(e)) + if "Server connection dropped" not in msg: + raise e + except EOFError as e: + error = e + else: + return remote_cmd(*args, **kwargs) + + # if the code gets here one of the errors that could be due to drop of the + # connection occurred. Try to close and reopen the connection and retry + # one more time + logger.warning( + f"Error while trying to execute a command on host {self.host}:\n" + f"{''.join(traceback.format_exception(error))}" + "Probably due to the connection dropping. " + "Will reopen the connection and retry." + ) + try: + self.connection.close() + except Exception: + logger.warning( + "Error while closing the connection during a retry. " + "Proceeding with the retry.", + exc_info=True, + ) + self._create_connection() + self.connect() + return remote_cmd(*args, **kwargs) From 49f1450f75664cf6a02a621a5ca925313b1cac59 Mon Sep 17 00:00:00 2001 From: Matthew Evans Date: Tue, 11 Jul 2023 16:47:26 +0200 Subject: [PATCH 31/89] Enable `ConfigManager` warnings for `jf project check` --- src/jobflow_remote/cli/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jobflow_remote/cli/project.py b/src/jobflow_remote/cli/project.py index c1b9485d..9591c682 100644 --- a/src/jobflow_remote/cli/project.py +++ b/src/jobflow_remote/cli/project.py @@ -164,7 +164,7 @@ def check( """ check_incompatible_opt({"jobstore": jobstore, "queue": queue, "worker": worker}) - cm = ConfigManager() + cm = ConfigManager(warn=True) project = cm.get_project() check_all = all(not v for v in (jobstore, worker, queue)) From a6f8915af25465144c623808932e75a15f02a0e7 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Wed, 12 Jul 2023 09:30:49 +0200 Subject: [PATCH 32/89] add run time information --- src/jobflow_remote/cli/formatting.py | 15 +++++++++++- src/jobflow_remote/fireworks/launchpad.py | 11 ++++++++- src/jobflow_remote/jobs/data.py | 29 +++++++++++++++++++++++ src/jobflow_remote/jobs/runner.py | 17 +++++++++++-- 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/jobflow_remote/cli/formatting.py b/src/jobflow_remote/cli/formatting.py index ea45938b..36a2c646 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.state import JobState +from jobflow_remote.jobs.state import JobState, RemoteState from jobflow_remote.utils.data import remove_none @@ -26,6 +26,7 @@ def get_job_info_table(jobs_info: list[JobInfo], verbosity: int): if verbosity >= 1: table.add_column("Queue id") + table.add_column("Run time") table.add_column("Retry time") table.add_column("Prev state") if verbosity < 2: @@ -56,6 +57,18 @@ def get_job_info_table(jobs_info: list[JobInfo], verbosity: int): if verbosity >= 1: row.append(ji.queue_job_id) + prefix = "" + if ji.remote_state == RemoteState.RUNNING: + run_time = ji.estimated_run_time + prefix = "~" + else: + run_time = ji.run_time + if run_time: + m, s = divmod(run_time, 60) + h, m = divmod(m, 60) + row.append(prefix + f"{h:g}:{m:02g}") + else: + row.append("") row.append( ji.retry_time_limit.strftime(fmt_datetime) if ji.retry_time_limit diff --git a/src/jobflow_remote/fireworks/launchpad.py b/src/jobflow_remote/fireworks/launchpad.py index 59841386..c3b0d35b 100644 --- a/src/jobflow_remote/fireworks/launchpad.py +++ b/src/jobflow_remote/fireworks/launchpad.py @@ -50,6 +50,8 @@ class RemoteRun: 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) @@ -188,8 +190,15 @@ def recover_remote( ) 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"): @@ -265,7 +274,7 @@ def recover_remote( self.lpad.complete_launch(launch_id, m_action, "FIZZLED") completed = True - return m_launch.fw_id, completed + return m_launch, completed def add_wf(self, wf): return self.lpad.add_wf(wf) diff --git a/src/jobflow_remote/jobs/data.py b/src/jobflow_remote/jobs/data.py index ddf0d1ed..ae29d45e 100644 --- a/src/jobflow_remote/jobs/data.py +++ b/src/jobflow_remote/jobs/data.py @@ -40,6 +40,8 @@ class JobData: 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, } @@ -63,6 +65,8 @@ class JobInfo: 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 @classmethod def from_fw_dict(cls, d): @@ -113,6 +117,13 @@ def from_fw_dict(cls, d): # 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"], @@ -130,8 +141,26 @@ def from_fw_dict(cls, d): 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 run_time(self) -> float | None: + 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: + if self.start_time: + return ( + datetime.now(tz=self.start_time.tzinfo) - self.start_time + ).total_seconds() + + return None + flow_info_projection = { "fws.fw_id": 1, diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index 087d87a0..20fb71f0 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -476,7 +476,7 @@ def complete_launch(self, doc): # TODO add ping data? remote_store = get_remote_store(store, local_path) remote_store.connect() - fw_id, completed = self.rlpad.recover_remote( + launch, completed = self.rlpad.recover_remote( remote_status=remote_data, store=store, remote_store=remote_store, @@ -484,6 +484,13 @@ def complete_launch(self, doc): 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() @@ -497,7 +504,7 @@ def complete_launch(self, doc): err_msg = "the parsed output does not contain the required information to complete the job" return err_msg, True, None - return None, False, None + return None, False, set_output def check_run_status(self): logger.debug("check_run_status") @@ -549,11 +556,13 @@ def check_run_status(self): 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 ): next_state = RemoteState.RUNNING + start_time = datetime.utcnow() logger.debug( f"remote job with id {remote_doc['process_id']} is running" ) @@ -588,6 +597,10 @@ def check_run_status(self): else None } } + 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 ) From 059e2c82f9ea14f7d71989c1c03a4c8a9b641559 Mon Sep 17 00:00:00 2001 From: Matthew Evans Date: Wed, 12 Jul 2023 14:33:35 +0200 Subject: [PATCH 33/89] Handle case that date in fw dict is already a date --- src/jobflow_remote/jobs/data.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/jobflow_remote/jobs/data.py b/src/jobflow_remote/jobs/data.py index ddf0d1ed..1727cc2c 100644 --- a/src/jobflow_remote/jobs/data.py +++ b/src/jobflow_remote/jobs/data.py @@ -72,8 +72,10 @@ def from_fw_dict(cls, d): RemoteState(remote_state_val) if remote_state_val is not None else None ) state = JobState.from_states(d["state"], remote_state) - # in FW the date is encoded in a string - last_updated = datetime.fromisoformat(d["updated_on"]) + 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") From a3f796e6a2e6da39dfd17402b9c0bbfbc874b4e7 Mon Sep 17 00:00:00 2001 From: Matthew Evans Date: Wed, 12 Jul 2023 17:42:30 +0200 Subject: [PATCH 34/89] Use string datetime when pinging jobs --- src/jobflow_remote/jobs/runner.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index 77a1b0e6..bd795318 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -410,7 +410,6 @@ def submit(self, doc): 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 elif submit_result.status == SubmissionStatus.SUCCESSFUL: - set_output = { "$set": {f"{REMOTE_DOC_PATH}.process_id": str(submit_result.job_id)} } @@ -519,7 +518,6 @@ def check_run_status(self): workers_ids_docs[worker_name][remote_doc["process_id"]] = (doc, remote_doc) for worker_name, ids_docs in workers_ids_docs.items(): - error = None if not ids_docs: continue @@ -603,5 +601,5 @@ def cleanup(self): 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()}} + {"nodes": db_id}, {"$set": {"updated_on": datetime.utcnow().isoformat()}} ) From 9886e44c513aec363fa932e432b7b5c524def48b Mon Sep 17 00:00:00 2001 From: David Waroquiers Date: Mon, 17 Jul 2023 14:47:26 +0200 Subject: [PATCH 35/89] Added documentation structure. --- doc/Makefile | 72 ++++++ doc/source/_static/index-images/api.svg | 50 ++++ .../_static/index-images/contributor.svg | 13 ++ .../_static/index-images/getting_started.svg | 5 + .../_static/index-images/image_licences.txt | 4 + .../_static/index-images/user_guide.svg | 47 ++++ doc/source/_static/jobflow_remote.css | 162 +++++++++++++ doc/source/api/index.rst | 9 + doc/source/conf.py | 216 ++++++++++++++++++ doc/source/dev/index.rst | 7 + doc/source/glossary.rst | 14 ++ doc/source/index.rst | 105 +++++++++ doc/source/license.rst | 6 + doc/source/user/basics.rst | 12 + doc/source/user/building.rst | 7 + doc/source/user/index.rst | 30 +++ doc/source/user/install.rst | 21 ++ doc/source/user/quickstart.rst | 14 ++ doc/source/user/whatisjobflowremote.rst | 8 + pyproject.toml | 5 + 20 files changed, 807 insertions(+) create mode 100644 doc/Makefile create mode 100644 doc/source/_static/index-images/api.svg create mode 100644 doc/source/_static/index-images/contributor.svg create mode 100644 doc/source/_static/index-images/getting_started.svg create mode 100644 doc/source/_static/index-images/image_licences.txt create mode 100644 doc/source/_static/index-images/user_guide.svg create mode 100644 doc/source/_static/jobflow_remote.css create mode 100644 doc/source/api/index.rst create mode 100644 doc/source/conf.py create mode 100644 doc/source/dev/index.rst create mode 100644 doc/source/glossary.rst create mode 100644 doc/source/index.rst create mode 100644 doc/source/license.rst create mode 100644 doc/source/user/basics.rst create mode 100644 doc/source/user/building.rst create mode 100644 doc/source/user/index.rst create mode 100644 doc/source/user/install.rst create mode 100644 doc/source/user/quickstart.rst create mode 100644 doc/source/user/whatisjobflowremote.rst diff --git a/doc/Makefile b/doc/Makefile new file mode 100644 index 00000000..0f69cc1a --- /dev/null +++ b/doc/Makefile @@ -0,0 +1,72 @@ +# Makefile for Sphinx documentation +# + +# PYVER needs to be major.minor, just "3" doesn't work - it will result in +# issues with the amendments to PYTHONPATH and install paths (see DIST_VARS). + +# Use explicit "version_info" indexing since make cannot handle colon characters, and +# evaluate it now to allow easier debugging when printing the variable + +PYVER:=$(shell python3 -c 'from sys import version_info as v; print("{0}.{1}".format(v[0], v[1]))') +PYTHON = python$(PYVER) + +# You can set these variables from the command line. +SPHINXOPTS ?= +SPHINXBUILD ?= LANG=C sphinx-build +PAPER ?= +# # For merging a documentation archive into a git checkout of numpy/doc +# # Turn a tag like v1.18.0 into 1.18 +# # Use sed -n -e 's/patttern/match/p' to return a blank value if no match +# TAG ?= $(shell git describe --tag | sed -n -e's,v\([1-9]\.[0-9]*\)\.[0-9].*,\1,p') + +FILES= + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -WT --keep-going -d build/doctrees $(PAPEROPT_$(PAPER)) \ + $(SPHINXOPTS) source + +.PHONY: help clean html version-check html-build + +#------------------------------------------------------------------------------ + +help: + @echo "Please use \`make ' where is one of" + @echo " clean to remove generated doc files and start fresh" + @echo " html to make standalone HTML files" + +clean: + -rm -rf build/* + find . -name generated -type d -prune -exec rm -rf "{}" ";" + + +#------------------------------------------------------------------------------ +# Automated generation of all documents +#------------------------------------------------------------------------------ + +# Build the current QToolKit version, and extract docs from it. +# We have to be careful of some issues: +# +# - Everything must be done using the same Python version +# + +#SPHINXBUILD="LANG=C sphinx-build" + + +#------------------------------------------------------------------------------ +# Basic Sphinx generation rules for different formats +#------------------------------------------------------------------------------ +generate: build/generate-stamp +build/generate-stamp: $(wildcard source/reference/*.rst) + mkdir -p build + touch build/generate-stamp + +html: api-doc html-build +html-build: generate + mkdir -p build/html build/doctrees + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) build/html $(FILES) + @echo + @echo "Build finished. The HTML pages are in build/html." +api-doc: + sphinx-apidoc -e -f -o source/api ../src/jobflow_remote diff --git a/doc/source/_static/index-images/api.svg b/doc/source/_static/index-images/api.svg new file mode 100644 index 00000000..9c883972 --- /dev/null +++ b/doc/source/_static/index-images/api.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/doc/source/_static/index-images/contributor.svg b/doc/source/_static/index-images/contributor.svg new file mode 100644 index 00000000..ffd444ef --- /dev/null +++ b/doc/source/_static/index-images/contributor.svg @@ -0,0 +1,13 @@ + + + + + + + + \ No newline at end of file diff --git a/doc/source/_static/index-images/getting_started.svg b/doc/source/_static/index-images/getting_started.svg new file mode 100644 index 00000000..20747f94 --- /dev/null +++ b/doc/source/_static/index-images/getting_started.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/doc/source/_static/index-images/image_licences.txt b/doc/source/_static/index-images/image_licences.txt new file mode 100644 index 00000000..85276bc0 --- /dev/null +++ b/doc/source/_static/index-images/image_licences.txt @@ -0,0 +1,4 @@ +getting_started.svg: https://www.svgrepo.com/svg/393367/rocket (PD Licence) +user_guide.svg: https://www.svgrepo.com/svg/75531/user-guide (CC0 Licence) +api.svg: https://www.svgrepo.com/svg/157898/gears-configuration-tool (CC0 Licence) +contributor.svg: https://www.svgrepo.com/svg/57189/code-programing-symbol (CC0 Licence) \ No newline at end of file diff --git a/doc/source/_static/index-images/user_guide.svg b/doc/source/_static/index-images/user_guide.svg new file mode 100644 index 00000000..6223a92c --- /dev/null +++ b/doc/source/_static/index-images/user_guide.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/doc/source/_static/jobflow_remote.css b/doc/source/_static/jobflow_remote.css new file mode 100644 index 00000000..4561c96c --- /dev/null +++ b/doc/source/_static/jobflow_remote.css @@ -0,0 +1,162 @@ +@import url('https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,400;0,700;0,900;1,400;1,700;1,900&family=Open+Sans:ital,wght@0,400;0,600;1,400;1,600&display=swap'); + +:root { + --matgenix-color: #46b3c1; + --matgenix-dark-color: #338d99; +} + +.navbar-brand img { + height: 75px; +} +.navbar-brand { + height: 75px; +} + +body { + font-family: 'Open Sans', sans-serif; +} + +pre, code { + font-size: 100%; + line-height: 155%; +} + +h1 { + font-family: "Lato", sans-serif; + color: #013243; /* warm black */ +} + +h2 { + color: #4d77cf; /* han blue */ + letter-spacing: -.03em; +} + +h3 { + color: #013243; /* warm black */ + letter-spacing: -.03em; +} + +/* Style the active version button. + +- dev: orange +- stable: green +- old, PR: red + +Colors from: + +Wong, B. Points of view: Color blindness. +Nat Methods 8, 441 (2011). https://doi.org/10.1038/nmeth.1618 +*/ + +/* If the active version has the name "dev", style it orange */ +#version_switcher_button[data-active-version-name*="dev"] { + background-color: #E69F00; + border-color: #E69F00; + color:#000000; +} + +/* green for `stable` */ +#version_switcher_button[data-active-version-name*="stable"] { + background-color: #009E73; + border-color: #009E73; +} + +/* red for `old` */ +#version_switcher_button:not([data-active-version-name*="stable"], [data-active-version-name*="dev"], [data-active-version-name=""]) { + background-color: #980F0F; + border-color: #980F0F; +} + +/* Main page overview cards */ + +.sd-card { + background: #fff; + border-radius: 0; + padding: 30px 10px 20px 10px; + margin: 10px 0px; +} + +.sd-card .sd-card-header { + text-align: center; +} + +.sd-card .sd-card-header .sd-card-text { + margin: 0px; +} + +.sd-card .sd-card-img-top { + height: 52px; + width: 52px; + margin-left: auto; + margin-right: auto; +} + +.sd-card .sd-card-header { + border: none; + background-color: white; + color: #150458 !important; + font-size: var(--pst-font-size-h5); + font-weight: bold; + padding: 2.5rem 0rem 0.5rem 0rem; +} + +.sd-card .sd-card-footer { + border: none; + background-color: white; +} + +.sd-card .sd-card-footer .sd-card-text { + max-width: 220px; + margin-left: auto; + margin-right: auto; +} + +/* Announcements */ +.bd-header-announcement { + background-color: orange; +} + +/* Dark theme tweaking */ +html[data-theme=dark] .sd-card img[src*='.svg'] { + filter: invert(0.82) brightness(0.8) contrast(1.2); +} + +/* Main index page overview cards */ +html[data-theme=dark] .sd-card { + background-color:var(--pst-color-background); +} + +html[data-theme=dark] .sd-shadow-sm { + box-shadow: 0 .1rem 1rem rgba(250, 250, 250, .6) !important +} + +html[data-theme=dark] .sd-card .sd-card-header { + background-color:var(--pst-color-background); + color: #150458 !important; +} + +html[data-theme=dark] .sd-card .sd-card-footer { + background-color:var(--pst-color-background); +} + +html[data-theme=dark] .bd-header-announcement { + background-color: red; +} + +html[data-theme=dark] h1 { + color: var(--pst-color-primary); +} + +html[data-theme=dark] h3 { + color: #0a6774; +} + +.sd-btn-secondary { + background-color: var(--matgenix-color) !important; + border-color: var(--matgenix-color) !important; +} + +.sd-btn-secondary:hover, .sd-btn-secondary:focus { + background-color: var(--matgenix-dark-color) !important; + border-color: var(--matgenix-dark-color) !important; +} \ No newline at end of file diff --git a/doc/source/api/index.rst b/doc/source/api/index.rst new file mode 100644 index 00000000..b73bf979 --- /dev/null +++ b/doc/source/api/index.rst @@ -0,0 +1,9 @@ +.. _api: + +############# +API Reference +############# + +This is the API reference + +.. include:: jobflow_remote.rst \ No newline at end of file diff --git a/doc/source/conf.py b/doc/source/conf.py new file mode 100644 index 00000000..b680a104 --- /dev/null +++ b/doc/source/conf.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/master/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys + +# sys.path.insert(0, os.path.abspath('.')) +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +) + +# -- Project information ----------------------------------------------------- + +project = "Jobflow-Remote" +copyright = "2023, Matgenix SRL" +author = "Guido Petretto, David Waroquiers" + + +import jobflow_remote +# The short X.Y version +version = jobflow_remote.__version__ +# The full version, including alpha/beta/rc tags +release = jobflow_remote.__version__ + + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.todo", + "sphinx.ext.viewcode", + "sphinx.ext.napoleon", # For Google Python Style Guide + 'sphinx.ext.coverage', + 'sphinx.ext.doctest', + 'sphinx.ext.autosummary', + 'sphinx.ext.graphviz', + 'sphinx.ext.ifconfig', + 'matplotlib.sphinxext.plot_directive', + 'IPython.sphinxext.ipython_console_highlighting', + 'IPython.sphinxext.ipython_directive', + 'sphinx.ext.mathjax', + 'sphinx_design', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = ".rst" + +# The master toctree document. +master_doc = "index" + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = 'en' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path . +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "sphinx" + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +# html_theme = 'sphinx_book_theme' +html_theme = 'pydata_sphinx_theme' +# html_favicon = '_static/favicon/favicon.ico' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +html_theme_options = { + # "logo": { + # "image_light": "index-image/api.svg", + # "image_dark": "index-image/contributor.svg", + # }, + "collapse_navigation": True, + 'announcement': ( + "

" + "Jobflow-Remote is still in beta phase. The API may change at any time." + "

" + ), + # "announcement": "

This is still in development

", + # "navbar_end": ["theme-switcher", "navbar-icon-links"], + # "navbar_end": ["theme-switcher", "version-switcher", "navbar-icon-links"], +} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ["_static"] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + + +# -- Options for HTMLHelp output --------------------------------------------- + +html_css_files = ["jobflow_remote.css"] +html_title = "%s v%s Manual" % (project, version) +html_last_updated_fmt = '%b %d, %Y' +# html_css_files = ["numpy.css"] +html_context = {"default_mode": "light"} +html_use_modindex = True +html_copy_source = False +html_domain_indices = False +html_file_suffix = '.html' + +# Output file base name for HTML help builder. +htmlhelp_basename = "jobflow_remote_doc" + + +# # -- Options for LaTeX output ------------------------------------------------ +# +# latex_elements = { +# # The paper size ('letterpaper' or 'a4paper'). +# # +# # 'papersize': 'letterpaper', +# # The font size ('10pt', '11pt' or '12pt'). +# # +# # 'pointsize': '10pt', +# # Additional stuff for the LaTeX preamble. +# # +# # 'preamble': '', +# # Latex figure (float) alignment +# # +# # 'figure_align': 'htbp', +# } +# +# # Grouping the document tree into LaTeX files. List of tuples +# # (source start file, target name, title, +# # author, documentclass [howto, manual, or own class]). +# latex_documents = [ +# # (master_doc, "turbomoleio.tex", "turbomoleio Documentation", author, "manual"), +# ] + + +# # -- Options for manual page output ------------------------------------------ +# +# # One entry per manual page. List of tuples +# # (source start file, name, description, authors, manual section). +# man_pages = [(master_doc, "turbomoleio", "turbomoleio Documentation", [author], 1)] +# +# +# # -- Options for Texinfo output ---------------------------------------------- +# +# # Grouping the document tree into Texinfo files. List of tuples +# # (source start file, target name, title, author, +# # dir menu entry, description, category) +# texinfo_documents = [ +# ( +# master_doc, +# "turbomoleio", +# "turbomoleio Documentation", +# author, +# "turbomoleio", +# "One line description of project.", +# "Miscellaneous", +# ), +# ] + + +# -- Extension configuration ------------------------------------------------- + +# -- Options for intersphinx extension --------------------------------------- + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {"https://docs.python.org/": None} + +# -- Options for todo extension ---------------------------------------------- + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = True + +# To print the content of the docstring of the __init__ method as well. +autoclass_content = "both" diff --git a/doc/source/dev/index.rst b/doc/source/dev/index.rst new file mode 100644 index 00000000..88e18c08 --- /dev/null +++ b/doc/source/dev/index.rst @@ -0,0 +1,7 @@ +.. _devindex: + +############################## +Contributing to Jobflow-Remote +############################## + +Here are the things that can be done. \ No newline at end of file diff --git a/doc/source/glossary.rst b/doc/source/glossary.rst new file mode 100644 index 00000000..eff30732 --- /dev/null +++ b/doc/source/glossary.rst @@ -0,0 +1,14 @@ +******** +Glossary +******** + +.. glossary:: + + + Project + The description of a project and its configuration. + + + Worker + The description of a given resource where to execute flows. + diff --git a/doc/source/index.rst b/doc/source/index.rst new file mode 100644 index 00000000..609ce301 --- /dev/null +++ b/doc/source/index.rst @@ -0,0 +1,105 @@ +.. _jobflow_remote_docs_mainpage: + +############################ +Jobflow-Remote documentation +############################ + +.. toctree:: + :maxdepth: 1 + :hidden: + + User Guide + API reference + Development + release + + +**Version**: |version| + +**Useful links**: +TO BE ADDED + +Jobflow-Remote is a package to submit jobflow flows remotely. + + + +.. grid:: 1 2 2 2 + + .. grid-item-card:: + :img-top: ../source/_static/index-images/getting_started.svg + + Getting Started + ^^^^^^^^^^^^^^^ + + If you want to get started quickly, check out our quickstart section. + It contains an introduction to Jobflow-Remote's main concepts. + + +++ + + .. button-ref:: user/quickstart + :expand: + :color: secondary + :click-parent: + + Quickstart + + .. grid-item-card:: + :img-top: ../source/_static/index-images/user_guide.svg + + User Guide + ^^^^^^^^^^ + + The user guide provides in-depth information on the + key concepts of Jobflow-Remote with useful background information and explanation. + + +++ + + .. button-ref:: user + :expand: + :color: secondary + :click-parent: + + User Guide + + .. grid-item-card:: + :img-top: ../source/_static/index-images/api.svg + + API Reference + ^^^^^^^^^^^^^ + + The reference guide contains a detailed description of the functions, + modules, and objects included in Jobflow-Remote. The reference describes how the + methods work and which parameters can be used. It assumes that you have an + understanding of the key concepts. + + +++ + + .. button-ref:: api + :expand: + :color: secondary + :click-parent: + + API Reference + + .. grid-item-card:: + :img-top: ../source/_static/index-images/contributor.svg + + Contributor's Guide + ^^^^^^^^^^^^^^^^^^^ + + Want to add to the codebase? + The contributing guidelines will guide you through the + process of improving Jobflow-Remote. + + +++ + + .. button-ref:: devindex + :expand: + :color: secondary + :click-parent: + + To the contributor's guide + +.. This is not really the index page, that is found in + _templates/indexcontent.html The toctree content here will be added to the + top of the template header \ No newline at end of file diff --git a/doc/source/license.rst b/doc/source/license.rst new file mode 100644 index 00000000..3631a0d9 --- /dev/null +++ b/doc/source/license.rst @@ -0,0 +1,6 @@ +********************** +Jobflow-Remote license +********************** + +.. include:: ../../LICENSE + :literal: diff --git a/doc/source/user/basics.rst b/doc/source/user/basics.rst new file mode 100644 index 00000000..bfaa0b3c --- /dev/null +++ b/doc/source/user/basics.rst @@ -0,0 +1,12 @@ +*************************** +Jobflow-Remote fundamentals +*************************** + +These documents clarify concepts, design decisions, and technical +constraints in Jobflow-Remote. This is a great place to understand the +fundamental Jobflow-Remote ideas and philosophy. + +.. + .. toctree:: + :maxdepth: 1 + diff --git a/doc/source/user/building.rst b/doc/source/user/building.rst new file mode 100644 index 00000000..93af682e --- /dev/null +++ b/doc/source/user/building.rst @@ -0,0 +1,7 @@ +.. _building-from-source: + +Building from source +==================== + +Get the source from the git repository. +Install it with pip install . \ No newline at end of file diff --git a/doc/source/user/index.rst b/doc/source/user/index.rst new file mode 100644 index 00000000..84f12f1f --- /dev/null +++ b/doc/source/user/index.rst @@ -0,0 +1,30 @@ +.. _user: + +######################### +Jobflow-Remote user guide +######################### + +This guide is an overview and explains the important features; +details are found in :ref:`reference`. + +.. toctree:: + :caption: Getting started + :maxdepth: 1 + + whatisjobflowremote + install + quickstart + +.. toctree:: + :caption: Advanced usage and interoperability + :maxdepth: 1 + + building + + +.. toctree:: + :hidden: + :caption: Extras + + ../glossary + ../license diff --git a/doc/source/user/install.rst b/doc/source/user/install.rst new file mode 100644 index 00000000..c71429e1 --- /dev/null +++ b/doc/source/user/install.rst @@ -0,0 +1,21 @@ +.. _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. diff --git a/doc/source/user/quickstart.rst b/doc/source/user/quickstart.rst new file mode 100644 index 00000000..cb5fc0e1 --- /dev/null +++ b/doc/source/user/quickstart.rst @@ -0,0 +1,14 @@ +.. _quickstart: + +========================= +Jobflow-Remote quickstart +========================= + +Prerequisites +============= +You need python + +The Basics +========== + +Create an easy script diff --git a/doc/source/user/whatisjobflowremote.rst b/doc/source/user/whatisjobflowremote.rst new file mode 100644 index 00000000..093dee2e --- /dev/null +++ b/doc/source/user/whatisjobflowremote.rst @@ -0,0 +1,8 @@ +.. _whatisjobflowremote: + +======================= +What is Jobflow-Remote? +======================= + +Jobflow-Remote is ... +TODO: add the features that it has. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index f2a58b54..fb829e37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,11 @@ tests = [ maintain = [ "git-changelog>=0.6", ] +docs = [ + "sphinx", + "sphinx_design", + "pydata-sphinx-theme", +] strict = [] [project.scripts] From 99171049f963a45f0b2747601cb38e490107cd10 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Wed, 19 Jul 2023 11:28:46 +0200 Subject: [PATCH 36/89] cli message for global options --- src/jobflow_remote/cli/jf.py | 1 + src/jobflow_remote/cli/jfr_typer.py | 11 +++++++++++ src/jobflow_remote/cli/runner.py | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/jobflow_remote/cli/jf.py b/src/jobflow_remote/cli/jf.py index 4efe0259..c1a4033c 100644 --- a/src/jobflow_remote/cli/jf.py +++ b/src/jobflow_remote/cli/jf.py @@ -11,6 +11,7 @@ add_completion=False, no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"]}, + epilog=None, # to remove the default message in JFRTyper ) diff --git a/src/jobflow_remote/cli/jfr_typer.py b/src/jobflow_remote/cli/jfr_typer.py index b90f20a2..1fd9b966 100644 --- a/src/jobflow_remote/cli/jfr_typer.py +++ b/src/jobflow_remote/cli/jfr_typer.py @@ -11,6 +11,17 @@ class JFRTyper(typer.Typer): Subclassing typer to intercept exceptions and print nicer error messages """ + def __init__(self, *args, **kwargs): + if "epilog" not in kwargs: + kwargs[ + "epilog" + ] = "Run [bold]'jf -h'[/] to display the [bold]global options[/]" + + if "rich_markup_mode" not in kwargs: + kwargs["rich_markup_mode"] = "rich" + + super().__init__(*args, **kwargs) + def command( self, *args, **kwargs ) -> Callable[[CommandFunctionType], CommandFunctionType]: diff --git a/src/jobflow_remote/cli/runner.py b/src/jobflow_remote/cli/runner.py index 95298825..0bfbbb10 100644 --- a/src/jobflow_remote/cli/runner.py +++ b/src/jobflow_remote/cli/runner.py @@ -97,7 +97,7 @@ def stop( from jobflow_remote import SETTINGS if SETTINGS.cli_suggestions: - out_console( + out_console.print( "The stop signal has been sent to the Runner. Run 'jf runner status' to verify if it stopped", style="yellow", ) From c1d0b96e1033ff547a9591d7c5986814e6dc9189 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Mon, 24 Jul 2023 17:40:06 +0200 Subject: [PATCH 37/89] fix FlowInfo for string updated_on --- src/jobflow_remote/jobs/data.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/jobflow_remote/jobs/data.py b/src/jobflow_remote/jobs/data.py index b44def09..9d515502 100644 --- a/src/jobflow_remote/jobs/data.py +++ b/src/jobflow_remote/jobs/data.py @@ -193,7 +193,10 @@ class FlowInfo: @classmethod def from_query_dict(cls, d): # the dates should be in utc time. Convert them to the system time - last_updated = d["updated_on"].replace(tzinfo=timezone.utc).astimezone(tz=None) + 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 [] workers = [] From 2539e79f0d837a7907a4a77f47d21e7374b36a94 Mon Sep 17 00:00:00 2001 From: Matthew Evans Date: Thu, 3 Aug 2023 16:53:02 +0100 Subject: [PATCH 38/89] Move fallback worker config up to submit function --- src/jobflow_remote/config/manager.py | 8 ++------ src/jobflow_remote/jobs/submit.py | 9 +++++++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/jobflow_remote/config/manager.py b/src/jobflow_remote/config/manager.py index 750070e7..ed4f1254 100644 --- a/src/jobflow_remote/config/manager.py +++ b/src/jobflow_remote/config/manager.py @@ -330,7 +330,7 @@ def remove_worker(self, worker_name: str, project_name: str | None = None): self.dump_project(project_data) def get_worker( - self, worker_name: str | None = None, project_name: str | None = None + self, worker_name: str, project_name: str | None = None ) -> WorkerBase: """ Return the worker object based on the name. @@ -338,8 +338,7 @@ def get_worker( Parameters ---------- worker_name - Name of the worker to retrieve, or None to use the first one listed in the - project. + Name of the worker to retrieve. project_name Name of the project from which the Worker should be retrieved, or None to use the one from the settings. @@ -348,9 +347,6 @@ def get_worker( The selected Worker. """ project = self.get_project(project_name) - if not worker_name: - worker_name = next(iter(project.workers.keys())) - if worker_name not in project.workers: raise ConfigError(f"Worker with name {worker_name} is not defined") return project.workers[worker_name] diff --git a/src/jobflow_remote/jobs/submit.py b/src/jobflow_remote/jobs/submit.py index 2d5425e9..dbfe6d9f 100644 --- a/src/jobflow_remote/jobs/submit.py +++ b/src/jobflow_remote/jobs/submit.py @@ -3,7 +3,7 @@ import jobflow from qtoolkit.core.data_objects import QResources -from jobflow_remote.config.base import ExecutionConfig +from jobflow_remote.config.base import ConfigError, ExecutionConfig from jobflow_remote.config.manager import ConfigManager from jobflow_remote.fireworks.convert import flow_to_workflow @@ -27,7 +27,8 @@ def submit_flow( flow A flow or job. worker - The name of the Worker where the calculation will be submitted + 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 @@ -46,6 +47,10 @@ def submit_flow( config_manager = ConfigManager() proj_obj = config_manager.get_project(project) + if worker is None: + if not proj_obj.workers: + raise ConfigError("No workers configured for this project.") + worker = next(iter(proj_obj.workers.keys())) # try to load the worker and exec_config to check that the values are well defined config_manager.get_worker(worker_name=worker, project_name=project) From 52eb7955868b2c58b4d5db4c3956edaafdba44c2 Mon Sep 17 00:00:00 2001 From: Matthew Evans Date: Thu, 3 Aug 2023 17:00:36 +0100 Subject: [PATCH 39/89] Do not allow null workers when constructing a firework --- src/jobflow_remote/fireworks/convert.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/jobflow_remote/fireworks/convert.py b/src/jobflow_remote/fireworks/convert.py index 1f03fcbf..eccc9474 100644 --- a/src/jobflow_remote/fireworks/convert.py +++ b/src/jobflow_remote/fireworks/convert.py @@ -5,7 +5,7 @@ from fireworks import Firework, Workflow from qtoolkit.core.data_objects import QResources -from jobflow_remote.config.base import ExecutionConfig +from jobflow_remote.config.base import ConfigError, ExecutionConfig from jobflow_remote.fireworks.tasks import RemoteJobFiretask if typing.TYPE_CHECKING: @@ -66,6 +66,9 @@ def flow_to_workflow( parent_mapping: dict[str, Firework] = {} fireworks = [] + if not worker: + raise ConfigError("Worker name must be set.") + flow = get_flow(flow) for job, parents in flow.iterflow(): From 808ca4a6ff9dae2c8d7d0840458ac4d2dae6f8c2 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Mon, 14 Aug 2023 12:34:23 +0200 Subject: [PATCH 40/89] improve CLI, include allow_external_references --- src/jobflow_remote/cli/admin.py | 15 ++++++++++++--- src/jobflow_remote/fireworks/convert.py | 6 +++++- src/jobflow_remote/jobs/submit.py | 11 ++++++++++- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/jobflow_remote/cli/admin.py b/src/jobflow_remote/cli/admin.py index ac8fc790..0f716c81 100644 --- a/src/jobflow_remote/cli/admin.py +++ b/src/jobflow_remote/cli/admin.py @@ -46,7 +46,7 @@ def reset( "--max-limit", "-max", help=( - "The database will be reset only if the number of Jobs is lower than the specified limit. 0 means no limit" + "The database will be reset only if the number of Flows is lower than the specified limit. 0 means no limit" ), ), ] = 25, @@ -56,6 +56,8 @@ def reset( Reset the jobflow database. WARNING: deletes all the data. These could not be retrieved anymore. """ + from jobflow_remote import SETTINGS + dm = DaemonManager() try: @@ -78,7 +80,8 @@ def reset( cm = ConfigManager() project_name = cm.get_project_data().project.name text = Text.from_markup( - f"[red]This operation will [bold]delete all the Jobs data[/bold] for project [bold]{project_name}[/bold]. Proceed anyway?[/red]" + "[red]This operation will [bold]delete all the Jobs data[/bold] " + f"for project [bold]{project_name}[/bold]. Proceed anyway?[/red]" ) confirmed = Confirm.ask(text, default=False) @@ -88,7 +91,13 @@ def reset( progress.add_task(description="Resetting the DB...", total=None) jc = JobController() done = jc.reset(reset_output=reset_output, max_limit=max_limit) - out_console.print(f"The database was {'' if done else 'NOT '}reset") + not_text = "" if done else "[bold]NOT [/bold]" + out_console.print(f"The database was {not_text}reset") + if not done and SETTINGS.cli_suggestions: + out_console.print( + "Check the amount of Flows and change --max-limit if this is the correct project to reset", + style="yellow", + ) @app_admin.command() diff --git a/src/jobflow_remote/fireworks/convert.py b/src/jobflow_remote/fireworks/convert.py index eccc9474..527d97b4 100644 --- a/src/jobflow_remote/fireworks/convert.py +++ b/src/jobflow_remote/fireworks/convert.py @@ -23,6 +23,7 @@ def flow_to_workflow( exec_config: str | ExecutionConfig = None, resources: dict | QResources | None = None, metadata: dict | None = None, + allow_external_references: bool = False, **kwargs, ) -> Workflow: """ @@ -52,6 +53,9 @@ def flow_to_workflow( 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. @@ -69,7 +73,7 @@ def flow_to_workflow( if not worker: raise ConfigError("Worker name must be set.") - flow = get_flow(flow) + flow = get_flow(flow, allow_external_references=allow_external_references) for job, parents in flow.iterflow(): fw = job_to_firework( diff --git a/src/jobflow_remote/jobs/submit.py b/src/jobflow_remote/jobs/submit.py index dbfe6d9f..1efc8718 100644 --- a/src/jobflow_remote/jobs/submit.py +++ b/src/jobflow_remote/jobs/submit.py @@ -15,6 +15,7 @@ def submit_flow( project: str | None = None, exec_config: str | ExecutionConfig | None = None, resources: dict | QResources | None = None, + allow_external_references: bool = False, ): """ Submit a flow for calculation to the selected Worker. @@ -43,6 +44,9 @@ def submit_flow( resources: Dict or QResources information passed to qtoolkit to require the resources for the submission to the queue. + allow_external_references + If False all the references to other outputs should be from other Jobs + of the Flow. """ config_manager = ConfigManager() @@ -60,7 +64,12 @@ def submit_flow( ) wf = flow_to_workflow( - flow, worker=worker, store=store, exec_config=exec_config, resources=resources + flow, + worker=worker, + store=store, + exec_config=exec_config, + resources=resources, + allow_external_references=allow_external_references, ) rlpad = proj_obj.get_launchpad() From 8d32d9f680c98a2db967fdc93e66255305da92eb Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Wed, 16 Aug 2023 17:12:08 +0200 Subject: [PATCH 41/89] disable job_ids options in the CLI --- src/jobflow_remote/cli/formatting.py | 4 +- src/jobflow_remote/cli/job.py | 66 ++++++++++++----------- src/jobflow_remote/cli/types.py | 4 ++ src/jobflow_remote/fireworks/launchpad.py | 10 +++- src/jobflow_remote/jobs/data.py | 9 ++++ 5 files changed, 59 insertions(+), 34 deletions(-) diff --git a/src/jobflow_remote/cli/formatting.py b/src/jobflow_remote/cli/formatting.py index 36a2c646..ab73086a 100644 --- a/src/jobflow_remote/cli/formatting.py +++ b/src/jobflow_remote/cli/formatting.py @@ -19,7 +19,7 @@ def get_job_info_table(jobs_info: list[JobInfo], verbosity: int): table.add_column("DB id") table.add_column("Name") table.add_column("State [Remote]") - table.add_column("Job id") + table.add_column("Job id (Index)") table.add_column("Worker") table.add_column("Last updated") @@ -50,7 +50,7 @@ def get_job_info_table(jobs_info: list[JobInfo], verbosity: int): str(ji.db_id), ji.name, Text.from_markup(state), - ji.job_id, + f"{ji.job_id} ({ji.job_index})", ji.worker, ji.last_updated.strftime(fmt_datetime), ] diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index dd5ed5ee..a262d406 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -11,10 +11,9 @@ from jobflow_remote.cli.jfr_typer import JFRTyper from jobflow_remote.cli.types import ( days_opt, - db_id_flag_opt, + db_id_arg, db_ids_opt, end_date_opt, - job_id_arg, job_ids_opt, job_state_opt, locked_opt, @@ -32,7 +31,6 @@ check_incompatible_opt, exit_with_error_msg, exit_with_warning_msg, - get_job_db_ids, loading_spinner, out_console, print_success_msg, @@ -118,8 +116,9 @@ def jobs_list( @app_job.command(name="info") def job_info( - job_id: job_id_arg, - db_id: db_id_flag_opt = False, + db_id: db_id_arg, + # job_id: job_id_arg, + # db_id: db_id_flag_opt = False, with_error: Annotated[ bool, typer.Option( @@ -145,11 +144,11 @@ def job_info( jc = JobController() - db_id_value, job_id_value = get_job_db_ids(db_id, job_id) + # db_id_value, job_id_value = get_job_db_ids(db_id, job_id) job_info = jc.get_job_info( - job_id=job_id_value, - db_id=db_id_value, + job_id=None, + db_id=db_id, full=with_error, ) if not job_info: @@ -160,8 +159,9 @@ def job_info( @app_job.command() def reset_failed( - job_id: job_id_arg, - db_id: db_id_flag_opt = False, + db_id: db_id_arg, + # job_id: job_id_arg, + # db_id: db_id_flag_opt = False, ): """ For a job with a FAILED remote state reset it to the previous state @@ -169,11 +169,11 @@ def reset_failed( with loading_spinner(): jc = JobController() - db_id_value, job_id_value = get_job_db_ids(db_id, job_id) + # db_id_value, job_id_value = get_job_db_ids(db_id, job_id) succeeded = jc.reset_failed_state( - job_id=job_id_value, - db_id=db_id_value, + job_id=None, + db_id=db_id, ) if not succeeded: @@ -184,8 +184,9 @@ def reset_failed( @app_job.command() def reset_remote_attempts( - job_id: job_id_arg, - db_id: db_id_flag_opt = False, + db_id: db_id_arg, + # job_id: job_id_arg, + # db_id: db_id_flag_opt = False, ): """ Resets the number of attempts to perform a remote action and eliminates @@ -194,11 +195,11 @@ def reset_remote_attempts( with loading_spinner(): jc = JobController() - db_id_value, job_id_value = get_job_db_ids(db_id, job_id) + # db_id_value, job_id_value = get_job_db_ids(db_id, job_id) succeeded = jc.reset_remote_attempts( - job_id=job_id_value, - db_id=db_id_value, + job_id=None, + db_id=db_id, ) if not succeeded: @@ -209,9 +210,10 @@ def reset_remote_attempts( @app_job.command() def set_remote_state( - job_id: job_id_arg, + db_id: db_id_arg, state: remote_state_arg, - db_id: db_id_flag_opt = False, + # job_id: job_id_arg, + # db_id: db_id_flag_opt = False, ): """ Sets the remote state to an arbitrary value. @@ -220,12 +222,12 @@ def set_remote_state( with loading_spinner(): jc = JobController() - db_id_value, job_id_value = get_job_db_ids(db_id, job_id) + # db_id_value, job_id_value = get_job_db_ids(db_id, job_id) succeeded = jc.set_remote_state( state=state, - job_id=job_id_value, - db_id=db_id_value, + job_id=None, + db_id=db_id, ) if not succeeded: @@ -236,7 +238,7 @@ def set_remote_state( @app_job.command() def rerun( - job_id: job_ids_opt = None, + # job_id: job_ids_opt = None, db_id: db_ids_opt = None, state: job_state_opt = None, remote_state: remote_state_opt = None, @@ -252,7 +254,7 @@ def rerun( with loading_spinner(): fw_ids = jc.rerun_jobs( - job_ids=job_id, + # job_ids=job_id, db_ids=db_id, state=state, remote_state=remote_state, @@ -265,18 +267,22 @@ def rerun( @app_job.command() def queue_out( - job_id: job_id_arg, - db_id: db_id_flag_opt = False, + db_id: db_id_arg, + # job_id: job_id_arg, + # db_id: db_id_flag_opt = False, ): + """ + Print the content of the output files produced by the queue manager. + """ with loading_spinner(processing=False) as progress: progress.add_task(description="Retrieving info...", total=None) jc = JobController() - db_id_value, job_id_value = get_job_db_ids(db_id, job_id) + # db_id_value, job_id_value = get_job_db_ids(db_id, job_id) job_data_list = jc.get_jobs_data( - job_ids=job_id_value, - db_ids=db_id_value, + job_ids=None, + db_ids=db_id, ) if not job_data_list: diff --git a/src/jobflow_remote/cli/types.py b/src/jobflow_remote/cli/types.py index 33d2eccc..9f8d4c75 100644 --- a/src/jobflow_remote/cli/types.py +++ b/src/jobflow_remote/cli/types.py @@ -152,6 +152,10 @@ job_id_arg = Annotated[str, typer.Argument(help="The ID of the job (i.e. the uuid)")] +db_id_arg = Annotated[ + int, typer.Argument(help="The DB id of the job (i.e. an integer)") +] + db_id_flag_opt = Annotated[ bool, diff --git a/src/jobflow_remote/fireworks/launchpad.py b/src/jobflow_remote/fireworks/launchpad.py index c3b0d35b..1ca03bef 100644 --- a/src/jobflow_remote/fireworks/launchpad.py +++ b/src/jobflow_remote/fireworks/launchpad.py @@ -10,7 +10,7 @@ from fireworks.utilities.fw_serializers import reconstitute_dates, recursive_dict from maggma.core import Store from maggma.stores import MongoStore -from pymongo import ASCENDING +from pymongo import ASCENDING, DESCENDING from qtoolkit.core.data_objects import QState from jobflow_remote.jobs.state import RemoteState @@ -22,6 +22,7 @@ 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}" @@ -123,7 +124,12 @@ def launches(self): 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, unique=True, background=True) + 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): """ diff --git a/src/jobflow_remote/jobs/data.py b/src/jobflow_remote/jobs/data.py index 9d515502..274ff736 100644 --- a/src/jobflow_remote/jobs/data.py +++ b/src/jobflow_remote/jobs/data.py @@ -6,6 +6,7 @@ from jobflow import Job, JobStore from jobflow_remote.fireworks.launchpad import ( + FW_INDEX_PATH, FW_UUID_PATH, REMOTE_DOC_PATH, get_job_doc, @@ -29,6 +30,7 @@ class JobData: job_info_projection = { "fw_id": 1, FW_UUID_PATH: 1, + FW_INDEX_PATH: 1, "state": 1, f"{REMOTE_DOC_PATH}.state": 1, "name": 1, @@ -51,6 +53,7 @@ class JobData: class JobInfo: db_id: int job_id: str + job_index: int state: JobState name: str last_updated: datetime @@ -129,6 +132,7 @@ def from_fw_dict(cls, d): 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, @@ -167,6 +171,7 @@ def estimated_run_time(self) -> float | None: 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, @@ -182,6 +187,7 @@ def estimated_run_time(self) -> float | None: class FlowInfo: db_ids: list[int] job_ids: list[str] + job_indexes: list[int] flow_id: str state: FlowState name: str @@ -204,11 +210,13 @@ def from_query_dict(cls, d): 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"]) @@ -223,6 +231,7 @@ def from_query_dict(cls, d): return cls( db_ids=db_ids, job_ids=job_ids, + job_indexes=job_indexes, flow_id=flow_id, state=state, name=d["name"], From 87c0753b70a33becac44026fdd8f6c633723ea48 Mon Sep 17 00:00:00 2001 From: Matthew Evans Date: Mon, 14 Aug 2023 11:53:55 +0100 Subject: [PATCH 42/89] Add work_dir checker to project check CLI - Delete canary file after use in project check --- src/jobflow_remote/config/base.py | 4 ++-- src/jobflow_remote/config/helper.py | 35 ++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/jobflow_remote/config/base.py b/src/jobflow_remote/config/base.py index edaba3f9..ad9a8d35 100644 --- a/src/jobflow_remote/config/base.py +++ b/src/jobflow_remote/config/base.py @@ -111,8 +111,8 @@ class WorkerBase(BaseModel): scheduler_type: str = Field( description="Type of the scheduler. Depending on the values supported by QToolKit" ) - work_dir: str = Field( - description="Path to the directory of the worker where subfolders for " + work_dir: Path = Field( + description="Absolute path of the directory of the worker where subfolders for " "executing the calculation will be created" ) resources: dict | None = Field( diff --git a/src/jobflow_remote/config/helper.py b/src/jobflow_remote/config/helper.py index d08e7869..a7e24c9a 100644 --- a/src/jobflow_remote/config/helper.py +++ b/src/jobflow_remote/config/helper.py @@ -2,6 +2,7 @@ import logging import traceback +from pathlib import Path from jobflow import JobStore from maggma.core import Store @@ -13,12 +14,12 @@ RemoteWorker, WorkerBase, ) +from jobflow_remote.remote.host import BaseHost logger = logging.getLogger(__name__) def generate_dummy_project(name: str, full: bool = False) -> Project: - remote_worker = generate_dummy_worker(scheduler_type="slurm", host_type="remote") workers = {"example_worker": remote_worker} exec_config = {} @@ -114,7 +115,36 @@ def generate_dummy_queue() -> dict: return lp_config +def _check_workdir(worker: WorkerBase, host: BaseHost) -> str | None: + """Check that the configured workdir exists or is writable on the worker. + + Parameters: + worker: The worker configuration. + host: A connected host. + + """ + try: + host_error = host.test() + if host_error: + return host_error + except Exception: + exc = traceback.format_exc() + return f"Error while testing worker:\n {exc}" + + try: + canary_file = worker.work_dir / ".jf_heartbeat" + host.write_text_file(canary_file, "\n") + except FileNotFoundError as exc: + raise FileNotFoundError( + f"Could not write to {canary_file} on {worker.host}. Does the folder exist on the remote?\nThe folder should be specified as an absolute path with no shell expansions or environment variables." + ) from exc + finally: + # Must be enclosed in quotes with '!r' as the path may contain spaces + host.execute(f"rm {str(canary_file)!r}") + + def check_worker(worker: WorkerBase) -> str | None: + """Check that a connection to the configured worker can be made.""" host = worker.get_host() try: host.connect() @@ -126,6 +156,9 @@ def check_worker(worker: WorkerBase) -> str | None: qm = QueueManager(scheduler_io=worker.get_scheduler_io(), host=host) qm.get_jobs_list() + + _check_workdir(worker=worker, host=host) + except Exception: exc = traceback.format_exc() return f"Error while testing worker:\n {exc}" From 54766902826335395bd811c879ed79b04c0c282c Mon Sep 17 00:00:00 2001 From: Matthew Evans Date: Mon, 14 Aug 2023 15:27:56 +0100 Subject: [PATCH 43/89] Use `Path` for remote operations to deal with spaces in dir names --- src/jobflow_remote/remote/host/remote.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/jobflow_remote/remote/host/remote.py b/src/jobflow_remote/remote/host/remote.py index cb9b1007..5a225673 100644 --- a/src/jobflow_remote/remote/host/remote.py +++ b/src/jobflow_remote/remote/host/remote.py @@ -107,8 +107,9 @@ def execute( # TODO: check if this works: if not workdir: workdir = "." - else: - workdir = str(workdir) + + workdir = Path(workdir) + timeout = timeout or self.timeout_execute if self.shell_cmd: @@ -135,10 +136,8 @@ def mkdir( self, directory: str | Path, recursive: bool = True, exist_ok: bool = True ) -> bool: """Create directory on the host.""" - command = "mkdir " - if recursive: - command += "-p " - command += str(directory) + directory = Path(directory) + command = f"mkdir {'-p ' if recursive else ''}{str(directory)!r}" try: stdout, stderr, returncode = self.execute(command) if returncode != 0: From d1298efa0837f798c3c2d3a9f670f126a40f64be Mon Sep 17 00:00:00 2001 From: Matthew Evans Date: Mon, 14 Aug 2023 15:32:01 +0100 Subject: [PATCH 44/89] Add validator for absoluteness of `work_dir` --- src/jobflow_remote/config/base.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/jobflow_remote/config/base.py b/src/jobflow_remote/config/base.py index ad9a8d35..0317a61c 100644 --- a/src/jobflow_remote/config/base.py +++ b/src/jobflow_remote/config/base.py @@ -146,6 +146,12 @@ 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) + def check_work_dir(cls, v) -> Path: + if not v.is_absolute(): + raise ValueError("`work_dir` must be an absolute path") + return v + def get_scheduler_io(self) -> BaseSchedulerIO: """ Get the BaseSchedulerIO from QToolKit depending on scheduler_type. From a325d6da8c7030b4947f328607b376c87bb473a2 Mon Sep 17 00:00:00 2001 From: Matthew Evans Date: Wed, 16 Aug 2023 18:45:48 +0100 Subject: [PATCH 45/89] Format `error_job` the same way as `error_remote` --- src/jobflow_remote/cli/formatting.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/jobflow_remote/cli/formatting.py b/src/jobflow_remote/cli/formatting.py index ab73086a..c0928cf4 100644 --- a/src/jobflow_remote/cli/formatting.py +++ b/src/jobflow_remote/cli/formatting.py @@ -40,7 +40,6 @@ def get_job_info_table(jobs_info: list[JobInfo], verbosity: int): 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: @@ -136,6 +135,9 @@ def format_job_info(job_info: JobInfo, show_none: bool = False): 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) return render_scope(d) From ae9d83eebe7121b3f2cddd44de0347912eabe184 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Sun, 20 Aug 2023 01:05:12 +0200 Subject: [PATCH 46/89] restore queries based on job id --- src/jobflow_remote/cli/admin.py | 11 +- src/jobflow_remote/cli/job.py | 103 +++++++++-------- src/jobflow_remote/cli/types.py | 40 ++++--- src/jobflow_remote/cli/utils.py | 47 +++++--- src/jobflow_remote/fireworks/launchpad.py | 135 ++++++++++++++-------- src/jobflow_remote/jobs/jobcontroller.py | 77 +++++++----- 6 files changed, 257 insertions(+), 156 deletions(-) diff --git a/src/jobflow_remote/cli/admin.py b/src/jobflow_remote/cli/admin.py index 0f716c81..cd7ad785 100644 --- a/src/jobflow_remote/cli/admin.py +++ b/src/jobflow_remote/cli/admin.py @@ -9,7 +9,7 @@ db_ids_opt, end_date_opt, force_opt, - job_ids_opt, + job_ids_indexes_opt, job_state_opt, remote_state_opt, start_date_opt, @@ -17,6 +17,7 @@ from jobflow_remote.cli.utils import ( check_incompatible_opt, exit_with_error_msg, + get_job_ids_indexes, loading_spinner, out_console, ) @@ -102,7 +103,7 @@ def reset( @app_admin.command() def remove_lock( - job_id: job_ids_opt = None, + job_id: job_ids_indexes_opt = None, db_id: db_ids_opt = None, state: job_state_opt = None, remote_state: remote_state_opt = None, @@ -116,6 +117,8 @@ def remove_lock( """ check_incompatible_opt({"state": state, "remote-state": remote_state}) + job_ids_indexes = get_job_ids_indexes(job_id) + jc = JobController() if not force: with loading_spinner(False) as progress: @@ -124,7 +127,7 @@ def remove_lock( ) jobs_info = jc.get_jobs_info( - job_ids=job_id, + job_ids=job_ids_indexes, db_ids=db_id, state=state, remote_state=remote_state, @@ -133,7 +136,7 @@ def remove_lock( end_date=end_date, ) - text = Text( + text = Text.from_markup( f"[red]This operation will [bold]remove the lock[/bold] for (roughly) [bold]{len(jobs_info)} Job(s)[/bold]. Proceed anyway?[/red]" ) confirmed = Confirm.ask(text, default=False) diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index a262d406..60b4f509 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -11,10 +11,11 @@ from jobflow_remote.cli.jfr_typer import JFRTyper from jobflow_remote.cli.types import ( days_opt, - db_id_arg, db_ids_opt, end_date_opt, - job_ids_opt, + job_db_id_arg, + job_ids_indexes_opt, + job_index_arg, job_state_opt, locked_opt, max_results_opt, @@ -31,6 +32,8 @@ check_incompatible_opt, exit_with_error_msg, exit_with_warning_msg, + get_job_db_ids, + get_job_ids_indexes, loading_spinner, out_console, print_success_msg, @@ -48,7 +51,7 @@ @app_job.command(name="list") def jobs_list( - job_id: job_ids_opt = None, + job_id: job_ids_indexes_opt = None, db_id: db_ids_opt = None, state: job_state_opt = None, remote_state: remote_state_opt = None, @@ -69,6 +72,8 @@ def jobs_list( check_incompatible_opt({"start_date": start_date, "days": days}) check_incompatible_opt({"end_date": end_date, "days": days}) + job_ids_indexes = get_job_ids_indexes(job_id) + jc = JobController() if days: @@ -85,7 +90,7 @@ def jobs_list( ) else: jobs_info = jc.get_jobs_info( - job_ids=job_id, + job_ids=job_ids_indexes, db_ids=db_id, state=state, remote_state=remote_state, @@ -116,9 +121,8 @@ def jobs_list( @app_job.command(name="info") def job_info( - db_id: db_id_arg, - # job_id: job_id_arg, - # db_id: db_id_flag_opt = False, + job_db_id: job_db_id_arg, + job_index: job_index_arg = None, with_error: Annotated[ bool, typer.Option( @@ -140,14 +144,15 @@ def job_info( Detail information on a specific job """ + db_id, job_id = get_job_db_ids(job_db_id, job_index) + with loading_spinner(): jc = JobController() - # db_id_value, job_id_value = get_job_db_ids(db_id, job_id) - job_info = jc.get_job_info( - job_id=None, + job_id=job_id, + job_index=job_index, db_id=db_id, full=with_error, ) @@ -159,20 +164,21 @@ def job_info( @app_job.command() def reset_failed( - db_id: db_id_arg, - # job_id: job_id_arg, - # db_id: db_id_flag_opt = False, + 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 """ + + db_id, job_id = get_job_db_ids(job_db_id, job_index) + with loading_spinner(): jc = JobController() - # db_id_value, job_id_value = get_job_db_ids(db_id, job_id) - succeeded = jc.reset_failed_state( - job_id=None, + job_id=job_id, + job_index=job_index, db_id=db_id, ) @@ -184,21 +190,22 @@ def reset_failed( @app_job.command() def reset_remote_attempts( - db_id: db_id_arg, - # job_id: job_id_arg, - # db_id: db_id_flag_opt = False, + job_db_id: job_db_id_arg, + job_index: job_index_arg = None, ): """ Resets the number of attempts to perform a remote action and eliminates the delay in retrying. This will not restore a Jon from its failed state. """ + + db_id, job_id = get_job_db_ids(job_db_id, job_index) + with loading_spinner(): jc = JobController() - # db_id_value, job_id_value = get_job_db_ids(db_id, job_id) - succeeded = jc.reset_remote_attempts( - job_id=None, + job_id=job_id, + job_index=job_index, db_id=db_id, ) @@ -210,23 +217,24 @@ def reset_remote_attempts( @app_job.command() def set_remote_state( - db_id: db_id_arg, state: remote_state_arg, - # job_id: job_id_arg, - # db_id: db_id_flag_opt = False, + job_db_id: job_db_id_arg, + job_index: job_index_arg = None, ): """ Sets the remote state to an arbitrary value. WARNING: 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() - # db_id_value, job_id_value = get_job_db_ids(db_id, job_id) - succeeded = jc.set_remote_state( state=state, - job_id=None, + job_id=job_id, + job_index=job_index, db_id=db_id, ) @@ -238,7 +246,7 @@ def set_remote_state( @app_job.command() def rerun( - # job_id: job_ids_opt = None, + job_id: job_ids_indexes_opt = None, db_id: db_ids_opt = None, state: job_state_opt = None, remote_state: remote_state_opt = None, @@ -250,11 +258,13 @@ def rerun( """ check_incompatible_opt({"state": state, "remote-state": remote_state}) + job_ids_indexes = get_job_ids_indexes(job_id) + jc = JobController() with loading_spinner(): fw_ids = jc.rerun_jobs( - # job_ids=job_id, + job_ids=job_ids_indexes, db_ids=db_id, state=state, remote_state=remote_state, @@ -267,42 +277,43 @@ def rerun( @app_job.command() def queue_out( - db_id: db_id_arg, - # job_id: job_id_arg, - # db_id: db_id_flag_opt = False, + job_db_id: job_db_id_arg, + job_index: job_index_arg = None, ): """ Print the content of the output files produced by the queue manager. """ + + db_id, job_id = get_job_db_ids(job_db_id, job_index) + with loading_spinner(processing=False) as progress: progress.add_task(description="Retrieving info...", total=None) jc = JobController() - # db_id_value, job_id_value = get_job_db_ids(db_id, job_id) - - job_data_list = jc.get_jobs_data( - job_ids=None, - db_ids=db_id, + job_info = jc.get_job_info( + job_id=job_id, + job_index=job_index, + db_id=db_id, ) - if not job_data_list: + if not job_info: exit_with_error_msg("No data matching the request") - job_data = job_data_list[0] - info = job_data.info - if info.remote_state not in ( + if job_info.remote_state not in ( RemoteState.RUNNING, RemoteState.TERMINATED, RemoteState.DOWNLOADED, RemoteState.COMPLETED, RemoteState.FAILED, ): - remote_state_str = f"[{info.remote_state.value}]" if info.remote_state else "" + 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 {info.state.value}{remote_state_str} and the queue output will not be present" + f"The Job is in state {job_info.state.value}{remote_state_str} and the queue output will not be present" ) - remote_dir = info.run_dir + remote_dir = job_info.run_dir out_path = Path(remote_dir, OUT_FNAME) err_path = Path(remote_dir, ERR_FNAME) @@ -313,7 +324,7 @@ def queue_out( with loading_spinner(processing=False) as progress: progress.add_task(description="Retrieving files...", total=None) cm = ConfigManager() - worker = cm.get_worker(info.worker) + worker = cm.get_worker(job_info.worker) host = worker.get_host() try: diff --git a/src/jobflow_remote/cli/types.py b/src/jobflow_remote/cli/types.py index 9f8d4c75..291121cd 100644 --- a/src/jobflow_remote/cli/types.py +++ b/src/jobflow_remote/cli/types.py @@ -10,12 +10,25 @@ from jobflow_remote.config.base import LogLevel from jobflow_remote.jobs.state import JobState, RemoteState +job_ids_indexes_opt = Annotated[ + Optional[List[str]], + typer.Option( + "--job-id", + "-jid", + help="One or more pair of job ids (i.e. uuids) and job index formatted " + "as UUID:INDEX (e.g. e1d66c4f-81db-4fff-bda2-2bf1d79d5961:2). " + "The index is mandatory", + ), +] + + job_ids_opt = Annotated[ Optional[List[str]], typer.Option( "--job-id", "-jid", - help="One or more job ids (i.e. uuids)", + help="One or more job ids (i.e. uuids). Only the id is needed since " + "jobs with the same uuid belong to the same flow", ), ] @@ -150,21 +163,18 @@ ] -job_id_arg = Annotated[str, typer.Argument(help="The ID of the job (i.e. the uuid)")] - -db_id_arg = Annotated[ - int, typer.Argument(help="The DB id of the job (i.e. an integer)") +job_db_id_arg = Annotated[ + str, + typer.Argument( + help="The ID of the job can the db id (i.e. an integer) or a string (i.e. the uuid)", + metavar="ID", + ), ] - - -db_id_flag_opt = Annotated[ - bool, - typer.Option( - "--db-id", - "-db", - help=( - "If set the id passed would be considered to be the DB id (i.e. an integer)" - ), +job_index_arg = Annotated[ + Optional[int], + typer.Argument( + help="The index of the job. If not defined the job with the largest index is selected", + metavar="INDEX", ), ] diff --git a/src/jobflow_remote/cli/utils.py b/src/jobflow_remote/cli/utils.py index cf029def..398c8082 100644 --- a/src/jobflow_remote/cli/utils.py +++ b/src/jobflow_remote/cli/utils.py @@ -48,19 +48,19 @@ def __repr__(self): return self -def exit_with_error_msg(message, code=1, **kwargs): +def exit_with_error_msg(message: str, code: int = 1, **kwargs): kwargs.setdefault("style", "red") err_console.print(message, **kwargs) raise typer.Exit(code) -def exit_with_warning_msg(message, code=0, **kwargs): +def exit_with_warning_msg(message: str, code: int = 0, **kwargs): kwargs.setdefault("style", "gold1") err_console.print(message, **kwargs) raise typer.Exit(code) -def print_success_msg(message="operation completed", **kwargs): +def print_success_msg(message: str = "operation completed", **kwargs): kwargs.setdefault("style", "green") out_console.print(message, **kwargs) @@ -114,19 +114,38 @@ def loading_spinner(processing: bool = True): yield progress -def get_job_db_ids(db_id, job_id): - if db_id: - try: - db_id_value = int(job_id) - except ValueError: +def get_job_db_ids(job_db_id: str, job_index: int | None): + try: + db_id = int(job_db_id) + job_id = None + except ValueError: + db_id = None + job_id = job_db_id + + if job_index and db_id is not None: + out_console.print( + "The index is defined even if an integer is passed as an ID. Will be ignored", + style="yellow", + ) + + return db_id, job_id + + +def get_job_ids_indexes(job_ids: list[str] | None) -> list[tuple[str, int]] | None: + if not job_ids: + return None + job_ids_indexes = [] + for j in job_ids: + split = j.split(":") + if len(split) != 2 or not split[1].isnumeric(): raise typer.BadParameter( - "if --db-id is selected the ID should be an integer" + "The job id should be in the format UUID:INDEX " + "(e.g. e1d66c4f-81db-4fff-bda2-2bf1d79d5961:2). " + f"Wrong format for {j}" ) - job_id_value = None - else: - job_id_value = job_id - db_id_value = None - return db_id_value, job_id_value + job_ids_indexes.append((split[0], int(split[1]))) + + return job_ids_indexes def cli_error_handler(func): diff --git a/src/jobflow_remote/fireworks/launchpad.py b/src/jobflow_remote/fireworks/launchpad.py index 1ca03bef..787a6b06 100644 --- a/src/jobflow_remote/fireworks/launchpad.py +++ b/src/jobflow_remote/fireworks/launchpad.py @@ -285,7 +285,12 @@ def recover_remote( 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): + 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. @@ -301,8 +306,8 @@ def get_fw_dict(self, fw_id: int | None = None, job_id: str | None = None): dict The dictionary defining the Firework """ - query = self._generate_id_query(fw_id, job_id) - fw_dict = self.fireworks.find_one(query) + 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}" @@ -333,24 +338,43 @@ def get_fw_dict(self, fw_id: int | None = None, job_id: str | None = None): return fw_dict @staticmethod - def _generate_id_query(fw_id: int | None = None, job_id: str | None = None) -> dict: + 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 fw_id: query["fw_id"] = fw_id if job_id: query[FW_UUID_PATH] = job_id + if job_index is None: + 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 + return query, sort - def _check_ids(self, fw_id: int | None = None, job_id: str | None = None): + def _check_ids( + self, + fw_id: int | None = None, + job_id: str | None = None, + job_index: int | None = None, + ): if job_id is None and fw_id is None: raise ValueError("At least one among fw_id and job_id should be defined") if job_id: - fw_id = self.get_fw_id_from_job_id(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): + 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. @@ -366,10 +390,11 @@ def get_fw(self, fw_id: int | None = None, job_id: str | None = None): Firework The retrieved Firework """ - return Firework.from_dict(self.get_fw_dict(fw_id, job_id)) + 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): - fw_dict = self.fireworks.find_one({FW_UUID_PATH: job_id}, projection=["fw_id"]) + 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}") @@ -379,6 +404,7 @@ 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, ): @@ -396,15 +422,13 @@ def rerun_fw( Returns: [int]: list of firework ids that were rerun """ - if job_id is None and fw_id is None: - raise ValueError("At least one among fw_id and job_id should be defined") + query, sort = self.generate_id_query( + fw_id=fw_id, job_id=job_id, job_index=job_index + ) - if job_id: - m_fw = self.fireworks.find_one( - {FW_UUID_PATH: job_id}, {"state": 1, "fw_id": 1} - ) - else: - m_fw = self.fireworks.find_one({"fw_id": fw_id}, {"state": 1, "fw_id": 1}) + 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!") @@ -466,14 +490,16 @@ def set_remote_values( values: dict, fw_id: int | None, job_id: str | None = None, + job_index: int | None = None, break_lock: bool = False, ) -> bool: - lock_filter = self._generate_id_query(fw_id, job_id) + 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()} @@ -490,19 +516,32 @@ def remove_lock(self, query: dict | None = None) -> int: ) return result.modified_count - def is_locked(self, fw_id: int | None = None, job_id: str | None = None) -> bool: - query = self._generate_id_query(fw_id, job_id) - result = self.fireworks.find_one(query, projection=[REMOTE_LOCK_PATH]) + 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 + self, + fw_id: int | None = None, + job_id: str | None = None, + job_index: int | None = None, ) -> bool: - lock_filter = self._generate_id_query(fw_id, job_id) + 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 + collection=self.fireworks, + filter=lock_filter, + lock_subdoc=REMOTE_DOC_PATH, + sort=sort, ) as lock: doc = lock.locked_document remote = get_remote_doc(doc) @@ -540,6 +579,8 @@ 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}) @@ -553,26 +594,27 @@ def delete_wf(self, fw_id: int | None = None, job_id: str | None = None): self.workflows.delete_one({"nodes": fw_id}) def get_remote_run( - self, fw_id: int | None = None, job_id: str | None = None + self, + fw_id: int | None = None, + job_id: str | None = None, + job_index: int | None = None, ) -> RemoteRun: - query: dict = {} - if job_id: - query[FW_UUID_PATH] = job_id - if fw_id: - query["fw_id"] = fw_id - if not query: - raise ValueError("At least one among fw_id and job_id should be defined") + query, sort = self.generate_id_query(fw_id, job_id, job_index) fw = self.fireworks.find_one(query) if not fw: - raise ValueError(f"No Job exists with fw id: {fw_id} or job_id {job_id}") + 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: - raise ValueError( - f"No Remote run exists with fw id: {fw_id} or job_id {job_id}" - ) + 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) @@ -590,7 +632,7 @@ def get_fw_remote_run( self, query: dict | None = None, projection: dict | None = None, - sort: 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) @@ -626,16 +668,13 @@ def get_fw_ids( return fw_ids def get_fw_remote_run_from_id( - self, fw_id: int | None = None, job_id: str | None = None + self, + fw_id: int | None = None, + job_id: str | None = None, + job_index: int | None = None, ) -> tuple[Firework, RemoteRun] | None: - if fw_id is None and job_id is None: - raise ValueError("at least one among fw_id and job_id should be defined") - query: dict = {} - if fw_id: - query["fw_id"] = fw_id - if job_id: - query[FW_UUID_PATH] = job_id - results = self.get_fw_remote_run(query=query) + 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] diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index 89b9267c..ffc6642a 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -4,6 +4,7 @@ import logging from contextlib import redirect_stdout from datetime import datetime, timezone +from typing import cast from fireworks import Firework from jobflow import JobStore @@ -12,6 +13,7 @@ from jobflow_remote.config.base import Project from jobflow_remote.config.manager import ConfigManager from jobflow_remote.fireworks.launchpad import ( + FW_INDEX_PATH, FW_UUID_PATH, REMOTE_DOC_PATH, RemoteLaunchPad, @@ -47,6 +49,7 @@ 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( @@ -63,7 +66,7 @@ def get_job_data( def _build_query_fw( self, - job_ids: str | list[str] | None = None, + job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, db_ids: int | list[int] | None = None, state: JobState | None = None, remote_state: RemoteState | None = None, @@ -76,8 +79,9 @@ def _build_query_fw( if remote_state is not None: remote_state = [remote_state] - if job_ids is not None and not isinstance(job_ids, (list, tuple)): - job_ids = [job_ids] + 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]) if db_ids is not None and not isinstance(db_ids, (list, tuple)): db_ids = [db_ids] @@ -86,7 +90,11 @@ def _build_query_fw( if db_ids: query["fw_id"] = {"$in": db_ids} if job_ids: - query[FW_UUID_PATH] = {"$in": 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}) + query["$or"] = or_list if state: fw_states, remote_state = state.to_states() @@ -175,7 +183,7 @@ def _build_query_wf( def get_jobs_data( self, - job_ids: str | list[str] | None = None, + job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, db_ids: int | list[int] | None = None, state: JobState | None = None, remote_state: RemoteState | None = None, @@ -243,7 +251,7 @@ def get_jobs_info_query( def get_jobs_info( self, - job_ids: str | list[str] | None = None, + job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, db_ids: int | list[int] | None = None, state: JobState | None = None, remote_state: RemoteState | None = None, @@ -265,10 +273,13 @@ def get_jobs_info( return self.get_jobs_info_query(query=query, sort=sort, limit=limit) def get_job_info( - self, job_id: str | None, db_id: int | None, full: bool = False + self, + job_id: str | None, + db_id: int | None, + job_index: int | None = None, + full: bool = False, ) -> JobInfo | None: - self.check_ids(job_id, db_id) - query = self._build_query_fw(job_ids=job_id, db_ids=db_id) + query, sort = self.rlpad.generate_id_query(db_id, job_id, job_index) if full: proj = dict(job_info_projection) @@ -279,27 +290,24 @@ def get_job_info( } ) data = list( - self.rlpad.get_fw_launch_remote_run_data(query=query, projection=proj) + 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) + self.rlpad.fireworks.find( + query, projection=job_info_projection, sort=sort, limit=1 + ) ) if not data: return None return JobInfo.from_fw_dict(data[0]) - @staticmethod - def check_ids(job_id: str | None, db_id: int | 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" - ) - def rerun_jobs( self, - job_ids: str | list[str] | None = None, + job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, db_ids: int | list[int] | None = None, state: JobState | None = None, remote_state: RemoteState | None = None, @@ -341,9 +349,12 @@ def reset(self, reset_output: bool = False, max_limit: int = 25) -> bool: return True def set_remote_state( - self, state: RemoteState, job_id: str | None, db_id: int | None + self, + state: RemoteState, + job_id: str | None, + db_id: int | None, + job_index: int | None = None, ) -> bool: - self.check_ids(job_id, db_id) values = { "state": state.value, "step_attempts": 0, @@ -352,19 +363,27 @@ def set_remote_state( "queue_state": None, "error": None, } - return self.rlpad.set_remote_values(values=values, job_id=job_id, fw_id=db_id) + return self.rlpad.set_remote_values( + values=values, job_id=job_id, fw_id=db_id, job_index=job_index + ) - def reset_remote_attempts(self, job_id: str | None, db_id: int | None) -> bool: - self.check_ids(job_id, db_id) + 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) + return self.rlpad.set_remote_values( + values=values, job_id=job_id, fw_id=db_id, job_index=job_index + ) - def reset_failed_state(self, job_id: str | None, db_id: int | None) -> bool: - self.check_ids(job_id, db_id) - return self.rlpad.reset_failed_state(job_id=job_id, fw_id=db_id) + 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 get_flows_info( self, @@ -426,7 +445,7 @@ def delete_flows( def remove_lock( self, - job_ids: str | list[str] | None = None, + job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, db_ids: int | list[int] | None = None, state: JobState | None = None, remote_state: RemoteState | None = None, From 0bf90b5906abf526d30dab763c530d47c16a914e Mon Sep 17 00:00:00 2001 From: David Waroquiers Date: Mon, 21 Aug 2023 16:21:08 +0200 Subject: [PATCH 47/89] Fixed jf flow list. --- src/jobflow_remote/cli/flow.py | 5 +++-- src/jobflow_remote/cli/types.py | 12 +++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/jobflow_remote/cli/flow.py b/src/jobflow_remote/cli/flow.py index 54d6cec6..4ef9cd6f 100644 --- a/src/jobflow_remote/cli/flow.py +++ b/src/jobflow_remote/cli/flow.py @@ -13,6 +13,7 @@ db_ids_opt, end_date_opt, flow_ids_opt, + flow_state_opt, force_opt, job_ids_opt, job_state_opt, @@ -42,7 +43,7 @@ def flows_list( job_id: job_ids_opt = None, db_id: db_ids_opt = None, flow_id: flow_ids_opt = None, - state: job_state_opt = None, + state: flow_state_opt = None, start_date: start_date_opt = None, end_date: end_date_opt = None, days: days_opt = None, @@ -93,7 +94,7 @@ def delete( job_id: job_ids_opt = None, db_id: db_ids_opt = None, flow_id: flow_ids_opt = None, - state: job_state_opt = None, + state: flow_state_opt = None, start_date: start_date_opt = None, end_date: end_date_opt = None, days: days_opt = None, diff --git a/src/jobflow_remote/cli/types.py b/src/jobflow_remote/cli/types.py index 33d2eccc..67bc5397 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 JobState, RemoteState +from jobflow_remote.jobs.state import FlowState, JobState, RemoteState job_ids_opt = Annotated[ Optional[List[str]], @@ -50,6 +50,16 @@ ] +flow_state_opt = Annotated[ + Optional[FlowState], + typer.Option( + "--state", + "-s", + help="One of the Flow states", + ), +] + + remote_state_opt = Annotated[ Optional[RemoteState], typer.Option( From fe03b051368261b4a0639c83f73673a4d902a9c7 Mon Sep 17 00:00:00 2001 From: David Waroquiers Date: Mon, 21 Aug 2023 16:24:54 +0200 Subject: [PATCH 48/89] Fixed state query in jobcontroller. --- src/jobflow_remote/jobs/jobcontroller.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index 89b9267c..b86827de 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -139,7 +139,7 @@ def _build_query_wf( not_in_states = list(Firework.STATE_RANKS.keys()) not_in_states.remove("WAITING") query["fws.state"] = {"$nin": not_in_states} - if state == FlowState.PAUSED: + elif state == FlowState.PAUSED: not_in_states = list(Firework.STATE_RANKS.keys()) not_in_states.remove("PAUSED") query["fws.state"] = {"$nin": not_in_states} @@ -162,6 +162,8 @@ def _build_query_wf( } }, ] + else: + raise RuntimeError("Unknown flow state.") # at variance with Firework doc, the dates in the Workflow are Date objects if start_date: From b29c55a70c7976368311cabf47968ced10e1b70a Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Tue, 22 Aug 2023 09:29:14 +0200 Subject: [PATCH 49/89] more fix for id based searches --- src/jobflow_remote/cli/flow.py | 4 ++-- src/jobflow_remote/cli/utils.py | 14 ++++++++++++++ src/jobflow_remote/fireworks/launchpad.py | 12 ++++++++++-- src/jobflow_remote/jobs/jobcontroller.py | 10 +++++----- 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/jobflow_remote/cli/flow.py b/src/jobflow_remote/cli/flow.py index 54d6cec6..43f000da 100644 --- a/src/jobflow_remote/cli/flow.py +++ b/src/jobflow_remote/cli/flow.py @@ -68,7 +68,7 @@ def flows_list( flows_info = jc.get_flows_info( job_ids=job_id, db_ids=db_id, - flow_id=flow_id, + flow_ids=flow_id, state=state, start_date=start_date, end_date=end_date, @@ -112,7 +112,7 @@ def delete( flows_info = jc.get_flows_info( job_ids=job_id, db_ids=db_id, - flow_id=flow_id, + flow_ids=flow_id, state=state, start_date=start_date, end_date=end_date, diff --git a/src/jobflow_remote/cli/utils.py b/src/jobflow_remote/cli/utils.py index 398c8082..5360eb08 100644 --- a/src/jobflow_remote/cli/utils.py +++ b/src/jobflow_remote/cli/utils.py @@ -1,6 +1,7 @@ from __future__ import annotations import functools +import uuid from contextlib import contextmanager from enum import Enum @@ -121,6 +122,7 @@ def get_job_db_ids(job_db_id: str, job_index: int | None): except ValueError: db_id = None job_id = job_db_id + check_valid_uuid(job_id) if job_index and db_id is not None: out_console.print( @@ -143,6 +145,7 @@ def get_job_ids_indexes(job_ids: list[str] | None) -> list[tuple[str, int]] | No "(e.g. e1d66c4f-81db-4fff-bda2-2bf1d79d5961:2). " f"Wrong format for {j}" ) + check_valid_uuid(split[0]) job_ids_indexes.append((split[0], int(split[1]))) return job_ids_indexes @@ -170,3 +173,14 @@ def wrapper(*args, **kwargs): ) return wrapper + + +def check_valid_uuid(uuid_str): + try: + uuid_obj = uuid.UUID(uuid_str) + if str(uuid_obj) == uuid_str: + return + except ValueError: + pass + + raise typer.BadParameter(f"UUID {uuid_str} is in the wrong format.") diff --git a/src/jobflow_remote/fireworks/launchpad.py b/src/jobflow_remote/fireworks/launchpad.py index 787a6b06..d94557c4 100644 --- a/src/jobflow_remote/fireworks/launchpad.py +++ b/src/jobflow_remote/fireworks/launchpad.py @@ -345,6 +345,12 @@ def generate_id_query( ) -> 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: @@ -363,8 +369,10 @@ def _check_ids( job_id: str | None = None, job_index: int | None = None, ): - if job_id is None and fw_id is None: - raise ValueError("At least one among fw_id and job_id should be defined") + 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 diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index ffc6642a..a36a1cff 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -121,7 +121,7 @@ def _build_query_wf( self, job_ids: str | list[str] | None = None, db_ids: int | list[int] | None = None, - flow_id: str | None = None, + flow_ids: str | None = None, state: FlowState | None = None, start_date: datetime | None = None, end_date: datetime | None = None, @@ -139,8 +139,8 @@ def _build_query_wf( if job_ids: query[f"fws.{FW_UUID_PATH}"] = {"$in": job_ids} - if flow_id: - query["metadata.flow_id"] = flow_id + if flow_ids: + query["metadata.flow_id"] = {"$in": flow_ids} if state: if state == FlowState.WAITING: @@ -389,7 +389,7 @@ def get_flows_info( self, job_ids: str | list[str] | None = None, db_ids: int | list[int] | None = None, - flow_id: str | None = None, + flow_ids: str | None = None, state: FlowState | None = None, start_date: datetime | None = None, end_date: datetime | None = None, @@ -399,7 +399,7 @@ def get_flows_info( query = self._build_query_wf( job_ids=job_ids, db_ids=db_ids, - flow_id=flow_id, + flow_ids=flow_ids, state=state, start_date=start_date, end_date=end_date, From 49b82a217610012cfa0ea4557cf5e3aba5e8efd3 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Wed, 23 Aug 2023 11:54:35 +0200 Subject: [PATCH 50/89] fix flow query for ONGOING state --- src/jobflow_remote/jobs/jobcontroller.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index 1fca2c36..2d835d17 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -157,7 +157,9 @@ def _build_query_wf( query["state"] = "COMPLETED" elif state == FlowState.ONGOING: query["state"] = "RUNNING" - query["fws.state"] = {"$in": ["RUNNING", "RESERVED"]} + query["fws.state"] = { + "$in": ["WAITING", "COMPLETED", "READY", "RUNNING", "RESERVED"] + } query[f"fws.{REMOTE_DOC_PATH}.state"] = { "$nin": [JobState.FAILED.value, JobState.REMOTE_ERROR.value] } From 24d2eda1069b81ab53e897aa5cdacaefb1d2881c Mon Sep 17 00:00:00 2001 From: David Waroquiers Date: Wed, 23 Aug 2023 14:15:24 +0200 Subject: [PATCH 51/89] Added STOPPED and CANCELLED Job States. Added STOPPED Flow State. Added proper queries for jobs and flows with a STOPPED state. --- src/jobflow_remote/jobs/jobcontroller.py | 2 ++ src/jobflow_remote/jobs/state.py | 13 ++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index 1fca2c36..4855d84f 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -170,6 +170,8 @@ def _build_query_wf( } }, ] + elif state == FlowState.STOPPED: + query["fws.state"] = "DEFUSED" else: raise RuntimeError("Unknown flow state.") diff --git a/src/jobflow_remote/jobs/state.py b/src/jobflow_remote/jobs/state.py index 5db8c1a0..fae80141 100644 --- a/src/jobflow_remote/jobs/state.py +++ b/src/jobflow_remote/jobs/state.py @@ -51,7 +51,9 @@ class JobState(Enum): REMOTE_ERROR = "REMOTE_ERROR" COMPLETED = "COMPLETED" FAILED = "FAILED" - PAUSED = "PAUSED" + PAUSED = "PAUSED" # Not yet used + STOPPED = "STOPPED" + CANCELLED = "CANCELLED" # Not yet used @classmethod def from_states( @@ -66,6 +68,10 @@ def from_states( 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}") @@ -80,6 +86,8 @@ def to_states(self) -> tuple[list[str], list[RemoteState] | None]: 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}") @@ -97,6 +105,7 @@ class FlowState(Enum): COMPLETED = "COMPLETED" FAILED = "FAILED" PAUSED = "PAUSED" + STOPPED = "STOPPED" @classmethod def from_jobs_states(cls, jobs_states: list[JobState]) -> FlowState: @@ -110,5 +119,7 @@ def from_jobs_states(cls, jobs_states: list[JobState]) -> FlowState: 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 else: return cls.ONGOING From 279f79c2e9ef0909975284d3fc0688d83c8f1d74 Mon Sep 17 00:00:00 2001 From: David Waroquiers Date: Wed, 23 Aug 2023 17:08:22 +0200 Subject: [PATCH 52/89] Fixed flow queries by state for STOPPED and FAILED. --- src/jobflow_remote/jobs/jobcontroller.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index 4855d84f..bcb28562 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -164,6 +164,7 @@ def _build_query_wf( 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] @@ -171,7 +172,8 @@ def _build_query_wf( }, ] elif state == FlowState.STOPPED: - query["fws.state"] = "DEFUSED" + query["state"] = "DEFUSED" + query["fws.state"] = {"$nin": ["FIZZLED"]} else: raise RuntimeError("Unknown flow state.") From 994ed7f7d8cc10cd7ce82c17fb4fdcc89450d892 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Fri, 25 Aug 2023 00:13:44 +0200 Subject: [PATCH 53/89] jf flow info and other cli updates --- src/jobflow_remote/cli/flow.py | 43 ++++++++++++++++- src/jobflow_remote/cli/formatting.py | 27 +++++++++++ src/jobflow_remote/cli/job.py | 11 +++++ src/jobflow_remote/cli/types.py | 43 ++++++++++++++++- src/jobflow_remote/cli/utils.py | 17 +++++++ src/jobflow_remote/fireworks/launchpad.py | 1 + src/jobflow_remote/jobs/jobcontroller.py | 57 ++++++++++++++++++++++- 7 files changed, 194 insertions(+), 5 deletions(-) diff --git a/src/jobflow_remote/cli/flow.py b/src/jobflow_remote/cli/flow.py index eedaf300..7f453cdf 100644 --- a/src/jobflow_remote/cli/flow.py +++ b/src/jobflow_remote/cli/flow.py @@ -5,19 +5,21 @@ from rich.text import Text from jobflow_remote import SETTINGS -from jobflow_remote.cli.formatting import get_flow_info_table +from jobflow_remote.cli.formatting import format_flow_info, get_flow_info_table from jobflow_remote.cli.jf import app from jobflow_remote.cli.jfr_typer import JFRTyper from jobflow_remote.cli.types import ( days_opt, db_ids_opt, end_date_opt, + flow_db_id_arg, flow_ids_opt, flow_state_opt, force_opt, + job_flow_id_flag_opt, job_ids_opt, - job_state_opt, max_results_opt, + name_opt, reverse_sort_flag_opt, sort_opt, start_date_opt, @@ -26,7 +28,9 @@ from jobflow_remote.cli.utils import ( SortOption, check_incompatible_opt, + exit_with_error_msg, exit_with_warning_msg, + get_job_db_ids, loading_spinner, out_console, ) @@ -97,6 +101,7 @@ def delete( state: flow_state_opt = None, start_date: start_date_opt = None, end_date: end_date_opt = None, + name: name_opt = None, days: days_opt = None, force: force_opt = False, ): @@ -117,6 +122,7 @@ def delete( state=state, start_date=start_date, end_date=end_date, + name=name, ) if not flows_info: @@ -140,3 +146,36 @@ def delete( out_console.print( f"Deleted Flow(s) with db_id: {', '.join(str(i) for i in to_delete)}" ) + + +@app_flow.command(name="info") +def flow_info( + flow_db_id: flow_db_id_arg, + job_id_flag: job_flow_id_flag_opt = False, +): + """ + 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 = JobController() + + flows_info = jc.get_flows_info( + job_ids=job_ids, + db_ids=db_ids, + flow_ids=flow_ids, + limit=1, + ) + if not flows_info: + exit_with_error_msg("No data matching the request") + + out_console.print(format_flow_info(flows_info[0])) diff --git a/src/jobflow_remote/cli/formatting.py b/src/jobflow_remote/cli/formatting.py index c0928cf4..7f942481 100644 --- a/src/jobflow_remote/cli/formatting.py +++ b/src/jobflow_remote/cli/formatting.py @@ -141,6 +141,33 @@ def format_job_info(job_info: JobInfo, show_none: bool = False): return render_scope(d) +def format_flow_info(flow_info: FlowInfo): + + title = f"Flow: {flow_info.name} - {flow_info.flow_id} - {flow_info.state.name}" + table = Table(title=title) + table.title_style = "bold" + table.add_column("DB id") + table.add_column("Name") + table.add_column("State [Remote]") + table.add_column("Job id (Index)") + table.add_column("Worker") + + for i, job_id in enumerate(flow_info.job_ids): + state = flow_info.job_states[i].name + + row = [ + str(flow_info.db_ids[i]), + flow_info.job_names[i], + state, + f"{job_id} ({flow_info.job_indexes[i]})", + flow_info.workers[i], + ] + + table.add_row(*row) + + return table + + def get_exec_config_table(exec_config: dict[str, ExecutionConfig], verbosity: int = 0): table = Table(title="Execution config", show_lines=verbosity > 0) table.add_column("Name") diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index 60b4f509..0519fc8c 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -13,12 +13,15 @@ days_opt, db_ids_opt, end_date_opt, + flow_ids_opt, job_db_id_arg, job_ids_indexes_opt, job_index_arg, job_state_opt, locked_opt, max_results_opt, + metadata_opt, + name_opt, query_opt, remote_state_arg, remote_state_opt, @@ -30,6 +33,7 @@ from jobflow_remote.cli.utils import ( SortOption, check_incompatible_opt, + convert_metadata, exit_with_error_msg, exit_with_warning_msg, get_job_db_ids, @@ -53,10 +57,13 @@ def jobs_list( 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, verbosity: verbosity_opt = 0, max_results: max_results_opt = 100, @@ -71,6 +78,7 @@ def jobs_list( check_incompatible_opt({"state": state, "remote-state": remote_state}) check_incompatible_opt({"start_date": start_date, "days": days}) check_incompatible_opt({"end_date": end_date, "days": days}) + metadata_dict = convert_metadata(metadata) job_ids_indexes = get_job_ids_indexes(job_id) @@ -92,11 +100,14 @@ def jobs_list( jobs_info = jc.get_jobs_info( job_ids=job_ids_indexes, db_ids=db_id, + flow_ids=flow_id, state=state, remote_state=remote_state, start_date=start_date, locked=locked, end_date=end_date, + name=name, + metadata=metadata_dict, limit=max_results, sort=sort, ) diff --git a/src/jobflow_remote/cli/types.py b/src/jobflow_remote/cli/types.py index 5dc6ec19..3cd5b301 100644 --- a/src/jobflow_remote/cli/types.py +++ b/src/jobflow_remote/cli/types.py @@ -82,6 +82,28 @@ ), ] +name_opt = Annotated[ + Optional[str], + typer.Option( + "--name", + "-n", + help="The name. A regex can be passed (e.g. .*test.*)", + ), +] + + +metadata_opt = Annotated[ + Optional[str], + typer.Option( + "--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"}\')', + ), +] + remote_state_arg = Annotated[ RemoteState, typer.Argument(help="One of the remote states") @@ -176,7 +198,7 @@ job_db_id_arg = Annotated[ str, typer.Argument( - help="The ID of the job can the db id (i.e. an integer) or a string (i.e. the uuid)", + help="The ID of the job. Can be the db id (i.e. an integer) or a string (i.e. the uuid)", metavar="ID", ), ] @@ -189,6 +211,15 @@ ] +flow_db_id_arg = Annotated[ + str, + typer.Argument( + help="The ID of the flow. Can the db id (i.e. an integer) or a string (i.e. the uuid)", + metavar="ID", + ), +] + + force_opt = Annotated[ bool, typer.Option( @@ -199,6 +230,16 @@ ] +job_flow_id_flag_opt = Annotated[ + bool, + typer.Option( + "--job", + "-j", + help="The passed ID will be the ID of one of the jobs" + " belonging to the flow, instead of the ID of the flow.", + ), +] + locked_opt = Annotated[ bool, typer.Option( diff --git a/src/jobflow_remote/cli/utils.py b/src/jobflow_remote/cli/utils.py index 5360eb08..8b3ff10b 100644 --- a/src/jobflow_remote/cli/utils.py +++ b/src/jobflow_remote/cli/utils.py @@ -1,6 +1,7 @@ from __future__ import annotations import functools +import json import uuid from contextlib import contextmanager from enum import Enum @@ -184,3 +185,19 @@ def check_valid_uuid(uuid_str): pass 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: + return None + + try: + metadata = json.loads(string_metadata) + except json.JSONDecodeError: + split = string_metadata.split("=") + if len(split) != 2: + raise typer.BadParameter(f"Wrong format for metadata {string_metadata}") + + metadata = {split[0]: split[1]} + + return metadata diff --git a/src/jobflow_remote/fireworks/launchpad.py b/src/jobflow_remote/fireworks/launchpad.py index d94557c4..f4a81a51 100644 --- a/src/jobflow_remote/fireworks/launchpad.py +++ b/src/jobflow_remote/fireworks/launchpad.py @@ -21,6 +21,7 @@ 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" diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index 11ddda94..5345237c 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -14,6 +14,7 @@ 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, @@ -53,7 +54,7 @@ def get_job_data( load_output: bool = False, ): fw, remote_run = self.rlpad.get_fw_remote_run_from_id( - job_id=job_id, fw_id=db_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) @@ -68,11 +69,14 @@ def _build_query_fw( 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") @@ -84,6 +88,8 @@ def _build_query_fw( job_ids = cast(list[tuple[str, int]], [job_ids]) if db_ids is not None and not isinstance(db_ids, (list, tuple)): db_ids = [db_ids] + if flow_ids and not isinstance(flow_ids, (list, tuple)): + flow_ids = [flow_ids] query: dict = {} @@ -96,6 +102,9 @@ def _build_query_fw( or_list.append({FW_UUID_PATH: job_id, FW_INDEX_PATH: job_index}) query["$or"] = or_list + if flow_ids: + query[f"{FW_JOB_PATH}.hosts"] = {"$in": flow_ids} + if state: fw_states, remote_state = state.to_states() query["state"] = {"$in": fw_states} @@ -115,6 +124,15 @@ def _build_query_fw( if locked: query[f"{REMOTE_DOC_PATH}.{MongoLock.LOCK_KEY}"] = {"$exists": True} + if name: + query["name"] = {"$regex": name} + + if metadata: + metadata_dict = { + f"{FW_JOB_PATH}.metadata.{k}": v for k, v in metadata.items() + } + query.update(metadata_dict) + return query def _build_query_wf( @@ -125,6 +143,7 @@ def _build_query_wf( state: FlowState | None = None, start_date: datetime | None = None, end_date: datetime | None = None, + name: str | None = None, ) -> dict: if job_ids is not None and not isinstance(job_ids, (list, tuple)): @@ -166,7 +185,12 @@ def _build_query_wf( elif state == FlowState.FAILED: query["$or"] = [ {"state": "FIZZLED"}, - {"$and": [{"state": "DEFUSED"}, {"fws.state": {"$in": ["FIZZLED"]}}]}, + { + "$and": [ + {"state": "DEFUSED"}, + {"fws.state": {"$in": ["FIZZLED"]}}, + ] + }, { f"fws.{REMOTE_DOC_PATH}.state": { "$in": [JobState.FAILED.value, JobState.REMOTE_ERROR.value] @@ -187,16 +211,22 @@ def _build_query_wf( end_date_str = end_date.astimezone(timezone.utc) query["updated_on"] = {"$lte": end_date_str} + if name: + query["name"] = {"$regex": name} + return query def get_jobs_data( 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, limit: int = 0, load_output: bool = False, @@ -204,10 +234,13 @@ def get_jobs_data( query = self._build_query_fw( 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, ) data = self.rlpad.fireworks.find(query, sort=sort, limit=limit) @@ -261,10 +294,13 @@ 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, locked: bool = False, sort: list[tuple] | None = None, limit: int = 0, @@ -272,11 +308,14 @@ def get_jobs_info( query = self._build_query_fw( 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) @@ -317,20 +356,26 @@ def rerun_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, 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, ) -> list[int]: query = self._build_query_fw( 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, ) fw_ids = self.rlpad.get_fw_ids(query=query, sort=sort, limit=limit) @@ -401,6 +446,7 @@ def get_flows_info( state: FlowState | None = None, start_date: datetime | None = None, end_date: datetime | None = None, + name: str | None = None, sort: list[tuple] | None = None, limit: int = 0, ) -> list[FlowInfo]: @@ -411,6 +457,7 @@ def get_flows_info( state=state, start_date=start_date, end_date=end_date, + name=name, ) data = self.rlpad.get_wf_fw_data( @@ -455,19 +502,25 @@ def remove_lock( 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( 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, + name=name, + metadata=metadata, ) return self.rlpad.remove_lock(query=query) From ff181ba2cbaa3d4b4d617c0e8935d0acd35ee628 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Fri, 25 Aug 2023 14:35:54 +0200 Subject: [PATCH 54/89] fix job query --- src/jobflow_remote/jobs/jobcontroller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index 5345237c..8c184e89 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -60,7 +60,7 @@ def get_job_data( 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 == RemoteState.COMPLETED: + if load_output and state == JobState.COMPLETED: output = jobstore.query_one({"uuid": job_id}, load=True) return JobData(job=job, state=state, db_id=fw.fw_id, output=output) @@ -258,7 +258,7 @@ def get_jobs_data( info = JobInfo.from_fw_dict(fw_dict) output = None - if state == RemoteState.COMPLETED and load_output: + if state == JobState.COMPLETED and load_output: output = store.query_one({"uuid": job.uuid}, load=True) jobs_data.append( JobData( From 9605bf6de16e4ac3c69ce5c91b24ed023eda71b9 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Mon, 28 Aug 2023 15:37:49 +0200 Subject: [PATCH 55/89] modify name search field --- src/jobflow_remote/cli/types.py | 2 +- src/jobflow_remote/jobs/jobcontroller.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/jobflow_remote/cli/types.py b/src/jobflow_remote/cli/types.py index 3cd5b301..2a2fbff8 100644 --- a/src/jobflow_remote/cli/types.py +++ b/src/jobflow_remote/cli/types.py @@ -87,7 +87,7 @@ typer.Option( "--name", "-n", - help="The name. A regex can be passed (e.g. .*test.*)", + help="The name. Default is an exact match, but all conventions from python fnmatch can be used (e.g. *test*)", ), ] diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index 8c184e89..68425e21 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -1,5 +1,6 @@ from __future__ import annotations +import fnmatch import io import logging from contextlib import redirect_stdout @@ -125,7 +126,7 @@ def _build_query_fw( query[f"{REMOTE_DOC_PATH}.{MongoLock.LOCK_KEY}"] = {"$exists": True} if name: - query["name"] = {"$regex": name} + query["name"] = {"$regex": fnmatch.translate(name)} if metadata: metadata_dict = { @@ -212,7 +213,7 @@ def _build_query_wf( query["updated_on"] = {"$lte": end_date_str} if name: - query["name"] = {"$regex": name} + query["name"] = {"$regex": fnmatch.translate(name)} return query From 5c63247e92a934b3b4b2021007f883ec77da5341 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Wed, 30 Aug 2023 01:02:15 +0200 Subject: [PATCH 56/89] fix query with aggregation --- src/jobflow_remote/fireworks/launchpad.py | 4 +++- src/jobflow_remote/jobs/jobcontroller.py | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/jobflow_remote/fireworks/launchpad.py b/src/jobflow_remote/fireworks/launchpad.py index f4a81a51..2ae68c9a 100644 --- a/src/jobflow_remote/fireworks/launchpad.py +++ b/src/jobflow_remote/fireworks/launchpad.py @@ -357,6 +357,8 @@ def generate_id_query( 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 @@ -623,7 +625,7 @@ def get_remote_run( 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) + raise ValueError(msg) return RemoteRun.from_db_dict(remote_dict) diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index 68425e21..67ef73aa 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -337,6 +337,9 @@ def get_job_info( 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 From 434beccb85f7f267b33212d1be6be4c229756496 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Tue, 5 Sep 2023 11:04:57 +0200 Subject: [PATCH 57/89] fix query by name --- src/jobflow_remote/cli/flow.py | 2 ++ src/jobflow_remote/jobs/jobcontroller.py | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/jobflow_remote/cli/flow.py b/src/jobflow_remote/cli/flow.py index 7f453cdf..9057d3a0 100644 --- a/src/jobflow_remote/cli/flow.py +++ b/src/jobflow_remote/cli/flow.py @@ -50,6 +50,7 @@ def flows_list( state: flow_state_opt = None, start_date: start_date_opt = None, end_date: end_date_opt = None, + name: name_opt = None, days: days_opt = None, verbosity: verbosity_opt = 0, max_results: max_results_opt = 100, @@ -77,6 +78,7 @@ def flows_list( state=state, start_date=start_date, end_date=end_date, + name=name, limit=max_results, sort=sort, ) diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index 67ef73aa..d5478a75 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -126,7 +126,8 @@ def _build_query_fw( query[f"{REMOTE_DOC_PATH}.{MongoLock.LOCK_KEY}"] = {"$exists": True} if name: - query["name"] = {"$regex": fnmatch.translate(name)} + mongo_regex = "^" + fnmatch.translate(name).replace("\\\\", "\\") + query["name"] = {"$regex": mongo_regex} if metadata: metadata_dict = { @@ -213,7 +214,8 @@ def _build_query_wf( query["updated_on"] = {"$lte": end_date_str} if name: - query["name"] = {"$regex": fnmatch.translate(name)} + mongo_regex = "^" + fnmatch.translate(name).replace("\\\\", "\\") + query["name"] = {"$regex": mongo_regex} return query From 7bcd12e43c366dada43a125d591cf0187611479f Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Fri, 15 Sep 2023 17:24:33 +0200 Subject: [PATCH 58/89] more filtering options for CLI --- src/jobflow_remote/cli/flow.py | 19 +++++++++++-------- src/jobflow_remote/cli/job.py | 11 ++++++----- src/jobflow_remote/cli/types.py | 11 +++++++++++ src/jobflow_remote/cli/utils.py | 18 ++++++++++++++++++ src/jobflow_remote/jobs/jobcontroller.py | 4 ++-- 5 files changed, 48 insertions(+), 15 deletions(-) diff --git a/src/jobflow_remote/cli/flow.py b/src/jobflow_remote/cli/flow.py index 9057d3a0..a13a2a6e 100644 --- a/src/jobflow_remote/cli/flow.py +++ b/src/jobflow_remote/cli/flow.py @@ -1,5 +1,3 @@ -from datetime import datetime, timedelta - import typer from rich.prompt import Confirm from rich.text import Text @@ -16,6 +14,7 @@ flow_ids_opt, flow_state_opt, force_opt, + hours_opt, job_flow_id_flag_opt, job_ids_opt, max_results_opt, @@ -31,6 +30,7 @@ exit_with_error_msg, exit_with_warning_msg, get_job_db_ids, + get_start_date, loading_spinner, out_console, ) @@ -52,6 +52,7 @@ def flows_list( end_date: end_date_opt = None, name: name_opt = None, days: days_opt = None, + hours: hours_opt = None, verbosity: verbosity_opt = 0, max_results: max_results_opt = 100, sort: sort_opt = SortOption.UPDATED_ON.value, @@ -60,13 +61,12 @@ def flows_list( """ Get the list of Jobs in the database """ - check_incompatible_opt({"start_date": start_date, "days": days}) - check_incompatible_opt({"end_date": end_date, "days": days}) + check_incompatible_opt({"start_date": start_date, "days": days, "hours": hours}) + check_incompatible_opt({"end_date": end_date, "days": days, "hours": hours}) jc = JobController() - if days: - start_date = datetime.now() - timedelta(days=days) + start_date = get_start_date(start_date, days, hours) sort = [(sort.query_field, 1 if reverse_sort else -1)] @@ -105,13 +105,16 @@ def delete( end_date: end_date_opt = None, name: name_opt = None, days: days_opt = None, + hours: hours_opt = None, force: force_opt = False, ): """ Permanently delete Flows from the database """ - check_incompatible_opt({"start_date": start_date, "days": days}) - check_incompatible_opt({"end_date": end_date, "days": days}) + check_incompatible_opt({"start_date": start_date, "days": days, "hours": hours}) + check_incompatible_opt({"end_date": end_date, "days": days, "hours": hours}) + + start_date = get_start_date(start_date, days, hours) jc = JobController() diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index 0519fc8c..ebcea6ae 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -1,5 +1,4 @@ import io -from datetime import datetime, timedelta from pathlib import Path import typer @@ -14,6 +13,7 @@ db_ids_opt, end_date_opt, flow_ids_opt, + hours_opt, job_db_id_arg, job_ids_indexes_opt, job_index_arg, @@ -38,6 +38,7 @@ exit_with_warning_msg, get_job_db_ids, get_job_ids_indexes, + get_start_date, loading_spinner, out_console, print_success_msg, @@ -65,6 +66,7 @@ def jobs_list( name: name_opt = None, metadata: metadata_opt = None, days: days_opt = None, + hours: hours_opt = None, verbosity: verbosity_opt = 0, max_results: max_results_opt = 100, sort: sort_opt = SortOption.UPDATED_ON.value, @@ -76,16 +78,15 @@ 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}) - check_incompatible_opt({"end_date": end_date, "days": days}) + 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) job_ids_indexes = get_job_ids_indexes(job_id) jc = JobController() - if days: - start_date = datetime.now() - timedelta(days=days) + start_date = get_start_date(start_date, days, hours) sort = [(sort.query_field, 1 if reverse_sort else -1)] diff --git a/src/jobflow_remote/cli/types.py b/src/jobflow_remote/cli/types.py index 2a2fbff8..b17d8ec0 100644 --- a/src/jobflow_remote/cli/types.py +++ b/src/jobflow_remote/cli/types.py @@ -116,6 +116,7 @@ "--start-date", "-sdate", help="Initial date for last update field", + formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d", "%H:%M:%S", "%H:%M:%S"], ), ] @@ -140,6 +141,16 @@ ] +hours_opt = Annotated[ + Optional[int], + typer.Option( + "--hours", + "-hs", + help="Last update field is in the last hours", + ), +] + + verbosity_opt = Annotated[ int, typer.Option( diff --git a/src/jobflow_remote/cli/utils.py b/src/jobflow_remote/cli/utils.py index 8b3ff10b..ad280d81 100644 --- a/src/jobflow_remote/cli/utils.py +++ b/src/jobflow_remote/cli/utils.py @@ -4,6 +4,7 @@ import json import uuid from contextlib import contextmanager +from datetime import datetime, timedelta from enum import Enum import typer @@ -201,3 +202,20 @@ def convert_metadata(string_metadata: str | None) -> dict | None: metadata = {split[0]: split[1]} return metadata + + +def get_start_date(start_date: datetime | None, days: int | None, hours: int | None): + + if start_date and (start_date.year, start_date.month, start_date.day) == ( + 1900, + 1, + 1, + ): + now = datetime.now() + start_date = start_date.replace(year=now.year, month=now.month, day=now.day) + elif days: + start_date = datetime.now() - timedelta(days=days) + elif hours: + start_date = datetime.now() - timedelta(hours=hours) + + return start_date diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index d5478a75..3740092e 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -207,10 +207,10 @@ def _build_query_wf( # at variance with Firework doc, the dates in the Workflow are Date objects if start_date: - start_date_str = start_date.astimezone(timezone.utc) + start_date_str = start_date.astimezone(timezone.utc).isoformat() query["updated_on"] = {"$gte": start_date_str} if end_date: - end_date_str = end_date.astimezone(timezone.utc) + end_date_str = end_date.astimezone(timezone.utc).isoformat() query["updated_on"] = {"$lte": end_date_str} if name: From c77029c85ed776a6487446d899deada549b0132c Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Mon, 18 Sep 2023 12:15:53 +0200 Subject: [PATCH 59/89] tune start date search in CLI --- src/jobflow_remote/cli/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/jobflow_remote/cli/utils.py b/src/jobflow_remote/cli/utils.py index ad280d81..355d0a03 100644 --- a/src/jobflow_remote/cli/utils.py +++ b/src/jobflow_remote/cli/utils.py @@ -213,6 +213,8 @@ def get_start_date(start_date: datetime | None, days: int | None, hours: int | N ): now = datetime.now() start_date = start_date.replace(year=now.year, month=now.month, day=now.day) + if start_date > now: + start_date = start_date - timedelta(days=1) elif days: start_date = datetime.now() - timedelta(days=days) elif hours: From 87fbb80f50363043a8ec86e0c75c4f11065ab55d Mon Sep 17 00:00:00 2001 From: Fabian Peschel Date: Thu, 21 Sep 2023 13:13:11 +0200 Subject: [PATCH 60/89] fix typo --- src/jobflow_remote/cli/job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index ebcea6ae..07f35780 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -207,7 +207,7 @@ def reset_remote_attempts( ): """ Resets the number of attempts to perform a remote action and eliminates - the delay in retrying. This will not restore a Jon from its failed state. + the delay in retrying. This will not restore a Job from its failed state. """ db_id, job_id = get_job_db_ids(job_db_id, job_index) From b1edf7f50427899fb9f70f20aad594d5ed476552 Mon Sep 17 00:00:00 2001 From: Matthew Evans Date: Thu, 21 Sep 2023 18:26:45 +0100 Subject: [PATCH 61/89] Run CI on PRs to develop branch --- .github/workflows/testing.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 3463332f..2ee59275 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -4,7 +4,7 @@ on: push: pull_request: - branches: [main] + branches: [main, develop] jobs: lint: From 0d539fe498eb77759d8f9e007171e10d0b46cc6e Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Fri, 22 Sep 2023 17:06:14 +0200 Subject: [PATCH 62/89] set full path for queue files --- src/jobflow_remote/jobs/runner.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index 0cba300c..df22c082 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -43,7 +43,7 @@ get_remote_store_filenames, ) from jobflow_remote.remote.host import BaseHost -from jobflow_remote.remote.queue import QueueManager, set_name_out +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 @@ -373,14 +373,18 @@ def submit(self, doc): remote_doc = get_remote_doc(doc) fw_job_data = self.get_fw_data(doc) - remote_path = remote_doc["run_dir"] + remote_path = Path(remote_doc["run_dir"]) script_commands = ["rlaunch singleshot --offline"] 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 {} - set_name_out(resources, fw_job_data.job.name) + 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") if isinstance(exec_config, str): exec_config = self.config_manager.get_exec_config( From a077df44daed57c4e88bd5296e24286eaba45989 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Fri, 29 Sep 2023 00:48:04 +0200 Subject: [PATCH 63/89] 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 37124c350e54288d201a9007fc0d7d9a8391c1ba Mon Sep 17 00:00:00 2001 From: Matthew Evans <7916000+ml-evs@users.noreply.github.com> Date: Tue, 10 Oct 2023 16:51:16 +0100 Subject: [PATCH 64/89] Use the qtoolkit and jobflow PyPI packages, add direct pydantic dep (#31) * Use the qtoolkit and jobflow PyPI package * Add direct dependency on pydantic --- pyproject.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 981131c8..71fb42ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,11 +26,12 @@ classifiers = [ ] requires-python = ">=3.8" dependencies =[ - "jobflow[strict] @ git+ssh://git@github.com/materialsproject/jobflow", + "jobflow[strict]", + "pydantic<2", "fireworks", "fabric", "tomlkit", - "qtoolkit @ git+ssh://git@github.com/matgenix/qtoolkit", # TODO: Should be updated here when released + "qtoolkit", "typer", "rich", "psutil", From 1cfc5b868edf07b5516253bf36b09b5d79922981 Mon Sep 17 00:00:00 2001 From: Matthew Evans Date: Tue, 10 Oct 2023 22:47:48 +0100 Subject: [PATCH 65/89] Fix CI trigger config --- .github/workflows/testing.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 2ee59275..17c690d7 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -2,9 +2,10 @@ name: testing on: push: + branches: + - develop pull_request: - branches: [main, develop] jobs: lint: From 3542a70d0450f6ecb4affd4776aa0eb350660448 Mon Sep 17 00:00:00 2001 From: Matthew Evans <7916000+ml-evs@users.noreply.github.com> Date: Wed, 11 Oct 2023 23:26:29 +0100 Subject: [PATCH 66/89] Linting fixes and CI config for multiple Python 3.9+ (#34) * Disable fail-fast * Disable py38 build * Remove docs workflow and re-add 3.8 to test matrix * Linting fixes; set default language version to 3.9 in pre-commit * Use 3.9 as the default CI version * Update dummy version tag in tests * Cancel running CI jobs if new changes are pushed to a PR * Run pre-commit autoupdate and unpin flake8 plugins * Add a second dummy test that checks top-level imports * Drop 3.8 support * Run linting with new black rules and fix warnings stacklevel warning * Reinforce python3.9 in pre-commit to avoid linting slosh * Use Python 3.9 compatible type hints for pydantic objects --- .github/workflows/testing.yml | 31 ++++--------- .pre-commit-config.yaml | 30 ++++++------ doc/source/_static/index-images/api.svg | 4 +- .../_static/index-images/contributor.svg | 4 +- .../_static/index-images/getting_started.svg | 2 +- .../_static/index-images/image_licences.txt | 2 +- .../_static/index-images/user_guide.svg | 4 +- doc/source/_static/jobflow_remote.css | 2 +- doc/source/api/index.rst | 2 +- doc/source/conf.py | 34 +++++++------- doc/source/dev/index.rst | 2 +- doc/source/glossary.rst | 1 - doc/source/index.rst | 2 +- doc/source/user/basics.rst | 1 - doc/source/user/building.rst | 2 +- doc/source/user/whatisjobflowremote.rst | 2 +- pyproject.toml | 5 +- src/jobflow_remote/cli/flow.py | 1 - src/jobflow_remote/cli/formatting.py | 1 - src/jobflow_remote/cli/job.py | 1 - src/jobflow_remote/cli/utils.py | 1 - src/jobflow_remote/config/base.py | 46 +++++++++---------- src/jobflow_remote/config/helper.py | 2 +- src/jobflow_remote/config/settings.py | 1 - src/jobflow_remote/fireworks/launcher.py | 1 - src/jobflow_remote/fireworks/launchpad.py | 3 -- src/jobflow_remote/jobs/daemon.py | 3 +- src/jobflow_remote/jobs/jobcontroller.py | 2 - src/jobflow_remote/jobs/runner.py | 4 +- src/jobflow_remote/remote/data.py | 1 - src/jobflow_remote/utils/db.py | 6 +-- src/jobflow_remote/utils/log.py | 2 + tests/test_jobflow_remote.py | 12 ++++- 33 files changed, 100 insertions(+), 117 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 17c690d7..77fc5180 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -7,6 +7,12 @@ on: pull_request: +# Cancel running workflows when additional changes are pushed +# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#example-using-a-fallback-value +concurrency: + group: ${{ github.head_ref || github.run_id }} + cancel-in-progress: true + jobs: lint: runs-on: ubuntu-latest @@ -15,7 +21,7 @@ jobs: - uses: actions/setup-python@v4 with: - python-version: '3.8' + python-version: '3.9' cache: pip cache-dependency-path: pyproject.toml @@ -30,8 +36,9 @@ jobs: test: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: ['3.8', '3.9', '3.10', '3.11'] + python-version: ['3.9', '3.10', '3.11'] steps: - uses: actions/checkout@v3 @@ -49,23 +56,3 @@ jobs: - name: Test run: pytest --cov=jobflow_remote --cov-report=xml - - docs: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - uses: actions/setup-python@v4 - with: - python-version: '3.11' - cache: pip - cache-dependency-path: pyproject.toml - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install .[strict,docs] - - - name: Build - run: jupyter-book build docs --path-output docs_build diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3dc3212a..0c66c04a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,9 +1,9 @@ default_language_version: - python: python3 + python: python3.9 #exclude: '^src/{{ package_name }}/some/directory/' repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: check-yaml - id: fix-encoding-pragma @@ -11,15 +11,15 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/myint/autoflake - rev: v2.0.0 + rev: v2.2.1 hooks: - id: autoflake - repo: https://github.com/psf/black - rev: 22.12.0 + rev: 23.9.1 hooks: - id: black - repo: https://github.com/asottile/blacken-docs - rev: v1.12.1 + rev: 1.16.0 hooks: - id: blacken-docs additional_dependencies: [black] @@ -29,18 +29,18 @@ repos: hooks: - id: isort - repo: https://github.com/pycqa/flake8 - rev: 6.0.0 + rev: 6.1.0 hooks: - id: flake8 entry: pflake8 files: ^src/ additional_dependencies: - - pyproject-flake8==6.0.0 - - flake8-bugbear==22.12.6 - - flake8-typing-imports==1.14.0 - - flake8-docstrings==1.6.0 - - flake8-rst-docstrings==0.3.0 - - flake8-rst==0.8.0 + - pyproject-flake8 + - flake8-bugbear + - flake8-typing-imports + - flake8-docstrings + - flake8-rst-docstrings + - flake8-rst - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 hooks: @@ -49,7 +49,7 @@ repos: - id: rst-directive-colons - id: rst-inline-touching-normal - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.991 + rev: v1.6.0 hooks: - id: mypy files: ^src/ @@ -58,13 +58,13 @@ repos: - types-pkg_resources==0.1.2 - types-paramiko - repo: https://github.com/codespell-project/codespell - rev: v2.2.2 + rev: v2.2.6 hooks: - id: codespell stages: [commit, commit-msg] args: [--ignore-words-list, 'titel,statics,ba,nd,te,nin'] - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 + rev: v3.15.0 hooks: - id: pyupgrade args: [--py38-plus] diff --git a/doc/source/_static/index-images/api.svg b/doc/source/_static/index-images/api.svg index 9c883972..4616cf56 100644 --- a/doc/source/_static/index-images/api.svg +++ b/doc/source/_static/index-images/api.svg @@ -1,7 +1,7 @@ - @@ -47,4 +47,4 @@ s2.474-5.511,5.512-5.511C80.366,58.338,82.838,60.81,82.838,63.848z"/> - \ No newline at end of file + diff --git a/doc/source/_static/index-images/contributor.svg b/doc/source/_static/index-images/contributor.svg index ffd444ef..c4e1cf67 100644 --- a/doc/source/_static/index-images/contributor.svg +++ b/doc/source/_static/index-images/contributor.svg @@ -1,7 +1,7 @@ - @@ -10,4 +10,4 @@ c-2.9-2.9-2.2-8.1,2.1-9.9c2.2-0.9,4.7-0.3,6.3,1.4l19.7,19.7c1.2,1.2,1.8,2.7,1.8,4.2S43.8,56.55,42.6,57.75z M86.5,79.15h-36 c-3.3,0-6-2.7-6-6s2.7-6,6-6h36c3.3,0,6,2.7,6,6S89.8,79.15,86.5,79.15z"/> - \ No newline at end of file + diff --git a/doc/source/_static/index-images/getting_started.svg b/doc/source/_static/index-images/getting_started.svg index 20747f94..e1a013e2 100644 --- a/doc/source/_static/index-images/getting_started.svg +++ b/doc/source/_static/index-images/getting_started.svg @@ -2,4 +2,4 @@ - \ No newline at end of file + diff --git a/doc/source/_static/index-images/image_licences.txt b/doc/source/_static/index-images/image_licences.txt index 85276bc0..019a2e93 100644 --- a/doc/source/_static/index-images/image_licences.txt +++ b/doc/source/_static/index-images/image_licences.txt @@ -1,4 +1,4 @@ getting_started.svg: https://www.svgrepo.com/svg/393367/rocket (PD Licence) user_guide.svg: https://www.svgrepo.com/svg/75531/user-guide (CC0 Licence) api.svg: https://www.svgrepo.com/svg/157898/gears-configuration-tool (CC0 Licence) -contributor.svg: https://www.svgrepo.com/svg/57189/code-programing-symbol (CC0 Licence) \ No newline at end of file +contributor.svg: https://www.svgrepo.com/svg/57189/code-programing-symbol (CC0 Licence) diff --git a/doc/source/_static/index-images/user_guide.svg b/doc/source/_static/index-images/user_guide.svg index 6223a92c..c1fa68d2 100644 --- a/doc/source/_static/index-images/user_guide.svg +++ b/doc/source/_static/index-images/user_guide.svg @@ -1,7 +1,7 @@ - @@ -44,4 +44,4 @@ - \ No newline at end of file + diff --git a/doc/source/_static/jobflow_remote.css b/doc/source/_static/jobflow_remote.css index 4561c96c..11155222 100644 --- a/doc/source/_static/jobflow_remote.css +++ b/doc/source/_static/jobflow_remote.css @@ -159,4 +159,4 @@ html[data-theme=dark] h3 { .sd-btn-secondary:hover, .sd-btn-secondary:focus { background-color: var(--matgenix-dark-color) !important; border-color: var(--matgenix-dark-color) !important; -} \ No newline at end of file +} diff --git a/doc/source/api/index.rst b/doc/source/api/index.rst index b73bf979..ea34b44c 100644 --- a/doc/source/api/index.rst +++ b/doc/source/api/index.rst @@ -6,4 +6,4 @@ API Reference This is the API reference -.. include:: jobflow_remote.rst \ No newline at end of file +.. include:: jobflow_remote.rst diff --git a/doc/source/conf.py b/doc/source/conf.py index b680a104..9b9fa745 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Configuration file for the Sphinx documentation builder. # @@ -28,6 +27,7 @@ import jobflow_remote + # The short X.Y version version = jobflow_remote.__version__ # The full version, including alpha/beta/rc tags @@ -49,16 +49,16 @@ "sphinx.ext.todo", "sphinx.ext.viewcode", "sphinx.ext.napoleon", # For Google Python Style Guide - 'sphinx.ext.coverage', - 'sphinx.ext.doctest', - 'sphinx.ext.autosummary', - 'sphinx.ext.graphviz', - 'sphinx.ext.ifconfig', - 'matplotlib.sphinxext.plot_directive', - 'IPython.sphinxext.ipython_console_highlighting', - 'IPython.sphinxext.ipython_directive', - 'sphinx.ext.mathjax', - 'sphinx_design', + "sphinx.ext.coverage", + "sphinx.ext.doctest", + "sphinx.ext.autosummary", + "sphinx.ext.graphviz", + "sphinx.ext.ifconfig", + "matplotlib.sphinxext.plot_directive", + "IPython.sphinxext.ipython_console_highlighting", + "IPython.sphinxext.ipython_directive", + "sphinx.ext.mathjax", + "sphinx_design", ] # Add any paths that contain templates here, relative to this directory. @@ -78,7 +78,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = 'en' +language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -95,7 +95,7 @@ # a list of builtin themes. # # html_theme = 'sphinx_book_theme' -html_theme = 'pydata_sphinx_theme' +html_theme = "pydata_sphinx_theme" # html_favicon = '_static/favicon/favicon.ico' # Theme options are theme-specific and customize the look and feel of a theme @@ -108,7 +108,7 @@ # "image_dark": "index-image/contributor.svg", # }, "collapse_navigation": True, - 'announcement': ( + "announcement": ( "

" "Jobflow-Remote is still in beta phase. The API may change at any time." "

" @@ -137,14 +137,14 @@ # -- Options for HTMLHelp output --------------------------------------------- html_css_files = ["jobflow_remote.css"] -html_title = "%s v%s Manual" % (project, version) -html_last_updated_fmt = '%b %d, %Y' +html_title = f"{project} v{version} Manual" +html_last_updated_fmt = "%b %d, %Y" # html_css_files = ["numpy.css"] html_context = {"default_mode": "light"} html_use_modindex = True html_copy_source = False html_domain_indices = False -html_file_suffix = '.html' +html_file_suffix = ".html" # Output file base name for HTML help builder. htmlhelp_basename = "jobflow_remote_doc" diff --git a/doc/source/dev/index.rst b/doc/source/dev/index.rst index 88e18c08..4b7eca16 100644 --- a/doc/source/dev/index.rst +++ b/doc/source/dev/index.rst @@ -4,4 +4,4 @@ Contributing to Jobflow-Remote ############################## -Here are the things that can be done. \ No newline at end of file +Here are the things that can be done. diff --git a/doc/source/glossary.rst b/doc/source/glossary.rst index eff30732..bbdfcb41 100644 --- a/doc/source/glossary.rst +++ b/doc/source/glossary.rst @@ -11,4 +11,3 @@ Glossary Worker The description of a given resource where to execute flows. - diff --git a/doc/source/index.rst b/doc/source/index.rst index 609ce301..e4a05105 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -102,4 +102,4 @@ Jobflow-Remote is a package to submit jobflow flows remotely. .. This is not really the index page, that is found in _templates/indexcontent.html The toctree content here will be added to the - top of the template header \ No newline at end of file + top of the template header diff --git a/doc/source/user/basics.rst b/doc/source/user/basics.rst index bfaa0b3c..b76a13bd 100644 --- a/doc/source/user/basics.rst +++ b/doc/source/user/basics.rst @@ -9,4 +9,3 @@ fundamental Jobflow-Remote ideas and philosophy. .. .. toctree:: :maxdepth: 1 - diff --git a/doc/source/user/building.rst b/doc/source/user/building.rst index 93af682e..27837520 100644 --- a/doc/source/user/building.rst +++ b/doc/source/user/building.rst @@ -4,4 +4,4 @@ Building from source ==================== Get the source from the git repository. -Install it with pip install . \ No newline at end of file +Install it with pip install . diff --git a/doc/source/user/whatisjobflowremote.rst b/doc/source/user/whatisjobflowremote.rst index 093dee2e..772bf54d 100644 --- a/doc/source/user/whatisjobflowremote.rst +++ b/doc/source/user/whatisjobflowremote.rst @@ -5,4 +5,4 @@ What is Jobflow-Remote? ======================= Jobflow-Remote is ... -TODO: add the features that it has. \ No newline at end of file +TODO: add the features that it has. diff --git a/pyproject.toml b/pyproject.toml index 71fb42ab..3d9bd4bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,6 @@ authors = [{ name = "Guido Petretto", email = "guido.petretto@matgenix.com" }] dynamic = ["version"] classifiers = [ "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -24,7 +23,7 @@ classifiers = [ "Topic :: Other/Nonlisted Topic", "Topic :: Scientific/Engineering", ] -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies =[ "jobflow[strict]", "pydantic<2", @@ -81,7 +80,7 @@ max-line-length = 88 max-doc-length = 88 select = "C, E, F, W, B" extend-ignore = "E203, W503, E501, F401, RST21" -min-python-version = "3.8.0" +min-python-version = "3.9.0" docstring-convention = "numpy" rst-roles = "class, func, ref, obj" diff --git a/src/jobflow_remote/cli/flow.py b/src/jobflow_remote/cli/flow.py index a13a2a6e..3ba4b0f6 100644 --- a/src/jobflow_remote/cli/flow.py +++ b/src/jobflow_remote/cli/flow.py @@ -171,7 +171,6 @@ def flow_info( flow_ids = [jf_id] with loading_spinner(): - jc = JobController() flows_info = jc.get_flows_info( diff --git a/src/jobflow_remote/cli/formatting.py b/src/jobflow_remote/cli/formatting.py index 7f942481..2b9fcf72 100644 --- a/src/jobflow_remote/cli/formatting.py +++ b/src/jobflow_remote/cli/formatting.py @@ -142,7 +142,6 @@ def format_job_info(job_info: JobInfo, show_none: bool = False): def format_flow_info(flow_info: FlowInfo): - title = f"Flow: {flow_info.name} - {flow_info.flow_id} - {flow_info.state.name}" table = Table(title=title) table.title_style = "bold" diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index 07f35780..9b215db1 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -159,7 +159,6 @@ def job_info( db_id, job_id = get_job_db_ids(job_db_id, job_index) with loading_spinner(): - jc = JobController() job_info = jc.get_job_info( diff --git a/src/jobflow_remote/cli/utils.py b/src/jobflow_remote/cli/utils.py index 355d0a03..ca250628 100644 --- a/src/jobflow_remote/cli/utils.py +++ b/src/jobflow_remote/cli/utils.py @@ -205,7 +205,6 @@ def convert_metadata(string_metadata: str | None) -> dict | None: def get_start_date(start_date: datetime | None, days: int | None, hours: int | None): - if start_date and (start_date.year, start_date.month, start_date.day) == ( 1900, 1, diff --git a/src/jobflow_remote/config/base.py b/src/jobflow_remote/config/base.py index 0317a61c..c0818e70 100644 --- a/src/jobflow_remote/config/base.py +++ b/src/jobflow_remote/config/base.py @@ -1,11 +1,9 @@ -from __future__ import annotations - import abc import logging import traceback from enum import Enum from pathlib import Path -from typing import Annotated, Literal +from typing import Annotated, Literal, Optional, Union from jobflow import JobStore from pydantic import BaseModel, Extra, Field, validator @@ -36,7 +34,7 @@ class RunnerOptions(BaseModel): 30, description="Delay between subsequent advancement of the job's remote state (seconds)", ) - lock_timeout: int | None = Field( + lock_timeout: Optional[int] = Field( 86400, description="Time to consider the lock on a document expired and can be overridden (seconds)", ) @@ -115,16 +113,16 @@ class WorkerBase(BaseModel): description="Absolute path of the directory of the worker where subfolders for " "executing the calculation will be created" ) - resources: dict | None = Field( + resources: Optional[dict] = Field( None, description="A dictionary defining the default resources requested to the " "scheduler. Used to fill in the QToolKit template", ) - pre_run: str | None = Field( + pre_run: Optional[str] = Field( None, description="String with commands that will be executed before the execution of the Job", ) - post_run: str | None = Field( + post_run: Optional[str] = Field( None, description="String with commands that will be executed after the execution of the Job", ) @@ -234,13 +232,13 @@ class RemoteWorker(WorkerBase): host: str = Field(description="The host to which to connect") user: str = Field(None, description="Login username") port: int = Field(None, description="Port number") - password: str | None = Field(None, description="Login password") - key_filename: str | list[str] | None = Field( + password: Optional[str] = Field(None, description="Login password") + key_filename: Optional[Union[str, list[str]]] = Field( None, description="The filename, or list of filenames, of optional private key(s) " "and/or certs to try for authentication", ) - passphrase: str | None = Field( + passphrase: Optional[str] = Field( None, description="Passphrase used for decrypting private keys" ) gateway: str = Field( @@ -259,10 +257,10 @@ class RemoteWorker(WorkerBase): description="Whether to send environment variables 'inline' as prefixes in " "front of command strings", ) - keepalive: int | None = Field( + keepalive: Optional[int] = Field( 60, description="Keepalive value in seconds passed to paramiko's transport" ) - shell_cmd: str | None = Field( + shell_cmd: Optional[str] = Field( "bash", description="The shell command used to execute the command remotely. If None " "the command is executed directly", @@ -318,7 +316,7 @@ def cli_info(self) -> dict: ) -WorkerConfig = Annotated[LocalWorker | RemoteWorker, Field(discriminator="type")] +WorkerConfig = Annotated[Union[LocalWorker, RemoteWorker], Field(discriminator="type")] class ExecutionConfig(BaseModel): @@ -326,14 +324,16 @@ class ExecutionConfig(BaseModel): Configuration to be set before and after the execution of a Job. """ - modules: list[str] | None = Field(None, description="list of modules to be loaded") - export: dict[str, str] | None = Field( + modules: Optional[list[str]] = Field( + None, description="list of modules to be loaded" + ) + export: Optional[dict[str, str]] = Field( None, description="dictionary with variable to be exported" ) - pre_run: str | None = Field( + pre_run: Optional[str] = Field( None, description="Other commands to be executed before the execution of a job" ) - post_run: str | None = Field( + post_run: Optional[str] = Field( None, description="Commands to be executed after the execution of a job" ) @@ -347,20 +347,20 @@ class Project(BaseModel): """ name: str = Field(description="The name of the project") - base_dir: str | None = Field( + base_dir: Optional[str] = Field( None, description="The base directory containing the project related files. Default " "is a folder with the project name inside the projects folder", ) - tmp_dir: str | None = Field( + tmp_dir: Optional[str] = Field( None, description="Folder where remote files are copied. Default a 'tmp' folder in base_dir", ) - log_dir: str | None = Field( + log_dir: Optional[str] = Field( None, description="Folder containing all the logs. Default a 'log' folder in base_dir", ) - daemon_dir: str | None = Field( + daemon_dir: Optional[str] = Field( None, description="Folder containing daemon related files. Default to a 'daemon' " "folder in base_dir", @@ -390,11 +390,11 @@ class Project(BaseModel): description="The JobStore used for the input. Can contain the monty " "serialized dictionary or the Store int the Jobflow format", ) - metadata: dict | None = Field( + metadata: Optional[dict] = Field( None, description="A dictionary with metadata associated to the project" ) - def get_jobstore(self) -> JobStore | None: + def get_jobstore(self) -> Optional[JobStore]: """ Generate an instance of the JobStore based on the configuration diff --git a/src/jobflow_remote/config/helper.py b/src/jobflow_remote/config/helper.py index a7e24c9a..063e1af5 100644 --- a/src/jobflow_remote/config/helper.py +++ b/src/jobflow_remote/config/helper.py @@ -2,7 +2,6 @@ import logging import traceback -from pathlib import Path from jobflow import JobStore from maggma.core import Store @@ -134,6 +133,7 @@ def _check_workdir(worker: WorkerBase, host: BaseHost) -> str | None: try: canary_file = worker.work_dir / ".jf_heartbeat" host.write_text_file(canary_file, "\n") + return None except FileNotFoundError as exc: raise FileNotFoundError( f"Could not write to {canary_file} on {worker.host}. Does the folder exist on the remote?\nThe folder should be specified as an absolute path with no shell expansions or environment variables." diff --git a/src/jobflow_remote/config/settings.py b/src/jobflow_remote/config/settings.py index 4b0610b1..82096b1c 100644 --- a/src/jobflow_remote/config/settings.py +++ b/src/jobflow_remote/config/settings.py @@ -10,7 +10,6 @@ class JobflowRemoteSettings(BaseSettings): - config_file: str = Field( DEFAULT_CONFIG_FILE_PATH, description="Location of the config file for jobflow remote.", diff --git a/src/jobflow_remote/fireworks/launcher.py b/src/jobflow_remote/fireworks/launcher.py index 0da92980..a666b0ec 100644 --- a/src/jobflow_remote/fireworks/launcher.py +++ b/src/jobflow_remote/fireworks/launcher.py @@ -32,7 +32,6 @@ def checkout_remote( 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!") diff --git a/src/jobflow_remote/fireworks/launchpad.py b/src/jobflow_remote/fireworks/launchpad.py index 2ae68c9a..8536ae1e 100644 --- a/src/jobflow_remote/fireworks/launchpad.py +++ b/src/jobflow_remote/fireworks/launchpad.py @@ -610,7 +610,6 @@ def get_remote_run( 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) @@ -697,7 +696,6 @@ def get_wf_fw_data( sort: dict | None = None, limit: int = 0, ) -> list[dict]: - pipeline: list[dict] = [ { "$lookup": { @@ -767,7 +765,6 @@ def get_fw_launch_remote_run_data( sort: dict | None = None, limit: int = 0, ) -> list[dict]: - # only take the most recent launch pipeline: list[dict] = [ { diff --git a/src/jobflow_remote/jobs/daemon.py b/src/jobflow_remote/jobs/daemon.py index 7753812a..32c1e6eb 100644 --- a/src/jobflow_remote/jobs/daemon.py +++ b/src/jobflow_remote/jobs/daemon.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging import subprocess from enum import Enum @@ -56,7 +58,6 @@ class DaemonStatus(Enum): class DaemonManager: - conf_template = Template(supervisord_conf_str) def __init__( diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index 3740092e..8596a373 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -147,7 +147,6 @@ def _build_query_wf( end_date: datetime | None = None, name: str | None = None, ) -> dict: - 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)): @@ -391,7 +390,6 @@ def rerun_jobs( return fw_ids def reset(self, reset_output: bool = False, max_limit: int = 25) -> bool: - password = datetime.now().strftime("%Y-%m-%d") if max_limit == 0 else None try: self.rlpad.reset( diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index df22c082..f5cdcddd 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -240,11 +240,11 @@ def lock_and_update( error, fail_now, set_output = function(doc) except ConfigError: error = traceback.format_exc() - warnings.warn(error) + warnings.warn(error, stacklevel=2) fail_now = True except Exception: error = traceback.format_exc() - warnings.warn(error) + warnings.warn(error, stacklevel=2) lock.update_on_release = self._prepare_lock_update( doc, error, fail_now, set_output, state.next diff --git a/src/jobflow_remote/remote/data.py b/src/jobflow_remote/remote/data.py index 65d5e53d..9945a259 100644 --- a/src/jobflow_remote/remote/data.py +++ b/src/jobflow_remote/remote/data.py @@ -76,7 +76,6 @@ def get_remote_store_filenames(store: JobStore) -> list[str]: def update_store(store, remote_store, save): - # TODO is it correct? data = list(remote_store.query(load=save)) if len(data) > 1: diff --git a/src/jobflow_remote/utils/db.py b/src/jobflow_remote/utils/db.py index cc1a46ca..c1025dbc 100644 --- a/src/jobflow_remote/utils/db.py +++ b/src/jobflow_remote/utils/db.py @@ -12,7 +12,6 @@ class MongoLock: - LOCK_KEY = "_lock_id" LOCK_TIME_KEY = "_lock_time" @@ -98,7 +97,7 @@ def acquire(self): msg = ( f"The lock was broken. Previous lock id: {self.get_lock_id(result)}" ) - warnings.warn(msg) + warnings.warn(msg, stacklevel=2) self.locked_document = result @@ -119,7 +118,7 @@ def release(self, exc_type, exc_val, exc_tb): # Check if the lock was successfully released if result.modified_count == 0: msg = f"Could not release lock for document {self.locked_document['_id']}" - warnings.warn(msg) + warnings.warn(msg, stacklevel=2) self.locked_document = None @@ -128,6 +127,5 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): - if self.locked_document: self.release(exc_type, exc_val, exc_tb) diff --git a/src/jobflow_remote/utils/log.py b/src/jobflow_remote/utils/log.py index 9dec2ec3..81cd93f8 100644 --- a/src/jobflow_remote/utils/log.py +++ b/src/jobflow_remote/utils/log.py @@ -1,5 +1,7 @@ """Tools for logging.""" +from __future__ import annotations + import logging import logging.config from pathlib import Path diff --git a/tests/test_jobflow_remote.py b/tests/test_jobflow_remote.py index fce15f0a..64ff8957 100644 --- a/tests/test_jobflow_remote.py +++ b/tests/test_jobflow_remote.py @@ -2,4 +2,14 @@ def test_version(): - assert __version__ == "0.1.0" + assert __version__ == "0.0.1" + + +def test_imports(): + """This test triggers all the top-level imports by importing + the global `SETTINGS`. + + """ + from jobflow_remote import SETTINGS # noqa + + ... From d8aa3c13e80ac6df8786313365e88641e00b4aa1 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Thu, 12 Oct 2023 00:31:54 +0200 Subject: [PATCH 67/89] add index to the folder name --- src/jobflow_remote/jobs/runner.py | 8 +++++--- src/jobflow_remote/remote/data.py | 4 ++-- src/jobflow_remote/utils/data.py | 7 +++++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index f5cdcddd..1089aea5 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -336,7 +336,7 @@ 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, job.index, fw_job_data.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 @@ -436,7 +436,7 @@ def download(self, doc): 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) + local_path = get_job_path(job.uuid, job.index, loca_base_dir) makedirs_p(local_path) @@ -465,7 +465,9 @@ def complete_launch(self, doc): fw_job_data = self.get_fw_data(doc) loca_base_dir = Path(self.project.tmp_dir, "download") - local_path = get_job_path(fw_job_data.job.uuid, loca_base_dir) + local_path = get_job_path( + fw_job_data.job.uuid, fw_job_data.job.index, loca_base_dir + ) try: remote_data = loadfn(Path(local_path, "FW_offline.json"), cls=None) diff --git a/src/jobflow_remote/remote/data.py b/src/jobflow_remote/remote/data.py index 9945a259..7b347728 100644 --- a/src/jobflow_remote/remote/data.py +++ b/src/jobflow_remote/remote/data.py @@ -11,13 +11,13 @@ from jobflow_remote.utils.data import uuid_to_path -def get_job_path(job_id: str, base_path: str | Path | None = None) -> str: +def get_job_path(job_id: str, index: int, base_path: str | Path | None = None) -> str: if base_path: base_path = Path(base_path) else: base_path = Path() - relative_path = uuid_to_path(job_id) + relative_path = uuid_to_path(job_id, index) return str(base_path / relative_path) diff --git a/src/jobflow_remote/utils/data.py b/src/jobflow_remote/utils/data.py index 89a1dbd0..0acd0cb5 100644 --- a/src/jobflow_remote/utils/data.py +++ b/src/jobflow_remote/utils/data.py @@ -77,7 +77,7 @@ def check_dict_keywords(obj: Any, keywords: list[str]) -> bool: return False -def uuid_to_path(uuid: str, num_subdirs: int = 3, subdir_len: int = 2): +def uuid_to_path(uuid: str, index: int = 1, num_subdirs: int = 3, subdir_len: int = 2): u = UUID(uuid) u_hex = u.hex @@ -87,8 +87,11 @@ def uuid_to_path(uuid: str, num_subdirs: int = 3, subdir_len: int = 2): for i in range(0, num_subdirs * subdir_len, subdir_len) ] + # add the index to the final dir name + dir_name = f"{uuid}_{index}" + # Combine root directory and subdirectories to form the final path - return os.path.join(*subdirs, uuid) + return os.path.join(*subdirs, dir_name) def store_from_dict(store_dict: dict) -> Store: From 5fe779f90fa490686ee81da69c1d0dfd8daab408 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Thu, 12 Oct 2023 01:00:13 +0200 Subject: [PATCH 68/89] 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 69/89] 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 70/89] 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 71/89] 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 72/89] 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 73/89] 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 74/89] 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 75/89] 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 76/89] 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 77/89] 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 78/89] 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 79/89] 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 80/89] 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 e6b5a8c8aeae3422d2976a941d17b90863a74f88 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Mon, 11 Dec 2023 09:51:46 +0100 Subject: [PATCH 81/89] Avoid Job deserialization in Runner. --- src/jobflow_remote/jobs/jobcontroller.py | 132 ++++++++++++++--------- src/jobflow_remote/jobs/run.py | 12 +++ src/jobflow_remote/jobs/runner.py | 77 ++++++------- src/jobflow_remote/remote/data.py | 87 +++++++++++++-- 4 files changed, 208 insertions(+), 100 deletions(-) diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index ea2ba117..fee578c9 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -14,6 +14,7 @@ import pymongo from jobflow import JobStore, OnMissing from maggma.stores import MongoStore +from monty.json import MontyDecoder from monty.serialization import loadfn from qtoolkit.core.data_objects import CancelStatus, QResources @@ -2395,23 +2396,53 @@ def add_flow( def _append_flow( self, - job_doc: JobDoc, + job_doc: dict, flow_dict: dict, - new_flow: jobflow.Flow | jobflow.Job | list[jobflow.Job], + new_flow_dict: dict, worker: str, response_type: DynamicResponseType, exec_config: ExecutionConfig | None = None, resources: QResources | None = None, ): - from jobflow.core.flow import get_flow + from jobflow import Flow, Job + + decoder = MontyDecoder() + + def deserialize_partial_flow(in_dict: dict): + """ + Recursively deserialize a Flow dictionary, avoiding the deserialization + of all the elements that may require external packages. + """ + if in_dict.get("@class", None) == "Flow": + jobs = [deserialize_partial_flow(d) for d in in_dict.get("jobs")] + flow_init = { + k: v + for k, v in in_dict.items() + if k not in ("@module", "@class", "@version", "job") + } + flow_init["jobs"] = jobs + return Flow(**flow_init) + # if it is not a Flow, should be a Job + job_init = { + k: v + for k, v in in_dict.items() + if k not in ("@module", "@class", "@version") + } + job_init["config"] = decoder.process_decoded(job_init["config"]) + return Job(**job_init) - new_flow = get_flow(new_flow, allow_external_references=True) + # It is sure that the new_flow_dict is a serialized Flow (and not Job + # or list[Job]), because the get_flow has already been applied at run + # time, during the remote execution. + # Recursive deserialize the Flow without deserializing function and + # arguments to take advantage of standard Flow/Job methods. + new_flow = deserialize_partial_flow(new_flow_dict) # get job parents if response_type == DynamicResponseType.REPLACE: - job_parents = job_doc.parents + job_parents = job_doc["parents"] else: - job_parents = [(job_doc.uuid, job_doc.index)] + job_parents = [(job_doc["uuid"], job_doc["index"])] # add new jobs to flow flow_dict = dict(flow_dict) @@ -2451,7 +2482,8 @@ def _append_flow( # 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}}} + {"parents": job_doc["uuid"]}, + {"$push": {"parents": {"$each": leaf_uuids}}}, ) # flow_dict["updated_on"] = datetime.utcnow() @@ -2544,7 +2576,7 @@ def checkout_job( # 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 + self, job_doc: dict, 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. @@ -2552,11 +2584,12 @@ def complete_job( # 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 + 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 + host_flow_id = job_doc["job"]["hosts"][-1] if not out_path.exists(): msg = ( f"The output file {OUT_FILENAME} was not present in the download " @@ -2565,13 +2598,16 @@ def complete_job( self.checkin_job( job_doc, flow_lock.locked_document, response=None, error=msg ) - self.update_flow_state(job_doc.job.hosts[-1]) + self.update_flow_state(host_flow_id) return True - out = loadfn(out_path) - doc_update = {"start_time": out["start_time"]} + # do not deserialize the response or stored data, saves time and + # avoids the need for packages to be installed. + out = loadfn(out_path, cls=None) + decoder = MontyDecoder() + doc_update = {"start_time": decoder.process_decoded(out["start_time"])} # update the time of the JobDoc, will be used in the checkin - end_time = out.get("end_time") + end_time = decoder.process_decoded(out.get("end_time")) if end_time: doc_update["end_time"] = end_time @@ -2584,7 +2620,7 @@ def complete_job( error=error, doc_update=doc_update, ) - self.update_flow_state(job_doc.job.hosts[-1]) + self.update_flow_state(host_flow_id) return True response = out.get("response") @@ -2601,24 +2637,18 @@ def complete_job( error=msg, doc_update=doc_update, ) - self.update_flow_state(job_doc.job.hosts[-1]) + self.update_flow_state(host_flow_id) 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) + update_store(store, remote_store) self.checkin_job( job_doc, flow_lock.locked_document, response=response, doc_update=doc_update, ) - self.update_flow_state(job_doc.job.hosts[-1]) + self.update_flow_state(host_flow_id) return True elif flow_lock.unavailable_document: # raising the error if the lock could not be acquired leaves @@ -2632,9 +2662,9 @@ def complete_job( def checkin_job( self, - job_doc: JobDoc, + job_doc: dict, flow_dict: dict, - response: jobflow.Response | None, + response: dict | None, error: str | None = None, doc_update: dict | None = None, ): @@ -2644,51 +2674,47 @@ def checkin_job( # handle response else: new_state = JobState.COMPLETED.value - if response.replace is not None: + if response["replace"] is not None: self._append_flow( job_doc, flow_dict, - response.replace, + response["replace"], response_type=DynamicResponseType.REPLACE, - worker=job_doc.worker, - exec_config=job_doc.exec_config, - resources=job_doc.resources, + worker=job_doc["worker"], + exec_config=job_doc["exec_config"], + resources=job_doc["resources"], ) - if response.addition is not None: + if response["addition"] is not None: self._append_flow( job_doc, flow_dict, - response.addition, + response["addition"], response_type=DynamicResponseType.ADDITION, - worker=job_doc.worker, - exec_config=job_doc.exec_config, - resources=job_doc.resources, + worker=job_doc["worker"], + exec_config=job_doc["exec_config"], + resources=job_doc["resources"], ) - if response.detour is not None: + if response["detour"] is not None: self._append_flow( job_doc, flow_dict, - response.detour, + response["detour"], response_type=DynamicResponseType.DETOUR, - worker=job_doc.worker, - exec_config=job_doc.exec_config, - resources=job_doc.resources, + worker=job_doc["worker"], + exec_config=job_doc["exec_config"], + resources=job_doc["resources"], ) - 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["stored_data"] is not None: + stored_data = response["stored_data"] - if response.stop_children: - self.stop_children(job_doc.uuid) + if response["stop_children"]: + self.stop_children(job_doc["uuid"]) - if response.stop_jobflow: - self.stop_jobflow(job_uuid=job_doc.uuid) + if response["stop_jobflow"]: + self.stop_jobflow(job_uuid=job_doc["uuid"]) if not doc_update: doc_update = {} @@ -2697,16 +2723,16 @@ def checkin_job( ) result = self.jobs.update_one( - {"uuid": job_doc.uuid, "index": job_doc.index}, {"$set": doc_update} + {"uuid": job_doc["uuid"], "index": job_doc["index"]}, {"$set": doc_update} ) if result.modified_count == 0: raise RuntimeError( - f"The job {job_doc.uuid} index {job_doc.index} 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"] + 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? diff --git a/src/jobflow_remote/jobs/run.py b/src/jobflow_remote/jobs/run.py index aa391bf2..9f1282e6 100644 --- a/src/jobflow_remote/jobs/run.py +++ b/src/jobflow_remote/jobs/run.py @@ -10,6 +10,7 @@ from pathlib import Path from jobflow import JobStore, initialize_logger +from jobflow.core.flow import get_flow from jobflow.core.job import Job from monty.os import cd from monty.serialization import dumpfn, loadfn @@ -62,6 +63,17 @@ def run_remote_job(run_dir: str | Path = "."): # The output of the response has already been stored in the store. response.output = None + + # Convert to Flow the dynamic responses before dumping the output. + # This is required so that the response does not need to be + # deserialized and converted by to Flows by the runner. + if response.addition: + response.addition = get_flow(response.addition) + if response.detour: + response.detour = get_flow(response.detour) + if response.replace: + response.replace = get_flow(response.replace) + output = { "response": response, "error": error, diff --git a/src/jobflow_remote/jobs/runner.py b/src/jobflow_remote/jobs/runner.py index 7aab0a39..eceb34ac 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -13,6 +13,7 @@ from fireworks import FWorker from jobflow.utils import suuid +from monty.json import MontyDecoder from monty.os import makedirs_p from qtoolkit.core.data_objects import QState, SubmissionStatus @@ -27,13 +28,14 @@ ) 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.data import IN_FILENAME, OUT_FILENAME, RemoteError from jobflow_remote.jobs.state import JobState from jobflow_remote.remote.data import ( get_job_path, get_remote_in_file, get_remote_store, get_remote_store_filenames, + resolve_job_dict_args, ) from jobflow_remote.remote.host import BaseHost from jobflow_remote.remote.queue import ERR_FNAME, OUT_FNAME, QueueManager, set_name_out @@ -263,17 +265,16 @@ def upload(self, lock): db_id = doc["db_id"] logger.debug(f"upload db_id: {db_id}") - job_doc = JobDoc(**doc) - job = job_doc.job + job_dict = doc["job"] - worker = self.get_worker(job_doc.worker) - host = self.get_host(job_doc.worker) + worker = self.get_worker(doc["worker"]) + host = self.get_host(doc["worker"]) 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() try: - job.resolve_args(store=store, inplace=True) + resolve_job_dict_args(job_dict, store) finally: try: store.close() @@ -281,10 +282,10 @@ def upload(self, lock): 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 + if "db_id" not in job_dict["metadata"]: + job_dict["metadata"]["db_id"] = db_id - remote_path = get_job_path(job.uuid, job.index, worker.work_dir) + remote_path = get_job_path(job_dict["uuid"], job_dict["index"], 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 @@ -302,7 +303,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) + serialized_input = get_remote_in_file(job_dict, remote_store) path_file = Path(remote_path, IN_FILENAME) host.put(serialized_input, str(path_file)) @@ -316,34 +317,34 @@ def submit(self, lock): doc = lock.locked_document logger.debug(f"submit db_id: {doc['db_id']}") - job_doc = JobDoc(**doc) - job = job_doc.job + job_dict = doc["job"] - worker = self.get_worker(job_doc.worker) + worker_name = doc["worker"] + worker = self.get_worker(worker_name) - remote_path = Path(job_doc.run_dir) + remote_path = Path(doc["run_dir"]) script_commands = [f"jf execution run {remote_path}"] - queue_manager = self.get_queue_manager(job_doc.worker) + queue_manager = self.get_queue_manager(worker_name) qout_fpath = remote_path / OUT_FNAME qerr_fpath = remote_path / ERR_FNAME - exec_config = job_doc.exec_config + exec_config = 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(job_doc.exec_config) + exec_config = ExecutionConfig.parse_obj(exec_config) # define an empty default if it is not set exec_config = exec_config or ExecutionConfig() - if job_doc.worker in self.batch_workers: + if worker_name in self.batch_workers: resources = {} set_name_out( - resources, job.name, out_fpath=qout_fpath, err_fpath=qerr_fpath + resources, job_dict["name"], out_fpath=qout_fpath, err_fpath=qerr_fpath ) shell_manager = queue_manager.get_shell_manager() shell_manager.write_submission_script( @@ -357,8 +358,8 @@ def submit(self, lock): create_submit_dir=False, ) - self.batch_workers[job_doc.worker].submit_job( - job_id=job_doc.uuid, index=job_doc.index + self.batch_workers[worker_name].submit_job( + job_id=doc["uuid"], index=doc["index"] ) lock.update_on_release = { "$set": { @@ -366,9 +367,14 @@ def submit(self, lock): } } else: - resources = job_doc.resources or worker.resources or {} + # decode in case it contains a QResources. It was not deserialized before. + resources = ( + MontyDecoder().process_decoded(doc["resources"]) + or worker.resources + or {} + ) set_name_out( - resources, job.name, out_fpath=qout_fpath, err_fpath=qerr_fpath + resources, job_dict["name"], out_fpath=qout_fpath, err_fpath=qerr_fpath ) pre_run = worker.pre_run or "" @@ -402,8 +408,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 + if worker_name in self.limited_workers: + self.limited_workers[worker_name]["current"] += 1 else: raise RemoteError( f"unhandled submission status {submit_result.status}", True @@ -413,20 +419,22 @@ 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 + # job_doc = JobDoc(**doc) + job_dict = doc["job"] # If the worker is local do not copy the files in the temporary folder # 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) + worker = self.get_worker(doc["worker"]) if not worker.is_local: - host = self.get_host(job_doc.worker) + host = self.get_host(doc["worker"]) store = self.jobstore - remote_path = job_doc.run_dir + remote_path = doc["run_dir"] local_base_dir = Path(self.project.tmp_dir, "download") - local_path = get_job_path(job.uuid, job.index, local_base_dir) + local_path = get_job_path( + job_dict["uuid"], job_dict["index"], local_base_dir + ) makedirs_p(local_path) @@ -441,9 +449,7 @@ def download(self, lock): 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" - ) + err_msg = f"file {remote_file_path} for job {job_dict['uuid']} does not exist" logger.error(err_msg) raise RemoteError(err_msg, True) @@ -463,9 +469,8 @@ def complete_job(self, lock): local_path = get_job_path(doc["uuid"], doc["index"], local_base_dir) try: - job_doc = JobDoc(**doc) store = self.jobstore - completed = self.job_controller.complete_job(job_doc, local_path, store) + completed = self.job_controller.complete_job(doc, local_path, store) except json.JSONDecodeError: # if an empty file is copied this error can appear, do not retry diff --git a/src/jobflow_remote/remote/data.py b/src/jobflow_remote/remote/data.py index a8ad9539..201460be 100644 --- a/src/jobflow_remote/remote/data.py +++ b/src/jobflow_remote/remote/data.py @@ -80,20 +80,85 @@ def get_remote_store_filenames(store: JobStore) -> list[str]: return filenames -def update_store(store, remote_store, save): - # TODO is it correct? - data = list(remote_store.query(load=save)) - if len(data) > 1: - raise RuntimeError("something wrong with the remote store") - - store.connect() +def update_store(store: JobStore, remote_store: JobStore): try: - for d in data: - data = dict(d) - data.pop("_id") - store.update(data, key=["uuid", "index"], save=save) + store.connect() + remote_store.connect() + + additional_stores = set(store.additional_stores.keys()) + additional_remote_stores = set(remote_store.additional_stores.keys()) + + # This checks that the additional stores in the two stores match correctly. + # It should not happen if not because of a bug, so the check could maybe be + # removed + if additional_stores ^ additional_remote_stores: + raise ValueError( + f"The additional stores in the local and remote JobStore do not " + f"match: {additional_stores ^ additional_remote_stores}" + ) + + # copy the data store by store, not using directly the JobStore. + # This avoids the need to deserialize the store content and the "save" + # argument. + for add_store_name, remote_add_store in remote_store.additional_stores.items(): + add_store = store.additional_stores[add_store_name] + + for d in remote_add_store.query(): + data = dict(d) + data.pop("_id", None) + add_store.update(data) + main_docs_list = list(remote_store.docs_store.query({})) + if len(main_docs_list) > 1: + raise RuntimeError( + "The downloaded output store contains more than one document" + ) + main_doc = main_docs_list[0] + main_doc.pop("_id", None) + store.docs_store.update(main_doc) finally: try: store.close() except Exception: logging.error(f"error while closing the store {store}", exc_info=True) + try: + remote_store.close() + except Exception: + logging.error( + f"error while closing the remote store {remote_store}", exc_info=True + ) + + +def resolve_job_dict_args(job_dict: dict, store: JobStore) -> dict: + """ + Resolve the references in a serialized Job. + + Similar to Job.resolve_args, but without the need to deserialize the Job. + The references are resolved inplace. + + Parameters + ---------- + job_dict + The serialized version of a Job. + store + The JobStore from where the references should be resolved. + + Returns + ------- + The updated version of the input dictionary with references resolved. + """ + from jobflow.core.reference import OnMissing, find_and_resolve_references + + on_missing = OnMissing(job_dict["config"]["on_missing_references"]) + cache: dict[str, Any] = {} + resolved_args = find_and_resolve_references( + job_dict["function_args"], store, cache=cache, on_missing=on_missing + ) + resolved_kwargs = find_and_resolve_references( + job_dict["function_kwargs"], store, cache=cache, on_missing=on_missing + ) + resolved_args = tuple(resolved_args) + + # substitution is in place + job_dict["function_args"] = resolved_args + job_dict["function_kwargs"] = resolved_kwargs + return job_dict From 1a664cc06b4dc721c3318f845c5cad6fc2d98ff8 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Tue, 12 Dec 2023 16:22:38 +0100 Subject: [PATCH 82/89] 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 83/89] 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 8d0e8241d373e708579a870e782d822cf3cca21a Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Tue, 12 Dec 2023 17:49:36 +0100 Subject: [PATCH 84/89] fix output --- src/jobflow_remote/jobs/jobcontroller.py | 2 +- src/jobflow_remote/jobs/runner.py | 4 ---- src/jobflow_remote/remote/data.py | 8 ++++++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index fee578c9..17efa8dc 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -2641,7 +2641,7 @@ def complete_job( return True remote_store = get_remote_store(store, local_path) - update_store(store, remote_store) + update_store(store, remote_store, 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 8c9791dc..d22633aa 100644 --- a/src/jobflow_remote/jobs/runner.py +++ b/src/jobflow_remote/jobs/runner.py @@ -348,10 +348,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_dict["metadata"]: - job_dict["metadata"]["db_id"] = db_id - remote_path = get_job_path(job_dict["uuid"], job_dict["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 201460be..15b80059 100644 --- a/src/jobflow_remote/remote/data.py +++ b/src/jobflow_remote/remote/data.py @@ -80,7 +80,7 @@ def get_remote_store_filenames(store: JobStore) -> list[str]: return filenames -def update_store(store: JobStore, remote_store: JobStore): +def update_store(store: JobStore, remote_store: JobStore, db_id: int): try: store.connect() remote_store.connect() @@ -114,7 +114,11 @@ def update_store(store: JobStore, remote_store: JobStore): ) main_doc = main_docs_list[0] main_doc.pop("_id", None) - store.docs_store.update(main_doc) + # Set the db_id here and not directly in the Job's metadata to prevent + # it from being propagated to its children/replacements. + if "db_id" not in main_doc["metadata"]: + main_doc["metadata"]["db_id"] = db_id + store.docs_store.update(main_doc, key=["uuid", "index"]) finally: try: store.close() From 33ba56550ca8da2ac878cb0d881e3627aae1e62d Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Wed, 13 Dec 2023 16:30:56 +0100 Subject: [PATCH 85/89] 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 86/89] 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 From 2c383f65ed2a067d94827fcc3575e6b9dd4d56fd Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Fri, 22 Dec 2023 11:07:19 +0100 Subject: [PATCH 87/89] breaking: change db_id type to str --- src/jobflow_remote/__init__.py | 1 + src/jobflow_remote/cli/job.py | 2 +- src/jobflow_remote/cli/types.py | 2 +- src/jobflow_remote/cli/utils.py | 24 +++---- src/jobflow_remote/config/base.py | 77 ++++++++++++++++------- src/jobflow_remote/config/helper.py | 2 +- src/jobflow_remote/config/jobconfig.py | 41 +++++++++++- src/jobflow_remote/config/manager.py | 5 +- src/jobflow_remote/jobs/data.py | 14 ++--- src/jobflow_remote/jobs/jobcontroller.py | 80 ++++++++++++++---------- 10 files changed, 165 insertions(+), 83 deletions(-) diff --git a/src/jobflow_remote/__init__.py b/src/jobflow_remote/__init__.py index 31ffb336..dbb67772 100644 --- a/src/jobflow_remote/__init__.py +++ b/src/jobflow_remote/__init__.py @@ -1,6 +1,7 @@ """jobflow-remote is a python package to run jobflow workflows on remote resources""" from jobflow_remote._version import __version__ +from jobflow_remote.config.jobconfig import set_run_config from jobflow_remote.config.manager import ConfigManager from jobflow_remote.config.settings import JobflowRemoteSettings from jobflow_remote.jobs.jobcontroller import JobController diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index f6a50464..19869b00 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -703,7 +703,7 @@ def resources( raise_on_error: raise_on_error_opt = False, ): """ - Set the worker for the selected Jobs. Only READY or WAITING Jobs. + Set the resources for the selected Jobs. Only READY or WAITING Jobs. """ resources_value = str_to_dict(resources_value) diff --git a/src/jobflow_remote/cli/types.py b/src/jobflow_remote/cli/types.py index 48e3535d..35c43f41 100644 --- a/src/jobflow_remote/cli/types.py +++ b/src/jobflow_remote/cli/types.py @@ -34,7 +34,7 @@ db_ids_opt = Annotated[ - Optional[List[int]], + Optional[List[str]], typer.Option( "--db-id", "-did", diff --git a/src/jobflow_remote/cli/utils.py b/src/jobflow_remote/cli/utils.py index 7d5b76fa..9e0b303c 100644 --- a/src/jobflow_remote/cli/utils.py +++ b/src/jobflow_remote/cli/utils.py @@ -179,14 +179,13 @@ def loading_spinner(processing: bool = True): yield progress -def get_job_db_ids(job_db_id: str | int, job_index: int | None): - try: - db_id = int(job_db_id) - job_id = None - except ValueError: +def get_job_db_ids(job_db_id: str, job_index: int | None): + if check_valid_uuid(job_db_id, raise_on_error=False): db_id = None job_id = job_db_id - check_valid_uuid(job_id) + else: + db_id = job_db_id + job_id = None if job_index and db_id is not None: out_console.print( @@ -239,15 +238,18 @@ def wrapper(*args, **kwargs): return wrapper -def check_valid_uuid(uuid_str): +def check_valid_uuid(uuid_str, raise_on_error: bool = True) -> bool: try: uuid_obj = uuid.UUID(uuid_str) if str(uuid_obj) == uuid_str: - return + return True except ValueError: pass - raise typer.BadParameter(f"UUID {uuid_str} is in the wrong format.") + if raise_on_error: + raise typer.BadParameter(f"UUID {uuid_str} is in the wrong format.") + else: + return False def str_to_dict(string: str | None) -> dict | None: @@ -291,10 +293,10 @@ def get_start_date(start_date: datetime | None, days: int | None, hours: int | N def execute_multi_jobs_cmd( single_cmd: Callable, multi_cmd: Callable, - job_db_id: str | int | None = None, + job_db_id: str | None = None, job_index: int | None = None, job_ids: list[str] | None = None, - db_ids: int | list[int] | None = None, + db_ids: str | list[str] | None = None, flow_ids: str | list[str] | None = None, state: JobState | None = None, start_date: datetime | None = None, diff --git a/src/jobflow_remote/config/base.py b/src/jobflow_remote/config/base.py index e920bc94..d54d2406 100644 --- a/src/jobflow_remote/config/base.py +++ b/src/jobflow_remote/config/base.py @@ -128,6 +128,7 @@ class BatchConfig(BaseModel): None, description="Maximum time after which a job will not submit more jobs (seconds). To help avoid hitting the walltime", ) + model_config = ConfigDict(extra="forbid") class WorkerBase(BaseModel): @@ -385,6 +386,54 @@ class ExecutionConfig(BaseModel): model_config = ConfigDict(extra="forbid") +class QueueConfig(BaseModel): + store: dict = Field( + 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. Should be subclass of a MongoStore, as it " + "requires to perform MongoDB actions. The collection is used to store the " + "jobs", + validate_default=True, + ) + flows_collection: str = Field( + "flows", + description="The name of the collection containing information about the flows. " + "Taken from the same database as the one defined in the store", + ) + auxiliary_collection: str = Field( + "jf_auxiliary", + description="The name of the collection containing auxiliary information. " + "Taken from the same database as the one defined in the store", + ) + db_id_prefix: Optional[str] = Field( + None, + description="a string defining the prefix added to the integer ID associated " + "to each Job in the database", + ) + + @field_validator("store") + def check_store(cls, store: dict) -> dict: + """ + Check that the queue configuration could be converted to a Store. + """ + if store: + try: + deserialized_store = store_from_dict(store) + except Exception as e: + raise ValueError( + f"error while converting queue to a maggma store. Error: {traceback.format_exc()}" + ) from e + if not isinstance(deserialized_store, MongoStore): + raise ValueError( + "The queue store should be a subclass of a " + f"MongoStore: {deserialized_store.__class__} instead" + ) + return store + + model_config = ConfigDict(extra="forbid") + + class Project(BaseModel): """ The configurations of a Project. @@ -422,13 +471,9 @@ class Project(BaseModel): description="A dictionary with the worker name as keys and the worker " "configuration as values", ) - queue: dict = Field( - 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. Should be subclass of a MongoStore, as it " - "requires to perform MongoDB actions.", - validate_default=True, + queue: QueueConfig = Field( + description="The configuration of the Store used to store the states of" + "the Jobs and the Flows", ) exec_config: dict[str, ExecutionConfig] = Field( default_factory=dict, @@ -468,7 +513,7 @@ def get_queue_store(self): ------- A maggma Store """ - return store_from_dict(self.queue) + return store_from_dict(self.queue.store) def get_job_controller(self): from jobflow_remote.jobs.jobcontroller import JobController @@ -530,22 +575,6 @@ def check_jobstore(cls, jobstore: dict) -> dict: ) from e return jobstore - @field_validator("queue") - def check_queue(cls, queue: dict) -> dict: - """ - Check that the queue configuration could be converted to a Store. - """ - if queue: - try: - 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/helper.py b/src/jobflow_remote/config/helper.py index 063e1af5..68dd6863 100644 --- a/src/jobflow_remote/config/helper.py +++ b/src/jobflow_remote/config/helper.py @@ -27,7 +27,7 @@ def generate_dummy_project(name: str, full: bool = False) -> Project: workers["example_local"] = local_worker exec_config = {"example_config": generate_dummy_exec_config()} - queue = generate_dummy_queue() + queue = {"store": generate_dummy_queue()} jobstore = generate_dummy_jobstore() diff --git a/src/jobflow_remote/config/jobconfig.py b/src/jobflow_remote/config/jobconfig.py index 97452f5b..ac1e5c05 100644 --- a/src/jobflow_remote/config/jobconfig.py +++ b/src/jobflow_remote/config/jobconfig.py @@ -15,19 +15,54 @@ def set_run_config( function_filter: Callable = None, exec_config: str | ExecutionConfig | None = None, resources: dict | QResources | None = None, -): - if not exec_config and not resources: - return + worker: str | None = None, +) -> Flow | Job: + """ + Modify in place a Flow or a Job by setting the properties in the + "manager_config" entry in the JobConfig associated to each Job + matching the filter. Uses the Flow/Job update_config() method, + so follows the same conventions, also setting the options in + the config_updates of the Job, to allow setting the same properties + also in dynamically generated Jobs. + + Parameters + ---------- + flow_or_job + A Flow or a Job to be modified + name_filter + A filter for the job name. Only jobs with a matching name will be updated. + Includes partial matches, e.g. "ad" will match a job with the name "adder". + function_filter + A filter for the job function. Only jobs with a matching function will be + updated. + exec_config + The execution configuration to be added to the selected Jobs. + resources + The resources to be set for the selected Jobs. + worker + The worker where the selected Jobs will be executed. + + Returns + ------- + Flow or Job + The modified object. + """ + if not exec_config and not resources and not worker: + return flow_or_job config: dict = {"manager_config": {}} if exec_config: config["manager_config"]["exec_config"] = exec_config if resources: config["manager_config"]["resources"] = resources + if worker: + config["manager_config"]["worker"] = worker flow_or_job.update_config( config=config, name_filter=name_filter, function_filter=function_filter ) + return flow_or_job + def load_job_store(project: str | None = None) -> JobStore: """ diff --git a/src/jobflow_remote/config/manager.py b/src/jobflow_remote/config/manager.py index 25ea6bea..b233d29b 100644 --- a/src/jobflow_remote/config/manager.py +++ b/src/jobflow_remote/config/manager.py @@ -154,7 +154,10 @@ def get_project_data(self, project_name: str | None = None) -> ProjectData: project_name = self.select_project_name(project_name) if project_name not in self.projects_data: - raise ConfigError(f"The selected project {project_name} does not exist") + raise ConfigError( + f"The selected project {project_name} does not exist " + "or could not be parsed correctly" + ) return self.projects_data[project_name] diff --git a/src/jobflow_remote/jobs/data.py b/src/jobflow_remote/jobs/data.py index 8be6de2a..43d81303 100644 --- a/src/jobflow_remote/jobs/data.py +++ b/src/jobflow_remote/jobs/data.py @@ -19,7 +19,7 @@ def get_initial_job_doc_dict( job: Job, parents: Optional[list[str]], - db_id: int, + db_id: str, worker: str, exec_config: Optional[ExecutionConfig], resources: Optional[Union[dict, QResources]], @@ -121,7 +121,7 @@ class JobInfo(BaseModel): uuid: str index: int - db_id: int + db_id: str worker: str name: str state: JobState @@ -226,7 +226,7 @@ class JobDoc(BaseModel): job: Job uuid: str index: int - db_id: int + db_id: str worker: str state: JobState remote: RemoteInfo = RemoteInfo() @@ -296,7 +296,7 @@ class FlowDoc(BaseModel): # 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) + ids: list[tuple[str, str, int]] = Field(default_factory=list) def as_db_dict(self) -> dict: """ @@ -348,7 +348,7 @@ def add_descendants(uuid): return list(descendants) @cached_property - def ids_mapping(self) -> dict[str, dict[int, int]]: + def ids_mapping(self) -> dict[str, dict[int, str]]: d: dict = defaultdict(dict) for db_id, job_id, index in self.ids: @@ -373,7 +373,7 @@ class FlowInfo(BaseModel): Mainly for visualization purposes. """ - db_ids: list[int] + db_ids: list[str] job_ids: list[str] job_indexes: list[int] flow_id: str @@ -439,7 +439,7 @@ def from_query_dict(cls, d) -> "FlowInfo": ) @cached_property - def ids_mapping(self) -> dict[str, dict[int, int]]: + def ids_mapping(self) -> dict[str, dict[int, str]]: d: dict = defaultdict(dict) for db_id, job_id, index in zip(self.db_ids, self.job_ids, self.job_indexes): diff --git a/src/jobflow_remote/jobs/jobcontroller.py b/src/jobflow_remote/jobs/jobcontroller.py index ace0e32e..f4d53338 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -120,9 +120,7 @@ def from_project_name(cls, project_name: str | None = None) -> JobController: """ 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 cls.from_project(project=project) @classmethod def from_project(cls, project: Project) -> JobController: @@ -140,8 +138,16 @@ def from_project(cls, project: Project) -> JobController: An instance of JobController associated with the project. """ queue_store = project.get_queue_store() + flows_collection = project.queue.flows_collection + auxiliary_collection = project.queue.auxiliary_collection jobstore = project.get_jobstore() - return cls(queue_store=queue_store, jobstore=jobstore, project=project) + return cls( + queue_store=queue_store, + jobstore=jobstore, + flows_collection=flows_collection, + auxiliary_collection=auxiliary_collection, + project=project, + ) def close(self): """ @@ -164,7 +170,7 @@ def close(self): def _build_query_job( self, job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, - db_ids: int | list[int] | None = None, + db_ids: str | list[str] | None = None, flow_ids: str | list[str] | None = None, state: JobState | None = None, locked: bool = False, @@ -258,7 +264,7 @@ def _build_query_job( def _build_query_flow( self, job_ids: str | list[str] | None = None, - db_ids: int | list[int] | None = None, + db_ids: str | list[str] | None = None, flow_ids: str | None = None, state: FlowState | None = None, start_date: datetime | None = None, @@ -361,7 +367,7 @@ def get_jobs_info_query(self, query: dict = None, **kwargs) -> list[JobInfo]: def get_jobs_info( self, job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, - db_ids: int | list[int] | None = None, + db_ids: str | list[str] | None = None, flow_ids: str | list[str] | None = None, state: JobState | None = None, start_date: datetime | None = None, @@ -450,7 +456,7 @@ def get_jobs_doc_query(self, query: dict = None, **kwargs) -> list[JobDoc]: def get_jobs_doc( self, job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, - db_ids: int | list[int] | None = None, + db_ids: str | list[str] | None = None, flow_ids: str | list[str] | None = None, state: JobState | None = None, start_date: datetime | None = None, @@ -515,7 +521,7 @@ def get_jobs_doc( @staticmethod def generate_job_id_query( - db_id: int | None = None, + db_id: str | None = None, job_id: str | None = None, job_index: int | None = None, ) -> tuple[dict, list | None]: @@ -563,7 +569,7 @@ def generate_job_id_query( def get_job_info( self, job_id: str | None = None, - db_id: int | None = None, + db_id: str | None = None, job_index: int | None = None, ) -> JobInfo | None: """ @@ -600,7 +606,7 @@ def _many_jobs_action( method: Callable, action_description: str, job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, - db_ids: int | list[int] | None = None, + db_ids: str | list[str] | None = None, flow_ids: str | list[str] | None = None, state: JobState | None = None, start_date: datetime | None = None, @@ -688,7 +694,7 @@ def _many_jobs_action( def rerun_jobs( self, job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, - db_ids: int | list[int] | None = None, + db_ids: str | list[str] | None = None, flow_ids: str | list[str] | None = None, state: JobState | None = None, start_date: datetime | None = None, @@ -766,7 +772,7 @@ def rerun_jobs( def rerun_job( self, job_id: str | None = None, - db_id: int | None = None, + db_id: str | None = None, job_index: int | None = None, force: bool = False, wait: int | None = None, @@ -1064,7 +1070,7 @@ def _reset_remote(self, doc: dict) -> dict: def _set_job_properties( self, values: dict, - db_id: int | None = None, + db_id: str | None = None, job_id: str | None = None, job_index: int | None = None, wait: int | None = None, @@ -1136,7 +1142,7 @@ def set_job_state( self, state: JobState, job_id: str | None = None, - db_id: int | None = None, + db_id: str | None = None, job_index: int | None = None, wait: int | None = None, break_lock: bool = False, @@ -1191,7 +1197,7 @@ def set_job_state( def retry_jobs( self, job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, - db_ids: int | list[int] | None = None, + db_ids: str | list[str] | None = None, flow_ids: str | list[str] | None = None, state: JobState | None = None, start_date: datetime | None = None, @@ -1265,7 +1271,7 @@ def retry_jobs( def retry_job( self, job_id: str | None = None, - db_id: int | None = None, + db_id: str | None = None, job_index: int | None = None, wait: int | None = None, break_lock: bool = False, @@ -1348,7 +1354,7 @@ def retry_job( def pause_jobs( self, job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, - db_ids: int | list[int] | None = None, + db_ids: str | list[str] | None = None, flow_ids: str | list[str] | None = None, state: JobState | None = None, start_date: datetime | None = None, @@ -1416,7 +1422,7 @@ def pause_jobs( def cancel_jobs( self, job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, - db_ids: int | list[int] | None = None, + db_ids: str | list[str] | None = None, flow_ids: str | list[str] | None = None, state: JobState | None = None, start_date: datetime | None = None, @@ -1491,7 +1497,7 @@ def cancel_jobs( def cancel_job( self, job_id: str | None = None, - db_id: int | None = None, + db_id: str | None = None, job_index: int | None = None, wait: int | None = None, break_lock: bool = False, @@ -1561,7 +1567,7 @@ def cancel_job( def pause_job( self, job_id: str | None = None, - db_id: int | None = None, + db_id: str | None = None, job_index: int | None = None, wait: int | None = None, ) -> list[int]: @@ -1612,7 +1618,7 @@ def pause_job( def play_jobs( self, job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, - db_ids: int | list[int] | None = None, + db_ids: str | list[str] | None = None, flow_ids: str | list[str] | None = None, state: JobState | None = None, start_date: datetime | None = None, @@ -1685,7 +1691,7 @@ def play_jobs( def play_job( self, job_id: str | None = None, - db_id: int | None = None, + db_id: str | None = None, job_index: int | None = None, wait: int | None = None, break_lock: bool = False, @@ -1765,7 +1771,7 @@ def set_job_run_properties( 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, + db_ids: str | list[str] | None = None, flow_ids: str | list[str] | None = None, state: JobState | None = None, start_date: datetime | None = None, @@ -1925,7 +1931,7 @@ def get_flow_job_aggreg( def get_flows_info( self, job_ids: str | list[str] | None = None, - db_ids: int | list[int] | None = None, + db_ids: str | list[str] | None = None, flow_ids: str | None = None, state: FlowState | None = None, start_date: datetime | None = None, @@ -2071,7 +2077,7 @@ def delete_flow(self, flow_id: str, delete_output: bool = False): def remove_lock_job( self, job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, - db_ids: int | list[int] | None = None, + db_ids: str | list[str] | None = None, flow_ids: str | list[str] | None = None, state: JobState | None = None, start_date: datetime | None = None, @@ -2134,7 +2140,7 @@ def remove_lock_job( def remove_lock_flow( self, job_ids: str | list[str] | None = None, - db_ids: int | list[int] | None = None, + db_ids: str | list[str] | None = None, flow_ids: str | None = None, state: FlowState | None = None, start_date: datetime | None = None, @@ -2319,7 +2325,7 @@ def get_job_info_by_job_uuid( def get_job_doc( self, job_id: str | None = None, - db_id: int | None = None, + db_id: str | None = None, job_index: int | None = None, ) -> JobDoc | None: query, sort = self.generate_job_id_query(db_id, job_id, job_index) @@ -2337,7 +2343,7 @@ 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, + db_ids: str | list[str] | None = None, flow_ids: str | list[str] | None = None, state: JobState | None = None, locked: bool = False, @@ -2364,7 +2370,7 @@ def count_flows( self, query: dict | None = None, job_ids: str | list[str] | None = None, - db_ids: int | list[int] | None = None, + db_ids: str | list[str] | None = None, flow_ids: str | None = None, state: FlowState | None = None, start_date: datetime | None = None, @@ -2399,7 +2405,7 @@ def add_flow( allow_external_references: bool = False, exec_config: ExecutionConfig | None = None, resources: dict | QResources | None = None, - ) -> list[int]: + ) -> list[str]: from jobflow.core.flow import get_flow flow = get_flow(flow, allow_external_references=allow_external_references) @@ -2418,7 +2424,11 @@ def add_flow( ) 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)): + for (job, parents), db_id_int in zip( + jobs_list, range(first_id, first_id + n_jobs) + ): + prefix = self.project.queue.db_id_prefix or "" + db_id = f"{prefix}{db_id_int}" db_ids.append(db_id) job_dicts.append( get_initial_job_doc_dict( @@ -2509,9 +2519,11 @@ def deserialize_partial_flow(in_dict: dict): job_dicts = [] flow_updates["$set"] = {} ids_to_push = [] - for (job, parents), db_id in zip( + for (job, parents), db_id_int in zip( jobs_list, range(first_id, first_id + n_new_jobs) ): + prefix = self.project.queue.db_id_prefix or "" + db_id = f"{prefix}{db_id_int}" # inherit the parents of the job to which we are appending parents = parents if parents else job_parents job_dicts.append( @@ -3146,7 +3158,7 @@ def lock_job_for_update( def lock_job_flow( self, job_id: str | None = None, - db_id: int | None = None, + db_id: str | None = None, job_index: int | None = None, wait: int | None = None, break_lock: bool = False, From 328c6dffa568f0db46394ceed3a501ca0a19355d Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Fri, 22 Dec 2023 11:52:58 +0100 Subject: [PATCH 88/89] breaking: rename CANCELLED JobState to USER_STOPPED and the related methods accordingly --- src/jobflow_remote/cli/job.py | 8 +++--- src/jobflow_remote/jobs/graph.py | 2 +- src/jobflow_remote/jobs/jobcontroller.py | 33 ++++++++++++++++-------- src/jobflow_remote/jobs/state.py | 9 +++---- 4 files changed, 30 insertions(+), 22 deletions(-) diff --git a/src/jobflow_remote/cli/job.py b/src/jobflow_remote/cli/job.py index 19869b00..d9a678cf 100644 --- a/src/jobflow_remote/cli/job.py +++ b/src/jobflow_remote/cli/job.py @@ -429,7 +429,7 @@ def play( @app_job.command() -def cancel( +def stop( job_db_id: job_db_id_arg = None, job_index: job_index_arg = None, job_id: job_ids_indexes_opt = None, @@ -448,7 +448,7 @@ def cancel( raise_on_error: raise_on_error_opt = False, ): """ - Cancel a Job. Only Jobs that did not complete or had an error can be cancelled. + Stop a Job. Only Jobs that did not complete or had an error can be stopped. The operation is irreversible. If possible, the associated job submitted to the remote queue will be cancelled. """ @@ -458,8 +458,8 @@ def cancel( jc = get_job_controller() execute_multi_jobs_cmd( - single_cmd=jc.cancel_job, - multi_cmd=jc.cancel_jobs, + single_cmd=jc.stop_job, + multi_cmd=jc.stop_jobs, job_db_id=job_db_id, job_index=job_index, job_ids=job_id, diff --git a/src/jobflow_remote/jobs/graph.py b/src/jobflow_remote/jobs/graph.py index 2fadfc7b..3e8c5c83 100644 --- a/src/jobflow_remote/jobs/graph.py +++ b/src/jobflow_remote/jobs/graph.py @@ -231,7 +231,7 @@ def add_subgraph(nested_hosts_hierarchy, indent_level=0): JobState.FAILED.value: RED_COLOR, JobState.PAUSED.value: "#EAE200", JobState.STOPPED.value: RED_COLOR, - JobState.CANCELLED.value: RED_COLOR, + JobState.USER_STOPPED.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 f4d53338..5ed1a5f3 100644 --- a/src/jobflow_remote/jobs/jobcontroller.py +++ b/src/jobflow_remote/jobs/jobcontroller.py @@ -1419,7 +1419,7 @@ def pause_jobs( wait=wait, ) - def cancel_jobs( + def stop_jobs( self, job_ids: tuple[str, int] | list[tuple[str, int]] | None = None, db_ids: str | list[str] | None = None, @@ -1434,8 +1434,8 @@ def cancel_jobs( break_lock: bool = False, ) -> list[int]: """ - Cancel selected Jobs. Only Jobs in the READY and all the running states - can be cancelled. + Stop selected Jobs. Only Jobs in the READY and all the running states + can be stopped. The action is not reversible. Parameters @@ -1479,8 +1479,8 @@ def cancel_jobs( List of db_ids of the updated Jobs. """ return self._many_jobs_action( - method=self.cancel_job, - action_description="cancelling", + method=self.stop_job, + action_description="stopping", job_ids=job_ids, db_ids=db_ids, flow_ids=flow_ids, @@ -1494,7 +1494,7 @@ def cancel_jobs( break_lock=break_lock, ) - def cancel_job( + def stop_job( self, job_id: str | None = None, db_id: str | None = None, @@ -1503,8 +1503,8 @@ def cancel_job( break_lock: bool = False, ) -> list[int]: """ - Cancel a single Job. Only Jobs in the READY and all the running states - can be cancelled. + Stop a single Job. Only Jobs in the READY and all the running states + can be stopped. Selected by db_id or uuid+index. Only one among db_id and job_id should be defined. The action is not reversible. @@ -1556,12 +1556,16 @@ def cancel_job( f"Failed cancelling the process for Job {job_doc['uuid']} {job_doc['index']}", exc_info=True, ) - updated_states = {job_id: {job_index: JobState.CANCELLED}} + job_id = job_doc["uuid"] + job_index = job_doc["index"] + updated_states = {job_id: {job_index: JobState.USER_STOPPED}} 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}} + job_lock.update_on_release = { + "$set": {"state": JobState.USER_STOPPED.value} + } return [job_lock.locked_document["db_id"]] def pause_job( @@ -1607,6 +1611,9 @@ def pause_job( job_lock_kwargs=job_lock_kwargs, flow_lock_kwargs=flow_lock_kwargs, ) as (job_lock, flow_lock): + job_doc = job_lock.locked_document + job_id = job_doc["uuid"] + job_index = job_doc["index"] updated_states = {job_id: {job_index: JobState.PAUSED}} self.update_flow_state( flow_uuid=flow_lock.locked_document["uuid"], @@ -1738,6 +1745,8 @@ def play_job( flow_lock_kwargs=flow_lock_kwargs, ) as (job_lock, flow_lock): job_doc = job_lock.locked_document + job_id = job_doc["uuid"] + job_index = job_doc["index"] on_missing = job_doc["job"]["config"]["on_missing_references"] allow_failed = on_missing != OnMissing.ERROR.value @@ -2834,7 +2843,9 @@ def refresh_children(self, job_uuids: list[str]) -> list[int]: 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)) + allowed_states.extend( + (JobState.FAILED.value, JobState.USER_STOPPED.value) + ) if job["state"] == JobState.WAITING.value and all( [jobs_mapping[p]["state"] in allowed_states for p in job["parents"]] ): diff --git a/src/jobflow_remote/jobs/state.py b/src/jobflow_remote/jobs/state.py index 4d84188a..86c2f7cf 100644 --- a/src/jobflow_remote/jobs/state.py +++ b/src/jobflow_remote/jobs/state.py @@ -21,7 +21,7 @@ class JobState(Enum): FAILED = "FAILED" PAUSED = "PAUSED" STOPPED = "STOPPED" - CANCELLED = "CANCELLED" + USER_STOPPED = "USER_STOPPED" BATCH_SUBMITTED = "BATCH_SUBMITTED" BATCH_RUNNING = "BATCH_RUNNING" @@ -44,7 +44,7 @@ def short_value(self) -> str: JobState.FAILED: "F", JobState.PAUSED: "P", JobState.STOPPED: "ST", - JobState.CANCELLED: "CA", + JobState.USER_STOPPED: "CA", JobState.BATCH_SUBMITTED: "BS", JobState.BATCH_RUNNING: "BR", } @@ -85,7 +85,6 @@ class FlowState(Enum): FAILED = "FAILED" PAUSED = "PAUSED" STOPPED = "STOPPED" - CANCELLED = "CANCELLED" @classmethod def from_jobs_states( @@ -122,10 +121,8 @@ def from_jobs_states( # when applying the change in the remote state. elif any(js == JobState.FAILED for js in jobs_states): return cls.FAILED - elif any(js == JobState.STOPPED for js in jobs_states): + elif any(js in (JobState.STOPPED, JobState.USER_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: From f2ddac4597d47905364819c893d7f178e9c8b362 Mon Sep 17 00:00:00 2001 From: Guido Petretto Date: Fri, 22 Dec 2023 12:13:53 +0100 Subject: [PATCH 89/89] more documentation --- doc/source/_static/code/project_simple.yaml | 13 +- doc/source/_static/img/project_erdantic.png | Bin 266090 -> 290317 bytes doc/source/conf.py | 1 + doc/source/user/index.rst | 2 + doc/source/user/install.rst | 2 +- doc/source/user/projectconf.rst | 157 ++++++++++++- doc/source/user/quickstart.rst | 22 +- doc/source/user/states.rst | 234 ++++++++++++++++++++ doc/source/user/tuning.rst | 149 +++++++++++++ pyproject.toml | 1 + 10 files changed, 562 insertions(+), 19 deletions(-) create mode 100644 doc/source/user/states.rst diff --git a/doc/source/_static/code/project_simple.yaml b/doc/source/_static/code/project_simple.yaml index d73335ab..513ccfb0 100644 --- a/doc/source/_static/code/project_simple.yaml +++ b/doc/source/_static/code/project_simple.yaml @@ -9,12 +9,13 @@ workers: host: remote.host.net user: bob queue: - type: MongoStore - host: localhost - database: db_name - username: bob - password: secret_password - collection_name: jobs + store: + type: MongoStore + host: localhost + database: db_name + username: bob + password: secret_password + collection_name: jobs exec_config: {} jobstore: docs_store: diff --git a/doc/source/_static/img/project_erdantic.png b/doc/source/_static/img/project_erdantic.png index b8fc3fbe2856dfccfdda615895c7d6ce8de4c752..82adce0c113133c455fd2dc82cd50936b51b2e0f 100644 GIT binary patch literal 290317 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

v*t$|+f`r)U&!#k@b%4pQjZ4JT4(_TU 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 diff --git a/doc/source/conf.py b/doc/source/conf.py index 3f9e254c..759ad05b 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -61,6 +61,7 @@ "sphinx_design", "sphinx_copybutton", "sphinxcontrib.autodoc_pydantic", + "sphinxcontrib.mermaid", ] # Add any paths that contain templates here, relative to this directory. diff --git a/doc/source/user/index.rst b/doc/source/user/index.rst index baddba9b..ec7c92ed 100644 --- a/doc/source/user/index.rst +++ b/doc/source/user/index.rst @@ -15,6 +15,8 @@ details are found in :ref:`reference`. install projectconf quickstart + tuning + states .. toctree:: :caption: Advanced usage and interoperability diff --git a/doc/source/user/install.rst b/doc/source/user/install.rst index cfbc7bf2..a1871f45 100644 --- a/doc/source/user/install.rst +++ b/doc/source/user/install.rst @@ -182,7 +182,7 @@ Jobstore -------- The ``jobstore`` used for ``jobflow``. Its definition is equivalent to the one used in -``jobflow``'s configuration file. See `Jobflows documentation `_ +``jobflow``'s configuration file. See `Jobflow's documentation `_ for more details. It can be the same as in the :ref:`queue simple config` or a different one. Check diff --git a/doc/source/user/projectconf.rst b/doc/source/user/projectconf.rst index 46a24d26..b14a0bdc 100644 --- a/doc/source/user/projectconf.rst +++ b/doc/source/user/projectconf.rst @@ -1,15 +1,21 @@ .. _projectconf: -********************** -Projects configuration -********************** +*********************************** +Projects configuration and Settings +*********************************** -Jobflow-remote allows to handle multiple configurations, defined projects. Since +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. +of a single project. The handling of :ref:`projectconf multi` 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 +Aside from the project options, a set of :ref:`projectconf general` can be also be +configured through environment variables or an additional configuration file. + +Project options +=============== + +The project 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. @@ -32,8 +38,7 @@ section below, while an example for a full configuration file can be generated r 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 ---------------- @@ -53,6 +58,8 @@ For all these folders the paths are set with defaults, but can be customised set 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. +.. _projectconf worker: + Workers ------- @@ -72,18 +79,118 @@ type all the credentials to connect automatically should be provided. The best o 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``. +The other key property of the workers is the ``scheduler_type``. It can be any of the +values supported by the `qtoolkit `_. Typical +values are: + +* ``shell``: the Job is executed directly in the shell. No queue will be used. + If not limited, all the Jobs can be executed simultaneously +* ``slurm``, ``pbs``, ...: the name of a queueing system. The job will be submitted + to the queue with the selected resources. + +Another mandatory argument is ``work_dir``, indicating the full path for a folder +on the worker machine where the Jobs will be actually executed. + +It is possible to optionally select default values for keywords like ``pre_run`` +and ``resources``, that can be overridden for individual Jobs. Note that these +configurations will be applied to *all* the Jobs executed by the worker. These +are thus more suitable for generic settings (e.g. the activation of a python +environment, or loading of some modules), rather than for the specific code +configurations. Those can better be set with the :ref:`projectconf execconfig`. .. note:: If a single worker is defined it will be used as default in the submission of new Flows. +JobStore +-------- + +The ``jobstore`` value contains a dictionary representation of the standard +``JobStore`` object defined in jobflow. It can either be the serialized +version as obtained by the ``as_dict`` module or the representation defined +in `jobflow's documentation `_. + +This ``JobStore`` will be used to store the outputs of all the Jobs executed +in this project. + +.. note:: + + The ``JobStore`` should be defined in jobflow-remote's configuration file. + The content of the standard jobflow configuration file will be ignored. + +Queue Store +----------- + +The ``queue`` element contains the definition of the database containing the +state of the Jobs and Flows. The subelement ``store`` should contain the +representation of a `maggma `_ ``Store``. +As for the ``JobStore`` it can be either its serialization or the same kind +of representation used for the ``docs_store`` in jobflow's configuration file. + +The collection defined by the ``Store`` will contain the information about the +state of the ``Job``, while two more collections will be created. The name +of these two collections can also be customized. + +.. warning:: + + The queue ``Store`` should be a subclass of the ``MongoStore`` and currently + it should be based on a real MongoDB (e.g. not a ``JSONStore``). + Some key operations required by jobflow-remote on the collections are not + supported by any file based MongoDB implementation at the moment. + +.. _projectconf execconfig: + +Execution configurations +------------------------ + +It is possible to define a set of ``ExecutionConfig`` objects to quickly set up +configurations for different kind of Jobs and Flow. The ``exec_config`` key +contains a dictionary where the keys are the names associated to the configurations +and for each a set of instruction to be set before and after the execution of the Job. + +Runner options +-------------- + +The behaviour of the ``Runner`` can also be customized to some extent. In particular +the ``Runner`` implements an exponential backoff mechanism for retrying when an +operation of updating of a Job state fails. The amount of tries and the delay between +them can be set ``max_step_attempts`` and ``delta_retry`` values. In addition some +reasonable values are set for the delay between each check of the database for +different kind of actions performed by the ``Runner``. These intervals can be +changed to better fit your needs. Remind that reducing these intervals too much +may put unnecessary strain on the database. + +Metadata +-------- + +While this does currently not play any role in the execution of jobflow-remote, +this can be used to include some additional information to be used by external +tools or to quickly distinguish a configuration file among others. + +.. _projectconf multi: Multiple Projects ================= -asdsd +While a single project can be enough for most of the users and for beginners, +it may be convenient to define different databases, configurations and python +environments to work on different topics. For this reason jobflow-remote will +consider as potential projects configuration all the YAML, JSON and TOML files +in the ``~/.jfremote`` folder. There is no additional procedure required to +add or remove project, aside from creating/deleting a project configuration file. + +If more than one project is present and a specific one is not selected, the +code will always stop asking for a project to be specified. Python functions +like ``submit_flow`` and ``get_jobstore`` accept a ``project`` argument to +specify which project should be considered. For the command line interface +a general ``-p`` allows to select a project for the command that is being +executed:: + + jf -p another_project job list + +To define a default project for all the functions and commands executed on the +system or in a specific cell see the :ref:`projectconf general` section. .. _project detailed specs: @@ -92,3 +199,31 @@ Project specs .. raw:: html :file: ../_static/project_schema.html + +.. _projectconf general: + +General Settings +================ + +Aside from the project specific configuration, a few options can also be +defined in general. There are two ways to set these options: + +* set the value in the ``~/.jfremote.yaml`` configuration file. +* export the variable name prepended by the ``jfremote`` prefix:: + + export jfremote_project=project_name + +.. note:: + + The name of the exported variables is case-insensitive (i.e. JFREMOTE_PROJECT + is equally valid). + +The most useful variable to set is the ``project`` one, allowing to select the +default project to be used in a multi-project environment. + +Other generic options are the location of the projects folder, instead of +``~/.jfremote`` (``projects_folder``) and the path to the ``~/.jfremote.yaml`` +file itself (``config_file``). + +Some customization options are also available for the behaviour of the CLI. +For more details see the API documentation :py:class:`jobflow_remote.config.settings.JobflowRemoteSettings`. diff --git a/doc/source/user/quickstart.rst b/doc/source/user/quickstart.rst index 4151633d..eb17ba61 100644 --- a/doc/source/user/quickstart.rst +++ b/doc/source/user/quickstart.rst @@ -56,9 +56,14 @@ This code will print an integer unique id associated to the submitted ``Job`` s. 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. + * If the argument is omitted the first worker in the project configuration is used. * In any case the worker is determined when the ``Job`` is inserted in the database. +.. warning:: + + Once the flow has been submitted to the database, any further change to the + ``Flow`` object will not be taken into account. + It is now possible to use the ``jf`` command line interface (CLI):: jf job list @@ -127,6 +132,11 @@ done before:: │ 1 │ add │ COMPLETED │ ae020c67-72f0-4805-858e-fe48644e4bb0 (1) │ local_shell │ 2023-12-19 16:44 │ └───────┴──────┴───────────┴───────────────────────────────────────────┴─────────────┴────────────────────┘ +.. note:: + + The ``Runner`` checks the states of the Jobs at regular intervals. A few seconds may + be required to have a change in the Job state. + 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 @@ -139,6 +149,16 @@ you can stop the process with:: By default the daemon will spawn several processes, each taking care of some of the actions listed above. +.. warning:: + + The ``stop`` command will send a ``SIGTERM`` command to the ``Runner`` processes, that + will terminate the action currently being performed before actually stopping. This should + prevent the presence on inconsistent states in the database. + However, if you believe the ``Runner`` is stuck or need to halt the ``Runner`` immediately + you can kill the processes with:: + + jf runner kill + Results ======= diff --git a/doc/source/user/states.rst b/doc/source/user/states.rst new file mode 100644 index 00000000..3bb14740 --- /dev/null +++ b/doc/source/user/states.rst @@ -0,0 +1,234 @@ +.. _states: + +****** +States +****** + + +Job States +********** + +During their execution by the ``Runner`` a Job can reach different states. +Each of the states describes the current status of the after the ``Runner`` +has finished switching from one state to another. + +Since the ``Runner`` can be stopped and will update the different states at +predefined intervals, it may be that the state does not reflect the +actual situation of a Job (for example it could be that the process of a +Job in the ``RUNNING`` state has finished, but the ``Runner`` did not +update its state yet. + +Description +=========== + +The states can then be grouped in + +* Waiting states: describing Jobs that has not started yet. +* Running states: states for which the ``Runner`` has started working on the Job +* Completed state: a state where the Job has been completed successfully +* Error states: states associated with some error in the Job, either programmatic + or during the execution. + +The list of Job states is defined in the :py:class:`jobflow_remote.jobs.state.JobState` +object. Here we present a list of each state with a short description. + +WAITING +------- + +Waiting state. A Job that has been inserted into the database but has +to wait for other Jobs to be completed before starting. + +READY +----- + +Waiting state. A Job that is ready to be executed by the ``Runner``. + +CHECKED_OUT +----------- + +Running state. A Job that has been selected by the ``Runner`` to +start its execution. + +UPLOADED +-------- + +Running state. All the inputs required by the Job has been copied +to the worker. + +SUBMITTED +--------- + +Running state. The Job has been submitted to the queueing +system of the worker. + +RUNNING +------- + +Running state. The ``Runner`` verified that the Job has started is being +executed on the worker. + +TERMINATED +---------- + +Running state. The process executing the Job on the worked has finished +running. No knowledge of whether this happened for an error or because +the Job was completed correctly is available at this point. + +DOWNLOADED +---------- + +Running state. The ``Runner`` has copied to the local machine all the +files containing the Job response and outputs to be stored. + +COMPLETED +--------- + +Completed state. A Job that has completed correctly. + +FAILED +------ + +Error state. The procedure to execute the Job completed correctly, but +an error happened during the execution of the Job's function, so the +Job did not complete successfully. + +REMOTE_ERROR +------------ + +Error state. An error occurred during the procedure to execute the Job. +For example the files could not be copied due to some network error and +the maximum number of attempts has been reached. The Job may or may not +be executed, depending on the action that generated the issue, but in +any case no information is available about it. This failure is independent +from the correct execution of the Job's function. + +PAUSED +------ + +Waiting state. The Job has been paused by the user and will not be +executed by the ``Runner``. A Job in this state can be started again. + +STOPPED +------- + +Error state. The Job was stopped by another Job as a consequence of a +``stop_jobflow`` or ``stop_children`` actions in the Job's response. +This state cannot be modified. + +USER_STOPPED +--------- + +Error state. A Job stopped by the user. This state cannot be modified. + +BATCH_SUBMITTED +--------------- + +Running state. A Job submitted for execution to a batch worker. Differs +from the ``SUBMITTED`` state since the ``Runner`` does not have to +check its state in the queueing system. + +BATCH_RUNNING +--------------- + +Running state. A Job that is being executed by a batch worker. Differs +from the ``RUNNING`` state since the ``Runner`` does not have to +check its state in the queueing system. + + +Evolution +========= + +If the state of a Job is not directly modified by user, the ``Runner`` +will consistently update the state of each Job in a running state. + +The following diagram illustrates which states transitions can +be performed by the ``Runner`` on a Job. This includes the transitions +to intermediate or final error states. + +.. mermaid:: + + stateDiagram-v2 + WAITING --> READY + READY --> CHECKED_OUT + CHECKED_OUT --> UPLOADED + UPLOADED --> SUBMITTED + SUBMITTED --> RUNNING + RUNNING --> TERMINATED + SUBMITTED --> TERMINATED + TERMINATED --> DOWNLOADED + DOWNLOADED --> COMPLETED + DOWNLOADED --> FAILED + + READY --> REMOTE_ERROR + CHECKED_OUT --> REMOTE_ERROR + UPLOADED --> REMOTE_ERROR + SUBMITTED --> REMOTE_ERROR + RUNNING --> REMOTE_ERROR + TERMINATED --> REMOTE_ERROR + DOWNLOADED --> REMOTE_ERROR + + + + classDef error fill:#E62A2A,color:white + classDef running fill:#2a48e6,color:white + classDef success fill:#289e21,color:white + classDef ready fill:#8be485 + classDef wait fill:#eae433 + + class REMOTE_ERROR,FAILED error + class CHECKED_OUT,UPLOADED,SUBMITTED,RUNNING,TERMINATED,DOWNLOADED running + class COMPLETED success + class READY ready + class WAITING wait + +Flow states +*********** + +Each Flow in the database also has a global state. This is a function of +the states of each of the Jobs included in the Flow. As for the Jobs, +the Flow states can change due to the action of the ``Runner`` +or of the user. + +Description +=========== + +The list of Flow states is simplified compared to the Job's states, since several +Job state will be grouped under a single Flow state. + +The list of Flow states is defined in the :py:class:`jobflow_remote.jobs.state.FlowState` +object. Here we present a list of each state with a short description. + +READY +----- + +There is at least one Job in the READY state. No Jobs have started or have failed. + +RUNNING +------- + +At least one of the Jobs is being or has been executed. The state will not be +changed if a single Job completes, but there are still other Jobs to be executed. + +COMPLETED +--------- + +All the left Jobs of the Flow are in the ``COMPLETED`` state. This means that some +intermediate Job may be in the ``FAILED`` state, but its children are set to +not give an error in the ``on_missing_references`` of the ``JobConfig``. + +FAILED +------ + +At least one of the Jobs failed and the Flow is not ``COMPLETED``. + +STOPPED +------- + +At least one of the Job is in the ``STOPPED`` or the ``USER_STOPPED`` state +and the flow is not in one of the previous states. + +PAUSED +------ + +At least one of the Job is in the ``PAUSED`` state and the flow is not in one +of the previous states. diff --git a/doc/source/user/tuning.rst b/doc/source/user/tuning.rst index 282de0aa..53a8f86f 100644 --- a/doc/source/user/tuning.rst +++ b/doc/source/user/tuning.rst @@ -3,3 +3,152 @@ ******************** Tuning Job execution ******************** + +Jobs with time consuming calculations require to properly configure +the environment and the resources used to execute them. This +section focuses on which options can be tuned and the ways available +in jobflow-remote to change them. + +Tuning options +============== + +Worker +------ + +A worker is a computational unit that will actually execute the function +inside a Job. The list of workers is given in the :ref:`projectconf worker` +project configuration. + +Workers are set by the name used to define them in the project and a worker +should always be defined for each Job when adding a Flow to the database. + +.. note:: + + A single worker should not necessarily be identified with a computation + resource as a whole. Different workers referring to the same for example + to the same HPC center, but with different configurations can be created. + The ``Runner`` will still open a single connection if the host is the same. + +Execution configuration +----------------------- + +An execution configuration, represented as an ``ExecutionConfig`` object in +the code, contains information to run additional commands before and after +the execution of a Job. + +These can be typically used to define the modules to load on an HPC center, +specific python environment to load or setting the ``PATH`` for some executable +needed by the Job. + +They can be usually given as a string referring to the setting defined in the +project configuration file, or as an instance of ``ExecutionConfig``. + +Resources +--------- + +If the worker executing the Job runs under the control of a queueing system +(e.g. SLURM, PBS), it is also important to specify which resources need to +be allocated when running a Job. + +Since the all the operations involving the queueing system are handled with +`qtoolkit `_, jobflow-remote +supports the same functionalities. In particular it is either possible to +pass a dictionary containing the keywords specific to the selected queuing system +or to pass an instance of a ``QResources``, a generic object defining resources +for standard use cases. These will be used to fill in a template and generate +a suitable submission script. + +How to tune +=========== + +Different ways of setting the worker, execution configuration and resources +for each Job are available. A combination of them can be used to ease the +configuration for all the Jobs. + +.. note:: + + If not defined otherwise, Jobs generated dynamically will inherit + the configuration of the Job that generated them. + +Submission +---------- + +The first entry point to customize the execution of the Jobs in a Flow +is to use the arguments in the ``submit_flow`` function. + +.. code-block:: python + + resource = {"nodes": 1, "ntasks": 4, "partition": "batch"} + submit_flow( + flow, worker="local_shell", exec_config="somecode_v.x.y", resources=resources + ) + +This will set the passed values for all the Jobs for which have not been +set in the Job previously. + +.. warning:: + + Once the flow has been submitted to the database, any further change to the + ``Flow`` object will not be taken into account. + +JobConfig +--------- + +Each jobflow's Job has a ``JobConfig`` attribute. This can be used to store +a ``manager_config`` dictionary with configuration specific to that Job. + +This can be done with the ``set_run_config`` function, that targets Jobs +based on their name or on the callable they are wrapping. Consider the +following example + +.. code-block:: python + + from jobflow_remote.utils.examples import add, value + from jobflow_remote import submit_flow, set_run_config + from jobflow import Flow + + job1 = value(5) + job2 = add(job1.output, 2) + + flow = Flow([job1, job2]) + + flow = set_run_config( + flow, name_filter="add", worker="secondw", exec_config="anotherconfig" + ) + + resource = {"nodes": 1, "ntasks": 4, "partition": "batch"} + submit_flow(flow, worker="firstw", exec_config="somecode_v.x.y", resources=resources) + +After being submitted to the database the ``value`` Job will be executed +on the ``firstw`` worker, while the ``add`` Job will be executed on the +``secondw`` worker. + +In addition, since ``set_run_config`` makes use of jobflow's ``update_config`` +method, these updates will also automatically be applied to any new Job +automatically generated in the Flow. + +.. warning:: + + The ``name_filter`` matches any name containing the string passed. + So using a ``name_filter=add`` will match both a job named ``add`` + and one named ``add more``. + + +CLI +--- + +After a Job has been added to the database, it is still possible to change +its settings. This can be achieved with the ``jf job set`` CLI command. +For example running:: + + jf job set worker -did 8 example_worker + +sets the worker for Job with DB id 8 to ``example_worker``. Similarly, +the ``jf job set resources`` and ``jf job set exec-config`` can be used +to set the values of the resources and execution configurations. + +.. note:: + + In order for this to be meaningful only Jobs that have not been started + can be modified. So this commands can be applied only to Jobs in the + ``READY`` or ``WAITING`` states. diff --git a/pyproject.toml b/pyproject.toml index 0dd980b5..d7e32a54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ docs = [ "pydata-sphinx-theme", "sphinx-copybutton", "autodoc_pydantic>=2.0.0", + "sphinxcontrib-mermaid" ] strict = []

Type: string

The name of the project