Allow workflows to consumer deferred {src: url} dicts..

This commit is contained in:
John Chilton
2024-09-30 13:25:02 -04:00
parent 80aaff8d23
commit 2fe88a05ea
10 changed files with 165 additions and 17 deletions
+8 -1
View File
@@ -11856,7 +11856,14 @@ export interface components {
* InvocationState
* @enum {string}
*/
InvocationState: "new" | "ready" | "scheduled" | "cancelled" | "cancelling" | "failed";
InvocationState:
| "new"
| "requires_materialization"
| "ready"
| "scheduled"
| "cancelled"
| "cancelling"
| "failed";
/**
* InvocationStep
* @description Information about workflow invocation step
@@ -3738,6 +3738,21 @@ mapping:
Optional configuration file similar to `job_config_file` to specify
which Galaxy processes should schedule workflows.
workflow_scheduling_separate_materialization_iteration:
type: bool
default: false
required: false
desc: |
Workflows launched with URI/URL inputs that are not marked as 'deferred'
are "materialized" (or undeferred) by the workflow scheduler. This might be
a lengthy process. Setting this to 'True' will place the invocation back in
the queue after materialization before scheduling the workflow so it is less
likely to starve other workflow scheduling. Ideally, Galaxy would allow more
fine grain control of handlers but until then, this provides a way to tip the
balance between "doing more work" and "being more fair". The default here is
pretty arbitrary - it has been to False to optimize Galaxy for automated,
single user applications where "fairness" is mostly irrelevant.
cache_user_job_count:
type: bool
default: false
+7 -1
View File
@@ -130,7 +130,13 @@ class ItemGrabber:
if self.grab_model is model.Job:
grab_condition = self.grab_model.state == self.grab_model.states.NEW
elif self.grab_model is model.WorkflowInvocation:
grab_condition = self.grab_model.state.in_((self.grab_model.states.NEW, self.grab_model.states.CANCELLING))
grab_condition = self.grab_model.state.in_(
(
self.grab_model.states.NEW,
self.grab_model.states.REQUIRES_MATERIALIZATION,
self.grab_model.states.CANCELLING,
)
)
else:
raise NotImplementedError(f"Grabbing {self.grab_model.__name__} not implemented")
subq = (
+27 -1
View File
@@ -18,6 +18,7 @@ import random
import string
from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass
from datetime import (
datetime,
timedelta,
@@ -8546,6 +8547,12 @@ class StoredWorkflowMenuEntry(Base, RepresentById):
)
@dataclass
class InputWithRequest:
input: Any
request: Dict[str, Any]
class WorkflowInvocation(Base, UsesCreateAndUpdateTime, Dictifiable, Serializable):
__tablename__ = "workflow_invocation"
@@ -8777,6 +8784,7 @@ class WorkflowInvocation(Base, UsesCreateAndUpdateTime, Dictifiable, Serializabl
and_conditions = [
or_(
WorkflowInvocation.state == WorkflowInvocation.states.NEW,
WorkflowInvocation.state == WorkflowInvocation.states.REQUIRES_MATERIALIZATION,
WorkflowInvocation.state == WorkflowInvocation.states.READY,
WorkflowInvocation.state == WorkflowInvocation.states.CANCELLING,
),
@@ -8868,6 +8876,14 @@ class WorkflowInvocation(Base, UsesCreateAndUpdateTime, Dictifiable, Serializabl
inputs.append(input_dataset_collection_assoc)
return inputs
def inputs_requiring_materialization(self):
hdas_to_materialize = []
for input_dataset_assoc in self.input_datasets:
request = input_dataset_assoc.request
if request and not request.get("deferred", False):
hdas_to_materialize.append(input_dataset_assoc.dataset)
return hdas_to_materialize
def _serialize(self, id_encoder, serialization_options):
invocation_attrs = dict_for(self)
invocation_attrs["state"] = self.state
@@ -9030,20 +9046,28 @@ class WorkflowInvocation(Base, UsesCreateAndUpdateTime, Dictifiable, Serializabl
else:
request_to_content.workflow_step = step
request: Optional[Dict[str, Any]] = None
if isinstance(content, InputWithRequest):
request = content.request
content = content.input
history_content_type = getattr(content, "history_content_type", None)
if history_content_type == "dataset":
request_to_content = WorkflowRequestToInputDatasetAssociation()
request_to_content.dataset = content
request_to_content.request = request
attach_step(request_to_content)
self.input_datasets.append(request_to_content)
elif history_content_type == "dataset_collection":
request_to_content = WorkflowRequestToInputDatasetCollectionAssociation()
request_to_content.dataset_collection = content
request_to_content.request = request
attach_step(request_to_content)
self.input_dataset_collections.append(request_to_content)
else:
request_to_content = WorkflowRequestInputStepParameter()
request_to_content.parameter_value = content
request_to_content.request = request
attach_step(request_to_content)
self.input_step_parameters.append(request_to_content)
@@ -9467,6 +9491,7 @@ class WorkflowRequestToInputDatasetAssociation(Base, Dictifiable, Serializable):
workflow_invocation_id: Mapped[Optional[int]] = mapped_column(ForeignKey("workflow_invocation.id"), index=True)
workflow_step_id: Mapped[Optional[int]] = mapped_column(ForeignKey("workflow_step.id"))
dataset_id: Mapped[Optional[int]] = mapped_column(ForeignKey("history_dataset_association.id"), index=True)
request: Mapped[Optional[Dict]] = mapped_column(JSONType)
workflow_step: Mapped[Optional["WorkflowStep"]] = relationship()
dataset: Mapped[Optional["HistoryDatasetAssociation"]] = relationship()
@@ -9502,6 +9527,7 @@ class WorkflowRequestToInputDatasetCollectionAssociation(Base, Dictifiable, Seri
workflow_invocation: Mapped[Optional["WorkflowInvocation"]] = relationship(
back_populates="input_dataset_collections"
)
request: Mapped[Optional[Dict]] = mapped_column(JSONType)
history_content_type = "dataset_collection"
dict_collection_visible_keys = ["id", "workflow_invocation_id", "workflow_step_id", "dataset_collection_id", "name"]
@@ -9525,6 +9551,7 @@ class WorkflowRequestInputStepParameter(Base, Dictifiable, Serializable):
workflow_invocation_id: Mapped[Optional[int]] = mapped_column(ForeignKey("workflow_invocation.id"), index=True)
workflow_step_id: Mapped[Optional[int]] = mapped_column(ForeignKey("workflow_step.id"))
parameter_value: Mapped[Optional[bytes]] = mapped_column(MutableJSONType)
request: Mapped[Optional[Dict]] = mapped_column(JSONType)
workflow_step: Mapped[Optional["WorkflowStep"]] = relationship()
workflow_invocation: Mapped[Optional["WorkflowInvocation"]] = relationship(back_populates="input_step_parameters")
@@ -9548,7 +9575,6 @@ class WorkflowInvocationOutputDatasetAssociation(Base, Dictifiable, Serializable
workflow_step_id: Mapped[Optional[int]] = mapped_column(ForeignKey("workflow_step.id"), index=True)
dataset_id: Mapped[Optional[int]] = mapped_column(ForeignKey("history_dataset_association.id"), index=True)
workflow_output_id: Mapped[Optional[int]] = mapped_column(ForeignKey("workflow_output.id"), index=True)
workflow_invocation: Mapped[Optional["WorkflowInvocation"]] = relationship(back_populates="output_datasets")
workflow_step: Mapped[Optional["WorkflowStep"]] = relationship()
dataset: Mapped[Optional["HistoryDatasetAssociation"]] = relationship()
+1
View File
@@ -281,6 +281,7 @@ class InvocationMessageResponseModel(RootModel):
class InvocationState(str, Enum):
NEW = "new" # Brand new workflow invocation... maybe this should be same as READY
REQUIRES_MATERIALIZATION = "requires_materialization" # an otherwise NEW or READY workflow that requires inputs to be materialized (undeferred)
READY = "ready" # Workflow ready for another iteration of scheduling.
SCHEDULED = "scheduled" # Workflow has been scheduled.
CANCELLED = "cancelled"
+6 -1
View File
@@ -144,7 +144,12 @@ def queue_invoke(
)
workflow_invocation = workflow_run_config_to_request(trans, workflow_run_config, workflow)
workflow_invocation.workflow = workflow
return trans.app.workflow_scheduling_manager.queue(workflow_invocation, request_params, flush=flush)
initial_state = model.WorkflowInvocation.states.NEW
if workflow_run_config.requires_materialization:
initial_state = model.WorkflowInvocation.states.REQUIRES_MATERIALIZATION
return trans.app.workflow_scheduling_manager.queue(
workflow_invocation, request_params, flush=flush, initial_state=initial_state
)
class WorkflowInvoker:
+33 -6
View File
@@ -10,11 +10,13 @@ from typing import (
)
from galaxy import exceptions
from galaxy.managers.hdas import dereference_input
from galaxy.model import (
EffectiveOutput,
History,
HistoryDatasetAssociation,
HistoryDatasetCollectionAssociation,
InputWithRequest,
LibraryDataset,
LibraryDatasetDatasetAssociation,
WorkflowInvocation,
@@ -25,6 +27,7 @@ from galaxy.model.base import (
ensure_object_added_to_session,
transaction,
)
from galaxy.tool_util.parameters import DataRequestUri
from galaxy.tools.parameters.meta import expand_workflow_inputs
from galaxy.workflow.resources import get_resource_mapper_function
@@ -57,6 +60,9 @@ class WorkflowRunConfig:
:param inputs: Map from step ids to dict's containing HDA for these steps.
:type inputs: dict
:param requires_materialization: True if an input requires materialization before
the workflow is scheduled.
:param inputs_by: How inputs maps to inputs (datasets/collections) to workflows
steps - by unencoded database id ('step_id'), index in workflow
'step_index' (independent of database), or by input name for
@@ -78,6 +84,7 @@ class WorkflowRunConfig:
copy_inputs_to_history: bool = False,
use_cached_job: bool = False,
resource_params: Optional[Dict[int, Any]] = None,
requires_materialization: bool = False,
preferred_object_store_id: Optional[str] = None,
preferred_outputs_object_store_id: Optional[str] = None,
preferred_intermediate_object_store_id: Optional[str] = None,
@@ -91,6 +98,7 @@ class WorkflowRunConfig:
self.resource_params = resource_params or {}
self.allow_tool_state_corrections = allow_tool_state_corrections
self.use_cached_job = use_cached_job
self.requires_materialization = requires_materialization
self.preferred_object_store_id = preferred_object_store_id
self.preferred_outputs_object_store_id = preferred_outputs_object_store_id
self.preferred_intermediate_object_store_id = preferred_intermediate_object_store_id
@@ -310,7 +318,7 @@ def build_workflow_run_configs(
legacy = payload.get("legacy", False)
already_normalized = payload.get("parameters_normalized", False)
raw_parameters = payload.get("parameters", {})
requires_materialization: bool = False
run_configs = []
unexpanded_param_map = _normalize_step_parameters(
workflow.steps, raw_parameters, legacy=legacy, already_normalized=already_normalized
@@ -368,16 +376,22 @@ def build_workflow_run_configs(
raise exceptions.RequestParameterInvalidException(
f"Not input source type defined for input '{input_dict}'."
)
if "id" not in input_dict:
raise exceptions.RequestParameterInvalidException(f"Not input id defined for input '{input_dict}'.")
input_source = input_dict["src"]
if "id" not in input_dict and input_source != "url":
raise exceptions.RequestParameterInvalidException(f"No input id defined for input '{input_dict}'.")
elif input_source == "url" and not input_dict.get("url"):
raise exceptions.RequestParameterInvalidException(
f"Supplied 'url' is empty or absent for input '{input_dict}'."
)
if "content" in input_dict:
raise exceptions.RequestParameterInvalidException(
f"Input cannot specify explicit 'content' attribute {input_dict}'."
)
input_source = input_dict["src"]
input_id = input_dict["id"]
input_id = input_dict.get("id")
try:
added_to_history = False
if input_source == "ldda":
assert input_id
ldda = trans.sa_session.get(LibraryDatasetDatasetAssociation, trans.security.decode_id(input_id))
assert ldda
assert trans.user_is_admin or trans.app.security_agent.can_access_dataset(
@@ -385,6 +399,7 @@ def build_workflow_run_configs(
)
content = ldda.to_history_dataset_association(history, add_to_history=add_to_history)
elif input_source == "ld":
assert input_id
library_dataset = trans.sa_session.get(LibraryDataset, trans.security.decode_id(input_id))
assert library_dataset
ldda = library_dataset.library_dataset_dataset_association
@@ -394,6 +409,7 @@ def build_workflow_run_configs(
)
content = ldda.to_history_dataset_association(history, add_to_history=add_to_history)
elif input_source == "hda":
assert input_id
# Get dataset handle, add to dict and history if necessary
content = trans.sa_session.get(HistoryDatasetAssociation, trans.security.decode_id(input_id))
assert trans.user_is_admin or trans.app.security_agent.can_access_dataset(
@@ -401,11 +417,21 @@ def build_workflow_run_configs(
)
elif input_source == "hdca":
content = app.dataset_collection_manager.get_dataset_collection_instance(trans, "history", input_id)
elif input_source == "url":
data_request = DataRequestUri.model_validate(input_dict)
hda: HistoryDatasetAssociation = dereference_input(trans, data_request, history)
added_to_history = True
content = InputWithRequest(
input=hda,
request=data_request.model_dump(mode="json"),
)
if not data_request.deferred:
requires_materialization = True
else:
raise exceptions.RequestParameterInvalidException(
f"Unknown workflow input source '{input_source}' specified."
)
if add_to_history and content.history != history:
if not added_to_history and add_to_history and content.history != history:
if isinstance(content, HistoryDatasetCollectionAssociation):
content = content.copy(element_destination=history, flush=False)
else:
@@ -474,6 +500,7 @@ def build_workflow_run_configs(
allow_tool_state_corrections=allow_tool_state_corrections,
use_cached_job=use_cached_job,
resource_params=resource_params,
requires_materialization=requires_materialization,
preferred_object_store_id=preferred_object_store_id,
preferred_outputs_object_store_id=preferred_outputs_object_store_id,
preferred_intermediate_object_store_id=preferred_intermediate_object_store_id,
+26 -2
View File
@@ -1,11 +1,17 @@
import os
from functools import partial
from typing import Optional
import galaxy.workflow.schedulers
from galaxy import model
from galaxy.exceptions import HandlerAssignmentError
from galaxy.jobs.handler import InvocationGrabber
from galaxy.model.base import transaction
from galaxy.schema.invocation import InvocationState
from galaxy.schema.tasks import (
MaterializeDatasetInstanceTaskRequest,
RequestUser,
)
from galaxy.util import plugin_config
from galaxy.util.custom_logging import get_logger
from galaxy.util.monitors import Monitors
@@ -154,8 +160,9 @@ class WorkflowSchedulingManager(ConfiguresHandlers):
if exception:
raise exception
def queue(self, workflow_invocation, request_params, flush=True):
workflow_invocation.set_state(model.WorkflowInvocation.states.NEW)
def queue(self, workflow_invocation, request_params, flush=True, initial_state: Optional[InvocationState] = None):
initial_state = initial_state or model.WorkflowInvocation.states.NEW
workflow_invocation.set_state(initial_state)
workflow_invocation.scheduler = request_params.get("scheduler", None) or self.default_scheduler_id
sa_session = self.app.model.context
sa_session.add(workflow_invocation)
@@ -329,6 +336,23 @@ class WorkflowRequestMonitor(Monitors):
def __attempt_schedule(self, invocation_id, workflow_scheduler):
with self.app.model.context() as session:
workflow_invocation = session.get(model.WorkflowInvocation, invocation_id)
if workflow_invocation.state == workflow_invocation.states.REQUIRES_MATERIALIZATION:
hdas_to_materialize = workflow_invocation.inputs_requiring_materialization()
for hda in hdas_to_materialize:
user = RequestUser(user_id=workflow_invocation.history.user_id)
task_request = MaterializeDatasetInstanceTaskRequest(
user=user,
history_id=workflow_invocation.history.id,
source="hda",
content=hda.id,
)
self.app.hda_manager.materialize(task_request, in_place=True)
# place back into ready and let it proceed normally on next iteration?
workflow_invocation.set_state(model.WorkflowInvocation.states.READY)
session.add(workflow_invocation)
session.commit()
if self.app.config.workflow_scheduling_separate_materialization_iteration:
return None
try:
if workflow_invocation.state == workflow_invocation.states.CANCELLING:
workflow_invocation.cancel_invocation_steps()
+18 -2
View File
@@ -1533,11 +1533,27 @@ steps:
def test_run_workflow(self):
self.__run_cat_workflow(inputs_by="step_id")
def __run_cat_workflow(self, inputs_by):
@skip_without_tool("cat1")
def test_run_workflow_by_deferred_url(self):
with self.dataset_populator.test_history() as history_id:
self.__run_cat_workflow(inputs_by="deferred_url", history_id=history_id)
input_dataset_details = self.dataset_populator.get_history_dataset_details(history_id, hid=1)
assert input_dataset_details["state"] == "deferred"
@skip_without_tool("cat1")
def test_run_workflow_by_url(self):
with self.dataset_populator.test_history() as history_id:
self.__run_cat_workflow(inputs_by="url", history_id=history_id)
input_dataset_details = self.dataset_populator.get_history_dataset_details(history_id, hid=1)
assert input_dataset_details["state"] == "ok"
def __run_cat_workflow(self, inputs_by, history_id: Optional[str] = None):
workflow = self.workflow_populator.load_workflow(name="test_for_run")
workflow["steps"]["0"]["uuid"] = str(uuid4())
workflow["steps"]["1"]["uuid"] = str(uuid4())
workflow_request, _, workflow_id = self._setup_workflow_run(workflow, inputs_by=inputs_by)
workflow_request, _, workflow_id = self._setup_workflow_run(
workflow, inputs_by=inputs_by, history_id=history_id
)
invocation_id = self.workflow_populator.invoke_workflow_and_wait(workflow_id, request=workflow_request).json()[
"id"
]
+24 -3
View File
@@ -2155,23 +2155,33 @@ class BaseWorkflowPopulator(BasePopulator):
workflow_id = self.create_workflow(workflow)
if not history_id:
history_id = self.dataset_populator.new_history()
hda1 = self.dataset_populator.new_dataset(history_id, content="1 2 3", wait=True)
hda2 = self.dataset_populator.new_dataset(history_id, content="4 5 6", wait=True)
hda1: Optional[Dict[str, Any]] = None
hda2: Optional[Dict[str, Any]] = None
label_map: Optional[Dict[str, Any]] = None
if inputs_by != "url":
hda1 = self.dataset_populator.new_dataset(history_id, content="1 2 3", wait=True)
hda2 = self.dataset_populator.new_dataset(history_id, content="4 5 6", wait=True)
label_map = {"WorkflowInput1": ds_entry(hda1), "WorkflowInput2": ds_entry(hda2)}
workflow_request = dict(
history=f"hist_id={history_id}",
)
label_map = {"WorkflowInput1": ds_entry(hda1), "WorkflowInput2": ds_entry(hda2)}
if inputs_by == "step_id":
assert label_map
ds_map = self.build_ds_map(workflow_id, label_map)
workflow_request["ds_map"] = ds_map
elif inputs_by == "step_index":
assert hda1
assert hda2
index_map = {"0": ds_entry(hda1), "1": ds_entry(hda2)}
workflow_request["inputs"] = json.dumps(index_map)
workflow_request["inputs_by"] = "step_index"
elif inputs_by == "name":
assert label_map
workflow_request["inputs"] = json.dumps(label_map)
workflow_request["inputs_by"] = "name"
elif inputs_by in ["step_uuid", "uuid_implicitly"]:
assert hda1
assert hda2
assert workflow, f"Must specify workflow for this inputs_by {inputs_by} parameter value"
uuid_map = {
workflow["steps"]["0"]["uuid"]: ds_entry(hda1),
@@ -2180,6 +2190,16 @@ class BaseWorkflowPopulator(BasePopulator):
workflow_request["inputs"] = json.dumps(uuid_map)
if inputs_by == "step_uuid":
workflow_request["inputs_by"] = "step_uuid"
elif inputs_by in ["url", "deferred_url"]:
input_b64_1 = base64.b64encode(b"1 2 3").decode("utf-8")
input_b64_2 = base64.b64encode(b"4 5 6").decode("utf-8")
deferred = inputs_by == "deferred_url"
inputs = {
"WorkflowInput1": {"src": "url", "url": f"base64://{input_b64_1}", "ext": "txt", "deferred": deferred},
"WorkflowInput2": {"src": "url", "url": f"base64://{input_b64_2}", "ext": "txt", "deferred": deferred},
}
workflow_request["inputs"] = json.dumps(inputs)
workflow_request["inputs_by"] = "name"
return workflow_request, history_id, workflow_id
@@ -3246,6 +3266,7 @@ def wait_on_state(
"queued",
"new",
"ready",
"requires_materialization",
"stop",
"stopped",
"setting_metadata",