fractal-server 2.13.0__py3-none-any.whl → 2.14.0__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 (127) hide show
  1. fractal_server/__init__.py +1 -1
  2. fractal_server/__main__.py +3 -1
  3. fractal_server/app/models/linkusergroup.py +6 -2
  4. fractal_server/app/models/v2/__init__.py +11 -1
  5. fractal_server/app/models/v2/accounting.py +35 -0
  6. fractal_server/app/models/v2/dataset.py +1 -11
  7. fractal_server/app/models/v2/history.py +78 -0
  8. fractal_server/app/models/v2/job.py +10 -3
  9. fractal_server/app/models/v2/task_group.py +2 -2
  10. fractal_server/app/models/v2/workflow.py +1 -1
  11. fractal_server/app/models/v2/workflowtask.py +1 -1
  12. fractal_server/app/routes/admin/v2/__init__.py +4 -0
  13. fractal_server/app/routes/admin/v2/accounting.py +98 -0
  14. fractal_server/app/routes/admin/v2/impersonate.py +35 -0
  15. fractal_server/app/routes/admin/v2/job.py +5 -13
  16. fractal_server/app/routes/admin/v2/task.py +1 -1
  17. fractal_server/app/routes/admin/v2/task_group.py +4 -29
  18. fractal_server/app/routes/api/__init__.py +1 -1
  19. fractal_server/app/routes/api/v2/__init__.py +8 -2
  20. fractal_server/app/routes/api/v2/_aux_functions.py +66 -0
  21. fractal_server/app/routes/api/v2/_aux_functions_history.py +166 -0
  22. fractal_server/app/routes/api/v2/_aux_functions_task_lifecycle.py +3 -3
  23. fractal_server/app/routes/api/v2/dataset.py +0 -17
  24. fractal_server/app/routes/api/v2/history.py +544 -0
  25. fractal_server/app/routes/api/v2/images.py +31 -43
  26. fractal_server/app/routes/api/v2/job.py +30 -0
  27. fractal_server/app/routes/api/v2/project.py +1 -53
  28. fractal_server/app/routes/api/v2/{status.py → status_legacy.py} +6 -6
  29. fractal_server/app/routes/api/v2/submit.py +17 -14
  30. fractal_server/app/routes/api/v2/task.py +3 -10
  31. fractal_server/app/routes/api/v2/task_collection_custom.py +4 -9
  32. fractal_server/app/routes/api/v2/task_group.py +2 -22
  33. fractal_server/app/routes/api/v2/verify_image_types.py +61 -0
  34. fractal_server/app/routes/api/v2/workflow.py +28 -69
  35. fractal_server/app/routes/api/v2/workflowtask.py +53 -50
  36. fractal_server/app/routes/auth/group.py +0 -16
  37. fractal_server/app/routes/auth/oauth.py +5 -3
  38. fractal_server/app/routes/aux/__init__.py +0 -20
  39. fractal_server/app/routes/pagination.py +47 -0
  40. fractal_server/app/runner/components.py +0 -3
  41. fractal_server/app/runner/compress_folder.py +57 -29
  42. fractal_server/app/runner/exceptions.py +4 -0
  43. fractal_server/app/runner/executors/base_runner.py +157 -0
  44. fractal_server/app/runner/{v2/_local/_local_config.py → executors/local/get_local_config.py} +7 -9
  45. fractal_server/app/runner/executors/local/runner.py +248 -0
  46. fractal_server/app/runner/executors/{slurm → slurm_common}/_batching.py +1 -1
  47. fractal_server/app/runner/executors/{slurm → slurm_common}/_slurm_config.py +9 -7
  48. fractal_server/app/runner/executors/slurm_common/base_slurm_runner.py +868 -0
  49. fractal_server/app/runner/{v2/_slurm_common → executors/slurm_common}/get_slurm_config.py +48 -17
  50. fractal_server/app/runner/executors/{slurm → slurm_common}/remote.py +36 -47
  51. fractal_server/app/runner/executors/slurm_common/slurm_job_task_models.py +134 -0
  52. fractal_server/app/runner/executors/slurm_ssh/runner.py +268 -0
  53. fractal_server/app/runner/executors/slurm_sudo/__init__.py +0 -0
  54. fractal_server/app/runner/executors/{slurm/sudo → slurm_sudo}/_subprocess_run_as_user.py +2 -83
  55. fractal_server/app/runner/executors/slurm_sudo/runner.py +193 -0
  56. fractal_server/app/runner/extract_archive.py +1 -3
  57. fractal_server/app/runner/task_files.py +134 -87
  58. fractal_server/app/runner/v2/__init__.py +0 -395
  59. fractal_server/app/runner/v2/_local.py +88 -0
  60. fractal_server/app/runner/v2/{_slurm_ssh/__init__.py → _slurm_ssh.py} +22 -19
  61. fractal_server/app/runner/v2/{_slurm_sudo/__init__.py → _slurm_sudo.py} +19 -15
  62. fractal_server/app/runner/v2/db_tools.py +119 -0
  63. fractal_server/app/runner/v2/runner.py +219 -98
  64. fractal_server/app/runner/v2/runner_functions.py +491 -189
  65. fractal_server/app/runner/v2/runner_functions_low_level.py +40 -43
  66. fractal_server/app/runner/v2/submit_workflow.py +358 -0
  67. fractal_server/app/runner/v2/task_interface.py +31 -0
  68. fractal_server/app/schemas/_validators.py +13 -24
  69. fractal_server/app/schemas/user.py +10 -7
  70. fractal_server/app/schemas/user_settings.py +9 -21
  71. fractal_server/app/schemas/v2/__init__.py +10 -1
  72. fractal_server/app/schemas/v2/accounting.py +18 -0
  73. fractal_server/app/schemas/v2/dataset.py +12 -94
  74. fractal_server/app/schemas/v2/dumps.py +26 -9
  75. fractal_server/app/schemas/v2/history.py +80 -0
  76. fractal_server/app/schemas/v2/job.py +15 -8
  77. fractal_server/app/schemas/v2/manifest.py +14 -7
  78. fractal_server/app/schemas/v2/project.py +9 -7
  79. fractal_server/app/schemas/v2/status_legacy.py +35 -0
  80. fractal_server/app/schemas/v2/task.py +72 -77
  81. fractal_server/app/schemas/v2/task_collection.py +14 -32
  82. fractal_server/app/schemas/v2/task_group.py +10 -9
  83. fractal_server/app/schemas/v2/workflow.py +10 -11
  84. fractal_server/app/schemas/v2/workflowtask.py +2 -21
  85. fractal_server/app/security/__init__.py +3 -3
  86. fractal_server/app/security/signup_email.py +2 -2
  87. fractal_server/config.py +91 -90
  88. fractal_server/images/tools.py +23 -0
  89. fractal_server/migrations/versions/47351f8c7ebc_drop_dataset_filters.py +50 -0
  90. fractal_server/migrations/versions/9db60297b8b2_set_ondelete.py +250 -0
  91. fractal_server/migrations/versions/af1ef1c83c9b_add_accounting_tables.py +57 -0
  92. fractal_server/migrations/versions/c90a7c76e996_job_id_in_history_run.py +41 -0
  93. fractal_server/migrations/versions/e81103413827_add_job_type_filters.py +36 -0
  94. fractal_server/migrations/versions/f37aceb45062_make_historyunit_logfile_required.py +39 -0
  95. fractal_server/migrations/versions/fbce16ff4e47_new_history_items.py +120 -0
  96. fractal_server/ssh/_fabric.py +28 -14
  97. fractal_server/tasks/v2/local/collect.py +2 -2
  98. fractal_server/tasks/v2/ssh/collect.py +2 -2
  99. fractal_server/tasks/v2/templates/2_pip_install.sh +1 -1
  100. fractal_server/tasks/v2/templates/4_pip_show.sh +1 -1
  101. fractal_server/tasks/v2/utils_background.py +1 -20
  102. fractal_server/tasks/v2/utils_database.py +30 -17
  103. fractal_server/tasks/v2/utils_templates.py +6 -0
  104. {fractal_server-2.13.0.dist-info → fractal_server-2.14.0.dist-info}/METADATA +4 -4
  105. {fractal_server-2.13.0.dist-info → fractal_server-2.14.0.dist-info}/RECORD +114 -99
  106. {fractal_server-2.13.0.dist-info → fractal_server-2.14.0.dist-info}/WHEEL +1 -1
  107. fractal_server/app/runner/executors/slurm/ssh/_executor_wait_thread.py +0 -126
  108. fractal_server/app/runner/executors/slurm/ssh/_slurm_job.py +0 -116
  109. fractal_server/app/runner/executors/slurm/ssh/executor.py +0 -1386
  110. fractal_server/app/runner/executors/slurm/sudo/_check_jobs_status.py +0 -71
  111. fractal_server/app/runner/executors/slurm/sudo/_executor_wait_thread.py +0 -130
  112. fractal_server/app/runner/executors/slurm/sudo/executor.py +0 -1281
  113. fractal_server/app/runner/v2/_local/__init__.py +0 -129
  114. fractal_server/app/runner/v2/_local/_submit_setup.py +0 -52
  115. fractal_server/app/runner/v2/_local/executor.py +0 -100
  116. fractal_server/app/runner/v2/_slurm_ssh/_submit_setup.py +0 -83
  117. fractal_server/app/runner/v2/_slurm_sudo/_submit_setup.py +0 -83
  118. fractal_server/app/runner/v2/handle_failed_job.py +0 -59
  119. fractal_server/app/schemas/v2/status.py +0 -16
  120. /fractal_server/app/{runner/executors/slurm → history}/__init__.py +0 -0
  121. /fractal_server/app/runner/executors/{slurm/ssh → local}/__init__.py +0 -0
  122. /fractal_server/app/runner/executors/{slurm/sudo → slurm_common}/__init__.py +0 -0
  123. /fractal_server/app/runner/executors/{_job_states.py → slurm_common/_job_states.py} +0 -0
  124. /fractal_server/app/runner/executors/{slurm → slurm_common}/utils_executors.py +0 -0
  125. /fractal_server/app/runner/{v2/_slurm_common → executors/slurm_ssh}/__init__.py +0 -0
  126. {fractal_server-2.13.0.dist-info → fractal_server-2.14.0.dist-info}/LICENSE +0 -0
  127. {fractal_server-2.13.0.dist-info → fractal_server-2.14.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,119 @@
1
+ from typing import Any
2
+
3
+ from sqlalchemy.dialects.postgresql import insert as pg_insert
4
+ from sqlalchemy.orm import Session
5
+ from sqlmodel import update
6
+
7
+ from fractal_server.app.models.v2 import HistoryImageCache
8
+ from fractal_server.app.models.v2 import HistoryRun
9
+ from fractal_server.app.models.v2 import HistoryUnit
10
+ from fractal_server.app.schemas.v2 import HistoryUnitStatus
11
+ from fractal_server.logger import set_logger
12
+
13
+
14
+ _CHUNK_SIZE = 2_000
15
+
16
+ logger = set_logger(__name__)
17
+
18
+
19
+ def update_status_of_history_run(
20
+ *,
21
+ history_run_id: int,
22
+ status: HistoryUnitStatus,
23
+ db_sync: Session,
24
+ ) -> None:
25
+ run = db_sync.get(HistoryRun, history_run_id)
26
+ if run is None:
27
+ raise ValueError(f"HistoryRun {history_run_id} not found.")
28
+ run.status = status
29
+ db_sync.merge(run)
30
+ db_sync.commit()
31
+
32
+
33
+ def update_status_of_history_unit(
34
+ *,
35
+ history_unit_id: int,
36
+ status: HistoryUnitStatus,
37
+ db_sync: Session,
38
+ ) -> None:
39
+ unit = db_sync.get(HistoryUnit, history_unit_id)
40
+ if unit is None:
41
+ raise ValueError(f"HistoryUnit {history_unit_id} not found.")
42
+ unit.status = status
43
+ db_sync.merge(unit)
44
+ db_sync.commit()
45
+
46
+
47
+ def bulk_update_status_of_history_unit(
48
+ *,
49
+ history_unit_ids: list[int],
50
+ status: HistoryUnitStatus,
51
+ db_sync: Session,
52
+ ) -> None:
53
+
54
+ len_history_unit_ids = len(history_unit_ids)
55
+ logger.debug(
56
+ f"[bulk_update_status_of_history_unit] {len_history_unit_ids=}."
57
+ )
58
+ for ind in range(0, len_history_unit_ids, _CHUNK_SIZE):
59
+ db_sync.execute(
60
+ update(HistoryUnit)
61
+ .where(
62
+ HistoryUnit.id.in_(history_unit_ids[ind : ind + _CHUNK_SIZE])
63
+ )
64
+ .values(status=status)
65
+ )
66
+ # NOTE: keeping commit within the for loop is much more efficient
67
+ db_sync.commit()
68
+
69
+
70
+ def bulk_upsert_image_cache_fast(
71
+ *,
72
+ list_upsert_objects: list[dict[str, Any]],
73
+ db: Session,
74
+ ) -> None:
75
+ """
76
+ Insert or update many objects into `HistoryImageCache` and commit
77
+
78
+ This function is an optimized version of
79
+
80
+ ```python
81
+ for obj in list_upsert_objects:
82
+ db.merge(**obj)
83
+ db.commit()
84
+ ```
85
+
86
+ See docs at
87
+ https://docs.sqlalchemy.org/en/20/dialects/postgresql.html#insert-on-conflict-upsert
88
+
89
+ NOTE: we tried to replace `index_elements` with
90
+ `constraint="pk_historyimagecache"`, but it did not work as expected.
91
+
92
+ Arguments:
93
+ list_upsert_objects:
94
+ List of dictionaries for objects to be upsert-ed.
95
+ db: A sync database session
96
+ """
97
+ len_list_upsert_objects = len(list_upsert_objects)
98
+
99
+ logger.debug(f"[bulk_upsert_image_cache_fast] {len_list_upsert_objects=}.")
100
+
101
+ if len_list_upsert_objects == 0:
102
+ return None
103
+
104
+ for ind in range(0, len_list_upsert_objects, _CHUNK_SIZE):
105
+ stmt = pg_insert(HistoryImageCache).values(
106
+ list_upsert_objects[ind : ind + _CHUNK_SIZE]
107
+ )
108
+ stmt = stmt.on_conflict_do_update(
109
+ index_elements=[
110
+ HistoryImageCache.zarr_url,
111
+ HistoryImageCache.dataset_id,
112
+ HistoryImageCache.workflowtask_id,
113
+ ],
114
+ set_=dict(
115
+ latest_history_unit_id=stmt.excluded.latest_history_unit_id
116
+ ),
117
+ )
118
+ db.execute(stmt)
119
+ db.commit()
@@ -1,27 +1,37 @@
1
1
  import logging
2
- from concurrent.futures import ThreadPoolExecutor
3
2
  from copy import copy
4
3
  from copy import deepcopy
5
4
  from pathlib import Path
5
+ from typing import Any
6
6
  from typing import Callable
7
+ from typing import Literal
7
8
  from typing import Optional
8
9
 
9
10
  from sqlalchemy.orm.attributes import flag_modified
11
+ from sqlmodel import delete
10
12
 
11
13
  from ....images import SingleImage
12
14
  from ....images.tools import filter_image_list
13
15
  from ....images.tools import find_image_by_zarr_url
14
16
  from ..exceptions import JobExecutionError
15
- from .runner_functions import no_op_submit_setup_call
17
+ from .merge_outputs import merge_outputs
16
18
  from .runner_functions import run_v2_task_compound
17
19
  from .runner_functions import run_v2_task_non_parallel
18
20
  from .runner_functions import run_v2_task_parallel
21
+ from .runner_functions import SubmissionOutcome
19
22
  from .task_interface import TaskOutput
20
23
  from fractal_server.app.db import get_sync_db
24
+ from fractal_server.app.models.v2 import AccountingRecord
21
25
  from fractal_server.app.models.v2 import DatasetV2
26
+ from fractal_server.app.models.v2 import HistoryImageCache
27
+ from fractal_server.app.models.v2 import HistoryRun
28
+ from fractal_server.app.models.v2 import TaskGroupV2
22
29
  from fractal_server.app.models.v2 import WorkflowTaskV2
23
- from fractal_server.app.schemas.v2.dataset import _DatasetHistoryItemV2
24
- from fractal_server.app.schemas.v2.workflowtask import WorkflowTaskStatusTypeV2
30
+ from fractal_server.app.runner.executors.base_runner import BaseRunner
31
+ from fractal_server.app.runner.v2.db_tools import update_status_of_history_run
32
+ from fractal_server.app.schemas.v2 import HistoryUnitStatus
33
+ from fractal_server.app.schemas.v2 import TaskDumpV2
34
+ from fractal_server.app.schemas.v2 import TaskGroupDumpV2
25
35
  from fractal_server.images.models import AttributeFiltersType
26
36
  from fractal_server.images.tools import merge_type_filters
27
37
 
@@ -30,26 +40,40 @@ def execute_tasks_v2(
30
40
  *,
31
41
  wf_task_list: list[WorkflowTaskV2],
32
42
  dataset: DatasetV2,
33
- executor: ThreadPoolExecutor,
43
+ runner: BaseRunner,
44
+ user_id: int,
34
45
  workflow_dir_local: Path,
46
+ job_id: int,
35
47
  workflow_dir_remote: Optional[Path] = None,
36
48
  logger_name: Optional[str] = None,
37
- submit_setup_call: Callable = no_op_submit_setup_call,
49
+ get_runner_config: Callable[
50
+ [
51
+ WorkflowTaskV2,
52
+ Literal["non_parallel", "parallel"],
53
+ Optional[Path],
54
+ ],
55
+ Any,
56
+ ],
57
+ job_type_filters: dict[str, bool],
38
58
  job_attribute_filters: AttributeFiltersType,
39
59
  ) -> None:
40
60
  logger = logging.getLogger(logger_name)
41
61
 
42
62
  if not workflow_dir_local.exists():
43
63
  logger.warning(
44
- f"Now creating {workflow_dir_local}, "
45
- "but it should have already happened."
64
+ f"Now creating {workflow_dir_local}, but it "
65
+ "should have already happened."
46
66
  )
47
67
  workflow_dir_local.mkdir()
48
68
 
69
+ # For local backend, remote and local folders are the same
70
+ if workflow_dir_remote is None:
71
+ workflow_dir_remote = workflow_dir_local
72
+
49
73
  # Initialize local dataset attributes
50
74
  zarr_dir = dataset.zarr_dir
51
75
  tmp_images = deepcopy(dataset.images)
52
- current_dataset_type_filters = deepcopy(dataset.type_filters)
76
+ current_dataset_type_filters = copy(job_type_filters)
53
77
 
54
78
  for wftask in wf_task_list:
55
79
  task = wftask.task
@@ -58,93 +82,143 @@ def execute_tasks_v2(
58
82
 
59
83
  # PRE TASK EXECUTION
60
84
 
61
- # Get filtered images
62
- type_filters = copy(current_dataset_type_filters)
63
- type_filters_patch = merge_type_filters(
64
- task_input_types=task.input_types,
65
- wftask_type_filters=wftask.type_filters,
66
- )
67
- type_filters.update(type_filters_patch)
68
- filtered_images = filter_image_list(
69
- images=tmp_images,
70
- type_filters=type_filters,
71
- attribute_filters=job_attribute_filters,
72
- )
85
+ # Filter images by types and attributes (in two steps)
86
+ if wftask.task_type in ["compound", "parallel", "non_parallel"]:
87
+ # Non-converter task
88
+ type_filters = copy(current_dataset_type_filters)
89
+ type_filters_patch = merge_type_filters(
90
+ task_input_types=task.input_types,
91
+ wftask_type_filters=wftask.type_filters,
92
+ )
93
+ type_filters.update(type_filters_patch)
94
+ type_filtered_images = filter_image_list(
95
+ images=tmp_images,
96
+ type_filters=type_filters,
97
+ attribute_filters=None,
98
+ )
99
+ num_available_images = len(type_filtered_images)
100
+ filtered_images = filter_image_list(
101
+ images=type_filtered_images,
102
+ type_filters=None,
103
+ attribute_filters=job_attribute_filters,
104
+ )
105
+ else:
106
+ # Converter task
107
+ filtered_images = []
108
+ num_available_images = 0
73
109
 
74
- # First, set status SUBMITTED in dataset.history for each wftask
75
110
  with next(get_sync_db()) as db:
76
- db_dataset = db.get(DatasetV2, dataset.id)
77
- new_history_item = _DatasetHistoryItemV2(
78
- workflowtask=dict(
79
- **wftask.model_dump(exclude={"task"}),
80
- task=wftask.task.model_dump(),
81
- ),
82
- status=WorkflowTaskStatusTypeV2.SUBMITTED,
83
- parallelization=dict(), # FIXME: re-include parallelization
111
+ # Create dumps for workflowtask and taskgroup
112
+ workflowtask_dump = dict(
113
+ **wftask.model_dump(exclude={"task"}),
114
+ task=TaskDumpV2(**wftask.task.model_dump()).model_dump(),
115
+ )
116
+ task_group = db.get(TaskGroupV2, wftask.task.taskgroupv2_id)
117
+ task_group_dump = TaskGroupDumpV2(
118
+ **task_group.model_dump()
84
119
  ).model_dump()
85
- db_dataset.history.append(new_history_item)
86
- flag_modified(db_dataset, "history")
87
- db.merge(db_dataset)
120
+ # Create HistoryRun
121
+ history_run = HistoryRun(
122
+ dataset_id=dataset.id,
123
+ workflowtask_id=wftask.id,
124
+ job_id=job_id,
125
+ workflowtask_dump=workflowtask_dump,
126
+ task_group_dump=task_group_dump,
127
+ num_available_images=num_available_images,
128
+ status=HistoryUnitStatus.SUBMITTED,
129
+ )
130
+ db.add(history_run)
88
131
  db.commit()
132
+ db.refresh(history_run)
133
+ history_run_id = history_run.id
134
+
89
135
  # TASK EXECUTION (V2)
90
- if task.type == "non_parallel":
91
- current_task_output = run_v2_task_non_parallel(
92
- images=filtered_images,
93
- zarr_dir=zarr_dir,
94
- wftask=wftask,
95
- task=task,
96
- workflow_dir_local=workflow_dir_local,
97
- workflow_dir_remote=workflow_dir_remote,
98
- executor=executor,
99
- logger_name=logger_name,
100
- submit_setup_call=submit_setup_call,
101
- )
102
- elif task.type == "parallel":
103
- current_task_output = run_v2_task_parallel(
104
- images=filtered_images,
105
- wftask=wftask,
106
- task=task,
107
- workflow_dir_local=workflow_dir_local,
108
- workflow_dir_remote=workflow_dir_remote,
109
- executor=executor,
110
- logger_name=logger_name,
111
- submit_setup_call=submit_setup_call,
112
- )
113
- elif task.type == "compound":
114
- current_task_output = run_v2_task_compound(
115
- images=filtered_images,
116
- zarr_dir=zarr_dir,
117
- wftask=wftask,
118
- task=task,
119
- workflow_dir_local=workflow_dir_local,
120
- workflow_dir_remote=workflow_dir_remote,
121
- executor=executor,
122
- logger_name=logger_name,
123
- submit_setup_call=submit_setup_call,
124
- )
125
- else:
126
- raise ValueError(f"Unexpected error: Invalid {task.type=}.")
136
+ try:
137
+ if task.type in ["non_parallel", "converter_non_parallel"]:
138
+ outcomes_dict, num_tasks = run_v2_task_non_parallel(
139
+ images=filtered_images,
140
+ zarr_dir=zarr_dir,
141
+ wftask=wftask,
142
+ task=task,
143
+ workflow_dir_local=workflow_dir_local,
144
+ workflow_dir_remote=workflow_dir_remote,
145
+ runner=runner,
146
+ get_runner_config=get_runner_config,
147
+ history_run_id=history_run_id,
148
+ dataset_id=dataset.id,
149
+ user_id=user_id,
150
+ task_type=task.type,
151
+ )
152
+ elif task.type == "parallel":
153
+ outcomes_dict, num_tasks = run_v2_task_parallel(
154
+ images=filtered_images,
155
+ wftask=wftask,
156
+ task=task,
157
+ workflow_dir_local=workflow_dir_local,
158
+ workflow_dir_remote=workflow_dir_remote,
159
+ runner=runner,
160
+ get_runner_config=get_runner_config,
161
+ history_run_id=history_run_id,
162
+ dataset_id=dataset.id,
163
+ user_id=user_id,
164
+ )
165
+ elif task.type in ["compound", "converter_compound"]:
166
+ outcomes_dict, num_tasks = run_v2_task_compound(
167
+ images=filtered_images,
168
+ zarr_dir=zarr_dir,
169
+ wftask=wftask,
170
+ task=task,
171
+ workflow_dir_local=workflow_dir_local,
172
+ workflow_dir_remote=workflow_dir_remote,
173
+ runner=runner,
174
+ get_runner_config=get_runner_config,
175
+ history_run_id=history_run_id,
176
+ dataset_id=dataset.id,
177
+ task_type=task.type,
178
+ user_id=user_id,
179
+ )
180
+ else:
181
+ raise ValueError(f"Unexpected error: Invalid {task.type=}.")
182
+ except Exception as e:
183
+ outcomes_dict = {
184
+ 0: SubmissionOutcome(
185
+ result=None,
186
+ exception=e,
187
+ )
188
+ }
189
+ num_tasks = 0
127
190
 
128
191
  # POST TASK EXECUTION
129
192
 
130
- # If `current_task_output` includes no images (to be created, edited or
131
- # removed), then flag all the input images as modified. See
132
- # fractal-server issue #1374.
133
- if (
134
- current_task_output.image_list_updates == []
135
- and current_task_output.image_list_removals == []
136
- ):
137
- current_task_output = TaskOutput(
138
- **current_task_output.model_dump(
139
- exclude={"image_list_updates"}
140
- ),
141
- image_list_updates=[
142
- dict(zarr_url=img["zarr_url"]) for img in filtered_images
143
- ],
144
- )
193
+ non_failed_task_outputs = [
194
+ value.task_output
195
+ for value in outcomes_dict.values()
196
+ if value.task_output is not None
197
+ ]
198
+ if len(non_failed_task_outputs) > 0:
199
+ current_task_output = merge_outputs(non_failed_task_outputs)
200
+ # If `current_task_output` includes no images (to be created or
201
+ # removed), then flag all the input images as modified.
202
+ # See fractal-server issues #1374 and #2409.
203
+ if (
204
+ current_task_output.image_list_updates == []
205
+ and current_task_output.image_list_removals == []
206
+ ):
207
+ current_task_output = TaskOutput(
208
+ image_list_updates=[
209
+ dict(zarr_url=img["zarr_url"])
210
+ for img in filtered_images
211
+ ],
212
+ )
213
+ else:
214
+ current_task_output = TaskOutput()
145
215
 
146
216
  # Update image list
217
+ num_new_images = 0
147
218
  current_task_output.check_zarr_urls_are_unique()
219
+ # NOTE: In principle we could make the task-output processing more
220
+ # granular, and also associate output-processing failures to history
221
+ # status.
148
222
  for image_obj in current_task_output.image_list_updates:
149
223
  image = image_obj.model_dump()
150
224
  # Edit existing image
@@ -246,6 +320,7 @@ def execute_tasks_v2(
246
320
  SingleImage(**new_image)
247
321
  # Add image into the dataset image list
248
322
  tmp_images.append(new_image)
323
+ num_new_images += 1
249
324
 
250
325
  # Remove images from tmp_images
251
326
  for img_zarr_url in current_task_output.image_list_removals:
@@ -263,22 +338,68 @@ def execute_tasks_v2(
263
338
  type_filters_from_task_manifest = task.output_types
264
339
  current_dataset_type_filters.update(type_filters_from_task_manifest)
265
340
 
266
- # Write current dataset attributes (history, images, filters) into the
267
- # database. They can be used (1) to retrieve the latest state
268
- # when the job fails, (2) from within endpoints that need up-to-date
269
- # information
270
341
  with next(get_sync_db()) as db:
342
+ # Write current dataset images into the database.
271
343
  db_dataset = db.get(DatasetV2, dataset.id)
272
- db_dataset.history[-1]["status"] = WorkflowTaskStatusTypeV2.DONE
273
- db_dataset.type_filters = current_dataset_type_filters
274
344
  db_dataset.images = tmp_images
275
- for attribute_name in [
276
- "type_filters",
277
- "history",
278
- "images",
279
- ]:
280
- flag_modified(db_dataset, attribute_name)
345
+ flag_modified(db_dataset, "images")
281
346
  db.merge(db_dataset)
347
+
348
+ db.execute(
349
+ delete(HistoryImageCache)
350
+ .where(HistoryImageCache.dataset_id == dataset.id)
351
+ .where(HistoryImageCache.workflowtask_id == wftask.id)
352
+ .where(
353
+ HistoryImageCache.zarr_url.in_(
354
+ current_task_output.image_list_removals
355
+ )
356
+ )
357
+ )
358
+
359
+ db.commit()
360
+ db.close() # NOTE: this is needed, but the reason is unclear
361
+
362
+ # Create accounting record
363
+ record = AccountingRecord(
364
+ user_id=user_id,
365
+ num_tasks=num_tasks,
366
+ num_new_images=num_new_images,
367
+ )
368
+ db.add(record)
282
369
  db.commit()
283
370
 
284
- logger.debug(f'END {wftask.order}-th task (name="{task_name}")')
371
+ # Update `HistoryRun` entry, and raise an error if task failed
372
+ try:
373
+ first_exception = next(
374
+ value.exception
375
+ for value in outcomes_dict.values()
376
+ if value.exception is not None
377
+ )
378
+ # An exception was found
379
+ update_status_of_history_run(
380
+ history_run_id=history_run_id,
381
+ status=HistoryUnitStatus.FAILED,
382
+ db_sync=db,
383
+ )
384
+ logger.error(
385
+ f'END {wftask.order}-th task (name="{task_name}") - '
386
+ "ERROR."
387
+ )
388
+ # Raise first error
389
+ raise JobExecutionError(
390
+ info=(
391
+ f"An error occurred.\n"
392
+ f"Original error:\n{first_exception}"
393
+ )
394
+ )
395
+ except StopIteration:
396
+ # No exception was found
397
+ update_status_of_history_run(
398
+ history_run_id=history_run_id,
399
+ status=HistoryUnitStatus.DONE,
400
+ db_sync=db,
401
+ )
402
+ db.commit()
403
+ logger.debug(
404
+ f'END {wftask.order}-th task (name="{task_name}")'
405
+ )