fractal-server 2.14.0a2__py3-none-any.whl → 2.14.0a4__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 (57) hide show
  1. fractal_server/__init__.py +1 -1
  2. fractal_server/__main__.py +3 -1
  3. fractal_server/app/history/__init__.py +4 -4
  4. fractal_server/app/history/image_updates.py +124 -142
  5. fractal_server/app/history/status_enum.py +2 -2
  6. fractal_server/app/models/v2/__init__.py +6 -4
  7. fractal_server/app/models/v2/history.py +44 -20
  8. fractal_server/app/routes/admin/v2/task.py +1 -1
  9. fractal_server/app/routes/api/__init__.py +1 -1
  10. fractal_server/app/routes/api/v2/__init__.py +4 -0
  11. fractal_server/app/routes/api/v2/_aux_functions_history.py +49 -0
  12. fractal_server/app/routes/api/v2/dataset.py +0 -12
  13. fractal_server/app/routes/api/v2/history.py +302 -176
  14. fractal_server/app/routes/api/v2/project.py +1 -26
  15. fractal_server/app/routes/api/v2/status_legacy.py +168 -0
  16. fractal_server/app/routes/api/v2/workflow.py +2 -17
  17. fractal_server/app/routes/api/v2/workflowtask.py +41 -71
  18. fractal_server/app/routes/auth/oauth.py +5 -3
  19. fractal_server/app/runner/executors/base_runner.py +2 -1
  20. fractal_server/app/runner/executors/local/_submit_setup.py +5 -13
  21. fractal_server/app/runner/executors/local/runner.py +10 -55
  22. fractal_server/app/runner/executors/slurm_common/_slurm_config.py +1 -1
  23. fractal_server/app/runner/executors/slurm_common/get_slurm_config.py +1 -1
  24. fractal_server/app/runner/executors/slurm_common/remote.py +1 -1
  25. fractal_server/app/runner/executors/slurm_sudo/runner.py +171 -108
  26. fractal_server/app/runner/v2/__init__.py +2 -22
  27. fractal_server/app/runner/v2/_slurm_ssh.py +1 -1
  28. fractal_server/app/runner/v2/_slurm_sudo.py +1 -1
  29. fractal_server/app/runner/v2/runner.py +47 -59
  30. fractal_server/app/runner/v2/runner_functions.py +185 -69
  31. fractal_server/app/schemas/_validators.py +13 -24
  32. fractal_server/app/schemas/user.py +10 -7
  33. fractal_server/app/schemas/user_settings.py +9 -21
  34. fractal_server/app/schemas/v2/dataset.py +8 -6
  35. fractal_server/app/schemas/v2/job.py +9 -5
  36. fractal_server/app/schemas/v2/manifest.py +3 -7
  37. fractal_server/app/schemas/v2/project.py +9 -7
  38. fractal_server/app/schemas/v2/task.py +41 -77
  39. fractal_server/app/schemas/v2/task_collection.py +14 -32
  40. fractal_server/app/schemas/v2/task_group.py +10 -9
  41. fractal_server/app/schemas/v2/workflow.py +10 -11
  42. fractal_server/app/security/__init__.py +3 -3
  43. fractal_server/app/security/signup_email.py +2 -2
  44. fractal_server/config.py +33 -34
  45. fractal_server/migrations/versions/fbce16ff4e47_new_history_items.py +120 -0
  46. fractal_server/tasks/v2/templates/2_pip_install.sh +1 -1
  47. fractal_server/tasks/v2/templates/4_pip_show.sh +1 -1
  48. fractal_server/tasks/v2/utils_templates.py +6 -0
  49. {fractal_server-2.14.0a2.dist-info → fractal_server-2.14.0a4.dist-info}/METADATA +1 -1
  50. {fractal_server-2.14.0a2.dist-info → fractal_server-2.14.0a4.dist-info}/RECORD +53 -54
  51. fractal_server/app/runner/executors/slurm_sudo/_executor_wait_thread.py +0 -130
  52. fractal_server/app/schemas/v2/history.py +0 -23
  53. fractal_server/migrations/versions/87cd72a537a2_add_historyitem_table.py +0 -68
  54. fractal_server/migrations/versions/954ddc64425a_image_status.py +0 -63
  55. {fractal_server-2.14.0a2.dist-info → fractal_server-2.14.0a4.dist-info}/LICENSE +0 -0
  56. {fractal_server-2.14.0a2.dist-info → fractal_server-2.14.0a4.dist-info}/WHEEL +0 -0
  57. {fractal_server-2.14.0a2.dist-info → fractal_server-2.14.0a4.dist-info}/entry_points.txt +0 -0
@@ -1,4 +1,3 @@
1
- import json
2
1
  import logging
3
2
  from copy import copy
4
3
  from copy import deepcopy
@@ -7,6 +6,7 @@ from typing import Callable
7
6
  from typing import Optional
8
7
 
9
8
  from sqlalchemy.orm.attributes import flag_modified
9
+ from sqlmodel import update
10
10
 
11
11
  from ....images import SingleImage
12
12
  from ....images.tools import filter_image_list
@@ -18,11 +18,10 @@ from .runner_functions import run_v2_task_non_parallel
18
18
  from .runner_functions import run_v2_task_parallel
19
19
  from .task_interface import TaskOutput
20
20
  from fractal_server.app.db import get_sync_db
21
- from fractal_server.app.history.status_enum import HistoryItemImageStatus
21
+ from fractal_server.app.history.status_enum import XXXStatus
22
22
  from fractal_server.app.models.v2 import AccountingRecord
23
23
  from fractal_server.app.models.v2 import DatasetV2
24
- from fractal_server.app.models.v2 import HistoryItemV2
25
- from fractal_server.app.models.v2 import ImageStatus
24
+ from fractal_server.app.models.v2 import HistoryRun
26
25
  from fractal_server.app.models.v2 import TaskGroupV2
27
26
  from fractal_server.app.models.v2 import WorkflowTaskV2
28
27
  from fractal_server.app.runner.executors.base_runner import BaseRunner
@@ -87,6 +86,7 @@ def execute_tasks_v2(
87
86
  **wftask.model_dump(exclude={"task"}),
88
87
  task=wftask.task.model_dump(),
89
88
  )
89
+
90
90
  # Exclude timestamps since they'd need to be serialized properly
91
91
  task_group = db.get(TaskGroupV2, wftask.task.taskgroupv2_id)
92
92
  task_group_dump = task_group.model_dump(
@@ -95,44 +95,18 @@ def execute_tasks_v2(
95
95
  "timestamp_last_used",
96
96
  }
97
97
  )
98
- parameters_hash = str(
99
- hash(
100
- json.dumps(
101
- [workflowtask_dump, task_group_dump],
102
- sort_keys=True,
103
- indent=None,
104
- ).encode("utf-8")
105
- )
106
- )
107
- images = {
108
- image["zarr_url"]: HistoryItemImageStatus.SUBMITTED
109
- for image in filtered_images
110
- }
111
- history_item = HistoryItemV2(
98
+ history_run = HistoryRun(
112
99
  dataset_id=dataset.id,
113
100
  workflowtask_id=wftask.id,
114
101
  workflowtask_dump=workflowtask_dump,
115
102
  task_group_dump=task_group_dump,
116
- parameters_hash=parameters_hash,
117
103
  num_available_images=len(type_filtered_images),
118
- num_current_images=len(filtered_images),
119
- images=images,
104
+ status=XXXStatus.SUBMITTED,
120
105
  )
121
- db.add(history_item)
122
- for image in filtered_images:
123
- db.merge(
124
- ImageStatus(
125
- zarr_url=image["zarr_url"],
126
- workflowtask_id=wftask.id,
127
- dataset_id=dataset.id,
128
- parameters_hash=parameters_hash,
129
- status=HistoryItemImageStatus.SUBMITTED,
130
- logfile="/placeholder",
131
- )
132
- )
106
+ db.add(history_run)
133
107
  db.commit()
134
- db.refresh(history_item)
135
- history_item_id = history_item.id
108
+ db.refresh(history_run)
109
+ history_run_id = history_run.id
136
110
 
137
111
  # TASK EXECUTION (V2)
138
112
  if task.type == "non_parallel":
@@ -149,7 +123,8 @@ def execute_tasks_v2(
149
123
  workflow_dir_remote=workflow_dir_remote,
150
124
  executor=runner,
151
125
  submit_setup_call=submit_setup_call,
152
- history_item_id=history_item_id,
126
+ history_run_id=history_run_id,
127
+ dataset_id=dataset.id,
153
128
  )
154
129
  elif task.type == "parallel":
155
130
  current_task_output, num_tasks, exceptions = run_v2_task_parallel(
@@ -160,7 +135,8 @@ def execute_tasks_v2(
160
135
  workflow_dir_remote=workflow_dir_remote,
161
136
  executor=runner,
162
137
  submit_setup_call=submit_setup_call,
163
- history_item_id=history_item_id,
138
+ history_run_id=history_run_id,
139
+ dataset_id=dataset.id,
164
140
  )
165
141
  elif task.type == "compound":
166
142
  current_task_output, num_tasks, exceptions = run_v2_task_compound(
@@ -172,7 +148,8 @@ def execute_tasks_v2(
172
148
  workflow_dir_remote=workflow_dir_remote,
173
149
  executor=runner,
174
150
  submit_setup_call=submit_setup_call,
175
- history_item_id=history_item_id,
151
+ history_run_id=history_run_id,
152
+ dataset_id=dataset.id,
176
153
  )
177
154
  else:
178
155
  raise ValueError(f"Unexpected error: Invalid {task.type=}.")
@@ -198,7 +175,8 @@ def execute_tasks_v2(
198
175
  # Update image list
199
176
  num_new_images = 0
200
177
  current_task_output.check_zarr_urls_are_unique()
201
- # FIXME: Introduce for loop over task outputs, and processe them sequentially
178
+ # FIXME: Introduce for loop over task outputs, and processe them
179
+ # sequentially
202
180
  # each failure should lead to an update of the specific image status
203
181
  for image_obj in current_task_output.image_list_updates:
204
182
  image = image_obj.model_dump()
@@ -319,22 +297,17 @@ def execute_tasks_v2(
319
297
  type_filters_from_task_manifest = task.output_types
320
298
  current_dataset_type_filters.update(type_filters_from_task_manifest)
321
299
 
322
- # Write current dataset attributes (history, images, filters) into the
323
- # database. They can be used (1) to retrieve the latest state
324
- # when the job fails, (2) from within endpoints that need up-to-date
325
- # information
326
300
  with next(get_sync_db()) as db:
301
+ # Write current dataset attributes (history + filters) into the
302
+ # database.
327
303
  db_dataset = db.get(DatasetV2, dataset.id)
328
304
  db_dataset.type_filters = current_dataset_type_filters
329
305
  db_dataset.images = tmp_images
330
- for attribute_name in [
331
- "type_filters",
332
- "history",
333
- "images",
334
- ]:
306
+ for attribute_name in ["type_filters", "images"]:
335
307
  flag_modified(db_dataset, attribute_name)
336
308
  db.merge(db_dataset)
337
309
  db.commit()
310
+ db.close() # FIXME: why is this needed?
338
311
 
339
312
  # Create accounting record
340
313
  record = AccountingRecord(
@@ -345,15 +318,30 @@ def execute_tasks_v2(
345
318
  db.add(record)
346
319
  db.commit()
347
320
 
348
- if exceptions != {}:
349
- logger.error(
350
- f'END {wftask.order}-th task (name="{task_name}") '
351
- "- ERROR."
352
- )
353
- # Raise first error
354
- for key, value in exceptions.items():
355
- raise JobExecutionError(
356
- info=(f"An error occurred.\nOriginal error:\n{value}")
321
+ # Update History tables, and raise an error if task failed
322
+ if exceptions == {}:
323
+ db.execute(
324
+ update(HistoryRun)
325
+ .where(HistoryRun.id == history_run_id)
326
+ .values(status=XXXStatus.DONE)
327
+ )
328
+ db.commit()
329
+ else:
330
+ db.execute(
331
+ update(HistoryRun)
332
+ .where(HistoryRun.id == history_run_id)
333
+ .values(status=XXXStatus.FAILED)
334
+ )
335
+ db.commit()
336
+ logger.error(
337
+ f'END {wftask.order}-th task (name="{task_name}") - '
338
+ "ERROR."
339
+ )
340
+ # Raise first error
341
+ for key, value in exceptions.items():
342
+ raise JobExecutionError(
343
+ info=(f"An error occurred.\nOriginal error:\n{value}")
344
+ )
345
+ logger.debug(
346
+ f'END {wftask.order}-th task (name="{task_name}")'
357
347
  )
358
-
359
- logger.debug(f'END {wftask.order}-th task (name="{task_name}")')
@@ -1,13 +1,12 @@
1
1
  import functools
2
2
  import logging
3
- import traceback
4
3
  from pathlib import Path
5
4
  from typing import Any
6
- from typing import Callable
7
5
  from typing import Literal
8
6
  from typing import Optional
9
7
 
10
8
  from pydantic import ValidationError
9
+ from sqlmodel import update
11
10
 
12
11
  from ..exceptions import JobExecutionError
13
12
  from .deduplicate_list import deduplicate_list
@@ -15,6 +14,10 @@ from .merge_outputs import merge_outputs
15
14
  from .runner_functions_low_level import run_single_task
16
15
  from .task_interface import InitTaskOutput
17
16
  from .task_interface import TaskOutput
17
+ from fractal_server.app.db import get_sync_db
18
+ from fractal_server.app.history.status_enum import XXXStatus
19
+ from fractal_server.app.models.v2 import HistoryImageCache
20
+ from fractal_server.app.models.v2 import HistoryUnit
18
21
  from fractal_server.app.models.v2 import TaskV2
19
22
  from fractal_server.app.models.v2 import WorkflowTaskV2
20
23
  from fractal_server.app.runner.components import _COMPONENT_KEY_
@@ -59,38 +62,18 @@ def _cast_and_validate_InitTaskOutput(
59
62
  )
60
63
 
61
64
 
62
- def no_op_submit_setup_call(*args, **kwargs) -> dict:
65
+ def no_op_submit_setup_call(
66
+ *,
67
+ wftask: WorkflowTaskV2,
68
+ root_dir_local: Path,
69
+ which_type: Literal["non_parallel", "parallel"],
70
+ ) -> dict[str, Any]:
63
71
  """
64
72
  Default (no-operation) interface of submit_setup_call in V2.
65
73
  """
66
74
  return {}
67
75
 
68
76
 
69
- # Backend-specific configuration
70
- def _get_executor_options(
71
- *,
72
- wftask: WorkflowTaskV2,
73
- workflow_dir_local: Path,
74
- workflow_dir_remote: Path,
75
- submit_setup_call: Callable,
76
- which_type: Literal["non_parallel", "parallel"],
77
- ) -> dict:
78
- try:
79
- options = submit_setup_call(
80
- wftask=wftask,
81
- root_dir_local=workflow_dir_local,
82
- root_dir_remote=workflow_dir_remote,
83
- which_type=which_type,
84
- )
85
- except Exception as e:
86
- tb = "".join(traceback.format_tb(e.__traceback__))
87
- raise RuntimeError(
88
- f"{type(e)} error in {submit_setup_call=}\n"
89
- f"Original traceback:\n{tb}"
90
- )
91
- return options
92
-
93
-
94
77
  def _check_parallelization_list_size(my_list):
95
78
  if len(my_list) > MAX_PARALLELIZATION_LIST_SIZE:
96
79
  raise JobExecutionError(
@@ -109,8 +92,9 @@ def run_v2_task_non_parallel(
109
92
  workflow_dir_local: Path,
110
93
  workflow_dir_remote: Optional[Path] = None,
111
94
  executor: BaseRunner,
112
- submit_setup_call: Callable = no_op_submit_setup_call,
113
- history_item_id: int,
95
+ submit_setup_call: callable = no_op_submit_setup_call,
96
+ dataset_id: int,
97
+ history_run_id: int,
114
98
  ) -> tuple[TaskOutput, int, dict[int, BaseException]]:
115
99
  """
116
100
  This runs server-side (see `executor` argument)
@@ -123,11 +107,10 @@ def run_v2_task_non_parallel(
123
107
  )
124
108
  workflow_dir_remote = workflow_dir_local
125
109
 
126
- executor_options = _get_executor_options(
110
+ executor_options = submit_setup_call(
127
111
  wftask=wftask,
128
- workflow_dir_local=workflow_dir_local,
129
- workflow_dir_remote=workflow_dir_remote,
130
- submit_setup_call=submit_setup_call,
112
+ root_dir_local=workflow_dir_local,
113
+ root_dir_remote=workflow_dir_remote,
131
114
  which_type="non_parallel",
132
115
  )
133
116
 
@@ -138,6 +121,29 @@ def run_v2_task_non_parallel(
138
121
  )
139
122
  function_kwargs[_COMPONENT_KEY_] = _index_to_component(0)
140
123
 
124
+ # Database History operations
125
+ with next(get_sync_db()) as db:
126
+ history_unit = HistoryUnit(
127
+ history_run_id=history_run_id,
128
+ status=XXXStatus.SUBMITTED,
129
+ logfile=None, # FIXME
130
+ zarr_urls=function_kwargs["zarr_urls"],
131
+ )
132
+ db.add(history_unit)
133
+ db.commit()
134
+ db.refresh(history_unit)
135
+ history_unit_id = history_unit.id
136
+ for zarr_url in function_kwargs["zarr_urls"]:
137
+ db.merge(
138
+ HistoryImageCache(
139
+ workflowtask_id=wftask.id,
140
+ dataset_id=dataset_id,
141
+ zarr_url=zarr_url,
142
+ latest_history_unit_id=history_unit_id,
143
+ )
144
+ )
145
+ db.commit()
146
+
141
147
  result, exception = executor.submit(
142
148
  functools.partial(
143
149
  run_single_task,
@@ -147,18 +153,30 @@ def run_v2_task_non_parallel(
147
153
  root_dir_remote=workflow_dir_remote,
148
154
  ),
149
155
  parameters=function_kwargs,
150
- history_item_id=history_item_id,
151
156
  **executor_options,
152
157
  )
153
158
 
154
159
  num_tasks = 1
155
- if exception is None:
156
- if result is None:
157
- return (TaskOutput(), num_tasks, {})
160
+ with next(get_sync_db()) as db:
161
+ if exception is None:
162
+ db.execute(
163
+ update(HistoryUnit)
164
+ .where(HistoryUnit.id == history_unit_id)
165
+ .values(status=XXXStatus.DONE)
166
+ )
167
+ db.commit()
168
+ if result is None:
169
+ return (TaskOutput(), num_tasks, {})
170
+ else:
171
+ return (_cast_and_validate_TaskOutput(result), num_tasks, {})
158
172
  else:
159
- return (_cast_and_validate_TaskOutput(result), num_tasks, {})
160
- else:
161
- return (TaskOutput(), num_tasks, {0: exception})
173
+ db.execute(
174
+ update(HistoryUnit)
175
+ .where(HistoryUnit.id == history_unit_id)
176
+ .values(status=XXXStatus.FAILED)
177
+ )
178
+ db.commit()
179
+ return (TaskOutput(), num_tasks, {0: exception})
162
180
 
163
181
 
164
182
  def run_v2_task_parallel(
@@ -169,32 +187,57 @@ def run_v2_task_parallel(
169
187
  executor: BaseRunner,
170
188
  workflow_dir_local: Path,
171
189
  workflow_dir_remote: Optional[Path] = None,
172
- submit_setup_call: Callable = no_op_submit_setup_call,
173
- history_item_id: int,
190
+ submit_setup_call: callable = no_op_submit_setup_call,
191
+ dataset_id: int,
192
+ history_run_id: int,
174
193
  ) -> tuple[TaskOutput, int, dict[int, BaseException]]:
175
194
 
176
195
  if len(images) == 0:
196
+ # FIXME: Do something with history units/images?
177
197
  return (TaskOutput(), 0, {})
178
198
 
179
199
  _check_parallelization_list_size(images)
180
200
 
181
- executor_options = _get_executor_options(
201
+ executor_options = submit_setup_call(
182
202
  wftask=wftask,
183
- workflow_dir_local=workflow_dir_local,
184
- workflow_dir_remote=workflow_dir_remote,
185
- submit_setup_call=submit_setup_call,
203
+ root_dir_local=workflow_dir_local,
204
+ root_dir_remote=workflow_dir_remote,
186
205
  which_type="parallel",
187
206
  )
188
207
 
189
208
  list_function_kwargs = []
190
- for ind, image in enumerate(images):
191
- list_function_kwargs.append(
192
- dict(
193
- zarr_url=image["zarr_url"],
194
- **(wftask.args_parallel or {}),
195
- ),
196
- )
197
- list_function_kwargs[-1][_COMPONENT_KEY_] = _index_to_component(ind)
209
+ history_unit_ids = []
210
+ with next(get_sync_db()) as db:
211
+ for ind, image in enumerate(images):
212
+ list_function_kwargs.append(
213
+ dict(
214
+ zarr_url=image["zarr_url"],
215
+ **(wftask.args_parallel or {}),
216
+ ),
217
+ )
218
+ list_function_kwargs[-1][_COMPONENT_KEY_] = _index_to_component(
219
+ ind
220
+ )
221
+ history_unit = HistoryUnit(
222
+ history_run_id=history_run_id,
223
+ status=XXXStatus.SUBMITTED,
224
+ logfile=None, # FIXME
225
+ zarr_urls=[image["zarr_url"]],
226
+ )
227
+ # FIXME: this should be a bulk operation
228
+ db.add(history_unit)
229
+ db.commit()
230
+ db.refresh(history_unit)
231
+ db.merge(
232
+ HistoryImageCache(
233
+ workflowtask_id=wftask.id,
234
+ dataset_id=dataset_id,
235
+ zarr_url=image["zarr_url"],
236
+ latest_history_unit_id=history_unit.id,
237
+ )
238
+ )
239
+ db.commit()
240
+ history_unit_ids.append(history_unit.id)
198
241
 
199
242
  results, exceptions = executor.multisubmit(
200
243
  functools.partial(
@@ -205,11 +248,12 @@ def run_v2_task_parallel(
205
248
  root_dir_remote=workflow_dir_remote,
206
249
  ),
207
250
  list_parameters=list_function_kwargs,
208
- history_item_id=history_item_id,
209
251
  **executor_options,
210
252
  )
211
253
 
212
254
  outputs = []
255
+ history_unit_ids_done: list[int] = []
256
+ history_unit_ids_failed: list[int] = []
213
257
  for ind in range(len(list_function_kwargs)):
214
258
  if ind in results.keys():
215
259
  result = results[ind]
@@ -218,11 +262,26 @@ def run_v2_task_parallel(
218
262
  else:
219
263
  output = _cast_and_validate_TaskOutput(result)
220
264
  outputs.append(output)
265
+ history_unit_ids_done.append(history_unit_ids[ind])
221
266
  elif ind in exceptions.keys():
222
267
  print(f"Bad: {exceptions[ind]}")
268
+ history_unit_ids_failed.append(history_unit_ids[ind])
223
269
  else:
224
270
  print("VERY BAD - should have not reached this point")
225
271
 
272
+ with next(get_sync_db()) as db:
273
+ db.execute(
274
+ update(HistoryUnit)
275
+ .where(HistoryUnit.id.in_(history_unit_ids_done))
276
+ .values(status=XXXStatus.DONE)
277
+ )
278
+ db.execute(
279
+ update(HistoryUnit)
280
+ .where(HistoryUnit.id.in_(history_unit_ids_failed))
281
+ .values(status=XXXStatus.FAILED)
282
+ )
283
+ db.commit()
284
+
226
285
  num_tasks = len(images)
227
286
  merged_output = merge_outputs(outputs)
228
287
  return (merged_output, num_tasks, exceptions)
@@ -237,22 +296,21 @@ def run_v2_task_compound(
237
296
  executor: BaseRunner,
238
297
  workflow_dir_local: Path,
239
298
  workflow_dir_remote: Optional[Path] = None,
240
- submit_setup_call: Callable = no_op_submit_setup_call,
241
- history_item_id: int,
299
+ submit_setup_call: callable = no_op_submit_setup_call,
300
+ dataset_id: int,
301
+ history_run_id: int,
242
302
  ) -> tuple[TaskOutput, int, dict[int, BaseException]]:
243
303
 
244
- executor_options_init = _get_executor_options(
304
+ executor_options_init = submit_setup_call(
245
305
  wftask=wftask,
246
- workflow_dir_local=workflow_dir_local,
247
- workflow_dir_remote=workflow_dir_remote,
248
- submit_setup_call=submit_setup_call,
306
+ root_dir_local=workflow_dir_local,
307
+ root_dir_remote=workflow_dir_remote,
249
308
  which_type="non_parallel",
250
309
  )
251
- executor_options_compute = _get_executor_options(
310
+ executor_options_compute = submit_setup_call(
252
311
  wftask=wftask,
253
- workflow_dir_local=workflow_dir_local,
254
- workflow_dir_remote=workflow_dir_remote,
255
- submit_setup_call=submit_setup_call,
312
+ root_dir_local=workflow_dir_local,
313
+ root_dir_remote=workflow_dir_remote,
256
314
  which_type="parallel",
257
315
  )
258
316
 
@@ -263,6 +321,33 @@ def run_v2_task_compound(
263
321
  **(wftask.args_non_parallel or {}),
264
322
  )
265
323
  function_kwargs[_COMPONENT_KEY_] = f"init_{_index_to_component(0)}"
324
+
325
+ # Create database History entries
326
+ input_image_zarr_urls = function_kwargs["zarr_urls"]
327
+ with next(get_sync_db()) as db:
328
+ # Create a single `HistoryUnit` for the whole compound task
329
+ history_unit = HistoryUnit(
330
+ history_run_id=history_run_id,
331
+ status=XXXStatus.SUBMITTED,
332
+ logfile=None, # FIXME
333
+ zarr_urls=input_image_zarr_urls,
334
+ )
335
+ db.add(history_unit)
336
+ db.commit()
337
+ db.refresh(history_unit)
338
+ history_unit_id = history_unit.id
339
+ # Create one `HistoryImageCache` for each input image
340
+ for zarr_url in input_image_zarr_urls:
341
+ db.merge(
342
+ HistoryImageCache(
343
+ workflowtask_id=wftask.id,
344
+ dataset_id=dataset_id,
345
+ zarr_url=zarr_url,
346
+ latest_history_unit_id=history_unit_id,
347
+ )
348
+ )
349
+ db.commit()
350
+
266
351
  result, exception = executor.submit(
267
352
  functools.partial(
268
353
  run_single_task,
@@ -272,8 +357,6 @@ def run_v2_task_compound(
272
357
  root_dir_remote=workflow_dir_remote,
273
358
  ),
274
359
  parameters=function_kwargs,
275
- history_item_id=history_item_id,
276
- in_compound_task=True,
277
360
  **executor_options_init,
278
361
  )
279
362
 
@@ -284,6 +367,13 @@ def run_v2_task_compound(
284
367
  else:
285
368
  init_task_output = _cast_and_validate_InitTaskOutput(result)
286
369
  else:
370
+ with next(get_sync_db()) as db:
371
+ db.execute(
372
+ update(HistoryUnit)
373
+ .where(HistoryUnit.id == history_unit_id)
374
+ .values(status=XXXStatus.FAILED)
375
+ )
376
+ db.commit()
287
377
  return (TaskOutput(), num_tasks, {0: exception})
288
378
 
289
379
  parallelization_list = init_task_output.parallelization_list
@@ -295,6 +385,13 @@ def run_v2_task_compound(
295
385
  _check_parallelization_list_size(parallelization_list)
296
386
 
297
387
  if len(parallelization_list) == 0:
388
+ with next(get_sync_db()) as db:
389
+ db.execute(
390
+ update(HistoryUnit)
391
+ .where(HistoryUnit.id == history_unit_id)
392
+ .values(status=XXXStatus.DONE)
393
+ )
394
+ db.commit()
298
395
  return (TaskOutput(), 0, {})
299
396
 
300
397
  list_function_kwargs = []
@@ -319,12 +416,12 @@ def run_v2_task_compound(
319
416
  root_dir_remote=workflow_dir_remote,
320
417
  ),
321
418
  list_parameters=list_function_kwargs,
322
- history_item_id=history_item_id,
323
419
  in_compound_task=True,
324
420
  **executor_options_compute,
325
421
  )
326
422
 
327
423
  outputs = []
424
+ failure = False
328
425
  for ind in range(len(list_function_kwargs)):
329
426
  if ind in results.keys():
330
427
  result = results[ind]
@@ -333,8 +430,27 @@ def run_v2_task_compound(
333
430
  else:
334
431
  output = _cast_and_validate_TaskOutput(result)
335
432
  outputs.append(output)
433
+
336
434
  elif ind in exceptions.keys():
337
435
  print(f"Bad: {exceptions[ind]}")
436
+ failure = True
437
+ else:
438
+ print("VERY BAD - should have not reached this point")
439
+
440
+ with next(get_sync_db()) as db:
441
+ if failure:
442
+ db.execute(
443
+ update(HistoryUnit)
444
+ .where(HistoryUnit.id == history_unit_id)
445
+ .values(status=XXXStatus.FAILED)
446
+ )
447
+ else:
448
+ db.execute(
449
+ update(HistoryUnit)
450
+ .where(HistoryUnit.id == history_unit_id)
451
+ .values(status=XXXStatus.DONE)
452
+ )
453
+ db.commit()
338
454
 
339
455
  merged_output = merge_outputs(outputs)
340
456
  return (merged_output, num_tasks, exceptions)
@@ -1,43 +1,32 @@
1
1
  import os
2
+ from typing import Annotated
2
3
  from typing import Any
3
4
  from typing import Optional
4
5
 
6
+ from pydantic.types import StringConstraints
5
7
 
6
- def valstr(attribute: str, accept_none: bool = False):
7
- """
8
- Check that a string attribute is not an empty string, and remove the
9
- leading and trailing whitespace characters.
10
8
 
11
- If `accept_none`, the validator also accepts `None`.
12
- """
9
+ def cant_set_none(value: Any) -> Any:
10
+ if value is None:
11
+ raise ValueError("Field cannot be set to 'None'.")
12
+ return value
13
13
 
14
- def val(cls, string: Optional[str]) -> Optional[str]:
15
- if string is None:
16
- if accept_none:
17
- return string
18
- else:
19
- raise ValueError(
20
- f"String attribute '{attribute}' cannot be None"
21
- )
22
- s = string.strip()
23
- if not s:
24
- raise ValueError(f"String attribute '{attribute}' cannot be empty")
25
- return s
26
14
 
27
- return val
15
+ NonEmptyString = Annotated[
16
+ str, StringConstraints(min_length=1, strip_whitespace=True)
17
+ ]
28
18
 
29
19
 
30
20
  def valdict_keys(attribute: str):
31
21
  def val(cls, d: Optional[dict[str, Any]]) -> Optional[dict[str, Any]]:
32
22
  """
33
- Apply valstr to every key of the dictionary, and fail if there are
34
- identical keys.
23
+ Strip every key of the dictionary, and fail if there are identical keys
35
24
  """
36
25
  if d is not None:
37
26
  old_keys = list(d.keys())
38
- new_keys = [
39
- valstr(f"{attribute}[{key}]")(cls, key) for key in old_keys
40
- ]
27
+ new_keys = [key.strip() for key in old_keys]
28
+ if any(k == "" for k in new_keys):
29
+ raise ValueError(f"Empty string in {new_keys}.")
41
30
  if len(new_keys) != len(set(new_keys)):
42
31
  raise ValueError(
43
32
  f"Dictionary contains multiple identical keys: '{d}'."