jadicorn's picture
Add debugging information to orchestrator_sql.py
8af11d2 verified
Raw History Blame Contribute Delete
44.8 kB
import copy
import json
import datetime
import logging
import time
import traceback
import typing
from typing import Any
import sqlalchemy as sql
from sqlalchemy import orm
from cloud_pipelines.orchestration.storage_providers import (
interfaces as storage_provider_interfaces,
)
from cloud_pipelines.orchestration.launchers import naming_utils
from . import backend_types_sql as bts
from . import component_structures as structures
from .launchers import common_annotations
from .launchers import interfaces as launcher_interfaces
_logger = logging.getLogger(__name__)
_T = typing.TypeVar("_T")
class OrchestratorError(RuntimeError):
pass
class OrchestratorService_Sql:
def __init__(
self,
session_factory: typing.Callable[[], orm.Session],
launcher: launcher_interfaces.ContainerTaskLauncher[
launcher_interfaces.LaunchedContainer
],
storage_provider: storage_provider_interfaces.StorageProvider,
data_root_uri: str,
logs_root_uri: str,
default_task_annotations: dict[str, Any] | None = None,
sleep_seconds_between_queue_sweeps: float = 1.0,
output_data_purge_duration: datetime.timedelta = None,
):
self._session_factory = session_factory
self._launcher = launcher
self._storage_provider = storage_provider
self._data_root_uri = data_root_uri
self._logs_root_uri = logs_root_uri
self._default_task_annotations = default_task_annotations
self._sleep_seconds_between_queue_sweeps = sleep_seconds_between_queue_sweeps
self._queued_executions_queue_idle = False
self._running_executions_queue_idle = False
self._output_data_purge_duration = output_data_purge_duration
def run_loop(self):
while True:
try:
self.process_each_queue_once()
time.sleep(self._sleep_seconds_between_queue_sweeps)
except:
_logger.exception("Error while calling `process_each_queue_once`")
def process_each_queue_once(self):
queue_handlers = [
self.internal_process_queued_executions_queue,
self.internal_process_running_executions_queue,
]
for queue_handler in queue_handlers:
try:
with self._session_factory() as session:
queue_handler(session=session)
except:
_logger.exception(f"Error while executing {queue_handler=}")
def internal_process_queued_executions_queue(self, session: orm.Session):
query = (
sql.select(bts.ExecutionNode).where(
bts.ExecutionNode.container_execution_status.in_(
(
bts.ContainerExecutionStatus.UNINITIALIZED,
bts.ContainerExecutionStatus.QUEUED,
)
)
)
# TODO: Maybe add last_processed_at
# .order_by(bts.ExecutionNode.last_processed_at)
# .order_by(bts.ExecutionNode.id)
.limit(1)
)
queued_execution = session.scalar(query)
if queued_execution:
self._queued_executions_queue_idle = False
_logger.info(f"Before processing {queued_execution.id=}")
try:
self.internal_process_one_queued_execution(
session=session, execution=queued_execution
)
except Exception as ex:
_logger.exception(f"Error processing {queued_execution.id=}")
session.rollback()
queued_execution.container_execution_status = (
bts.ContainerExecutionStatus.SYSTEM_ERROR
)
record_system_error_exception(execution=queued_execution, exception=ex)
session.commit()
_logger.info(f"After processing {queued_execution.id=}")
return True
else:
if not self._queued_executions_queue_idle:
self._queued_executions_queue_idle = True
_logger.debug(f"No queued executions found")
return False
def internal_process_running_executions_queue(self, session: orm.Session):
query = (
sql.select(bts.ContainerExecution)
.where(
bts.ContainerExecution.status.in_(
(
bts.ContainerExecutionStatus.PENDING,
bts.ContainerExecutionStatus.RUNNING,
)
)
)
.order_by(bts.ContainerExecution.last_processed_at)
.limit(1)
)
running_container_execution = session.scalar(query)
if running_container_execution:
self._running_executions_queue_idle = False
try:
_logger.info(f"Before processing {running_container_execution.id=}")
self.internal_process_one_running_execution(
session=session, container_execution=running_container_execution
)
_logger.info(f"After processing {running_container_execution.id=}")
except Exception as ex:
_logger.exception(f"Error processing {running_container_execution.id=}")
session.rollback()
running_container_execution.status = (
bts.ContainerExecutionStatus.SYSTEM_ERROR
)
# Doing an intermediate commit here because it's most important to mark the problematic execution as SYSTEM_ERROR.
session.commit()
# Mark our ExecutionNode as SYSTEM_ERROR
execution_nodes = running_container_execution.execution_nodes
for execution_node in execution_nodes:
execution_node.container_execution_status = (
bts.ContainerExecutionStatus.SYSTEM_ERROR
)
record_system_error_exception(
execution=execution_node, exception=ex
)
# Doing an intermediate commit here because it's most important to mark the problematic node as SYSTEM_ERROR.
session.commit()
# Skip downstream executions
for execution_node in execution_nodes:
_mark_all_downstream_executions_as_skipped(
session=session, execution=execution_node
)
session.commit()
return True
else:
if not self._running_executions_queue_idle:
_logger.debug(f"No running container executions found")
self._running_executions_queue_idle = True
return False
def internal_process_one_queued_execution(
self, session: orm.Session, execution: bts.ExecutionNode
):
task_spec = structures.TaskSpec.from_json_dict(execution.task_spec)
component_spec = task_spec.component_ref.spec
if component_spec is None:
raise OrchestratorError(
f"Missing ComponentSpec. {task_spec.component_ref=}"
)
implementation = component_spec.implementation
if not implementation:
raise OrchestratorError(f"Missing implementation. {component_spec=}")
if implementation is None or not isinstance(
implementation, structures.ContainerImplementation
):
raise OrchestratorError(
f"Implementation must be container. {implementation=}"
)
container_spec = implementation.container
input_artifact_data = dict(
session.execute(
sql.select(bts.InputArtifactLink.input_name, bts.ArtifactData)
.where(bts.InputArtifactLink.execution == execution)
.join(bts.InputArtifactLink.artifact)
.join(bts.ArtifactNode.artifact_data, isouter=True)
)
.tuples()
.all()
)
if None in input_artifact_data.values():
_logger.info(
f"Execution did not have all input artifact data present. Waiting for upstream. {execution.id=}"
)
execution.container_execution_status = (
bts.ContainerExecutionStatus.WAITING_FOR_UPSTREAM
)
session.commit()
return
cache_key = _calculate_container_execution_cache_key(
container_spec=container_spec, input_artifact_data=input_artifact_data
)
# Trying to reuse an older execution from cache.
max_cache_staleness_str: str | None = None
if task_spec.execution_options and task_spec.execution_options.caching_strategy:
max_cache_staleness_str = (
task_spec.execution_options.caching_strategy.max_cache_staleness
)
# TODO: Support other ways to express 0-length time period.
if max_cache_staleness_str == "P0D":
execution_candidates: typing.Sequence[bts.ExecutionNode] = []
else:
# TODO: Support cache reuse options (max cached data staleness etc).
# TODO: It might be better to search for ContainerExecutions rather than ExecutionNodes.
execution_candidates = session.scalars(
sql.select(bts.ExecutionNode)
.where(bts.ExecutionNode.container_execution_cache_key == cache_key)
.where(
bts.ExecutionNode.container_execution_status.in_(
[
# We can reuse both succeeded executions and also non yet finished ones.
# Reusing still running executions is important since it allows cache reuse
# when multiple versions of a pipeline are submitted in parallel.
# bts.ContainerExecutionStatus.STARTING, # Doesn't exist yet
bts.ContainerExecutionStatus.PENDING,
bts.ContainerExecutionStatus.RUNNING,
bts.ContainerExecutionStatus.SUCCEEDED,
]
)
)
# TODO: Filter by purged==False. Or `expires_at`.
.join(bts.ContainerExecution)
.order_by(bts.ContainerExecution.created_at.desc())
).all()
non_purged_candidates = [
execution_candidate
for execution_candidate in execution_candidates
if not (execution_candidate.extra_data or {}).get("is_purged", False)
]
if self._output_data_purge_duration:
current_time = datetime.datetime.now(datetime.timezone.utc)
data_purge_threshold_time = current_time - self._output_data_purge_duration
non_purged_candidates = [
execution_candidate
for execution_candidate in execution_candidates
# The `execution_candidate.container_execution` attribute access accesses the DB.
# TODO: Move the time filtering to the DB side.
# TODO: Filter by `ended_at`, not `created_at`.
# Note: Need `.astimezone` to avoid `TypeError: can't compare offset-naive and offset-aware datetimes`
if execution_candidate.container_execution.created_at.astimezone(
datetime.timezone.utc
)
> data_purge_threshold_time
]
if non_purged_candidates:
# Re-using the oldest candidate
# TODO: Use better cached execution selection strategy:
# 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.
# 2. Else: Try to reuse the latest SUCCEEDED execution. Mark it `extra_data["used_for_max_cache_staleness"][max_cache_staleness] = True`.
# 3. Else: Try to reuse the latest RUNNING/PENDING execution. Mark it `extra_data["used_for_max_cache_staleness"][max_cache_staleness] = True`.
# There must be at least one SUCCEEDED/RUNNING/PENDING since non_purged_candidates is non-empty.
old_execution = non_purged_candidates[-1]
_logger.info(
f"Execution {execution.id=} will reuse the {old_execution.id=} with "
f"{old_execution.container_execution_id=}, {old_execution.container_execution_status=}"
)
# Reusing the execution:
if not execution.extra_data:
execution.extra_data = {}
execution.extra_data["reused_from_execution_node_id"] = old_execution.id
execution.container_execution_status = (
old_execution.container_execution_status
)
# If the execution is still running, it's enough to just link execution node to it.
# The container execution will set the outputs itself when it succeeds.
execution.container_execution_id = old_execution.container_execution_id
# However if the container execution has already ended, we need to copy the outputs ourselves.
if (
old_execution.container_execution_status
== bts.ContainerExecutionStatus.SUCCEEDED
):
# Copying the output artifact data (if the execution already succeeded).
reused_execution_output_artifact_data_ids = {
output_name: artifact_data_id
for output_name, artifact_data_id in session.execute(
sql.select(
bts.OutputArtifactLink.output_name,
bts.ArtifactNode.artifact_data_id,
)
.join(bts.OutputArtifactLink.artifact)
.where(bts.OutputArtifactLink.execution_id == old_execution.id)
).tuples()
}
# Setting artifact data
for output_name, artifact_node in session.execute(
sql.select(bts.OutputArtifactLink.output_name, bts.ArtifactNode)
# TODO: Verify that this improves the performance
.with_for_update()
.join(bts.OutputArtifactLink.artifact)
.where(bts.OutputArtifactLink.execution_id == execution.id)
).tuples():
artifact_node.artifact_data_id = (
reused_execution_output_artifact_data_ids[output_name]
)
# Waking up all direct downstream executions.
# Many of them will get processed and go back to WAITING_FOR_UPSTREAM state.
for downstream_execution in _get_direct_downstream_executions(
session=session, execution=execution
):
if (
downstream_execution.container_execution_status
== bts.ContainerExecutionStatus.WAITING_FOR_UPSTREAM
):
downstream_execution.container_execution_status = (
bts.ContainerExecutionStatus.QUEUED
)
session.commit()
return
# Launching the container execution
pipeline_run = session.scalar(
sql.select(bts.PipelineRun)
.join(
bts.ExecutionToAncestorExecutionLink,
bts.ExecutionToAncestorExecutionLink.ancestor_execution_id
== bts.PipelineRun.root_execution_id,
)
.where(bts.ExecutionToAncestorExecutionLink.execution_id == execution.id)
)
session.rollback()
if pipeline_run is None:
raise OrchestratorError(
f"Execution {execution} is not associated with a PipelineRun."
)
# Handling cancellation.
# In case of cancellation, we do not create container execution and we skip all downstream executions.
should_terminate_execution = (execution.extra_data or {}).get(
"desired_state"
) == "TERMINATED"
should_terminate_run = (pipeline_run.extra_data or {}).get(
"desired_state"
) == "TERMINATED"
should_terminate = should_terminate_execution or should_terminate_run
if should_terminate:
_logger.info(
f"Cancelling execution {execution.id} and skipping all downstream executions."
)
execution.container_execution_status = (
bts.ContainerExecutionStatus.CANCELLED
)
_mark_all_downstream_executions_as_skipped(
session=session, execution=execution
)
# Creating new container execution
container_execution_uuid = _generate_random_id()
_ARTIFACT_PATH_LAST_PART = "data"
_LOG_FILE_NAME = "log.txt"
# _TIMESTAMPED_LOG_FILE_NAME = "timestamped.log.txt"
# _STDOUT_LOG_FILE_NAME = "stdout.log.txt"
# _STDERR_LOG_FILE_NAME = "stderr.log.txt"
# Input URIs might be needed to write constant input data.
# Or to prevent the data from being purged during the execution.
# TODO: Ensure unique artifact URIs (they can become non-unique due to sanitization)
def generate_input_artifact_uri(
root_dir: str,
execution_id: str,
input_name: str,
) -> str:
root_dir = root_dir.rstrip("/")
execution_id_str = naming_utils.sanitize_file_name(execution_id)
input_name_str = naming_utils.sanitize_file_name(input_name)
return f"{root_dir}/by_execution/{execution_id_str}/inputs/{input_name_str}/{_ARTIFACT_PATH_LAST_PART}"
def generate_output_artifact_uri(
root_dir: str,
execution_id: str,
output_name: str,
) -> str:
root_dir = root_dir.rstrip("/")
execution_id_str = naming_utils.sanitize_file_name(execution_id)
output_name_str = naming_utils.sanitize_file_name(output_name)
return f"{root_dir}/by_execution/{execution_id_str}/outputs/{output_name_str}/{_ARTIFACT_PATH_LAST_PART}"
def generate_execution_log_uri(
root_dir: str,
execution_id: str,
) -> str:
root_dir = root_dir.rstrip("/")
execution_id_str = naming_utils.sanitize_file_name(execution_id)
return f"{root_dir}/by_execution/{execution_id_str}/{_LOG_FILE_NAME}"
log_uri = generate_execution_log_uri(
root_dir=self._logs_root_uri, execution_id=container_execution_uuid
)
output_artifact_uris = {
output_spec.name: generate_output_artifact_uri(
root_dir=self._data_root_uri,
execution_id=container_execution_uuid,
output_name=output_spec.name,
)
for output_spec in component_spec.outputs or []
}
input_arguments = {
input_name: launcher_interfaces.InputArgument(
total_size=artifact_data.total_size,
is_dir=artifact_data.is_dir,
value=artifact_data.value,
uri=artifact_data.uri,
staging_uri=generate_input_artifact_uri(
root_dir=self._data_root_uri,
execution_id=container_execution_uuid,
input_name=input_name,
),
)
for input_name, artifact_data in input_artifact_data.items()
}
# Handling annotations
full_annotations = {}
_update_dict_recursive(
full_annotations, copy.deepcopy(self._default_task_annotations or {})
)
_update_dict_recursive(
full_annotations, copy.deepcopy(pipeline_run.annotations or {})
)
_update_dict_recursive(
full_annotations, copy.deepcopy(task_spec.annotations or {})
)
full_annotations[common_annotations.PIPELINE_RUN_CREATED_BY_ANNOTATION_KEY] = (
pipeline_run.created_by
)
full_annotations[common_annotations.PIPELINE_RUN_ID_ANNOTATION_KEY] = (
pipeline_run.id
)
full_annotations[common_annotations.EXECUTION_NODE_ID_ANNOTATION_KEY] = (
execution.id
)
full_annotations[common_annotations.CONTAINER_EXECUTION_ID_ANNOTATION_KEY] = (
container_execution_uuid
)
try:
launched_container: launcher_interfaces.LaunchedContainer = (
self._launcher.launch_container_task(
component_spec=component_spec,
# The value and uri might be updated during launching.
input_arguments=input_arguments,
output_uris=output_artifact_uris,
log_uri=log_uri,
annotations=full_annotations,
)
)
if launched_container.status not in (
launcher_interfaces.ContainerStatus.PENDING,
launcher_interfaces.ContainerStatus.RUNNING,
):
raise OrchestratorError(
f"Unexpected status of just launched container: {launched_container.status=}, {launched_container=}"
)
except Exception as ex:
session.rollback()
with session.begin():
# Logs whole exception
_logger.exception(f"Error launching container for {execution.id=}")
execution.container_execution_status = (
bts.ContainerExecutionStatus.SYSTEM_ERROR
)
record_system_error_exception(execution=execution, exception=ex)
_mark_all_downstream_executions_as_skipped(
session=session, execution=execution
)
return
current_time = _get_current_time()
container_execution = bts.ContainerExecution(
status=bts.ContainerExecutionStatus(launched_container.status),
last_processed_at=current_time,
created_at=current_time,
launcher_data=launched_container.to_dict(),
# Updating the map with uploaded value URIs and downloaded values.
input_artifact_data_map={
input_name: dict(
id=artifact_data.id,
is_dir=artifact_data.is_dir,
total_size=artifact_data.total_size,
hash=artifact_data.hash,
# The value and URI might have been updated during launching.
value=input_arguments[input_name].value,
uri=input_arguments[input_name].uri,
)
for input_name, artifact_data in input_artifact_data.items()
},
output_artifact_data_map={
output_name: dict(
uri=uri,
)
for output_name, uri in output_artifact_uris.items()
},
log_uri=log_uri,
)
container_execution.id = container_execution_uuid
# Need to commit/rollback before explicitly beginning non-nested transaction.
session.rollback()
with session.begin():
session.add(container_execution)
execution.container_execution = container_execution
execution.container_execution_cache_key = cache_key
execution.container_execution_status = container_execution.status
# TODO: Maybe add artifact value and URI to input ArtifactData.
def internal_process_one_running_execution(
self, session: orm.Session, container_execution: bts.ContainerExecution
):
# Should we update last_processed_at early? Should we do it always (even when there are errors).
# ALways updating the last_processed_at such that a problematic container does not get queue stuck.
session.expire_on_commit = False
session.rollback()
with session.begin():
container_execution.last_processed_at = _get_current_time()
launcher_data = _assert_not_none(container_execution.launcher_data)
launched_container: launcher_interfaces.LaunchedContainer = (
self._launcher.deserialize_launched_container_from_dict(launcher_data)
)
previous_status = launched_container.status
if previous_status not in (
bts.ContainerExecutionStatus.PENDING,
bts.ContainerExecutionStatus.RUNNING,
):
raise OrchestratorError(
f"Unexpected running container status: {previous_status=}, {launched_container=}"
)
# Handling cancellation
votes_to_terminate = []
votes_to_not_terminate = []
# TODO: Get the desired state from the pipeline runs, not execution nodes
execution_nodes = container_execution.execution_nodes
for execution_node in execution_nodes:
should_terminate = (execution_node.extra_data or {}).get(
"desired_state"
) == "TERMINATED"
if should_terminate:
votes_to_terminate.append(execution_node)
else:
votes_to_not_terminate.append(execution_node)
if votes_to_terminate:
# Terminate the container execution only when all execution nodes pointing to it are asked to terminate.
terminated = False
if votes_to_not_terminate:
_logger.info(
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."
)
else:
_logger.info(
f"Terminating container execution {container_execution.id}."
)
# We should preserve the logs before terminating/deleting the container
try:
_retry(lambda: launched_container.upload_log())
except:
_logger.exception(
f"Error uploading logs for {container_execution.id=} before termination."
)
# Requesting container termination.
# Termination might not happen immediately (e.g. Kubernetes has grace period).
launched_container.terminate()
container_execution.ended_at = _get_current_time()
# We need to mark the execution as CANCELLED otherwise orchestrator will continue polling it.
container_execution.status = bts.ContainerExecutionStatus.CANCELLED
terminated = True
# Mark the execution nodes as cancelled only after the launched container is successfully terminated (if needed)
for execution_node in votes_to_terminate:
_logger.info(
f"Cancelling execution {execution_node.id} ({container_execution.id=}) and skipping all downstream executions."
)
execution_node.container_execution_status = (
bts.ContainerExecutionStatus.CANCELLED
)
_mark_all_downstream_executions_as_skipped(
session=session, execution=execution_node
)
session.commit()
if terminated:
return
# Asking the launcher to refresh the container state.
reloaded_launched_container: launcher_interfaces.LaunchedContainer = (
self._launcher.get_refreshed_launched_container_from_dict(launcher_data)
)
current_time = _get_current_time()
# Saving the updated launcher data
reloaded_launcher_data = reloaded_launched_container.to_dict()
if reloaded_launcher_data != launcher_data:
session.rollback()
with session.begin():
import json
try:
json.dumps(reloaded_launcher_data)
except TypeError as e:
print("launcher_data JSON ERROR:", e)
print("launcher_data:", repr(reloaded_launcher_data))
raise
container_execution.launcher_data = reloaded_launcher_data
container_execution.updated_at = current_time
new_status: launcher_interfaces.ContainerStatus = (
reloaded_launched_container.status
)
if new_status == previous_status:
_logger.info(
f"Container execution {container_execution.id} remains in {new_status} state."
)
return
_logger.info(
f"Container execution {container_execution.id} is now in state {new_status} (was {previous_status})."
)
session.rollback()
container_execution.updated_at = current_time
execution_nodes = container_execution.execution_nodes
if not execution_nodes:
raise OrchestratorError(
f"Could not find ExecutionNode associated with ContainerExecution. {container_execution.id=}"
)
if len(execution_nodes) > 1:
execution_node_ids = [execution.id for execution in execution_nodes]
_logger.warning(
f"ContainerExecution is associated with multiple ExecutionNodes: {container_execution.id=}, {execution_node_ids=}"
)
if new_status == launcher_interfaces.ContainerStatus.RUNNING:
container_execution.status = bts.ContainerExecutionStatus.RUNNING
container_execution.started_at = reloaded_launched_container.started_at
for execution_node in execution_nodes:
execution_node.container_execution_status = (
bts.ContainerExecutionStatus.RUNNING
)
elif new_status == launcher_interfaces.ContainerStatus.SUCCEEDED:
container_execution.status = bts.ContainerExecutionStatus.SUCCEEDED
container_execution.exit_code = reloaded_launched_container.exit_code
container_execution.started_at = reloaded_launched_container.started_at
container_execution.ended_at = reloaded_launched_container.ended_at
# Don't fail the execution if log upload fails.
# Logs are important, but not so important that we should fail a successfully completed container execution.
try:
_retry(reloaded_launched_container.upload_log)
except Exception as ex:
_logger.exception(
f"! Error during `LaunchedContainer.upload_log` call: {ex}."
)
_MAX_PRELOAD_VALUE_SIZE = 255
def _maybe_preload_value(
uri_reader: storage_provider_interfaces.UriReader,
data_info: storage_provider_interfaces.DataInfo,
) -> str | None:
"""Preloads artifact value is it's small enough (e.g. <=255 bytes)"""
if (
not data_info.is_dir
and data_info.total_size < _MAX_PRELOAD_VALUE_SIZE
):
# Don't fail the execution if small value preloading fails.
# Those values may be useful for preservation, but not so important that we should fail a successfully completed container execution.
try:
data = uri_reader.download_as_bytes()
except Exception as ex:
_logger.exception(
f"Error during preloading small artifact values."
)
return None
try:
text = data.decode("utf-8")
return text
except:
pass
output_artifact_uris: dict[str, str] = {
output_name: output_artifact_info_dict["uri"]
for output_name, output_artifact_info_dict in container_execution.output_artifact_data_map.items()
}
# We need to first check that the output data exists. Otherwise `uri_reader.get_info()`` throws `IndexError`
# in cloud_pipelines/orchestration/storage_providers/interfaces.py", line 172, in _make_data_info_for_dir
missing_output_names = [
output_name
for output_name, uri in output_artifact_uris.items()
if not _retry(
lambda: self._storage_provider.make_uri(uri).get_reader().exists()
)
]
if missing_output_names:
# Marking the container execution as FAILED (even though the program itself has completed successfully)
container_execution.status = bts.ContainerExecutionStatus.FAILED
orchestration_error_message = f"Container execution {container_execution.id} is marked as FAILED due to missing outputs: {missing_output_names}."
_logger.error(orchestration_error_message)
_record_orchestration_error_message(
container_execution=container_execution,
execution_nodes=execution_nodes,
message=orchestration_error_message,
)
# Skip downstream executions
for execution_node in execution_nodes:
execution_node.container_execution_status = (
bts.ContainerExecutionStatus.FAILED
)
_mark_all_downstream_executions_as_skipped(
session=session, execution=execution_node
)
else:
output_artifact_data_info_map = {
output_name: _retry(
lambda: self._storage_provider.make_uri(uri)
.get_reader()
.get_info()
)
for output_name, uri in output_artifact_uris.items()
}
new_output_artifact_data_map = {
output_name: bts.ArtifactData(
total_size=data_info.total_size,
is_dir=data_info.is_dir,
# Using 1st hash only.
# TODO: Support multiple hashes.
hash=[
f"{hash_name}={hash_value}"
for hash_name, hash_value in data_info.hashes.items()
][0],
uri=output_artifact_uris[output_name],
# Preloading artifact value is it's small enough (e.g. <=255 bytes)
value=_retry(
lambda: _maybe_get_small_artifact_value(
uri_reader=self._storage_provider.make_uri(
output_artifact_uris[output_name]
).get_reader(),
data_info=data_info,
)
),
created_at=current_time,
)
for output_name, data_info in output_artifact_data_info_map.items()
}
session.add_all(new_output_artifact_data_map.values())
for execution_node in execution_nodes:
execution_node.container_execution_status = (
bts.ContainerExecutionStatus.SUCCEEDED
)
# TODO: Optimize
for output_name, artifact_node in session.execute(
sql.select(bts.OutputArtifactLink.output_name, bts.ArtifactNode)
.join(bts.OutputArtifactLink.artifact)
.where(bts.OutputArtifactLink.execution_id == execution_node.id)
).tuples():
artifact_node.artifact_data = new_output_artifact_data_map[
output_name
]
artifact_node.had_data_in_past = True
# Waking up all direct downstream executions.
# Many of them will get processed and go back to WAITING_FOR_UPSTREAM state.
for downstream_execution in _get_direct_downstream_executions(
session=session, execution=execution_node
):
if (
downstream_execution.container_execution_status
== bts.ContainerExecutionStatus.WAITING_FOR_UPSTREAM
):
downstream_execution.container_execution_status = (
bts.ContainerExecutionStatus.QUEUED
)
elif new_status == launcher_interfaces.ContainerStatus.FAILED:
container_execution.status = bts.ContainerExecutionStatus.FAILED
container_execution.exit_code = reloaded_launched_container.exit_code
container_execution.started_at = reloaded_launched_container.started_at
container_execution.ended_at = reloaded_launched_container.ended_at
launcher_error = reloaded_launched_container.launcher_error_message
if launcher_error:
orchestration_error_message = f"Launcher error: {launcher_error}"
_record_orchestration_error_message(
container_execution=container_execution,
execution_nodes=execution_nodes,
message=orchestration_error_message,
)
_retry(reloaded_launched_container.upload_log)
# Skip downstream executions
for execution_node in execution_nodes:
execution_node.container_execution_status = (
bts.ContainerExecutionStatus.FAILED
)
_mark_all_downstream_executions_as_skipped(
session=session, execution=execution_node
)
else:
_logger.error(
f"Container execution {container_execution.id} is now in unexpected state {new_status}. System error. {container_execution=}"
)
# This SYSTEM_ERROR will be handled by the outer exception handler
raise OrchestratorError(
f"Unexpected running container status: {new_status=}, {launched_container=}"
)
session.commit()
def _get_direct_downstream_executions(
session: orm.Session, execution: bts.ExecutionNode
):
return session.scalars(
sql.select(bts.ExecutionNode)
.select_from(bts.OutputArtifactLink)
.where(bts.OutputArtifactLink.execution_id == execution.id)
.join(bts.ArtifactNode)
.join(bts.InputArtifactLink)
.join(bts.ExecutionNode)
).all()
# TODO: Cover with tests
def _mark_all_downstream_executions_as_skipped(
session: orm.Session,
execution: bts.ExecutionNode,
seen_execution_ids: set[bts.IdType] | None = None,
):
if seen_execution_ids is None:
seen_execution_ids = set()
if execution.id in seen_execution_ids:
return
seen_execution_ids.add(execution.id)
if execution.container_execution_status in {
bts.ContainerExecutionStatus.WAITING_FOR_UPSTREAM
}:
execution.container_execution_status = bts.ContainerExecutionStatus.SKIPPED
# for artifact_node in execution.output_artifact_nodes:
# for downstream_execution in artifact_node.downstream_executions:
for downstream_execution in _get_direct_downstream_executions(session, execution):
_mark_all_downstream_executions_as_skipped(
session=session,
execution=downstream_execution,
seen_execution_ids=seen_execution_ids,
)
def _assert_type(value: typing.Any, typ: typing.Type[_T]) -> _T:
if not isinstance(value, typ):
raise TypeError(f"Expected type {typ}, but got {type(value)}: {value}")
return value
def _assert_not_none(value: _T | None) -> _T:
if value is None:
raise TypeError(f"Expected value to be not None, but got {value}.")
return value
def _calculate_hash(s: str) -> str:
import hashlib
return "md5=" + hashlib.md5(s.encode("utf-8")).hexdigest()
def _calculate_container_execution_cache_key(
container_spec: structures.ContainerSpec,
input_artifact_data: dict[str, bts.ArtifactData],
):
# Using ContainerSPec instead of the whole ComponentSpec.
input_hashes = {
input_name: _assert_not_none(artifact_data).hash
for input_name, artifact_data in (input_artifact_data or {}).items()
}
cache_key_struct = {
"container_spec": container_spec.to_json_dict(),
"input_hashes": input_hashes,
}
# Need to sort keys to ensure consistency
cache_key_str = json.dumps(cache_key_struct, separators=(",", ":"), sort_keys=True)
# We could use a different hash function, but there is no reason to.
cache_key = _calculate_hash(cache_key_str)
return cache_key
def _get_current_time() -> datetime.datetime:
return datetime.datetime.now(tz=datetime.timezone.utc)
def _generate_random_id() -> str:
import os
import time
random_bytes = os.urandom(4)
nanoseconds = time.time_ns()
milliseconds = nanoseconds // 1_000_000
return ("%012x" % milliseconds) + random_bytes.hex()
def _update_dict_recursive(d1: dict, d2: dict):
for k, v2 in d2.items():
if k in d1:
v1 = d1[k]
if isinstance(v1, dict) and isinstance(v2, dict):
_update_dict_recursive(v1, v2)
continue
# elif isinstance(v1, list) and isinstance(v2, list):
# # Merging lists is not supported yet
d1[k] = v2
def _retry(
func: typing.Callable[[], _T], max_retries: int = 5, wait_seconds: float = 1.0
) -> _T:
for i in range(max_retries):
try:
return func()
except:
_logger.exception(f"Exception calling {func}.")
time.sleep(wait_seconds)
if i == max_retries - 1:
raise
raise
def record_system_error_exception(execution: bts.ExecutionNode, exception: Exception):
if execution.extra_data is None:
execution.extra_data = {}
execution.extra_data[
bts.EXECUTION_NODE_EXTRA_DATA_SYSTEM_ERROR_EXCEPTION_MESSAGE_KEY
] = "".join(traceback.format_exception_only(type(exception), exception))
execution.extra_data[
bts.EXECUTION_NODE_EXTRA_DATA_SYSTEM_ERROR_EXCEPTION_FULL_KEY
] = traceback.format_exc()
def _record_orchestration_error_message(
container_execution: bts.ContainerExecution,
execution_nodes: list[bts.ExecutionNode],
message: str,
):
if container_execution.extra_data is None:
container_execution.extra_data = {}
container_execution.extra_data[
bts.CONTAINER_EXECUTION_EXTRA_DATA_ORCHESTRATION_ERROR_MESSAGE_KEY
] = message
for execution_node in execution_nodes:
if execution_node.extra_data is None:
execution_node.extra_data = {}
execution_node.extra_data[
bts.EXECUTION_NODE_EXTRA_DATA_ORCHESTRATION_ERROR_MESSAGE_KEY
] = message
_MAX_PRELOAD_VALUE_SIZE = 255
def _maybe_get_small_artifact_value(
uri_reader: storage_provider_interfaces.UriReader,
data_info: storage_provider_interfaces.DataInfo,
) -> str | None:
"""Preloads artifact value is it's small enough (e.g. <=255 bytes)"""
if not data_info.is_dir and data_info.total_size < _MAX_PRELOAD_VALUE_SIZE:
# Don't fail the execution if small value preloading fails.
# Those values may be useful for preservation, but not so important that we should fail a successfully completed container execution.
try:
data = uri_reader.download_as_bytes()
except Exception as ex:
_logger.exception(f"Error during preloading small artifact values.")
return None
try:
text = data.decode("utf-8")
return text
except:
pass