From 37faa1c1ef79835c538929a778920ef81ea3cdfc Mon Sep 17 00:00:00 2001 From: Luiggi Tenorio Date: Fri, 9 Feb 2024 13:58:24 +0100 Subject: [PATCH 01/18] prepare v4.0.0b4 --- CHANGELOG.md | 2 ++ autosubmit_api/__init__.py | 2 +- setup.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 978cdfd..ef0cfcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # CHANGELOG +### Pre-release v4.0.0b4 - Release date: TBD + ### Pre-release v4.0.0b3 - Release date: 2023-02-09 * Fix HPC value in the running endpoint diff --git a/autosubmit_api/__init__.py b/autosubmit_api/__init__.py index 8226b2e..31671cf 100644 --- a/autosubmit_api/__init__.py +++ b/autosubmit_api/__init__.py @@ -17,6 +17,6 @@ # You should have received a copy of the GNU General Public License # along with Autosubmit. If not, see . -__version__ = "4.0.0b3" +__version__ = "4.0.0b4" __author__ = "Luiggi Tenorio, Bruno P. Kinoshita, Cristian GutiƩrrez, Julian Berlin, Wilmer Uruchi" __credits__ = "Barcelona Supercomputing Center" \ No newline at end of file diff --git a/setup.py b/setup.py index 8a648a8..87d42ee 100644 --- a/setup.py +++ b/setup.py @@ -61,7 +61,7 @@ setup( "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Operating System :: POSIX :: Linux", - "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", ], entry_points={ "console_scripts": [ -- GitLab From b98d3558f8e07494fdae078823613a93aecef379 Mon Sep 17 00:00:00 2001 From: Luiggi Tenorio Date: Mon, 12 Feb 2024 16:03:29 +0100 Subject: [PATCH 02/18] refactor Experiment Status Worker --- autosubmit_api/bgtasks/bgtask.py | 103 ++--- autosubmit_api/bgtasks/scheduler.py | 8 +- .../bgtasks/tasks/status_updater.py | 119 +++++ autosubmit_api/builders/experiment_builder.py | 16 +- autosubmit_api/database/common.py | 60 ++- autosubmit_api/database/models.py | 2 +- autosubmit_api/experiment/old_code.txt | 412 ------------------ .../experiment_status_db_manager.py | 155 ------- .../history/database_managers/test.py | 261 ----------- autosubmit_api/history/experiment_status.py | 81 ---- .../history/experiment_status_manager.py | 89 ---- autosubmit_api/persistance/__init__.py | 0 autosubmit_api/persistance/experiment.py | 33 ++ .../workers/populate_running_experiments.py | 4 +- autosubmit_api/workers/test.py | 18 - tests/bgtasks/test_status_updater.py | 26 ++ tests/experiments/as_times.db | Bin 20480 -> 20480 bytes 17 files changed, 288 insertions(+), 1099 deletions(-) create mode 100644 autosubmit_api/bgtasks/tasks/status_updater.py delete mode 100644 autosubmit_api/experiment/old_code.txt delete mode 100644 autosubmit_api/history/database_managers/experiment_status_db_manager.py delete mode 100644 autosubmit_api/history/database_managers/test.py delete mode 100644 autosubmit_api/history/experiment_status.py delete mode 100644 autosubmit_api/history/experiment_status_manager.py create mode 100644 autosubmit_api/persistance/__init__.py create mode 100644 autosubmit_api/persistance/experiment.py delete mode 100644 autosubmit_api/workers/test.py create mode 100644 tests/bgtasks/test_status_updater.py diff --git a/autosubmit_api/bgtasks/bgtask.py b/autosubmit_api/bgtasks/bgtask.py index 30698b6..eeefa36 100644 --- a/autosubmit_api/bgtasks/bgtask.py +++ b/autosubmit_api/bgtasks/bgtask.py @@ -1,91 +1,82 @@ from abc import ABC, abstractmethod +import traceback +from autosubmit_api.logger import logger from autosubmit_api.experiment import common_requests -from autosubmit_api.history.experiment_status_manager import ExperimentStatusManager from autosubmit_api.config.basicConfig import APIBasicConfig from autosubmit_api.workers.business import populate_times, process_graph_drawings from autosubmit_api.workers.populate_details.populate import DetailsProcessor -class BackgroundTask(ABC): - @staticmethod +class BackgroundTaskTemplate(ABC): + """ + Interface to define Background Tasks. + Do not override the run method. + """ + logger = logger + + @classmethod + def run(cls): + """ + Not blocking exceptions + """ + try: + cls.procedure() + except Exception as exc: + cls.logger.error(f"Exception on Background Task {cls.id}: {exc}") + cls.logger.error(traceback.print_exc()) + + @classmethod @abstractmethod - def run(): + def procedure(cls): raise NotImplementedError - + @property @abstractmethod def id(self) -> dict: raise NotImplementedError - + @property @abstractmethod def trigger_options(self) -> dict: raise NotImplementedError - -class PopulateDetailsDB(BackgroundTask): + +class PopulateDetailsDB(BackgroundTaskTemplate): id = "TASK_POPDET" - trigger_options = { - "trigger": "interval", - "hours": 4 - } + trigger_options = {"trigger": "interval", "hours": 4} - @staticmethod - def run(): + @classmethod + def procedure(cls): APIBasicConfig.read() return DetailsProcessor(APIBasicConfig).process() - -class PopulateQueueRuntimes(BackgroundTask): + +class PopulateQueueRuntimes(BackgroundTaskTemplate): id = "TASK_POPQUE" - trigger_options = { - "trigger": "interval", - "minutes": 3 - } - - @staticmethod - def run(): - """ Process and updates queuing and running times. """ - populate_times.process_completed_times() - + trigger_options = {"trigger": "interval", "minutes": 3} -class PopulateRunningExperiments(BackgroundTask): - id = "TASK_POPRUNEX" - trigger_options = { - "trigger": "interval", - "minutes": 5 - } + @classmethod + def procedure(cls): + """Process and updates queuing and running times.""" + populate_times.process_completed_times() - @staticmethod - def run(): - """ - Updates STATUS of experiments. - """ - ExperimentStatusManager().update_running_experiments() - -class VerifyComplete(BackgroundTask): +class VerifyComplete(BackgroundTaskTemplate): id = "TASK_VRFCMPT" - trigger_options = { - "trigger": "interval", - "minutes": 10 - } + trigger_options = {"trigger": "interval", "minutes": 10} + + @classmethod + def procedure(cls): + common_requests.verify_last_completed(1800) - @staticmethod - def run(): - common_requests.verify_last_completed(1800) - -class PopulateGraph(BackgroundTask): +class PopulateGraph(BackgroundTaskTemplate): id = "TASK_POPGRPH" - trigger_options = { - "trigger": "interval", - "hours": 24 - } + trigger_options = {"trigger": "interval", "hours": 24} - @staticmethod - def run(): + @classmethod + def procedure(cls): """ Process coordinates of nodes in a graph drawing and saves them. """ - process_graph_drawings.process_active_graphs() \ No newline at end of file + process_graph_drawings.process_active_graphs() diff --git a/autosubmit_api/bgtasks/scheduler.py b/autosubmit_api/bgtasks/scheduler.py index 2c667c1..a941e39 100644 --- a/autosubmit_api/bgtasks/scheduler.py +++ b/autosubmit_api/bgtasks/scheduler.py @@ -1,13 +1,13 @@ from typing import List from flask_apscheduler import APScheduler from autosubmit_api.bgtasks.bgtask import ( - BackgroundTask, + BackgroundTaskTemplate, PopulateDetailsDB, PopulateQueueRuntimes, - PopulateRunningExperiments, VerifyComplete, PopulateGraph, ) +from autosubmit_api.bgtasks.tasks.status_updater import StatusUpdater from autosubmit_api.config import ( DISABLE_BACKGROUND_TASKS, RUN_BACKGROUND_TASKS_ON_START, @@ -15,10 +15,10 @@ from autosubmit_api.config import ( from autosubmit_api.logger import logger, with_log_run_times -REGISTERED_TASKS: List[BackgroundTask] = [ +REGISTERED_TASKS: List[BackgroundTaskTemplate] = [ PopulateDetailsDB, PopulateQueueRuntimes, - PopulateRunningExperiments, + StatusUpdater, VerifyComplete, PopulateGraph, ] diff --git a/autosubmit_api/bgtasks/tasks/status_updater.py b/autosubmit_api/bgtasks/tasks/status_updater.py new file mode 100644 index 0000000..91eb99f --- /dev/null +++ b/autosubmit_api/bgtasks/tasks/status_updater.py @@ -0,0 +1,119 @@ +from datetime import datetime +import os +import time +from typing import List + +from sqlalchemy import select +from autosubmit_api.bgtasks.bgtask import BackgroundTaskTemplate +from autosubmit_api.database import tables +from autosubmit_api.database.common import ( + create_autosubmit_db_engine, + create_as_times_db_engine, + create_main_db_conn, +) +from autosubmit_api.database.models import ExperimentModel +from autosubmit_api.experiment.common_requests import _is_exp_running +from autosubmit_api.history.database_managers.database_models import RunningStatus +from autosubmit_api.persistance.experiment import ExperimentPaths + + +class StatusUpdater(BackgroundTaskTemplate): + id = "TASK_STTSUPDTR" + trigger_options = {"trigger": "interval", "minutes": 5} + + @classmethod + def _clear_missing_experiments(cls): + """ + Clears the experiments that are not in the experiments table + """ + with create_main_db_conn() as conn: + try: + del_stmnt = tables.experiment_status_table.delete().where( + tables.experiment_status_table.c.exp_id.not_in( + select(tables.experiment_table.c.id) + ) + ) + conn.execute(del_stmnt) + conn.commit() + except Exception as exc: + conn.rollback() + cls.logger.error( + f"[{cls.id}] Error while clearing missing experiments status: {exc}" + ) + + @classmethod + def _get_experiments(cls) -> List[ExperimentModel]: + """ + Get the experiments list + """ + with create_autosubmit_db_engine().connect() as conn: + query_result = conn.execute(tables.experiment_table.select()).all() + return [ExperimentModel.model_validate(row._mapping) for row in query_result] + + @classmethod + def _check_exp_running(cls, expid: str) -> bool: + """ + Decide if the experiment is running + """ + MAX_PKL_AGE = 600 # 10 minutes + MAX_PKL_AGE_EXHAUSTIVE = 3600 # 1 hour + + is_running = False + try: + pkl_path = ExperimentPaths(expid).job_list_pkl + pkl_age = int(time.time()) - int(os.stat(pkl_path).st_mtime) + + if pkl_age < MAX_PKL_AGE: # First running check + is_running = True + elif pkl_age < MAX_PKL_AGE_EXHAUSTIVE: # Exhaustive check + _, _, _flag, _, _ = _is_exp_running(expid) # Exhaustive validation + if _flag: + is_running = True + except Exception as exc: + cls.logger.error( + f"[{cls.id}] Error while checking experiment {expid}: {exc}" + ) + + return is_running + + @classmethod + def _update_experiment_status(cls, experiment: ExperimentModel, is_running: bool): + with create_as_times_db_engine().connect() as conn: + try: + del_stmnt = tables.experiment_status_table.delete().where( + tables.experiment_status_table.c.exp_id == experiment.id + ) + ins_stmnt = tables.experiment_status_table.insert().values( + exp_id=experiment.id, + name=experiment.name, + status=( + RunningStatus.RUNNING + if is_running + else RunningStatus.NOT_RUNNING + ), + seconds_diff=0, + modified=datetime.now().isoformat(timespec="seconds"), + ) + conn.execute(del_stmnt) + conn.execute(ins_stmnt) + conn.commit() + except Exception as exc: + conn.rollback() + cls.logger.error( + f"[{cls.id}] Error while doing database operations on experiment {experiment.name}: {exc}" + ) + + @classmethod + def procedure(cls): + """ + Updates STATUS of experiments. + """ + cls._clear_missing_experiments() + + # Read experiments table + exp_list = cls._get_experiments() + + # Check every experiment status & update + for experiment in exp_list: + is_running = cls._check_exp_running(experiment.name) + cls._update_experiment_status(experiment, is_running) diff --git a/autosubmit_api/builders/experiment_builder.py b/autosubmit_api/builders/experiment_builder.py index 5a3b5a6..7bf5c48 100644 --- a/autosubmit_api/builders/experiment_builder.py +++ b/autosubmit_api/builders/experiment_builder.py @@ -4,16 +4,16 @@ from autosubmit_api.builders.configuration_facade_builder import ( ConfigurationFacadeDirector, ) from autosubmit_api.database import tables -from autosubmit_api.database.common import create_main_db_conn -from autosubmit_api.database.models import Experiment +from autosubmit_api.database.common import create_autosubmit_db_engine, create_main_db_conn +from autosubmit_api.database.models import ExperimentModel class ExperimentBuilder(BaseBuilder): def produce_base_from_dict(self, obj: dict): - self._product = Experiment.model_validate(obj) + self._product = ExperimentModel.model_validate(obj) def produce_base(self, expid): - with create_main_db_conn() as conn: + with create_autosubmit_db_engine().connect() as conn: result = conn.execute( tables.experiment_table.select().where( tables.experiment_table.c.name == expid @@ -21,7 +21,7 @@ class ExperimentBuilder(BaseBuilder): ).one() # Set new product - self._product = Experiment( + self._product = ExperimentModel( id=result.id, name=result.name, description=result.description, @@ -30,12 +30,12 @@ class ExperimentBuilder(BaseBuilder): def produce_details(self): exp_id = self._product.id - with create_main_db_conn() as conn: + with create_autosubmit_db_engine().connect()() as conn: result = conn.execute( tables.details_table.select().where( tables.details_table.c.exp_id == exp_id ) - ).one_or_none + ).one_or_none() # Set details props if result: @@ -63,5 +63,5 @@ class ExperimentBuilder(BaseBuilder): ) @property - def product(self) -> Experiment: + def product(self) -> ExperimentModel: return super().product \ No newline at end of file diff --git a/autosubmit_api/database/common.py b/autosubmit_api/database/common.py index c39b76d..6891df5 100644 --- a/autosubmit_api/database/common.py +++ b/autosubmit_api/database/common.py @@ -1,27 +1,63 @@ import os from typing import Any -from sqlalchemy import Connection, Select, create_engine, select, text, func +from sqlalchemy import Connection, Engine, Select, create_engine, select, text, func +from autosubmit_api.builders import BaseBuilder from autosubmit_api.logger import logger from autosubmit_api.config.basicConfig import APIBasicConfig -def create_main_db_conn(): - APIBasicConfig.read() - autosubmit_db_path = os.path.abspath(APIBasicConfig.DB_PATH) - as_times_db_path = os.path.join(APIBasicConfig.DB_DIR, APIBasicConfig.AS_TIMES_DB) - engine = create_engine("sqlite://") - conn = engine.connect() - conn.execute(text(f"attach database '{autosubmit_db_path}' as autosubmit;")) - conn.execute(text(f"attach database '{as_times_db_path}' as as_times;")) - return conn +class AttachedDatabaseConnBuilder(BaseBuilder): + """ + SQLite utility to build attached databases. + """ + + def __init__(self) -> None: + super().__init__(False) + APIBasicConfig.read() + self.engine = create_engine("sqlite://") + self._product = self.engine.connect() + + def attach_db(self, path: str, name: str): + self._product.execute(text(f"attach database '{path}' as {name};")) + def attach_autosubmit_db(self): + autosubmit_db_path = os.path.abspath(APIBasicConfig.DB_PATH) + self.attach_db(autosubmit_db_path, "autosubmit") -def create_autosubmit_db_engine(): + def attach_as_times_db(self): + as_times_db_path = os.path.join( + APIBasicConfig.DB_DIR, APIBasicConfig.AS_TIMES_DB + ) + self.attach_db(as_times_db_path, "as_times") + + @property + def product(self) -> Connection: + return super().product + + +def create_main_db_conn() -> Connection: + """ + Connection with the autosubmit and as_times DDBB. + """ + builder = AttachedDatabaseConnBuilder() + builder.attach_autosubmit_db() + builder.attach_as_times_db() + + return builder.product + + +def create_autosubmit_db_engine() -> Engine: + """ + Create an engine for the autosubmit DDBB. Usually named autosubmit.db + """ APIBasicConfig.read() return create_engine(f"sqlite:///{ os.path.abspath(APIBasicConfig.DB_PATH)}") -def create_as_times_db_engine(): +def create_as_times_db_engine() -> Engine: + """ + Create an engine for the AS_TIMES DDBB. Usually named as_times.db + """ APIBasicConfig.read() db_path = os.path.join(APIBasicConfig.DB_DIR, APIBasicConfig.AS_TIMES_DB) return create_engine(f"sqlite:///{ os.path.abspath(db_path)}") diff --git a/autosubmit_api/database/models.py b/autosubmit_api/database/models.py index db4a3e0..c63b2b0 100644 --- a/autosubmit_api/database/models.py +++ b/autosubmit_api/database/models.py @@ -2,7 +2,7 @@ from typing import Optional from pydantic import BaseModel -class Experiment(BaseModel): +class ExperimentModel(BaseModel): id: int name: str description: str diff --git a/autosubmit_api/experiment/old_code.txt b/autosubmit_api/experiment/old_code.txt deleted file mode 100644 index d9cac6a..0000000 --- a/autosubmit_api/experiment/old_code.txt +++ /dev/null @@ -1,412 +0,0 @@ -def get_experiment_metrics(expid): - """ - Gets metrics - """ - error = False - error_message = "" - SYPD = ASYPD = CHSY = JPSY = RSYPD = Parallelization = 0 - seconds_in_a_day = 86400 - list_considered = [] - core_hours_year = [] - warnings_job_data = [] - total_run_time = 0 - total_queue_time = total_CHSY = total_JPSY = energy_count = 0 - - try: - # Basic info - BasicConfig.read() - path = BasicConfig.LOCAL_ROOT_DIR + '/' + expid + '/pkl' - tmp_path = os.path.join( - BasicConfig.LOCAL_ROOT_DIR, expid, BasicConfig.LOCAL_TMP_DIR) - pkl_filename = "job_list_" + str(expid) + ".pkl" - path_pkl = path + "/" + pkl_filename - - as_conf = AutosubmitConfig( - expid, BasicConfig, ConfigParserFactory()) - if not as_conf.check_conf_files(): - # Log.critical('Can not run with invalid configuration') - raise Exception( - 'Autosubmit GUI might not have permission to access necessary configuration files.') - - # Chunk Information - chunk_unit = as_conf.get_chunk_size_unit() - chunk_size = as_conf.get_chunk_size() - year_per_sim = datechunk_to_year(chunk_unit, chunk_size) - if year_per_sim <= 0: - raise Exception("The system couldn't calculate year per SIM value " + str(year_per_sim) + - ", for chunk size " + str(chunk_size) + " and chunk unit " + str(chunk_unit)) - - # From database - # db_file = os.path.join(BasicConfig.LOCAL_ROOT_DIR, "ecearth.db") - # conn = DbRequests.create_connection(db_file) - # job_times = DbRequests.get_times_detail_by_expid(conn, expid) - - # Job time information - # Try to get packages - job_to_package = {} - package_to_jobs = {} - job_to_package, package_to_jobs, _, _ = JobList.retrieve_packages( - BasicConfig, expid) - # Basic data - job_running_to_seconds = {} - # SIM POST TRANSFER jobs (COMPLETED) in experiment - sim_jobs_in_pkl = [] - post_jobs_in_pkl = [] - transfer_jobs_in_pkl = [] - clean_jobs_in_pkl = [] - sim_jobs_info = [] - post_jobs_info = [] - transfer_jobs_info = [] - clean_jobs_info = [] - sim_jobs_info_asypd = [] - sim_jobs_info_rsypd = [] - outlied = [] - # Get pkl information - if os.path.exists(path_pkl): - fd = open(path_pkl, 'r') - pickle_content = pickle.load(fd) - # pickle 0: Name, 2: StatusCode - sim_jobs_in_pkl = [item[0] - for item in pickle_content if 'SIM' in item[0].split('_') and int(item[2]) == Status.COMPLETED] - post_jobs_in_pkl = [item[0] - for item in pickle_content if 'POST' in item[0].split('_') and int(item[2]) == Status.COMPLETED] - transfer_member = [item[0] - for item in pickle_content if item[0].find('TRANSFER_MEMBER') > 0 and int(item[2]) == Status.COMPLETED] - transfer_jobs_in_pkl = [item[0] - for item in pickle_content if 'TRANSFER' in item[0].split('_') and int(item[2]) == Status.COMPLETED and item[0] not in transfer_member] - clean_member = [item[0] - for item in pickle_content if item[0].find('CLEAN_MEMBER') > 0 and int(item[2]) == Status.COMPLETED] - clean_jobs_in_pkl = [item[0] - for item in pickle_content if 'CLEAN' in item[0].split('_') and int(item[2]) == Status.COMPLETED and item[0] not in clean_member] - - # print(transfer_jobs_in_pkl) - fakeAllJobs = [SimpleJob(item[0], tmp_path, int(item[2])) - for item in pickle_content] - del pickle_content - job_running_to_seconds, _, warnings_job_data = JobList.get_job_times_collection( - BasicConfig, fakeAllJobs, expid, job_to_package, package_to_jobs, timeseconds=True) - # ASYPD - RSYPD warnings - if len(post_jobs_in_pkl) == 0: - warnings_job_data.append( - "ASYPD | There are no (COMPLETED) POST jobs in the experiment, ASYPD cannot be computed.") - if len(transfer_jobs_in_pkl) == 0 and len(clean_jobs_in_pkl) == 0: - warnings_job_data.append( - "RSYPD | There are no TRANSFER nor CLEAN (COMPLETED) jobs in the experiment, RSYPD cannot be computed.") - if len(transfer_jobs_in_pkl) == 0 and len(clean_jobs_in_pkl) > 0: - warnings_job_data.append( - "RSYPD | There are no TRANSFER (COMPLETED) jobs in the experiment, we resort to use (COMPLETED) CLEAN jobs to compute RSYPD.") - - Parallelization = 0 - try: - processors_value = as_conf.get_processors("SIM") - if processors_value.find(":") >= 0: - # It is an expression - components = processors_value.split(":") - Parallelization = int(sum( - [math.ceil(float(x) / 36.0) * 36.0 for x in components])) - warnings_job_data.append("Parallelization parsing | {0} was interpreted as {1} cores.".format( - processors_value, Parallelization)) - else: - # It is int - Parallelization = int(processors_value) - except Exception as exp: - # print(exp) - warnings_job_data.append( - "CHSY Critical | Autosubmit API could not parse the number of processors for the SIM job.") - pass - - # ASYPD - # Main Loop - # Times exist - if len(job_running_to_seconds) > 0: - # job_info attributes: ['name', 'queue_time', 'run_time', 'status', 'energy', 'submit', 'start', 'finish', 'ncpus'] - sim_jobs_info = [job_running_to_seconds[job_name] - for job_name in sim_jobs_in_pkl if job_running_to_seconds.get(job_name, None) is not None] - sim_jobs_info.sort(key=lambda x: tostamp(x.finish), reverse=True) - - # SIM outlier detection - data_run_times = [job.run_time for job in sim_jobs_info] - mean_1 = np.mean(data_run_times) if len(data_run_times) > 0 else 0 - std_1 = np.std(data_run_times) if len(data_run_times) > 0 else 0 - threshold = 2 - - # ASYPD Pre - post_jobs_info = [job_running_to_seconds[job_name] - for job_name in post_jobs_in_pkl if job_running_to_seconds.get(job_name, None) is not None and job_running_to_seconds[job_name].finish is not None] - post_jobs_info.sort(key=lambda x: tostamp(x.finish), reverse=True) - # End ASYPD Pre - for job_info in sim_jobs_info: - # JobRow object - z_score = (job_info.run_time - mean_1) / \ - std_1 if std_1 > 0 else 0 - # print("{} : {}".format(job_info.name, z_score, threshold)) - if np.abs(z_score) <= threshold and job_info.run_time > 0: - status = job_info.status if job_info else "UNKNOWN" - # Energy - energy = round(job_info.energy, 2) if job_info else 0 - if energy == 0: - warnings_job_data.append( - "Considered | Job {0} (Package {1}) has no energy information and is not going to be considered for energy calculations.".format(job_info.name, job_to_package.get(job_info.name, ""))) - total_queue_time += max(int(job_info.queue_time), 0) - total_run_time += max(int(job_info.run_time), 0) - seconds_per_year = (Parallelization * - job_info.run_time) / year_per_sim - job_JPSY = round(energy / year_per_sim, - 2) if year_per_sim > 0 else 0 - job_SYPD = round((year_per_sim * seconds_in_a_day) / - max(int(job_info.run_time), 0), 2) if job_info.run_time > 0 else 0 - job_ASYPD = round((year_per_sim * seconds_in_a_day) / (int(job_info.queue_time) + int(job_info.run_time) + sum( - job.queue_time + job.run_time for job in post_jobs_info) / len(post_jobs_info)) if len(post_jobs_info) > 0 else 0, 2) - - # Maximum finish time - # max_sim_finish = tostamp(job_info.finish) if job_info.finish is not None and tostamp( - # job_info.finish) > max_sim_finish else max_sim_finish - # sim_count += 1 - total_CHSY += round(seconds_per_year / 3600, 2) - total_JPSY += job_JPSY - if job_JPSY > 0: - # Ignore for mean calculation - energy_count += 1 - # core_hours_year.append(year_seconds/3600) - list_considered.append( - {"name": job_info.name, - "queue": int(job_info.queue_time), - "running": int(job_info.run_time), - "CHSY": round(seconds_per_year / 3600, 2), - "SYPD": job_SYPD, - "ASYPD": job_ASYPD, - "JPSY": job_JPSY, - "energy": energy}) - else: - # print("Outlied {}".format(job_info.name)) - outlied.append(job_info.name) - warnings_job_data.append( - "Outlier | Job {0} (Package {1} - Running time {2} seconds) has been considered an outlier (mean {3}, std {4}, z_score {5}) and will be ignored for performance calculations.".format(job_info.name, job_to_package.get(job_info.name, "NA"), str(job_info.run_time), str(round(mean_1, 2)), str(round(std_1, 2)), str(round(z_score, 2)))) - - # ASYPD Pre - sim_jobs_info_asypd = [job for job in sim_jobs_info if job.name not in outlied] if len( - post_jobs_info) > 0 else [] - sim_jobs_info_asypd.sort( - key=lambda x: tostamp(x.finish), reverse=True) - - # RSYPD - transfer_jobs_info = [job_running_to_seconds[job_name] - for job_name in transfer_jobs_in_pkl if job_running_to_seconds.get(job_name, None) is not None and job_running_to_seconds[job_name].finish is not None] - transfer_jobs_info.sort( - key=lambda x: tostamp(x.finish), reverse=True) - if len(transfer_jobs_info) <= 0: - clean_jobs_info = [job_running_to_seconds[job_name] - for job_name in clean_jobs_in_pkl if job_running_to_seconds.get(job_name, None) is not None and job_running_to_seconds[job_name].finish is not None] - clean_jobs_info.sort( - key=lambda x: tostamp(x.finish), reverse=True) - sim_jobs_info_rsypd = [job for job in sim_jobs_info if job.name not in outlied and tostamp(job.finish) <= tostamp( - clean_jobs_info[0].finish)] if len(clean_jobs_info) > 0 else [] - else: - sim_jobs_info_rsypd = [job for job in sim_jobs_info if job.name not in outlied and tostamp(job.finish) <= tostamp( - transfer_jobs_info[0].finish)] - sim_jobs_info_rsypd.sort( - key=lambda x: tostamp(x.finish), reverse=True) - - SYPD = ((year_per_sim * len(list_considered) * seconds_in_a_day) / - (total_run_time)) if total_run_time > 0 else 0 - SYPD = round(SYPD, 2) - # Old - # ASYPD = ((year_per_sim * len(list_considered) * seconds_in_a_day) / - # (total_run_time + total_queue_time) if (total_run_time + - # total_queue_time) > 0 else 0) - # Paper Implementation - # ASYPD = ((year_per_sim * len(list_considered) * seconds_in_a_day) / (max_sim_finish - - # min_submit)) if (max_sim_finish - min_submit) > 0 else 0 - # ASYPD New Implementation - ASYPD = (year_per_sim * len(sim_jobs_info_asypd) * seconds_in_a_day) / (sum(job.queue_time + job.run_time for job in sim_jobs_info_asypd) + - sum(job.queue_time + job.run_time for job in post_jobs_info) / len(post_jobs_info)) if len(sim_jobs_info_asypd) > 0 and len(post_jobs_info) > 0 else 0 - - # RSYPD - RSYPD = 0 - if len(transfer_jobs_info) > 0: - RSYPD = (year_per_sim * len(sim_jobs_info_rsypd) * seconds_in_a_day) / (tostamp(transfer_jobs_info[0].finish) - tostamp(sim_jobs_info_rsypd[-1].start)) if len( - sim_jobs_info_rsypd) > 0 and len(transfer_jobs_info) > 0 and (tostamp(transfer_jobs_info[0].finish) - tostamp(sim_jobs_info_rsypd[-1].start)) > 0 else 0 - else: - RSYPD = (year_per_sim * len(sim_jobs_info_rsypd) * seconds_in_a_day) / (tostamp(clean_jobs_info[0].finish) - tostamp(sim_jobs_info_rsypd[-1].start)) if len( - sim_jobs_info_rsypd) > 0 and len(clean_jobs_info) > 0 and (tostamp(clean_jobs_info[0].finish) - tostamp(sim_jobs_info_rsypd[-1].start)) > 0 else 0 - - ASYPD = round(ASYPD, 4) - RSYPD = round(RSYPD, 4) - CHSY = round(total_CHSY / len(list_considered), - 2) if len(list_considered) > 0 else total_CHSY - JPSY = round( - total_JPSY / energy_count, 2) if energy_count > 0 else total_JPSY - except Exception as ex: - print(traceback.format_exc()) - error = True - error_message = str(ex) - pass - - return {"SYPD": SYPD, - "ASYPD": ASYPD, - "RSYPD": RSYPD, - "CHSY": CHSY, - "JPSY": JPSY, - "Parallelization": Parallelization, - "considered": list_considered, - "error": error, - "error_message": error_message, - "warnings_job_data": warnings_job_data, - } - -def get_experiment_graph_format_test(expid): - """ - Some testing. Does not serve any purpose now, but the code might be useful. - """ - base_list = dict() - pkl_timestamp = 10000000 - try: - notransitive = False - print("Received " + str(expid)) - BasicConfig.read() - exp_path = os.path.join(BasicConfig.LOCAL_ROOT_DIR, expid) - tmp_path = os.path.join(exp_path, BasicConfig.LOCAL_TMP_DIR) - as_conf = AutosubmitConfig( - expid, BasicConfig, ConfigParserFactory()) - if not as_conf.check_conf_files(): - # Log.critical('Can not run with invalid configuration') - raise Exception( - 'Autosubmit GUI might not have permission to access necessary configuration files.') - - job_list = Autosubmit.load_job_list( - expid, as_conf, notransitive=notransitive) - - job_list.sort_by_id() - base_list = job_list.get_graph_representation(BasicConfig) - except Exception as e: - return {'nodes': [], - 'edges': [], - 'error': True, - 'error_message': str(e), - 'graphviz': False, - 'max_children': 0, - 'max_parents': 0, - 'total_jobs': 0, - 'pkl_timestamp': 0} - name_to_id = dict() - # name_to_weight = dict() - list_nodes = list() - list_edges = list() - i = 0 - with open('/home/Earth/wuruchi/Documents/Personal/spectralgraph/data/graph_' + expid + '.txt', 'w') as the_file: - for item in base_list['nodes']: - # the_file.write(str(i) + "\n") - name_to_id[item['id']] = i - list_nodes.append(i) - i += 1 - for item in base_list['edges']: - the_file.write(str(name_to_id[item['from']]) + " " + str( - name_to_id[item['to']]) + " " + ("10" if item['is_wrapper'] == True else "1") + "\n") - list_edges.append( - (name_to_id[item['from']], name_to_id[item['to']])) - for item in base_list['fake_edges']: - the_file.write(str(name_to_id[item['from']]) + " " + str( - name_to_id[item['to']]) + " " + ("10" if item['is_wrapper'] == True else "1") + "\n") - list_edges.append( - (name_to_id[item['from']], name_to_id[item['to']])) - return list_nodes, list_edges - - - -def update_running_experiments(time_condition=600): - """ - Tests if an experiment is running and updates database as_times.db accordingly.\n - :return: Nothing - """ - t0 = time.time() - experiment_to_modified_ts = dict() # type: Dict[str, int] - try: - BasicConfig.read() - path = BasicConfig.LOCAL_ROOT_DIR - # List of experiments from pkl - tp0 = time.time() - currentDirectories = subprocess.Popen(['ls', '-t', path], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT) if (os.path.exists(path)) else None - stdOut, stdErr = currentDirectories.communicate( - ) if currentDirectories else (None, None) - - # Build connection to ecearth.db - db_file = os.path.join(path, "ecearth.db") - conn = DbRequests.create_connection(db_file) - current_table = DbRequests.prepare_status_db() - readingpkl = stdOut.split() if stdOut else [] - - tp1 = time.time() - for expid in readingpkl: - pkl_path = os.path.join(path, expid, "pkl", "job_list_{0}.pkl".format(expid)) - if not len(expid) == 4 or not os.path.exists(pkl_path): - continue - t1 = time.time() - # Timer safeguard - if (t1 - t0) > SAFE_TIME_LIMIT_STATUS: - raise Exception( - "Time limit reached {0:06.2f} seconds on update_running_experiments while processing {1}. \ - Time spent on reading data {2:06.2f} seconds.".format((t1 - t0), expid, (tp1 - tp0))) - current_stat = os.stat(pkl_path) - time_diff = int(time.time()) - int(current_stat.st_mtime) - if (time_diff < time_condition): - experiment_to_modified_ts[expid] = time_diff - if current_table.get(expid, None) is not None: - # UPDATE RUNNING - _exp_id, _status, _seconds = current_table[expid] - if _status != "RUNNING": - DbRequests.update_exp_status( - expid, "RUNNING", time_diff) - else: - # Insert new experiment - current_id = DbRequests.insert_experiment_status( - conn, expid, time_diff) - current_table[expid] = ( - current_id, 'RUNNING', time_diff) - elif (time_diff <= 3600): - # If it has been running in the last 1 hour - # It must have been added before - error, error_message, is_running, timediff, _ = _is_exp_running( - expid) - if is_running == True: - if current_table.get(expid, None): - _exp_id, _status, _seconds = current_table[expid] - if _status != "RUNNING" and is_running == True: - DbRequests.update_exp_status( - expid, "RUNNING", _seconds) - else: - current_id = DbRequests.insert_experiment_status( - conn, expid, time_diff) - current_table[expid] = ( - current_id, 'RUNNING', time_diff) - - for expid in current_table: - exp_id, status, seconds = current_table[expid] - if status == "RUNNING" and experiment_to_modified_ts.get(expid, None) is None: - # Perform exhaustive check - error, error_message, is_running, timediff, _ = _is_exp_running( - expid) - # UPDATE NOT RUNNING - if (is_running == False): - # print("Update NOT RUNNING for " + expid) - DbRequests.update_exp_status( - expid, "NOT RUNNING", timediff) - except Exception as e: - # print(expid) - print(e.message) - # print(traceback.format_exc()) - -def get_experiment_run_detail(expid, run_id): - error = False - error_message = "" - result = None - try: - result = JobDataStructure(expid).get_current_run_job_data_json(run_id) - tags = {"source": JobList.get_sourcetag(), "target": JobList.get_targettag(), "sync": JobList.get_synctag(), "check": JobList.get_checkmark( - ), "completed": JobList.get_completed_tag(), "running_tag": JobList.get_running_tag(), "queuing_tag": JobList.get_queuing_tag(), "failed_tag": JobList.get_failed_tag()} - except Exception as exp: - error = True - error_message = str(exp) - pass - return {"error": error, "error_message": error_message, "rundata": result} \ No newline at end of file diff --git a/autosubmit_api/history/database_managers/experiment_status_db_manager.py b/autosubmit_api/history/database_managers/experiment_status_db_manager.py deleted file mode 100644 index b7092a4..0000000 --- a/autosubmit_api/history/database_managers/experiment_status_db_manager.py +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env python - - -# Copyright 2015-2020 Earth Sciences Department, BSC-CNS -# This file is part of Autosubmit. - -# Autosubmit is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# Autosubmit is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# You should have received a copy of the GNU General Public License -# along with Autosubmit. If not, see . - -import os -import textwrap -import time - -from autosubmit_api.experiment.common_db_requests import prepare_status_db -from .database_manager import DatabaseManager, DEFAULT_LOCAL_ROOT_DIR -from ...config.basicConfig import APIBasicConfig -from ...history import utils as HUtils -from ...history.database_managers import database_models as Models -from typing import List - -class ExperimentStatusDbManager(DatabaseManager): - """ Manages the actions on the status database """ - def __init__(self, expid, basic_config): - # type: (str, APIBasicConfig) -> None - super(ExperimentStatusDbManager, self).__init__(expid, basic_config) - self._as_times_file_path = os.path.join(APIBasicConfig.DB_DIR, self.AS_TIMES_DB_NAME) - self._ecearth_file_path = os.path.join(APIBasicConfig.DB_DIR, APIBasicConfig.DB_FILE) - self._pkl_file_path = os.path.join(APIBasicConfig.LOCAL_ROOT_DIR, self.expid, "pkl", "job_list_{0}.pkl".format(self.expid)) - self.default_experiment_status_row = Models.ExperimentStatusRow(0, "DEFAULT", "NOT RUNNING", 0, "") - - - def validate_status_database(self): - """ Creates experiment_status table if it does not exist """ - prepare_status_db() - # Redundant code - # create_table_query = textwrap.dedent( - # '''CREATE TABLE - # IF NOT EXISTS experiment_status ( - # exp_id integer PRIMARY KEY, - # name text NOT NULL, - # status text NOT NULL, - # seconds_diff integer NOT NULL, - # modified text NOT NULL - # );''' - # ) - # self.execute_statement_on_dbfile(self._as_times_file_path, create_table_query) - - def print_current_table(self): - for experiment in self._get_experiment_status_content(): - print(experiment) - if self.current_experiment_status_row: - print(("Current Row:\n\t" + self.current_experiment_status_row.name + "\n\t" + - str(self.current_experiment_status_row.exp_id) + "\n\t" + self.current_experiment_status_row.status)) - - def get_experiment_table_content(self): - # type: () -> List[Models.ExperimentStatusRow] - return self._get_experiment_status_content() - - def is_running(self, time_condition=600): - # type : (int) -> bool - """ True if experiment is running, False otherwise. """ - if (os.path.exists(self._pkl_file_path)): - current_stat = os.stat(self._pkl_file_path) - timest = int(current_stat.st_mtime) - timesys = int(time.time()) - time_diff = int(timesys - timest) - if (time_diff < time_condition): - return True - else: - return False - return False - - def set_existing_experiment_status_as_running(self, expid): - """ Set the experiment_status row as running. """ - self.update_exp_status(expid, Models.RunningStatus.RUNNING) - - def create_experiment_status_as_running(self, experiment): - """ Create a new experiment_status row for the Models.Experiment item.""" - self.create_exp_status(experiment.id, experiment.name, Models.RunningStatus.RUNNING) - - - def get_experiment_status_row_by_expid(self, expid): - # type: (str) -> Models.ExperimentStatusRow | None - """ - Get Models.ExperimentStatusRow by expid. Uses exp_id (int id) as an intermediate step and validation. - """ - experiment_row = self.get_experiment_row_by_expid(expid) - return self.get_experiment_status_row_by_exp_id(experiment_row.id) if experiment_row else None - - def get_experiment_row_by_expid(self, expid): - # type: (str) -> Models.ExperimentRow | None - """ - Get the experiment from ecearth.db by expid as Models.ExperimentRow. - """ - statement = self.get_built_select_statement("experiment", "name=?") - current_rows = self.get_from_statement_with_arguments(self._ecearth_file_path, statement, (expid,)) - if len(current_rows) <= 0: - return None - # raise ValueError("Experiment {0} not found in {1}".format(expid, self._ecearth_file_path)) - return Models.ExperimentRow(*current_rows[0]) - - def _get_experiment_status_content(self): - # type: () -> List[Models.ExperimentStatusRow] - """ - Get all registers from experiment_status as List of Models.ExperimentStatusRow.\n - """ - statement = self.get_built_select_statement("experiment_status") - current_rows = self.get_from_statement(self._as_times_file_path, statement) - return [Models.ExperimentStatusRow(*row) for row in current_rows] - - def get_experiment_status_row_by_exp_id(self, exp_id): - # type: (int) -> Models.ExperimentStatusRow - """ Get Models.ExperimentStatusRow from as_times.db by exp_id (int)""" - statement = self.get_built_select_statement("experiment_status", "exp_id=?") - arguments = (exp_id,) - current_rows = self.get_from_statement_with_arguments(self._as_times_file_path, statement, arguments) - if len(current_rows) <= 0: - return None - return Models.ExperimentStatusRow(*current_rows[0]) - - - def create_exp_status(self, exp_id, expid, status): - # type: (int, str, str) -> None - """ - Create experiment status - """ - statement = ''' INSERT INTO experiment_status(exp_id, name, status, seconds_diff, modified) VALUES(?,?,?,?,?) ''' - arguments = (exp_id, expid, status, 0, HUtils.get_current_datetime()) - return self.insert_statement_with_arguments(self._as_times_file_path, statement, arguments) - - def update_exp_status(self, expid, status="RUNNING"): - # type: (str, str) -> None - """ - Update status, seconds_diff, modified in experiment_status. - """ - statement = ''' UPDATE experiment_status SET status = ?, seconds_diff = ?, modified = ? WHERE name = ? ''' - arguments = (status, 0, HUtils.get_current_datetime(), expid) - self.execute_statement_with_arguments_on_dbfile(self._as_times_file_path, statement, arguments) - - def delete_exp_status(self, expid): - # type: (str) -> None - """ Deletes experiment_status row by expid. Useful for testing purposes. """ - statement = ''' DELETE FROM experiment_status where name = ? ''' - arguments = (expid,) - self.execute_statement_with_arguments_on_dbfile(self._as_times_file_path, statement, arguments) \ No newline at end of file diff --git a/autosubmit_api/history/database_managers/test.py b/autosubmit_api/history/database_managers/test.py deleted file mode 100644 index 1b88b37..0000000 --- a/autosubmit_api/history/database_managers/test.py +++ /dev/null @@ -1,261 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2015-2020 Earth Sciences Department, BSC-CNS -# This file is part of Autosubmit. - -# Autosubmit is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# Autosubmit is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# You should have received a copy of the GNU General Public License -# along with Autosubmit. If not, see . - -import unittest -import time -import random -import os -from shutil import copy2 -from autosubmit_api.history.database_managers.experiment_history_db_manager import ExperimentHistoryDbManager -from autosubmit_api.history.database_managers.experiment_status_db_manager import ExperimentStatusDbManager -# from experiment_status_db_manager import ExperimentStatusDbManager -from autosubmit_api.history.data_classes.experiment_run import ExperimentRun -from autosubmit_api.history.data_classes.job_data import JobData -import autosubmit_api.history.database_managers.database_models as Models -from autosubmit_api.config.basicConfig import APIBasicConfig -import autosubmit_api.history.utils as HUtils - -EXPID_TT00_SOURCE = "test_database.db~" -EXPID_TT01_SOURCE = "test_database_no_run.db~" -EXPID = "t024" -EXPID_NONE = "t027" -# BasicConfig.read() -JOBDATA_DIR = APIBasicConfig.JOBDATA_DIR -LOCAL_ROOT_DIR = APIBasicConfig.LOCAL_ROOT_DIR - -class TestExperimentStatusDatabaseManager(unittest.TestCase): - """ Covers Experiment Status Database Manager """ - def setUp(self): - APIBasicConfig.read() - self.exp_status_db = ExperimentStatusDbManager(EXPID, APIBasicConfig) - if self.exp_status_db.get_experiment_status_row_by_expid(EXPID) == None: - self.exp_status_db.create_exp_status(7375, "t024", "NOT RUNNING") - - - def test_get_current_experiment_status_row(self): - exp_status_row = self.exp_status_db.get_experiment_status_row_by_expid(EXPID) - self.assertIsNotNone(exp_status_row) - exp_status_row_none = self.exp_status_db.get_experiment_status_row_by_expid(EXPID_NONE) - self.assertIsNone(exp_status_row_none) - exp_row_direct = self.exp_status_db.get_experiment_status_row_by_exp_id(exp_status_row.exp_id) - self.assertTrue(exp_status_row.exp_id == exp_row_direct.exp_id) - - - def test_update_exp_status(self): - self.exp_status_db.update_exp_status(EXPID, "RUNNING") - exp_status_row_current = self.exp_status_db.get_experiment_status_row_by_expid(EXPID) - self.assertTrue(exp_status_row_current.status == "RUNNING") - self.exp_status_db.update_exp_status(EXPID, "NOT RUNNING") - exp_status_row_current = self.exp_status_db.get_experiment_status_row_by_expid(EXPID) - self.assertTrue(exp_status_row_current.status == "NOT RUNNING") - - def test_create_exp_status(self): - experiment = self.exp_status_db.get_experiment_row_by_expid(EXPID_NONE) - self.exp_status_db.create_experiment_status_as_running(experiment) - experiment_status = self.exp_status_db.get_experiment_status_row_by_expid(EXPID_NONE) - self.assertIsNotNone(experiment_status) - self.exp_status_db.delete_exp_status(EXPID_NONE) - experiment_status = self.exp_status_db.get_experiment_status_row_by_expid(EXPID_NONE) - self.assertIsNone(experiment_status) - - -class TestExperimentHistoryDbManager(unittest.TestCase): - """ Covers Experiment History Database Manager and Data Models """ - def setUp(self): - APIBasicConfig.read() - source_path_tt00 = os.path.join(JOBDATA_DIR, EXPID_TT00_SOURCE) - self.target_path_tt00 = os.path.join(JOBDATA_DIR, "job_data_{0}.db".format(EXPID)) - copy2(source_path_tt00, self.target_path_tt00) - source_path_tt01 = os.path.join(JOBDATA_DIR, EXPID_TT01_SOURCE) - self.target_path_tt01 = os.path.join(JOBDATA_DIR, "job_data_{0}.db".format(EXPID_NONE)) - copy2(source_path_tt01, self.target_path_tt01) - self.experiment_database = ExperimentHistoryDbManager(EXPID, APIBasicConfig) - self.experiment_database.initialize() - - def tearDown(self): - os.remove(self.target_path_tt00) - os.remove(self.target_path_tt01) - - def test_get_max_id(self): - max_item = self.experiment_database.get_experiment_run_dc_with_max_id() - self.assertTrue(max_item.run_id > 0) - self.assertTrue(max_item.run_id >= 18) # Max is 18 - - def test_pragma(self): - self.assertTrue(self.experiment_database._get_pragma_version() == Models.DatabaseVersion.CURRENT_DB_VERSION.value) # Update version on changes - - def test_get_job_data(self): - job_data = self.experiment_database._get_job_data_last_by_name("a29z_20000101_fc0_1_SIM") - self.assertTrue(len(job_data) > 0) - self.assertTrue(job_data[0].last == 1) - self.assertTrue(job_data[0].job_name == "a29z_20000101_fc0_1_SIM") - - job_data = self.experiment_database.get_job_data_dcs_by_name("a29z_20000101_fc0_1_SIM") - self.assertTrue(job_data[0].job_name == "a29z_20000101_fc0_1_SIM") - - job_data = self.experiment_database._get_job_data_last_by_run_id(18) # Latest - self.assertTrue(len(job_data) > 0) - - job_data = self.experiment_database._get_job_data_last_by_run_id_and_finished(18) - self.assertTrue(len(job_data) > 0) - - job_data = self.experiment_database.get_job_data_all() - self.assertTrue(len(job_data) > 0) - - def test_insert_and_delete_experiment_run(self): - new_run = ExperimentRun(19) - new_id = self.experiment_database._insert_experiment_run(new_run) - self.assertIsNotNone(new_id) - last_experiment_run = self.experiment_database.get_experiment_run_dc_with_max_id() - self.assertTrue(new_id == last_experiment_run.run_id) - self.experiment_database.delete_experiment_run(new_id) - last_experiment_run = self.experiment_database.get_experiment_run_dc_with_max_id() - self.assertTrue(new_id != last_experiment_run.run_id) - - def test_insert_and_delete_job_data(self): - max_run_id = self.experiment_database.get_experiment_run_dc_with_max_id().run_id - new_job_data_name = "test_001_name_{0}".format(int(time.time())) - new_job_data = JobData(_id=1, job_name=new_job_data_name, run_id=max_run_id) - new_job_data_id = self.experiment_database._insert_job_data(new_job_data) - self.assertIsNotNone(new_job_data_id) - self.experiment_database.delete_job_data(new_job_data_id) - job_data = self.experiment_database.get_job_data_dcs_by_name(new_job_data_name) - self.assertTrue(len(job_data) == 0) - - - def test_update_experiment_run(self): - experiment_run_data_class = self.experiment_database.get_experiment_run_dc_with_max_id() # 18 - backup_run = self.experiment_database.get_experiment_run_dc_with_max_id() - experiment_run_data_class.chunk_unit = "unouno" - experiment_run_data_class.running = random.randint(1, 100) - experiment_run_data_class.queuing = random.randint(1, 100) - experiment_run_data_class.suspended = random.randint(1, 100) - self.experiment_database._update_experiment_run(experiment_run_data_class) - last_experiment_run = self.experiment_database.get_experiment_run_dc_with_max_id() # 18 - self.assertTrue(self.experiment_database.db_version == Models.DatabaseVersion.CURRENT_DB_VERSION.value) - self.assertTrue(last_experiment_run.chunk_unit == experiment_run_data_class.chunk_unit) - self.assertTrue(last_experiment_run.running == experiment_run_data_class.running) - self.assertTrue(last_experiment_run.queuing == experiment_run_data_class.queuing) - self.assertTrue(last_experiment_run.suspended == experiment_run_data_class.suspended) - self.experiment_database._update_experiment_run(backup_run) - - def test_job_data_from_model(self): - job_data_rows = self.experiment_database._get_job_data_last_by_name("a29z_20000101_fc0_1_SIM") - job_data_row_first = job_data_rows[0] - job_data_data_class = JobData.from_model(job_data_row_first) - self.assertTrue(job_data_row_first.job_name == job_data_data_class.job_name) - - def test_update_job_data_processed(self): - current_time = time.time() - job_data_rows = self.experiment_database._get_job_data_last_by_name("a29z_20000101_fc0_1_SIM") - job_data_row_first = job_data_rows[0] - job_data_data_class = JobData.from_model(job_data_row_first) - backup_job_dc = JobData.from_model(job_data_row_first) - job_data_data_class.nnodes = random.randint(1, 1000) - job_data_data_class.ncpus = random.randint(1, 1000) - job_data_data_class.status = "DELAYED" - job_data_data_class.finish = current_time - self.experiment_database._update_job_data_by_id(job_data_data_class) - job_data_rows_current = self.experiment_database._get_job_data_last_by_name("a29z_20000101_fc0_1_SIM") - job_data_row_first = job_data_rows_current[0] - self.assertTrue(job_data_row_first.nnodes == job_data_data_class.nnodes) - self.assertTrue(job_data_row_first.ncpus == job_data_data_class.ncpus) - self.assertTrue(job_data_row_first.status == job_data_data_class.status) - self.assertTrue(job_data_row_first.finish == job_data_data_class.finish) - self.experiment_database._update_job_data_by_id(backup_job_dc) - - def test_bulk_update(self): - current_time = time.time() - all_job_data_rows = self.experiment_database.get_job_data_all() - job_data_rows_test = [job for job in all_job_data_rows if job.run_id == 3] - backup = [JobData.from_model(job) for job in job_data_rows_test] - list_job_data_class = [JobData.from_model(job) for job in job_data_rows_test] - backup_changes = [(HUtils.get_current_datetime(), job.status, job.rowstatus, job._id) for job in list_job_data_class] - changes = [(HUtils.get_current_datetime(), "DELAYED", job.rowstatus, job._id) for job in list_job_data_class] - self.experiment_database.update_many_job_data_change_status(changes) - all_job_data_rows = self.experiment_database.get_job_data_all() - job_data_rows_validate = [job for job in all_job_data_rows if job.run_id == 3] - for (job_val, change_item) in zip(job_data_rows_validate, changes): - modified, status, rowstatus, _id = change_item - # self.assertTrue(job_val.finish == finish) - self.assertTrue(job_val.modified == modified) - self.assertTrue(job_val.status == status) - self.assertTrue(job_val.rowstatus == rowstatus) - self.assertTrue(job_val.id == _id) - self.experiment_database.update_many_job_data_change_status(backup_changes) - - def test_job_data_maxcounter(self): - new_job_data = ExperimentHistoryDbManager(EXPID_NONE, APIBasicConfig) - new_job_data.initialize() - max_empty_table_counter = new_job_data.get_job_data_max_counter() - self.assertTrue(max_empty_table_counter == 0) - max_existing_counter = self.experiment_database.get_job_data_max_counter() - self.assertTrue(max_existing_counter > 0) - - def test_register_submitted_job_data_dc(self): - job_data_dc = self.experiment_database.get_job_data_dc_unique_latest_by_job_name("a29z_20000101_fc0_1_SIM") - max_counter = self.experiment_database.get_job_data_max_counter() - self.assertTrue(max_counter > 0) - self.assertTrue(job_data_dc.counter > 0) - next_counter = max(max_counter, job_data_dc.counter + 1) - self.assertTrue(next_counter >= max_counter) - self.assertTrue(next_counter >= job_data_dc.counter + 1) - job_data_dc.counter = next_counter - job_data_dc_current = self.experiment_database.register_submitted_job_data_dc(job_data_dc) - self.assertTrue(job_data_dc._id < job_data_dc_current._id) - job_data_last_list = self.experiment_database._get_job_data_last_by_name(job_data_dc.job_name) - self.assertTrue(len(job_data_last_list) == 1) - self.experiment_database.delete_job_data(job_data_last_list[0].id) - job_data_dc.last = 1 - updated_job_data_dc = self.experiment_database.update_job_data_dc_by_id(job_data_dc) - self.assertTrue(job_data_dc._id == updated_job_data_dc._id) - job_data_dc = self.experiment_database.get_job_data_dc_unique_latest_by_job_name("a29z_20000101_fc0_1_SIM") - self.assertTrue(job_data_dc._id == updated_job_data_dc._id) - - def test_update_children_and_platform_output(self): - job_data_dc = self.experiment_database.get_job_data_dc_unique_latest_by_job_name("a29z_20000101_fc0_1_SIM") - children_str = "a00, a01, a02" - platform_output_str = " SLURM OUTPUT " - job_data_dc.children = children_str - job_data_dc.platform_output = platform_output_str - self.assertTrue(self.experiment_database.db_version == Models.DatabaseVersion.CURRENT_DB_VERSION.value) - self.experiment_database.update_job_data_dc_by_id(job_data_dc) - job_data_dc_updated = self.experiment_database.get_job_data_dc_unique_latest_by_job_name("a29z_20000101_fc0_1_SIM") - self.assertTrue(job_data_dc_updated.children == children_str) - self.assertTrue(job_data_dc_updated.platform_output == platform_output_str) - # Back to normal - job_data_dc.children = "" - job_data_dc.platform_output = "NO OUTPUT" - self.experiment_database.update_job_data_dc_by_id(job_data_dc) - job_data_dc_updated = self.experiment_database.get_job_data_dc_unique_latest_by_job_name("a29z_20000101_fc0_1_SIM") - self.assertTrue(job_data_dc_updated.children == "") - self.assertTrue(job_data_dc_updated.platform_output == "NO OUTPUT") - - - - def test_experiment_run_dc(self): - experiment_run = self.experiment_database.get_experiment_run_dc_with_max_id() - self.assertIsNotNone(experiment_run) - - def test_if_database_not_exists(self): - exp_manager = ExperimentHistoryDbManager("0000", APIBasicConfig) - self.assertTrue(exp_manager.my_database_exists() == False) - - -if __name__ == '__main__': - unittest.main() \ No newline at end of file diff --git a/autosubmit_api/history/experiment_status.py b/autosubmit_api/history/experiment_status.py deleted file mode 100644 index d9046aa..0000000 --- a/autosubmit_api/history/experiment_status.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2015-2020 Earth Sciences Department, BSC-CNS -# This file is part of Autosubmit. - -# Autosubmit is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# Autosubmit is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# You should have received a copy of the GNU General Public License -# along with Autosubmit. If not, see . - -import traceback -from .database_managers.experiment_status_db_manager import ExperimentStatusDbManager -from .database_managers.database_manager import DEFAULT_LOCAL_ROOT_DIR, DEFAULT_HISTORICAL_LOGS_DIR -from .internal_logging import Logging -from ..config.basicConfig import APIBasicConfig -from typing import List -from .database_managers.database_models import ExperimentStatusRow - -class ExperimentStatus(): - """ Represents the Experiment Status Mechanism that keeps track of currently active experiments """ - def __init__(self, expid): - # type: (str) -> None - self.expid = expid # type: str - print(expid) - APIBasicConfig.read() - try: - self.manager = ExperimentStatusDbManager(self.expid, APIBasicConfig) - except Exception as exp: - message = "Error while trying to update {0} in experiment_status.".format(str(self.expid)) - print(message) - print(str(exp)) - print() - Logging(self.expid, APIBasicConfig).log(message, traceback.format_exc()) - self.manager = None - - def validate_database(self): - # type: () -> None - self.manager.validate_status_database() - - def get_current_table_content(self): - # type: () -> List[ExperimentStatusRow] - return self.manager.get_experiment_table_content() - - def set_as_running(self): - # type: () -> ExperimentStatusRow - """ Set the status of the experiment in experiment_status of as_times.db as RUNNING. Inserts row if necessary.""" - if not self.manager: - raise Exception("ExperimentStatus: The database manager is not available.") - exp_status_row = self.manager.get_experiment_status_row_by_expid(self.expid) - if exp_status_row: - self.manager.set_existing_experiment_status_as_running(exp_status_row.name) - return self.manager.get_experiment_status_row_by_expid(self.expid) - else: - exp_row = self.manager.get_experiment_row_by_expid(self.expid) - if exp_row: - self.manager.create_experiment_status_as_running(exp_row) - return self.manager.get_experiment_status_row_by_expid(self.expid) - else: - print(("Couldn't find {} in the main database. There is not enough information to set it as running.".format(self.expid))) - return self.manager.default_experiment_status_row - - - def set_as_not_running(self): - # type: () -> None - """ Deletes row by expid. """ - if not self.manager: - raise Exception("ExperimentStatus: The database manager is not available.") - exp_status_row = self.manager.get_experiment_status_row_by_expid(self.expid) - if not exp_status_row: - # raise Exception("ExperimentStatus: Query error, experiment {} not found in status table.".format(self.expid)) - pass # If it is not in the status table, we don't need to worry about it. - else: - self.manager.delete_exp_status(exp_status_row.name) diff --git a/autosubmit_api/history/experiment_status_manager.py b/autosubmit_api/history/experiment_status_manager.py deleted file mode 100644 index 8a79477..0000000 --- a/autosubmit_api/history/experiment_status_manager.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2015-2020 Earth Sciences Department, BSC-CNS -# This file is part of Autosubmit. - -# Autosubmit is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# Autosubmit is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# You should have received a copy of the GNU General Public License -# along with Autosubmit. If not, see . - -import os -import time -import subprocess -from .experiment_status import ExperimentStatus -from ..config.basicConfig import APIBasicConfig -from ..experiment.common_requests import _is_exp_running -from ..common.utils import get_experiments_from_folder -from typing import Dict, Set -from .utils import SAFE_TIME_LIMIT_STATUS -from . import utils as HUtils - - - -class ExperimentStatusManager(object): - """ Manages the update of the status table. """ - def __init__(self): - APIBasicConfig.read() - self._basic_config = APIBasicConfig - self._experiments_updated = set() - self._local_root_path = self._basic_config.LOCAL_ROOT_DIR - self._base_experiment_status = ExperimentStatus("0000") - self._base_experiment_status.validate_database() - self._validate_configuration() - self._creation_timestamp = int(time.time()) - - def _validate_configuration(self): - # type: () -> None - if not os.path.exists(self._local_root_path): - raise Exception("Experiment Status Manager: LOCAL ROOT DIR not found.") - - def update_running_experiments(self, time_condition=600): - # type: (int) -> None - """ - Tests if an experiment is running and updates database as_times.db accordingly.\n - :return: Nothing - """ - - # start_reading_folders = int(time.time()) - # currentDirectories = subprocess.Popen(['ls', '-t', self._local_root_path], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - # stdOut, _ = currentDirectories.communicate() - # readingpkl = stdOut.split() - # time_reading_folders = int(time.time()) - start_reading_folders - readingpkl = get_experiments_from_folder(self._local_root_path) - # Update those that are RUNNING - for expid in readingpkl: - pkl_path = os.path.join(self._local_root_path, str(expid), "pkl", "job_list_{0}.pkl".format(str(expid))) - if not len(expid) == 4 or not os.path.exists(pkl_path): - continue - time_spent = int(time.time()) - self._creation_timestamp - if time_spent > SAFE_TIME_LIMIT_STATUS: - raise Exception( - "Time limit reached {0} seconds on update_running_experiments while processing {1}. \ - Time spent on reading data {2} seconds.".format(time_spent, expid, time_reading_folders)) - time_diff = int(time.time()) - int(os.stat(pkl_path).st_mtime) - if (time_diff < time_condition): - self._experiments_updated.add(ExperimentStatus(expid).set_as_running().exp_id) - elif (time_diff <= 3600): - _, _ , is_running, _, _ = _is_exp_running(expid) # Exhaustive validation - if is_running == True: - self._experiments_updated.add(ExperimentStatus(expid).set_as_running().exp_id) - # Update those that were RUNNING - self._detect_and_delete_not_running() - - def _detect_and_delete_not_running(self): - # type: () -> None - current_rows = self._base_experiment_status.get_current_table_content() - for experiment_status_row in current_rows: - if experiment_status_row.status == "RUNNING" and experiment_status_row.exp_id not in self._experiments_updated: - _, _, is_running, _, _ = _is_exp_running(experiment_status_row.name) # Exhaustive validation - if is_running == False: - ExperimentStatus(experiment_status_row.name).set_as_not_running() \ No newline at end of file diff --git a/autosubmit_api/persistance/__init__.py b/autosubmit_api/persistance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/autosubmit_api/persistance/experiment.py b/autosubmit_api/persistance/experiment.py new file mode 100644 index 0000000..71151ae --- /dev/null +++ b/autosubmit_api/persistance/experiment.py @@ -0,0 +1,33 @@ +import os +from autosubmit_api.config.basicConfig import APIBasicConfig + + +class ExperimentPaths: + """ + Helper class that builds related directories/files paths of an experiment + """ + + def __init__(self, expid: str) -> None: + self._expid = expid + + @property + def expid(self): + return self._expid + + @property + def exp_dir(self): + return os.path.join(APIBasicConfig.LOCAL_ROOT_DIR, self.expid) + + @property + def pkl_dir(self): + return os.path.join(self.exp_dir, "pkl") + + @property + def job_list_pkl(self): + filename = f"job_list_{self.expid}.pkl" + return os.path.join(self.pkl_dir, filename) + + @property + def job_packages_db(self): + filename = f"job_packages_{self.expid}.db" + return os.path.join(self.pkl_dir, filename) diff --git a/autosubmit_api/workers/populate_running_experiments.py b/autosubmit_api/workers/populate_running_experiments.py index c0fe4ce..9355539 100644 --- a/autosubmit_api/workers/populate_running_experiments.py +++ b/autosubmit_api/workers/populate_running_experiments.py @@ -1,11 +1,11 @@ # import autosubmitAPIwu.experiment.common_requests as ExperimentUtils -from autosubmit_api.bgtasks.bgtask import PopulateRunningExperiments +from autosubmit_api.bgtasks.tasks.status_updater import StatusUpdater def main(): """ Updates STATUS of experiments. """ - PopulateRunningExperiments.run() + StatusUpdater.run() if __name__ == "__main__": diff --git a/autosubmit_api/workers/test.py b/autosubmit_api/workers/test.py deleted file mode 100644 index cd2a57b..0000000 --- a/autosubmit_api/workers/test.py +++ /dev/null @@ -1,18 +0,0 @@ -import unittest - -import workers.business.process_graph_drawings as ProcessGraph -from builders.configuration_facade_builder import ConfigurationFacadeDirector, AutosubmitConfigurationFacadeBuilder - -class TestGraphDraw(unittest.TestCase): - def setUp(self): - pass - - def test_graph_drawing_generator(self): - EXPID = "a29z" - autosubmit_configuration_facade = ConfigurationFacadeDirector(AutosubmitConfigurationFacadeBuilder(EXPID)).build_autosubmit_configuration_facade() - result = ProcessGraph._process_graph(EXPID, autosubmit_configuration_facade.chunk_size) - self.assertTrue(result == None) - - -if __name__ == "__main__": - unittest.main() \ No newline at end of file diff --git a/tests/bgtasks/test_status_updater.py b/tests/bgtasks/test_status_updater.py new file mode 100644 index 0000000..7e4db33 --- /dev/null +++ b/tests/bgtasks/test_status_updater.py @@ -0,0 +1,26 @@ +from autosubmit_api.bgtasks.tasks.status_updater import StatusUpdater +from autosubmit_api.database import tables +from autosubmit_api.database.common import ( + create_autosubmit_db_engine, + create_as_times_db_engine, +) +from autosubmit_api.history.database_managers.database_models import RunningStatus + +class TestStatusUpdater: + + def test_same_tables(self, fixture_mock_basic_config): + + StatusUpdater.run() + + with create_autosubmit_db_engine().connect() as conn: + experiments = conn.execute(tables.experiment_table.select()).all() + + with create_as_times_db_engine().connect() as conn: + exps_status = conn.execute(tables.experiment_status_table.select()).all() + + assert len(experiments) == len(exps_status) + assert set([x.id for x in experiments]) == set([x.exp_id for x in exps_status]) + assert set([x.name for x in experiments]) == set([x.name for x in exps_status]) + + for e_st in exps_status: + assert e_st.status in [RunningStatus.RUNNING, RunningStatus.NOT_RUNNING] diff --git a/tests/experiments/as_times.db b/tests/experiments/as_times.db index ef93733acc0d1b1e25f8f3577df6df68f498c66a..6f9bb2a2aaa0b33bf250e8e0ba4b93858abcc5bd 100644 GIT binary patch delta 166 zcmZozz}T>Wae_3X%tRSyRv8ApqD3217T9w!^EWZ@U*liH-?Uj!poG6pgPDy%RGGs# z(YPeZ&p$*VDAdo-)6d<=z{o_`z)087D8$gr%D~9V&`5&`s?xx~oPbJ3xJqOEDgjua BCR_jj delta 28 kcmZozz}T>Wae_1>`$QRMR(1xxyj>ep7T9lQ5%|Lo0Db2P4gdfE -- GitLab From c04fb95eaecdaac3fb2cc0b03431581815af0058 Mon Sep 17 00:00:00 2001 From: Luiggi Tenorio Date: Tue, 13 Feb 2024 11:15:36 +0100 Subject: [PATCH 03/18] moved job times updater --- autosubmit_api/bgtasks/bgtask.py | 10 --- autosubmit_api/bgtasks/scheduler.py | 4 +- .../bgtasks/tasks/job_times_updater.py | 60 ++++++++++++++++++ .../bgtasks/tasks/status_updater.py | 2 +- .../experiment/configuration_facade.py | 13 ++-- autosubmit_api/experiment/common_requests.py | 47 -------------- autosubmit_api/experiment/test.py | 45 ------------- autosubmit_api/persistance/experiment.py | 12 ++++ autosubmit_api/workers/verify_complete.py | 4 +- tests/experiments/as_times.db | Bin 20480 -> 20480 bytes 10 files changed, 85 insertions(+), 112 deletions(-) create mode 100644 autosubmit_api/bgtasks/tasks/job_times_updater.py delete mode 100644 autosubmit_api/experiment/test.py diff --git a/autosubmit_api/bgtasks/bgtask.py b/autosubmit_api/bgtasks/bgtask.py index eeefa36..c043c57 100644 --- a/autosubmit_api/bgtasks/bgtask.py +++ b/autosubmit_api/bgtasks/bgtask.py @@ -1,7 +1,6 @@ from abc import ABC, abstractmethod import traceback from autosubmit_api.logger import logger -from autosubmit_api.experiment import common_requests from autosubmit_api.config.basicConfig import APIBasicConfig from autosubmit_api.workers.business import populate_times, process_graph_drawings from autosubmit_api.workers.populate_details.populate import DetailsProcessor @@ -61,15 +60,6 @@ class PopulateQueueRuntimes(BackgroundTaskTemplate): populate_times.process_completed_times() -class VerifyComplete(BackgroundTaskTemplate): - id = "TASK_VRFCMPT" - trigger_options = {"trigger": "interval", "minutes": 10} - - @classmethod - def procedure(cls): - common_requests.verify_last_completed(1800) - - class PopulateGraph(BackgroundTaskTemplate): id = "TASK_POPGRPH" trigger_options = {"trigger": "interval", "hours": 24} diff --git a/autosubmit_api/bgtasks/scheduler.py b/autosubmit_api/bgtasks/scheduler.py index a941e39..7623f8a 100644 --- a/autosubmit_api/bgtasks/scheduler.py +++ b/autosubmit_api/bgtasks/scheduler.py @@ -4,9 +4,9 @@ from autosubmit_api.bgtasks.bgtask import ( BackgroundTaskTemplate, PopulateDetailsDB, PopulateQueueRuntimes, - VerifyComplete, PopulateGraph, ) +from autosubmit_api.bgtasks.tasks.job_times_updater import JobTimesUpdater from autosubmit_api.bgtasks.tasks.status_updater import StatusUpdater from autosubmit_api.config import ( DISABLE_BACKGROUND_TASKS, @@ -19,7 +19,7 @@ REGISTERED_TASKS: List[BackgroundTaskTemplate] = [ PopulateDetailsDB, PopulateQueueRuntimes, StatusUpdater, - VerifyComplete, + JobTimesUpdater, PopulateGraph, ] diff --git a/autosubmit_api/bgtasks/tasks/job_times_updater.py b/autosubmit_api/bgtasks/tasks/job_times_updater.py new file mode 100644 index 0000000..e47ca18 --- /dev/null +++ b/autosubmit_api/bgtasks/tasks/job_times_updater.py @@ -0,0 +1,60 @@ +import os +import time +from autosubmit_api.bgtasks.bgtask import BackgroundTaskTemplate +from autosubmit_api.common import utils as common_utils +from autosubmit_api.components.jobs import utils as JUtils +from autosubmit_api.config.basicConfig import APIBasicConfig +from autosubmit_api.experiment import common_db_requests as DbRequests +from autosubmit_api.experiment.common_requests import SAFE_TIME_LIMIT + + +def verify_last_completed(seconds=300): + """ + Verifying last 300 seconds by default + """ + # Basic info + t0 = time.time() + APIBasicConfig.read() + # Current timestamp + current_st = time.time() + # Current latest detail + td0 = time.time() + latest_detail = DbRequests.get_latest_completed_jobs(seconds) + t_data = time.time() - td0 + # Main Loop + for job_name, detail in list(latest_detail.items()): + tmp_path = os.path.join( + APIBasicConfig.LOCAL_ROOT_DIR, job_name[:4], APIBasicConfig.LOCAL_TMP_DIR) + detail_id, submit, start, finish, status = detail + submit_time, start_time, finish_time, status_text_res = JUtils.get_job_total_stats( + common_utils.Status.COMPLETED, job_name, tmp_path) + submit_ts = int(time.mktime(submit_time.timetuple())) if len( + str(submit_time)) > 0 else 0 + start_ts = int(time.mktime(start_time.timetuple())) if len( + str(start_time)) > 0 else 0 + finish_ts = int(time.mktime(finish_time.timetuple())) if len( + str(finish_time)) > 0 else 0 + if (finish_ts != finish): + #print("\tMust Update") + DbRequests.update_job_times(detail_id, + int(current_st), + submit_ts, + start_ts, + finish_ts, + status, + debug=False, + no_modify_time=True) + t1 = time.time() + # Timer safeguard + if (t1 - t0) > SAFE_TIME_LIMIT: + raise Exception( + "Time limit reached {0:06.2f} seconds on verify_last_completed while reading {1}. Time spent on reading data {2:06.2f} seconds.".format((t1 - t0), job_name, t_data)) + + +class JobTimesUpdater(BackgroundTaskTemplate): + id = "TASK_JBTMUPDTR" + trigger_options = {"trigger": "interval", "minutes": 10} + + @classmethod + def procedure(cls): + verify_last_completed(1800) \ No newline at end of file diff --git a/autosubmit_api/bgtasks/tasks/status_updater.py b/autosubmit_api/bgtasks/tasks/status_updater.py index 91eb99f..186be6f 100644 --- a/autosubmit_api/bgtasks/tasks/status_updater.py +++ b/autosubmit_api/bgtasks/tasks/status_updater.py @@ -92,7 +92,7 @@ class StatusUpdater(BackgroundTaskTemplate): else RunningStatus.NOT_RUNNING ), seconds_diff=0, - modified=datetime.now().isoformat(timespec="seconds"), + modified=datetime.now().isoformat(sep="-", timespec="seconds"), ) conn.execute(del_stmnt) conn.execute(ins_stmnt) diff --git a/autosubmit_api/components/experiment/configuration_facade.py b/autosubmit_api/components/experiment/configuration_facade.py index 47762a2..8ff5fad 100644 --- a/autosubmit_api/components/experiment/configuration_facade.py +++ b/autosubmit_api/components/experiment/configuration_facade.py @@ -11,6 +11,8 @@ from abc import ABCMeta, abstractmethod from autosubmit_api.common.utils import JobSection, parse_number_processors, timestamp_to_datetime_format, datechunk_to_year from typing import List +from autosubmit_api.persistance.experiment import ExperimentPaths + class ProjectType: GIT = "git" SVN = "svn" @@ -40,11 +42,12 @@ class ConfigurationFacade(metaclass=ABCMeta): def _process_basic_config(self): # type: () -> None - self.pkl_filename = "job_list_{0}.pkl".format(self.expid) - self.experiment_path = os.path.join(self.basic_configuration.LOCAL_ROOT_DIR, self.expid) - self.pkl_path = os.path.join(self.basic_configuration.LOCAL_ROOT_DIR, self.expid, "pkl", self.pkl_filename) - self.tmp_path = os.path.join(self.basic_configuration.LOCAL_ROOT_DIR, self.expid, self.basic_configuration.LOCAL_TMP_DIR) - self.log_path = os.path.join(self.basic_configuration.LOCAL_ROOT_DIR, self.expid, "tmp", "LOG_{0}".format(self.expid)) + exp_paths = ExperimentPaths(self.expid) + self.pkl_filename = os.path.basename(exp_paths.job_list_pkl) + self.experiment_path = exp_paths.exp_dir + self.pkl_path = exp_paths.job_list_pkl + self.tmp_path = exp_paths.tmp_dir + self.log_path = exp_paths.tmp_log_dir self.structures_path = self.basic_configuration.STRUCTURES_DIR if not os.path.exists(self.experiment_path): raise IOError("Experiment folder {0} not found".format(self.experiment_path)) if not os.path.exists(self.pkl_path): raise IOError("Required file {0} not found.".format(self.pkl_path)) diff --git a/autosubmit_api/experiment/common_requests.py b/autosubmit_api/experiment/common_requests.py index dce9f43..f963dad 100644 --- a/autosubmit_api/experiment/common_requests.py +++ b/autosubmit_api/experiment/common_requests.py @@ -769,53 +769,6 @@ def get_experiment_tree_structured(expid, log): return {'tree': [], 'jobs': [], 'total': 0, 'reference': [], 'error': True, 'error_message': str(e), 'pkl_timestamp': 0} -def verify_last_completed(seconds=300): - """ - Verifying last 300 seconds by default - """ - # Basic info - t0 = time.time() - APIBasicConfig.read() - # Current timestamp - current_st = time.time() - # Connection - path = APIBasicConfig.LOCAL_ROOT_DIR - db_file = os.path.join(path, DbRequests.DB_FILE_AS_TIMES) - conn = DbRequests.create_connection(db_file) - # Current latest detail - td0 = time.time() - latest_detail = DbRequests.get_latest_completed_jobs(seconds) - t_data = time.time() - td0 - # Main Loop - for job_name, detail in list(latest_detail.items()): - tmp_path = os.path.join( - APIBasicConfig.LOCAL_ROOT_DIR, job_name[:4], APIBasicConfig.LOCAL_TMP_DIR) - detail_id, submit, start, finish, status = detail - submit_time, start_time, finish_time, status_text_res = JUtils.get_job_total_stats( - common_utils.Status.COMPLETED, job_name, tmp_path) - submit_ts = int(time.mktime(submit_time.timetuple())) if len( - str(submit_time)) > 0 else 0 - start_ts = int(time.mktime(start_time.timetuple())) if len( - str(start_time)) > 0 else 0 - finish_ts = int(time.mktime(finish_time.timetuple())) if len( - str(finish_time)) > 0 else 0 - if (finish_ts != finish): - #print("\tMust Update") - DbRequests.update_job_times(detail_id, - int(current_st), - submit_ts, - start_ts, - finish_ts, - status, - debug=False, - no_modify_time=True) - t1 = time.time() - # Timer safeguard - if (t1 - t0) > SAFE_TIME_LIMIT: - raise Exception( - "Time limit reached {0:06.2f} seconds on verify_last_completed while reading {1}. Time spent on reading data {2:06.2f} seconds.".format((t1 - t0), job_name, t_data)) - - def get_experiment_counters(expid): """ Returns status counters of the experiment. diff --git a/autosubmit_api/experiment/test.py b/autosubmit_api/experiment/test.py deleted file mode 100644 index d3e98f7..0000000 --- a/autosubmit_api/experiment/test.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2015 Earth Sciences Department, BSC-CNS - -# This file is part of Autosubmit. - -# Autosubmit is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# Autosubmit is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# You should have received a copy of the GNU General Public License -# along with Autosubmit. If not, see . - -import unittest - -from experiment.common_requests import get_job_history, get_experiment_data - -class TestCommonRequests(unittest.TestCase): - def setUp(self): - pass - - def test_get_history(self): - result = get_job_history("a3z4", "a3z4_19951101_fc8_1_SIM") - print(result) - self.assertTrue(result != None) - - def test_get_experiment_data(self): - result = get_experiment_data("a29z") - print(result) - result2 = get_experiment_data("a4a0") - print(result2) - result3 = get_experiment_data("a2am") - print(result3) - self.assertTrue(result != None) - self.assertTrue(result2 != None) - self.assertTrue(result3 != None) - -if __name__ == "__main__": - unittest.main() \ No newline at end of file diff --git a/autosubmit_api/persistance/experiment.py b/autosubmit_api/persistance/experiment.py index 71151ae..d34370e 100644 --- a/autosubmit_api/persistance/experiment.py +++ b/autosubmit_api/persistance/experiment.py @@ -31,3 +31,15 @@ class ExperimentPaths: def job_packages_db(self): filename = f"job_packages_{self.expid}.db" return os.path.join(self.pkl_dir, filename) + + @property + def tmp_dir(self): + return os.path.join(self.exp_dir, APIBasicConfig.LOCAL_TMP_DIR) + + @property + def tmp_dir(self): + return os.path.join(self.exp_dir, APIBasicConfig.LOCAL_TMP_DIR) + + @property + def tmp_log_dir(self): + return os.path.join(self.tmp_dir, f"LOG_{self.expid}") \ No newline at end of file diff --git a/autosubmit_api/workers/verify_complete.py b/autosubmit_api/workers/verify_complete.py index a8e85a4..490b9d4 100644 --- a/autosubmit_api/workers/verify_complete.py +++ b/autosubmit_api/workers/verify_complete.py @@ -1,9 +1,9 @@ #!/usr/bin/env python -from autosubmit_api.bgtasks.bgtask import VerifyComplete +from autosubmit_api.bgtasks.tasks.job_times_updater import JobTimesUpdater def main(): - VerifyComplete.run() + JobTimesUpdater.run() if __name__ == "__main__": diff --git a/tests/experiments/as_times.db b/tests/experiments/as_times.db index 6f9bb2a2aaa0b33bf250e8e0ba4b93858abcc5bd..a829b9ce72f6fb798f35448dfa62f9f98e8d282c 100644 GIT binary patch delta 54 xcmZozz}T>Wae_3X{6raNM){2iPX6r1x`u{UhNhEO`YXfPm;IHwpyF1BMgX1X4j}*l delta 54 xcmZozz}T>Wae_3X%tRSyMwyKXPX6phA% Date: Tue, 13 Feb 2024 11:28:20 +0100 Subject: [PATCH 04/18] removed currently_running view --- autosubmit_api/database/extended_db.py | 3 - .../experiment/common_db_requests.py | 33 ----- .../business/process_graph_drawings.py | 113 +++++++++++------- tests/experiments/as_times.db | Bin 20480 -> 20480 bytes 4 files changed, 71 insertions(+), 78 deletions(-) diff --git a/autosubmit_api/database/extended_db.py b/autosubmit_api/database/extended_db.py index d246901..3cfc703 100644 --- a/autosubmit_api/database/extended_db.py +++ b/autosubmit_api/database/extended_db.py @@ -29,8 +29,5 @@ class ExtendedDB: def prepare_as_times_db(self): prepare_completed_times_db() prepare_status_db() - self.as_times_db_manager.create_view('currently_running', - 'select s.name, s.status, t.total_jobs from experiment_status as s inner join experiment_times as t on s.name = t.name where s.status="RUNNING" ORDER BY t.total_jobs' - ) diff --git a/autosubmit_api/experiment/common_db_requests.py b/autosubmit_api/experiment/common_db_requests.py index 752d5e8..9d9d4a1 100644 --- a/autosubmit_api/experiment/common_db_requests.py +++ b/autosubmit_api/experiment/common_db_requests.py @@ -324,22 +324,6 @@ def get_experiment_status(): return experiment_status -def get_currently_running_experiments(): - """ - Gets the list of currently running experiments ordered by total_jobs - Connects to AS_TIMES - :return: map of expid -> total_jobs - :rtype: OrderedDict() str -> int - """ - experiment_running = OrderedDict() - current_running = _get_exp_only_active() - if current_running: - for item in current_running: - name, status, total_jobs = item - experiment_running[name] = total_jobs - return experiment_running - - def get_specific_experiment_status(expid): """ Gets the current status from database.\n @@ -653,23 +637,6 @@ def _get_exp_status(): return dict() -def _get_exp_only_active(): - """ - Get all registers of experiments ACTIVE - """ - try: - conn = create_connection(os.path.join(APIBasicConfig.DB_DIR, APIBasicConfig.AS_TIMES_DB)) - conn.text_factory = str - cur = conn.cursor() - cur.execute("select name, status, total_jobs from currently_running") - rows = cur.fetchall() - return rows - except Exception as exp: - print((traceback.format_exc())) - print(exp) - return None - - def _get_specific_exp_status(expid): """ Get all registers from experiment_status.\n diff --git a/autosubmit_api/workers/business/process_graph_drawings.py b/autosubmit_api/workers/business/process_graph_drawings.py index 7a51772..c6fa84c 100644 --- a/autosubmit_api/workers/business/process_graph_drawings.py +++ b/autosubmit_api/workers/business/process_graph_drawings.py @@ -1,49 +1,78 @@ import time import traceback -from ...experiment import common_db_requests as DbRequests -from ...common import utils as common_utils -from ...database.db_jobdata import ExperimentGraphDrawing -from ...builders.configuration_facade_builder import ConfigurationFacadeDirector, AutosubmitConfigurationFacadeBuilder -from ...builders.joblist_loader_builder import JobListLoaderBuilder, JobListLoaderDirector, JobListHelperBuilder +from autosubmit_api.database import tables +from autosubmit_api.database.common import create_as_times_db_engine +from autosubmit_api.common import utils as common_utils +from autosubmit_api.database.db_jobdata import ExperimentGraphDrawing +from autosubmit_api.builders.configuration_facade_builder import ( + ConfigurationFacadeDirector, + AutosubmitConfigurationFacadeBuilder, +) +from autosubmit_api.builders.joblist_loader_builder import ( + JobListLoaderBuilder, + JobListLoaderDirector, +) from typing import List, Any + def process_active_graphs(): - """ - Process the list of active experiments to generate the positioning of their graphs - """ - try: - currently_running = DbRequests.get_currently_running_experiments() - - for expid in currently_running: - - try: - autosubmit_configuration_facade = ConfigurationFacadeDirector(AutosubmitConfigurationFacadeBuilder(expid)).build_autosubmit_configuration_facade() - if common_utils.is_version_historical_ready(autosubmit_configuration_facade.get_autosubmit_version()): - # job_count = currently_running.get(expid, 0) - _process_graph(expid, autosubmit_configuration_facade.chunk_size) - except Exception as exp: - print((traceback.format_exc())) - print(("Error while processing: {}".format(expid))) - - except Exception as exp: - print((traceback.format_exc())) - print(("Error while processing graph drawing: {}".format(exp))) + """ + Process the list of active experiments to generate the positioning of their graphs + """ + try: + with create_as_times_db_engine().connect() as conn: + query_result = conn.execute( + tables.experiment_status_table.select().where() + ).all() + + active_experiments: List[str] = [exp.name for exp in query_result] + + for expid in active_experiments: + try: + autosubmit_configuration_facade = ConfigurationFacadeDirector( + AutosubmitConfigurationFacadeBuilder(expid) + ).build_autosubmit_configuration_facade() + if common_utils.is_version_historical_ready( + autosubmit_configuration_facade.get_autosubmit_version() + ): + _process_graph(expid, autosubmit_configuration_facade.chunk_size) + except Exception as exp: + print((traceback.format_exc())) + print(("Error while processing: {}".format(expid))) + + except Exception as exp: + print((traceback.format_exc())) + print(("Error while processing graph drawing: {}".format(exp))) + def _process_graph(expid, chunk_size): - # type: (str, int) -> List[Any] | None - result = None - experimentGraphDrawing = ExperimentGraphDrawing(expid) - locked = experimentGraphDrawing.locked - # print("Start Processing {} with {} jobs".format(expid, job_count)) - if not locked: - start_time = time.time() - job_list_loader = JobListLoaderDirector(JobListLoaderBuilder(expid)).build_loaded_joblist_loader() - current_data = experimentGraphDrawing.get_validated_data(job_list_loader.jobs) - if not current_data: - print(("Must update {}".format(expid))) - result = experimentGraphDrawing.calculate_drawing(job_list_loader.jobs, independent=False, num_chunks=chunk_size, job_dictionary=job_list_loader.job_dictionary) - print(("Time Spent in {}: {} seconds.".format(expid, int(time.time() - start_time)))) - else: - print(("{} Locked".format(expid))) - - return result \ No newline at end of file + # type: (str, int) -> List[Any] | None + result = None + experimentGraphDrawing = ExperimentGraphDrawing(expid) + locked = experimentGraphDrawing.locked + # print("Start Processing {} with {} jobs".format(expid, job_count)) + if not locked: + start_time = time.time() + job_list_loader = JobListLoaderDirector( + JobListLoaderBuilder(expid) + ).build_loaded_joblist_loader() + current_data = experimentGraphDrawing.get_validated_data(job_list_loader.jobs) + if not current_data: + print(("Must update {}".format(expid))) + result = experimentGraphDrawing.calculate_drawing( + job_list_loader.jobs, + independent=False, + num_chunks=chunk_size, + job_dictionary=job_list_loader.job_dictionary, + ) + print( + ( + "Time Spent in {}: {} seconds.".format( + expid, int(time.time() - start_time) + ) + ) + ) + else: + print(("{} Locked".format(expid))) + + return result diff --git a/tests/experiments/as_times.db b/tests/experiments/as_times.db index a829b9ce72f6fb798f35448dfa62f9f98e8d282c..dd846a25021a24072ee08856906e7775e5fdcf6d 100644 GIT binary patch delta 45 pcmZozz}T>Wae_3X(nJ|&Mx~7j?*6Ps=2oVblh^pGLRr^fEC4T-44nV~ delta 45 pcmZozz}T>Wae_3X{6raNM){2i?*6QXrdEbVlh^pGLRr^fEC4Lk3~c}a -- GitLab From f127898685fb16cb9b759c51bb074899bf089a2f Mon Sep 17 00:00:00 2001 From: Luiggi Tenorio Date: Tue, 13 Feb 2024 14:56:40 +0100 Subject: [PATCH 05/18] removed usage of experiment_times and job_times --- .../autosubmit_legacy/job/job_list.py | 2 +- autosubmit_api/bgtasks/scheduler.py | 4 ++-- .../bgtasks/tasks/job_times_updater.py | 2 +- autosubmit_api/database/__init__.py | 7 +++++++ autosubmit_api/database/db_common.py | 8 ++++---- autosubmit_api/database/extended_db.py | 8 ++------ autosubmit_api/database/queries.py | 8 -------- autosubmit_api/database/tables.py | 11 ----------- autosubmit_api/experiment/common_requests.py | 3 ++- .../experiment_history_db_manager.py | 5 +---- autosubmit_api/views/v4.py | 8 ++------ .../workers/business/populate_times.py | 2 +- tests/bgtasks/test_status_updater.py | 3 ++- tests/conftest.py | 6 +++++- tests/experiments/as_times.db | Bin 20480 -> 8192 bytes .../metadata/data/job_data_a007.db | 0 .../metadata/data/job_data_a3tb.db | Bin 0 -> 750592 bytes tests/test_endpoints_v3.py | 6 +----- 18 files changed, 31 insertions(+), 52 deletions(-) create mode 100755 tests/experiments/metadata/data/job_data_a007.db create mode 100644 tests/experiments/metadata/data/job_data_a3tb.db diff --git a/autosubmit_api/autosubmit_legacy/job/job_list.py b/autosubmit_api/autosubmit_legacy/job/job_list.py index 40cff2a..2715556 100644 --- a/autosubmit_api/autosubmit_legacy/job/job_list.py +++ b/autosubmit_api/autosubmit_legacy/job/job_list.py @@ -590,7 +590,7 @@ class JobList: conn = DbRequests.create_connection(db_file) # job_data = None # Job information from worker database - job_times = DbRequests.get_times_detail_by_expid(conn, expid) + job_times = dict() # REMOVED: DbRequests.get_times_detail_by_expid(conn, expid) conn.close() # Job information from job historic data # print("Get current job data structure...") diff --git a/autosubmit_api/bgtasks/scheduler.py b/autosubmit_api/bgtasks/scheduler.py index 7623f8a..26aafc5 100644 --- a/autosubmit_api/bgtasks/scheduler.py +++ b/autosubmit_api/bgtasks/scheduler.py @@ -17,9 +17,9 @@ from autosubmit_api.logger import logger, with_log_run_times REGISTERED_TASKS: List[BackgroundTaskTemplate] = [ PopulateDetailsDB, - PopulateQueueRuntimes, + # PopulateQueueRuntimes, StatusUpdater, - JobTimesUpdater, + # JobTimesUpdater, PopulateGraph, ] diff --git a/autosubmit_api/bgtasks/tasks/job_times_updater.py b/autosubmit_api/bgtasks/tasks/job_times_updater.py index e47ca18..dd8c817 100644 --- a/autosubmit_api/bgtasks/tasks/job_times_updater.py +++ b/autosubmit_api/bgtasks/tasks/job_times_updater.py @@ -57,4 +57,4 @@ class JobTimesUpdater(BackgroundTaskTemplate): @classmethod def procedure(cls): - verify_last_completed(1800) \ No newline at end of file + pass \ No newline at end of file diff --git a/autosubmit_api/database/__init__.py b/autosubmit_api/database/__init__.py index e69de29..0ac9383 100644 --- a/autosubmit_api/database/__init__.py +++ b/autosubmit_api/database/__init__.py @@ -0,0 +1,7 @@ +from autosubmit_api.database.common import create_as_times_db_engine +from autosubmit_api.database.tables import experiment_status_table + + +def prepare_db(): + with create_as_times_db_engine().connect() as conn: + experiment_status_table.create(conn, checkfirst=True) \ No newline at end of file diff --git a/autosubmit_api/database/db_common.py b/autosubmit_api/database/db_common.py index d9f714b..7b370b8 100644 --- a/autosubmit_api/database/db_common.py +++ b/autosubmit_api/database/db_common.py @@ -231,7 +231,7 @@ def search_experiment_by_id(query, exp_type=None, only_active=None, owner=None): experiment_times = dict() if len(table) > 0: experiment_status = DbRequests.get_experiment_status() - experiment_times = DbRequests.get_experiment_times() + # REMOVED: experiment_times = DbRequests.get_experiment_times() for row in table: expid = str(row[1]) @@ -268,7 +268,7 @@ def search_experiment_by_id(query, exp_type=None, only_active=None, owner=None): try: current_run = ExperimentHistoryDirector(ExperimentHistoryBuilder(expid)).build_reader_experiment_history().manager.get_experiment_run_dc_with_max_id() - if current_run and current_run.total > 0 and (current_run.total == total or current_run.modified_timestamp > last_modified_timestamp): + if current_run and current_run.total > 0: completed = current_run.completed total = current_run.total submitted = current_run.submitted @@ -313,7 +313,7 @@ def get_current_running_exp(): experiment_status = dict() experiment_times = dict() experiment_status = DbRequests.get_experiment_status() - experiment_times = DbRequests.get_experiment_times() + # REMOVED: experiment_times = DbRequests.get_experiment_times() for row in table: expid = str(row[1]) status = "NOT RUNNING" @@ -353,7 +353,7 @@ def get_current_running_exp(): # Try to retrieve experiment_run data try: current_run = ExperimentHistoryDirector(ExperimentHistoryBuilder(expid)).build_reader_experiment_history().manager.get_experiment_run_dc_with_max_id() - if current_run and current_run.total > 0 and (current_run.total == total or current_run.modified_timestamp > last_modified_timestamp): + if current_run and current_run.total > 0: completed = current_run.completed total = current_run.total submitted = current_run.submitted diff --git a/autosubmit_api/database/extended_db.py b/autosubmit_api/database/extended_db.py index 3cfc703..518941a 100644 --- a/autosubmit_api/database/extended_db.py +++ b/autosubmit_api/database/extended_db.py @@ -1,6 +1,6 @@ from autosubmit_api.config.basicConfig import APIBasicConfig from autosubmit_api.database.db_manager import DbManager -from autosubmit_api.experiment.common_db_requests import prepare_completed_times_db, prepare_status_db +from autosubmit_api.database import prepare_db from autosubmit_api.workers.populate_details.populate import DetailsProcessor class ExtendedDB: @@ -15,7 +15,7 @@ class ExtendedDB: Create tables and views that are required """ self.prepare_main_db() - self.prepare_as_times_db() + prepare_db() def prepare_main_db(self): @@ -25,9 +25,5 @@ class ExtendedDB: 'listexp', 'select id,name,user,created,model,branch,hpc,description from experiment left join details on experiment.id = details.exp_id' ) - - def prepare_as_times_db(self): - prepare_completed_times_db() - prepare_status_db() diff --git a/autosubmit_api/database/queries.py b/autosubmit_api/database/queries.py index d23d684..ad3e327 100644 --- a/autosubmit_api/database/queries.py +++ b/autosubmit_api/database/queries.py @@ -20,9 +20,6 @@ def generate_query_listexp_extended( select( tables.experiment_table, tables.details_table, - tables.experiment_times_table.c.exp_id, - tables.experiment_times_table.c.total_jobs, - tables.experiment_times_table.c.completed_jobs, tables.experiment_status_table.c.exp_id, tables.experiment_status_table.c.status, ) @@ -31,11 +28,6 @@ def generate_query_listexp_extended( tables.experiment_table.c.id == tables.details_table.c.exp_id, isouter=True, ) - .join( - tables.experiment_times_table, - tables.experiment_table.c.id == tables.experiment_times_table.c.exp_id, - isouter=True, - ) .join( tables.experiment_status_table, tables.experiment_table.c.id == tables.experiment_status_table.c.exp_id, diff --git a/autosubmit_api/database/tables.py b/autosubmit_api/database/tables.py index bc3913d..e4929c6 100644 --- a/autosubmit_api/database/tables.py +++ b/autosubmit_api/database/tables.py @@ -28,17 +28,6 @@ details_table = Table( # AS_TIMES TABLES -experiment_times_table = Table( - "experiment_times", - metadata_obj, - Column("exp_id", Integer, primary_key=True), - Column("name", Text, nullable=False), - Column("created", Integer, nullable=False), - Column("modified", Integer, nullable=False), - Column("total_jobs", Integer, nullable=False), - Column("completed_jobs", Integer, nullable=False), -) - experiment_status_table = Table( "experiment_status", metadata_obj, diff --git a/autosubmit_api/experiment/common_requests.py b/autosubmit_api/experiment/common_requests.py index f963dad..8e3d910 100644 --- a/autosubmit_api/experiment/common_requests.py +++ b/autosubmit_api/experiment/common_requests.py @@ -172,7 +172,8 @@ def get_experiment_data(expid): result["completed_jobs"] = experiment_run.completed result["db_historic_version"] = experiment_history.manager.db_version else: - _, result["total_jobs"], result["completed_jobs"] = DbRequests.get_experiment_times_by_expid(expid) + result["total_jobs"] = 0 + result["completed_jobs"] = 0 result["db_historic_version"] = "NA" except Exception as exp: diff --git a/autosubmit_api/history/database_managers/experiment_history_db_manager.py b/autosubmit_api/history/database_managers/experiment_history_db_manager.py index 6d03428..0a821a3 100644 --- a/autosubmit_api/history/database_managers/experiment_history_db_manager.py +++ b/autosubmit_api/history/database_managers/experiment_history_db_manager.py @@ -176,10 +176,7 @@ class ExperimentHistoryDbManager(DatabaseManager): def get_experiment_run_dc_with_max_id(self): """ Get Current (latest) ExperimentRun data class. """ - if self.db_version >= Models.DatabaseVersion.EXPERIMENT_HEADER_SCHEMA_CHANGES.value: - return ExperimentRun.from_model(self._get_experiment_run_with_max_id()) - else: - return ExperimentRun(run_id=0) + return ExperimentRun.from_model(self._get_experiment_run_with_max_id()) def register_experiment_run_dc(self, experiment_run_dc): self._insert_experiment_run(experiment_run_dc) diff --git a/autosubmit_api/views/v4.py b/autosubmit_api/views/v4.py index 07db10c..f42b77e 100644 --- a/autosubmit_api/views/v4.py +++ b/autosubmit_api/views/v4.py @@ -165,8 +165,8 @@ class ExperimentView(MethodView): # Get current run data from history last_modified_timestamp = exp.created - completed = exp.completed_jobs if exp.completed_jobs else 0 - total = exp.total_jobs if exp.total_jobs else 0 + completed = 0 + total = 0 submitted = 0 queuing = 0 running = 0 @@ -181,10 +181,6 @@ class ExperimentView(MethodView): if ( current_run and current_run.total > 0 - and ( - current_run.total == total - or current_run.modified_timestamp > last_modified_timestamp - ) ): completed = current_run.completed total = current_run.total diff --git a/autosubmit_api/workers/business/populate_times.py b/autosubmit_api/workers/business/populate_times.py index 5c5e306..89bbc79 100644 --- a/autosubmit_api/workers/business/populate_times.py +++ b/autosubmit_api/workers/business/populate_times.py @@ -38,7 +38,7 @@ def process_completed_times(time_condition=60): stdOut, _ = currentDirectories.communicate() if currentDirectories else (None, None) # Building connection to ecearth - current_table = DbRequests.prepare_completed_times_db() + current_table = dict() # REMOVED: DbRequests.prepare_completed_times_db() # Build list of all folder in /esarchive/autosubmit which should be considered as experiments (although some might not be) # Pre process _preprocess_completed_times() diff --git a/tests/bgtasks/test_status_updater.py b/tests/bgtasks/test_status_updater.py index 7e4db33..6d23029 100644 --- a/tests/bgtasks/test_status_updater.py +++ b/tests/bgtasks/test_status_updater.py @@ -1,5 +1,5 @@ from autosubmit_api.bgtasks.tasks.status_updater import StatusUpdater -from autosubmit_api.database import tables +from autosubmit_api.database import prepare_db, tables from autosubmit_api.database.common import ( create_autosubmit_db_engine, create_as_times_db_engine, @@ -9,6 +9,7 @@ from autosubmit_api.history.database_managers.database_models import RunningStat class TestStatusUpdater: def test_same_tables(self, fixture_mock_basic_config): + prepare_db() StatusUpdater.run() diff --git a/tests/conftest.py b/tests/conftest.py index 8fa8be4..d609778 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ -# Conftest file for sharing fixtures +# Conftest file for sharing fixtures # Reference: https://docs.pytest.org/en/latest/reference/fixtures.html#conftest-py-sharing-fixtures-across-multiple-files import os @@ -17,6 +17,7 @@ FAKE_EXP_DIR = "./tests/experiments/" def fixture_disable_protection(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("PROTECTION_LEVEL", "NONE") + @pytest.fixture def fixture_mock_basic_config(monkeypatch: pytest.MonkeyPatch): # Patch APIBasicConfig parent BasicConfig @@ -25,6 +26,9 @@ def fixture_mock_basic_config(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(BasicConfig, "LOCAL_ROOT_DIR", FAKE_EXP_DIR) monkeypatch.setattr(BasicConfig, "DB_DIR", FAKE_EXP_DIR) monkeypatch.setattr(BasicConfig, "DB_FILE", "autosubmit.db") + monkeypatch.setattr( + BasicConfig, "JOBDATA_DIR", os.path.join(FAKE_EXP_DIR, "metadata", "data") + ) monkeypatch.setattr( BasicConfig, "DB_PATH", os.path.join(FAKE_EXP_DIR, "autosubmit.db") ) diff --git a/tests/experiments/as_times.db b/tests/experiments/as_times.db index dd846a25021a24072ee08856906e7775e5fdcf6d..b724c52b2b5cd4f28182a5f93ddc31c741d5b1b3 100644 GIT binary patch delta 263 zcmZozz}Vn0L0XW7fq{Vuh+%+nqK+{?3xi(KB3_^nBfk&>ztCnufhBxgjoFOs;`;iG z&8(B3aH%sgP3GZNtmjOvD2UHYQSkH&admeMQt;>=6TO;reSjR1-u zi58b6mXsD_5l>Cd&r2zePsvP6!)jb^K2Rz%H3eC#48*2@AWvV%phyL8*GL5oh>JA2 bG&eac`j0;*P07RsicqBM0OC@+w>gB=Ik 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
    ^NjzaWCvj+v$^!># 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%0APFQ4aKsWsjyV0OW0svxOsRRJDzV2`PluVAF^%7V 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 Date: Tue, 13 Feb 2024 15:57:49 +0100 Subject: [PATCH 06/18] quickfix tasks module --- autosubmit_api/app.py | 8 +++----- autosubmit_api/bgtasks/tasks/__init__.py | 0 autosubmit_api/database/__init__.py | 18 +++++++++++++++--- tests/experiments/as_times.db | Bin 8192 -> 8192 bytes 4 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 autosubmit_api/bgtasks/tasks/__init__.py diff --git a/autosubmit_api/app.py b/autosubmit_api/app.py index 5c7acd7..6e63fa4 100644 --- a/autosubmit_api/app.py +++ b/autosubmit_api/app.py @@ -6,6 +6,7 @@ from flask import Flask from autosubmit_api.bgtasks.scheduler import create_bind_scheduler from autosubmit_api.blueprints.v3 import create_v3_blueprint from autosubmit_api.blueprints.v4 import create_v4_blueprint +from autosubmit_api.database import prepare_db from autosubmit_api.database.extended_db import ExtendedDB from autosubmit_api.experiment import common_requests as CommonRequests from autosubmit_api.logger import get_app_logger @@ -66,11 +67,8 @@ def create_app(): ) # Prepare DB - ext_db = ExtendedDB( - APIBasicConfig.DB_DIR, APIBasicConfig.DB_FILE, APIBasicConfig.AS_TIMES_DB - ) - ext_db.prepare_db() - + prepare_db() + # Background Scheduler create_bind_scheduler(app) diff --git a/autosubmit_api/bgtasks/tasks/__init__.py b/autosubmit_api/bgtasks/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/autosubmit_api/database/__init__.py b/autosubmit_api/database/__init__.py index 0ac9383..e2c02e2 100644 --- a/autosubmit_api/database/__init__.py +++ b/autosubmit_api/database/__init__.py @@ -1,7 +1,19 @@ -from autosubmit_api.database.common import create_as_times_db_engine -from autosubmit_api.database.tables import experiment_status_table +from sqlalchemy import text +from autosubmit_api.database.common import ( + create_as_times_db_engine, + create_autosubmit_db_engine, +) +from autosubmit_api.database.tables import experiment_status_table, details_table def prepare_db(): with create_as_times_db_engine().connect() as conn: - experiment_status_table.create(conn, checkfirst=True) \ No newline at end of file + experiment_status_table.create(conn, checkfirst=True) + + with create_autosubmit_db_engine().connect() as conn: + details_table.create(conn, checkfirst=True) + + view_name = "listexp" + view_from = "select id,name,user,created,model,branch,hpc,description from experiment left join details on experiment.id = details.exp_id" + new_view_stmnt = f"CREATE VIEW IF NOT EXISTS {view_name} as {view_from}" + conn.execute(text(new_view_stmnt)) diff --git a/tests/experiments/as_times.db b/tests/experiments/as_times.db index b724c52b2b5cd4f28182a5f93ddc31c741d5b1b3..668a6fa93ea4ca479c7410c907ebe1dee8db4824 100644 GIT binary patch delta 49 pcmZp0XmFSy&B#7c#+i|QV}h$ZyQ!6_k(Ghj8vs6C3`76` delta 49 pcmZp0XmFSy&B!uQ#+i|2V}h$ZyNQ*lv6ZpO8vs3*3_$<@ -- GitLab From b8c442bc2a40118d5fdfb9a1864a8c050023a200 Mon Sep 17 00:00:00 2001 From: Luiggi Tenorio Date: Wed, 14 Feb 2024 13:49:16 +0100 Subject: [PATCH 07/18] fix tests fixture --- README.md | 2 +- autosubmit_api/config/confConfigStrategy.py | 2 +- tests/conftest.py | 25 +++++++++--------- tests/experiments/.autosubmitrc | 18 +++++++++++++ tests/experiments/as_times.db | Bin 8192 -> 8192 bytes .../metadata/structures/structure_a003.db | Bin 0 -> 12288 bytes .../metadata/structures/structure_a007.db | Bin 0 -> 12288 bytes .../metadata/structures/structure_a3tb.db | Bin 0 -> 12288 bytes tests/test_config.py | 25 ++++++++++++++++++ 9 files changed, 58 insertions(+), 14 deletions(-) create mode 100644 tests/experiments/.autosubmitrc create mode 100644 tests/experiments/metadata/structures/structure_a003.db create mode 100644 tests/experiments/metadata/structures/structure_a007.db create mode 100755 tests/experiments/metadata/structures/structure_a3tb.db diff --git a/README.md b/README.md index dcc7389..dc078e6 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ pytest tests/* ### Run tests with coverage HTML report: ```bash -pytest --cov=autosubmit_api --cov-config=.coveragerc --cov-report=html tests/* +pytest --cov=autosubmit_api --cov-config=.coveragerc --cov-report=html tests/ ``` You will find the report in `htmlcov/index.html` diff --git a/autosubmit_api/config/confConfigStrategy.py b/autosubmit_api/config/confConfigStrategy.py index 9ccd8ee..2bbadb2 100644 --- a/autosubmit_api/config/confConfigStrategy.py +++ b/autosubmit_api/config/confConfigStrategy.py @@ -647,7 +647,7 @@ class confConfigStrategy(IConfigStrategy): if self._proj_parser_file == '': self._proj_parser = None else: - self._proj_parser = AutosubmitConfig.get_parser( + self._proj_parser = confConfigStrategy.get_parser( self.parser_factory, self._proj_parser_file) return True except Exception as e: diff --git a/tests/conftest.py b/tests/conftest.py index d609778..ee750ce 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,19 +20,20 @@ def fixture_disable_protection(monkeypatch: pytest.MonkeyPatch): @pytest.fixture def fixture_mock_basic_config(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("AUTOSUBMIT_CONFIGURATION", os.path.join(FAKE_EXP_DIR, ".autosubmitrc")) # Patch APIBasicConfig parent BasicConfig - monkeypatch.setattr(BasicConfig, "read", custom_return_value(None)) - monkeypatch.setattr(APIBasicConfig, "read", custom_return_value(None)) - monkeypatch.setattr(BasicConfig, "LOCAL_ROOT_DIR", FAKE_EXP_DIR) - monkeypatch.setattr(BasicConfig, "DB_DIR", FAKE_EXP_DIR) - monkeypatch.setattr(BasicConfig, "DB_FILE", "autosubmit.db") - monkeypatch.setattr( - BasicConfig, "JOBDATA_DIR", os.path.join(FAKE_EXP_DIR, "metadata", "data") - ) - monkeypatch.setattr( - BasicConfig, "DB_PATH", os.path.join(FAKE_EXP_DIR, "autosubmit.db") - ) - monkeypatch.setattr(BasicConfig, "AS_TIMES_DB", "as_times.db") + # monkeypatch.setattr(BasicConfig, "read", custom_return_value(None)) + # monkeypatch.setattr(APIBasicConfig, "read", custom_return_value(None)) + # monkeypatch.setattr(BasicConfig, "LOCAL_ROOT_DIR", FAKE_EXP_DIR) + # monkeypatch.setattr(BasicConfig, "DB_DIR", FAKE_EXP_DIR) + # monkeypatch.setattr(BasicConfig, "DB_FILE", "autosubmit.db") + # monkeypatch.setattr( + # BasicConfig, "JOBDATA_DIR", os.path.join(FAKE_EXP_DIR, "metadata", "data") + # ) + # monkeypatch.setattr( + # BasicConfig, "DB_PATH", os.path.join(FAKE_EXP_DIR, "autosubmit.db") + # ) + # monkeypatch.setattr(BasicConfig, "AS_TIMES_DB", "as_times.db") yield APIBasicConfig diff --git a/tests/experiments/.autosubmitrc b/tests/experiments/.autosubmitrc new file mode 100644 index 0000000..d2f3642 --- /dev/null +++ b/tests/experiments/.autosubmitrc @@ -0,0 +1,18 @@ +[database] +path = ./tests/experiments/ +filename = autosubmit.db + +[local] +path = ./tests/experiments/ + +[globallogs] +path = ./tests/experiments/logs + +[historicdb] +path = ./tests/experiments/metadata/data + +[structures] +path = ./tests/experiments/metadata/structures + +[historiclog] +path = ./tests/experiments/metadata/logs \ No newline at end of file diff --git a/tests/experiments/as_times.db b/tests/experiments/as_times.db index 668a6fa93ea4ca479c7410c907ebe1dee8db4824..ed8f01eeefc418b04555f6251cf9cb59167b514b 100644 GIT binary patch delta 58 scmZp0XmFSy&FDN)#+lK1V}g@Bmx->Sv6YFrm8s$6HS!9`+-oRY0K?`EcmMzZ delta 58 scmZp0XmFSy&B#7c#+i|QV}g@Bm$9y)sg&M<^gayZc=|CO4@U8c;HP3dITDj`?wZ={cy8pe$bId5EU${~Y_KaM z=KXOpVj`Xj7PKQ4bbWvKuRBDtD&GxShh1-HzR_;XMN(d>FTYg_+wbcN8Jrx))6-AG zw0GydiAZG3^UlaCd8@O&e%9ZU&2$>(4?@rBclwPc^d-HUh8Pfl00bZa0SG_<0uX=z z1Rwx`+b8hQ(DtmJZP&QpZn{1Xy{LO2#Gs{P!q@cxm_TR|(k zSm9x7zp|z|IR!G8Eve2*wsfsnlDUI+7|DyK@43MfO)I&7!fbWaaf9%U*Lk37PBEL7 zo6F_dzGX$T8Zx^2eA%k>(KgbzX_yy;zMyBcK);$7bV$!{-z&z6ApijgKmY;|fB*y_ z009U<;J+4ltnXQhYJuNO=6e_3w+^|D@a=Lz{S cAls-fx_|AF?i6zly4?P&Pj#zt=Vtx?Z|s=+O8@`> literal 0 HcmV?d00001 diff --git a/tests/experiments/metadata/structures/structure_a007.db b/tests/experiments/metadata/structures/structure_a007.db new file mode 100644 index 0000000000000000000000000000000000000000..1db581bacb13626eed16874a91a27b31f468662b GIT binary patch literal 12288 zcmeI$F>ljA6bJBg?8Z>3Iape@Tto#8w6)zR^56v%8HnUMP3%lmI)&m$k(vhFiJP@ow9uP$#FBl_) z9Hw1O`(;zo8|`wCUgzUw?U3vy-`41FQmR~#$_4$oYyt8RfB*y_009U<00Izz00bZa zfqxbF@$MNh#oQ%ftiQuOqnI4I0QWz_>dY&73EOKA^9xGbKwpQ7d z7V}{;88Z>j1PeM53;MplePf20EXwzTcDL_sWe?hpxtP?it*^f>l{elt6*4+`AJ0xd z9;dZCA56tWj(O1@xm6UM*Y)@2o@;5>E58Umqd(|p8qr<4o(|C<009U<00Izz00bZa z0SG_<0(Vcq&{f+Rn5ND5%(Pji#fL{GZwKvs(Bffxf4!_$YchAx2_t#a@;x`$Tv2xn zIrKX%*XN-Z^$+B@=k0eQFALN))v$9>8MyiWr>a_WWkx((c{+#Mm(eAOHafKmY;|fB*y_ z009X6*8-MiJ4(I=zP<9-A8FNEw&Hr}Uw)$P7+GRb?Z5I|Gwf{I)n5XIThQuS&CTX6 css7c5<`kV{QTbom)ST-5{EOwkfxq?tAIJjxbpQYW literal 0 HcmV?d00001 diff --git a/tests/experiments/metadata/structures/structure_a3tb.db b/tests/experiments/metadata/structures/structure_a3tb.db new file mode 100755 index 0000000000000000000000000000000000000000..45dbb0f57b16cd6af2db84180d89348307c2729c GIT binary patch literal 12288 zcmeI#&r8EF6u|MMippR=ZoB5V!4RbM?v$cJsZ86`4y9@ag>}^~g>Pj_-q{ydGwQi^k>gb=bG2ld#7%@4Op{j~Y6km7Xe zw!0s~>8wR(-F@Fxz&-*9Ab_i}d y00IagfB*srAb Date: Wed, 14 Feb 2024 14:50:47 +0100 Subject: [PATCH 08/18] include assert error messages --- autosubmit_api/experiment/common_requests.py | 2 +- tests/docker/Dockerfile | 17 ++++++++++++++ tests/experiments/as_times.db | Bin 8192 -> 8192 bytes tests/test_endpoints_v3.py | 23 +++++++++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 tests/docker/Dockerfile diff --git a/autosubmit_api/experiment/common_requests.py b/autosubmit_api/experiment/common_requests.py index 8e3d910..5d29898 100644 --- a/autosubmit_api/experiment/common_requests.py +++ b/autosubmit_api/experiment/common_requests.py @@ -727,7 +727,7 @@ def get_experiment_tree_rundetail(expid, run_id): print((traceback.format_exc())) return {'tree': [], 'jobs': [], 'total': 0, 'reference': [], 'error': True, 'error_message': str(e), 'pkl_timestamp': 0} base_list['error'] = False - base_list['error_message'] = 'None' + base_list['error_message'] = '' base_list['pkl_timestamp'] = pkl_timestamp return base_list diff --git a/tests/docker/Dockerfile b/tests/docker/Dockerfile new file mode 100644 index 0000000..60b823e --- /dev/null +++ b/tests/docker/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.8-slim-bullseye + +WORKDIR / + +RUN apt-get update && apt-get install -y git + +RUN git clone https://earth.bsc.es/gitlab/es/autosubmit_api.git + +WORKDIR /autosubmit_api/ + +RUN git checkout v4.0.0b4 + +RUN pip install . + +RUN pip install -U pytest pytest-cov + +RUN pytest --cov=autosubmit_api --cov-config=.coveragerc --cov-report=html tests/ \ No newline at end of file diff --git a/tests/experiments/as_times.db b/tests/experiments/as_times.db index ed8f01eeefc418b04555f6251cf9cb59167b514b..aaf59db395bd3f51b877b4e12c0cd433e5d84f1c 100644 GIT binary patch delta 49 pcmZp0XmFSy&FDE%#+lJ`V}h$ZyNQ*lft8Wb8vs$-47mUR delta 49 pcmZp0XmFSy&FDN)#+lK1V}h$ZyRnstxs|Em8vs!|48i~a diff --git a/tests/test_endpoints_v3.py b/tests/test_endpoints_v3.py index d0f5896..7d0e791 100644 --- a/tests/test_endpoints_v3.py +++ b/tests/test_endpoints_v3.py @@ -89,6 +89,7 @@ class TestExpInfo: expid = "a003" response = fixture_client.get(self.endpoint.format(expid=expid)) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["expid"] == expid assert resp_obj["total_jobs"] == 8 @@ -97,6 +98,7 @@ class TestExpInfo: expid = "a3tb" response = fixture_client.get(self.endpoint.format(expid=expid)) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["expid"] == expid assert resp_obj["total_jobs"] == 55 @@ -113,12 +115,14 @@ class TestPerformance: expid = "a007" response = fixture_client.get(self.endpoint.format(expid=expid)) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["Parallelization"] == 8 expid = "a3tb" response = fixture_client.get(self.endpoint.format(expid=expid)) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["Parallelization"] == 768 @@ -129,6 +133,7 @@ class TestPerformance: expid = "a003" response = fixture_client.get(self.endpoint.format(expid=expid)) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["Parallelization"] == 16 @@ -145,6 +150,7 @@ class TestTree: ) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["total"] == 8 assert resp_obj["total"] == len(resp_obj["jobs"]) @@ -160,6 +166,7 @@ class TestTree: ) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["total"] == 55 assert resp_obj["total"] == len(resp_obj["jobs"]) @@ -179,6 +186,7 @@ class TestRunsList: response = fixture_client.get(self.endpoint.format(expid=expid)) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert isinstance(resp_obj["runs"], list) @@ -192,6 +200,7 @@ class TestRunDetail: response = fixture_client.get(self.endpoint.format(expid=expid, runId=2)) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["total"] == 8 @@ -204,6 +213,7 @@ class TestQuick: response = fixture_client.get(self.endpoint.format(expid=expid)) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["total"] == len(resp_obj["tree_view"]) assert resp_obj["total"] == len(resp_obj["view_data"]) @@ -221,6 +231,7 @@ class TestGraph: ) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["total_jobs"] == len(resp_obj["nodes"]) @@ -235,6 +246,7 @@ class TestGraph: ) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["total_jobs"] == len(resp_obj["nodes"]) @@ -247,6 +259,7 @@ class TestGraph: ) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["total_jobs"] == len(resp_obj["nodes"]) @@ -259,6 +272,7 @@ class TestGraph: ) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["total_jobs"] == len(resp_obj["nodes"]) @@ -271,6 +285,7 @@ class TestExpCount: response = fixture_client.get(self.endpoint.format(expid=expid)) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["total"] == sum( [resp_obj["counters"][key] for key in resp_obj["counters"]] @@ -284,6 +299,7 @@ class TestExpCount: response = fixture_client.get(self.endpoint.format(expid=expid)) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["total"] == sum( [resp_obj["counters"][key] for key in resp_obj["counters"]] @@ -308,6 +324,7 @@ class TestSummary: ) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["n_sim"] > 0 @@ -322,6 +339,7 @@ class TestStatistics: ) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["Statistics"]["Period"]["From"] == "None" @@ -334,6 +352,7 @@ class TestCurrentConfig: response = fixture_client.get(self.endpoint.format(expid=expid)) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert ( resp_obj["configuration_filesystem"]["CONFIG"]["AUTOSUBMIT_VERSION"] @@ -345,6 +364,7 @@ class TestCurrentConfig: response = fixture_client.get(self.endpoint.format(expid=expid)) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert ( resp_obj["configuration_filesystem"]["conf"]["config"]["AUTOSUBMIT_VERSION"] @@ -360,6 +380,7 @@ class TestPklInfo: response = fixture_client.get(self.endpoint.format(expid=expid, timestamp=0)) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert len(resp_obj["pkl_content"]) == 8 @@ -375,6 +396,7 @@ class TestPklTreeInfo: response = fixture_client.get(self.endpoint.format(expid=expid, timestamp=0)) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert len(resp_obj["pkl_content"]) == 8 @@ -390,5 +412,6 @@ class TestExpRunLog: response = fixture_client.get(self.endpoint.format(expid=expid)) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["found"] == True -- GitLab From dc06d1554bceb03082d9afdf1b6ba78ac3e046ac Mon Sep 17 00:00:00 2001 From: Luiggi Tenorio Date: Wed, 14 Feb 2024 15:04:50 +0100 Subject: [PATCH 09/18] fix protection level fixture --- autosubmit_api/auth/__init__.py | 6 +++--- tests/conftest.py | 2 ++ tests/experiments/as_times.db | Bin 8192 -> 8192 bytes tests/test_auth.py | 6 ++++++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/autosubmit_api/auth/__init__.py b/autosubmit_api/auth/__init__.py index b15562c..92ee07a 100644 --- a/autosubmit_api/auth/__init__.py +++ b/autosubmit_api/auth/__init__.py @@ -3,7 +3,7 @@ from http import HTTPStatus from flask import request import jwt from autosubmit_api.logger import logger -from autosubmit_api.config import PROTECTION_LEVEL, JWT_ALGORITHM, JWT_SECRET +from autosubmit_api import config from enum import IntEnum @@ -45,12 +45,12 @@ def with_auth_token( def inner_wrapper(*args, **kwargs): try: current_token = request.headers.get("Authorization") - jwt_token = jwt.decode(current_token, JWT_SECRET, JWT_ALGORITHM) + jwt_token = jwt.decode(current_token, config.JWT_SECRET, config.JWT_ALGORITHM) except Exception as exc: error_msg = "Unauthorized" if isinstance(exc, jwt.ExpiredSignatureError): error_msg = "Expired token" - auth_level = _parse_protection_level_env(PROTECTION_LEVEL) + auth_level = _parse_protection_level_env(config.PROTECTION_LEVEL) if threshold <= auth_level: # If True, will trigger *_on_fail if raise_on_fail: raise AppAuthError(error_msg) diff --git a/tests/conftest.py b/tests/conftest.py index ee750ce..9d3f98e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ import pytest from autosubmitconfigparser.config.basicconfig import BasicConfig from autosubmit_api.app import create_app from autosubmit_api.config.basicConfig import APIBasicConfig +from autosubmit_api import config from tests.custom_utils import custom_return_value FAKE_EXP_DIR = "./tests/experiments/" @@ -15,6 +16,7 @@ FAKE_EXP_DIR = "./tests/experiments/" #### FIXTURES #### @pytest.fixture(autouse=True) def fixture_disable_protection(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(config, "PROTECTION_LEVEL", "NONE") monkeypatch.setenv("PROTECTION_LEVEL", "NONE") diff --git a/tests/experiments/as_times.db b/tests/experiments/as_times.db index aaf59db395bd3f51b877b4e12c0cd433e5d84f1c..922044b6ef2ad0e745b1d769441aa83ed22a7691 100644 GIT binary patch delta 47 scmZp0XmFSy%@{XP#+fm0V}h$Zo2iw7v6ac>)$%GZ=2dwWc8I7E06^XhfB*mh delta 47 scmZp0XmFSy&FDE%#+lJ`V}h$Zn~9aFftAtZ)$%GZ=2dwWc8I7E06LruQvd(} diff --git a/tests/test_auth.py b/tests/test_auth.py index ce59c9d..5fef206 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,13 +1,19 @@ +import os from uuid import uuid4 import pytest from autosubmit_api.auth import ProtectionLevels, with_auth_token from autosubmit_api import auth from autosubmit_api.auth.utils import validate_client from autosubmit_api.config.basicConfig import APIBasicConfig +from autosubmit_api import config from tests.custom_utils import custom_return_value, dummy_response class TestCommonAuth: + def test_mock_env_protection_level(self): + assert os.environ.get("PROTECTION_LEVEL") == "NONE" + assert config.PROTECTION_LEVEL == "NONE" + def test_levels_enum(self): assert ProtectionLevels.ALL > ProtectionLevels.WRITEONLY assert ProtectionLevels.WRITEONLY > ProtectionLevels.NONE -- GitLab From 40bca2b86b89bd48e32ebb630cfb410dae2a7a13 Mon Sep 17 00:00:00 2001 From: Luiggi Tenorio Date: Wed, 14 Feb 2024 16:59:35 +0100 Subject: [PATCH 10/18] fix graph path config --- autosubmit_api/config/basicConfig.py | 6 ++- autosubmit_api/database/db_jobdata.py | 3 +- tests/docker/Dockerfile | 2 +- tests/experiments/.autosubmitrc | 5 ++- tests/experiments/as_times.db | Bin 8192 -> 8192 bytes .../graph/graph_data_a003.db | Bin .../metadata/graph/graph_data_a3tb.db | Bin 0 -> 8192 bytes tests/test_config.py | 4 ++ tests/test_endpoints_v3.py | 40 ++++++++++++++++-- 9 files changed, 51 insertions(+), 9 deletions(-) rename tests/experiments/{as_metadata => metadata}/graph/graph_data_a003.db (100%) create mode 100755 tests/experiments/metadata/graph/graph_data_a3tb.db diff --git a/autosubmit_api/config/basicConfig.py b/autosubmit_api/config/basicConfig.py index fdb9e57..95acbff 100644 --- a/autosubmit_api/config/basicConfig.py +++ b/autosubmit_api/config/basicConfig.py @@ -27,8 +27,8 @@ class APIBasicConfig(BasicConfig): Extended class to manage configuration for Autosubmit path, database and default values for new experiments in the Autosubmit API """ - GRAPHDATA_DIR = os.path.join(os.path.expanduser('~'), 'autosubmit', 'as_metadata', 'graph') - FILE_STATUS_DIR = os.path.join(os.path.expanduser('~'), 'autosubmit', 'as_metadata', 'test') + GRAPHDATA_DIR = os.path.join(os.path.expanduser('~'), 'autosubmit', 'metadata', 'graph') + FILE_STATUS_DIR = os.path.join(os.path.expanduser('~'), 'autosubmit', 'metadata', 'test') FILE_STATUS_DB = 'status.db' ALLOWED_CLIENTS = set([]) @@ -45,6 +45,8 @@ class APIBasicConfig(BasicConfig): if parser.has_option('graph', 'path'): APIBasicConfig.GRAPHDATA_DIR = parser.get('graph', 'path') + else: + APIBasicConfig.GRAPHDATA_DIR = os.path.join(APIBasicConfig.LOCAL_ROOT_DIR, 'metadata', 'graph') if parser.has_option('statusdb', 'path'): APIBasicConfig.FILE_STATUS_DIR = parser.get('statusdb', 'path') if parser.has_option('statusdb', 'filename'): diff --git a/autosubmit_api/database/db_jobdata.py b/autosubmit_api/database/db_jobdata.py index d9fb4e1..6f0826c 100644 --- a/autosubmit_api/database/db_jobdata.py +++ b/autosubmit_api/database/db_jobdata.py @@ -533,8 +533,7 @@ class ExperimentGraphDrawing(MainDataBase): APIBasicConfig.read() self.expid = expid self.folder_path = APIBasicConfig.LOCAL_ROOT_DIR - self.database_path = os.path.join( - self.folder_path, "as_metadata", "graph" , "graph_data_" + str(expid) + ".db") + self.database_path = os.path.join(APIBasicConfig.GRAPHDATA_DIR, "graph_data_" + str(expid) + ".db") self.create_table_query = textwrap.dedent( '''CREATE TABLE IF NOT EXISTS experiment_graph_draw ( diff --git a/tests/docker/Dockerfile b/tests/docker/Dockerfile index 60b823e..9e2ed0c 100644 --- a/tests/docker/Dockerfile +++ b/tests/docker/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.8-slim-bullseye WORKDIR / -RUN apt-get update && apt-get install -y git +RUN apt-get update && apt-get install -y git graphviz RUN git clone https://earth.bsc.es/gitlab/es/autosubmit_api.git diff --git a/tests/experiments/.autosubmitrc b/tests/experiments/.autosubmitrc index d2f3642..da7bbe7 100644 --- a/tests/experiments/.autosubmitrc +++ b/tests/experiments/.autosubmitrc @@ -15,4 +15,7 @@ path = ./tests/experiments/metadata/data path = ./tests/experiments/metadata/structures [historiclog] -path = ./tests/experiments/metadata/logs \ No newline at end of file +path = ./tests/experiments/metadata/logs + +[graph] +path = ./tests/experiments/metadata/graph \ No newline at end of file diff --git a/tests/experiments/as_times.db b/tests/experiments/as_times.db index 922044b6ef2ad0e745b1d769441aa83ed22a7691..3f9a4743b4dd8df93226cb03fa54a25b3ca4572f 100644 GIT binary patch delta 49 ncmZp0XmFSy%~&u|#+k8TV}h$ZyP1`#1rSYMBd-i+Uqi3~VLc5% delta 49 pcmZp0XmFSy%@{XP#+fm0V}h$ZyQ!6dv6YF@8vs~y4CVj; diff --git a/tests/experiments/as_metadata/graph/graph_data_a003.db b/tests/experiments/metadata/graph/graph_data_a003.db similarity index 100% rename from tests/experiments/as_metadata/graph/graph_data_a003.db rename to tests/experiments/metadata/graph/graph_data_a003.db diff --git a/tests/experiments/metadata/graph/graph_data_a3tb.db b/tests/experiments/metadata/graph/graph_data_a3tb.db new file mode 100755 index 0000000000000000000000000000000000000000..ff0fb9fcdbfb86d91e53f51afa5666b2c2a28d6b GIT binary patch literal 8192 zcmeI#!E3@W6bA4_guy`cw(Hw%1KGuc*L5RESy#1Iq-;=9nr)R;B?MNF`j?x+S`QYy zihLgl;pHVc{BD^|z04^3TI*aI8n7PYoIMjU#<-m=JFAp+yDIEj*5|_6)5oA=Px00LbV^82|tP literal 0 HcmV?d00001 diff --git a/tests/test_config.py b/tests/test_config.py index 6c6daf8..64b1245 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -35,6 +35,10 @@ class TestBasicConfig: FAKE_EXP_DIR, "metadata", "logs" ) + assert APIBasicConfig.GRAPHDATA_DIR == os.path.join( + FAKE_EXP_DIR, "metadata", "graph" + ) + class TestConfigResolver: def test_simple_init(self, monkeypatch: pytest.MonkeyPatch): # Conf test decision diff --git a/tests/test_endpoints_v3.py b/tests/test_endpoints_v3.py index 7d0e791..4d5b036 100644 --- a/tests/test_endpoints_v3.py +++ b/tests/test_endpoints_v3.py @@ -230,7 +230,6 @@ class TestGraph: query_string={"loggedUser": random_user}, ) resp_obj: dict = response.get_json() - assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["total_jobs"] == len(resp_obj["nodes"]) @@ -245,7 +244,6 @@ class TestGraph: query_string={"loggedUser": random_user}, ) resp_obj: dict = response.get_json() - assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["total_jobs"] == len(resp_obj["nodes"]) @@ -258,7 +256,6 @@ class TestGraph: query_string={"loggedUser": random_user}, ) resp_obj: dict = response.get_json() - assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["total_jobs"] == len(resp_obj["nodes"]) @@ -271,7 +268,44 @@ class TestGraph: query_string={"loggedUser": random_user}, ) resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" + assert resp_obj["error"] == False + assert resp_obj["total_jobs"] == len(resp_obj["nodes"]) + def test_graph_standard_none_retro3(self, fixture_client: FlaskClient): + expid = "a3tb" + random_user = str(uuid4()) + response = fixture_client.get( + self.endpoint.format(expid=expid, graph_type="standard", grouped="none"), + query_string={"loggedUser": random_user}, + ) + resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" + assert resp_obj["error"] == False + assert resp_obj["total_jobs"] == len(resp_obj["nodes"]) + + def test_graph_standard_datemember_retro3(self, fixture_client: FlaskClient): + expid = "a3tb" + random_user = str(uuid4()) + response = fixture_client.get( + self.endpoint.format( + expid=expid, graph_type="standard", grouped="date-member" + ), + query_string={"loggedUser": random_user}, + ) + resp_obj: dict = response.get_json() + assert resp_obj["error_message"] == "" + assert resp_obj["error"] == False + assert resp_obj["total_jobs"] == len(resp_obj["nodes"]) + + def test_graph_standard_status_retro3(self, fixture_client: FlaskClient): + expid = "a3tb" + random_user = str(uuid4()) + response = fixture_client.get( + self.endpoint.format(expid=expid, graph_type="standard", grouped="status"), + query_string={"loggedUser": random_user}, + ) + resp_obj: dict = response.get_json() assert resp_obj["error_message"] == "" assert resp_obj["error"] == False assert resp_obj["total_jobs"] == len(resp_obj["nodes"]) -- GitLab From b043d642ca7883f7638c154bd1cb35b4df8a9e26 Mon Sep 17 00:00:00 2001 From: Luiggi Tenorio Date: Thu, 15 Feb 2024 12:57:11 +0100 Subject: [PATCH 11/18] removed unused workers --- .../autosubmit_legacy/job/job_list.py | 4 +- autosubmit_api/bgtasks/bgtask.py | 13 +- autosubmit_api/bgtasks/scheduler.py | 4 - .../bgtasks/tasks/job_times_updater.py | 60 -- .../experiment/common_db_requests.py | 686 ------------------ .../workers/business/populate_times.py | 365 ---------- .../workers/populate_queue_run_times.py | 10 - autosubmit_api/workers/verify_complete.py | 10 - tests/experiments/as_times.db | Bin 8192 -> 8192 bytes 9 files changed, 4 insertions(+), 1148 deletions(-) delete mode 100644 autosubmit_api/bgtasks/tasks/job_times_updater.py delete mode 100644 autosubmit_api/workers/business/populate_times.py delete mode 100644 autosubmit_api/workers/populate_queue_run_times.py delete mode 100644 autosubmit_api/workers/verify_complete.py diff --git a/autosubmit_api/autosubmit_legacy/job/job_list.py b/autosubmit_api/autosubmit_legacy/job/job_list.py index 2715556..dab3d9a 100644 --- a/autosubmit_api/autosubmit_legacy/job/job_list.py +++ b/autosubmit_api/autosubmit_legacy/job/job_list.py @@ -590,7 +590,7 @@ class JobList: conn = DbRequests.create_connection(db_file) # job_data = None # Job information from worker database - job_times = dict() # REMOVED: DbRequests.get_times_detail_by_expid(conn, expid) + # job_times = dict() # REMOVED: DbRequests.get_times_detail_by_expid(conn, expid) conn.close() # Job information from job historic data # print("Get current job data structure...") @@ -612,7 +612,7 @@ class JobList: # print("Start main loop") for job in allJobs: job_info = JobList.retrieve_times( - job.status, job.name, job._tmp_path, make_exception=False, job_times=job_times, seconds=timeseconds, job_data_collection=job_data) + job.status, job.name, job._tmp_path, make_exception=False, job_times=None, seconds=timeseconds, job_data_collection=job_data) # if job_info: job_name_to_job_info[job.name] = job_info time_total = (job_info.queue_time + diff --git a/autosubmit_api/bgtasks/bgtask.py b/autosubmit_api/bgtasks/bgtask.py index c043c57..a23aa27 100644 --- a/autosubmit_api/bgtasks/bgtask.py +++ b/autosubmit_api/bgtasks/bgtask.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod import traceback from autosubmit_api.logger import logger from autosubmit_api.config.basicConfig import APIBasicConfig -from autosubmit_api.workers.business import populate_times, process_graph_drawings +from autosubmit_api.workers.business import process_graph_drawings from autosubmit_api.workers.populate_details.populate import DetailsProcessor @@ -11,6 +11,7 @@ class BackgroundTaskTemplate(ABC): Interface to define Background Tasks. Do not override the run method. """ + logger = logger @classmethod @@ -50,16 +51,6 @@ class PopulateDetailsDB(BackgroundTaskTemplate): return DetailsProcessor(APIBasicConfig).process() -class PopulateQueueRuntimes(BackgroundTaskTemplate): - id = "TASK_POPQUE" - trigger_options = {"trigger": "interval", "minutes": 3} - - @classmethod - def procedure(cls): - """Process and updates queuing and running times.""" - populate_times.process_completed_times() - - class PopulateGraph(BackgroundTaskTemplate): id = "TASK_POPGRPH" trigger_options = {"trigger": "interval", "hours": 24} diff --git a/autosubmit_api/bgtasks/scheduler.py b/autosubmit_api/bgtasks/scheduler.py index 26aafc5..6e890a6 100644 --- a/autosubmit_api/bgtasks/scheduler.py +++ b/autosubmit_api/bgtasks/scheduler.py @@ -3,10 +3,8 @@ from flask_apscheduler import APScheduler from autosubmit_api.bgtasks.bgtask import ( BackgroundTaskTemplate, PopulateDetailsDB, - PopulateQueueRuntimes, PopulateGraph, ) -from autosubmit_api.bgtasks.tasks.job_times_updater import JobTimesUpdater from autosubmit_api.bgtasks.tasks.status_updater import StatusUpdater from autosubmit_api.config import ( DISABLE_BACKGROUND_TASKS, @@ -17,9 +15,7 @@ from autosubmit_api.logger import logger, with_log_run_times REGISTERED_TASKS: List[BackgroundTaskTemplate] = [ PopulateDetailsDB, - # PopulateQueueRuntimes, StatusUpdater, - # JobTimesUpdater, PopulateGraph, ] diff --git a/autosubmit_api/bgtasks/tasks/job_times_updater.py b/autosubmit_api/bgtasks/tasks/job_times_updater.py deleted file mode 100644 index dd8c817..0000000 --- a/autosubmit_api/bgtasks/tasks/job_times_updater.py +++ /dev/null @@ -1,60 +0,0 @@ -import os -import time -from autosubmit_api.bgtasks.bgtask import BackgroundTaskTemplate -from autosubmit_api.common import utils as common_utils -from autosubmit_api.components.jobs import utils as JUtils -from autosubmit_api.config.basicConfig import APIBasicConfig -from autosubmit_api.experiment import common_db_requests as DbRequests -from autosubmit_api.experiment.common_requests import SAFE_TIME_LIMIT - - -def verify_last_completed(seconds=300): - """ - Verifying last 300 seconds by default - """ - # Basic info - t0 = time.time() - APIBasicConfig.read() - # Current timestamp - current_st = time.time() - # Current latest detail - td0 = time.time() - latest_detail = DbRequests.get_latest_completed_jobs(seconds) - t_data = time.time() - td0 - # Main Loop - for job_name, detail in list(latest_detail.items()): - tmp_path = os.path.join( - APIBasicConfig.LOCAL_ROOT_DIR, job_name[:4], APIBasicConfig.LOCAL_TMP_DIR) - detail_id, submit, start, finish, status = detail - submit_time, start_time, finish_time, status_text_res = JUtils.get_job_total_stats( - common_utils.Status.COMPLETED, job_name, tmp_path) - submit_ts = int(time.mktime(submit_time.timetuple())) if len( - str(submit_time)) > 0 else 0 - start_ts = int(time.mktime(start_time.timetuple())) if len( - str(start_time)) > 0 else 0 - finish_ts = int(time.mktime(finish_time.timetuple())) if len( - str(finish_time)) > 0 else 0 - if (finish_ts != finish): - #print("\tMust Update") - DbRequests.update_job_times(detail_id, - int(current_st), - submit_ts, - start_ts, - finish_ts, - status, - debug=False, - no_modify_time=True) - t1 = time.time() - # Timer safeguard - if (t1 - t0) > SAFE_TIME_LIMIT: - raise Exception( - "Time limit reached {0:06.2f} seconds on verify_last_completed while reading {1}. Time spent on reading data {2:06.2f} seconds.".format((t1 - t0), job_name, t_data)) - - -class JobTimesUpdater(BackgroundTaskTemplate): - id = "TASK_JBTMUPDTR" - trigger_options = {"trigger": "interval", "minutes": 10} - - @classmethod - def procedure(cls): - pass \ No newline at end of file diff --git a/autosubmit_api/experiment/common_db_requests.py b/autosubmit_api/experiment/common_db_requests.py index 9d9d4a1..801e7e4 100644 --- a/autosubmit_api/experiment/common_db_requests.py +++ b/autosubmit_api/experiment/common_db_requests.py @@ -1,11 +1,7 @@ import os -import time -import textwrap import traceback import sqlite3 from datetime import datetime -from collections import OrderedDict -from typing import List, Tuple, Dict from autosubmit_api.config.basicConfig import APIBasicConfig APIBasicConfig.read() @@ -15,98 +11,8 @@ DB_FILES_STATUS = os.path.join(APIBasicConfig.LOCAL_ROOT_DIR, "as_metadata", "te # PATH_DB_DATA = "/esarchive/autosubmit/as_metadata/data/" -def prepare_status_db(): - """ - Creates table of experiment status if it does not exist. - :return: Map from experiment name to (Id of experiment, Status, Seconds) - :rtype: Dictionary Key: String, Value: Integer, String, Integer - """ - conn = create_connection(DB_FILE_AS_TIMES) - create_table_query = textwrap.dedent( - '''CREATE TABLE - IF NOT EXISTS experiment_status ( - exp_id integer PRIMARY KEY, - name text NOT NULL, - status text NOT NULL, - seconds_diff integer NOT NULL, - modified text NOT NULL, - FOREIGN KEY (exp_id) REFERENCES experiment (id) - );''') - # drop_table_query = ''' DROP TABLE experiment_status ''' - # create_table(conn, drop_table_query) - create_table(conn, create_table_query) - current_table = _get_exp_status() - current_table_expid = dict() - # print(current_table) - # print(type(current_table)) - for item in current_table: - exp_id, expid, status, seconds = item - current_table_expid[expid] = (exp_id, status, seconds) - return current_table_expid - - -def prepare_completed_times_db(): - """ - Creates two tables: - "experiment_times" that stores general information about experiments' jobs completed updates - "job_times" that stores information about completed time for jobs in the database as_times.db - Then retrieves all the experiments data from "experiment_times". - :return: Dictionary that maps experiment name to experiment data. - :rtype: Dictionary: Key String, Value 6-tuple (exp_id, name, created, modified, total_jobs, completed_jobs) - """ - conn = create_connection(DB_FILE_AS_TIMES) - create_table_header_query = textwrap.dedent( - '''CREATE TABLE - IF NOT EXISTS experiment_times ( - exp_id integer PRIMARY KEY, - name text NOT NULL, - created int NOT NULL, - modified int NOT NULL, - total_jobs int NOT NULL, - completed_jobs int NOT NULL, - FOREIGN KEY (exp_id) REFERENCES experiment (id) - );''') - - create_table_detail_query = textwrap.dedent( - '''CREATE TABLE - IF NOT EXISTS job_times ( - detail_id integer PRIMARY KEY AUTOINCREMENT, - exp_id integer NOT NULL, - job_name text NOT NULL, - created integer NOT NULL, - modified integer NOT NULL, - submit_time int NOT NULL, - start_time int NOT NULL, - finish_time int NOT NULL, - status text NOT NULL, - FOREIGN KEY (exp_id) REFERENCES experiment (id) - );''') - - drop_table_header_query = ''' DROP TABLE experiment_times ''' - drop_table_details_query = ''' DROP TABLE job_times ''' - # create_table(conn, drop_table_details_query) - # create_table(conn, drop_table_header_query) - # return - create_table(conn, create_table_header_query) - create_table(conn, create_table_detail_query) - current_table = _get_exp_times() - current_table_expid = dict() - - for item in current_table: - try: - exp_id, name, created, modified, total_jobs, completed_jobs = item - current_table_expid[name] = (exp_id, int(created), int( - modified), int(total_jobs), int(completed_jobs)) - except Exception as ex: - print((traceback.format_exc())) - print(item) - print((str(name) + " ~ " + str(created) + "\t" + str(modified))) - - return current_table_expid - # STATUS ARCHIVE - def insert_archive_status(status, alatency, abandwidth, clatency, cbandwidth, rtime): try: @@ -146,99 +52,6 @@ def get_last_read_archive_status(): # INSERTIONS - -def insert_experiment_times_header(expid, timest, total_jobs, completed_jobs, debug=False, log=None): - """ - Inserts into experiment times header. Requires ecearth.db connection. - :param expid: Experiment name - :type expid: String - :param timest: timestamp of the pkl last modified date - :type timest: Integer - :param total_jobs: Total number of jobs - :type total_jobs: Integer - :param completed_jobs: Number of completed jobs - :type completed_jobs: Integer - :param debug: Flag (testing purposes) - :type debug: Boolean - :param conn_ecearth: ecearth.db connection - :type conn: sqlite3 connection - :return: exp_id of the experiment (not the experiment name) - :rtype: Integer - """ - try: - current_id = _get_id_db(create_connection(APIBasicConfig.DB_PATH), expid) - if (current_id): - if (debug == True): - print(("INSERTING INTO EXPERIMENT_TIMES " + str(current_id) + "\t" + str(expid) + - "\t" + str(timest) + "\t" + str(total_jobs) + "\t" + str(completed_jobs))) - return current_id - row_content = (current_id, expid, int(timest), int(timest), total_jobs, completed_jobs) - result = _create_exp_times(row_content) - return current_id - else: - return -1 - except sqlite3.Error as e: - print(("Error on Insert : " + str(type(e).__name__))) - print(current_id) - - -def _create_exp_times(row_content): - """ - Create experiment times - :param conn: - :param details: - :return: - """ - try: - conn = create_connection(DB_FILE_AS_TIMES) - sql = ''' INSERT OR REPLACE INTO experiment_times(exp_id, name, created, modified, total_jobs, completed_jobs) VALUES(?,?,?,?,?,?) ''' - # print(row_content) - cur = conn.cursor() - cur.execute(sql, row_content) - # print(cur) - conn.commit() - return cur.lastrowid - except sqlite3.Error as e: - print(("Error on Insert : " + str(type(e).__name__))) - print(row_content) - - -def create_many_job_times(list_job): - try: - conn = create_connection(DB_FILE_AS_TIMES) - sql = ''' INSERT INTO job_times(exp_id, job_name, created, modified, submit_time, start_time, finish_time, status) VALUES(?,?,?,?,?,?,?,?) ''' - # print(row_content) - cur = conn.cursor() - cur.executemany(sql, list_job) - # print(cur) - # Commit outside the loop - conn.commit() - except sqlite3.Error as e: - print((traceback.format_exc())) - print(("Error on Insert : " + str(type(e).__name__))) - # print((exp_id, job_name, timest, timest, - # submit_time, start_time, finish_time, status)) - - -def _insert_into_ecearth_details(exp_id, user, created, model, branch, hpc): - """ - Inserts into the details table of ecearth.db - :return: Id - :rtype: int - """ - conn = create_connection(APIBasicConfig.DB_PATH) - if conn: - try: - sql = ''' INSERT INTO details(exp_id, user, created, model, branch, hpc) VALUES(?,?,?,?,?,?) ''' - cur = conn.cursor() - cur.execute(sql, (exp_id, user, created, model, branch, hpc)) - conn.commit() - return cur.lastrowid - except Exception as exp: - print(exp) - return False - - def create_connection(db_file): # type: (str) -> sqlite3.Connection """ @@ -253,63 +66,8 @@ def create_connection(db_file): print(exp) -def create_table(conn, create_table_sql): - """ create a table from the create_table_sql statement - :param conn: Connection object - :param create_table_sql: a CREATE TABLE statement - :return: - """ - try: - c = conn.cursor() - c.execute(create_table_sql) - except Exception as e: - print(e) - - # SELECTS -def get_times_detail(exp_id): - """ - Gets the current detail of the experiment from job_times - :param exp_id: Id of the experiment - :type exp_id: Integer - :return: Dictionary Key: Job Name, Values: 5-tuple (submit time, start time, finish time, status, detail id) - :rtype: dict - """ - # conn = create_connection(DB_FILE_AS_TIMES) - try: - current_table_detail = dict() - current_table = _get_job_times(exp_id) - if current_table is None: - return None - for item in current_table: - detail_id, exp_id, job_name, created, modified, submit_time, start_time, finish_time, status = item - current_table_detail[job_name] = ( - submit_time, start_time, finish_time, status, detail_id) - return current_table_detail - except Exception as exp: - print((traceback.format_exc())) - return None - - -def get_times_detail_by_expid(conn, expid): - """ - Gets the detail of times of the experiment by expid (name of experiment).\n - :param conn: ecearth.db connection - :rtype conn: sqlite3 connection - :param expid: Experiment name - :type expid: str - :return: Dictionary Key: Job Name, Values: 5-tuple (submit time, start time, finish time, status, detail id) - :rtype: dict - """ - # conn = create_connection(DB_FILE_AS_TIMES) - exp_id = _get_id_db(conn, expid) - if (exp_id): - return get_times_detail(exp_id) - else: - return None - - def get_experiment_status(): """ Gets table experiment_status as dictionary @@ -337,287 +95,6 @@ def get_specific_experiment_status(expid): return (name, status) -def get_experiment_times(): - # type: () -> Dict[str, Tuple[int, int, int]] - """ - Gets table experiment_times as dictionary - conn is expected to reference as_times.db - """ - # conn = create_connection(DB_FILE_AS_TIMES) - experiment_times = dict() - current_table = _get_exp_times() - for item in current_table: - exp_id, name, created, modified, total_jobs, completed_jobs = item - experiment_times[name] = (total_jobs, completed_jobs, modified) - # if extended == True: - # experiment_times[name] = (total_jobs, completed_jobs, created, modified) - return experiment_times - - -def get_experiment_times_by_expid(expid): - """[summary] - :return: exp_id, total number of jobs, number of completed jobs - :rtype 3-tuple: (int, int, int) - """ - current_row = _get_exp_times_by_expid(expid) - if current_row: - exp_id, name, created, modified, total_jobs, completed_jobs = current_row - return (exp_id, total_jobs, completed_jobs) - return None - - -def get_experiment_times_group(): - """ - Gets table experiment_times as dictionary id -> name - conn is expected to reference as_times.db - """ - experiment_times = dict() - current_table = _get_exp_times() - for item in current_table: - exp_id, name, created, modified, total_jobs, completed_jobs = item - if name not in list(experiment_times.keys()): - experiment_times[name] = list() - experiment_times[name].append(exp_id) - # if extended == True: - # experiment_times[name] = (total_jobs, completed_jobs, created, modified) - return experiment_times - - -def get_exps_base(): - """ - Get exp name and id from experiment table in ecearth.db - :param conn: ecearth.db connection - :param expid: - :return: - """ - conn = create_connection(APIBasicConfig.DB_PATH) - result = dict() - conn.text_factory = str - cur = conn.cursor() - cur.execute( - "SELECT name, id FROM experiment WHERE autosubmit_version IS NOT NULL") - rows = cur.fetchall() - cur.close() - conn.close() - if (rows): - for row in rows: - _name, _id = row - result[_name] = _id - else: - return None - return result - - -def get_exps_detailed_complete(): - """ - Get information from details table - :param conn: ecearth.db connection - :param expid: - :return: Dictionary exp_id -> (user, created, model, branch, hpc) - """ - all_details = _get_exps_detailed_complete(create_connection(APIBasicConfig.DB_PATH)) - result = dict() - if (all_details): - for item in all_details: - exp_id, user, created, model, branch, hpc = item - result[exp_id] = (user, created, model, branch, hpc) - return result - - -def get_latest_completed_jobs(seconds=300): - """ - Get latest completed jobs - """ - result = dict() - latest_completed_detail = _get_latest_completed_jobs(seconds) - if (latest_completed_detail): - for item in latest_completed_detail: - detail_id, exp_id, job_name, created, modified, submit_time, start_time, finish_time, status = item - result[job_name] = (detail_id, submit_time, - start_time, finish_time, status) - return result - - -def _get_exps_detailed_complete(conn): - """ - Get information from details table - :param conn: ecearth.db connection - :param expid: - :return: - """ - conn.text_factory = str - cur = conn.cursor() - cur.execute( - "SELECT * FROM details") - rows = cur.fetchall() - cur.close() - conn.close() - return rows - - -def _get_exp_times(): - # type: () -> List[Tuple[int, str, int, int, int, int]] - """ - Get all experiments from table experiment_times.\n - :return: Row content (exp_id, name, created, modified, total_jobs, completed_jobs) - :rtype: 6-tuple (int, str, int, int, int, int) - """ - try: - conn = create_connection(os.path.join(APIBasicConfig.DB_DIR, APIBasicConfig.AS_TIMES_DB)) - conn.text_factory = str - cur = conn.cursor() - cur.execute( - "SELECT exp_id, name, created, modified, total_jobs, completed_jobs FROM experiment_times") - rows = cur.fetchall() - conn.close() - return rows - except Exception as exp: - print((traceback.format_exc())) - return list() - - -def _get_exp_times_by_expid(expid): - """ - Returns data from experiment_time table by expid. - - :param expid: expid/name - :type expid: str - :return: Row content (exp_id, name, created, modified, total_jobs, completed_jobs) - :rtype: 6-tuple (int, str, str, str, int, int) - """ - try: - conn = create_connection(os.path.join(APIBasicConfig.DB_DIR, APIBasicConfig.AS_TIMES_DB)) - conn.text_factory = str - cur = conn.cursor() - cur.execute( - "SELECT exp_id, name, created, modified, total_jobs, completed_jobs FROM experiment_times WHERE name=?", (expid,)) - rows = cur.fetchall() - conn.close() - return rows[0] if len(rows) > 0 else None - except Exception as exp: - print((traceback.format_exc())) - return None - - -def _get_job_times(exp_id): - """ - Get exp job times detail for a given expid from job_times. - :param exp_id: Experiment id (not name) - :type exp_id: Integer - :return: Detail content - :rtype: list of 9-tuple: (detail_id, exp_id, job_name, created, modified, submit_time, start_time, finish_time, status) - """ - try: - conn = create_connection(os.path.join(APIBasicConfig.DB_DIR, APIBasicConfig.AS_TIMES_DB)) - cur = conn.cursor() - cur.execute("SELECT detail_id, exp_id, job_name, created, modified, submit_time, start_time, finish_time, status FROM job_times WHERE exp_id=?", (exp_id,)) - rows = cur.fetchall() - conn.close() - return rows - except Exception as ex: - print((traceback.format_exc())) - print((str(exp_id))) - print((str(ex))) - return None - - -def _get_latest_completed_jobs(seconds=300): - """ - get latest completed jobs, defaults at 300 seconds, 5 minutes - """ - current_ts = time.time() - try: - conn = create_connection(os.path.join(APIBasicConfig.DB_DIR, APIBasicConfig.AS_TIMES_DB)) - cur = conn.cursor() - cur.execute("SELECT detail_id, exp_id, job_name, created, modified, submit_time, start_time, finish_time, status FROM job_times WHERE (? - modified)<=? AND status='COMPLETED'", (current_ts, seconds)) - rows = cur.fetchall() - conn.close() - return rows - except Exception as ex: - print((traceback.format_exc())) - print((str(seconds))) - print((str(ex))) - return None - - -def _delete_many_from_job_times_detail(detail_list): - try: - conn = create_connection(os.path.join(APIBasicConfig.DB_DIR, APIBasicConfig.AS_TIMES_DB)) - cur = conn.cursor() - #print("Cursor defined") - cur.executemany( - "DELETE FROM job_times WHERE detail_id=?", detail_list) - # print(cur.rowcount) - cur.close() - conn.commit() - conn.close() - # No reliable way to get any feedback from cursor at this point, so let's just return 1 - return True - except Exception as exp: - # print("Error while trying to delete " + - # str(detail_id) + " from job_times.") - print((traceback.format_exc())) - return None - - -def delete_experiment_data(exp_id): - # type: (int) -> bool - """ - Deletes experiment data from experiment_times and job_times in as_times.db - - :param exp_id: Id of experiment - :type exp_id: int - """ - deleted_e = _delete_from_experiment_times(exp_id) - deleted_j = _delete_from_job_times(exp_id) - print(("Main exp " + str(deleted_e) + "\t" + str(deleted_j))) - if deleted_e and deleted_j: - return True - return False - - -def _delete_from_experiment_times(exp_id): - """ - Deletes an experiment from experiment_times in as_times.db - - :param exp_id: Id of experiment - :type exp_id: int - """ - try: - conn = create_connection(os.path.join(APIBasicConfig.DB_DIR, APIBasicConfig.AS_TIMES_DB)) - cur = conn.cursor() - cur.execute("DELETE FROM experiment_times WHERE exp_id=?", (exp_id,)) - conn.commit() - conn.close() - return True - except Exception as exp: - print(("Error while trying to delete " + - str(exp_id) + " from experiment_times.")) - print((traceback.format_exc())) - return None - - -def _delete_from_job_times(exp_id): - """ - Deletes an experiment from job_times in as_times.db - - :param exp_id: Id of experiment - :type exp_id: int - """ - try: - conn = create_connection(os.path.join(APIBasicConfig.DB_DIR, APIBasicConfig.AS_TIMES_DB)) - cur = conn.cursor() - cur.execute("DELETE FROM job_times WHERE exp_id=?", (exp_id,)) - conn.commit() - conn.close() - return True - except Exception as exp: - print(("Error while trying to delete " + - str(exp_id) + " from job_times.")) - print((traceback.format_exc())) - return None - - def _get_exp_status(): """ Get all registers from experiment_status.\n @@ -659,167 +136,4 @@ def _get_specific_exp_status(expid): return (0, expid, "NOT RUNNING", 0) -def _get_id_db(conn, expid): - """ - Get exp_id of the experiment (different than the experiment name). - :param conn: ecearth.db connection - :type conn: sqlite3 connection - :param expid: Experiment name - :type expid: String - :return: Id of the experiment - :rtype: Integer or None - """ - try: - cur = conn.cursor() - cur.execute("SELECT id FROM experiment WHERE name=?", (expid,)) - row = cur.fetchone() - return row[0] - except Exception as exp: - print(("Couldn't get exp_id for {0}".format(expid))) - print(traceback.format_exc()) - return None - - # UPDATES - - -def update_exp_status(expid, status, seconds_diff): - """ - Update existing experiment_status. - :param expid: Experiment name - :type expid: String - :param status: Experiment status - :type status: String - :param seconds_diff: Indicator of how long it has been active since the last time it was checked - :type seconds_diff: Integer - :return: Id of register - :rtype: Integer - """ - try: - conn = create_connection(os.path.join(APIBasicConfig.DB_DIR, APIBasicConfig.AS_TIMES_DB)) - sql = ''' UPDATE experiment_status SET status = ?, seconds_diff = ?, modified = ? WHERE name = ? ''' - cur = conn.cursor() - cur.execute(sql, (status, seconds_diff, - datetime.today().strftime('%Y-%m-%d-%H:%M:%S'), expid)) - conn.commit() - return cur.lastrowid - except sqlite3.Error as e: - print(("Error while trying to update " + - str(expid) + " in experiment_status.")) - print((traceback.format_exc())) - print(("Error on Update: " + str(type(e).__name__))) - - -def _update_ecearth_details(exp_id, user, created, model, branch, hpc): - """ - Updates ecearth.db table details. - """ - conn = create_connection(APIBasicConfig.DB_PATH) - if conn: - try: - sql = ''' UPDATE details SET user=?, created=?, model=?, branch=?, hpc=? where exp_id=? ''' - cur = conn.cursor() - cur.execute(sql, (user, created, model, branch, hpc, exp_id)) - conn.commit() - return True - except Exception as exp: - print(exp) - return False - return False - - -def update_experiment_times(exp_id, modified, completed_jobs, total_jobs, debug=False): - """ - Update existing experiment times header - """ - try: - conn = create_connection(os.path.join(APIBasicConfig.DB_DIR, APIBasicConfig.AS_TIMES_DB)) - if (debug == True): - print(("UPDATE experiment_times " + str(exp_id) + "\t" + - str(completed_jobs) + "\t" + str(total_jobs))) - return - sql = ''' UPDATE experiment_times SET modified = ?, completed_jobs = ?, total_jobs = ? WHERE exp_id = ? ''' - cur = conn.cursor() - cur.execute(sql, (int(modified), completed_jobs, total_jobs, exp_id)) - conn.commit() - # print("Updated header") - return exp_id - except sqlite3.Error as e: - print(("Error while trying to update " + - str(exp_id) + " in experiment_times.")) - print((traceback.format_exc())) - print(("Error on Update: " + str(type(e).__name__))) - - -def update_experiment_times_only_modified(exp_id, modified): - try: - conn = create_connection(os.path.join(APIBasicConfig.DB_DIR, APIBasicConfig.AS_TIMES_DB)) - sql = ''' UPDATE experiment_times SET modified = ? WHERE exp_id = ? ''' - cur = conn.cursor() - cur.execute(sql, (int(modified), exp_id)) - conn.commit() - # print("Updated header") - return exp_id - except sqlite3.Error as e: - print(("Error while trying to update " + - str(exp_id) + " in experiment_times.")) - print((traceback.format_exc())) - print(("Error on Update: " + str(type(e).__name__))) - - -def update_job_times(detail_id, modified, submit_time, start_time, finish_time, status, debug=False, no_modify_time=False): - """ - Update single experiment job detail \n - :param conn: Connection to as_times.db. \n - :type conn: sqlite3 connection. \n - :param detail_id: detail_id in as_times.job_times. \n - :type detail_id: Integer. \n - :param modified: Timestamp of current date of pkl \n - :type modified: Integer. \n - """ - try: - conn = create_connection(os.path.join(APIBasicConfig.DB_DIR, APIBasicConfig.AS_TIMES_DB)) - if (debug == True): - print(("UPDATING JOB TIMES " + str(detail_id) + " ~ " + str(modified) + "\t" + - str(submit_time) + "\t" + str(start_time) + "\t" + str(finish_time) + "\t" + str(status))) - return - if no_modify_time == False: - sql = ''' UPDATE job_times SET modified = ?, submit_time = ?, start_time = ?, finish_time = ?, status = ? WHERE detail_id = ? ''' - cur = conn.cursor() - cur.execute(sql, (modified, submit_time, start_time, - finish_time, status, detail_id)) - # Commit outside the loop - conn.commit() - cur.close() - conn.close() - else: - sql = ''' UPDATE job_times SET submit_time = ?, start_time = ?, finish_time = ?, status = ? WHERE detail_id = ? ''' - cur = conn.cursor() - cur.execute(sql, (submit_time, start_time, - finish_time, status, detail_id)) - # Commit outside the loop - conn.commit() - cur.close() - conn.close() - - except sqlite3.Error as e: - print(("Error while trying to update " + - str(detail_id) + " in job_times.")) - print((traceback.format_exc())) - print(("Error on Update: " + str(type(e).__name__))) - - -def update_many_job_times(list_jobs): - try: - conn = create_connection(os.path.join(APIBasicConfig.DB_DIR, APIBasicConfig.AS_TIMES_DB)) - sql = ''' UPDATE job_times SET modified = ?, submit_time = ?, start_time = ?, finish_time = ?, status = ? WHERE detail_id = ? ''' - cur = conn.cursor() - cur.executemany(sql, list_jobs) - # Commit outside the loop - conn.commit() - cur.close() - conn.close() - except sqlite3.Error as e: - print("Error while trying to update many in update_many_job_times.") - print((traceback.format_exc())) - print(("Error on Update: " + str(type(e).__name__))) diff --git a/autosubmit_api/workers/business/populate_times.py b/autosubmit_api/workers/business/populate_times.py deleted file mode 100644 index 89bbc79..0000000 --- a/autosubmit_api/workers/business/populate_times.py +++ /dev/null @@ -1,365 +0,0 @@ -import datetime -import os -import pwd -import time -import subprocess -import traceback -import socket -import pickle -from autosubmit_api.builders.configuration_facade_builder import AutosubmitConfigurationFacadeBuilder, ConfigurationFacadeDirector -from autosubmit_api.components.experiment.pkl_organizer import PklOrganizer -from autosubmit_api.components.jobs.utils import get_job_total_stats -from autosubmit_api.config.config_common import AutosubmitConfigResolver -from autosubmit_api.experiment import common_db_requests as DbRequests -from autosubmit_api.config.basicConfig import APIBasicConfig -from autosubmit_api.common.utils import Status -from bscearth.utils.config_parser import ConfigParserFactory - - -SAFE_TIME_LIMIT = 300 - - -def process_completed_times(time_condition=60): - """ - Tests for completed jobs of all autosubmit experiments and updates their completion times data in job_times and experiment_times. - :param time_condition: Time difference in seconds that qualifies a experiment as out of date. - :type time_condition: Integer - """ - try: - t0 = time.time() - DEBUG = False - APIBasicConfig.read() - path = APIBasicConfig.LOCAL_ROOT_DIR - # Time test for data retrieval - # All experiment from file system - currentDirectories = subprocess.Popen(['ls', '-t', path], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT) if (os.path.exists(path)) else None - stdOut, _ = currentDirectories.communicate() if currentDirectories else (None, None) - # Building connection to ecearth - - current_table = dict() # REMOVED: DbRequests.prepare_completed_times_db() - # Build list of all folder in /esarchive/autosubmit which should be considered as experiments (although some might not be) - # Pre process - _preprocess_completed_times() - experiments = stdOut.split() if stdOut else [] - counter = 0 - - # Get current `details` from ecearth.db and convert to set for effcient contain test - details_table_ids_set = set(DbRequests.get_exps_detailed_complete().keys()) - # Get current `experiments` from ecearth.db - experiments_table = DbRequests.get_exps_base() - - for expid in experiments: - # Experiment names should be 4 char long - if (len(expid) != 4): - counter += 1 - continue - # Experiment names must correspond to an experiment that contains a .pkl file - exp_str = expid.decode("UTF-8") - full_path = os.path.join(path, exp_str, "pkl", "job_list_" + exp_str + ".pkl") - timest = 0 - if os.path.exists(full_path): - # get time of last modification - timest = int(os.stat(full_path).st_mtime) - else: - counter += 1 - continue - counter += 1 - experiments_table_exp_id = experiments_table.get(exp_str, None) - if current_table.get(exp_str, None) is None: - # Pkl exists but is not registered in the table - # INSERT - # print("Pkl of " + exp_str + " exists but not in the table: INSERT") - current_id = _process_pkl_insert_times(exp_str, full_path, timest, APIBasicConfig, DEBUG) - _process_details_insert_or_update(exp_str, experiments_table_exp_id, experiments_table_exp_id in details_table_ids_set) - else: - exp_id, created, modified, total_jobs, completed_jobs = current_table[exp_str] - time_diff = int(timest - modified) - # print("Pkl of " + exp_str + " exists") - current_id = _process_pkl_insert_times(exp_str, full_path, timest, APIBasicConfig, DEBUG) - if time_diff > time_condition: - # Update table - _process_pkl_update_times(exp_str, full_path, timest, APIBasicConfig, exp_id, DEBUG) - _process_details_insert_or_update(exp_str, experiments_table_exp_id, experiments_table_exp_id in details_table_ids_set) - DbRequests.update_experiment_times_only_modified(exp_id, timest) - t1 = time.time() - # Timer safeguard - if (t1 - t0) > SAFE_TIME_LIMIT: - raise Exception( - "Time limit reached {0:06.2f} seconds on process_completed_times while processing {1}. Time spent on reading data {2:06.2f} seconds.".format((t1 - t0), exp_str, (t1 - t0))) - except Exception as ex: - print((traceback.format_exc())) - print(str(ex)) - - -def _describe_experiment(experiment_id): - user = "" - created = "" - model = "" - branch = "" - hpc = "" - APIBasicConfig.read() - exp_path = os.path.join(APIBasicConfig.LOCAL_ROOT_DIR, experiment_id) - if not os.path.exists(exp_path): - return user, created, model, branch, hpc - - user = os.stat(exp_path).st_uid - try: - user = pwd.getpwuid(user).pw_name - except: - pass - - created = datetime.datetime.fromtimestamp( - os.path.getmtime(exp_path)) - - try: - as_conf = AutosubmitConfigResolver( - experiment_id, APIBasicConfig, ConfigParserFactory()) - as_conf.reload() - - project_type = as_conf.get_project_type() - if project_type != "none": - if not as_conf.check_proj(): - return False - if (as_conf.get_svn_project_url()): - model = as_conf.get_svn_project_url() - branch = as_conf.get_svn_project_url() - else: - model = as_conf.get_git_project_origin() - branch = as_conf.get_git_project_branch() - if model is "": - model = "Not Found" - if branch is "": - branch = "Not Found" - hpc = as_conf.get_platform() - except: - pass - return user, created, model, branch, hpc - - -def _process_details_insert_or_update(expid, exp_id, current_details): - """ - Decides whether the experiment should be inserted or updated in the details table. - :param expid: name of experiment e.g: a001 - :type expid: str - :param exp_id: id of experiment e.g: 1 - :type exp_id: int - :param current_details: True if it exp_id exists in details table, False otherwise - :rtype: bool - :result: True if successful, False otherwise - :rtype: bool - """ - result = False - if exp_id: - try: - user, created, model, branch, hpc = _describe_experiment(expid) - if current_details: - # Update - result = DbRequests._update_ecearth_details(exp_id, user, created, model, branch, hpc) - else: - # Insert - _Id = DbRequests._insert_into_ecearth_details(exp_id, user, created, model, branch, hpc) - result = True if _Id else False - except Exception as exp: - print(exp) - return result - - -def _preprocess_completed_times(): - """ - Preprocess table to get rid of possible conflicts - :param current_table: table experiment_times from as_times.db - """ - current_table = DbRequests.get_experiment_times_group() - for name, _ids in list(current_table.items()): - if len(_ids) > 1: - print((str(name) + " has more than 1 register.")) - for i in range(0, len(_ids) - 1): - _id = _ids[i] - deleted_outdated = DbRequests.delete_experiment_data(_id) - -def _process_pkl_update_times(expid, path_pkl, timest_pkl, BasicConfig, exp_id, debug=False): - """ - Updates register in job_times and experiment_times for the given experiment. - :param expid: Experiment name - :type expid: String - :param path_pkl: path to the pkl file (DEPRECATED) - :type path_pkl: String - :param timest_pkl: Timestamp of the last modified date of the pkl file - :type timest_pkl: Integer - :param BasicConfig: Configuration of AS - :type BasicConfig: Object - :param exp_id: Id of experiment - :type exp_id: Integer - :param debug: Flag (testing purposes) - :type debug: Boolean - :return: Nothing - """ - # debug = True - try: - found_in_pkl = list() - BasicConfig.read() - - tmp_path = os.path.join(BasicConfig.LOCAL_ROOT_DIR, expid, BasicConfig.LOCAL_TMP_DIR) - job_times_db = dict() - total_jobs = 0 - completed_jobs = 0 - fd = None - # t_start = time.time() - # Get current detail from database - experiment_times_detail = DbRequests.get_times_detail(exp_id) - # t_seconds = time.time() - t_start - must_update_header = False - - to_update = [] - to_create = [] - - # Read PKL - autosubmit_config_facade = ConfigurationFacadeDirector( - AutosubmitConfigurationFacadeBuilder(expid)).build_autosubmit_configuration_facade() - pkl_organizer = PklOrganizer(autosubmit_config_facade) - for job_item in pkl_organizer.current_content: - total_jobs += 1 - status_code = job_item.status - job_name = job_item.name - found_in_pkl.append(job_name) - status_text = str(Status.VALUE_TO_KEY[status_code]) - if (status_code == Status.COMPLETED): - completed_jobs += 1 - if (experiment_times_detail) and job_name in list(experiment_times_detail.keys()): - # If job in pkl exists in database, retrieve data from database - submit_time, start_time, finish_time, status_text_in_table, detail_id = experiment_times_detail[job_name] - if (status_text_in_table != status_text): - # If status has changed - submit_time, start_time, finish_time, _ = get_job_total_stats(status_code, job_name, tmp_path) - submit_ts = int(time.mktime(submit_time.timetuple())) if len(str(submit_time)) > 0 else 0 - start_ts = int(time.mktime(start_time.timetuple())) if len(str(start_time)) > 0 else 0 - finish_ts = int(time.mktime(finish_time.timetuple())) if len(str(finish_time)) > 0 else 0 - # UPDATE - must_update_header = True - to_update.append((int(timest_pkl), - submit_ts, - start_ts, - finish_ts, - status_text, - detail_id)) - - else: - # Insert only if it is not WAITING nor READY - if (status_code not in [Status.WAITING, Status.READY]): - submit_time, start_time, finish_time, status_text = get_job_total_stats(status_code, job_name, tmp_path) - must_update_header = True - to_create.append((exp_id, - job_name, - int(timest_pkl), - int(timest_pkl), - int(time.mktime(submit_time.timetuple())) if len(str(submit_time)) > 0 else 0, - int(time.mktime(start_time.timetuple())) if len(str(start_time)) > 0 else 0, - int(time.mktime(finish_time.timetuple())) if len(str(finish_time)) > 0 else 0, - status_text)) - - - # Update Many - if len(to_update) > 0: - DbRequests.update_many_job_times(to_update) - # Create Many - if len(to_create) > 0: - DbRequests.create_many_job_times(to_create) - - if must_update_header == True: - exp_id = DbRequests.update_experiment_times(exp_id, int(timest_pkl), completed_jobs, total_jobs, debug) - - # Reviewing for deletes: - if len(found_in_pkl) > 0 and (experiment_times_detail): - detail_list = [] - for key in experiment_times_detail: - if key not in found_in_pkl: - # Delete Row - submit_time, start_time, finish_time, status_text_in_table, detail_id = experiment_times_detail[key] - detail_list.append((detail_id,)) - if len(detail_list) > 0: - DbRequests._delete_many_from_job_times_detail(detail_list) - - except (socket.error, EOFError): - # print(str(expid) + "\t EOF Error") - pass - except Exception as ex: - print(expid) - print((traceback.format_exc())) - - -def _process_pkl_insert_times(expid, path_pkl, timest_pkl, BasicConfig, debug=False): - """ - Process Pkl contents and insert information into database if status of jobs is not WAITING (to save space). - :param conn: Connection to database - :type conn: Sqlite3 connection object - :param expid: Experiment name - :type expid: String - :param path_pkl: Path to the pkl file - :type path_pkl: String - :param timest_pkl: Timestamp of the pkl modified date - :type timest_pkl: Integer - :param BasicConfig: Configuration data of AS - :type BasicConfig: Object - :param debug: Flag (proper name should be test) - :type debug: Boolean - """ - # BasicConfig.read() - # path = BasicConfig.LOCAL_ROOT_DIR - # db_file = os.path.join(path, DbRequests.DB_FILE_AS_TIMES) - # Build tmp path to search for TOTAL_STATS files - tmp_path = os.path.join(BasicConfig.LOCAL_ROOT_DIR, expid, BasicConfig.LOCAL_TMP_DIR) - job_times = dict() # Key: job_name - total_jobs = 0 - completed_jobs = 0 - status_code = Status.UNKNOWN - status_text = str(Status.VALUE_TO_KEY[status_code]) - try: - # Read PKL - autosubmit_config_facade = ConfigurationFacadeDirector( - AutosubmitConfigurationFacadeBuilder(expid)).build_autosubmit_configuration_facade() - pkl_organizer = PklOrganizer(autosubmit_config_facade) - for job_item in pkl_organizer.current_content: - total_jobs += 1 - status_code = job_item.status - job_name = job_item.name - status_text = str(Status.VALUE_TO_KEY[status_code]) - if (status_code == Status.COMPLETED): - completed_jobs += 1 - job_times[job_name] = status_code - except: - pass - - try: - # Insert header - current_id = DbRequests.insert_experiment_times_header(expid, int(timest_pkl), total_jobs, completed_jobs, debug) - if(current_id > 0): - # Insert detail - to_insert_many = [] - for job_name in job_times: - # Inserting detail. Do not insert WAITING or READY jobs. - status_code = job_times[job_name] - if (status_code not in [Status.WAITING, Status.READY]): - submit_time, start_time, finish_time, status_text = get_job_total_stats(status_code, job_name, tmp_path) - to_insert_many.append((current_id, - job_name, - int(timest_pkl), - int(timest_pkl), - int(time.mktime(submit_time.timetuple())) if len( - str(submit_time)) > 0 else 0, - int(time.mktime(start_time.timetuple())) if len( - str(start_time)) > 0 else 0, - int(time.mktime(finish_time.timetuple())) if len( - str(finish_time)) > 0 else 0, - status_text)) - if len(to_insert_many) > 0: - DbRequests.create_many_job_times(to_insert_many) - else: - pass - - return current_id - except Exception as ex: - print(expid) - print((traceback.format_exc())) - return 0 diff --git a/autosubmit_api/workers/populate_queue_run_times.py b/autosubmit_api/workers/populate_queue_run_times.py deleted file mode 100644 index 0236886..0000000 --- a/autosubmit_api/workers/populate_queue_run_times.py +++ /dev/null @@ -1,10 +0,0 @@ -from autosubmit_api.bgtasks.bgtask import PopulateQueueRuntimes - - -def main(): - """ Process and updates queuing and running times. """ - PopulateQueueRuntimes.run() - - -if __name__ == "__main__": - main() diff --git a/autosubmit_api/workers/verify_complete.py b/autosubmit_api/workers/verify_complete.py deleted file mode 100644 index 490b9d4..0000000 --- a/autosubmit_api/workers/verify_complete.py +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env python -from autosubmit_api.bgtasks.tasks.job_times_updater import JobTimesUpdater - - -def main(): - JobTimesUpdater.run() - - -if __name__ == "__main__": - main() diff --git a/tests/experiments/as_times.db b/tests/experiments/as_times.db index 3f9a4743b4dd8df93226cb03fa54a25b3ca4572f..c70732eb783e06bcef253b4f0e7ac92c0a225396 100644 GIT binary patch delta 58 scmZp0XmFSy%~&>3#+k8fV}g@Bm#MCyk(H^5m8s$6HS!9`+-oRY0M^qE*Z=?k delta 58 rcmZp0XmFSy%~&u|#+k8TV}g@Bmx->SnU$#p5KUequYk Date: Thu, 15 Feb 2024 15:53:31 +0100 Subject: [PATCH 12/18] remove history module dead code --- autosubmit_api/components/jobs/test.py | 36 -- autosubmit_api/config/files/autosubmit.conf | 42 -- autosubmit_api/config/files/expdef.conf | 77 ---- autosubmit_api/config/files/jobs.conf | 96 ----- autosubmit_api/config/files/platforms.conf | 55 --- autosubmit_api/database/data/autosubmit.sql | 8 - .../experiment/experiment_db_manager.py | 38 -- autosubmit_api/history/experiment_history.py | 305 +-------------- .../history/platform_monitor/__init__.py | 0 .../output_examples/pending.txt | 1 - .../output_examples/wrapper1.txt | 3 - .../output_examples/wrapper2.txt | 3 - .../output_examples/wrapper_big.txt | 33 -- .../platform_monitor/platform_monitor.py | 30 -- .../platform_monitor/platform_utils.py | 70 ---- .../history/platform_monitor/slurm_monitor.py | 65 ---- .../platform_monitor/slurm_monitor_item.py | 98 ----- .../history/platform_monitor/test.py | 94 ----- autosubmit_api/history/test.py | 367 ------------------ autosubmit_api/history/test_job_history.py | 71 ---- autosubmit_api/history/test_strategies.py | 94 ----- autosubmit_api/history/test_utils.py | 32 -- .../workers/populate_details/test.py | 53 --- tests/experiments/as_times.db | Bin 8192 -> 8192 bytes 24 files changed, 7 insertions(+), 1664 deletions(-) delete mode 100644 autosubmit_api/components/jobs/test.py delete mode 100644 autosubmit_api/config/files/autosubmit.conf delete mode 100644 autosubmit_api/config/files/expdef.conf delete mode 100644 autosubmit_api/config/files/jobs.conf delete mode 100644 autosubmit_api/config/files/platforms.conf delete mode 100644 autosubmit_api/database/data/autosubmit.sql delete mode 100644 autosubmit_api/experiment/experiment_db_manager.py delete mode 100644 autosubmit_api/history/platform_monitor/__init__.py delete mode 100644 autosubmit_api/history/platform_monitor/output_examples/pending.txt delete mode 100644 autosubmit_api/history/platform_monitor/output_examples/wrapper1.txt delete mode 100644 autosubmit_api/history/platform_monitor/output_examples/wrapper2.txt delete mode 100644 autosubmit_api/history/platform_monitor/output_examples/wrapper_big.txt delete mode 100644 autosubmit_api/history/platform_monitor/platform_monitor.py delete mode 100644 autosubmit_api/history/platform_monitor/platform_utils.py delete mode 100644 autosubmit_api/history/platform_monitor/slurm_monitor.py delete mode 100644 autosubmit_api/history/platform_monitor/slurm_monitor_item.py delete mode 100644 autosubmit_api/history/platform_monitor/test.py delete mode 100644 autosubmit_api/history/test.py delete mode 100644 autosubmit_api/history/test_job_history.py delete mode 100644 autosubmit_api/history/test_strategies.py delete mode 100644 autosubmit_api/history/test_utils.py delete mode 100644 autosubmit_api/workers/populate_details/test.py diff --git a/autosubmit_api/components/jobs/test.py b/autosubmit_api/components/jobs/test.py deleted file mode 100644 index dc64a75..0000000 --- a/autosubmit_api/components/jobs/test.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python - -import unittest -from components.jobs.joblist_loader import JobListLoader - - -class TestJobListHelper(unittest.TestCase): - def setUp(self): - pass - - def get_joblist_helper(self): - pass - -class TestJobListLoader(unittest.TestCase): - def setUp(self): - pass - - def tearDown(self): - pass - - def test_load(self): - pass - # loader = JobListLoader("a29z") - # loader.load_jobs() - # self.assertTrue(len(loader.jobs) > 0) - # for job in loader.jobs: - # job.do_print() - - # def test_loader(self): - # tree = - - # def test_load_out_err_files(self): - - -if __name__ == '__main__': - unittest.main() diff --git a/autosubmit_api/config/files/autosubmit.conf b/autosubmit_api/config/files/autosubmit.conf deleted file mode 100644 index 90770b8..0000000 --- a/autosubmit_api/config/files/autosubmit.conf +++ /dev/null @@ -1,42 +0,0 @@ -[config] -# Experiment identifier -# No need to change -EXPID = -# No need to change. -# Autosubmit version identifier -AUTOSUBMIT_VERSION = -# Default maximum number of jobs to be waiting in any platform -# Default = 3 -MAXWAITINGJOBS = 3 -# Default maximum number of jobs to be running at the same time at any platform -# Default = 6 -TOTALJOBS = 6 -# Time (seconds) between connections to the HPC queue scheduler to poll already submitted jobs status -# Default = 10 -SAFETYSLEEPTIME = 10 -# Number of retrials if a job fails. Can ve override at job level -# Default = 0 -RETRIALS = 0 - -[mail] -# Enable mail notifications -# Default = False -NOTIFICATIONS = False -# Mail address where notifications will be received -TO = - -[communications] -# Communications library used to connect with platforms: paramiko or saga. -# Default = paramiko -API = paramiko - -[storage] -# Defines the way of storing the progress of the experiment. The available options are: -# A PICKLE file (pkl) or an SQLite database (db). Default = pkl -TYPE = pkl -# Defines if the remote logs will be copied to the local platform. Default = True. -COPY_REMOTE_LOGS = True - -[migrate] -# Changes experiment files owner. -TO_USER = diff --git a/autosubmit_api/config/files/expdef.conf b/autosubmit_api/config/files/expdef.conf deleted file mode 100644 index 96e2b6f..0000000 --- a/autosubmit_api/config/files/expdef.conf +++ /dev/null @@ -1,77 +0,0 @@ -[DEFAULT] -# Experiment identifier -# No need to change -EXPID = -# HPC name. -# No need to change -HPCARCH = - -[experiment] -# Supply the list of start dates. Available formats: YYYYMMDD YYYYMMDDhh YYYYMMDDhhmm -# You can also use an abbreviated syntax for multiple dates with common parts: 200001[01 15] <=> 20000101 20000115 -# 200001[01-04] <=> 20000101 20000102 20000103 20000104 -# DATELIST = 19600101 19650101 19700101 -# DATELIST = 1960[0101 0201 0301] -# DATELIST = 19[60-65] -DATELIST = -# Supply the list of members. Format fcX -# You can also use an abreviated syntax for multiple members: fc[0 1 2] <=> fc0 fc1 fc2 -# fc[0-2] <=> fc0 fc1 fc2 -# MEMBERS = fc0 fc1 fc2 fc3 fc4 -# MEMBERS = fc[0-4] -MEMBERS = -# Chunk size unit. STRING = hour, day, month, year -CHUNKSIZEUNIT = month -# Chunk size. NUMERIC = 4, 6, 12 -CHUNKSIZE = -# Total number of chunks in experiment. NUMERIC = 30, 15, 10 -NUMCHUNKS = -# Initial chunk of the experiment. Optional. DEFAULT = 1 -CHUNKINI = -# Calendar used. LIST: standard, noleap -CALENDAR = standard - -[project] -# Select project type. STRING = git, svn, local, none -# If PROJECT_TYPE is set to none, Autosubmit self-contained dummy templates will be used -PROJECT_TYPE = -# Destination folder name for project. type = STRING, default = leave empty, -PROJECT_DESTINATION = - -# If PROJECT_TYPE is not git, no need to change -[git] -# Repository URL STRING = 'https://github.com/torvalds/linux.git' -PROJECT_ORIGIN = -# Select branch or tag, STRING, default = 'master', help = {'master' (default), 'develop', 'v3.1b', ...} -PROJECT_BRANCH = -# type = STRING, default = leave empty, help = if model branch is a TAG leave empty -PROJECT_COMMIT = -# type = STRING, default = leave empty and will load all submodules, help = loadThisSubmodule alsoloadthis anotherLoad ... -PROJECT_SUBMODULES = -# If PROJECT_TYPE is not svn, no need to change -[svn] -# type = STRING, help = 'https://svn.ec-earth.org/ecearth3' -PROJECT_URL = -# Select revision number. NUMERIC = 1778 -PROJECT_REVISION = - -# If PROJECT_TYPE is not local, no need to change -[local] -# type = STRING, help = /foo/bar/ecearth -PROJECT_PATH = - -# If PROJECT_TYPE is none, no need to change -[project_files] -# Where is PROJECT CONFIGURATION file location relative to project root path -FILE_PROJECT_CONF = -# Where is JOBS CONFIGURATION file location relative to project root path -FILE_JOBS_CONF = -# Default job scripts type in the project. type = STRING, default = bash, supported = 'bash', 'python' or 'r' -JOB_SCRIPTS_TYPE = - -[rerun] -# Is a rerun or not? [Default: Do set FALSE]. BOOLEAN = TRUE, FALSE -RERUN = FALSE -# If RERUN = TRUE then supply the list of chunks to rerun -# LIST = [ 19601101 [ fc0 [1 2 3 4] fc1 [1] ] 19651101 [ fc0 [16-30] ] ] -CHUNKLIST = \ No newline at end of file diff --git a/autosubmit_api/config/files/jobs.conf b/autosubmit_api/config/files/jobs.conf deleted file mode 100644 index eaf192c..0000000 --- a/autosubmit_api/config/files/jobs.conf +++ /dev/null @@ -1,96 +0,0 @@ -# Example job with all options specified - -## Job name -# [JOBNAME] -## Script to execute. If not specified, job will be omitted from workflow. -## Path relative to the project directory -# FILE = -## Platform to execute the job. If not specified, defaults to HPCARCH in expdef file. -## LOCAL is always defined and represents the current machine -# PLATFORM = -## Queue to add the job to. If not specified, uses PLATFORM default. -# QUEUE = -## Defines dependencies from job as a list of parents jobs separated by spaces. -## Dependencies to jobs in previous chunk, member o startdate, use -(DISTANCE) -# DEPENDENCIES = INI SIM-1 CLEAN-2 -## Define if jobs runs once, once per stardate, once per member or once per chunk. Options: once, date, member, chunk. -## If not specified, defaults to once -# RUNNING = once -## Specifies that job has only to be run after X dates, members or chunk. A job will always be created for the last -## If not specified, defaults to 1 -# FREQUENCY = 3 -## Specifies if a job with FREQUENCY > 1 has only to wait for all the jobs in the previous chunks on its period or just -## for the ones in the chunk it is going to execute -## If not specified, defaults to True -# WAIT = False -## Defines if job is only to be executed in reruns. If not specified, defaults to false. -# RERUN_ONLY = False -## Defines jobs needed to be rerun if this job is going to be rerunned -# RERUN_DEPENDENCIES = RERUN INI LOCAL_SETUP REMOTE_SETUP TRANSFER -## Wallclock to be submitted to the HPC queue in format HH:MM. If not specified, defaults to empty. -# WALLCLOCK = 00:05 -## Processors number to be submitted to the HPC. If not specified, defaults to 1. -# PROCESSORS = 1 -## Threads number to be submitted to the HPC. If not specified, defaults to 1. -# THREADS = 1 -## Tasks number (number of processes per node) to be submitted to the HPC. If not specified, defaults to empty. -# TASKS = 16 -## Memory requirements for the job in MB. Optional. If not specified, then not defined for the scheduler. -# MEMORY = 4096 -## Memory per task requirements for the job in MB. Optional. If not specified, then not defined for the scheduler. -# MEMORY_PER_TASK = 1024 -## Scratch free space requirements for the job in percentage (%). If not specified, it won't be defined on the template. -# SCRATCH_FREE_SPACE = 10 -## Number of retrials if a job fails. If not specified, defaults to the value given on experiment's autosubmit.conf -# RETRIALS = 4 -## Some jobs can not be checked before running previous jobs. Set this option to false if that is the case -# CHECK = False -## Select the interpreter that will run the job. Options: bash, python, r Default: bash -# TYPE = bash -## Synchronize a chunk job with its dependency chunks at a 'date' or 'member' level -# SYNCHRONIZE = date | member -## Optional. Custom directives for the resource manager of the platform used for that job. -## Put as many as you wish in json formatted array. -# CUSTOM_DIRECTIVE = ["#PBS -v myvar=value, "#PBS -v othervar=value"] - -[LOCAL_SETUP] -FILE = LOCAL_SETUP.sh -PLATFORM = LOCAL - -[REMOTE_SETUP] -FILE = REMOTE_SETUP.sh -DEPENDENCIES = LOCAL_SETUP -WALLCLOCK = 00:05 - -[INI] -FILE = INI.sh -DEPENDENCIES = REMOTE_SETUP -RUNNING = member -WALLCLOCK = 00:05 - -[SIM] -FILE = SIM.sh -DEPENDENCIES = INI SIM-1 CLEAN-2 -RUNNING = chunk -WALLCLOCK = 00:05 -PROCESSORS = 2 -THREADS = 1 -TASKS = 1 - -[POST] -FILE = POST.sh -DEPENDENCIES = SIM -RUNNING = chunk -WALLCLOCK = 00:05 - -[CLEAN] -FILE = CLEAN.sh -DEPENDENCIES = POST -RUNNING = chunk -WALLCLOCK = 00:05 - -[TRANSFER] -FILE = TRANSFER.sh -PLATFORM = LOCAL -DEPENDENCIES = CLEAN -RUNNING = member diff --git a/autosubmit_api/config/files/platforms.conf b/autosubmit_api/config/files/platforms.conf deleted file mode 100644 index a06f59d..0000000 --- a/autosubmit_api/config/files/platforms.conf +++ /dev/null @@ -1,55 +0,0 @@ -# Example platform with all options specified - -## Platform name -# [PLATFORM] -## Queue type. Options: PBS, SGE, PS, LSF, ecaccess, SLURM. Required -# TYPE = -## Version of queue manager to use. Needed only in PBS (options: 10, 11, 12) and ecaccess (options: pbs, loadleveler) -# VERSION = -## Hostname of the HPC. Required -# HOST = -## Project for the machine scheduler. Required -# PROJECT = -## Budget account for the machine scheduler. If omitted, takes the value defined in PROJECT -# BUDGET = -## Option to add project name to host. This is required for some HPCs. -# ADD_PROJECT_TO_HOST = False -## User for the machine scheduler. Required -# USER = -## Optional. If given, Autosubmit will change owner of files in given platform when using migrate_exp. -# USER_TO = -## Path to the scratch directory for the machine. Required. -# SCRATCH_DIR = /scratch -## Path to the machine's temporary directory for migrate purposes. -# TEMP_DIR = /tmp -## If true, Autosubmit test command can use this queue as a main queue. Defaults to False -# TEST_SUITE = False -## If given, Autosubmit will add jobs to the given queue. Required for some platforms. -# QUEUE = -## Optional. If given, Autosubmit will submit the serial jobs with the exclusivity directive. -# EXCLUSIVITY = -## Optional. If specified, autosubmit will run jobs with only one processor in the specified platform. -# SERIAL_PLATFORM = SERIAL_PLATFORM_NAME -## Optional. If specified, autosubmit will run jobs with only one processor in the specified queue. -## Autosubmit will ignore this configuration if SERIAL_PLATFORM is provided -# SERIAL_QUEUE = SERIAL_QUEUE_NAME -## Optional. Default number of processors per node to be used in jobs -# PROCESSORS_PER_NODE = -## Optional. Integer. Scratch free space requirements for the platform in percentage (%). -## If not specified, it won't be defined on the template. -# SCRATCH_FREE_SPACE = -## Optional. Integer. Default Maximum number of jobs to be waiting in any platform queue -## Default = 3 -# MAX_WAITING_JOBS = -## Optional. Integer. Default maximum number of jobs to be running at the same time at any platform -## Default = 6 -# TOTAL_JOBS = -## Max wallclock per job submitted to the HPC queue in format HH:MM. If not specified, defaults to empty. -## Optional. Required for wrappers. -# MAX_WALLCLOCK = 72:00 -## Max processors number per job submitted to the HPC. If not specified, defaults to empty. -## Optional. Required for wrappers. -# MAX_PROCESSORS = 1 -## Optional. Custom directives for the resource manager of the platform used. -## Put as many as you wish in a json formatted array. -# CUSTOM_DIRECTIVE = ["#PBS -v myvar=value, "#PBS -v othervar=value"] diff --git a/autosubmit_api/database/data/autosubmit.sql b/autosubmit_api/database/data/autosubmit.sql deleted file mode 100644 index f1a1332..0000000 --- a/autosubmit_api/database/data/autosubmit.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE experiment( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - name VARCHAR NOT NULL, - description VARCHAR NOT NULL, - autosubmit_version VARCHAR); -CREATE TABLE db_version( - version INTEGER NOT NULL); -INSERT INTO db_version (version) VALUES (1); \ No newline at end of file diff --git a/autosubmit_api/experiment/experiment_db_manager.py b/autosubmit_api/experiment/experiment_db_manager.py deleted file mode 100644 index f07f39d..0000000 --- a/autosubmit_api/experiment/experiment_db_manager.py +++ /dev/null @@ -1,38 +0,0 @@ -# References BasicConfig.DB_PATH - -import history.database_managers.database_models as Models -from history.database_managers.database_manager import DatabaseManager -from config.basicConfig import APIBasicConfig -from typing import List - -class ExperimentDbManager(DatabaseManager): - # The current implementation only handles the experiment table. The details table is ignored because it is handled by a different worker. - def __init__(self, basic_config, expid): - # type: (APIBasicConfig, str) -> None - super(ExperimentDbManager, self).__init__(expid, basic_config) - self.basic_config = basic_config - self._ecearth_file_path = self.basic_config.DB_PATH - - def get_experiment_row_by_expid(self, expid): - # type: (str) -> Models.ExperimentRow | None - """ - Get the experiment from ecearth.db by expid as Models.ExperimentRow. - """ - statement = self.get_built_select_statement("experiment", "name=?") - current_rows = self.get_from_statement_with_arguments(self._ecearth_file_path, statement, (expid,)) - if len(current_rows) <= 0: - return None - # raise ValueError("Experiment {0} not found in {1}".format(expid, self._ecearth_file_path)) - return Models.ExperimentRow(*current_rows[0]) - - def get_experiments_with_valid_version(self): - # type: () -> List[Models.ExperimentRow] - statement = self.get_built_select_statement("experiment", "autosubmit_version IS NOT NULL") - rows = self.get_from_statement(self._ecearth_file_path, statement) - return [Models.ExperimentRow(*row) for row in rows] - - # def insert_experiment_details(self, exp_id, user, created, model, branch, hpc): - - # statement = ''' INSERT INTO details(exp_id, user, created, model, branch, hpc) VALUES(?,?,?,?,?,?) ''' - # arguments = (exp_id, user, status, 0, HUtils.get_current_datetime()) - # return self.insert_statement_with_arguments(self._as_times_file_path, statement, arguments) \ No newline at end of file diff --git a/autosubmit_api/history/experiment_history.py b/autosubmit_api/history/experiment_history.py index f36521d..f9ae423 100644 --- a/autosubmit_api/history/experiment_history.py +++ b/autosubmit_api/history/experiment_history.py @@ -15,20 +15,14 @@ # You should have received a copy of the GNU General Public License # along with Autosubmit. If not, see . -import os import traceback -from .database_managers import database_models as Models -from ..history import utils as HUtils -from ..performance import utils as PUtils -from time import time, sleep -from ..history.database_managers.experiment_history_db_manager import ExperimentHistoryDbManager -from ..history.database_managers.database_manager import DEFAULT_JOBDATA_DIR, DEFAULT_HISTORICAL_LOGS_DIR -from ..history.strategies import PlatformInformationHandler, SingleAssociationStrategy, StraightWrapperAssociationStrategy, TwoDimWrapperDistributionStrategy, GeneralizedWrapperDistributionStrategy -from ..history.data_classes.job_data import JobData -from ..history.data_classes.experiment_run import ExperimentRun -from ..history.platform_monitor.slurm_monitor import SlurmMonitor -from ..history.internal_logging import Logging -from ..config.basicConfig import APIBasicConfig +from autosubmit_api.history.database_managers import database_models as Models +from autosubmit_api.performance import utils as PUtils +from autosubmit_api.history.database_managers.experiment_history_db_manager import ExperimentHistoryDbManager +from autosubmit_api.history.data_classes.job_data import JobData +from autosubmit_api.history.data_classes.experiment_run import ExperimentRun +from autosubmit_api.history.internal_logging import Logging +from autosubmit_api.config.basicConfig import APIBasicConfig from typing import List, Dict, Tuple, Any SECONDS_WAIT_PLATFORM = 60 @@ -47,133 +41,11 @@ class ExperimentHistory(): self._log.log(str(exp), traceback.format_exc()) self.manager = None - def initialize_database(self): - try: - self.manager.initialize() - except Exception as exp: - self._log.log(str(exp), traceback.format_exc()) - self.manager = None - def is_header_ready(self): if self.manager: return self.manager.is_header_ready_db_version() return False - - def write_submit_time(self, job_name, submit=0, status="UNKNOWN", ncpus=0, wallclock="00:00", qos="debug", date="", - member="", section="", chunk=0, platform="NA", job_id=0, wrapper_queue=None, wrapper_code=None, children=""): - try: - next_counter = self._get_next_counter_by_job_name(job_name) - current_experiment_run = self.manager.get_experiment_run_dc_with_max_id() - job_data_dc = JobData(_id=0, - counter=next_counter, - job_name=job_name, - submit=submit, - status=status, - rowtype=self._get_defined_rowtype(wrapper_code), - ncpus=ncpus, - wallclock=wallclock, - qos=self._get_defined_queue_name(wrapper_queue, wrapper_code, qos), - date=date, - member=member, - section=section, - chunk=chunk, - platform=platform, - job_id=job_id, - children=children, - run_id=current_experiment_run.run_id) - return self.manager.register_submitted_job_data_dc(job_data_dc) - except Exception as exp: - self._log.log(str(exp), traceback.format_exc()) - return None - - def write_start_time(self, job_name, start=0, status="UNKWOWN", ncpus=0, wallclock="00:00", qos="debug", date="", - member="", section="", chunk=0, platform="NA", job_id=0, wrapper_queue=None, wrapper_code=None, children=""): - try: - job_data_dc_last = self.manager.get_job_data_dc_unique_latest_by_job_name(job_name) - if not job_data_dc_last: - job_data_dc_last = self.write_submit_time(job_name=job_name, - status=status, - ncpus=ncpus, - wallclock=wallclock, - qos=qos, - date=date, - member=member, - section=section, - chunk=chunk, - platform=platform, - job_id=job_id, - wrapper_queue=wrapper_queue, - wrapper_code=wrapper_code) - self._log.log("write_start_time {0} start not found.".format(job_name)) - job_data_dc_last.start = start - job_data_dc_last.qos = self._get_defined_queue_name(wrapper_queue, wrapper_code, qos) - job_data_dc_last.status = status - job_data_dc_last.rowtype = self._get_defined_rowtype(wrapper_code) - job_data_dc_last.job_id = job_id - job_data_dc_last.children = children - return self.manager.update_job_data_dc_by_id(job_data_dc_last) - except Exception as exp: - self._log.log(str(exp), traceback.format_exc()) - - def write_finish_time(self, job_name, finish=0, status="UNKNOWN", ncpus=0, wallclock="00:00", qos="debug", date="", - member="", section="", chunk=0, platform="NA", job_id=0, out_file=None, err_file=None, - wrapper_queue=None, wrapper_code=None, children=""): - try: - job_data_dc_last = self.manager.get_job_data_dc_unique_latest_by_job_name(job_name) - if not job_data_dc_last: - job_data_dc_last = self.write_submit_time(job_name=job_name, - status=status, - ncpus=ncpus, - wallclock=wallclock, - qos=qos, - date=date, - member=member, - section=section, - chunk=chunk, - platform=platform, - job_id=job_id, - wrapper_queue=wrapper_queue, - wrapper_code=wrapper_code, - children=children) - self._log.log("write_finish_time {0} submit not found.".format(job_name)) - job_data_dc_last.finish = finish if finish > 0 else int(time()) - job_data_dc_last.status = status - job_data_dc_last.job_id = job_id - job_data_dc_last.rowstatus = Models.RowStatus.PENDING_PROCESS - job_data_dc_last.out = out_file if out_file else "" - job_data_dc_last.err = err_file if err_file else "" - return self.manager.update_job_data_dc_by_id(job_data_dc_last) - except Exception as exp: - self._log.log(str(exp), traceback.format_exc()) - - def write_platform_data_after_finish(self, job_data_dc, platform_obj): - """ - Call it in a thread. - """ - try: - sleep(SECONDS_WAIT_PLATFORM) - ssh_output = platform_obj.check_job_energy(job_data_dc.job_id) - slurm_monitor = SlurmMonitor(ssh_output) - self._verify_slurm_monitor(slurm_monitor, job_data_dc) - job_data_dcs_in_wrapper = self.manager.get_job_data_dcs_last_by_wrapper_code(job_data_dc.wrapper_code) - job_data_dcs_to_update = [] - if len(job_data_dcs_in_wrapper) > 0: - info_handler = PlatformInformationHandler(StraightWrapperAssociationStrategy(self._historiclog_dir_path)) - job_data_dcs_to_update = info_handler.execute_distribution(job_data_dc, job_data_dcs_in_wrapper, slurm_monitor) - if len(job_data_dcs_to_update) == 0: - info_handler.strategy = TwoDimWrapperDistributionStrategy(self._historiclog_dir_path) - job_data_dcs_to_update = info_handler.execute_distribution(job_data_dc, job_data_dcs_in_wrapper, slurm_monitor) - if len(job_data_dcs_to_update) == 0: - info_handler.strategy = GeneralizedWrapperDistributionStrategy(self._historiclog_dir_path) - job_data_dcs_to_update = info_handler.execute_distribution(job_data_dc, job_data_dcs_in_wrapper, slurm_monitor) - else: - info_handler = PlatformInformationHandler(SingleAssociationStrategy(self._historiclog_dir_path)) - job_data_dcs_to_update = info_handler.execute_distribution(job_data_dc, job_data_dcs_in_wrapper, slurm_monitor) - return self.manager.update_list_job_data_dc_by_each_id(job_data_dcs_to_update) - except Exception as exp: - self._log.log(str(exp), traceback.format_exc()) - def get_historic_job_data(self, job_name): # type: (str) -> List[Dict[str, Any]] result = [] @@ -231,166 +103,3 @@ class ExperimentHistory(): "err": job_data_dc.err }) return result - - - def update_job_finish_time_if_zero(self, job_name, finish_ts): - # type: (str, int) -> JobData - try: - job_data_dc = self.manager.get_job_data_dc_unique_latest_by_job_name(job_name) - if job_data_dc and job_data_dc.finish == 0: - job_data_dc.finish = finish_ts - return self.manager.update_job_data_dc_by_id(job_data_dc) - except Exception as exp: - self._log.log(str(exp), traceback.format_exc()) - - def _verify_slurm_monitor(self, slurm_monitor, job_data_dc): - try: - if slurm_monitor.header.status not in ["COMPLETED", "FAILED"]: - self._log.log("Assertion Error on job {0} with ssh_output {1}".format(job_data_dc.job_name, slurm_monitor.original_input), - "Slurm status {0} is not COMPLETED nor FAILED for ID {1}.\n".format(slurm_monitor.header.status, slurm_monitor.header.name)) - if not slurm_monitor.steps_plus_extern_approximate_header_energy(): - self._log.log("Assertion Error on job {0} with ssh_output {1}".format(job_data_dc.job_name, slurm_monitor.original_input), - "Steps + extern != total energy for ID {0}. Number of steps {1}.\n".format(slurm_monitor.header.name, slurm_monitor.step_count)) - except Exception as exp: - self._log.log(str(exp), traceback.format_exc()) - - def process_status_changes(self, job_list=None, chunk_unit="NA", chunk_size=0, current_config=""): - """ Detect status differences between job_list and current job_data rows, and update. Creates a new run if necessary. """ - try: - current_experiment_run_dc = self.manager.get_experiment_run_dc_with_max_id() - update_these_changes = self._get_built_list_of_changes(job_list) - should_create_new_run = self.should_we_create_a_new_run(job_list, len(update_these_changes), current_experiment_run_dc, chunk_unit, chunk_size) - if len(update_these_changes) > 0 and should_create_new_run == False: - self.manager.update_many_job_data_change_status(update_these_changes) - if should_create_new_run: - return self.create_new_experiment_run(chunk_unit, chunk_size, current_config, job_list) - return self.update_counts_on_experiment_run_dc(current_experiment_run_dc, job_list) - except Exception as exp: - self._log.log(str(exp), traceback.format_exc()) - - def _get_built_list_of_changes(self, job_list): - """ Return: List of (current timestamp, current datetime str, status, rowstatus, id in job_data). One tuple per change. """ - job_data_dcs = self.detect_changes_in_job_list(job_list) - return [(HUtils.get_current_datetime(), job.status, Models.RowStatus.CHANGED, job._id) for job in job_data_dcs] - - def process_job_list_changes_to_experiment_totals(self, job_list=None): - """ Updates current experiment_run row with totals calculated from job_list. """ - try: - current_experiment_run_dc = self.manager.get_experiment_run_dc_with_max_id() - return self.update_counts_on_experiment_run_dc(current_experiment_run_dc, job_list) - except Exception as exp: - self._log.log(str(exp), traceback.format_exc()) - - def should_we_create_a_new_run(self, job_list, changes_count, current_experiment_run_dc, new_chunk_unit, new_chunk_size): - if len(job_list) != current_experiment_run_dc.total: - return True - if changes_count > int(self._get_date_member_completed_count(job_list)): - return True - return self._chunk_config_has_changed(current_experiment_run_dc, new_chunk_unit, new_chunk_size) - - def _chunk_config_has_changed(self, current_exp_run_dc, new_chunk_unit, new_chunk_size): - if not current_exp_run_dc: - return True - if current_exp_run_dc.chunk_unit != new_chunk_unit or current_exp_run_dc.chunk_size != new_chunk_size: - return True - return False - - def update_counts_on_experiment_run_dc(self, experiment_run_dc, job_list=None): - """ Return updated row as Models.ExperimentRun. """ - status_counts = self.get_status_counts_from_job_list(job_list) - experiment_run_dc.completed = status_counts[HUtils.SupportedStatus.COMPLETED] - experiment_run_dc.failed = status_counts[HUtils.SupportedStatus.FAILED] - experiment_run_dc.queuing = status_counts[HUtils.SupportedStatus.QUEUING] - experiment_run_dc.submitted = status_counts[HUtils.SupportedStatus.SUBMITTED] - experiment_run_dc.running = status_counts[HUtils.SupportedStatus.RUNNING] - experiment_run_dc.suspended = status_counts[HUtils.SupportedStatus.SUSPENDED] - experiment_run_dc.total = status_counts["TOTAL"] - return self.manager.update_experiment_run_dc_by_id(experiment_run_dc) - - def finish_current_experiment_run(self): - if self.manager.is_there_a_last_experiment_run(): - current_experiment_run_dc = self.manager.get_experiment_run_dc_with_max_id() - current_experiment_run_dc.finish = int(time()) - return self.manager.update_experiment_run_dc_by_id(current_experiment_run_dc) - return None - - def create_new_experiment_run(self, chunk_unit="NA", chunk_size=0, current_config="", job_list=None): - """ Also writes the finish timestamp of the previous run. """ - self.finish_current_experiment_run() - return self._create_new_experiment_run_dc_with_counts(chunk_unit=chunk_unit, chunk_size=chunk_size, current_config=current_config, job_list=job_list) - - def _create_new_experiment_run_dc_with_counts(self, chunk_unit, chunk_size, current_config="", job_list=None): - """ Create new experiment_run row and return the new Models.ExperimentRun data class from database. """ - status_counts = self.get_status_counts_from_job_list(job_list) - experiment_run_dc = ExperimentRun(0, - chunk_unit=chunk_unit, - chunk_size=chunk_size, - metadata=current_config, - start=int(time()), - completed=status_counts[HUtils.SupportedStatus.COMPLETED], - total=status_counts["TOTAL"], - failed=status_counts[HUtils.SupportedStatus.FAILED], - queuing=status_counts[HUtils.SupportedStatus.QUEUING], - running=status_counts[HUtils.SupportedStatus.RUNNING], - submitted=status_counts[HUtils.SupportedStatus.SUBMITTED], - suspended=status_counts[HUtils.SupportedStatus.SUSPENDED]) - return self.manager.register_experiment_run_dc(experiment_run_dc) - - def detect_changes_in_job_list(self, job_list): - """ Detect changes in job_list compared to the current contents of job_data table. Returns a list of JobData data classes where the status of each item is the new status.""" - job_name_to_job = {job.name: job for job in job_list} - current_job_data_dcs = self.manager.get_all_last_job_data_dcs() - differences = [] - for job_dc in current_job_data_dcs: - if job_dc.job_name in job_name_to_job and job_dc.status != job_name_to_job[job_dc.job_name].status_str: - job_dc.status = job_name_to_job[job_dc.job_name].status_str - differences.append(job_dc) - return differences - - def _get_defined_rowtype(self, code): - if code: - return code - else: - return Models.RowType.NORMAL - - def _get_defined_queue_name(self, wrapper_queue, wrapper_code, qos): - if wrapper_code and wrapper_code > 2 and wrapper_queue is not None: - return wrapper_queue - return qos - - def _get_next_counter_by_job_name(self, job_name): - """ Return the counter attribute from the latest job data row by job_name. """ - job_data_dc = self.manager.get_job_data_dc_unique_latest_by_job_name(job_name) - max_counter = self.manager.get_job_data_max_counter() - if job_data_dc: - return max(max_counter, job_data_dc.counter + 1) - else: - return max_counter - - def _get_date_member_completed_count(self, job_list): - """ Each item in the job_list must have attributes: date, member, status_str. """ - job_list = job_list if job_list else [] - return sum(1 for job in job_list if job.date is not None and job.member is not None and job.status_str == HUtils.SupportedStatus.COMPLETED) - - def get_status_counts_from_job_list(self, job_list): - """ - Return dict with keys COMPLETED, FAILED, QUEUING, SUBMITTED, RUNNING, SUSPENDED, TOTAL. - """ - result = { - HUtils.SupportedStatus.COMPLETED: 0, - HUtils.SupportedStatus.FAILED: 0, - HUtils.SupportedStatus.QUEUING: 0, - HUtils.SupportedStatus.SUBMITTED: 0, - HUtils.SupportedStatus.RUNNING: 0, - HUtils.SupportedStatus.SUSPENDED: 0, - "TOTAL": 0 - } - - if not job_list: - job_list = [] - - for job in job_list: - if job.status_str in result: - result[job.status_str] += 1 - result["TOTAL"] = len(job_list) - return result diff --git a/autosubmit_api/history/platform_monitor/__init__.py b/autosubmit_api/history/platform_monitor/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/autosubmit_api/history/platform_monitor/output_examples/pending.txt b/autosubmit_api/history/platform_monitor/output_examples/pending.txt deleted file mode 100644 index 007e88d..0000000 --- a/autosubmit_api/history/platform_monitor/output_examples/pending.txt +++ /dev/null @@ -1 +0,0 @@ - 17838842 PENDING 4 1 2021-10-11T10:55:53 Unknown Unknown diff --git a/autosubmit_api/history/platform_monitor/output_examples/wrapper1.txt b/autosubmit_api/history/platform_monitor/output_examples/wrapper1.txt deleted file mode 100644 index 61b855c..0000000 --- a/autosubmit_api/history/platform_monitor/output_examples/wrapper1.txt +++ /dev/null @@ -1,3 +0,0 @@ - 12535498 COMPLETED 2 1 2020-11-18T13:54:24 2020-11-18T13:55:55 2020-11-18T13:56:10 2.77K - 12535498.batch COMPLETED 2 1 2020-11-18T13:55:55 2020-11-18T13:55:55 2020-11-18T13:56:10 2.69K 659K 659K - 12535498.extern COMPLETED 2 1 2020-11-18T13:55:55 2020-11-18T13:55:55 2020-11-18T13:56:10 2.77K 24K 24K \ No newline at end of file diff --git a/autosubmit_api/history/platform_monitor/output_examples/wrapper2.txt b/autosubmit_api/history/platform_monitor/output_examples/wrapper2.txt deleted file mode 100644 index 082eb01..0000000 --- a/autosubmit_api/history/platform_monitor/output_examples/wrapper2.txt +++ /dev/null @@ -1,3 +0,0 @@ - 12535498 COMPLETED 2 1 2020-11-18T13:54:24 2020-11-18T13:55:55 2020-11-18T13:56:10 2.77K - 12535498.batch COMPLETED 2 1 2020-11-18T13:55:55 2020-11-18T13:55:55 2020-11-18T13:56:10 2.69K 659K 659K - 12535498.0 COMPLETED 2 1 2020-11-18T13:55:55 2020-11-18T13:55:55 2020-11-18T13:56:10 2.77K 24K 24K \ No newline at end of file diff --git a/autosubmit_api/history/platform_monitor/output_examples/wrapper_big.txt b/autosubmit_api/history/platform_monitor/output_examples/wrapper_big.txt deleted file mode 100644 index 65c6c11..0000000 --- a/autosubmit_api/history/platform_monitor/output_examples/wrapper_big.txt +++ /dev/null @@ -1,33 +0,0 @@ - 17857525 COMPLETED 10 1 2021-10-13T15:51:16 2021-10-13T15:51:17 2021-10-13T15:52:47 19.05K - 17857525.batch COMPLETED 10 1 2021-10-13T15:51:17 2021-10-13T15:51:17 2021-10-13T15:52:47 13.38K 6264K 6264K - 17857525.extern COMPLETED 10 1 2021-10-13T15:51:17 2021-10-13T15:51:17 2021-10-13T15:52:47 13.66K 473K 68K - 17857525.0 COMPLETED 10 1 2021-10-13T15:51:21 2021-10-13T15:51:21 2021-10-13T15:51:22 186 352K 312.30K - 17857525.1 COMPLETED 10 1 2021-10-13T15:51:23 2021-10-13T15:51:23 2021-10-13T15:51:24 186 420K 306.70K - 17857525.2 COMPLETED 10 1 2021-10-13T15:51:24 2021-10-13T15:51:24 2021-10-13T15:51:27 188 352K 325.80K - 17857525.3 COMPLETED 10 1 2021-10-13T15:51:28 2021-10-13T15:51:28 2021-10-13T15:51:29 192 352K 341.90K - 17857525.4 COMPLETED 10 1 2021-10-13T15:51:29 2021-10-13T15:51:29 2021-10-13T15:51:31 186 352K 335.20K - 17857525.5 COMPLETED 10 1 2021-10-13T15:51:31 2021-10-13T15:51:31 2021-10-13T15:51:32 186 352K 329.80K - 17857525.6 COMPLETED 10 1 2021-10-13T15:51:32 2021-10-13T15:51:32 2021-10-13T15:51:33 184 428K 311.10K - 17857525.7 COMPLETED 10 1 2021-10-13T15:51:34 2021-10-13T15:51:34 2021-10-13T15:51:35 185 416K 341.40K - 17857525.8 COMPLETED 10 1 2021-10-13T15:51:35 2021-10-13T15:51:35 2021-10-13T15:51:37 180 428K 317.40K - 17857525.9 COMPLETED 10 1 2021-10-13T15:51:39 2021-10-13T15:51:39 2021-10-13T15:51:42 17 424K 272.70K - 17857525.10 COMPLETED 10 1 2021-10-13T15:51:42 2021-10-13T15:51:42 2021-10-13T15:51:44 185 356K 304.20K - 17857525.11 COMPLETED 10 1 2021-10-13T15:51:44 2021-10-13T15:51:44 2021-10-13T15:51:45 189 352K 322.20K - 17857525.12 COMPLETED 10 1 2021-10-13T15:51:45 2021-10-13T15:51:45 2021-10-13T15:51:47 184 388K 310.70K - 17857525.13 COMPLETED 10 1 2021-10-13T15:51:48 2021-10-13T15:51:48 2021-10-13T15:51:49 183 352K 336.90K - 17857525.14 COMPLETED 10 1 2021-10-13T15:51:49 2021-10-13T15:51:49 2021-10-13T15:51:51 183 428K 346.60K - 17857525.15 COMPLETED 10 1 2021-10-13T15:51:51 2021-10-13T15:51:51 2021-10-13T15:51:53 187 352K 335.90K - 17857525.16 COMPLETED 10 1 2021-10-13T15:51:54 2021-10-13T15:51:54 2021-10-13T15:51:55 184 424K 270K - 17857525.17 COMPLETED 10 1 2021-10-13T15:51:55 2021-10-13T15:51:55 2021-10-13T15:51:57 186 352K 304.80K - 17857525.18 COMPLETED 10 1 2021-10-13T15:51:57 2021-10-13T15:51:57 2021-10-13T15:51:59 182 428K 357K - 17857525.19 COMPLETED 10 1 2021-10-13T15:51:59 2021-10-13T15:51:59 2021-10-13T15:52:01 185 420K 280.60K - 17857525.20 COMPLETED 10 1 2021-10-13T15:52:01 2021-10-13T15:52:01 2021-10-13T15:52:03 185 352K 339.90K - 17857525.21 COMPLETED 10 1 2021-10-13T15:52:04 2021-10-13T15:52:04 2021-10-13T15:52:05 188 356K 340.20K - 17857525.22 COMPLETED 10 1 2021-10-13T15:52:06 2021-10-13T15:52:06 2021-10-13T15:52:08 185 352K 287.50K - 17857525.23 COMPLETED 10 1 2021-10-13T15:52:08 2021-10-13T15:52:08 2021-10-13T15:52:11 187 420K 349.40K - 17857525.24 COMPLETED 10 1 2021-10-13T15:52:14 2021-10-13T15:52:14 2021-10-13T15:52:16 185 420K 353.70K - 17857525.25 COMPLETED 10 1 2021-10-13T15:52:20 2021-10-13T15:52:20 2021-10-13T15:52:22 187 352K 340.30K - 17857525.26 COMPLETED 10 1 2021-10-13T15:52:24 2021-10-13T15:52:24 2021-10-13T15:52:32 186 420K 345.80K - 17857525.27 COMPLETED 10 1 2021-10-13T15:52:37 2021-10-13T15:52:37 2021-10-13T15:52:39 184 352K 341K - 17857525.28 COMPLETED 10 1 2021-10-13T15:52:41 2021-10-13T15:52:41 2021-10-13T15:52:43 184 352K 326.20K - 17857525.29 COMPLETED 10 1 2021-10-13T15:52:44 2021-10-13T15:52:44 2021-10-13T15:52:47 183 352K 319.30K diff --git a/autosubmit_api/history/platform_monitor/platform_monitor.py b/autosubmit_api/history/platform_monitor/platform_monitor.py deleted file mode 100644 index 4624978..0000000 --- a/autosubmit_api/history/platform_monitor/platform_monitor.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2015-2020 Earth Sciences Department, BSC-CNS -# This file is part of Autosubmit. - -# Autosubmit is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# Autosubmit is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# You should have received a copy of the GNU General Public License -# along with Autosubmit. If not, see . - -from abc import ABCMeta, abstractmethod - -class PlatformMonitor(metaclass=ABCMeta): - def __init__(self, platform_output): - self.original_input = platform_output - self.input = str(platform_output).strip() - - - @abstractmethod - def _identify_input_rows(self): - """ """ - diff --git a/autosubmit_api/history/platform_monitor/platform_utils.py b/autosubmit_api/history/platform_monitor/platform_utils.py deleted file mode 100644 index dc50816..0000000 --- a/autosubmit_api/history/platform_monitor/platform_utils.py +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2015-2020 Earth Sciences Department, BSC-CNS -# This file is part of Autosubmit. - -# Autosubmit is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# Autosubmit is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# You should have received a copy of the GNU General Public License -# along with Autosubmit. If not, see . - -import os -from time import mktime -from datetime import datetime - -SLURM_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S" - -def parse_output_number(string_number): - """ - Parses number in format 1.0K 1.0M 1.0G - - :param string_number: String representation of number - :type string_number: str - :return: number in float format - :rtype: float - """ - number = 0.0 - if (string_number): - last_letter = string_number.strip()[-1] - multiplier = 1.0 - if last_letter == "G": - multiplier = 1000000000.0 # Billion - number = float(string_number[:-1]) - elif last_letter == "M": - multiplier = 1000000.0 # Million - number = float(string_number[:-1]) - elif last_letter == "K": - multiplier = 1000.0 # Thousand - number = float(string_number[:-1]) - else: - number = float(string_number) - try: - number = float(number) * multiplier - except Exception as exp: - number = 0.0 - pass - return number - -def try_parse_time_to_timestamp(input): - """ - Receives a string in format "%Y-%m-%dT%H:%M:%S" and tries to parse it to timestamp. - """ - try: - return int(mktime(datetime.strptime(input, SLURM_DATETIME_FORMAT).timetuple())) - except: - return 0 - -def read_example(example_name): - source_path = "autosubmit_api/history/platform_monitor/output_examples/" - file_path = os.path.join(source_path, example_name) - with open(file_path, "r") as fp: - output_ssh = fp.read() - return output_ssh \ No newline at end of file diff --git a/autosubmit_api/history/platform_monitor/slurm_monitor.py b/autosubmit_api/history/platform_monitor/slurm_monitor.py deleted file mode 100644 index 30d7e4b..0000000 --- a/autosubmit_api/history/platform_monitor/slurm_monitor.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2015-2020 Earth Sciences Department, BSC-CNS -# This file is part of Autosubmit. - -# Autosubmit is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# Autosubmit is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# You should have received a copy of the GNU General Public License -# along with Autosubmit. If not, see . - -from .platform_monitor import PlatformMonitor -from .slurm_monitor_item import SlurmMonitorItem - -class SlurmMonitor(PlatformMonitor): - """ Manages Slurm commands interpretation. """ - def __init__(self, platform_output): - super(SlurmMonitor, self).__init__(platform_output) - self._identify_input_rows() - - @property - def steps_energy(self): - return sum([step.energy for step in self.input_items if step.is_step]) - - @property - def total_energy(self): - return max(self.header.energy, self.steps_energy + self.extern.energy) - - @property - def step_count(self): - return len([step for step in self.input_items if step.is_step]) - - def _identify_input_rows(self): - lines = self.input.split("\n") - self.input_items = [SlurmMonitorItem.from_line(line) for line in lines] - - @property - def steps(self): - return [item for item in self.input_items if item.is_step] - - @property - def header(self): - return next((header for header in self.input_items if header.is_header), None) - - @property - def batch(self): - return next((batch for batch in self.input_items if batch.is_batch), None) - - @property - def extern(self): - return next((extern for extern in self.input_items if extern.is_extern), None) - - def steps_plus_extern_approximate_header_energy(self): - return abs(self.steps_energy + self.extern.energy - self.header.energy) <= 10 - - def print_items(self): - for item in self.input_items: - print(item) diff --git a/autosubmit_api/history/platform_monitor/slurm_monitor_item.py b/autosubmit_api/history/platform_monitor/slurm_monitor_item.py deleted file mode 100644 index 65ed787..0000000 --- a/autosubmit_api/history/platform_monitor/slurm_monitor_item.py +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2015-2020 Earth Sciences Department, BSC-CNS -# This file is part of Autosubmit. - -# Autosubmit is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# Autosubmit is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# You should have received a copy of the GNU General Public License -# along with Autosubmit. If not, see . - -from . import platform_utils as utils - -class SlurmMonitorItem(): - def __init__(self, name, status, ncpus, nnodes, submit, start, finish, energy="0", MaxRSS=0.0, AveRSS=0.0): - self.name = str(name) - self.status = str(status) - self.ncpus = int(ncpus) - self.nnodes = int(nnodes) - self.submit = utils.try_parse_time_to_timestamp(submit) - self.start = utils.try_parse_time_to_timestamp(start) - self.finish = utils.try_parse_time_to_timestamp(finish) - self.energy_str = energy - self.energy = utils.parse_output_number(energy) - self.MaxRSS = utils.parse_output_number(MaxRSS) - self.AveRSS = utils.parse_output_number(AveRSS) - - @property - def is_header(self): - return not self.is_detail - - @property - def is_detail(self): - if self.name.find(".") >= 0: - return True - return False - - @property - def is_extern(self): - if self.name.find(".ext") >= 0: - return True - return False - - @property - def is_batch(self): - if self.name.find(".bat") >= 0: - return True - return False - - @property - def step_number(self): - if self.is_step == True: - point_loc = self.name.find(".") - return int(self.name[point_loc+1:]) - return -1 - - @property - def is_step(self): - if self.name.find(".") >= 0 and self.is_batch == False and self.is_extern == False: - return True - return False - - @classmethod - def from_line(cls, line): - line = line.strip().split() - if len(line) < 2: - raise Exception("Slurm parser found a line too short {0}".format(line)) - new_item = cls(line[0], - line[1], - str(line[2]) if len(line) > 2 else 0, - str(line[3]) if len(line) > 3 else 0, - str(line[4]) if len(line) > 4 else 0, - str(line[5]) if len(line) > 5 else 0, - str(line[6]) if len(line) > 6 else 0, - str(line[7]) if len(line) > 7 else 0, - str(line[8]) if len(line) > 8 else 0, - str(line[9]) if len(line) > 9 else 0) - return new_item - - def get_as_dict(self): - return {"ncpus": self.ncpus, - "nnodes": self.nnodes, - "submit": self.submit, - "start": self.start, - "finish": self.finish, - "energy": self.energy, - "MaxRSS": self.MaxRSS, - "AveRSS": self.AveRSS} - - def __str__(self): - return "Name {0}, Status {1}, NCpus {2}, NNodes {3}, Submit {4}, Start {5}, Finish {6}, Energy {7}, MaxRSS {8}, AveRSS {9} [Energy Str {10}]".format(self.name, self.status, self.ncpus, self.nnodes, self.submit, self.start, self.finish, self.energy, self.MaxRSS, self.AveRSS, self.energy_str, self.is_batch) \ No newline at end of file diff --git a/autosubmit_api/history/platform_monitor/test.py b/autosubmit_api/history/platform_monitor/test.py deleted file mode 100644 index 9ae8997..0000000 --- a/autosubmit_api/history/platform_monitor/test.py +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2015-2020 Earth Sciences Department, BSC-CNS -# This file is part of Autosubmit. - -# Autosubmit is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# Autosubmit is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# You should have received a copy of the GNU General Public License -# along with Autosubmit. If not, see . - -import unittest -from autosubmit_api.history.platform_monitor import platform_utils as utils -from autosubmit_api.history.platform_monitor.slurm_monitor import SlurmMonitor - -class TestSlurmMonitor(unittest.TestCase): - def test_reader_on_simple_wrapper_example_1(self): - ssh_output = utils.read_example("wrapper1.txt") - slurm_monitor = SlurmMonitor(ssh_output) - # Header - self.assertTrue(slurm_monitor.input_items[0].is_batch == False) - self.assertTrue(slurm_monitor.input_items[0].is_detail == False) - self.assertTrue(slurm_monitor.input_items[0].is_extern == False) - self.assertTrue(slurm_monitor.input_items[0].is_header == True) - self.assertTrue(slurm_monitor.input_items[0].is_detail == False) - # Batch - self.assertTrue(slurm_monitor.input_items[1].is_batch == True) - self.assertTrue(slurm_monitor.input_items[1].is_detail == True) - self.assertTrue(slurm_monitor.input_items[1].is_extern == False) - self.assertTrue(slurm_monitor.input_items[1].is_header == False) - self.assertTrue(slurm_monitor.input_items[1].is_detail == True) - # Extern - self.assertTrue(slurm_monitor.input_items[2].is_batch == False) - self.assertTrue(slurm_monitor.input_items[2].is_detail == True) - self.assertTrue(slurm_monitor.input_items[2].is_extern == True) - self.assertTrue(slurm_monitor.input_items[2].is_header == False) - self.assertTrue(slurm_monitor.input_items[2].is_detail == True) - header = slurm_monitor.header - batch = slurm_monitor.batch - extern = slurm_monitor.extern - self.assertIsNotNone(header) - self.assertIsNotNone(batch) - self.assertIsNotNone(extern) - # print("{0} {1} <- {2}".format(batch.name, batch.energy, batch.energy_str)) - # print("{0} {1} <- {2}".format(extern.name, extern.energy, extern.energy_str)) - # print("{0} {1} <- {2}".format(header.name, header.energy, header.energy_str)) - self.assertTrue(slurm_monitor.steps_plus_extern_approximate_header_energy()) - - - def test_reader_on_simple_wrapper_example_2(self): - ssh_output = utils.read_example("wrapper2.txt") # not real - slurm_monitor = SlurmMonitor(ssh_output) - # Header - self.assertTrue(slurm_monitor.input_items[0].is_batch == False) - self.assertTrue(slurm_monitor.input_items[0].is_detail == False) - self.assertTrue(slurm_monitor.input_items[0].is_step == False) - self.assertTrue(slurm_monitor.input_items[0].is_extern == False) - self.assertTrue(slurm_monitor.input_items[0].is_header == True) - # Batch - self.assertTrue(slurm_monitor.input_items[1].is_batch == True) - self.assertTrue(slurm_monitor.input_items[1].is_detail == True) - self.assertTrue(slurm_monitor.input_items[1].is_step == False) - self.assertTrue(slurm_monitor.input_items[1].is_extern == False) - self.assertTrue(slurm_monitor.input_items[1].is_header == False) - # Step 0 - self.assertTrue(slurm_monitor.input_items[2].is_batch == False) - self.assertTrue(slurm_monitor.input_items[2].is_detail == True) - self.assertTrue(slurm_monitor.input_items[2].is_step == True) - self.assertTrue(slurm_monitor.input_items[2].is_extern == False) - self.assertTrue(slurm_monitor.input_items[2].is_header == False) - self.assertTrue(slurm_monitor.input_items[2].step_number >= 0) - - def test_reader_on_big_wrapper(self): - ssh_output = utils.read_example("wrapper_big.txt") - slurm_monitor = SlurmMonitor(ssh_output) - self.assertTrue(slurm_monitor.step_count == 30) - header = slurm_monitor.header - batch = slurm_monitor.batch - extern = slurm_monitor.extern - self.assertIsNotNone(header) - self.assertIsNotNone(batch) - self.assertIsNotNone(extern) - self.assertTrue(slurm_monitor.steps_plus_extern_approximate_header_energy()) - - -if __name__ == '__main__': - unittest.main() \ No newline at end of file diff --git a/autosubmit_api/history/test.py b/autosubmit_api/history/test.py deleted file mode 100644 index 9d0bbfd..0000000 --- a/autosubmit_api/history/test.py +++ /dev/null @@ -1,367 +0,0 @@ -#!/usr/bin/python - -# Copyright 2015-2020 Earth Sciences Department, BSC-CNS -# This file is part of Autosubmit. - -# Autosubmit is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# Autosubmit is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# You should have received a copy of the GNU General Public License -# along with Autosubmit. If not, see . - -import unittest -import traceback -import os -import time -import autosubmit_api.common.utils_for_testing as UtilsForTesting -import autosubmit_api.performance.utils as PUtils -from shutil import copy2 -from collections import namedtuple -from autosubmit_api.history.internal_logging import Logging -from autosubmit_api.history.strategies import StraightWrapperAssociationStrategy, GeneralizedWrapperDistributionStrategy, PlatformInformationHandler -from autosubmit_api.config.basicConfig import APIBasicConfig -from autosubmit_api.history.platform_monitor.slurm_monitor import SlurmMonitor -from autosubmit_api.builders.experiment_history_builder import ExperimentHistoryDirector, ExperimentHistoryBuilder -EXPID_TT00_SOURCE = "test_database.db~" -EXPID_TT01_SOURCE = "test_database_no_run.db~" -EXPID = "tt00" -EXPID_NONE = "tt01" -# BasicConfig.read() -JOBDATA_DIR = APIBasicConfig.JOBDATA_DIR -LOCAL_ROOT_DIR = APIBasicConfig.LOCAL_ROOT_DIR -job = namedtuple("Job", ["name", "date", "member", "status_str", "children"]) - -class TestExperimentHistory(unittest.TestCase): - # @classmethod - # def setUpClass(cls): - # cls.exp = ExperimentHistory("tt00") # example database - def setUp(self): - APIBasicConfig.read() - source_path_tt00 = os.path.join(JOBDATA_DIR, EXPID_TT00_SOURCE) - self.target_path_tt00 = os.path.join(JOBDATA_DIR, "job_data_{0}.db".format(EXPID)) - copy2(source_path_tt00, self.target_path_tt00) - source_path_tt01 = os.path.join(JOBDATA_DIR, EXPID_TT01_SOURCE) - self.target_path_tt01 = os.path.join(JOBDATA_DIR, "job_data_{0}.db".format(EXPID_NONE)) - copy2(source_path_tt01, self.target_path_tt01) - self.job_list = [ - job("a29z_20000101_fc2_1_POST", "2000-01-01 00:00:00", "POST", "COMPLETED", ""), - job("a29z_20000101_fc1_1_CLEAN", "2000-01-01 00:00:00", "CLEAN", "COMPLETED", ""), - job("a29z_20000101_fc3_1_POST", "2000-01-01 00:00:00", "POST", "RUNNING", ""), - job("a29z_20000101_fc2_1_CLEAN", "2000-01-01 00:00:00", "CLEAN", "COMPLETED", ""), - job("a29z_20000101_fc0_3_SIM", "2000-01-01 00:00:00", "SIM", "COMPLETED", ""), - job("a29z_20000101_fc1_2_POST", "2000-01-01 00:00:00", "POST", "QUEUING", ""), - ] # 2 differences, all COMPLETED - self.job_list_large = [ - job("a29z_20000101_fc2_1_POST", "2000-01-01 00:00:00", "POST", "COMPLETED", ""), - job("a29z_20000101_fc1_1_CLEAN", "2000-01-01 00:00:00", "CLEAN", "COMPLETED", ""), - job("a29z_20000101_fc3_1_POST", "2000-01-01 00:00:00", "POST", "RUNNING", ""), - job("a29z_20000101_fc2_1_CLEAN", "2000-01-01 00:00:00", "CLEAN", "COMPLETED", ""), - job("a29z_20000101_fc0_3_SIM", "2000-01-01 00:00:00", "SIM", "COMPLETED", ""), - job("a29z_20000101_fc1_2_POST", "2000-01-01 00:00:00", "POST", "QUEUING", ""), - job("a29z_20000101_fc1_5_POST", "2000-01-01 00:00:00", "POST", "SUSPENDED", ""), - job("a29z_20000101_fc1_4_POST", "2000-01-01 00:00:00", "POST", "FAILED", ""), - job("a29z_20000101_fc2_5_CLEAN", "2000-01-01 00:00:00", "CLEAN", "SUBMITTED", ""), - job("a29z_20000101_fc0_1_POST", "2000-01-01 00:00:00", "POST", "RUNNING", ""), - ] - # self.logging_tt00 = Logging("tt00", BasicConfig) - # self.exp_db_manager_tt00 = ExperimentHistoryDbManager("tt00", BasicConfig) - # self.exp_db_manager_tt00.initialize() - - def tearDown(self): - os.remove(self.target_path_tt00) - os.remove(self.target_path_tt01) - - def test_get_POST_jobs(self): - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder("a28v")).build_reader_experiment_history(UtilsForTesting.get_mock_basic_config()) - completed_post_jobs = exp_history.manager.get_job_data_dcs_COMPLETED_by_section("POST") - completed_sim_jobs = exp_history.manager.get_job_data_dcs_COMPLETED_by_section("SIM") - self.assertTrue(len(completed_post_jobs) == 0) - self.assertTrue(len(completed_sim_jobs) == 42) - - def test_get_job_data_dc_COMPLETED_by_wrapper_run_id(self): - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder("a28v")).build_reader_experiment_history(UtilsForTesting.get_mock_basic_config()) - completed_jobs_by_wrapper_and_run_id = exp_history.manager.get_job_data_dc_COMPLETED_by_wrapper_run_id(1644619612435, 6) - self.assertTrue(len(completed_jobs_by_wrapper_and_run_id) == 5) - - def test_queue_time_considering_wrapper_info(self): - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder("a28v")).build_reader_experiment_history(UtilsForTesting.get_mock_basic_config()) - sim_job = exp_history.manager.get_job_data_dc_unique_latest_by_job_name("a28v_19500101_fc0_580_SIM") - package_jobs = exp_history.manager.get_job_data_dc_COMPLETED_by_wrapper_run_id(sim_job.rowtype, sim_job.run_id) - self.assertTrue(sim_job.queuing_time == 79244) - # print("Adjusted queue time: {}".format(sim_job.queuing_time_considering_package(package_jobs))) - self.assertTrue(sim_job.queuing_time_considering_package(package_jobs) == (79244 - (63179 + 16065))) - - def test_performance_metrics_historic(self): - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder("a28v")).build_reader_experiment_history(UtilsForTesting.get_mock_basic_config()) - sim_job = exp_history.manager.get_job_data_dc_unique_latest_by_job_name("a28v_19500101_fc0_580_SIM") - experiment_run = exp_history.manager.get_experiment_run_by_id(sim_job.run_id) - package_jobs = exp_history.manager.get_job_data_dc_COMPLETED_by_wrapper_run_id(sim_job.rowtype, sim_job.run_id) - calculated_ASYPD = PUtils.calculate_ASYPD_perjob(experiment_run.chunk_unit, experiment_run.chunk_size, sim_job.chunk, sim_job.queuing_time_considering_package(package_jobs) + sim_job.running_time, 0.0, sim_job.status_code) - calculated_SYPD = PUtils.calculate_SYPD_perjob(experiment_run.chunk_unit, experiment_run.chunk_size, sim_job.chunk, sim_job.running_time, sim_job.status_code) - # print("calculated ASYPD: {}".format(calculated_ASYPD)) - # print("calculated SYPD: {}".format(calculated_SYPD)) - self.assertTrue(calculated_ASYPD == round(((1.0/12.0) * 86400) / 15552, 2)) - self.assertTrue(calculated_SYPD == round(((1.0/12.0) * 86400) / 15552, 2)) - - def test_get_historic(self): - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder("a28v")).build_reader_experiment_history(UtilsForTesting.get_mock_basic_config()) - result = exp_history.get_historic_job_data("a28v_19500101_fc0_580_SIM") - self.assertTrue(result[0]["ASYPD"] == 0.46) - self.assertTrue(result[0]["SYPD"] == 0.46) - - def test_db_exists(self): - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder(EXPID)).build_current_experiment_history() # ExperimentHistory("tt00", BasicConfig, self.exp_db_manager_tt00, self.logging_tt00) - # # exp_history.initialize_database() - self.assertTrue(exp_history.manager.my_database_exists() == True) - - def test_db_not_exists(self): - # exp_history_db_manager = ExperimentHistoryDbManager("tt99", BasicConfig) - # exp_history_logging = Logging("tt99", BasicConfig) - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder("tt99")).build_reader_experiment_history() # ExperimentHistory("tt99", BasicConfig, exp_history_db_manager, exp_history_logging) - self.assertTrue(exp_history.manager.my_database_exists() == False) - - def test_is_header_ready(self): - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder(EXPID)).build_current_experiment_history() # ExperimentHistory("tt00", BasicConfig, self.exp_db_manager_tt00, self.logging_tt00) - self.assertTrue(exp_history.is_header_ready() == True) - - def test_detect_differences_job_list(self): - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder(EXPID)).build_current_experiment_history() # ExperimentHistory("tt00", BasicConfig, self.exp_db_manager_tt00, self.logging_tt00) - # exp_history.initialize_database() - differences = exp_history.detect_changes_in_job_list(self.job_list) - expected_differences = ["a29z_20000101_fc3_1_POST", "a29z_20000101_fc1_2_POST"] - for job_dc in differences: - self.assertTrue(job_dc.job_name in expected_differences) - self.assertTrue(len(differences) == 2) - - def test_built_list_of_changes(self): - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder(EXPID)).build_current_experiment_history() # ExperimentHistory("tt00", BasicConfig, self.exp_db_manager_tt00, self.logging_tt00) - # exp_history.initialize_database() - built_differences = exp_history._get_built_list_of_changes(self.job_list) - expected_ids_differences = [90, 101] - for item in built_differences: - self.assertTrue(item[3] in expected_ids_differences) - - def test_get_date_member_count(self): - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder(EXPID)).build_current_experiment_history() # ExperimentHistory("tt00", BasicConfig, self.exp_db_manager_tt00, self.logging_tt00) - # exp_history.initialize_database() - dm_count = exp_history._get_date_member_completed_count(self.job_list) - self.assertTrue(dm_count > 0) - - def test_should_we_create_new_run(self): - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder(EXPID)).build_current_experiment_history() # ExperimentHistory("tt00", BasicConfig, self.exp_db_manager_tt00, self.logging_tt00) - # exp_history.initialize_database() - CHANGES_COUNT = 1 - TOTAL_COUNT = 6 - current_experiment_run_dc = exp_history.manager.get_experiment_run_dc_with_max_id() - current_experiment_run_dc.total = TOTAL_COUNT - should_we = exp_history.should_we_create_a_new_run(self.job_list, CHANGES_COUNT, current_experiment_run_dc, current_experiment_run_dc.chunk_unit, current_experiment_run_dc.chunk_size) - self.assertTrue(should_we == False) - TOTAL_COUNT_DIFF = 5 - current_experiment_run_dc.total = TOTAL_COUNT_DIFF - should_we = exp_history.should_we_create_a_new_run(self.job_list, CHANGES_COUNT, current_experiment_run_dc, current_experiment_run_dc.chunk_unit, current_experiment_run_dc.chunk_size) - self.assertTrue(should_we == True) - CHANGES_COUNT = 5 - should_we = exp_history.should_we_create_a_new_run(self.job_list, CHANGES_COUNT, current_experiment_run_dc, current_experiment_run_dc.chunk_unit, current_experiment_run_dc.chunk_size) - self.assertTrue(should_we == True) - CHANGES_COUNT = 1 - current_experiment_run_dc.total = TOTAL_COUNT - should_we = exp_history.should_we_create_a_new_run(self.job_list, CHANGES_COUNT, current_experiment_run_dc, current_experiment_run_dc.chunk_unit, current_experiment_run_dc.chunk_size*20) - self.assertTrue(should_we == True) - should_we = exp_history.should_we_create_a_new_run(self.job_list, CHANGES_COUNT, current_experiment_run_dc, current_experiment_run_dc.chunk_unit, current_experiment_run_dc.chunk_size) - self.assertTrue(should_we == False) - should_we = exp_history.should_we_create_a_new_run(self.job_list, CHANGES_COUNT, current_experiment_run_dc, "day", current_experiment_run_dc.chunk_size) - self.assertTrue(should_we == True) - - def test_status_counts(self): - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder(EXPID)).build_current_experiment_history() # ExperimentHistory("tt00", BasicConfig, self.exp_db_manager_tt00, self.logging_tt00) - # exp_history.initialize_database() - result = exp_history.get_status_counts_from_job_list(self.job_list_large) - self.assertTrue(result["COMPLETED"] == 4) - self.assertTrue(result["QUEUING"] == 1) - self.assertTrue(result["RUNNING"] == 2) - self.assertTrue(result["FAILED"] == 1) - self.assertTrue(result["SUSPENDED"] == 1) - self.assertTrue(result["TOTAL"] == len(self.job_list_large)) - - def test_create_new_experiment_run_with_counts(self): - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder(EXPID)).build_current_experiment_history() # ExperimentHistory("tt00", BasicConfig, self.exp_db_manager_tt00, self.logging_tt00) - # exp_history.initialize_database() - exp_run = exp_history.create_new_experiment_run(job_list=self.job_list) - self.assertTrue(exp_run.chunk_size == 0) - self.assertTrue(exp_run.chunk_unit == "NA") - self.assertTrue(exp_run.total == len(self.job_list)) - self.assertTrue(exp_run.completed == 4) - - def test_finish_current_run(self): - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder(EXPID)).build_current_experiment_history() # ExperimentHistory("tt00", BasicConfig, self.exp_db_manager_tt00, self.logging_tt00) - # exp_history.initialize_database() - exp_run = exp_history.finish_current_experiment_run() - self.assertTrue(len(exp_run.modified) > 0) - self.assertTrue(exp_run.finish > 0) - - def test_process_job_list_changes(self): - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder(EXPID)).build_current_experiment_history() # ExperimentHistory("tt00", BasicConfig, self.exp_db_manager_tt00, self.logging_tt00) - # exp_history.initialize_database() - exp_run = exp_history.process_job_list_changes_to_experiment_totals(self.job_list) - self.assertTrue(exp_run.total == len(self.job_list)) - self.assertTrue(exp_run.completed == 4) - self.assertTrue(exp_run.running == 1) - self.assertTrue(exp_run.queuing == 1) - - def test_calculated_weights(self): - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder(EXPID)).build_current_experiment_history() # ExperimentHistory("tt00", BasicConfig, self.exp_db_manager_tt00, self.logging_tt00) - # exp_history.initialize_database() - job_data_dcs = exp_history.manager.get_all_last_job_data_dcs() - calculated_weights = GeneralizedWrapperDistributionStrategy().get_calculated_weights_of_jobs_in_wrapper(job_data_dcs) - sum_comp_weight = 0 - for job_name in calculated_weights: - sum_comp_weight += calculated_weights[job_name] - self.assertTrue(abs(sum_comp_weight - 1) <= 0.01) - - def test_distribute_energy_in_wrapper_1_to_1(self): - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder(EXPID)).build_current_experiment_history() # ExperimentHistory("tt00", BasicConfig, self.exp_db_manager_tt00, self.logging_tt00) - # exp_history.initialize_database() - ssh_output = ''' 17857525 COMPLETED 10 1 2021-10-13T15:51:16 2021-10-13T15:51:17 2021-10-13T15:52:47 2.41K - 17857525.batch COMPLETED 10 1 2021-10-13T15:51:17 2021-10-13T15:51:17 2021-10-13T15:52:47 1.88K 6264K 6264K - 17857525.extern COMPLETED 10 1 2021-10-13T15:51:17 2021-10-13T15:51:17 2021-10-13T15:52:47 1.66K 473K 68K - 17857525.0 COMPLETED 10 1 2021-10-13T15:51:21 2021-10-13T15:51:21 2021-10-13T15:51:22 186 352K 312.30K - 17857525.1 COMPLETED 10 1 2021-10-13T15:51:23 2021-10-13T15:51:23 2021-10-13T15:51:24 186 420K 306.70K - 17857525.2 COMPLETED 10 1 2021-10-13T15:51:24 2021-10-13T15:51:24 2021-10-13T15:51:27 188 352K 325.80K - 17857525.3 COMPLETED 10 1 2021-10-13T15:51:28 2021-10-13T15:51:28 2021-10-13T15:51:29 192 352K 341.90K - ''' - slurm_monitor = SlurmMonitor(ssh_output) - job_data_dcs = exp_history.manager.get_all_last_job_data_dcs()[:4] # Get me 4 jobs - weights = StraightWrapperAssociationStrategy().get_calculated_weights_of_jobs_in_wrapper(job_data_dcs) - info_handler = PlatformInformationHandler(StraightWrapperAssociationStrategy()) - job_data_dcs_with_data = info_handler.execute_distribution(job_data_dcs[0], job_data_dcs, slurm_monitor) - self.assertTrue(job_data_dcs_with_data[0].energy == round(slurm_monitor.steps[0].energy + weights[job_data_dcs_with_data[0].job_name]*slurm_monitor.extern.energy, 2)) - self.assertTrue(job_data_dcs_with_data[0].MaxRSS == slurm_monitor.steps[0].MaxRSS) - self.assertTrue(job_data_dcs_with_data[2].energy == round(slurm_monitor.steps[2].energy + weights[job_data_dcs_with_data[2].job_name]*slurm_monitor.extern.energy, 2)) - self.assertTrue(job_data_dcs_with_data[2].AveRSS == slurm_monitor.steps[2].AveRSS) - - def test_distribute_energy_in_wrapper_general_case(self): - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder(EXPID)).build_current_experiment_history() # ExperimentHistory("tt00", BasicConfig, self.exp_db_manager_tt00, self.logging_tt00) - # exp_history.initialize_database() - ssh_output = ''' 17857525 COMPLETED 10 1 2021-10-13T15:51:16 2021-10-13T15:51:17 2021-10-13T15:52:47 2.41K - 17857525.batch COMPLETED 10 1 2021-10-13T15:51:17 2021-10-13T15:51:17 2021-10-13T15:52:47 1.88K 6264K 6264K - 17857525.extern COMPLETED 10 1 2021-10-13T15:51:17 2021-10-13T15:51:17 2021-10-13T15:52:47 1.66K 473K 68K - 17857525.0 COMPLETED 10 1 2021-10-13T15:51:21 2021-10-13T15:51:21 2021-10-13T15:51:22 186 352K 312.30K - 17857525.1 COMPLETED 10 1 2021-10-13T15:51:23 2021-10-13T15:51:23 2021-10-13T15:51:24 186 420K 306.70K - 17857525.2 COMPLETED 10 1 2021-10-13T15:51:24 2021-10-13T15:51:24 2021-10-13T15:51:27 188 352K 325.80K - 17857525.3 COMPLETED 10 1 2021-10-13T15:51:28 2021-10-13T15:51:28 2021-10-13T15:51:29 192 352K 341.90K - ''' - slurm_monitor = SlurmMonitor(ssh_output) - job_data_dcs = exp_history.manager.get_all_last_job_data_dcs()[:5] # Get me 5 jobs - weights = GeneralizedWrapperDistributionStrategy().get_calculated_weights_of_jobs_in_wrapper(job_data_dcs) - # print(sum(weights[k] for k in weights)) - info_handler = PlatformInformationHandler(GeneralizedWrapperDistributionStrategy()) - job_data_dcs_with_data = info_handler.execute_distribution(job_data_dcs[0], job_data_dcs, slurm_monitor) - self.assertTrue(job_data_dcs_with_data[0].energy == round(slurm_monitor.total_energy * weights[job_data_dcs_with_data[0].job_name], 2)) - self.assertTrue(job_data_dcs_with_data[1].energy == round(slurm_monitor.total_energy * weights[job_data_dcs_with_data[1].job_name], 2)) - self.assertTrue(job_data_dcs_with_data[2].energy == round(slurm_monitor.total_energy * weights[job_data_dcs_with_data[2].job_name], 2)) - self.assertTrue(job_data_dcs_with_data[3].energy == round(slurm_monitor.total_energy * weights[job_data_dcs_with_data[3].job_name], 2)) - self.assertTrue(job_data_dcs_with_data[4].energy == round(slurm_monitor.total_energy * weights[job_data_dcs_with_data[4].job_name], 2)) - sum_energy = sum(job.energy for job in job_data_dcs_with_data[:5]) # Last 1 is original job_data_dc - self.assertTrue(abs(sum_energy - slurm_monitor.total_energy) <= 10) - - def test_process_status_changes(self): - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder(EXPID)).build_current_experiment_history() # ExperimentHistory("tt00", BasicConfig, self.exp_db_manager_tt00, self.logging_tt00) - # exp_history.initialize_database() - CHUNK_UNIT = "month" - CHUNK_SIZE = 20 - CURRENT_CONFIG = "CURRENT CONFIG" - current_experiment_run_dc = exp_history.manager.get_experiment_run_dc_with_max_id() - exp_run = exp_history.process_status_changes(job_list=self.job_list, chunk_unit=CHUNK_UNIT, chunk_size=CHUNK_SIZE, current_config=CURRENT_CONFIG) # Generates new run - self.assertTrue(current_experiment_run_dc.run_id != exp_run.run_id) - self.assertTrue(exp_run.chunk_unit == CHUNK_UNIT) - self.assertTrue(exp_run.metadata == CURRENT_CONFIG) - self.assertTrue(exp_run.total == len(self.job_list)) - current_experiment_run_dc = exp_history.manager.get_experiment_run_dc_with_max_id() - exp_run = exp_history.process_status_changes(job_list=self.job_list, chunk_unit=CHUNK_UNIT, chunk_size=CHUNK_SIZE, current_config=CURRENT_CONFIG) # Same run - self.assertTrue(current_experiment_run_dc.run_id == exp_run.run_id) - new_job_list = [ - job("a29z_20000101_fc2_1_POST", "2000-01-01 00:00:00", "POST", "FAILED", ""), - job("a29z_20000101_fc1_1_CLEAN", "2000-01-01 00:00:00", "CLEAN", "FAILED", ""), - job("a29z_20000101_fc3_1_POST", "2000-01-01 00:00:00", "POST", "RUNNING", ""), - job("a29z_20000101_fc2_1_CLEAN", "2000-01-01 00:00:00", "CLEAN", "FAILED", ""), - job("a29z_20000101_fc0_3_SIM", "2000-01-01 00:00:00", "SIM", "FAILED", ""), - job("a29z_20000101_fc1_2_POST", "2000-01-01 00:00:00", "POST", "QUEUING", ""), - ] - current_experiment_run_dc = exp_history.manager.get_experiment_run_dc_with_max_id() - exp_run = exp_history.process_status_changes(job_list=new_job_list, chunk_unit=CHUNK_UNIT, chunk_size=CHUNK_SIZE, current_config=CURRENT_CONFIG) # Generates new run - self.assertTrue(current_experiment_run_dc.run_id != exp_run.run_id) - self.assertTrue(exp_run.total == len(new_job_list)) - self.assertTrue(exp_run.failed == 4) - - def test_write_submit_time(self): - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder(EXPID)).build_current_experiment_history() # ExperimentHistory("tt00", BasicConfig, self.exp_db_manager_tt00, self.logging_tt00) - # exp_history.initialize_database() - JOB_NAME = "a29z_20000101_fc2_1_SIM" - NCPUS = 128 - PLATFORM_NAME = "marenostrum4" - JOB_ID = 101 - inserted_job_data_dc = exp_history.write_submit_time(JOB_NAME, time.time(), "SUBMITTED", NCPUS, "00:30", "debug", "20000101", "fc2", "SIM", 1, PLATFORM_NAME, JOB_ID, "bsc_es", 1, "") - self.assertTrue(inserted_job_data_dc.job_name == JOB_NAME) - self.assertTrue(inserted_job_data_dc.ncpus == NCPUS) - self.assertTrue(inserted_job_data_dc.children == "") - self.assertTrue(inserted_job_data_dc.energy == 0) - self.assertTrue(inserted_job_data_dc.platform == PLATFORM_NAME) - self.assertTrue(inserted_job_data_dc.job_id == JOB_ID) - self.assertTrue(inserted_job_data_dc.qos == "debug") - - - def test_write_start_time(self): - exp_history = ExperimentHistoryDirector(ExperimentHistoryBuilder(EXPID)).build_current_experiment_history() # ExperimentHistory("tt00", BasicConfig, self.exp_db_manager_tt00, self.logging_tt00) - # # exp_history.initialize_database() - JOB_NAME = "a29z_20000101_fc2_1_SIM" - NCPUS = 128 - PLATFORM_NAME = "marenostrum4" - JOB_ID = 101 - inserted_job_data_dc_submit = exp_history.write_submit_time(JOB_NAME, time.time(), "SUBMITTED", NCPUS, "00:30", "debug", "20000101", "fc2", "SIM", 1, PLATFORM_NAME, JOB_ID, "bsc_es", 1, "") - inserted_job_data_dc = exp_history.write_start_time(JOB_NAME, time.time(), "RUNNING", NCPUS, "00:30", "debug", "20000101", "fc2", "SIM", 1, PLATFORM_NAME, JOB_ID, "bsc_es", 1, "") - self.assertTrue(inserted_job_data_dc.job_name == JOB_NAME) - self.assertTrue(inserted_job_data_dc.ncpus == NCPUS) - self.assertTrue(inserted_job_data_dc.children == "") - self.assertTrue(inserted_job_data_dc.energy == 0) - self.assertTrue(inserted_job_data_dc.platform == PLATFORM_NAME) - self.assertTrue(inserted_job_data_dc.job_id == JOB_ID) - self.assertTrue(inserted_job_data_dc.status == "RUNNING") - self.assertTrue(inserted_job_data_dc.qos == "debug") - - - -class TestLogging(unittest.TestCase): - - def setUp(self): - APIBasicConfig.read() - message = "No Message" - try: - raise Exception("Setup test exception") - except: - message = traceback.format_exc() - self.log = Logging("tt00", APIBasicConfig) - self.exp_message = "Exception message" - self.trace_message = message - - def test_build_message(self): - message = self.log.build_message(self.exp_message, self.trace_message) - # print(message) - self.assertIsNotNone(message) - self.assertTrue(len(message) > 0) - - def test_log(self): - self.log.log(self.exp_message, self.trace_message) - - - - - -if __name__ == '__main__': - unittest.main() \ No newline at end of file diff --git a/autosubmit_api/history/test_job_history.py b/autosubmit_api/history/test_job_history.py deleted file mode 100644 index 7e70bb3..0000000 --- a/autosubmit_api/history/test_job_history.py +++ /dev/null @@ -1,71 +0,0 @@ -import unittest -import autosubmit_api.common.utils_for_testing as TestUtils -from autosubmit_api.builders.experiment_history_builder import ExperimentHistoryDirector, ExperimentHistoryBuilder - -class TestJobHistory(unittest.TestCase): - - def setUp(self): - self.basic_config = TestUtils.get_mock_basic_config() - - def test_get_job_history_gets_correct_number_of_cases(self): - # Arrange - sut = ExperimentHistoryDirector(ExperimentHistoryBuilder("a3tb")).build_reader_experiment_history(self.basic_config) - # Act - job_history = sut.get_historic_job_data("a3tb_19930501_fc01_3_SIM") - # Assert - self.assertEqual(len(job_history), 5) # 5 rows in database - - def test_get_job_history_gets_correct_queuing_times(self): - # Arrange - sut = ExperimentHistoryDirector(ExperimentHistoryBuilder("a3tb")).build_reader_experiment_history(self.basic_config) - # Act - job_history = sut.get_historic_job_data("a3tb_19930501_fc01_3_SIM") - counter_to_queue_timedelta_str = {int(job["counter"]): job["queue_time"] for job in job_history} - # Assert - self.assertEqual(counter_to_queue_timedelta_str[18], "0:03:28") - self.assertEqual(counter_to_queue_timedelta_str[19], "0:00:04") - self.assertEqual(counter_to_queue_timedelta_str[20], "0:00:05") - self.assertEqual(counter_to_queue_timedelta_str[24], "0:01:18") - self.assertEqual(counter_to_queue_timedelta_str[25], "0:02:35") - - def test_get_job_history_gets_correct_running_times(self): - # Arrange - sut = ExperimentHistoryDirector(ExperimentHistoryBuilder("a3tb")).build_reader_experiment_history(self.basic_config) - # Act - job_history = sut.get_historic_job_data("a3tb_19930501_fc01_3_SIM") - counter_to_run_timedelta_str = {int(job["counter"]): job["run_time"] for job in job_history} - # Assert - self.assertEqual(counter_to_run_timedelta_str[18], "0:00:54") - self.assertEqual(counter_to_run_timedelta_str[19], "0:00:53") - self.assertEqual(counter_to_run_timedelta_str[20], "0:00:52") - self.assertEqual(counter_to_run_timedelta_str[24], "0:23:16") - self.assertEqual(counter_to_run_timedelta_str[25], "0:07:58") - - def test_get_job_history_gets_correct_SYPD(self): - # Arrange - sut = ExperimentHistoryDirector(ExperimentHistoryBuilder("a3tb")).build_reader_experiment_history(self.basic_config) - # Act - job_history = sut.get_historic_job_data("a3tb_19930501_fc01_3_SIM") - counter_to_SYPD = {int(job["counter"]): job["SYPD"] for job in job_history} - # Assert - self.assertEqual(counter_to_SYPD[18], None) - self.assertEqual(counter_to_SYPD[19], None) - self.assertEqual(counter_to_SYPD[20], None) - self.assertEqual(counter_to_SYPD[24], round(86400*(1.0/12.0)/1396, 2)) - self.assertEqual(counter_to_SYPD[25], round(86400*(1.0/12.0)/478, 2)) - - def test_get_job_history_gets_correct_ASYPD(self): - # Arrange - sut = ExperimentHistoryDirector(ExperimentHistoryBuilder("a3tb")).build_reader_experiment_history(self.basic_config) - # Act - job_history = sut.get_historic_job_data("a3tb_19930501_fc01_3_SIM") - counter_to_ASYPD = {int(job["counter"]): job["ASYPD"] for job in job_history} - # Assert - self.assertEqual(counter_to_ASYPD[18], None) - self.assertEqual(counter_to_ASYPD[19], None) - self.assertEqual(counter_to_ASYPD[20], None) - self.assertEqual(counter_to_ASYPD[24], round(86400*(1.0/12.0)/(1396 + 78), 2)) - self.assertEqual(counter_to_ASYPD[25], round(86400*(1.0/12.0)/(478 + 155), 2)) - -if __name__ == "__main__": - unittest.main() \ No newline at end of file diff --git a/autosubmit_api/history/test_strategies.py b/autosubmit_api/history/test_strategies.py deleted file mode 100644 index 04d7fa8..0000000 --- a/autosubmit_api/history/test_strategies.py +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/python - -# Copyright 2015-2020 Earth Sciences Department, BSC-CNS -# This file is part of Autosubmit. - -# Autosubmit is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# Autosubmit is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# You should have received a copy of the GNU General Public License -# along with Autosubmit. If not, see . - -import unittest -from collections import namedtuple -from autosubmit_api.history.data_classes.job_data import JobData -from autosubmit_api.history.strategies import StraightWrapperAssociationStrategy, GeneralizedWrapperDistributionStrategy, PlatformInformationHandler, TwoDimWrapperDistributionStrategy -from autosubmit_api.history.platform_monitor.slurm_monitor import SlurmMonitor -job_dc = namedtuple("Job", ["job_name", "date", "member", "status_str", "children", "children_list"]) - -class Test2DWrapperDistributionStrategy(unittest.TestCase): - def setUp(self): - self.strategy = TwoDimWrapperDistributionStrategy() - self.job_data_dcs_in_wrapper = [ - JobData(0, job_name="a29z_20000101_fc2_1_POSTR", status="COMPLETED", submit=10, start=100, finish=200, ncpus=100, energy=0, children="a29z_20000101_fc1_1_CLEAN, a29z_20000101_fc3_1_POST"), - JobData(0, job_name="a29z_20000101_fc1_1_CLEAN", status="COMPLETED", submit=10, start=100, finish=200, ncpus=100, energy=0, children="a29z_20000101_fc2_1_CLEAN"), - JobData(0, job_name="a29z_20000101_fc3_1_POST", status="COMPLETED", submit=10, start=100, finish=200, ncpus=100, energy=0, children="a29z_20000101_fc0_3_SIM"), - JobData(0, job_name="a29z_20000101_fc2_1_CLEAN", status="COMPLETED", submit=10, start=100, finish=200, ncpus=100, energy=0, children=""), - JobData(0, job_name="a29z_20000101_fc0_3_SIM", status="COMPLETED", submit=10, start=100, finish=200, ncpus=100, energy=0, children=""), - JobData(0, job_name="a29z_20000101_fc1_2_POSTR1", status="COMPLETED", submit=10, start=100, finish=200, ncpus=100, energy=0, children="a29z_20000101_fc1_5_POST2"), - JobData(0, job_name="a29z_20000101_fc1_5_POST2", status="COMPLETED", submit=10, start=100, finish=200, ncpus=100, energy=0, children="a29z_20000101_fc1_4_POST3"), - JobData(0, job_name="a29z_20000101_fc1_4_POST3", status="COMPLETED", submit=10, start=100, finish=200, ncpus=100, energy=0, children="a29z_20000101_fc2_5_CLEAN4"), - JobData(0, job_name="a29z_20000101_fc2_5_CLEAN4", status="COMPLETED", submit=10, start=100, finish=200, ncpus=100, energy=0, children="a29z_20000101_fc0_1_POST5"), - JobData(0, job_name="a29z_20000101_fc0_1_POST5", status="COMPLETED", submit=10, start=100, finish=200, ncpus=100, energy=0, children=""), - ] - - def test_get_all_children(self): - children = self.strategy._get_all_children(self.job_data_dcs_in_wrapper) - self.assertTrue(len(children) == 8) - - def test_get_roots(self): - roots = self.strategy._get_roots(self.job_data_dcs_in_wrapper) - self.assertTrue(len(roots) == 2) - - def test_get_level(self): - roots = self.strategy._get_roots(self.job_data_dcs_in_wrapper) - job_name_to_children_names = {job.job_name: job.children_list for job in self.job_data_dcs_in_wrapper} - next_level = self.strategy.get_level(roots, job_name_to_children_names) - self.assertTrue(len(next_level) == 3) - - def test_get_jobs_per_level(self): - levels = self.strategy.get_jobs_per_level(self.job_data_dcs_in_wrapper) - for level in levels: - print([job.job_name for job in level]) - self.assertTrue(len(levels) == 5) - self.assertTrue("a29z_20000101_fc0_1_POST5" in [job.job_name for job in levels[4]]) - - def test_energy_distribution(self): - ssh_output = ''' 17857525 COMPLETED 10 1 2021-10-13T15:51:16 2021-10-13T15:51:17 2021-10-13T15:52:47 2.62K - 17857525.batch COMPLETED 10 1 2021-10-13T15:51:17 2021-10-13T15:51:17 2021-10-13T15:52:47 1.88K 6264K 6264K - 17857525.extern COMPLETED 10 1 2021-10-13T15:51:17 2021-10-13T15:51:17 2021-10-13T15:52:47 1.66K 473K 68K - 17857525.0 COMPLETED 10 1 2021-10-13T15:51:21 2021-10-13T15:51:21 2021-10-13T15:51:22 186 352K 312.30K - 17857525.1 COMPLETED 10 1 2021-10-13T15:51:23 2021-10-13T15:51:23 2021-10-13T15:51:24 186 420K 306.70K - 17857525.2 COMPLETED 10 1 2021-10-13T15:51:24 2021-10-13T15:51:24 2021-10-13T15:51:27 188 352K 325.80K - 17857525.3 COMPLETED 10 1 2021-10-13T15:51:28 2021-10-13T15:51:28 2021-10-13T15:51:29 192 352K 341.90K - 17857525.4 COMPLETED 10 1 2021-10-13T15:51:28 2021-10-13T15:51:28 2021-10-13T15:51:29 210 352K 341.90K - ''' - slurm_monitor = SlurmMonitor(ssh_output) - info_handler = PlatformInformationHandler(TwoDimWrapperDistributionStrategy()) - job_dcs = info_handler.execute_distribution(self.job_data_dcs_in_wrapper[0], self.job_data_dcs_in_wrapper, slurm_monitor) - for job in job_dcs: - print(("{0} -> {1} and {2} : ncpus {3} running {4}".format(job.job_name, job.energy, job.rowstatus, job.ncpus, job.running_time))) - for level in info_handler.strategy.jobs_per_level: - print([job.job_name for job in level]) - total_in_jobs = sum(job.energy for job in job_dcs[:-1]) # ignore last - self.assertTrue(abs(total_in_jobs - slurm_monitor.total_energy) <= 10) - self.assertTrue(abs(job_dcs[0].energy - 259) < 1) - self.assertTrue(abs(job_dcs[1].energy - 259) < 1) - self.assertTrue(abs(job_dcs[2].energy - 228) < 1) - self.assertTrue(abs(job_dcs[3].energy - 228) < 1) - self.assertTrue(abs(job_dcs[4].energy - 228) < 1) - self.assertTrue(abs(job_dcs[5].energy - 228.67) < 1) - self.assertTrue(abs(job_dcs[6].energy - 228.67) < 1) - self.assertTrue(abs(job_dcs[7].energy - 228.67) < 1) - self.assertTrue(abs(job_dcs[8].energy - 358) < 1) - self.assertTrue(abs(job_dcs[9].energy - 376) < 1) - -if __name__ == '__main__': - unittest.main() \ No newline at end of file diff --git a/autosubmit_api/history/test_utils.py b/autosubmit_api/history/test_utils.py deleted file mode 100644 index 7abcc51..0000000 --- a/autosubmit_api/history/test_utils.py +++ /dev/null @@ -1,32 +0,0 @@ - -import unittest -import history.utils as HUtils - -class TestUtils(unittest.TestCase): - def setUp(self): - pass - - def test_generate_arguments(self): - arguments = {"status": 4, "last": 1, "rowtype": 2} - statement, arg_values = HUtils.get_built_statement_from_kwargs("id", status=4, last=1, rowtype=2) - print(statement) - print(arg_values) - self.assertTrue(statement.find("status") >= 0) - self.assertTrue(statement.find("last") >= 0) - self.assertTrue(statement.find("rowtype") >= 0) - self.assertTrue(statement.find(" ORDER BY id") >= 0) - - def test_generate_arguments_kwargs(self): - def inner_call(expid, **kwargs): - return HUtils.get_built_statement_from_kwargs("created", **kwargs) - arguments = {"status": 4, "last": 1, "rowtype": 2} - answer, arg_values = inner_call("a28v", **arguments) - print(answer) - print(arg_values) - self.assertTrue(answer.find("status") >= 0) - self.assertTrue(answer.find("last") >= 0) - self.assertTrue(answer.find("rowtype") >= 0) - self.assertTrue(answer.find(" ORDER BY created") >= 0) - -if __name__ == "__main__": - unittest.main() \ No newline at end of file diff --git a/autosubmit_api/workers/populate_details/test.py b/autosubmit_api/workers/populate_details/test.py deleted file mode 100644 index 24fc30a..0000000 --- a/autosubmit_api/workers/populate_details/test.py +++ /dev/null @@ -1,53 +0,0 @@ -import unittest -from autosubmit_api.workers.populate_details.populate import DetailsProcessor -import autosubmit_api.common.utils_for_testing as TestUtils - -class TestPopulate(unittest.TestCase): - def setUp(self): - pass - - def test_retrieve_correct_experiments(self): - # Arrange - sut = DetailsProcessor(TestUtils.get_mock_basic_config()) - # Act - experiments = sut._get_experiments() - names = [experiment.name for experiment in experiments] - # Assert - self.assertIn("a28v", names) - self.assertIn("a3tb", names) - - - def test_get_details_from_experiment(self): - # Arrange - sut = DetailsProcessor(TestUtils.get_mock_basic_config()) - # Act - details = sut._get_details_data_from_experiment("a28v") - # Assert - self.assertIsNotNone(details) - self.assertEqual("wuruchi", details.owner) - self.assertIsInstance(details.created, str) - self.assertIsInstance(details.model, str) - self.assertIsInstance(details.branch, str) - self.assertEqual("marenostrum4", details.hpc) - - - def test_get_all_experiment_details_equals_number_of_test_cases(self): - # Arrange - sut = DetailsProcessor(TestUtils.get_mock_basic_config()) - # Act - processed_data = sut._get_all_details() - # Assert - self.assertEqual(len(processed_data), 2) # There are 2 cases in the test_cases folder - - - def test_process_inserts_the_details(self): - # Arrange - sut = DetailsProcessor(TestUtils.get_mock_basic_config()) - # Act - number_rows = sut.process() - # Assert - self.assertEqual(number_rows, 2) - - -if __name__ == '__main__': - unittest.main() \ No newline at end of file diff --git a/tests/experiments/as_times.db b/tests/experiments/as_times.db index c70732eb783e06bcef253b4f0e7ac92c0a225396..43bd6b66db7a106c2f556c68a50b9272caf871ae 100644 GIT binary patch delta 49 pcmZp0XmFSy&Db8vtfF4O;*J delta 49 pcmZp0XmFSy%~&>3#+k8fV}h$ZyOEWtiIu708vtNp4Icmi -- GitLab From 6108b6223506e978bf68b53b1b39a11e94ea1a15 Mon Sep 17 00:00:00 2001 From: Luiggi Tenorio Date: Fri, 16 Feb 2024 13:29:56 +0100 Subject: [PATCH 13/18] fix outlier calculation #65 --- autosubmit_api/common/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/autosubmit_api/common/utils.py b/autosubmit_api/common/utils.py index 7056076..e0ce68f 100644 --- a/autosubmit_api/common/utils.py +++ b/autosubmit_api/common/utils.py @@ -71,12 +71,12 @@ def parse_number_processors(processors_str): except: return 1 -def get_jobs_with_no_outliers(jobs): +def get_jobs_with_no_outliers(jobs: List): """ Detects outliers and removes them from the returned list """ new_list = [] data_run_times = [job.run_time for job in jobs] # print(data_run_times) - if len(data_run_times) == 0: + if len(data_run_times) <= 1: return jobs mean = statistics.mean(data_run_times) -- GitLab From 5818dfc763e5de13ef6a26dd598e6d18565cf473 Mon Sep 17 00:00:00 2001 From: Luiggi Tenorio Date: Fri, 16 Feb 2024 17:44:35 +0100 Subject: [PATCH 14/18] applied experiment path builder --- .../autosubmit_legacy/job/job_list.py | 19 +- autosubmit_api/database/db_jobdata.py | 11 +- autosubmit_api/database/db_structure.py | 68 +----- autosubmit_api/experiment/common_requests.py | 20 +- .../experiment_history_db_manager.py | 5 +- autosubmit_api/history/strategies.py | 220 ------------------ autosubmit_api/persistance/experiment.py | 29 ++- tests/experiments/as_times.db | Bin 8192 -> 8192 bytes 8 files changed, 65 insertions(+), 307 deletions(-) delete mode 100644 autosubmit_api/history/strategies.py diff --git a/autosubmit_api/autosubmit_legacy/job/job_list.py b/autosubmit_api/autosubmit_legacy/job/job_list.py index dab3d9a..c9ad0cb 100644 --- a/autosubmit_api/autosubmit_legacy/job/job_list.py +++ b/autosubmit_api/autosubmit_legacy/job/job_list.py @@ -31,6 +31,7 @@ from dateutil.relativedelta import * from bscearth.utils.log import Log from autosubmit_api.autosubmit_legacy.job.job_utils import SubJob from autosubmit_api.autosubmit_legacy.job.job_utils import SubJobManager, job_times_to_text +from autosubmit_api.config.basicConfig import APIBasicConfig from autosubmit_api.performance.utils import calculate_ASYPD_perjob, calculate_SYPD_perjob from autosubmit_api.components.jobs import utils as JUtils from autosubmit_api.monitor.monitor import Monitor @@ -46,6 +47,8 @@ from autosubmit_api.history.data_classes.job_data import JobData from typing import List, Dict, Tuple +from autosubmit_api.persistance.experiment import ExperimentPaths + class JobList: """ @@ -153,8 +156,8 @@ class JobList: date_member_repetition = {} job_name_to_job_title = {} job_name_to_job = {job.job_name: job for job in job_list} - path_to_logs = os.path.join( - BasicConfig.LOCAL_ROOT_DIR, expid, "tmp", "LOG_" + expid) + exp_paths = ExperimentPaths(expid) + path_to_logs = exp_paths.tmp_log_dir packages = {job.rowtype for job in job_list if job.rowtype > 2} package_to_jobs = {package: [ @@ -577,7 +580,7 @@ class JobList: return "" @staticmethod - def get_job_times_collection(basic_config, allJobs, expid, job_to_package=None, package_to_jobs=None, timeseconds=True): + def get_job_times_collection(basic_config: APIBasicConfig, allJobs, expid, job_to_package=None, package_to_jobs=None, timeseconds=True): """ Gets queuing and running time for the collection of jobs @@ -860,23 +863,21 @@ class JobList: """ monitor = Monitor() packages = None + exp_paths = ExperimentPaths(expid) try: - packages = JobPackagePersistence(os.path.join(basic_config.LOCAL_ROOT_DIR, expid, "pkl"), - "job_packages_" + expid).load(wrapper=False) + packages = JobPackagePersistence(exp_paths.job_packages_db).load(wrapper=False) # if the main table exist but is empty, we try the other one if not (any(packages.keys()) or any(packages.values())): Log.info("Wrapper table empty, trying packages.") - packages = JobPackagePersistence(os.path.join(basic_config.LOCAL_ROOT_DIR, expid, "pkl"), - "job_packages_" + expid).load(wrapper=True) + packages = JobPackagePersistence(exp_paths.job_packages_db).load(wrapper=True) except Exception as ex: print("Wrapper table not found, trying packages.") packages = None try: - packages = JobPackagePersistence(os.path.join(basic_config.LOCAL_ROOT_DIR, expid, "pkl"), - "job_packages_" + expid).load(wrapper=True) + packages = JobPackagePersistence(exp_paths.job_packages_db).load(wrapper=True) except Exception as exp2: packages = None pass diff --git a/autosubmit_api/database/db_jobdata.py b/autosubmit_api/database/db_jobdata.py index 6f0826c..44db432 100644 --- a/autosubmit_api/database/db_jobdata.py +++ b/autosubmit_api/database/db_jobdata.py @@ -38,6 +38,8 @@ from autosubmit_api.common.utils import get_jobs_with_no_outliers, Status, datec # import autosubmitAPIwu.experiment.common_db_requests as DbRequests from bscearth.utils.date import Log +from autosubmit_api.persistance.experiment import ExperimentPaths + # Version 15 includes out err MaxRSS AveRSS and rowstatus CURRENT_DB_VERSION = 15 # Used to be 10 or 0 @@ -532,8 +534,9 @@ class ExperimentGraphDrawing(MainDataBase): MainDataBase.__init__(self, expid) APIBasicConfig.read() self.expid = expid + exp_paths = ExperimentPaths(expid) self.folder_path = APIBasicConfig.LOCAL_ROOT_DIR - self.database_path = os.path.join(APIBasicConfig.GRAPHDATA_DIR, "graph_data_" + str(expid) + ".db") + self.database_path = exp_paths.graph_data_db self.create_table_query = textwrap.dedent( '''CREATE TABLE IF NOT EXISTS experiment_graph_draw ( @@ -733,7 +736,7 @@ class ExperimentGraphDrawing(MainDataBase): class JobDataStructure(MainDataBase): - def __init__(self, expid, basic_config): + def __init__(self, expid: str, basic_config: APIBasicConfig): """Initializes the object based on the unique identifier of the experiment. Args: @@ -743,8 +746,8 @@ class JobDataStructure(MainDataBase): # BasicConfig.read() # self.expid = expid self.folder_path = basic_config.JOBDATA_DIR - self.database_path = os.path.join( - self.folder_path, "job_data_" + str(expid) + ".db") + exp_paths = ExperimentPaths(expid) + self.database_path = exp_paths.job_data_db # self.conn = None self.db_version = None # self.jobdata_list = JobDataList(self.expid) diff --git a/autosubmit_api/database/db_structure.py b/autosubmit_api/database/db_structure.py index 87fa177..ea379e9 100644 --- a/autosubmit_api/database/db_structure.py +++ b/autosubmit_api/database/db_structure.py @@ -3,6 +3,8 @@ import textwrap import traceback import pysqlite3 as sqlite3 +from autosubmit_api.persistance.experiment import ExperimentPaths + def get_structure(expid, structures_path): """ Creates file of database and table of experiment structure if it does not exist. @@ -12,10 +14,10 @@ def get_structure(expid, structures_path): :rtype: Dictionary Key: String, Value: List(of String) """ try: + exp_paths = ExperimentPaths(expid) + db_structure_path = exp_paths.structure_db #pkl_path = os.path.join(exp_path, expid, "pkl") - if os.path.exists(structures_path): - db_structure_path = os.path.join( - structures_path, "structure_" + expid + ".db") + if os.path.exists(db_structure_path): # Create file os.umask(0) if not os.path.exists(db_structure_path): @@ -51,8 +53,8 @@ def get_structure(expid, structures_path): return dict() else: # pkl folder not found - raise Exception("structures folder not found " + - str(structures_path)) + raise Exception("structures db not found " + + str(db_structure_path)) except Exception as exp: print((traceback.format_exc())) @@ -100,59 +102,3 @@ def _get_exp_structure(path): except Exception as exp: print((traceback.format_exc())) return dict() - - -def save_structure(graph, exp_id, structures_path): - """ - Saves structure if path is valid - """ - #pkl_path = os.path.join(exp_path, exp_id, "pkl") - if os.path.exists(structures_path): - db_structure_path = os.path.join( - structures_path, "structure_" + exp_id + ".db") - # with open(db_structure_path, "w"): - conn = create_connection(db_structure_path) - deleted = _delete_table_content(conn) - if deleted == True: - nodes_edges = {u for u, v in graph.edges()} - nodes_edges.update({v for u, v in graph.edges()}) - independent_nodes = { - u for u in graph.nodes() if u not in nodes_edges} - data = {(u, v) for u, v in graph.edges()} - data.update({(u, u) for u in independent_nodes}) - # save - _create_edge(conn, data) - #print("Created edge " + str(u) + str(v)) - conn.commit() - else: - # pkl folder not found - raise Exception("pkl folder not found " + str(structures_path)) - - -def _create_edge(conn, data): - """ - Create edge - """ - try: - sql = ''' INSERT INTO experiment_structure(e_from, e_to) VALUES(?,?) ''' - cur = conn.cursor() - cur.executemany(sql, data) - # return cur.lastrowid - except sqlite3.Error as e: - print(("Error on Insert : " + str(type(e).__name__))) - - -def _delete_table_content(conn): - """ - Deletes table content - """ - try: - sql = ''' DELETE FROM experiment_structure ''' - cur = conn.cursor() - cur.execute(sql) - conn.commit() - return True - except Exception as exp: - # print(traceback.format_exc()) - print(("Error on Delete _delete_table_content: {}".format(str(exp)))) - return False diff --git a/autosubmit_api/experiment/common_requests.py b/autosubmit_api/experiment/common_requests.py index 5d29898..0028357 100644 --- a/autosubmit_api/experiment/common_requests.py +++ b/autosubmit_api/experiment/common_requests.py @@ -43,6 +43,7 @@ from autosubmit_api.logger import logger from autosubmit_api.performance.utils import calculate_SYPD_perjob from autosubmit_api.monitor.monitor import Monitor +from autosubmit_api.persistance.experiment import ExperimentPaths from autosubmit_api.statistics.statistics import Statistics @@ -212,10 +213,9 @@ def _is_exp_running(expid, time_condition=300): definite_log_path = None try: APIBasicConfig.read() - pathlog_aslog = APIBasicConfig.LOCAL_ROOT_DIR + '/' + expid + '/' + \ - APIBasicConfig.LOCAL_TMP_DIR + '/' + APIBasicConfig.LOCAL_ASLOG_DIR - pathlog_tmp = APIBasicConfig.LOCAL_ROOT_DIR + '/' + \ - expid + '/' + APIBasicConfig.LOCAL_TMP_DIR + exp_paths = ExperimentPaths(expid) + pathlog_aslog = exp_paths.tmp_as_logs_dir + pathlog_tmp = exp_paths.tmp_dir # Basic Configuration look_old_folder = False current_version = None @@ -480,7 +480,8 @@ def get_experiment_log_last_lines(expid): try: APIBasicConfig.read() - path = APIBasicConfig.LOCAL_ROOT_DIR + '/' + expid + '/' + APIBasicConfig.LOCAL_TMP_DIR + '/' + APIBasicConfig.LOCAL_ASLOG_DIR + exp_paths = ExperimentPaths(expid) + path = exp_paths.tmp_as_logs_dir reading = os.popen('ls -t ' + path + ' | grep "run.log"').read() if (os.path.exists(path)) else "" # Finding log files @@ -529,7 +530,8 @@ def get_job_log(expid, logfile, nlines=150): logcontent = [] reading = "" APIBasicConfig.read() - logfilepath = os.path.join(APIBasicConfig.LOCAL_ROOT_DIR, expid, APIBasicConfig.LOCAL_TMP_DIR, "LOG_{0}".format(expid), logfile) + exp_paths = ExperimentPaths(expid) + logfilepath = os.path.join(exp_paths.tmp_log_dir, logfile) try: if os.path.exists(logfilepath): current_stat = os.stat(logfilepath) @@ -817,7 +819,8 @@ def get_quick_view(expid): total_count = completed_count = failed_count = running_count = queuing_count = 0 try: APIBasicConfig.read() - path_to_logs = os.path.join(APIBasicConfig.LOCAL_ROOT_DIR, expid, "tmp", "LOG_" + expid) + exp_paths = ExperimentPaths(expid) + path_to_logs = exp_paths.tmp_log_dir # Retrieving packages now_ = time.time() @@ -906,7 +909,8 @@ def get_job_history(expid, job_name): result = None try: APIBasicConfig.read() - path_to_job_logs = os.path.join(APIBasicConfig.LOCAL_ROOT_DIR, expid, "tmp", "LOG_" + expid) + exp_paths = ExperimentPaths(expid) + path_to_job_logs = exp_paths.tmp_log_dir result = ExperimentHistoryDirector(ExperimentHistoryBuilder(expid)).build_reader_experiment_history().get_historic_job_data(job_name) except Exception as exp: print((traceback.format_exc())) diff --git a/autosubmit_api/history/database_managers/experiment_history_db_manager.py b/autosubmit_api/history/database_managers/experiment_history_db_manager.py index 0a821a3..f8a1e6b 100644 --- a/autosubmit_api/history/database_managers/experiment_history_db_manager.py +++ b/autosubmit_api/history/database_managers/experiment_history_db_manager.py @@ -19,6 +19,8 @@ import pysqlite3 as sqlite3 import os import traceback import textwrap + +from autosubmit_api.persistance.experiment import ExperimentPaths from .. import utils as HUtils from .. database_managers import database_models as Models from .. data_classes.job_data import JobData @@ -39,7 +41,8 @@ class ExperimentHistoryDbManager(DatabaseManager): super(ExperimentHistoryDbManager, self).__init__(expid, basic_config) self._set_schema_changes() self._set_table_queries() - self.historicaldb_file_path = os.path.join(self.JOBDATA_DIR, "job_data_{0}.db".format(self.expid)) # type : str + exp_paths = ExperimentPaths(expid) + self.historicaldb_file_path = exp_paths.job_data_db if self.my_database_exists(): self.set_db_version_models() diff --git a/autosubmit_api/history/strategies.py b/autosubmit_api/history/strategies.py deleted file mode 100644 index 8fdf88d..0000000 --- a/autosubmit_api/history/strategies.py +++ /dev/null @@ -1,220 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2015-2020 Earth Sciences Department, BSC-CNS -# This file is part of Autosubmit. - -# Autosubmit is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# Autosubmit is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. - -# You should have received a copy of the GNU General Public License -# along with Autosubmit. If not, see . - -from abc import ABCMeta, abstractmethod -from .database_managers import database_models as Models -import traceback -from .internal_logging import Logging -from .database_managers.database_manager import DEFAULT_LOCAL_ROOT_DIR, DEFAULT_HISTORICAL_LOGS_DIR - -class PlatformInformationHandler(): - def __init__(self, strategy): - self._strategy = strategy - - @property - def strategy(self): - return self._strategy - - @strategy.setter - def strategy(self, strategy): - self._strategy = strategy - - def execute_distribution(self, job_data_dc, job_data_dcs_in_wrapper, slurm_monitor): - return self._strategy.apply_distribution(job_data_dc, job_data_dcs_in_wrapper, slurm_monitor) - - -class Strategy(metaclass=ABCMeta): - """ Strategy Interface """ - - def __init__(self, historiclog_dir_path=DEFAULT_HISTORICAL_LOGS_DIR): - self.historiclog_dir_path = historiclog_dir_path - - @abstractmethod - def apply_distribution(self, job_data_dc, job_data_dcs_in_wrapper, slurm_monitor): - pass - - def set_job_data_dc_as_processed(self, job_data_dc, original_ssh_output): - job_data_dc.platform_output = original_ssh_output - job_data_dc.rowstatus = Models.RowStatus.PROCESSED - return job_data_dc - - def set_job_data_dc_as_process_failed(self, job_data_dc, original_ssh_output): - job_data_dc.platform_output = original_ssh_output - job_data_dc.rowstatus = Models.RowStatus.FAULTY - return job_data_dc - - def get_calculated_weights_of_jobs_in_wrapper(self, job_data_dcs_in_wrapper): - """ Based on computational weight: running time in seconds * number of cpus. """ - total_weight = sum(job.computational_weight for job in job_data_dcs_in_wrapper) - return {job.job_name: round(job.computational_weight/total_weight, 4) for job in job_data_dcs_in_wrapper} - - -class SingleAssociationStrategy(Strategy): - - def __init__(self, historiclog_dir_path=DEFAULT_HISTORICAL_LOGS_DIR): - super(SingleAssociationStrategy, self).__init__(historiclog_dir_path=historiclog_dir_path) - - def apply_distribution(self, job_data_dc, job_data_dcs_in_wrapper, slurm_monitor): - try: - if len(job_data_dcs_in_wrapper) > 0: - return [] - # job_data_dc.submit = slurm_monitor.header.submit - # job_data_dc.start = slurm_monitor.header.start - # job_data_dc.finish = slurm_monitor.header.finish - job_data_dc.ncpus = slurm_monitor.header.ncpus - job_data_dc.nnodes = slurm_monitor.header.nnodes - job_data_dc.energy = slurm_monitor.header.energy - job_data_dc.MaxRSS = max(slurm_monitor.header.MaxRSS, slurm_monitor.batch.MaxRSS if slurm_monitor.batch else 0, slurm_monitor.extern.MaxRSS if slurm_monitor.extern else 0) # TODO: Improve this rule - job_data_dc.AveRSS = max(slurm_monitor.header.AveRSS, slurm_monitor.batch.AveRSS if slurm_monitor.batch else 0, slurm_monitor.extern.AveRSS if slurm_monitor.extern else 0) - job_data_dc = self.set_job_data_dc_as_processed(job_data_dc, slurm_monitor.original_input) - return [job_data_dc] - except Exception as exp: - Logging("strategies", self.historiclog_dir_path).log("SingleAssociationStrategy failed for {0}. Using ssh_output: {1}. Exception message: {2}".format(job_data_dc.job_name, slurm_monitor.original_input, str(exp)), - traceback.format_exc()) - job_data_dc = self.set_job_data_dc_as_process_failed(job_data_dc, slurm_monitor.original_input) - return [job_data_dc] - -class StraightWrapperAssociationStrategy(Strategy): - - def __init__(self, historiclog_dir_path=DEFAULT_HISTORICAL_LOGS_DIR): - super(StraightWrapperAssociationStrategy, self).__init__(historiclog_dir_path=historiclog_dir_path) - - def apply_distribution(self, job_data_dc, job_data_dcs_in_wrapper, slurm_monitor): - """ """ - try: - if len(job_data_dcs_in_wrapper) != slurm_monitor.step_count: - return [] - result = [] - computational_weights = self.get_calculated_weights_of_jobs_in_wrapper(job_data_dcs_in_wrapper) - for job_dc, step in zip(job_data_dcs_in_wrapper, slurm_monitor.steps): - job_dc.energy = step.energy + computational_weights.get(job_dc.job_name, 0) * slurm_monitor.extern.energy - job_dc.AveRSS = step.AveRSS - job_dc.MaxRSS = step.MaxRSS - job_dc.platform_output = "" - if job_dc.job_name == job_data_dc.job_name: - job_data_dc.energy = job_dc.energy - job_data_dc.AveRSS = job_dc.AveRSS - job_data_dc.MaxRSS = job_dc.MaxRSS - result.append(job_dc) - job_data_dc = self.set_job_data_dc_as_processed(job_data_dc, slurm_monitor.original_input) - result.append(job_data_dc) - return result - except Exception as exp: - Logging("strategies", self.historiclog_dir_path).log("StraightWrapperAssociationStrategy failed for {0}. Using ssh_output: {1}. Exception message: {2}".format(job_data_dc.job_name, - slurm_monitor.original_input, - str(exp)), - traceback.format_exc()) - job_data_dc = self.set_job_data_dc_as_process_failed(job_data_dc, slurm_monitor.original_input) - return [job_data_dc] - -class GeneralizedWrapperDistributionStrategy(Strategy): - - def __init__(self, historiclog_dir_path=DEFAULT_HISTORICAL_LOGS_DIR): - super(GeneralizedWrapperDistributionStrategy, self).__init__(historiclog_dir_path=historiclog_dir_path) - - def apply_distribution(self, job_data_dc, job_data_dcs_in_wrapper, slurm_monitor): - try: - result = [] - computational_weights = self.get_calculated_weights_of_jobs_in_wrapper(job_data_dcs_in_wrapper) - for job_dc in job_data_dcs_in_wrapper: - job_dc.energy = round(computational_weights.get(job_dc.job_name, 0) * slurm_monitor.total_energy,2) - job_dc.platform_output = "" - if job_dc.job_name == job_data_dc.job_name: - job_data_dc.energy = job_dc.energy - result.append(job_dc) - job_data_dc = self.set_job_data_dc_as_processed(job_data_dc, slurm_monitor.original_input) - result.append(job_data_dc) - return result - except Exception as exp: - Logging("strategies", self.historiclog_dir_path).log("GeneralizedWrapperDistributionStrategy failed for {0}. Using ssh_output: {1}. Exception message: {2}".format(job_data_dc.job_name, slurm_monitor.original_input, str(exp)), - traceback.format_exc()) - job_data_dc = self.set_job_data_dc_as_process_failed(job_data_dc, slurm_monitor.original_input) - return [job_data_dc] - -class TwoDimWrapperDistributionStrategy(Strategy): - - def __init__(self, historiclog_dir_path=DEFAULT_HISTORICAL_LOGS_DIR): - super(TwoDimWrapperDistributionStrategy, self).__init__(historiclog_dir_path=historiclog_dir_path) - - def apply_distribution(self, job_data_dc, job_data_dcs_in_wrapper, slurm_monitor): - try: - result = [] - self.jobs_per_level = self.get_jobs_per_level(job_data_dcs_in_wrapper) - if len(self.jobs_per_level) != slurm_monitor.step_count: - return [] - comp_weight_per_level = self.get_comp_weight_per_level(self.jobs_per_level) - level_energy = [] - for i, step in enumerate(slurm_monitor.steps): - level_energy.append(step.energy + comp_weight_per_level[i] * slurm_monitor.extern.energy) - for i, jobs in enumerate(self.jobs_per_level): - weights = self.get_comp_weight_per_group_of_job_dcs(jobs) - for j, job_dc in enumerate(jobs): - job_dc.energy = round(level_energy[i] * weights[j], 2) - if job_dc.job_name == job_data_dc.job_name: - job_data_dc.energy = job_dc.energy - result.append(job_dc) - job_data_dc = self.set_job_data_dc_as_processed(job_data_dc, slurm_monitor.original_input) - result.append(job_data_dc) - return result - except Exception as exp: - Logging("strategies", self.historiclog_dir_path).log("TwoDimWrapperDistributionStrategy failed for {0}. Using ssh_output: {1}. Exception message: {2}".format(job_data_dc.job_name, slurm_monitor.original_input, str(exp)), - traceback.format_exc()) - job_data_dc = self.set_job_data_dc_as_process_failed(job_data_dc, slurm_monitor.original_input) - return [job_data_dc] - - def get_jobs_per_level(self, job_data_dcs_in_wrapper): - """ List of Lists, index of list is the level. """ - job_name_to_object = {job.job_name: job for job in job_data_dcs_in_wrapper} - levels = [] - roots_dcs = self._get_roots(job_data_dcs_in_wrapper) - levels.append(roots_dcs) - next_level = self.get_level(roots_dcs, job_name_to_object) - while len(next_level) > 0: - levels.append([job for job in next_level]) - next_level = self.get_level(next_level, job_name_to_object) - return levels - - def _get_roots(self, job_data_dcs_in_wrapper): - children_names = self._get_all_children(job_data_dcs_in_wrapper) - return [job for job in job_data_dcs_in_wrapper if job.job_name not in children_names] - - def _get_all_children(self, job_data_dcs_in_wrapper): - result = [] - for job_dc in job_data_dcs_in_wrapper: - result.extend(job_dc.children_list) - return result - - def get_comp_weight_per_group_of_job_dcs(self, jobs): - total = sum(job.computational_weight for job in jobs) - return [round(job.computational_weight/total, 4) for job in jobs] - - def get_comp_weight_per_level(self, jobs_per_level): - level_weight = [] - total_weight = 0 - for jobs in jobs_per_level: - computational_weight = sum(job.computational_weight for job in jobs) - total_weight += computational_weight - level_weight.append(computational_weight) - return [round(weight/total_weight, 4) for weight in level_weight] - - def get_level(self, previous_level_dcs, job_name_to_object): - children_names = [] - for job_dc in previous_level_dcs: - children_names.extend(job_dc.children_list) - level_dcs = [job_name_to_object[job_name] for job_name in children_names if job_name in job_name_to_object] - return level_dcs diff --git a/autosubmit_api/persistance/experiment.py b/autosubmit_api/persistance/experiment.py index d34370e..178c1b0 100644 --- a/autosubmit_api/persistance/experiment.py +++ b/autosubmit_api/persistance/experiment.py @@ -34,12 +34,33 @@ class ExperimentPaths: @property def tmp_dir(self): + """ + tmp dir + """ return os.path.join(self.exp_dir, APIBasicConfig.LOCAL_TMP_DIR) + + @property + def tmp_log_dir(self): + """ + tmp/LOG_{expid} dir + """ + return os.path.join(self.tmp_dir, f"LOG_{self.expid}") @property - def tmp_dir(self): - return os.path.join(self.exp_dir, APIBasicConfig.LOCAL_TMP_DIR) + def tmp_as_logs_dir(self): + """ + tmp/ASLOGS dir + """ + return os.path.join(self.tmp_dir, APIBasicConfig.LOCAL_ASLOG_DIR) @property - def tmp_log_dir(self): - return os.path.join(self.tmp_dir, f"LOG_{self.expid}") \ No newline at end of file + def job_data_db(self): + return os.path.join(APIBasicConfig.JOBDATA_DIR, f"job_data_{self.expid}.db") + + @property + def structure_db(self): + return os.path.join(APIBasicConfig.STRUCTURES_DIR, f"structure_{self.expid}.db") + + @property + def graph_data_db(self): + return os.path.join(APIBasicConfig.GRAPHDATA_DIR, f"graph_data_{self.expid}.db") diff --git a/tests/experiments/as_times.db b/tests/experiments/as_times.db index 43bd6b66db7a106c2f556c68a50b9272caf871ae..cf469cc2ce4130f3744c12e19fb8d511d5d03f7d 100644 GIT binary patch delta 58 scmZp0XmFSy%{XhKj5Fh`jR{WjTxPn4=2j-gR)!{%*T^d%bFZOr0p(i{DF6Tf delta 58 scmZp0XmFSy&Db Date: Mon, 19 Feb 2024 11:24:42 +0100 Subject: [PATCH 15/18] update pysqlite3 to sqlite3 import --- CHANGELOG.md | 4 +++ autosubmit_api/app.py | 1 - autosubmit_api/database/db_common.py | 4 +-- autosubmit_api/database/db_jobdata.py | 2 +- autosubmit_api/database/db_structure.py | 4 +-- autosubmit_api/database/extended_db.py | 29 ------------------ .../database_managers/database_manager.py | 10 +++--- .../experiment_history_db_manager.py | 14 ++++----- tests/conftest.py | 14 +-------- tests/experiments/as_times.db | Bin 8192 -> 8192 bytes 10 files changed, 21 insertions(+), 61 deletions(-) delete mode 100644 autosubmit_api/database/extended_db.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ef0cfcd..2750192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ### Pre-release v4.0.0b4 - Release date: TBD +* The background task that updates the experiment status have refactored. Now, it keep the records of all the experiments +* **Major change:** `experiment_times` and `job_times` tables are removed. Background tasks related to them are also removed +* Fixed bug when performance metrics are not calculated when there is only one SIM job + ### Pre-release v4.0.0b3 - Release date: 2023-02-09 * Fix HPC value in the running endpoint diff --git a/autosubmit_api/app.py b/autosubmit_api/app.py index 6e63fa4..b191135 100644 --- a/autosubmit_api/app.py +++ b/autosubmit_api/app.py @@ -7,7 +7,6 @@ from autosubmit_api.bgtasks.scheduler import create_bind_scheduler from autosubmit_api.blueprints.v3 import create_v3_blueprint from autosubmit_api.blueprints.v4 import create_v4_blueprint from autosubmit_api.database import prepare_db -from autosubmit_api.database.extended_db import ExtendedDB from autosubmit_api.experiment import common_requests as CommonRequests from autosubmit_api.logger import get_app_logger from autosubmit_api.config.basicConfig import APIBasicConfig diff --git a/autosubmit_api/database/db_common.py b/autosubmit_api/database/db_common.py index 7b370b8..961bba6 100644 --- a/autosubmit_api/database/db_common.py +++ b/autosubmit_api/database/db_common.py @@ -22,7 +22,7 @@ Module containing functions to manage autosubmit's database. """ import os from sqlite3 import Connection, Cursor -import pysqlite3 as sqlite3 +import sqlite3 from bscearth.utils.log import Log from autosubmit_api.config.basicConfig import APIBasicConfig @@ -96,7 +96,7 @@ def open_conn(check_version=True) -> Tuple[Connection, Cursor]: return conn, cursor -def close_conn(conn, cursor): +def close_conn(conn: Connection, cursor): """ Commits changes and close connection to database diff --git a/autosubmit_api/database/db_jobdata.py b/autosubmit_api/database/db_jobdata.py index 44db432..cae28a8 100644 --- a/autosubmit_api/database/db_jobdata.py +++ b/autosubmit_api/database/db_jobdata.py @@ -21,7 +21,7 @@ import os import time import textwrap import traceback -import pysqlite3 as sqlite3 +import sqlite3 import collections import portalocker from datetime import datetime, timedelta diff --git a/autosubmit_api/database/db_structure.py b/autosubmit_api/database/db_structure.py index ea379e9..0866488 100644 --- a/autosubmit_api/database/db_structure.py +++ b/autosubmit_api/database/db_structure.py @@ -1,7 +1,7 @@ import os import textwrap import traceback -import pysqlite3 as sqlite3 +import sqlite3 from autosubmit_api.persistance.experiment import ExperimentPaths @@ -72,7 +72,7 @@ def create_connection(db_file): return None -def create_table(conn, create_table_sql): +def create_table(conn: sqlite3.Connection, create_table_sql): """ create a table from the create_table_sql statement :param conn: Connection object :param create_table_sql: a CREATE TABLE statement diff --git a/autosubmit_api/database/extended_db.py b/autosubmit_api/database/extended_db.py deleted file mode 100644 index 518941a..0000000 --- a/autosubmit_api/database/extended_db.py +++ /dev/null @@ -1,29 +0,0 @@ -from autosubmit_api.config.basicConfig import APIBasicConfig -from autosubmit_api.database.db_manager import DbManager -from autosubmit_api.database import prepare_db -from autosubmit_api.workers.populate_details.populate import DetailsProcessor - -class ExtendedDB: - def __init__(self, root_path: str, db_name: str, as_times_db_name: str) -> None: - self.root_path = root_path - self.db_name = db_name - self.main_db_manager = DbManager(root_path, db_name) - self.as_times_db_manager = DbManager(root_path, as_times_db_name) - - def prepare_db(self): - """ - Create tables and views that are required - """ - self.prepare_main_db() - prepare_db() - - - def prepare_main_db(self): - APIBasicConfig.read() - DetailsProcessor(APIBasicConfig).create_details_table_if_not_exists() - self.main_db_manager.create_view( - 'listexp', - 'select id,name,user,created,model,branch,hpc,description from experiment left join details on experiment.id = details.exp_id' - ) - - diff --git a/autosubmit_api/history/database_managers/database_manager.py b/autosubmit_api/history/database_managers/database_manager.py index 0bd01db..c7cb2d9 100644 --- a/autosubmit_api/history/database_managers/database_manager.py +++ b/autosubmit_api/history/database_managers/database_manager.py @@ -16,12 +16,12 @@ # You should have received a copy of the GNU General Public License # along with Autosubmit. If not, see . -import pysqlite3 as sqlite3 +import sqlite3 import os -from .. import utils as HUtils -from . import database_models as Models -from ...config.basicConfig import APIBasicConfig -from abc import ABCMeta, abstractmethod +from autosubmit_api.history import utils as HUtils +from autosubmit_api.history.database_managers import database_models as Models +from autosubmit_api.config.basicConfig import APIBasicConfig +from abc import ABCMeta DEFAULT_JOBDATA_DIR = os.path.join('/esarchive', 'autosubmit', 'as_metadata', 'data') DEFAULT_HISTORICAL_LOGS_DIR = os.path.join('/esarchive', 'autosubmit', 'as_metadata', 'logs') diff --git a/autosubmit_api/history/database_managers/experiment_history_db_manager.py b/autosubmit_api/history/database_managers/experiment_history_db_manager.py index f8a1e6b..1f35a92 100644 --- a/autosubmit_api/history/database_managers/experiment_history_db_manager.py +++ b/autosubmit_api/history/database_managers/experiment_history_db_manager.py @@ -15,18 +15,16 @@ # You should have received a copy of the GNU General Public License # along with Autosubmit. If not, see . -import pysqlite3 as sqlite3 import os -import traceback import textwrap from autosubmit_api.persistance.experiment import ExperimentPaths -from .. import utils as HUtils -from .. database_managers import database_models as Models -from .. data_classes.job_data import JobData -from ..data_classes.experiment_run import ExperimentRun -from ...config.basicConfig import APIBasicConfig -from .database_manager import DatabaseManager, DEFAULT_JOBDATA_DIR +from autosubmit_api.history import utils as HUtils +from autosubmit_api.history.database_managers import database_models as Models +from autosubmit_api.history.data_classes.job_data import JobData +from autosubmit_api.history.data_classes.experiment_run import ExperimentRun +from autosubmit_api.config.basicConfig import APIBasicConfig +from autosubmit_api.history.database_managers.database_manager import DatabaseManager from typing import List from collections import namedtuple diff --git a/tests/conftest.py b/tests/conftest.py index 9d3f98e..be699e0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,20 +22,8 @@ def fixture_disable_protection(monkeypatch: pytest.MonkeyPatch): @pytest.fixture def fixture_mock_basic_config(monkeypatch: pytest.MonkeyPatch): + # Get APIBasicConfig from file monkeypatch.setenv("AUTOSUBMIT_CONFIGURATION", os.path.join(FAKE_EXP_DIR, ".autosubmitrc")) - # Patch APIBasicConfig parent BasicConfig - # monkeypatch.setattr(BasicConfig, "read", custom_return_value(None)) - # monkeypatch.setattr(APIBasicConfig, "read", custom_return_value(None)) - # monkeypatch.setattr(BasicConfig, "LOCAL_ROOT_DIR", FAKE_EXP_DIR) - # monkeypatch.setattr(BasicConfig, "DB_DIR", FAKE_EXP_DIR) - # monkeypatch.setattr(BasicConfig, "DB_FILE", "autosubmit.db") - # monkeypatch.setattr( - # BasicConfig, "JOBDATA_DIR", os.path.join(FAKE_EXP_DIR, "metadata", "data") - # ) - # monkeypatch.setattr( - # BasicConfig, "DB_PATH", os.path.join(FAKE_EXP_DIR, "autosubmit.db") - # ) - # monkeypatch.setattr(BasicConfig, "AS_TIMES_DB", "as_times.db") yield APIBasicConfig diff --git a/tests/experiments/as_times.db b/tests/experiments/as_times.db index cf469cc2ce4130f3744c12e19fb8d511d5d03f7d..cc4cf74a4902e90ee22eebd09d244ca78646d699 100644 GIT binary patch delta 52 vcmZp0XmFSy&A4o$j5Fi1jR{Wj?3TKQhE_%}I-#=2j-glUK?s!`YYRmARneR)!`3k?sxU -- GitLab From 5be7b7f20ab741305018afbfd376b181a5f23b57 Mon Sep 17 00:00:00 2001 From: Bruno de Paula Kinoshita <777-bdepaula@users.noreply.earth.bsc.es> Date: Mon, 19 Feb 2024 11:28:10 +0100 Subject: [PATCH 16/18] Use dependency groups in setuptools --- README.md | 4 ++-- pytest.ini | 10 ++++++++++ setup.py | 58 +++++++++++++++++++++++++++++++++--------------------- 3 files changed, 48 insertions(+), 24 deletions(-) create mode 100644 pytest.ini diff --git a/README.md b/README.md index dc078e6..c33ed7c 100644 --- a/README.md +++ b/README.md @@ -70,13 +70,13 @@ The Autosubmit API have some configuration options that can be modified by setti ### Install pytest ```bash -pip install -U pytest pytest-cov +pip install -e .[test] ``` ### Run tests: ```bash -pytest tests/* +pytest ``` ### Run tests with coverage HTML report: diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..da75339 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,10 @@ +[pytest] +addopts = + --cov=autosubmit_api --cov-config=.coveragerc --cov-report=html +testpaths = + tests/ +doctest_optionflags = + NORMALIZE_WHITESPACE + IGNORE_EXCEPTION_DETAIL + ELLIPSIS + diff --git a/setup.py b/setup.py index 87d42ee..8eb87b2 100644 --- a/setup.py +++ b/setup.py @@ -19,6 +19,40 @@ def get_authors(): return autosubmit_api.__author__ +install_requires = [ + "Flask~=2.2.5", + "pyjwt~=2.8.0", + "requests~=2.28.1", + "flask_cors~=3.0.10", + "bscearth.utils~=0.5.2", + "pysqlite-binary", + "pydotplus~=2.0.2", + "portalocker~=2.6.0", + "networkx~=2.6.3", + "scipy~=1.7.3", + "paramiko~=2.12.0", + "python-dotenv", + "autosubmitconfigparser~=1.0.48", + "autosubmit>=3.13", + "Flask-APScheduler", + "gunicorn", + "pydantic~=2.5.2", + "SQLAlchemy~=2.0.23", + "python-cas>=1.6.0", + "Authlib>=1.3.0" +] + +# Test dependencies +test_requires = [ + "pytest", + "pytest-cov" +] + +extras_require = { + 'test': test_requires, + 'all': install_requires + test_requires +} + setup( name="autosubmit_api", version=get_version(), @@ -32,28 +66,8 @@ setup( packages=find_packages(), keywords=["autosubmit", "API"], python_requires=">=3.8", - install_requires=[ - "Flask~=2.2.5", - "pyjwt~=2.8.0", - "requests~=2.28.1", - "flask_cors~=3.0.10", - "bscearth.utils~=0.5.2", - "pysqlite-binary", - "pydotplus~=2.0.2", - "portalocker~=2.6.0", - "networkx~=2.6.3", - "scipy~=1.7.3", - "paramiko~=2.12.0", - "python-dotenv", - "autosubmitconfigparser~=1.0.48", - "autosubmit>=3.13", - "Flask-APScheduler", - "gunicorn", - "pydantic~=2.5.2", - "SQLAlchemy~=2.0.23", - "python-cas>=1.6.0", - "Authlib>=1.3.0" - ], + install_requires=install_requires, + extras_require=extras_require, include_package_data=True, package_data={"autosubmit-api": ["README", "VERSION", "LICENSE"]}, classifiers=[ -- GitLab From 5958ee1bb7a566d64dcfbcceea6f3f77c55437b1 Mon Sep 17 00:00:00 2001 From: Luiggi Tenorio Date: Thu, 22 Feb 2024 09:41:43 +0100 Subject: [PATCH 17/18] fix graph background task parser --- autosubmit_api/database/db_jobdata.py | 7 +- autosubmit_api/database/tables.py | 12 +++ tests/experiments/as_times.db | Bin 8192 -> 8192 bytes .../experiments/calculation_in_progress.lock | 0 .../metadata/graph/graph_data_a003.db | Bin 8192 -> 8192 bytes tests/test_graph.py | 75 ++++++++++++++++++ 6 files changed, 91 insertions(+), 3 deletions(-) delete mode 100644 tests/experiments/calculation_in_progress.lock create mode 100644 tests/test_graph.py diff --git a/autosubmit_api/database/db_jobdata.py b/autosubmit_api/database/db_jobdata.py index cae28a8..25a70ad 100644 --- a/autosubmit_api/database/db_jobdata.py +++ b/autosubmit_api/database/db_jobdata.py @@ -622,9 +622,10 @@ class ExperimentGraphDrawing(MainDataBase): result = graph.create('dot', format="plain") for u in result.split(b"\n"): splitList = u.split(b" ") - if len(splitList) > 1 and splitList[0] == "node": - self.coordinates.append((splitList[1], int( - float(splitList[2]) * 90), int(float(splitList[3]) * -90))) + if len(splitList) > 1 and splitList[0].decode() == "node": + + self.coordinates.append((splitList[1].decode(), int( + float(splitList[2].decode()) * 90), int(float(splitList[3].decode()) * -90))) # self.coordinates[splitList[1]] = ( # int(float(splitList[2]) * 90), int(float(splitList[3]) * -90)) self.insert_coordinates() diff --git a/autosubmit_api/database/tables.py b/autosubmit_api/database/tables.py index e4929c6..7a42573 100644 --- a/autosubmit_api/database/tables.py +++ b/autosubmit_api/database/tables.py @@ -37,3 +37,15 @@ experiment_status_table = Table( Column("seconds_diff", Integer, nullable=False), Column("modified", Text, nullable=False), ) + + +# Graph Data TABLES + +graph_data_table = Table( + "experiment_graph_draw", + metadata_obj, + Column("id", Integer, primary_key=True), + Column("job_name", Text, nullable=False), + Column("x", Integer, nullable=False), + Column("y", Integer, nullable=False), +) diff --git a/tests/experiments/as_times.db b/tests/experiments/as_times.db index cc4cf74a4902e90ee22eebd09d244ca78646d699..4c5bbb7fe7429ad841f559522f44241d10055b91 100644 GIT binary patch delta 61 scmZp0XmFSy&A4Wwj5Fh!jR}tO+(t&a29{RFMph=Klh??j@~@%t0T?F_-~a#s delta 61 scmZp0XmFSy&A4o$j5Fi1jR}tO+=iCAhK5!~CRTN9B*V`WMp7sU>Kj4Y!Dw3>y;2Y zD}y3bjkAxdqaVYp2|{eF4Dw7&V0{7p!66K@{+lVVvNBjh#Zb&LiVybmWtjEvjsgps ziVT=_hEOF2>0 C6$;Y; diff --git a/tests/test_graph.py b/tests/test_graph.py new file mode 100644 index 0000000..0dc5bed --- /dev/null +++ b/tests/test_graph.py @@ -0,0 +1,75 @@ +import os + +from sqlalchemy import create_engine +from autosubmit_api.builders.configuration_facade_builder import ( + AutosubmitConfigurationFacadeBuilder, + ConfigurationFacadeDirector, +) +from autosubmit_api.builders.joblist_loader_builder import ( + JobListLoaderBuilder, + JobListLoaderDirector, +) +from autosubmit_api.database import tables +from autosubmit_api.database.db_jobdata import ExperimentGraphDrawing +from autosubmit_api.monitor.monitor import Monitor +from autosubmit_api.persistance.experiment import ExperimentPaths + + +class TestPopulateDB: + + def test_monitor_dot(self, fixture_mock_basic_config): + expid = "a003" + job_list_loader = JobListLoaderDirector( + JobListLoaderBuilder(expid) + ).build_loaded_joblist_loader() + + monitor = Monitor() + graph = monitor.create_tree_list( + expid, + job_list_loader.jobs, + None, + dict(), + False, + job_list_loader.job_dictionary, + ) + assert graph + + result = graph.create("dot", format="plain") + assert result and len(result) > 0 + + def test_process_graph(self, fixture_mock_basic_config): + expid = "a003" + experimentGraphDrawing = ExperimentGraphDrawing(expid) + job_list_loader = JobListLoaderDirector( + JobListLoaderBuilder(expid) + ).build_loaded_joblist_loader() + + autosubmit_configuration_facade = ConfigurationFacadeDirector( + AutosubmitConfigurationFacadeBuilder(expid) + ).build_autosubmit_configuration_facade() + + exp_paths = ExperimentPaths(expid) + with create_engine( + f"sqlite:///{ os.path.abspath(exp_paths.graph_data_db)}" + ).connect() as conn: + conn.execute(tables.graph_data_table.delete()) + conn.commit() + + experimentGraphDrawing.calculate_drawing( + allJobs=job_list_loader.jobs, + independent=False, + num_chunks=autosubmit_configuration_facade.chunk_size, + job_dictionary=job_list_loader.job_dictionary, + ) + + assert ( + experimentGraphDrawing.coordinates + and len(experimentGraphDrawing.coordinates) == 8 + ) + + rows = conn.execute(tables.graph_data_table.select()).all() + + assert len(rows) == 8 + for job in rows: + job_name: str = job.job_name + assert job_name.startswith(expid) -- GitLab From 973d6da3d889a1fc16aade429772264d8db48ea5 Mon Sep 17 00:00:00 2001 From: Luiggi Tenorio Date: Fri, 23 Feb 2024 11:38:34 +0100 Subject: [PATCH 18/18] update changelog --- CHANGELOG.md | 12 ++++++++---- tests/experiments/as_times.db | Bin 8192 -> 8192 bytes .../metadata/graph/graph_data_a003.db | Bin 8192 -> 8192 bytes 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2750192..e491adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,16 @@ # CHANGELOG -### Pre-release v4.0.0b4 - Release date: TBD +### Pre-release v4.0.0b4 - Release date: 2024-02-23 -* The background task that updates the experiment status have refactored. Now, it keep the records of all the experiments -* **Major change:** `experiment_times` and `job_times` tables are removed. Background tasks related to them are also removed +* The background task that updates the experiment status has been refactored. Now, it keeps the records of all the experiments +* **Major change:** Removed `experiment_times` and `job_times` tables and background tasks related to them * Fixed bug when performance metrics are not calculated when there is only one SIM job +* Multiple tests have been added +* Testing module configuration fixtures have been fixed +* A lot of dead code has been removed +* Fix the decoding issue on graph coordinates generation on the background task -### Pre-release v4.0.0b3 - Release date: 2023-02-09 +### Pre-release v4.0.0b3 - Release date: 2024-02-09 * Fix HPC value in the running endpoint * **Major change:** Updated all route names. Versioning path prefix is included: diff --git a/tests/experiments/as_times.db b/tests/experiments/as_times.db index 4c5bbb7fe7429ad841f559522f44241d10055b91..0f8be2ec756ee0c158c810bbb21ecc5baa5089d0 100644 GIT binary patch delta 58 scmZp0XmFSy&A5J|j5FiPEmR80_RwkyC*T^d%bFZOr0qc|xN&o-= diff --git a/tests/experiments/metadata/graph/graph_data_a003.db b/tests/experiments/metadata/graph/graph_data_a003.db index 5083d17c32f839b7956f4a0380e12877978e504b..ed0362b552850f04244080a7fe51840c7fdaf54c 100755 GIT binary patch delta 17 YcmZp0XmFSy%_uuj#+gxeW5NP?0519kC;$Ke delta 17 YcmZp0XmFSy%_ucd#+gxSW5NP?04~i1A^-pY -- GitLab