fractal-server 1.4.10__py3-none-any.whl → 2.0.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. fractal_server/__init__.py +1 -1
  2. fractal_server/app/models/__init__.py +3 -7
  3. fractal_server/app/models/linkuserproject.py +9 -0
  4. fractal_server/app/models/security.py +6 -0
  5. fractal_server/app/models/state.py +1 -1
  6. fractal_server/app/models/v1/__init__.py +11 -0
  7. fractal_server/app/models/{dataset.py → v1/dataset.py} +5 -5
  8. fractal_server/app/models/{job.py → v1/job.py} +5 -5
  9. fractal_server/app/models/{project.py → v1/project.py} +5 -5
  10. fractal_server/app/models/{task.py → v1/task.py} +7 -2
  11. fractal_server/app/models/{workflow.py → v1/workflow.py} +5 -5
  12. fractal_server/app/models/v2/__init__.py +20 -0
  13. fractal_server/app/models/v2/dataset.py +55 -0
  14. fractal_server/app/models/v2/job.py +51 -0
  15. fractal_server/app/models/v2/project.py +31 -0
  16. fractal_server/app/models/v2/task.py +93 -0
  17. fractal_server/app/models/v2/workflow.py +43 -0
  18. fractal_server/app/models/v2/workflowtask.py +90 -0
  19. fractal_server/app/routes/{admin.py → admin/v1.py} +42 -42
  20. fractal_server/app/routes/admin/v2.py +274 -0
  21. fractal_server/app/routes/api/v1/__init__.py +7 -7
  22. fractal_server/app/routes/api/v1/_aux_functions.py +2 -2
  23. fractal_server/app/routes/api/v1/dataset.py +37 -37
  24. fractal_server/app/routes/api/v1/job.py +14 -14
  25. fractal_server/app/routes/api/v1/project.py +23 -21
  26. fractal_server/app/routes/api/v1/task.py +24 -14
  27. fractal_server/app/routes/api/v1/task_collection.py +16 -14
  28. fractal_server/app/routes/api/v1/workflow.py +24 -24
  29. fractal_server/app/routes/api/v1/workflowtask.py +10 -10
  30. fractal_server/app/routes/api/v2/__init__.py +28 -0
  31. fractal_server/app/routes/api/v2/_aux_functions.py +497 -0
  32. fractal_server/app/routes/api/v2/dataset.py +309 -0
  33. fractal_server/app/routes/api/v2/images.py +207 -0
  34. fractal_server/app/routes/api/v2/job.py +200 -0
  35. fractal_server/app/routes/api/v2/project.py +202 -0
  36. fractal_server/app/routes/api/v2/submit.py +220 -0
  37. fractal_server/app/routes/api/v2/task.py +222 -0
  38. fractal_server/app/routes/api/v2/task_collection.py +229 -0
  39. fractal_server/app/routes/api/v2/workflow.py +397 -0
  40. fractal_server/app/routes/api/v2/workflowtask.py +269 -0
  41. fractal_server/app/routes/aux/_job.py +1 -1
  42. fractal_server/app/runner/async_wrap.py +27 -0
  43. fractal_server/app/runner/components.py +5 -0
  44. fractal_server/app/runner/exceptions.py +129 -0
  45. fractal_server/app/runner/executors/slurm/__init__.py +3 -0
  46. fractal_server/app/runner/{_slurm → executors/slurm}/_batching.py +1 -1
  47. fractal_server/app/runner/{_slurm → executors/slurm}/_check_jobs_status.py +1 -1
  48. fractal_server/app/runner/{_slurm → executors/slurm}/_executor_wait_thread.py +1 -1
  49. fractal_server/app/runner/{_slurm → executors/slurm}/_slurm_config.py +3 -152
  50. fractal_server/app/runner/{_slurm → executors/slurm}/_subprocess_run_as_user.py +1 -1
  51. fractal_server/app/runner/{_slurm → executors/slurm}/executor.py +32 -19
  52. fractal_server/app/runner/filenames.py +6 -0
  53. fractal_server/app/runner/set_start_and_last_task_index.py +39 -0
  54. fractal_server/app/runner/task_files.py +103 -0
  55. fractal_server/app/runner/{__init__.py → v1/__init__.py} +22 -20
  56. fractal_server/app/runner/{_common.py → v1/_common.py} +13 -120
  57. fractal_server/app/runner/{_local → v1/_local}/__init__.py +5 -5
  58. fractal_server/app/runner/{_local → v1/_local}/_local_config.py +6 -7
  59. fractal_server/app/runner/{_local → v1/_local}/_submit_setup.py +1 -5
  60. fractal_server/app/runner/v1/_slurm/__init__.py +310 -0
  61. fractal_server/app/runner/{_slurm → v1/_slurm}/_submit_setup.py +3 -9
  62. fractal_server/app/runner/v1/_slurm/get_slurm_config.py +163 -0
  63. fractal_server/app/runner/v1/common.py +117 -0
  64. fractal_server/app/runner/{handle_failed_job.py → v1/handle_failed_job.py} +8 -8
  65. fractal_server/app/runner/v2/__init__.py +336 -0
  66. fractal_server/app/runner/v2/_local/__init__.py +167 -0
  67. fractal_server/app/runner/v2/_local/_local_config.py +118 -0
  68. fractal_server/app/runner/v2/_local/_submit_setup.py +52 -0
  69. fractal_server/app/runner/v2/_local/executor.py +100 -0
  70. fractal_server/app/runner/{_slurm → v2/_slurm}/__init__.py +34 -45
  71. fractal_server/app/runner/v2/_slurm/_submit_setup.py +83 -0
  72. fractal_server/app/runner/v2/_slurm/get_slurm_config.py +179 -0
  73. fractal_server/app/runner/v2/deduplicate_list.py +22 -0
  74. fractal_server/app/runner/v2/handle_failed_job.py +156 -0
  75. fractal_server/app/runner/v2/merge_outputs.py +38 -0
  76. fractal_server/app/runner/v2/runner.py +267 -0
  77. fractal_server/app/runner/v2/runner_functions.py +341 -0
  78. fractal_server/app/runner/v2/runner_functions_low_level.py +134 -0
  79. fractal_server/app/runner/v2/task_interface.py +43 -0
  80. fractal_server/app/runner/v2/v1_compat.py +21 -0
  81. fractal_server/app/schemas/__init__.py +4 -42
  82. fractal_server/app/schemas/v1/__init__.py +42 -0
  83. fractal_server/app/schemas/{applyworkflow.py → v1/applyworkflow.py} +18 -18
  84. fractal_server/app/schemas/{dataset.py → v1/dataset.py} +30 -30
  85. fractal_server/app/schemas/{dumps.py → v1/dumps.py} +8 -8
  86. fractal_server/app/schemas/{manifest.py → v1/manifest.py} +5 -5
  87. fractal_server/app/schemas/{project.py → v1/project.py} +9 -9
  88. fractal_server/app/schemas/{task.py → v1/task.py} +12 -12
  89. fractal_server/app/schemas/{task_collection.py → v1/task_collection.py} +7 -7
  90. fractal_server/app/schemas/{workflow.py → v1/workflow.py} +38 -38
  91. fractal_server/app/schemas/v2/__init__.py +34 -0
  92. fractal_server/app/schemas/v2/dataset.py +89 -0
  93. fractal_server/app/schemas/v2/dumps.py +87 -0
  94. fractal_server/app/schemas/v2/job.py +114 -0
  95. fractal_server/app/schemas/v2/manifest.py +159 -0
  96. fractal_server/app/schemas/v2/project.py +37 -0
  97. fractal_server/app/schemas/v2/task.py +120 -0
  98. fractal_server/app/schemas/v2/task_collection.py +105 -0
  99. fractal_server/app/schemas/v2/workflow.py +79 -0
  100. fractal_server/app/schemas/v2/workflowtask.py +119 -0
  101. fractal_server/config.py +5 -4
  102. fractal_server/images/__init__.py +2 -0
  103. fractal_server/images/models.py +50 -0
  104. fractal_server/images/tools.py +85 -0
  105. fractal_server/main.py +11 -3
  106. fractal_server/migrations/env.py +0 -2
  107. fractal_server/migrations/versions/d71e732236cd_v2.py +239 -0
  108. fractal_server/tasks/__init__.py +0 -5
  109. fractal_server/tasks/endpoint_operations.py +13 -19
  110. fractal_server/tasks/utils.py +35 -0
  111. fractal_server/tasks/{_TaskCollectPip.py → v1/_TaskCollectPip.py} +3 -3
  112. fractal_server/tasks/{background_operations.py → v1/background_operations.py} +18 -50
  113. fractal_server/tasks/v1/get_collection_data.py +14 -0
  114. fractal_server/tasks/v2/_TaskCollectPip.py +103 -0
  115. fractal_server/tasks/v2/background_operations.py +381 -0
  116. fractal_server/tasks/v2/get_collection_data.py +14 -0
  117. {fractal_server-1.4.10.dist-info → fractal_server-2.0.0a1.dist-info}/METADATA +1 -1
  118. fractal_server-2.0.0a1.dist-info/RECORD +160 -0
  119. fractal_server/app/runner/_slurm/.gitignore +0 -2
  120. fractal_server/app/runner/common.py +0 -311
  121. fractal_server-1.4.10.dist-info/RECORD +0 -98
  122. /fractal_server/app/runner/{_slurm → executors/slurm}/remote.py +0 -0
  123. /fractal_server/app/runner/{_local → v1/_local}/executor.py +0 -0
  124. {fractal_server-1.4.10.dist-info → fractal_server-2.0.0a1.dist-info}/LICENSE +0 -0
  125. {fractal_server-1.4.10.dist-info → fractal_server-2.0.0a1.dist-info}/WHEEL +0 -0
  126. {fractal_server-1.4.10.dist-info → fractal_server-2.0.0a1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,336 @@
1
+ """
2
+ Runner backend subsystem root V2
3
+
4
+ This module is the single entry point to the runner backend subsystem V2.
5
+ Other subystems should only import this module and not its submodules or
6
+ the individual backends.
7
+ """
8
+ import os
9
+ import traceback
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from sqlalchemy.orm.attributes import flag_modified
14
+
15
+ from ....config import get_settings
16
+ from ....logger import close_logger
17
+ from ....logger import set_logger
18
+ from ....syringe import Inject
19
+ from ....utils import get_timestamp
20
+ from ...db import DB
21
+ from ...models.v2 import DatasetV2
22
+ from ...models.v2 import JobV2
23
+ from ...models.v2 import WorkflowTaskV2
24
+ from ...models.v2 import WorkflowV2
25
+ from ...schemas.v2 import JobStatusTypeV2
26
+ from ..exceptions import JobExecutionError
27
+ from ..exceptions import TaskExecutionError
28
+ from ..filenames import WORKFLOW_LOG_FILENAME
29
+ from ._local import process_workflow as local_process_workflow
30
+ from ._slurm import process_workflow as slurm_process_workflow
31
+ from .handle_failed_job import assemble_filters_failed_job
32
+ from .handle_failed_job import assemble_history_failed_job
33
+ from .handle_failed_job import assemble_images_failed_job
34
+ from fractal_server import __VERSION__
35
+
36
+ _backends = {}
37
+ _backends["local"] = local_process_workflow
38
+ _backends["slurm"] = slurm_process_workflow
39
+
40
+
41
+ async def submit_workflow(
42
+ *,
43
+ workflow_id: int,
44
+ dataset_id: int,
45
+ job_id: int,
46
+ worker_init: Optional[str] = None,
47
+ slurm_user: Optional[str] = None,
48
+ user_cache_dir: Optional[str] = None,
49
+ ) -> None:
50
+ """
51
+ Prepares a workflow and applies it to a dataset
52
+
53
+ This function wraps the process_workflow one, which is different for each
54
+ backend (e.g. local or slurm backend).
55
+
56
+ Args:
57
+ workflow_id:
58
+ ID of the workflow being applied
59
+ dataset_id:
60
+ Dataset ID
61
+ job_id:
62
+ Id of the job record which stores the state for the current
63
+ workflow application.
64
+ worker_init:
65
+ Custom executor parameters that get parsed before the execution of
66
+ each task.
67
+ user_cache_dir:
68
+ Cache directory (namely a path where the user can write); for the
69
+ slurm backend, this is used as a base directory for
70
+ `job.working_dir_user`.
71
+ slurm_user:
72
+ The username to impersonate for the workflow execution, for the
73
+ slurm backend.
74
+ """
75
+
76
+ # Declare runner backend and set `process_workflow` function
77
+ settings = Inject(get_settings)
78
+ FRACTAL_RUNNER_BACKEND = settings.FRACTAL_RUNNER_BACKEND
79
+ if FRACTAL_RUNNER_BACKEND == "local":
80
+ process_workflow = local_process_workflow
81
+ elif FRACTAL_RUNNER_BACKEND == "slurm":
82
+ process_workflow = slurm_process_workflow
83
+ else:
84
+ raise RuntimeError(f"Invalid runner backend {FRACTAL_RUNNER_BACKEND=}")
85
+
86
+ with next(DB.get_sync_db()) as db_sync:
87
+
88
+ job: JobV2 = db_sync.get(JobV2, job_id)
89
+ if not job:
90
+ raise ValueError(f"Cannot fetch job {job_id} from database")
91
+
92
+ dataset: DatasetV2 = db_sync.get(DatasetV2, dataset_id)
93
+ workflow: WorkflowV2 = db_sync.get(WorkflowV2, workflow_id)
94
+ if not (dataset and workflow):
95
+ log_msg = ""
96
+ if not dataset:
97
+ log_msg += f"Cannot fetch dataset {dataset_id} from database\n"
98
+ if not workflow:
99
+ log_msg += (
100
+ f"Cannot fetch workflow {workflow_id} from database\n"
101
+ )
102
+ job.status = JobStatusTypeV2.FAILED
103
+ job.end_timestamp = get_timestamp()
104
+ job.log = log_msg
105
+ db_sync.merge(job)
106
+ db_sync.commit()
107
+ db_sync.close()
108
+ return
109
+
110
+ # Define and create server-side working folder
111
+ project_id = workflow.project_id
112
+ timestamp_string = get_timestamp().strftime("%Y%m%d_%H%M%S")
113
+ WORKFLOW_DIR = (
114
+ settings.FRACTAL_RUNNER_WORKING_BASE_DIR
115
+ / (
116
+ f"proj_{project_id:07d}_wf_{workflow_id:07d}_job_{job_id:07d}"
117
+ f"_{timestamp_string}"
118
+ )
119
+ ).resolve()
120
+
121
+ if WORKFLOW_DIR.exists():
122
+ raise RuntimeError(f"Workflow dir {WORKFLOW_DIR} already exists.")
123
+
124
+ # Create WORKFLOW_DIR with 755 permissions
125
+ original_umask = os.umask(0)
126
+ WORKFLOW_DIR.mkdir(parents=True, mode=0o755)
127
+ os.umask(original_umask)
128
+
129
+ # Define and create user-side working folder, if needed
130
+ if FRACTAL_RUNNER_BACKEND == "local":
131
+ WORKFLOW_DIR_USER = WORKFLOW_DIR
132
+ elif FRACTAL_RUNNER_BACKEND == "slurm":
133
+
134
+ from ..executors.slurm._subprocess_run_as_user import (
135
+ _mkdir_as_user,
136
+ )
137
+
138
+ WORKFLOW_DIR_USER = (
139
+ Path(user_cache_dir) / f"{WORKFLOW_DIR.name}"
140
+ ).resolve()
141
+ _mkdir_as_user(folder=str(WORKFLOW_DIR_USER), user=slurm_user)
142
+ else:
143
+ raise ValueError(f"{FRACTAL_RUNNER_BACKEND=} not supported")
144
+
145
+ # Update db
146
+ job.working_dir = WORKFLOW_DIR.as_posix()
147
+ job.working_dir_user = WORKFLOW_DIR_USER.as_posix()
148
+ db_sync.merge(job)
149
+ db_sync.commit()
150
+
151
+ # After Session.commit() is called, either explicitly or when using a
152
+ # context manager, all objects associated with the Session are expired.
153
+ # https://docs.sqlalchemy.org/en/14/orm/
154
+ # session_basics.html#opening-and-closing-a-session
155
+ # https://docs.sqlalchemy.org/en/14/orm/
156
+ # session_state_management.html#refreshing-expiring
157
+
158
+ # See issue #928:
159
+ # https://github.com/fractal-analytics-platform/
160
+ # fractal-server/issues/928
161
+
162
+ db_sync.refresh(dataset)
163
+ db_sync.refresh(workflow)
164
+
165
+ # Write logs
166
+ logger_name = f"WF{workflow_id}_job{job_id}"
167
+ log_file_path = WORKFLOW_DIR / WORKFLOW_LOG_FILENAME
168
+ logger = set_logger(
169
+ logger_name=logger_name,
170
+ log_file_path=log_file_path,
171
+ )
172
+ logger.info(
173
+ f'Start execution of workflow "{workflow.name}"; '
174
+ f"more logs at {str(log_file_path)}"
175
+ )
176
+ logger.debug(f"fractal_server.__VERSION__: {__VERSION__}")
177
+ logger.debug(f"FRACTAL_RUNNER_BACKEND: {FRACTAL_RUNNER_BACKEND}")
178
+ logger.debug(f"slurm_user: {slurm_user}")
179
+ logger.debug(f"slurm_account: {job.slurm_account}")
180
+ logger.debug(f"worker_init: {worker_init}")
181
+ logger.debug(f"job.id: {job.id}")
182
+ logger.debug(f"job.working_dir: {job.working_dir}")
183
+ logger.debug(f"job.working_dir_user: {job.working_dir_user}")
184
+ logger.debug(f"job.first_task_index: {job.first_task_index}")
185
+ logger.debug(f"job.last_task_index: {job.last_task_index}")
186
+ logger.debug(f'START workflow "{workflow.name}"')
187
+
188
+ try:
189
+ # "The Session.close() method does not prevent the Session from being
190
+ # used again. The Session itself does not actually have a distinct
191
+ # “closed” state; it merely means the Session will release all database
192
+ # connections and ORM objects."
193
+ # (https://docs.sqlalchemy.org/en/20/orm/session_api.html#sqlalchemy.orm.Session.close).
194
+ #
195
+ # We close the session before the (possibly long) process_workflow
196
+ # call, to make sure all DB connections are released. The reason why we
197
+ # are not using a context manager within the try block is that we also
198
+ # need access to db_sync in the except branches.
199
+ db_sync = next(DB.get_sync_db())
200
+ db_sync.close()
201
+
202
+ new_dataset_attributes = await process_workflow(
203
+ workflow=workflow,
204
+ dataset=dataset,
205
+ slurm_user=slurm_user,
206
+ slurm_account=job.slurm_account,
207
+ user_cache_dir=user_cache_dir,
208
+ workflow_dir=WORKFLOW_DIR,
209
+ workflow_dir_user=WORKFLOW_DIR_USER,
210
+ logger_name=logger_name,
211
+ worker_init=worker_init,
212
+ first_task_index=job.first_task_index,
213
+ last_task_index=job.last_task_index,
214
+ )
215
+
216
+ logger.info(
217
+ f'End execution of workflow "{workflow.name}"; '
218
+ f"more logs at {str(log_file_path)}"
219
+ )
220
+ logger.debug(f'END workflow "{workflow.name}"')
221
+
222
+ # Update dataset attributes, in case of successful execution
223
+ dataset.history.extend(new_dataset_attributes["history"])
224
+ dataset.filters = new_dataset_attributes["filters"]
225
+ dataset.images = new_dataset_attributes["images"]
226
+ for attribute_name in ["filters", "history", "images"]:
227
+ flag_modified(dataset, attribute_name)
228
+ db_sync.merge(dataset)
229
+
230
+ # Update job DB entry
231
+ job.status = JobStatusTypeV2.DONE
232
+ job.end_timestamp = get_timestamp()
233
+ with log_file_path.open("r") as f:
234
+ logs = f.read()
235
+ job.log = logs
236
+ db_sync.merge(job)
237
+ db_sync.commit()
238
+
239
+ except TaskExecutionError as e:
240
+
241
+ logger.debug(f'FAILED workflow "{workflow.name}", TaskExecutionError.')
242
+ logger.info(f'Workflow "{workflow.name}" failed (TaskExecutionError).')
243
+
244
+ # Read dataset attributes produced by the last successful task, and
245
+ # update the DB dataset accordingly
246
+ failed_wftask = db_sync.get(WorkflowTaskV2, e.workflow_task_id)
247
+ dataset.history = assemble_history_failed_job(
248
+ job,
249
+ dataset,
250
+ workflow,
251
+ logger,
252
+ failed_wftask=failed_wftask,
253
+ )
254
+ latest_filters = assemble_filters_failed_job(job)
255
+ if latest_filters is not None:
256
+ dataset.filters = latest_filters
257
+ latest_images = assemble_images_failed_job(job)
258
+ if latest_images is not None:
259
+ dataset.images = latest_images
260
+ db_sync.merge(dataset)
261
+
262
+ job.status = JobStatusTypeV2.FAILED
263
+ job.end_timestamp = get_timestamp()
264
+
265
+ exception_args_string = "\n".join(e.args)
266
+ job.log = (
267
+ f"TASK ERROR: "
268
+ f"Task name: {e.task_name}, "
269
+ f"position in Workflow: {e.workflow_task_order}\n"
270
+ f"TRACEBACK:\n{exception_args_string}"
271
+ )
272
+ db_sync.merge(job)
273
+ db_sync.commit()
274
+
275
+ except JobExecutionError as e:
276
+
277
+ logger.debug(f'FAILED workflow "{workflow.name}", JobExecutionError.')
278
+ logger.info(f'Workflow "{workflow.name}" failed (JobExecutionError).')
279
+
280
+ # Read dataset attributes produced by the last successful task, and
281
+ # update the DB dataset accordingly
282
+ dataset.history = assemble_history_failed_job(
283
+ job,
284
+ dataset,
285
+ workflow,
286
+ logger,
287
+ )
288
+ latest_filters = assemble_filters_failed_job(job)
289
+ if latest_filters is not None:
290
+ dataset.filters = latest_filters
291
+ latest_images = assemble_images_failed_job(job)
292
+ if latest_images is not None:
293
+ dataset.images = latest_images
294
+ db_sync.merge(dataset)
295
+
296
+ job.status = JobStatusTypeV2.FAILED
297
+ job.end_timestamp = get_timestamp()
298
+ error = e.assemble_error()
299
+ job.log = f"JOB ERROR in Fractal job {job.id}:\nTRACEBACK:\n{error}"
300
+ db_sync.merge(job)
301
+ db_sync.commit()
302
+
303
+ except Exception:
304
+
305
+ logger.debug(f'FAILED workflow "{workflow.name}", unknown error.')
306
+ logger.info(f'Workflow "{workflow.name}" failed (unkwnon error).')
307
+
308
+ current_traceback = traceback.format_exc()
309
+
310
+ # Read dataset attributes produced by the last successful task, and
311
+ # update the DB dataset accordingly
312
+ dataset.history = assemble_history_failed_job(
313
+ job,
314
+ dataset,
315
+ workflow,
316
+ logger,
317
+ )
318
+ latest_filters = assemble_filters_failed_job(job)
319
+ if latest_filters is not None:
320
+ dataset.filters = latest_filters
321
+ latest_images = assemble_images_failed_job(job)
322
+ if latest_images is not None:
323
+ dataset.images = latest_images
324
+ db_sync.merge(dataset)
325
+
326
+ job.status = JobStatusTypeV2.FAILED
327
+ job.end_timestamp = get_timestamp()
328
+ job.log = (
329
+ f"UNKNOWN ERROR in Fractal job {job.id}\n"
330
+ f"TRACEBACK:\n{current_traceback}"
331
+ )
332
+ db_sync.merge(job)
333
+ db_sync.commit()
334
+ finally:
335
+ close_logger(logger)
336
+ db_sync.close()
@@ -0,0 +1,167 @@
1
+ # Copyright 2022 (C) Friedrich Miescher Institute for Biomedical Research and
2
+ # University of Zurich
3
+ #
4
+ # Original authors:
5
+ # Jacopo Nespolo <jacopo.nespolo@exact-lab.it>
6
+ # Tommaso Comparin <tommaso.comparin@exact-lab.it>
7
+ # Marco Franzon <marco.franzon@exact-lab.it>
8
+ #
9
+ # This file is part of Fractal and was originally developed by eXact lab S.r.l.
10
+ # <exact-lab.it> under contract with Liberali Lab from the Friedrich Miescher
11
+ # Institute for Biomedical Research and Pelkmans Lab from the University of
12
+ # Zurich.
13
+ """
14
+ Local Bakend
15
+
16
+ This backend runs Fractal workflows using `FractalThreadPoolExecutor` (a custom
17
+ version of Python
18
+ [ThreadPoolExecutor](https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.ThreadPoolExecutor))
19
+ to run tasks in several threads.
20
+ Incidentally, it also represents the reference implementation for a backend.
21
+ """
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ from ....models.v2 import DatasetV2
26
+ from ....models.v2 import WorkflowV2
27
+ from ...async_wrap import async_wrap
28
+ from ...set_start_and_last_task_index import set_start_and_last_task_index
29
+ from ..runner import execute_tasks_v2
30
+ from ._submit_setup import _local_submit_setup
31
+ from .executor import FractalThreadPoolExecutor
32
+
33
+
34
+ def _process_workflow(
35
+ *,
36
+ workflow: WorkflowV2,
37
+ dataset: DatasetV2,
38
+ logger_name: str,
39
+ workflow_dir: Path,
40
+ first_task_index: int,
41
+ last_task_index: int,
42
+ ) -> dict:
43
+ """
44
+ Internal processing routine
45
+
46
+ Schedules the workflow using a `FractalThreadPoolExecutor`.
47
+
48
+ Cf. [process_workflow][fractal_server.app.runner._local.process_workflow]
49
+ for the call signature.
50
+ """
51
+
52
+ with FractalThreadPoolExecutor() as executor:
53
+ new_dataset_attributes = execute_tasks_v2(
54
+ wf_task_list=workflow.task_list[
55
+ first_task_index : (last_task_index + 1) # noqa
56
+ ], # noqa
57
+ dataset=dataset,
58
+ executor=executor,
59
+ workflow_dir=workflow_dir,
60
+ workflow_dir_user=workflow_dir,
61
+ logger_name=logger_name,
62
+ submit_setup_call=_local_submit_setup,
63
+ )
64
+ return new_dataset_attributes
65
+
66
+
67
+ async def process_workflow(
68
+ *,
69
+ workflow: WorkflowV2,
70
+ dataset: DatasetV2,
71
+ workflow_dir: Path,
72
+ workflow_dir_user: Optional[Path] = None,
73
+ first_task_index: Optional[int] = None,
74
+ last_task_index: Optional[int] = None,
75
+ logger_name: str,
76
+ # Slurm-specific
77
+ user_cache_dir: Optional[str] = None,
78
+ slurm_user: Optional[str] = None,
79
+ slurm_account: Optional[str] = None,
80
+ worker_init: Optional[str] = None,
81
+ ) -> dict:
82
+ """
83
+ Run a workflow
84
+
85
+ This function is responsible for running a workflow on some input data,
86
+ saving the output and taking care of any exception raised during the run.
87
+
88
+ NOTE: This is the `local` backend's public interface, which also works as
89
+ a reference implementation for other backends.
90
+
91
+ Args:
92
+ workflow:
93
+ The workflow to be run
94
+ input_paths:
95
+ The paths to the input files to pass to the first task of the
96
+ workflow
97
+ output_path:
98
+ The destination path for the last task of the workflow
99
+ input_metadata:
100
+ Initial metadata, passed to the first task
101
+ logger_name:
102
+ Name of the logger to log information on the run to
103
+ workflow_dir:
104
+ Working directory for this run.
105
+ workflow_dir_user:
106
+ Working directory for this run, on the user side. This argument is
107
+ present for compatibility with the standard backend interface, but
108
+ for the `local` backend it cannot be different from `workflow_dir`.
109
+ slurm_user:
110
+ Username to impersonate to run the workflow. This argument is
111
+ present for compatibility with the standard backend interface, but
112
+ is ignored in the `local` backend.
113
+ slurm_account:
114
+ SLURM account to use when running the workflow. This argument is
115
+ present for compatibility with the standard backend interface, but
116
+ is ignored in the `local` backend.
117
+ user_cache_dir:
118
+ Cache directory of the user who will run the workflow. This
119
+ argument is present for compatibility with the standard backend
120
+ interface, but is ignored in the `local` backend.
121
+ worker_init:
122
+ Any additional, usually backend specific, information to be passed
123
+ to the backend executor. This argument is present for compatibility
124
+ with the standard backend interface, but is ignored in the `local`
125
+ backend.
126
+ first_task_index:
127
+ Positional index of the first task to execute; if `None`, start
128
+ from `0`.
129
+ last_task_index:
130
+ Positional index of the last task to execute; if `None`, proceed
131
+ until the last task.
132
+
133
+ Raises:
134
+ TaskExecutionError: wrapper for errors raised during tasks' execution
135
+ (positive exit codes).
136
+ JobExecutionError: wrapper for errors raised by the tasks' executors
137
+ (negative exit codes).
138
+
139
+ Returns:
140
+ output_dataset_metadata:
141
+ The updated metadata for the dataset, as returned by the last task
142
+ of the workflow
143
+ """
144
+
145
+ if workflow_dir_user and (workflow_dir_user != workflow_dir):
146
+ raise NotImplementedError(
147
+ "Local backend does not support different directories "
148
+ f"{workflow_dir=} and {workflow_dir_user=}"
149
+ )
150
+
151
+ # Set values of first_task_index and last_task_index
152
+ num_tasks = len(workflow.task_list)
153
+ first_task_index, last_task_index = set_start_and_last_task_index(
154
+ num_tasks,
155
+ first_task_index=first_task_index,
156
+ last_task_index=last_task_index,
157
+ )
158
+
159
+ new_dataset_attributes = await async_wrap(_process_workflow)(
160
+ workflow=workflow,
161
+ dataset=dataset,
162
+ logger_name=logger_name,
163
+ workflow_dir=workflow_dir,
164
+ first_task_index=first_task_index,
165
+ last_task_index=last_task_index,
166
+ )
167
+ return new_dataset_attributes
@@ -0,0 +1,118 @@
1
+ # Copyright 2022 (C) Friedrich Miescher Institute for Biomedical Research and
2
+ # University of Zurich
3
+ #
4
+ # Original authors:
5
+ # Tommaso Comparin <tommaso.comparin@exact-lab.it>
6
+ #
7
+ # This file is part of Fractal and was originally developed by eXact lab S.r.l.
8
+ # <exact-lab.it> under contract with Liberali Lab from the Friedrich Miescher
9
+ # Institute for Biomedical Research and Pelkmans Lab from the University of
10
+ # Zurich.
11
+ """
12
+ Submodule to handle the local-backend configuration for a WorkflowTask
13
+ """
14
+ import json
15
+ from pathlib import Path
16
+ from typing import Literal
17
+ from typing import Optional
18
+
19
+ from pydantic import BaseModel
20
+ from pydantic import Extra
21
+ from pydantic.error_wrappers import ValidationError
22
+
23
+ from .....config import get_settings
24
+ from .....syringe import Inject
25
+ from ....models.v2 import WorkflowTaskV2
26
+
27
+
28
+ class LocalBackendConfigError(ValueError):
29
+ """
30
+ Local-backend configuration error
31
+ """
32
+
33
+ pass
34
+
35
+
36
+ class LocalBackendConfig(BaseModel, extra=Extra.forbid):
37
+ """
38
+ Specifications of the local-backend configuration
39
+
40
+ Attributes:
41
+ parallel_tasks_per_job:
42
+ Maximum number of tasks to be run in parallel as part of a call to
43
+ `FractalThreadPoolExecutor.map`; if `None`, then all tasks will
44
+ start at the same time.
45
+ """
46
+
47
+ parallel_tasks_per_job: Optional[int]
48
+
49
+
50
+ def get_default_local_backend_config():
51
+ """
52
+ Return a default `LocalBackendConfig` configuration object
53
+ """
54
+ return LocalBackendConfig(parallel_tasks_per_job=None)
55
+
56
+
57
+ def get_local_backend_config(
58
+ wftask: WorkflowTaskV2,
59
+ which_type: Literal["non_parallel", "parallel"],
60
+ config_path: Optional[Path] = None,
61
+ ) -> LocalBackendConfig:
62
+ """
63
+ Prepare a `LocalBackendConfig` configuration object
64
+
65
+ The sources for `parallel_tasks_per_job` attributes, starting from the
66
+ highest-priority one, are
67
+
68
+ 1. Properties in `wftask.meta_parallel` or `wftask.meta_non_parallel`
69
+ (depending on `which_type`);
70
+ 2. The general content of the local-backend configuration file;
71
+ 3. The default value (`None`).
72
+
73
+ Arguments:
74
+ wftask:
75
+ WorkflowTaskV2 for which the backend configuration should
76
+ be prepared.
77
+ config_path:
78
+ Path of local-backend configuration file; if `None`, use
79
+ `FRACTAL_LOCAL_CONFIG_FILE` variable from settings.
80
+
81
+ Returns:
82
+ A local-backend configuration object
83
+ """
84
+
85
+ key = "parallel_tasks_per_job"
86
+ default_value = None
87
+
88
+ if which_type == "non_parallel":
89
+ wftask_meta = wftask.meta_non_parallel
90
+ elif which_type == "parallel":
91
+ wftask_meta = wftask.meta_parallel
92
+ else:
93
+ raise ValueError(
94
+ "`get_local_backend_config` received an invalid argument"
95
+ f" {which_type=}."
96
+ )
97
+
98
+ if wftask_meta and key in wftask_meta:
99
+ parallel_tasks_per_job = wftask.meta[key]
100
+ else:
101
+ if not config_path:
102
+ settings = Inject(get_settings)
103
+ config_path = settings.FRACTAL_LOCAL_CONFIG_FILE
104
+ if config_path is None:
105
+ parallel_tasks_per_job = default_value
106
+ else:
107
+ with config_path.open("r") as f:
108
+ env = json.load(f)
109
+ try:
110
+ _ = LocalBackendConfig(**env)
111
+ except ValidationError as e:
112
+ raise LocalBackendConfigError(
113
+ f"Error while loading {config_path=}. "
114
+ f"Original error:\n{str(e)}"
115
+ )
116
+
117
+ parallel_tasks_per_job = env.get(key, default_value)
118
+ return LocalBackendConfig(parallel_tasks_per_job=parallel_tasks_per_job)
@@ -0,0 +1,52 @@
1
+ # Copyright 2022 (C) Friedrich Miescher Institute for Biomedical Research and
2
+ # University of Zurich
3
+ #
4
+ # Original authors:
5
+ # Tommaso Comparin <tommaso.comparin@exact-lab.it>
6
+ #
7
+ # This file is part of Fractal and was originally developed by eXact lab S.r.l.
8
+ # <exact-lab.it> under contract with Liberali Lab from the Friedrich Miescher
9
+ # Institute for Biomedical Research and Pelkmans Lab from the University of
10
+ # Zurich.
11
+ """
12
+ Submodule to define _local_submit_setup
13
+ """
14
+ from pathlib import Path
15
+ from typing import Literal
16
+ from typing import Optional
17
+
18
+ from ....models.v2 import WorkflowTaskV2
19
+ from ._local_config import get_local_backend_config
20
+
21
+
22
+ def _local_submit_setup(
23
+ *,
24
+ wftask: WorkflowTaskV2,
25
+ workflow_dir: Optional[Path] = None,
26
+ workflow_dir_user: Optional[Path] = None,
27
+ which_type: Literal["non_parallel", "parallel"],
28
+ ) -> dict[str, object]:
29
+ """
30
+ Collect WorfklowTask-specific configuration parameters from different
31
+ sources, and inject them for execution.
32
+
33
+ Arguments:
34
+ wftask:
35
+ WorkflowTask for which the configuration is to be assembled
36
+ workflow_dir:
37
+ Not used in this function.
38
+ workflow_dir_user:
39
+ Not used in this function.
40
+
41
+ Returns:
42
+ submit_setup_dict:
43
+ A dictionary that will be passed on to
44
+ `FractalThreadPoolExecutor.submit` and
45
+ `FractalThreadPoolExecutor.map`, so as to set extra options.
46
+ """
47
+
48
+ local_backend_config = get_local_backend_config(
49
+ wftask=wftask, which_type=which_type
50
+ )
51
+
52
+ return dict(local_backend_config=local_backend_config)