diff --git a/antarest/core/config.py b/antarest/core/config.py index 2ba7a72745..d7b7ed1243 100644 --- a/antarest/core/config.py +++ b/antarest/core/config.py @@ -197,6 +197,40 @@ def __post_init__(self) -> None: raise ValueError(msg) +@dataclass(frozen=True) +class TimeLimitConfig: + """ + The TimeLimitConfig class is designed to manage the configuration of the time limit for a job. + + Attributes: + min: int: minimum allowed value for the time limit (in hours). + default: int: default value for the time limit (in hours). + max: int: maximum allowed value for the time limit (in hours). + """ + + min: int = 1 + default: int = 48 + max: int = 48 + + def to_json(self) -> Dict[str, int]: + """ + Retrieves the time limit parameters, returning a dictionary containing the values "min" + (minimum allowed value), "defaultValue" (default value), and "max" (maximum allowed value) + + Returns: + A dictionary: `{"min": min, "defaultValue": default, "max": max}`. + Because ReactJs Material UI expects "min", "defaultValue" and "max" keys. + """ + return {"min": self.min, "defaultValue": self.default, "max": self.max} + + def __post_init__(self) -> None: + """validation of CPU configuration""" + if 1 <= self.min <= self.default <= self.max: + return + msg = f"Invalid configuration: 1 <= {self.min=} <= {self.default=} <= {self.max=}" + raise ValueError(msg) + + @dataclass(frozen=True) class LocalConfig: """Sub config object dedicated to launcher module (local)""" @@ -204,6 +238,7 @@ class LocalConfig: binaries: Dict[str, Path] = field(default_factory=dict) enable_nb_cores_detection: bool = True nb_cores: NbCoresConfig = NbCoresConfig() + time_limit: TimeLimitConfig = TimeLimitConfig() @classmethod def from_dict(cls, data: JSON) -> "LocalConfig": @@ -251,7 +286,7 @@ class SlurmConfig: key_password: str = "" password: str = "" default_wait_time: int = 0 - default_time_limit: int = 0 + time_limit: TimeLimitConfig = TimeLimitConfig() default_json_db_name: str = "" slurm_script_path: str = "" partition: str = "" @@ -279,6 +314,9 @@ def from_dict(cls, data: JSON) -> "SlurmConfig": nb_cores["max"] = max(nb_cores["max"], nb_cores["default"]) if enable_nb_cores_detection: nb_cores.update(cls._autodetect_nb_cores()) + # In the configuration file, the default time limit is in seconds, so we convert it to hours + max_time_limit = data.get("default_time_limit", defaults.time_limit.max * 3600) // 3600 + time_limit = TimeLimitConfig(min=1, default=max_time_limit, max=max_time_limit) return cls( local_workspace=Path(data.get("local_workspace", defaults.local_workspace)), username=data.get("username", defaults.username), @@ -288,7 +326,7 @@ def from_dict(cls, data: JSON) -> "SlurmConfig": key_password=data.get("key_password", defaults.key_password), password=data.get("password", defaults.password), default_wait_time=data.get("default_wait_time", defaults.default_wait_time), - default_time_limit=data.get("default_time_limit", defaults.default_time_limit), + time_limit=time_limit, default_json_db_name=data.get("default_json_db_name", defaults.default_json_db_name), slurm_script_path=data.get("slurm_script_path", defaults.slurm_script_path), partition=data.get("partition", defaults.partition), @@ -308,7 +346,7 @@ def _autodetect_nb_cores(cls) -> Dict[str, int]: class InvalidConfigurationError(Exception): """ - Exception raised when an attempt is made to retrieve the number of cores + Exception raised when an attempt is made to retrieve a property of a launcher that doesn't exist in the configuration. """ @@ -371,6 +409,28 @@ def get_nb_cores(self, launcher: str) -> "NbCoresConfig": raise InvalidConfigurationError(launcher) return launcher_config.nb_cores + def get_time_limit(self, launcher: str) -> TimeLimitConfig: + """ + Retrieve the time limit for a job of the given launcher: "local" or "slurm". + If "default" is specified, retrieve the configuration of the default launcher. + + Args: + launcher: type of launcher "local", "slurm" or "default". + + Returns: + Time limit for a job of the given launcher (in seconds). + + Raises: + InvalidConfigurationError: Exception raised when an attempt is made to retrieve + a property of a launcher that doesn't exist in the configuration. + """ + config_map = {"local": self.local, "slurm": self.slurm} + config_map["default"] = config_map[self.default] + launcher_config = config_map.get(launcher) + if launcher_config is None: + raise InvalidConfigurationError(launcher) + return launcher_config.time_limit + @dataclass(frozen=True) class LoggingConfig: diff --git a/antarest/launcher/adapters/slurm_launcher/slurm_launcher.py b/antarest/launcher/adapters/slurm_launcher/slurm_launcher.py index 6aba6f21b3..714ce6d0e5 100644 --- a/antarest/launcher/adapters/slurm_launcher/slurm_launcher.py +++ b/antarest/launcher/adapters/slurm_launcher/slurm_launcher.py @@ -7,9 +7,8 @@ import threading import time import traceback -from copy import deepcopy +import typing as t from pathlib import Path -from typing import Awaitable, Callable, Dict, List, Optional, cast from antareslauncher.data_repo.data_repo_tinydb import DataRepoTinydb from antareslauncher.main import MainParameters, run_with @@ -17,7 +16,7 @@ from antareslauncher.study_dto import StudyDTO from filelock import FileLock -from antarest.core.config import Config, SlurmConfig +from antarest.core.config import Config, NbCoresConfig, SlurmConfig, TimeLimitConfig from antarest.core.interfaces.cache import ICache from antarest.core.interfaces.eventbus import Event, EventType, IEventBus from antarest.core.model import PermissionInfo, PublicMode @@ -32,8 +31,6 @@ logger = logging.getLogger(__name__) logging.getLogger("paramiko").setLevel("WARN") -MAX_TIME_LIMIT = 864000 -MIN_TIME_LIMIT = 3600 WORKSPACE_LOCK_FILE_NAME = ".lock" LOCK_FILE_NAME = "slurm_launcher_init.lock" LOG_DIR_NAME = "LOGS" @@ -49,6 +46,85 @@ class JobIdNotFound(Exception): pass +class LauncherArgs(argparse.Namespace): + """ + Launcher arguments to be passed to `antareslauncher.main.run_with`. + """ + + def __init__(self, launcher_args: argparse.Namespace): + """ + Create a copy of the `argparse.Namespace` object. + + Args: + launcher_args: The arguments to copy. + """ + super().__init__() + + # known arguments + self.other_options: t.Optional[str] = None + self.xpansion_mode: t.Optional[str] = None + self.time_limit: int = 0 + self.n_cpu: int = 0 + self.post_processing: bool = False + + args = vars(launcher_args) + for key, value in args.items(): + setattr(self, key, value) + + def _append_other_option(self, option: str) -> None: + self.other_options = f"{self.other_options} {option}" if self.other_options else option + + def apply_other_options(self, launcher_params: LauncherParametersDTO) -> None: + other_options = launcher_params.other_options or "" + options = other_options.split() if other_options else [] + options = [re.sub("[^a-zA-Z0-9_,-]", "", opt) for opt in options] + self.other_options = " ".join(options) + + def apply_xpansion_mode(self, launcher_params: LauncherParametersDTO) -> None: + if launcher_params.xpansion: # not None and not False + self.xpansion_mode = {True: "r", False: "cpp"}[launcher_params.xpansion_r_version] + if ( + isinstance(launcher_params.xpansion, XpansionParametersDTO) + and launcher_params.xpansion.sensitivity_mode + ): + self._append_other_option("xpansion_sensitivity") + + def apply_time_limit(self, launcher_params: LauncherParametersDTO, time_limit_cfg: TimeLimitConfig) -> None: + # The `time_limit` parameter could be `None`, in that case, the default value is used. + min_allowed = time_limit_cfg.min * 3600 + max_allowed = time_limit_cfg.max * 3600 + time_limit = launcher_params.time_limit or time_limit_cfg.default * 3600 + time_limit = min(max(time_limit, min_allowed), max_allowed) + if self.time_limit != time_limit: + logger.warning( + f"Invalid slurm launcher time_limit ({time_limit})," + f" should be between {min_allowed} and {max_allowed} (in seconds)" + ) + self.time_limit = time_limit + + def apply_nb_cpu(self, launcher_params: LauncherParametersDTO, nb_cores_cfg: NbCoresConfig) -> None: + nb_cpu = launcher_params.nb_cpu + if nb_cpu is not None: + if nb_cores_cfg.min <= nb_cpu <= nb_cores_cfg.max: + self.n_cpu = nb_cpu + else: + logger.warning( + f"Invalid slurm launcher nb_cpu ({nb_cpu})," + f" should be between {nb_cores_cfg.min} and {nb_cores_cfg.max}" + ) + self.n_cpu = nb_cores_cfg.default + + def apply_post_processing(self, launcher_params: LauncherParametersDTO) -> None: + post_processing = launcher_params.post_processing + if post_processing is not None: + self.post_processing = post_processing + + def apply_adequacy_patch(self, launcher_params: LauncherParametersDTO) -> None: + adequacy_patch = launcher_params.adequacy_patch + if adequacy_patch is not None: + self.post_processing = True + + class SlurmLauncher(AbstractLauncher): def __init__( self, @@ -67,8 +143,8 @@ def __init__( self.check_state: bool = True self.event_bus = event_bus self.event_bus.add_listener(self._create_event_listener(), [EventType.STUDY_JOB_CANCEL_REQUEST]) - self.thread: Optional[threading.Thread] = None - self.job_list: List[str] = [] + self.thread: t.Optional[threading.Thread] = None + self.job_list: t.List[str] = [] self._check_config() self.antares_launcher_lock = threading.Lock() @@ -148,10 +224,10 @@ def stop(self) -> None: self.thread = None logger.info("slurm_launcher loop stopped") - def _init_launcher_arguments(self, local_workspace: Optional[Path] = None) -> argparse.Namespace: + def _init_launcher_arguments(self, local_workspace: t.Optional[Path] = None) -> argparse.Namespace: main_options_parameters = ParserParameters( default_wait_time=self.slurm_config.default_wait_time, - default_time_limit=self.slurm_config.default_time_limit, + default_time_limit=self.slurm_config.time_limit.default * 3600, default_n_cpu=self.slurm_config.nb_cores.default, studies_in_dir=str((Path(local_workspace or self.slurm_config.local_workspace) / STUDIES_INPUT_DIR_NAME)), log_dir=str((Path(self.slurm_config.local_workspace) / LOG_DIR_NAME)), @@ -165,7 +241,7 @@ def _init_launcher_arguments(self, local_workspace: Optional[Path] = None) -> ar parser.add_basic_arguments() parser.add_advanced_arguments() - arguments = cast(argparse.Namespace, parser.parse_args([])) + arguments = t.cast(argparse.Namespace, parser.parse_args([])) arguments.wait_mode = False arguments.check_queue = False arguments.json_ssh_config = None @@ -177,7 +253,7 @@ def _init_launcher_arguments(self, local_workspace: Optional[Path] = None) -> ar return arguments - def _init_launcher_parameters(self, local_workspace: Optional[Path] = None) -> MainParameters: + def _init_launcher_parameters(self, local_workspace: t.Optional[Path] = None) -> MainParameters: return MainParameters( json_dir=local_workspace or self.slurm_config.local_workspace, default_json_db_name=self.slurm_config.default_json_db_name, @@ -206,13 +282,13 @@ def _delete_workspace_file(self, study_path: Path) -> None: def _import_study_output( self, job_id: str, - xpansion_mode: Optional[str] = None, - log_dir: Optional[str] = None, - ) -> Optional[str]: + xpansion_mode: t.Optional[str] = None, + log_dir: t.Optional[str] = None, + ) -> t.Optional[str]: if xpansion_mode is not None: self._import_xpansion_result(job_id, xpansion_mode) - launcher_logs: Dict[str, List[Path]] = {} + launcher_logs: t.Dict[str, t.List[Path]] = {} if log_dir is not None: launcher_logs = { log_name: log_path @@ -374,17 +450,17 @@ def _handle_success(self, study: StudyDTO) -> None: self.callbacks.update_status(study.name, JobStatus.SUCCESS, None, output_id) @staticmethod - def _get_log_path(study: StudyDTO, log_type: LogType = LogType.STDOUT) -> Optional[Path]: + def _get_log_path(study: StudyDTO, log_type: LogType = LogType.STDOUT) -> t.Optional[Path]: log_dir = Path(study.job_log_dir) return SlurmLauncher._get_log_path_from_log_dir(log_dir, log_type) @staticmethod - def _find_log_dir(base_log_dir: Path, job_id: str) -> Optional[Path]: + def _find_log_dir(base_log_dir: Path, job_id: str) -> t.Optional[Path]: pattern = f"{job_id}*" return next(iter(base_log_dir.glob(pattern)), None) @staticmethod - def _get_log_path_from_log_dir(log_dir: Path, log_type: LogType = LogType.STDOUT) -> Optional[Path]: + def _get_log_path_from_log_dir(log_dir: Path, log_type: LogType = LogType.STDOUT) -> t.Optional[Path]: pattern = { LogType.STDOUT: "antares-out-*", LogType.STDERR: "antares-err-*", @@ -496,54 +572,15 @@ def _apply_params(self, launcher_params: LauncherParametersDTO) -> argparse.Name to launch a simulation using Antares Launcher. """ if launcher_params: - launcher_args = deepcopy(self.launcher_args) - - if launcher_params.other_options: - options = launcher_params.other_options.split() - other_options = [re.sub("[^a-zA-Z0-9_,-]", "", opt) for opt in options] - else: - other_options = [] - - # launcher_params.xpansion can be an `XpansionParametersDTO`, a bool or `None` - if launcher_params.xpansion: # not None and not False - launcher_args.xpansion_mode = {True: "r", False: "cpp"}[launcher_params.xpansion_r_version] - if ( - isinstance(launcher_params.xpansion, XpansionParametersDTO) - and launcher_params.xpansion.sensitivity_mode - ): - other_options.append("xpansion_sensitivity") - - # The `time_limit` parameter could be `None`, in that case, the default value is used. - time_limit = launcher_params.time_limit or MIN_TIME_LIMIT - time_limit = min(max(time_limit, MIN_TIME_LIMIT), MAX_TIME_LIMIT) - if launcher_args.time_limit != time_limit: - logger.warning( - f"Invalid slurm launcher time_limit ({time_limit})," - f" should be between {MIN_TIME_LIMIT} and {MAX_TIME_LIMIT}" - ) - launcher_args.time_limit = time_limit - - post_processing = launcher_params.post_processing - if post_processing is not None: - launcher_args.post_processing = post_processing - - nb_cpu = launcher_params.nb_cpu - if nb_cpu is not None: - nb_cores = self.slurm_config.nb_cores - if nb_cores.min <= nb_cpu <= nb_cores.max: - launcher_args.n_cpu = nb_cpu - else: - logger.warning( - f"Invalid slurm launcher nb_cpu ({nb_cpu})," - f" should be between {nb_cores.min} and {nb_cores.max}" - ) - launcher_args.n_cpu = nb_cores.default - - if launcher_params.adequacy_patch is not None: # the adequacy patch can be an empty object - launcher_args.post_processing = True - - launcher_args.other_options = " ".join(other_options) + launcher_args = LauncherArgs(self.launcher_args) + launcher_args.apply_other_options(launcher_params) + launcher_args.apply_xpansion_mode(launcher_params) + launcher_args.apply_time_limit(launcher_params, self.slurm_config.time_limit) + launcher_args.apply_post_processing(launcher_params) + launcher_args.apply_nb_cpu(launcher_params, self.slurm_config.nb_cores) + launcher_args.apply_adequacy_patch(launcher_params) return launcher_args + return self.launcher_args def run_study( @@ -561,8 +598,8 @@ def run_study( ) thread.start() - def get_log(self, job_id: str, log_type: LogType) -> Optional[str]: - log_path: Optional[Path] = None + def get_log(self, job_id: str, log_type: LogType) -> t.Optional[str]: + log_path: t.Optional[Path] = None for study in self.data_repo_tinydb.get_list_of_studies(): if study.name == job_id: log_path = SlurmLauncher._get_log_path(study, log_type) @@ -572,14 +609,14 @@ def get_log(self, job_id: str, log_type: LogType) -> Optional[str]: log_path = SlurmLauncher._get_log_path_from_log_dir(log_dir, log_type) return log_path.read_text() if log_path else None - def _create_event_listener(self) -> Callable[[Event], Awaitable[None]]: + def _create_event_listener(self) -> t.Callable[[Event], t.Awaitable[None]]: async def _listen_to_kill_job(event: Event) -> None: self.kill_job(event.payload, dispatch=False) return _listen_to_kill_job def kill_job(self, job_id: str, dispatch: bool = True) -> None: - launcher_args = deepcopy(self.launcher_args) + launcher_args = LauncherArgs(self.launcher_args) for study in self.data_repo_tinydb.get_list_of_studies(): if study.name == job_id: launcher_args.job_id_to_kill = study.job_id diff --git a/antarest/launcher/web.py b/antarest/launcher/web.py index 14eb39aee2..9635c664f1 100644 --- a/antarest/launcher/web.py +++ b/antarest/launcher/web.py @@ -41,6 +41,25 @@ def __init__(self, solver: str) -> None: ) +LauncherQuery = Query( + "default", + examples={ + "Default launcher": { + "description": "Default solver (auto-detected)", + "value": "default", + }, + "SLURM launcher": { + "description": "SLURM solver configuration", + "value": "slurm", + }, + "Local launcher": { + "description": "Local solver configuration", + "value": "local", + }, + }, +) + + def create_launcher_api(service: LauncherService, config: Config) -> APIRouter: bp = APIRouter(prefix="/v1/launcher") @@ -214,25 +233,7 @@ def get_load() -> LauncherLoadDTO: summary="Get list of supported solver versions", response_model=List[str], ) - def get_solver_versions( - solver: str = Query( - "default", - examples={ - "Default solver": { - "description": "Get the solver versions of the default configuration", - "value": "default", - }, - "SLURM solver": { - "description": "Get the solver versions of the SLURM server if available", - "value": "slurm", - }, - "Local solver": { - "description": "Get the solver versions of the Local server if available", - "value": "local", - }, - }, - ), - ) -> List[str]: + def get_solver_versions(solver: str = LauncherQuery) -> List[str]: """ Get list of supported solver versions defined in the configuration. @@ -251,25 +252,7 @@ def get_solver_versions( summary="Retrieving Min, Default, and Max Core Count", response_model=Dict[str, int], ) - def get_nb_cores( - launcher: str = Query( - "default", - examples={ - "Default launcher": { - "description": "Min, Default, and Max Core Count", - "value": "default", - }, - "SLURM launcher": { - "description": "Min, Default, and Max Core Count", - "value": "slurm", - }, - "Local launcher": { - "description": "Min, Default, and Max Core Count", - "value": "local", - }, - }, - ), - ) -> Dict[str, int]: + def get_nb_cores(launcher: str = LauncherQuery) -> Dict[str, int]: """ Retrieve the numer of cores of the launcher. @@ -288,4 +271,31 @@ def get_nb_cores( except InvalidConfigurationError: raise UnknownSolverConfig(launcher) + # noinspection SpellCheckingInspection + @bp.get( + "/time-limit", + tags=[APITag.launcher], + summary="Retrieve the time limit for a job (in hours)", + ) + def get_time_limit(launcher: str = LauncherQuery) -> Dict[str, int]: + """ + Retrieve the time limit for a job (in hours) of the given launcher: "local" or "slurm". + + If a jobs exceed this time limit, SLURM kills the job and it is considered failed. + + Args: + - `launcher`: name of the configuration to read: "slurm" or "local". + If "default" is specified, retrieve the configuration of the default launcher. + + Returns: + - "min": min allowed time limit + - "defaultValue": default time limit + - "max": max allowed time limit + """ + logger.info(f"Fetching the time limit for the '{launcher}' configuration") + try: + return service.config.launcher.get_time_limit(launcher).to_json() + except InvalidConfigurationError: + raise UnknownSolverConfig(launcher) + return bp diff --git a/tests/integration/launcher_blueprint/test_launcher_local.py b/tests/integration/launcher_blueprint/test_launcher_local.py index 7244fba8ee..08d1175889 100644 --- a/tests/integration/launcher_blueprint/test_launcher_local.py +++ b/tests/integration/launcher_blueprint/test_launcher_local.py @@ -3,7 +3,7 @@ import pytest from starlette.testclient import TestClient -from antarest.core.config import LocalConfig +from antarest.core.config import LocalConfig, TimeLimitConfig # noinspection SpellCheckingInspection @@ -68,3 +68,57 @@ def test_get_launcher_nb_cores( "description": "Unknown solver configuration: 'unknown'", "exception": "UnknownSolverConfig", } + + def test_get_launcher_time_limit( + self, + client: TestClient, + user_access_token: str, + ) -> None: + res = client.get( + "/v1/launcher/time-limit", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + res.raise_for_status() + actual = res.json() + expected = TimeLimitConfig().to_json() + assert actual == expected + + res = client.get( + "/v1/launcher/time-limit?launcher=default", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + res.raise_for_status() + actual = res.json() + assert actual == expected + + res = client.get( + "/v1/launcher/time-limit?launcher=local", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + res.raise_for_status() + actual = res.json() + assert actual == expected + + # Check that the endpoint raise an exception when the "slurm" launcher is requested. + res = client.get( + "/v1/launcher/time-limit?launcher=slurm", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == http.HTTPStatus.UNPROCESSABLE_ENTITY, res.json() + actual = res.json() + assert actual == { + "description": "Unknown solver configuration: 'slurm'", + "exception": "UnknownSolverConfig", + } + + # Check that the endpoint raise an exception when an unknown launcher is requested. + res = client.get( + "/v1/launcher/time-limit?launcher=unknown", + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + assert res.status_code == http.HTTPStatus.UNPROCESSABLE_ENTITY, res.json() + actual = res.json() + assert actual == { + "description": "Unknown solver configuration: 'unknown'", + "exception": "UnknownSolverConfig", + } diff --git a/tests/launcher/test_slurm_launcher.py b/tests/launcher/test_slurm_launcher.py index e1c69f63d4..9476f86be8 100644 --- a/tests/launcher/test_slurm_launcher.py +++ b/tests/launcher/test_slurm_launcher.py @@ -1,4 +1,5 @@ import os +import random import shutil import textwrap import uuid @@ -11,12 +12,10 @@ from antareslauncher.main import MainParameters from antareslauncher.study_dto import StudyDTO -from antarest.core.config import Config, LauncherConfig, NbCoresConfig, SlurmConfig +from antarest.core.config import Config, LauncherConfig, NbCoresConfig, SlurmConfig, TimeLimitConfig from antarest.launcher.adapters.abstractlauncher import LauncherInitException from antarest.launcher.adapters.slurm_launcher.slurm_launcher import ( LOG_DIR_NAME, - MAX_TIME_LIMIT, - MIN_TIME_LIMIT, WORKSPACE_LOCK_FILE_NAME, SlurmLauncher, VersionNotSupportedError, @@ -36,7 +35,7 @@ def launcher_config(tmp_path: Path) -> Config: "key_password": "password", "password": "password", "default_wait_time": 10, - "default_time_limit": MAX_TIME_LIMIT, + "default_time_limit": 24 * 3600, # 24 hours "default_json_db_name": "antares.db", "slurm_script_path": "/path/to/slurm/launcher.sh", "partition": "fake_partition", @@ -68,7 +67,7 @@ def test_init_slurm_launcher_arguments(tmp_path: Path) -> None: launcher=LauncherConfig( slurm=SlurmConfig( default_wait_time=42, - default_time_limit=43, + time_limit=TimeLimitConfig(), nb_cores=NbCoresConfig(min=1, default=30, max=36), local_workspace=tmp_path, ) @@ -183,7 +182,7 @@ def test_extra_parameters(launcher_config: Config) -> None: slurm_config = slurm_launcher.config.launcher.slurm assert slurm_config is not None assert launcher_params.n_cpu == slurm_config.nb_cores.default - assert launcher_params.time_limit == slurm_config.default_time_limit + assert launcher_params.time_limit == slurm_config.time_limit.default * 3600 assert not launcher_params.xpansion_mode assert not launcher_params.post_processing @@ -202,17 +201,19 @@ def test_extra_parameters(launcher_config: Config) -> None: launcher_params = apply_params(LauncherParametersDTO(nb_cpu=999)) assert launcher_params.n_cpu == slurm_config.nb_cores.default # out of range + _config_time_limit = launcher_config.launcher.slurm.time_limit launcher_params = apply_params(LauncherParametersDTO.construct(time_limit=None)) - assert launcher_params.time_limit == MIN_TIME_LIMIT + assert launcher_params.time_limit == _config_time_limit.default * 3600 - launcher_params = apply_params(LauncherParametersDTO(time_limit=10)) - assert launcher_params.time_limit == MIN_TIME_LIMIT + launcher_params = apply_params(LauncherParametersDTO(time_limit=10)) # 10 seconds + assert launcher_params.time_limit == _config_time_limit.min * 3600 launcher_params = apply_params(LauncherParametersDTO(time_limit=999999999)) - assert launcher_params.time_limit == MAX_TIME_LIMIT + assert launcher_params.time_limit == _config_time_limit.max * 3600 - launcher_params = apply_params(LauncherParametersDTO(time_limit=99999)) - assert launcher_params.time_limit == 99999 + _time_limit_sec = random.randrange(_config_time_limit.min, _config_time_limit.max) * 3600 + launcher_params = apply_params(LauncherParametersDTO(time_limit=_time_limit_sec)) + assert launcher_params.time_limit == _time_limit_sec launcher_params = apply_params(LauncherParametersDTO(xpansion=False)) assert launcher_params.xpansion_mode is None @@ -269,16 +270,13 @@ def test_run_study( cache=Mock(), ) - study_uuid = "study_uuid" - argument = Mock() - argument.studies_in = launcher_config.launcher.slurm.local_workspace / "studies_in" - slurm_launcher.launcher_args = argument slurm_launcher._clean_local_workspace = Mock() slurm_launcher.start = Mock() slurm_launcher._delete_workspace_file = Mock() job_id = str(uuid.uuid4()) - study_dir = argument.studies_in / job_id + studies_in = launcher_config.launcher.slurm.local_workspace / "studies_in" + study_dir = studies_in / job_id study_dir.mkdir(parents=True) study_antares_path = study_dir.joinpath("study.antares") study_antares_path.write_text( @@ -298,6 +296,7 @@ def call_launcher_mock(arguments: Namespace, parameters: MainParameters): slurm_launcher._call_launcher = call_launcher_mock # When the launcher is called + study_uuid = str(uuid.uuid4()) slurm_launcher._run_study(study_uuid, job_id, LauncherParametersDTO(), str(version)) # Check the results @@ -468,7 +467,7 @@ def test_kill_job( output_dir=str(tmp_path / "OUTPUT"), post_processing=False, studies_in=str(tmp_path / "STUDIES_IN"), - time_limit=slurm_config.default_time_limit, + time_limit=slurm_config.time_limit.default * 3600, version=False, wait_mode=False, wait_time=slurm_config.default_wait_time, diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index ccfac647f2..bc124b50bf 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -564,6 +564,7 @@ "study.error.listOutputs": "Failed to retrieve output list", "study.error.launcherVersions": "Failed to retrieve launcher versions", "study.error.launcherCores": "Failed to retrieve launcher number of cores", + "study.error.launcherTimeLimit": "Failed to retrieve launcher time limit", "study.error.fetchComments": "Failed to fetch comments", "study.error.commentsNotSaved": "Comments not saved", "study.error.studyIdCopy": "Failed to copy study ID", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index ef12be54ec..a0504ae745 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -564,6 +564,7 @@ "study.error.listOutputs": "Échec de la récupération des sorties", "study.error.launcherVersions": "Échec lors de la récupération des versions du launcher", "study.error.launcherCores": "Échec lors de la récupération du nombre de cœurs du launcher", + "study.error.launcherTimeLimit": "Échec lors de la récupération de la limite de temps du launcher", "study.error.fetchComments": "Échec lors de la récupération des commentaires", "study.error.commentsNotSaved": "Erreur lors de l'enregistrement des commentaires", "study.error.studyIdCopy": "Erreur lors de la copie de l'identifiant de l'étude", diff --git a/webapp/src/components/App/Studies/LauncherDialog.tsx b/webapp/src/components/App/Studies/LauncherDialog.tsx index 7e610e2f88..670cb54cdc 100644 --- a/webapp/src/components/App/Studies/LauncherDialog.tsx +++ b/webapp/src/components/App/Studies/LauncherDialog.tsx @@ -18,12 +18,13 @@ import { useSnackbar } from "notistack"; import { useMountedState } from "react-use"; import { shallowEqual } from "react-redux"; import { + LaunchOptions, StudyMetadata, StudyOutput, - LaunchOptions, } from "../../../common/types"; import { getLauncherCores, + getLauncherTimeLimit, getLauncherVersions, getStudyOutputs, launchStudy, @@ -38,10 +39,6 @@ import CheckBoxFE from "../../common/fieldEditors/CheckBoxFE"; import { convertVersions } from "../../../services/utils"; import UsePromiseCond from "../../common/utils/UsePromiseCond"; import SwitchFE from "../../common/fieldEditors/SwitchFE"; -import moment from "moment"; - -const DEFAULT_NB_CPU = 22; -const DEFAULT_TIME_LIMIT = 240 * 3600; // 240 hours in seconds interface Props { open: boolean; @@ -55,19 +52,18 @@ function LauncherDialog(props: Props) { const { enqueueSnackbar } = useSnackbar(); const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const [options, setOptions] = useState({ - nb_cpu: DEFAULT_NB_CPU, auto_unzip: true, - time_limit: DEFAULT_TIME_LIMIT, }); const [solverVersion, setSolverVersion] = useState(); const [isLaunching, setIsLaunching] = useState(false); const isMounted = useMountedState(); + const studyNames = useAppSelector( (state) => studyIds.map((sid) => getStudy(state, sid)?.name), shallowEqual, ); - const res = usePromiseWithSnackbarError( + const launcherCores = usePromiseWithSnackbarError( () => getLauncherCores().then((cores) => { setOptions((prevOptions) => { @@ -83,6 +79,22 @@ function LauncherDialog(props: Props) { }, ); + const launcherTimeLimit = usePromiseWithSnackbarError( + () => + getLauncherTimeLimit().then((timeLimit) => { + setOptions((prevOptions) => { + return { + ...prevOptions, + time_limit: timeLimit.defaultValue * 3600, + }; + }); + return timeLimit; + }), + { + errorMessage: t("study.error.launcherTimeLimit"), + }, + ); + const { data: outputList } = usePromiseWithSnackbarError( () => Promise.all(studyIds.map((sid) => getStudyOutputs(sid))), { errorMessage: t("study.error.listOutputs"), deps: [studyIds] }, @@ -97,7 +109,7 @@ function LauncherDialog(props: Props) { // Event Handlers //////////////////////////////////////////////////////////////// - const handleLaunchClick = async () => { + const handleLaunchClick = () => { if (studyIds.length > 0) { setIsLaunching(true); Promise.all( @@ -169,22 +181,6 @@ function LauncherDialog(props: Props) { }); }; - //////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////// - - /** - * Parses an hour value from a string and converts it to seconds. - * If the input is invalid, returns a default value. - * - * @param hourString - A string representing the number of hours. - * @returns The equivalent number of seconds, or a default value for invalid inputs. - */ - const parseHoursToSeconds = (hourString: string): number => { - const seconds = moment.duration(hourString, "hours").asSeconds(); - return seconds > 0 ? seconds : DEFAULT_TIME_LIMIT; - }; - //////////////////////////////////////////////////////////////// // JSX //////////////////////////////////////////////////////////////// @@ -194,9 +190,8 @@ function LauncherDialog(props: Props) { title={t("study.runStudy")} open={open} onClose={onClose} - contentProps={{ - sx: { width: "600px", height: "500px", p: 0, overflow: "hidden" }, - }} + maxWidth="md" + PaperProps={{ sx: { width: 700 } }} actions={ <>