jadicorn commited on
Commit
8af11d2
·
verified ·
1 Parent(s): 97cedce

Add debugging information to orchestrator_sql.py

Browse files
huggingface_overlay/cloud_pipelines_backend/orchestrator_sql.py ADDED
@@ -0,0 +1,1005 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import copy
2
+ import json
3
+ import datetime
4
+ import logging
5
+ import time
6
+ import traceback
7
+ import typing
8
+ from typing import Any
9
+
10
+
11
+ import sqlalchemy as sql
12
+ from sqlalchemy import orm
13
+
14
+ from cloud_pipelines.orchestration.storage_providers import (
15
+ interfaces as storage_provider_interfaces,
16
+ )
17
+ from cloud_pipelines.orchestration.launchers import naming_utils
18
+
19
+ from . import backend_types_sql as bts
20
+ from . import component_structures as structures
21
+ from .launchers import common_annotations
22
+ from .launchers import interfaces as launcher_interfaces
23
+
24
+
25
+ _logger = logging.getLogger(__name__)
26
+
27
+ _T = typing.TypeVar("_T")
28
+
29
+
30
+ class OrchestratorError(RuntimeError):
31
+ pass
32
+
33
+
34
+ class OrchestratorService_Sql:
35
+ def __init__(
36
+ self,
37
+ session_factory: typing.Callable[[], orm.Session],
38
+ launcher: launcher_interfaces.ContainerTaskLauncher[
39
+ launcher_interfaces.LaunchedContainer
40
+ ],
41
+ storage_provider: storage_provider_interfaces.StorageProvider,
42
+ data_root_uri: str,
43
+ logs_root_uri: str,
44
+ default_task_annotations: dict[str, Any] | None = None,
45
+ sleep_seconds_between_queue_sweeps: float = 1.0,
46
+ output_data_purge_duration: datetime.timedelta = None,
47
+ ):
48
+ self._session_factory = session_factory
49
+ self._launcher = launcher
50
+ self._storage_provider = storage_provider
51
+ self._data_root_uri = data_root_uri
52
+ self._logs_root_uri = logs_root_uri
53
+ self._default_task_annotations = default_task_annotations
54
+ self._sleep_seconds_between_queue_sweeps = sleep_seconds_between_queue_sweeps
55
+ self._queued_executions_queue_idle = False
56
+ self._running_executions_queue_idle = False
57
+ self._output_data_purge_duration = output_data_purge_duration
58
+
59
+ def run_loop(self):
60
+ while True:
61
+ try:
62
+ self.process_each_queue_once()
63
+ time.sleep(self._sleep_seconds_between_queue_sweeps)
64
+ except:
65
+ _logger.exception("Error while calling `process_each_queue_once`")
66
+
67
+ def process_each_queue_once(self):
68
+ queue_handlers = [
69
+ self.internal_process_queued_executions_queue,
70
+ self.internal_process_running_executions_queue,
71
+ ]
72
+ for queue_handler in queue_handlers:
73
+ try:
74
+ with self._session_factory() as session:
75
+ queue_handler(session=session)
76
+ except:
77
+ _logger.exception(f"Error while executing {queue_handler=}")
78
+
79
+ def internal_process_queued_executions_queue(self, session: orm.Session):
80
+ query = (
81
+ sql.select(bts.ExecutionNode).where(
82
+ bts.ExecutionNode.container_execution_status.in_(
83
+ (
84
+ bts.ContainerExecutionStatus.UNINITIALIZED,
85
+ bts.ContainerExecutionStatus.QUEUED,
86
+ )
87
+ )
88
+ )
89
+ # TODO: Maybe add last_processed_at
90
+ # .order_by(bts.ExecutionNode.last_processed_at)
91
+ # .order_by(bts.ExecutionNode.id)
92
+ .limit(1)
93
+ )
94
+ queued_execution = session.scalar(query)
95
+ if queued_execution:
96
+ self._queued_executions_queue_idle = False
97
+ _logger.info(f"Before processing {queued_execution.id=}")
98
+ try:
99
+ self.internal_process_one_queued_execution(
100
+ session=session, execution=queued_execution
101
+ )
102
+ except Exception as ex:
103
+ _logger.exception(f"Error processing {queued_execution.id=}")
104
+ session.rollback()
105
+ queued_execution.container_execution_status = (
106
+ bts.ContainerExecutionStatus.SYSTEM_ERROR
107
+ )
108
+ record_system_error_exception(execution=queued_execution, exception=ex)
109
+ session.commit()
110
+ _logger.info(f"After processing {queued_execution.id=}")
111
+ return True
112
+ else:
113
+ if not self._queued_executions_queue_idle:
114
+ self._queued_executions_queue_idle = True
115
+ _logger.debug(f"No queued executions found")
116
+ return False
117
+
118
+ def internal_process_running_executions_queue(self, session: orm.Session):
119
+ query = (
120
+ sql.select(bts.ContainerExecution)
121
+ .where(
122
+ bts.ContainerExecution.status.in_(
123
+ (
124
+ bts.ContainerExecutionStatus.PENDING,
125
+ bts.ContainerExecutionStatus.RUNNING,
126
+ )
127
+ )
128
+ )
129
+ .order_by(bts.ContainerExecution.last_processed_at)
130
+ .limit(1)
131
+ )
132
+ running_container_execution = session.scalar(query)
133
+ if running_container_execution:
134
+ self._running_executions_queue_idle = False
135
+ try:
136
+ _logger.info(f"Before processing {running_container_execution.id=}")
137
+ self.internal_process_one_running_execution(
138
+ session=session, container_execution=running_container_execution
139
+ )
140
+ _logger.info(f"After processing {running_container_execution.id=}")
141
+ except Exception as ex:
142
+ _logger.exception(f"Error processing {running_container_execution.id=}")
143
+ session.rollback()
144
+ running_container_execution.status = (
145
+ bts.ContainerExecutionStatus.SYSTEM_ERROR
146
+ )
147
+ # Doing an intermediate commit here because it's most important to mark the problematic execution as SYSTEM_ERROR.
148
+ session.commit()
149
+ # Mark our ExecutionNode as SYSTEM_ERROR
150
+ execution_nodes = running_container_execution.execution_nodes
151
+ for execution_node in execution_nodes:
152
+ execution_node.container_execution_status = (
153
+ bts.ContainerExecutionStatus.SYSTEM_ERROR
154
+ )
155
+ record_system_error_exception(
156
+ execution=execution_node, exception=ex
157
+ )
158
+ # Doing an intermediate commit here because it's most important to mark the problematic node as SYSTEM_ERROR.
159
+ session.commit()
160
+ # Skip downstream executions
161
+ for execution_node in execution_nodes:
162
+ _mark_all_downstream_executions_as_skipped(
163
+ session=session, execution=execution_node
164
+ )
165
+ session.commit()
166
+ return True
167
+ else:
168
+ if not self._running_executions_queue_idle:
169
+ _logger.debug(f"No running container executions found")
170
+ self._running_executions_queue_idle = True
171
+ return False
172
+
173
+ def internal_process_one_queued_execution(
174
+ self, session: orm.Session, execution: bts.ExecutionNode
175
+ ):
176
+ task_spec = structures.TaskSpec.from_json_dict(execution.task_spec)
177
+ component_spec = task_spec.component_ref.spec
178
+ if component_spec is None:
179
+ raise OrchestratorError(
180
+ f"Missing ComponentSpec. {task_spec.component_ref=}"
181
+ )
182
+ implementation = component_spec.implementation
183
+ if not implementation:
184
+ raise OrchestratorError(f"Missing implementation. {component_spec=}")
185
+ if implementation is None or not isinstance(
186
+ implementation, structures.ContainerImplementation
187
+ ):
188
+ raise OrchestratorError(
189
+ f"Implementation must be container. {implementation=}"
190
+ )
191
+ container_spec = implementation.container
192
+ input_artifact_data = dict(
193
+ session.execute(
194
+ sql.select(bts.InputArtifactLink.input_name, bts.ArtifactData)
195
+ .where(bts.InputArtifactLink.execution == execution)
196
+ .join(bts.InputArtifactLink.artifact)
197
+ .join(bts.ArtifactNode.artifact_data, isouter=True)
198
+ )
199
+ .tuples()
200
+ .all()
201
+ )
202
+ if None in input_artifact_data.values():
203
+ _logger.info(
204
+ f"Execution did not have all input artifact data present. Waiting for upstream. {execution.id=}"
205
+ )
206
+ execution.container_execution_status = (
207
+ bts.ContainerExecutionStatus.WAITING_FOR_UPSTREAM
208
+ )
209
+ session.commit()
210
+ return
211
+
212
+ cache_key = _calculate_container_execution_cache_key(
213
+ container_spec=container_spec, input_artifact_data=input_artifact_data
214
+ )
215
+
216
+ # Trying to reuse an older execution from cache.
217
+ max_cache_staleness_str: str | None = None
218
+ if task_spec.execution_options and task_spec.execution_options.caching_strategy:
219
+ max_cache_staleness_str = (
220
+ task_spec.execution_options.caching_strategy.max_cache_staleness
221
+ )
222
+
223
+ # TODO: Support other ways to express 0-length time period.
224
+ if max_cache_staleness_str == "P0D":
225
+ execution_candidates: typing.Sequence[bts.ExecutionNode] = []
226
+ else:
227
+ # TODO: Support cache reuse options (max cached data staleness etc).
228
+ # TODO: It might be better to search for ContainerExecutions rather than ExecutionNodes.
229
+ execution_candidates = session.scalars(
230
+ sql.select(bts.ExecutionNode)
231
+ .where(bts.ExecutionNode.container_execution_cache_key == cache_key)
232
+ .where(
233
+ bts.ExecutionNode.container_execution_status.in_(
234
+ [
235
+ # We can reuse both succeeded executions and also non yet finished ones.
236
+ # Reusing still running executions is important since it allows cache reuse
237
+ # when multiple versions of a pipeline are submitted in parallel.
238
+ # bts.ContainerExecutionStatus.STARTING, # Doesn't exist yet
239
+ bts.ContainerExecutionStatus.PENDING,
240
+ bts.ContainerExecutionStatus.RUNNING,
241
+ bts.ContainerExecutionStatus.SUCCEEDED,
242
+ ]
243
+ )
244
+ )
245
+ # TODO: Filter by purged==False. Or `expires_at`.
246
+ .join(bts.ContainerExecution)
247
+ .order_by(bts.ContainerExecution.created_at.desc())
248
+ ).all()
249
+ non_purged_candidates = [
250
+ execution_candidate
251
+ for execution_candidate in execution_candidates
252
+ if not (execution_candidate.extra_data or {}).get("is_purged", False)
253
+ ]
254
+ if self._output_data_purge_duration:
255
+ current_time = datetime.datetime.now(datetime.timezone.utc)
256
+ data_purge_threshold_time = current_time - self._output_data_purge_duration
257
+ non_purged_candidates = [
258
+ execution_candidate
259
+ for execution_candidate in execution_candidates
260
+ # The `execution_candidate.container_execution` attribute access accesses the DB.
261
+ # TODO: Move the time filtering to the DB side.
262
+ # TODO: Filter by `ended_at`, not `created_at`.
263
+ # Note: Need `.astimezone` to avoid `TypeError: can't compare offset-naive and offset-aware datetimes`
264
+ if execution_candidate.container_execution.created_at.astimezone(
265
+ datetime.timezone.utc
266
+ )
267
+ > data_purge_threshold_time
268
+ ]
269
+
270
+ if non_purged_candidates:
271
+ # Re-using the oldest candidate
272
+ # TODO: Use better cached execution selection strategy:
273
+ # 1. Try to reuse the execution with `max_cache_staleness in extra_data["used_for_max_cache_staleness"]`. There should be 0 or 1 of those.
274
+ # 2. Else: Try to reuse the latest SUCCEEDED execution. Mark it `extra_data["used_for_max_cache_staleness"][max_cache_staleness] = True`.
275
+ # 3. Else: Try to reuse the latest RUNNING/PENDING execution. Mark it `extra_data["used_for_max_cache_staleness"][max_cache_staleness] = True`.
276
+ # There must be at least one SUCCEEDED/RUNNING/PENDING since non_purged_candidates is non-empty.
277
+ old_execution = non_purged_candidates[-1]
278
+ _logger.info(
279
+ f"Execution {execution.id=} will reuse the {old_execution.id=} with "
280
+ f"{old_execution.container_execution_id=}, {old_execution.container_execution_status=}"
281
+ )
282
+ # Reusing the execution:
283
+ if not execution.extra_data:
284
+ execution.extra_data = {}
285
+ execution.extra_data["reused_from_execution_node_id"] = old_execution.id
286
+
287
+ execution.container_execution_status = (
288
+ old_execution.container_execution_status
289
+ )
290
+
291
+ # If the execution is still running, it's enough to just link execution node to it.
292
+ # The container execution will set the outputs itself when it succeeds.
293
+ execution.container_execution_id = old_execution.container_execution_id
294
+ # However if the container execution has already ended, we need to copy the outputs ourselves.
295
+ if (
296
+ old_execution.container_execution_status
297
+ == bts.ContainerExecutionStatus.SUCCEEDED
298
+ ):
299
+ # Copying the output artifact data (if the execution already succeeded).
300
+ reused_execution_output_artifact_data_ids = {
301
+ output_name: artifact_data_id
302
+ for output_name, artifact_data_id in session.execute(
303
+ sql.select(
304
+ bts.OutputArtifactLink.output_name,
305
+ bts.ArtifactNode.artifact_data_id,
306
+ )
307
+ .join(bts.OutputArtifactLink.artifact)
308
+ .where(bts.OutputArtifactLink.execution_id == old_execution.id)
309
+ ).tuples()
310
+ }
311
+ # Setting artifact data
312
+ for output_name, artifact_node in session.execute(
313
+ sql.select(bts.OutputArtifactLink.output_name, bts.ArtifactNode)
314
+ # TODO: Verify that this improves the performance
315
+ .with_for_update()
316
+ .join(bts.OutputArtifactLink.artifact)
317
+ .where(bts.OutputArtifactLink.execution_id == execution.id)
318
+ ).tuples():
319
+ artifact_node.artifact_data_id = (
320
+ reused_execution_output_artifact_data_ids[output_name]
321
+ )
322
+ # Waking up all direct downstream executions.
323
+ # Many of them will get processed and go back to WAITING_FOR_UPSTREAM state.
324
+ for downstream_execution in _get_direct_downstream_executions(
325
+ session=session, execution=execution
326
+ ):
327
+ if (
328
+ downstream_execution.container_execution_status
329
+ == bts.ContainerExecutionStatus.WAITING_FOR_UPSTREAM
330
+ ):
331
+ downstream_execution.container_execution_status = (
332
+ bts.ContainerExecutionStatus.QUEUED
333
+ )
334
+ session.commit()
335
+ return
336
+
337
+ # Launching the container execution
338
+
339
+ pipeline_run = session.scalar(
340
+ sql.select(bts.PipelineRun)
341
+ .join(
342
+ bts.ExecutionToAncestorExecutionLink,
343
+ bts.ExecutionToAncestorExecutionLink.ancestor_execution_id
344
+ == bts.PipelineRun.root_execution_id,
345
+ )
346
+ .where(bts.ExecutionToAncestorExecutionLink.execution_id == execution.id)
347
+ )
348
+ session.rollback()
349
+
350
+ if pipeline_run is None:
351
+ raise OrchestratorError(
352
+ f"Execution {execution} is not associated with a PipelineRun."
353
+ )
354
+
355
+ # Handling cancellation.
356
+ # In case of cancellation, we do not create container execution and we skip all downstream executions.
357
+ should_terminate_execution = (execution.extra_data or {}).get(
358
+ "desired_state"
359
+ ) == "TERMINATED"
360
+ should_terminate_run = (pipeline_run.extra_data or {}).get(
361
+ "desired_state"
362
+ ) == "TERMINATED"
363
+ should_terminate = should_terminate_execution or should_terminate_run
364
+ if should_terminate:
365
+ _logger.info(
366
+ f"Cancelling execution {execution.id} and skipping all downstream executions."
367
+ )
368
+ execution.container_execution_status = (
369
+ bts.ContainerExecutionStatus.CANCELLED
370
+ )
371
+ _mark_all_downstream_executions_as_skipped(
372
+ session=session, execution=execution
373
+ )
374
+
375
+ # Creating new container execution
376
+ container_execution_uuid = _generate_random_id()
377
+
378
+ _ARTIFACT_PATH_LAST_PART = "data"
379
+ _LOG_FILE_NAME = "log.txt"
380
+ # _TIMESTAMPED_LOG_FILE_NAME = "timestamped.log.txt"
381
+ # _STDOUT_LOG_FILE_NAME = "stdout.log.txt"
382
+ # _STDERR_LOG_FILE_NAME = "stderr.log.txt"
383
+
384
+ # Input URIs might be needed to write constant input data.
385
+ # Or to prevent the data from being purged during the execution.
386
+ # TODO: Ensure unique artifact URIs (they can become non-unique due to sanitization)
387
+ def generate_input_artifact_uri(
388
+ root_dir: str,
389
+ execution_id: str,
390
+ input_name: str,
391
+ ) -> str:
392
+ root_dir = root_dir.rstrip("/")
393
+ execution_id_str = naming_utils.sanitize_file_name(execution_id)
394
+ input_name_str = naming_utils.sanitize_file_name(input_name)
395
+ return f"{root_dir}/by_execution/{execution_id_str}/inputs/{input_name_str}/{_ARTIFACT_PATH_LAST_PART}"
396
+
397
+ def generate_output_artifact_uri(
398
+ root_dir: str,
399
+ execution_id: str,
400
+ output_name: str,
401
+ ) -> str:
402
+ root_dir = root_dir.rstrip("/")
403
+ execution_id_str = naming_utils.sanitize_file_name(execution_id)
404
+ output_name_str = naming_utils.sanitize_file_name(output_name)
405
+ return f"{root_dir}/by_execution/{execution_id_str}/outputs/{output_name_str}/{_ARTIFACT_PATH_LAST_PART}"
406
+
407
+ def generate_execution_log_uri(
408
+ root_dir: str,
409
+ execution_id: str,
410
+ ) -> str:
411
+ root_dir = root_dir.rstrip("/")
412
+ execution_id_str = naming_utils.sanitize_file_name(execution_id)
413
+ return f"{root_dir}/by_execution/{execution_id_str}/{_LOG_FILE_NAME}"
414
+
415
+ log_uri = generate_execution_log_uri(
416
+ root_dir=self._logs_root_uri, execution_id=container_execution_uuid
417
+ )
418
+ output_artifact_uris = {
419
+ output_spec.name: generate_output_artifact_uri(
420
+ root_dir=self._data_root_uri,
421
+ execution_id=container_execution_uuid,
422
+ output_name=output_spec.name,
423
+ )
424
+ for output_spec in component_spec.outputs or []
425
+ }
426
+
427
+ input_arguments = {
428
+ input_name: launcher_interfaces.InputArgument(
429
+ total_size=artifact_data.total_size,
430
+ is_dir=artifact_data.is_dir,
431
+ value=artifact_data.value,
432
+ uri=artifact_data.uri,
433
+ staging_uri=generate_input_artifact_uri(
434
+ root_dir=self._data_root_uri,
435
+ execution_id=container_execution_uuid,
436
+ input_name=input_name,
437
+ ),
438
+ )
439
+ for input_name, artifact_data in input_artifact_data.items()
440
+ }
441
+
442
+ # Handling annotations
443
+
444
+ full_annotations = {}
445
+ _update_dict_recursive(
446
+ full_annotations, copy.deepcopy(self._default_task_annotations or {})
447
+ )
448
+ _update_dict_recursive(
449
+ full_annotations, copy.deepcopy(pipeline_run.annotations or {})
450
+ )
451
+ _update_dict_recursive(
452
+ full_annotations, copy.deepcopy(task_spec.annotations or {})
453
+ )
454
+
455
+ full_annotations[common_annotations.PIPELINE_RUN_CREATED_BY_ANNOTATION_KEY] = (
456
+ pipeline_run.created_by
457
+ )
458
+ full_annotations[common_annotations.PIPELINE_RUN_ID_ANNOTATION_KEY] = (
459
+ pipeline_run.id
460
+ )
461
+ full_annotations[common_annotations.EXECUTION_NODE_ID_ANNOTATION_KEY] = (
462
+ execution.id
463
+ )
464
+ full_annotations[common_annotations.CONTAINER_EXECUTION_ID_ANNOTATION_KEY] = (
465
+ container_execution_uuid
466
+ )
467
+
468
+ try:
469
+ launched_container: launcher_interfaces.LaunchedContainer = (
470
+ self._launcher.launch_container_task(
471
+ component_spec=component_spec,
472
+ # The value and uri might be updated during launching.
473
+ input_arguments=input_arguments,
474
+ output_uris=output_artifact_uris,
475
+ log_uri=log_uri,
476
+ annotations=full_annotations,
477
+ )
478
+ )
479
+ if launched_container.status not in (
480
+ launcher_interfaces.ContainerStatus.PENDING,
481
+ launcher_interfaces.ContainerStatus.RUNNING,
482
+ ):
483
+ raise OrchestratorError(
484
+ f"Unexpected status of just launched container: {launched_container.status=}, {launched_container=}"
485
+ )
486
+ except Exception as ex:
487
+ session.rollback()
488
+ with session.begin():
489
+ # Logs whole exception
490
+ _logger.exception(f"Error launching container for {execution.id=}")
491
+ execution.container_execution_status = (
492
+ bts.ContainerExecutionStatus.SYSTEM_ERROR
493
+ )
494
+ record_system_error_exception(execution=execution, exception=ex)
495
+ _mark_all_downstream_executions_as_skipped(
496
+ session=session, execution=execution
497
+ )
498
+ return
499
+
500
+ current_time = _get_current_time()
501
+
502
+ container_execution = bts.ContainerExecution(
503
+ status=bts.ContainerExecutionStatus(launched_container.status),
504
+ last_processed_at=current_time,
505
+ created_at=current_time,
506
+ launcher_data=launched_container.to_dict(),
507
+ # Updating the map with uploaded value URIs and downloaded values.
508
+ input_artifact_data_map={
509
+ input_name: dict(
510
+ id=artifact_data.id,
511
+ is_dir=artifact_data.is_dir,
512
+ total_size=artifact_data.total_size,
513
+ hash=artifact_data.hash,
514
+ # The value and URI might have been updated during launching.
515
+ value=input_arguments[input_name].value,
516
+ uri=input_arguments[input_name].uri,
517
+ )
518
+ for input_name, artifact_data in input_artifact_data.items()
519
+ },
520
+ output_artifact_data_map={
521
+ output_name: dict(
522
+ uri=uri,
523
+ )
524
+ for output_name, uri in output_artifact_uris.items()
525
+ },
526
+ log_uri=log_uri,
527
+ )
528
+ container_execution.id = container_execution_uuid
529
+
530
+ # Need to commit/rollback before explicitly beginning non-nested transaction.
531
+ session.rollback()
532
+ with session.begin():
533
+ session.add(container_execution)
534
+ execution.container_execution = container_execution
535
+ execution.container_execution_cache_key = cache_key
536
+ execution.container_execution_status = container_execution.status
537
+ # TODO: Maybe add artifact value and URI to input ArtifactData.
538
+
539
+ def internal_process_one_running_execution(
540
+ self, session: orm.Session, container_execution: bts.ContainerExecution
541
+ ):
542
+ # Should we update last_processed_at early? Should we do it always (even when there are errors).
543
+ # ALways updating the last_processed_at such that a problematic container does not get queue stuck.
544
+ session.expire_on_commit = False
545
+ session.rollback()
546
+ with session.begin():
547
+ container_execution.last_processed_at = _get_current_time()
548
+ launcher_data = _assert_not_none(container_execution.launcher_data)
549
+ launched_container: launcher_interfaces.LaunchedContainer = (
550
+ self._launcher.deserialize_launched_container_from_dict(launcher_data)
551
+ )
552
+ previous_status = launched_container.status
553
+ if previous_status not in (
554
+ bts.ContainerExecutionStatus.PENDING,
555
+ bts.ContainerExecutionStatus.RUNNING,
556
+ ):
557
+ raise OrchestratorError(
558
+ f"Unexpected running container status: {previous_status=}, {launched_container=}"
559
+ )
560
+
561
+ # Handling cancellation
562
+ votes_to_terminate = []
563
+ votes_to_not_terminate = []
564
+ # TODO: Get the desired state from the pipeline runs, not execution nodes
565
+ execution_nodes = container_execution.execution_nodes
566
+ for execution_node in execution_nodes:
567
+ should_terminate = (execution_node.extra_data or {}).get(
568
+ "desired_state"
569
+ ) == "TERMINATED"
570
+ if should_terminate:
571
+ votes_to_terminate.append(execution_node)
572
+ else:
573
+ votes_to_not_terminate.append(execution_node)
574
+
575
+ if votes_to_terminate:
576
+ # Terminate the container execution only when all execution nodes pointing to it are asked to terminate.
577
+ terminated = False
578
+ if votes_to_not_terminate:
579
+ _logger.info(
580
+ f"Not terminating container execution {container_execution.id=} since some other executions ({[execution_node.id for execution_node in votes_to_not_terminate]}) are still using it."
581
+ )
582
+ else:
583
+ _logger.info(
584
+ f"Terminating container execution {container_execution.id}."
585
+ )
586
+ # We should preserve the logs before terminating/deleting the container
587
+ try:
588
+ _retry(lambda: launched_container.upload_log())
589
+ except:
590
+ _logger.exception(
591
+ f"Error uploading logs for {container_execution.id=} before termination."
592
+ )
593
+ # Requesting container termination.
594
+ # Termination might not happen immediately (e.g. Kubernetes has grace period).
595
+ launched_container.terminate()
596
+ container_execution.ended_at = _get_current_time()
597
+ # We need to mark the execution as CANCELLED otherwise orchestrator will continue polling it.
598
+ container_execution.status = bts.ContainerExecutionStatus.CANCELLED
599
+ terminated = True
600
+
601
+ # Mark the execution nodes as cancelled only after the launched container is successfully terminated (if needed)
602
+ for execution_node in votes_to_terminate:
603
+ _logger.info(
604
+ f"Cancelling execution {execution_node.id} ({container_execution.id=}) and skipping all downstream executions."
605
+ )
606
+ execution_node.container_execution_status = (
607
+ bts.ContainerExecutionStatus.CANCELLED
608
+ )
609
+ _mark_all_downstream_executions_as_skipped(
610
+ session=session, execution=execution_node
611
+ )
612
+ session.commit()
613
+ if terminated:
614
+ return
615
+
616
+ # Asking the launcher to refresh the container state.
617
+ reloaded_launched_container: launcher_interfaces.LaunchedContainer = (
618
+ self._launcher.get_refreshed_launched_container_from_dict(launcher_data)
619
+ )
620
+ current_time = _get_current_time()
621
+ # Saving the updated launcher data
622
+ reloaded_launcher_data = reloaded_launched_container.to_dict()
623
+ if reloaded_launcher_data != launcher_data:
624
+ session.rollback()
625
+ with session.begin():
626
+ import json
627
+
628
+ try:
629
+ json.dumps(reloaded_launcher_data)
630
+ except TypeError as e:
631
+ print("launcher_data JSON ERROR:", e)
632
+ print("launcher_data:", repr(reloaded_launcher_data))
633
+ raise
634
+
635
+ container_execution.launcher_data = reloaded_launcher_data
636
+ container_execution.updated_at = current_time
637
+ new_status: launcher_interfaces.ContainerStatus = (
638
+ reloaded_launched_container.status
639
+ )
640
+ if new_status == previous_status:
641
+ _logger.info(
642
+ f"Container execution {container_execution.id} remains in {new_status} state."
643
+ )
644
+ return
645
+ _logger.info(
646
+ f"Container execution {container_execution.id} is now in state {new_status} (was {previous_status})."
647
+ )
648
+ session.rollback()
649
+ container_execution.updated_at = current_time
650
+ execution_nodes = container_execution.execution_nodes
651
+ if not execution_nodes:
652
+ raise OrchestratorError(
653
+ f"Could not find ExecutionNode associated with ContainerExecution. {container_execution.id=}"
654
+ )
655
+ if len(execution_nodes) > 1:
656
+ execution_node_ids = [execution.id for execution in execution_nodes]
657
+ _logger.warning(
658
+ f"ContainerExecution is associated with multiple ExecutionNodes: {container_execution.id=}, {execution_node_ids=}"
659
+ )
660
+
661
+ if new_status == launcher_interfaces.ContainerStatus.RUNNING:
662
+ container_execution.status = bts.ContainerExecutionStatus.RUNNING
663
+ container_execution.started_at = reloaded_launched_container.started_at
664
+ for execution_node in execution_nodes:
665
+ execution_node.container_execution_status = (
666
+ bts.ContainerExecutionStatus.RUNNING
667
+ )
668
+ elif new_status == launcher_interfaces.ContainerStatus.SUCCEEDED:
669
+ container_execution.status = bts.ContainerExecutionStatus.SUCCEEDED
670
+ container_execution.exit_code = reloaded_launched_container.exit_code
671
+ container_execution.started_at = reloaded_launched_container.started_at
672
+ container_execution.ended_at = reloaded_launched_container.ended_at
673
+
674
+ # Don't fail the execution if log upload fails.
675
+ # Logs are important, but not so important that we should fail a successfully completed container execution.
676
+ try:
677
+ _retry(reloaded_launched_container.upload_log)
678
+ except Exception as ex:
679
+ _logger.exception(
680
+ f"! Error during `LaunchedContainer.upload_log` call: {ex}."
681
+ )
682
+
683
+ _MAX_PRELOAD_VALUE_SIZE = 255
684
+
685
+ def _maybe_preload_value(
686
+ uri_reader: storage_provider_interfaces.UriReader,
687
+ data_info: storage_provider_interfaces.DataInfo,
688
+ ) -> str | None:
689
+ """Preloads artifact value is it's small enough (e.g. <=255 bytes)"""
690
+ if (
691
+ not data_info.is_dir
692
+ and data_info.total_size < _MAX_PRELOAD_VALUE_SIZE
693
+ ):
694
+ # Don't fail the execution if small value preloading fails.
695
+ # Those values may be useful for preservation, but not so important that we should fail a successfully completed container execution.
696
+ try:
697
+ data = uri_reader.download_as_bytes()
698
+ except Exception as ex:
699
+ _logger.exception(
700
+ f"Error during preloading small artifact values."
701
+ )
702
+ return None
703
+ try:
704
+ text = data.decode("utf-8")
705
+ return text
706
+ except:
707
+ pass
708
+
709
+ output_artifact_uris: dict[str, str] = {
710
+ output_name: output_artifact_info_dict["uri"]
711
+ for output_name, output_artifact_info_dict in container_execution.output_artifact_data_map.items()
712
+ }
713
+
714
+ # We need to first check that the output data exists. Otherwise `uri_reader.get_info()`` throws `IndexError`
715
+ # in cloud_pipelines/orchestration/storage_providers/interfaces.py", line 172, in _make_data_info_for_dir
716
+ missing_output_names = [
717
+ output_name
718
+ for output_name, uri in output_artifact_uris.items()
719
+ if not _retry(
720
+ lambda: self._storage_provider.make_uri(uri).get_reader().exists()
721
+ )
722
+ ]
723
+
724
+ if missing_output_names:
725
+ # Marking the container execution as FAILED (even though the program itself has completed successfully)
726
+ container_execution.status = bts.ContainerExecutionStatus.FAILED
727
+ orchestration_error_message = f"Container execution {container_execution.id} is marked as FAILED due to missing outputs: {missing_output_names}."
728
+ _logger.error(orchestration_error_message)
729
+ _record_orchestration_error_message(
730
+ container_execution=container_execution,
731
+ execution_nodes=execution_nodes,
732
+ message=orchestration_error_message,
733
+ )
734
+ # Skip downstream executions
735
+ for execution_node in execution_nodes:
736
+ execution_node.container_execution_status = (
737
+ bts.ContainerExecutionStatus.FAILED
738
+ )
739
+ _mark_all_downstream_executions_as_skipped(
740
+ session=session, execution=execution_node
741
+ )
742
+ else:
743
+ output_artifact_data_info_map = {
744
+ output_name: _retry(
745
+ lambda: self._storage_provider.make_uri(uri)
746
+ .get_reader()
747
+ .get_info()
748
+ )
749
+ for output_name, uri in output_artifact_uris.items()
750
+ }
751
+ new_output_artifact_data_map = {
752
+ output_name: bts.ArtifactData(
753
+ total_size=data_info.total_size,
754
+ is_dir=data_info.is_dir,
755
+ # Using 1st hash only.
756
+ # TODO: Support multiple hashes.
757
+ hash=[
758
+ f"{hash_name}={hash_value}"
759
+ for hash_name, hash_value in data_info.hashes.items()
760
+ ][0],
761
+ uri=output_artifact_uris[output_name],
762
+ # Preloading artifact value is it's small enough (e.g. <=255 bytes)
763
+ value=_retry(
764
+ lambda: _maybe_get_small_artifact_value(
765
+ uri_reader=self._storage_provider.make_uri(
766
+ output_artifact_uris[output_name]
767
+ ).get_reader(),
768
+ data_info=data_info,
769
+ )
770
+ ),
771
+ created_at=current_time,
772
+ )
773
+ for output_name, data_info in output_artifact_data_info_map.items()
774
+ }
775
+
776
+ session.add_all(new_output_artifact_data_map.values())
777
+ for execution_node in execution_nodes:
778
+ execution_node.container_execution_status = (
779
+ bts.ContainerExecutionStatus.SUCCEEDED
780
+ )
781
+ # TODO: Optimize
782
+ for output_name, artifact_node in session.execute(
783
+ sql.select(bts.OutputArtifactLink.output_name, bts.ArtifactNode)
784
+ .join(bts.OutputArtifactLink.artifact)
785
+ .where(bts.OutputArtifactLink.execution_id == execution_node.id)
786
+ ).tuples():
787
+ artifact_node.artifact_data = new_output_artifact_data_map[
788
+ output_name
789
+ ]
790
+ artifact_node.had_data_in_past = True
791
+ # Waking up all direct downstream executions.
792
+ # Many of them will get processed and go back to WAITING_FOR_UPSTREAM state.
793
+ for downstream_execution in _get_direct_downstream_executions(
794
+ session=session, execution=execution_node
795
+ ):
796
+ if (
797
+ downstream_execution.container_execution_status
798
+ == bts.ContainerExecutionStatus.WAITING_FOR_UPSTREAM
799
+ ):
800
+ downstream_execution.container_execution_status = (
801
+ bts.ContainerExecutionStatus.QUEUED
802
+ )
803
+ elif new_status == launcher_interfaces.ContainerStatus.FAILED:
804
+ container_execution.status = bts.ContainerExecutionStatus.FAILED
805
+ container_execution.exit_code = reloaded_launched_container.exit_code
806
+ container_execution.started_at = reloaded_launched_container.started_at
807
+ container_execution.ended_at = reloaded_launched_container.ended_at
808
+ launcher_error = reloaded_launched_container.launcher_error_message
809
+ if launcher_error:
810
+ orchestration_error_message = f"Launcher error: {launcher_error}"
811
+ _record_orchestration_error_message(
812
+ container_execution=container_execution,
813
+ execution_nodes=execution_nodes,
814
+ message=orchestration_error_message,
815
+ )
816
+
817
+ _retry(reloaded_launched_container.upload_log)
818
+ # Skip downstream executions
819
+ for execution_node in execution_nodes:
820
+ execution_node.container_execution_status = (
821
+ bts.ContainerExecutionStatus.FAILED
822
+ )
823
+ _mark_all_downstream_executions_as_skipped(
824
+ session=session, execution=execution_node
825
+ )
826
+ else:
827
+ _logger.error(
828
+ f"Container execution {container_execution.id} is now in unexpected state {new_status}. System error. {container_execution=}"
829
+ )
830
+ # This SYSTEM_ERROR will be handled by the outer exception handler
831
+ raise OrchestratorError(
832
+ f"Unexpected running container status: {new_status=}, {launched_container=}"
833
+ )
834
+ session.commit()
835
+
836
+
837
+ def _get_direct_downstream_executions(
838
+ session: orm.Session, execution: bts.ExecutionNode
839
+ ):
840
+ return session.scalars(
841
+ sql.select(bts.ExecutionNode)
842
+ .select_from(bts.OutputArtifactLink)
843
+ .where(bts.OutputArtifactLink.execution_id == execution.id)
844
+ .join(bts.ArtifactNode)
845
+ .join(bts.InputArtifactLink)
846
+ .join(bts.ExecutionNode)
847
+ ).all()
848
+
849
+
850
+ # TODO: Cover with tests
851
+ def _mark_all_downstream_executions_as_skipped(
852
+ session: orm.Session,
853
+ execution: bts.ExecutionNode,
854
+ seen_execution_ids: set[bts.IdType] | None = None,
855
+ ):
856
+ if seen_execution_ids is None:
857
+ seen_execution_ids = set()
858
+ if execution.id in seen_execution_ids:
859
+ return
860
+ seen_execution_ids.add(execution.id)
861
+ if execution.container_execution_status in {
862
+ bts.ContainerExecutionStatus.WAITING_FOR_UPSTREAM
863
+ }:
864
+ execution.container_execution_status = bts.ContainerExecutionStatus.SKIPPED
865
+
866
+ # for artifact_node in execution.output_artifact_nodes:
867
+ # for downstream_execution in artifact_node.downstream_executions:
868
+ for downstream_execution in _get_direct_downstream_executions(session, execution):
869
+ _mark_all_downstream_executions_as_skipped(
870
+ session=session,
871
+ execution=downstream_execution,
872
+ seen_execution_ids=seen_execution_ids,
873
+ )
874
+
875
+
876
+ def _assert_type(value: typing.Any, typ: typing.Type[_T]) -> _T:
877
+ if not isinstance(value, typ):
878
+ raise TypeError(f"Expected type {typ}, but got {type(value)}: {value}")
879
+ return value
880
+
881
+
882
+ def _assert_not_none(value: _T | None) -> _T:
883
+ if value is None:
884
+ raise TypeError(f"Expected value to be not None, but got {value}.")
885
+ return value
886
+
887
+
888
+ def _calculate_hash(s: str) -> str:
889
+ import hashlib
890
+
891
+ return "md5=" + hashlib.md5(s.encode("utf-8")).hexdigest()
892
+
893
+
894
+ def _calculate_container_execution_cache_key(
895
+ container_spec: structures.ContainerSpec,
896
+ input_artifact_data: dict[str, bts.ArtifactData],
897
+ ):
898
+ # Using ContainerSPec instead of the whole ComponentSpec.
899
+ input_hashes = {
900
+ input_name: _assert_not_none(artifact_data).hash
901
+ for input_name, artifact_data in (input_artifact_data or {}).items()
902
+ }
903
+ cache_key_struct = {
904
+ "container_spec": container_spec.to_json_dict(),
905
+ "input_hashes": input_hashes,
906
+ }
907
+ # Need to sort keys to ensure consistency
908
+ cache_key_str = json.dumps(cache_key_struct, separators=(",", ":"), sort_keys=True)
909
+ # We could use a different hash function, but there is no reason to.
910
+ cache_key = _calculate_hash(cache_key_str)
911
+ return cache_key
912
+
913
+
914
+ def _get_current_time() -> datetime.datetime:
915
+ return datetime.datetime.now(tz=datetime.timezone.utc)
916
+
917
+
918
+ def _generate_random_id() -> str:
919
+ import os
920
+ import time
921
+
922
+ random_bytes = os.urandom(4)
923
+ nanoseconds = time.time_ns()
924
+ milliseconds = nanoseconds // 1_000_000
925
+
926
+ return ("%012x" % milliseconds) + random_bytes.hex()
927
+
928
+
929
+ def _update_dict_recursive(d1: dict, d2: dict):
930
+ for k, v2 in d2.items():
931
+ if k in d1:
932
+ v1 = d1[k]
933
+ if isinstance(v1, dict) and isinstance(v2, dict):
934
+ _update_dict_recursive(v1, v2)
935
+ continue
936
+ # elif isinstance(v1, list) and isinstance(v2, list):
937
+ # # Merging lists is not supported yet
938
+ d1[k] = v2
939
+
940
+
941
+ def _retry(
942
+ func: typing.Callable[[], _T], max_retries: int = 5, wait_seconds: float = 1.0
943
+ ) -> _T:
944
+ for i in range(max_retries):
945
+ try:
946
+ return func()
947
+ except:
948
+ _logger.exception(f"Exception calling {func}.")
949
+ time.sleep(wait_seconds)
950
+ if i == max_retries - 1:
951
+ raise
952
+ raise
953
+
954
+
955
+ def record_system_error_exception(execution: bts.ExecutionNode, exception: Exception):
956
+ if execution.extra_data is None:
957
+ execution.extra_data = {}
958
+ execution.extra_data[
959
+ bts.EXECUTION_NODE_EXTRA_DATA_SYSTEM_ERROR_EXCEPTION_MESSAGE_KEY
960
+ ] = "".join(traceback.format_exception_only(type(exception), exception))
961
+ execution.extra_data[
962
+ bts.EXECUTION_NODE_EXTRA_DATA_SYSTEM_ERROR_EXCEPTION_FULL_KEY
963
+ ] = traceback.format_exc()
964
+
965
+
966
+ def _record_orchestration_error_message(
967
+ container_execution: bts.ContainerExecution,
968
+ execution_nodes: list[bts.ExecutionNode],
969
+ message: str,
970
+ ):
971
+ if container_execution.extra_data is None:
972
+ container_execution.extra_data = {}
973
+ container_execution.extra_data[
974
+ bts.CONTAINER_EXECUTION_EXTRA_DATA_ORCHESTRATION_ERROR_MESSAGE_KEY
975
+ ] = message
976
+
977
+ for execution_node in execution_nodes:
978
+ if execution_node.extra_data is None:
979
+ execution_node.extra_data = {}
980
+ execution_node.extra_data[
981
+ bts.EXECUTION_NODE_EXTRA_DATA_ORCHESTRATION_ERROR_MESSAGE_KEY
982
+ ] = message
983
+
984
+
985
+ _MAX_PRELOAD_VALUE_SIZE = 255
986
+
987
+
988
+ def _maybe_get_small_artifact_value(
989
+ uri_reader: storage_provider_interfaces.UriReader,
990
+ data_info: storage_provider_interfaces.DataInfo,
991
+ ) -> str | None:
992
+ """Preloads artifact value is it's small enough (e.g. <=255 bytes)"""
993
+ if not data_info.is_dir and data_info.total_size < _MAX_PRELOAD_VALUE_SIZE:
994
+ # Don't fail the execution if small value preloading fails.
995
+ # Those values may be useful for preservation, but not so important that we should fail a successfully completed container execution.
996
+ try:
997
+ data = uri_reader.download_as_bytes()
998
+ except Exception as ex:
999
+ _logger.exception(f"Error during preloading small artifact values.")
1000
+ return None
1001
+ try:
1002
+ text = data.decode("utf-8")
1003
+ return text
1004
+ except:
1005
+ pass