# coding: utf-8
# Copyright (c) Max-Planck-Institut für Eisenforschung GmbH - Computational Materials Design (CM) Department
# Distributed under the terms of "New BSD License", see the LICENSE file.
"""
Worker Class to execute calculation in an asynchronous way
"""
import os
import time
from datetime import datetime
from multiprocessing import Pool
from typing import Tuple
import numpy as np
import psutil
from pyiron_base.jobs.job.template import PythonTemplateJob
from pyiron_base.state import state
__author__ = "Jan Janssen"
__copyright__ = (
"Copyright 2021, Max-Planck-Institut für Eisenforschung GmbH - "
"Computational Materials Design (CM) Department"
)
__version__ = "1.0"
__maintainer__ = "Jan Janssen"
__email__ = "janssen@mpie.de"
__status__ = "production"
__date__ = "Nov 5, 2021"
[docs]
def worker_function(args: list) -> None:
"""
The worker function is executed inside an aproc processing pool.
Args:
args (list): A list of arguments
Arguments inside the argument list:
working_directory (str): working directory of the job
job_id (int/ None): job ID
hdf5_file (str): path to the HDF5 file of the job
h5_path (str): path inside the HDF5 file to load the job
submit_on_remote (bool): submit to queuing system on remote host
debug (bool): enable debug mode [True/False] (optional)
"""
import subprocess
working_directory, job_link = args
if isinstance(job_link, int) or str(job_link).isdigit():
executable = [
"python",
"-m",
"pyiron_base.cli",
"wrapper",
"-p",
working_directory,
"-j",
str(job_link),
]
else:
executable = [
"python",
"-m",
"pyiron_base.cli",
"wrapper",
"-p",
working_directory,
"-f",
job_link,
]
try:
_ = subprocess.run(
executable,
cwd=working_directory,
shell=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
universal_newlines=True,
env=os.environ.copy(),
)
except subprocess.CalledProcessError:
pass
[docs]
class WorkerJob(PythonTemplateJob):
"""
The WorkerJob executes jobs linked to its master id.
The worker can either be in the same project as the calculation it should execute
or a different project. For the example two projects are created:
>>> from pyiron_base import Project
>>> pr_worker = Project("worker")
>>> pr_calc = Project("calc")
The worker is configured to be executed in the background using the non_modal mode,
with the number of cores defining the total number avaiable to the worker and the
cores_per_job definitng the per job allocation. It is recommended to use the same
number of cores for each task the worker executes to optimise the load balancing.
>>> job_worker = pr_worker.create.job.WorkerJob("runner")
>>> job_worker.server.run_mode.non_modal = True
>>> job_worker.server.cores = 4
>>> job_worker.input.cores_per_job = 2
>>> job_worker.run()
The calculation are assinged to the worker by setting the run_mode to worker and
assigning the job_id of the worker as master_id of each job. In this example a total
of ten toyjobs are attached to the worker, with each toyjob using two cores.
>>> for i in range(10):
>>> job = pr_calc.create.job.ToyJob("toy_" + str(i))
>>> job.server.run_mode.worker = True
>>> job.server.cores = 2
>>> job.master_id = job_worker.job_id
>>> job.run()
The execution can be monitored using the job_table of the calculation object:
>>> pr_calc.job_table()
Finally after all calculation are finished the status of the worker is set to collect,
which internally stops the execution of the worker and afterwards updates the job status
to finished:
>>> pr_calc.wait_for_jobs()
>>> job_worker.status.collect = True
"""
[docs]
def __init__(self, project: "pyiron_base.project.generic.Project", job_name: str):
super(WorkerJob, self).__init__(project, job_name)
if not state.database.database_is_disabled:
self.input.project = project.path
else:
self.input.project = self.working_directory
self.input.cores_per_job = 1
self.input.sleep_interval = 10
self.input.child_runtime = 0
self.input.queue_limit_factor = 2
self.input.maxtasksperchild = 1
self._job_with_calculate_function = True
@property
def project_to_watch(self) -> "pyiron_base.project.generic.Project":
rel_path = os.path.relpath(self.input.project, self.project.path)
return self.project.open(rel_path)
@project_to_watch.setter
def project_to_watch(self, pr: "pyiron_base.project.generic.Project") -> None:
self.input.project = pr.path
@property
def cores_per_job(self) -> int:
return self.input.cores_per_job
@cores_per_job.setter
def cores_per_job(self, cores: int) -> None:
self.input.cores_per_job = int(cores)
@property
def queue_limit_factor(self) -> int:
return self.input.queue_limit_factor
@queue_limit_factor.setter
def queue_limit_factor(self, limit_factor: int) -> None:
self.input.queue_limit_factor = limit_factor
@property
def child_runtime(self) -> int: # in seconds:
return self.input.child_runtime
@child_runtime.setter
def child_runtime(self, time_in_sec: int) -> None:
self.input.child_runtime = time_in_sec
@property
def sleep_interval(self) -> int: # in seconds:
return self.input.sleep_interval
@sleep_interval.setter
def sleep_interval(self, interval: int) -> None:
self.input.sleep_interval = int(interval)
# This function is executed
[docs]
def run_static(self) -> None:
"""
Run the worker job in static mode.
If the database is enabled, the worker job will execute calculations by querying the database for jobs to
execute. If the database is disabled, the worker job will execute calculations by scanning the working
directory for HDF5 files.
Returns:
None
"""
if not state.database.database_is_disabled:
self.run_static_with_database()
else:
self.run_static_without_database()
[docs]
def run_static_with_database(self) -> None:
"""
Run the worker job in static mode with database.
This method executes calculations by querying the database for jobs to execute.
Returns:
None
"""
self.status.running = True
master_id = self.job_id
pr = self.project_to_watch
self.project_hdf5.create_working_directory()
log_file = os.path.join(self.working_directory, "worker.log")
active_job_ids, res_lst = [], []
process = psutil.Process(os.getpid())
number_tasks = int(self.server.cores / self.cores_per_job)
with Pool(
processes=number_tasks, maxtasksperchild=self.input.maxtasksperchild
) as pool:
while True:
# Check the database if there are more calculation to execute
df = pr.job_table()
df_sub = df[
(df["status"] == "submitted")
& (df["masterid"] == master_id)
& (~df["id"].isin(active_job_ids))
]
if (
len(df_sub) > 0
and sum([i for r, i in res_lst if not r.ready()])
< number_tasks * self.input.queue_limit_factor
): # Check if there are jobs to execute
path_lst = [
[pp, p, job_id]
for pp, p, job_id in zip(
df_sub["projectpath"].values,
df_sub["project"].values,
df_sub["id"].values,
)
if job_id not in active_job_ids
]
job_lst = [
[p, job_id] if pp is None else [os.path.join(pp, p), job_id]
for pp, p, job_id in path_lst
]
active_job_ids += [j[1] for j in job_lst]
result = pool.map_async(worker_function, job_lst)
res_lst.append([result, len(job_lst)])
elif self.status.collect or self.status.aborted or self.status.finished:
if self.status.collect:
while sum([i for r, i in res_lst if not r.ready()]) > 0:
time.sleep(self.input.sleep_interval)
if self.status.aborted or self.status.finished:
break
break # The infinite loop can be stopped by setting the job status to collect.
else: # The sleep interval can be set as part of the input
if self.input.child_runtime > 0:
df_run = df[
(df["status"] == "running") & (df["masterid"] == master_id)
]
if len(df_run) > 0:
for job_id in df_run[
(
np.array(datetime.now(), dtype="datetime64[ns]")
- df_run.timestart.values
).astype("timedelta64[s]")
> np.array(self.input.child_runtime).astype(
"timedelta64[s]"
)
].id.values:
self.project.db.set_job_status(
job_id=job_id, status="aborted"
)
time.sleep(self.input.sleep_interval)
# job submission
with open(log_file, "a") as f:
f.write(
str(datetime.today())
+ " "
+ str(len(active_job_ids))
+ " "
+ str(len(df))
+ " "
+ str(len(df_sub))
+ " "
+ str(process.memory_info().rss / 1024 / 1024 / 1024)
+ "GB"
+ "\n"
)
# The job is finished
self.status.finished = True
@staticmethod
def _get_working_directory_and_h5path(path: str) -> Tuple[str, str]:
"""
Get the working directory and h5path from the given path.
Args:
path (str): The path to the h5 file.
Returns:
Tuple[str, str]: The working directory and h5path.
Example:
>>> _get_working_directory_and_h5path("/path/to/job.h5")
("/path/to/job_hdf5/job", "/path/to/job.h5/job")
"""
path_split = path.split("/")
job_name = path_split[-1].split(".h5")[0]
parent_dir = "/".join(path_split[:-1])
working_directory = parent_dir + "/" + job_name + "_hdf5/" + job_name
h5path = path + "/" + job_name
return working_directory, h5path
[docs]
def run_static_without_database(self) -> None:
"""
Run the worker job in static mode without a database.
This method executes calculations by scanning the working directory for HDF5 files.
Returns:
None
"""
self.project_hdf5.create_working_directory()
working_directory = self.working_directory
log_file = os.path.join(working_directory, "worker.log")
file_memory_lst, res_lst = [], []
process = psutil.Process(os.getpid())
number_tasks = int(self.server.cores / self.cores_per_job)
with Pool(number_tasks) as pool:
while True:
file_lst = [
os.path.join(working_directory, f)
for f in os.listdir(working_directory)
if f.endswith(".h5")
]
file_vec = ~np.isin(file_lst, file_memory_lst)
file_lst = np.array(file_lst)[file_vec].tolist()
if (
len(file_lst) > 0
and sum([i for r, i in res_lst if not r.ready()])
< number_tasks * self.input.queue_limit_factor
):
job_submit_lst = [
self._get_working_directory_and_h5path(path=f) for f in file_lst
]
file_memory_lst += file_lst
result = pool.map_async(worker_function, job_submit_lst)
res_lst.append([result, len(job_submit_lst)])
elif self.project_hdf5["status"] in ["collect", "aborted", "finished"]:
if self.project_hdf5["status"] == "collect":
while sum([i for r, i in res_lst if not r.ready()]) > 0:
time.sleep(self.input.sleep_interval)
if self.project_hdf5["status"] in ["aborted", "finished"]:
break
break
time.sleep(self.input.sleep_interval)
with open(log_file, "a") as f:
f.write(
str(datetime.today())
+ " "
+ str(len(file_memory_lst))
+ " "
+ str(len(file_lst))
+ " "
+ str(process.memory_info().rss / 1024 / 1024 / 1024)
+ "GB"
+ "\n"
)
# The job is finished
self.status.finished = True
[docs]
def wait_for_worker(
self, interval_in_s: int = 60, max_iterations: int = 10
) -> None:
"""
Wait for the workerjob to finish the execution of all jobs. If no job is in status running or submitted the
workerjob shuts down automatically after 10 minutes.
Args:
interval_in_s (int): interval when the job status is queried from the database - default 60 sec.
max_iterations (int): maximum number of iterations - default 10
"""
finished = False
j = 0
log_file = os.path.join(self.working_directory, "process.log")
if not state.database.database_is_disabled:
pr = self.project_to_watch
master_id = self.job_id
else:
pr = self.project.open(self.working_directory)
master_id = None
while not finished:
df = pr.job_table()
if master_id is not None:
df_sub = df[
((df["status"] == "submitted") | (df.status == "running"))
& (df["masterid"] == master_id)
]
else:
df_sub = df[((df["status"] == "submitted") | (df.status == "running"))]
if len(df_sub) == 0:
j += 1
if j > max_iterations:
finished = True
else:
j = 0
with open(log_file, "a") as f:
log_str = str(datetime.today()) + " j: " + str(j)
for status in ["submitted", "running", "finished", "aborted"]:
log_str += (
" " + status + " : " + str(len(df[df.status == status]))
)
log_str += "\n"
f.write(log_str)
if (
not state.database.database_is_disabled
and state.database.get_job_status(job_id=self.job_id) == "aborted"
):
raise ValueError("The worker job was aborted.")
time.sleep(interval_in_s)
self.status.collect = True