Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
from inference.core.workflows.execution_engine.v1.compiler.cache import (
BasicWorkflowsCache,
)
from inference.core.workflows.execution_engine.v1.compiler.disabled_steps import (
strip_disabled_steps,
)
from inference.core.workflows.execution_engine.v1.compiler.entities import (
CompiledWorkflow,
GraphCompilationResult,
Expand Down Expand Up @@ -169,6 +172,10 @@ def compile_workflow_graph(
available_blocks=available_blocks,
profiler=profiler,
)
inlined_raw_workflow_definition = strip_disabled_steps(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium — running after inline_inner_workflow_steps makes the fix a silent no-op for disabled Inner Workflow blocks.

inline_inner_workflow_steps replaces a roboflow_core/inner_workflow@v1 step named my_sub with its children, renamed to {inner}__{child} (inner_workflow/inline.py:75-92, _unique_prefixed_step_name). The my_sub step no longer exists in steps by the time strip_disabled_steps runs.

So for metadata.ui.nodes["$steps.my_sub"].disabled = true:

  • disabled = {"my_sub"} matches no surviving step name (my_sub__child, …) → nothing dropped
  • disabled_node_ids = {"$steps.my_sub"} matches no surviving reference either, because inlining already rewrote $steps.my_sub.<output> to the child selectors

Net effect: a user who disables an Inner Workflow block in the builder still gets every child step compiled and executed, weights included — exactly the reported symptom the PR is fixing. (The related gap: disabled flags stored in a child workflow's own metadata are never read, since only root-level metadata is consulted.)

Running the strip on raw_workflow_definition before inlining would handle the outer case naturally. If ordering has to stay as-is, the inner-workflow case needs explicit handling — and either way it needs a test, since nothing here fails loudly.

workflow_definition=inlined_raw_workflow_definition,
available_blocks=available_blocks,
)
parsed_workflow_definition = parse_workflow_definition(
raw_workflow_definition=inlined_raw_workflow_definition,
available_blocks=available_blocks,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
"""
Honour steps disabled in the Workflow builder at runtime.

The builder stores "disable block" as a UI-only flag at
``metadata.ui.nodes["$steps.<name>"].disabled`` and strips such steps client-side
before preview runs. The persisted specification still contains them, so any
runtime that fetches the workflow by id (inference server, edge devices, Dedicated
Deployments, serverless) would otherwise compile and execute every step - including
loading model weights for disabled model blocks.

This module mirrors the builder's ``stripDisabledForExecution`` logic:
* seed: every step manually flagged ``disabled: true``
* cascade (a): a step whose required (no-default) field would be emptied entirely
by removing references to disabled steps is itself disabled
* cascade (b): a step gated only by conditional-flow blocks (``next_steps``)
that are all disabled is itself disabled
* strip: drop disabled steps, remove references to them from surviving steps,
drop outputs whose selector points at a disabled step
"""

import re
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Type

from pydantic.fields import FieldInfo
from typing_extensions import get_args

from inference.core.workflows.execution_engine.v1.compiler.entities import (
BlockSpecification,
)
from inference.core.workflows.prototypes.block import WorkflowBlockManifest

STEP_REF_PATTERN = re.compile(r"\$steps\.([^.\s\]\[\"']+)")
NEXT_STEPS_FIELD = "next_steps"
RESERVED_STEP_KEYS = {"type", "name", "id"}


def strip_disabled_steps(
workflow_definition: Dict[str, Any],
available_blocks: Iterable[BlockSpecification],
) -> Dict[str, Any]:
"""Return a copy of ``workflow_definition`` with disabled steps removed.

Returns the input object untouched when nothing is disabled, so the common
path is free of copies.
"""
manually_disabled = _collect_manually_disabled_step_names(workflow_definition)
if not manually_disabled:
return workflow_definition
steps = workflow_definition.get("steps") or []
manifests_by_type = _index_manifests_by_type(available_blocks)
disabled = _compute_disabled_step_names(
steps=steps,
seed=manually_disabled,
manifests_by_type=manifests_by_type,
)
disabled_node_ids = {f"$steps.{name}" for name in disabled}
surviving_steps = []
for step in steps:
if _step_name(step) in disabled:
continue
surviving_steps.append(_strip_references(step, disabled_node_ids))
surviving_outputs = [
output
for output in workflow_definition.get("outputs") or []
if not (
isinstance(output, dict)
and isinstance(output.get("selector"), str)
and _selector_points_at_any(output["selector"], disabled_node_ids)
)
]
result = dict(workflow_definition)
result["steps"] = surviving_steps
result["outputs"] = surviving_outputs
return result


def _collect_manually_disabled_step_names(
workflow_definition: Dict[str, Any],
) -> Set[str]:
metadata = workflow_definition.get("metadata")
if not isinstance(metadata, dict):
return set()
ui = metadata.get("ui")
if not isinstance(ui, dict):
return set()
nodes = ui.get("nodes")
if not isinstance(nodes, dict):
return set()
result = set()
for node_id, node_meta in nodes.items():
if not isinstance(node_meta, dict) or node_meta.get("disabled") is not True:
continue
if not isinstance(node_id, str) or not node_id.startswith("$steps."):
continue
result.add(node_id[len("$steps.") :])
return result


def _compute_disabled_step_names(
steps: List[Dict[str, Any]],
seed: Set[str],
manifests_by_type: Dict[str, Type[WorkflowBlockManifest]],
) -> Set[str]:
disabled = set(seed)
control_predecessors = _build_control_predecessor_map(steps)
changed = True
while changed:
changed = False
disabled_node_ids = {f"$steps.{name}" for name in disabled}
for step in steps:
name = _step_name(step)
if not name or name in disabled:
continue
if _has_fully_disabled_required_field(
step=step,
disabled_node_ids=disabled_node_ids,
manifests_by_type=manifests_by_type,
) or _has_only_disabled_control_predecessors(
step_name=name,
control_predecessors=control_predecessors,
disabled=disabled,
):
disabled.add(name)
changed = True
return disabled


def _has_fully_disabled_required_field(
step: Dict[str, Any],
disabled_node_ids: Set[str],
manifests_by_type: Dict[str, Type[WorkflowBlockManifest]],
) -> bool:
manifest_class = manifests_by_type.get(step.get("type"))
if manifest_class is None:
return False
for field_name, field_info in manifest_class.model_fields.items():
if field_name in RESERVED_STEP_KEYS or field_name == NEXT_STEPS_FIELD:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — disabling the only downstream target of a flow-control block makes the whole workflow fail to compile.

next_steps is excluded from the cascade-(a) check here, but it is not excluded from _strip_references (L185-197). _clean_value on a list that ends up empty returns _DELETE (L223), and _strip_references then drops the key entirely.

next_steps is a required field (no default) on every flow-control block that has it:

  • core_steps/flow_control/continue_if/v1.py:115
  • core_steps/flow_control/rate_limiter/v1.py:104
  • core_steps/flow_control/delta_filter/v1.py:93

Concrete failure — this is the mirror image of the scenario your own test covers:

steps: [
  {"type": "roboflow_core/continue_if@v1", "name": "gate", ..., "next_steps": ["$steps.second_model"]},
  {"type": "roboflow_core/roboflow_object_detection_model@v2", "name": "second_model", ...}
]
metadata: {"ui": {"nodes": {"$steps.second_model": {"disabled": true}}}}   // user disables the TARGET, keeps the gate
  • seed = {second_model}
  • cascade (a) for gate: condition_statement has no step refs, next_steps is skipped by this line → gate survives
  • cascade (b): gate has no control predecessors → survives
  • _strip_references(gate): ["$steps.second_model"] → empty list → _DELETEnext_steps key removed
  • parse_workflow_definition → pydantic field requiredWorkflowSyntaxError

So the user disables one block in the builder and the entire workflow stops running with a syntax error on every runtime that fetches it by id — strictly worse than the bug being fixed.

Same crash from a second trigger: _clean_value returns _DELETE for a collection that was already empty before stripping (L222-223 doesn't distinguish "emptied by us" from "empty to begin with"). A continue_if persisted with "next_steps": [] — explicitly documented as valid ("If empty, the branch terminates even when the condition is true", continue_if/v1.py:115) — will lose the key and fail to parse as soon as any step anywhere in the workflow is disabled. More generally, an explicitly-empty list/dict on a surviving step is silently replaced by the manifest default rather than preserved.

Suggested direction: when a cleaned collection becomes empty, keep []/{} instead of deleting when the field is required (or at minimum special-case next_steps[], which the blocks already handle as "terminate branch"), and only fall back to _DELETE for optional fields that were non-empty to begin with.

continue
if not _is_required(field_info):
continue
value = step.get(field_name)
if value is None and field_info.alias:
value = step.get(field_info.alias)
if value is None:
continue
if not _find_step_refs(value):
continue
cleaned, _ = _clean_value(value, disabled_node_ids)
if cleaned is _DELETE:
return True
return False


def _is_required(field_info: FieldInfo) -> bool:
try:
return field_info.is_required()
except AttributeError: # pragma: no cover - pydantic v1 fallback
return getattr(field_info, "required", False) is True


def _build_control_predecessor_map(
steps: List[Dict[str, Any]],
) -> Dict[str, Set[str]]:
result: Dict[str, Set[str]] = {}
for step in steps:
source = _step_name(step)
if not source or NEXT_STEPS_FIELD not in step:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — cascade (b) misses switch_case@v1, so disabling a Switch Case makes its gated branches run unconditionally.

The control-predecessor map only looks at a field literally named next_steps. roboflow_core/switch_case@v1 routes control through cases: Dict[str, StepSelector] and default_next_steps (core_steps/flow_control/switch_case/v1.py:99 and :115) — neither is named next_steps.

Failure path with $steps.router (a Switch Case) disabled and on_red / on_blue reachable only through it:

  • seed = {router}, cascade (b) finds no control predecessors for on_red/on_blue (map is empty)
  • cascade (a) doesn't fire either — their required fields reference $inputs.image, not the router
  • router is stripped; on_red/on_blue survive with their data dependencies intact

Control flow only suppresses steps in the executor, so once the gate is gone both branches execute on every run. Before this PR they ran only on a matching case. That inverts the PR's stated goal: disabling a Switch Case now causes more model weights to be loaded and more branches to execute than before, on every runtime that fetches the workflow by id.

rate_limiter and delta_filter happen to be covered because they use the literal next_steps name — the map should be driven by the manifest's StepSelector-typed fields (you already have manifests_by_type available) rather than by a hardcoded field name, so any current or future flow-control block is picked up.

continue
for target in _find_step_refs(step.get(NEXT_STEPS_FIELD)):
result.setdefault(target, set()).add(source)
return result


def _has_only_disabled_control_predecessors(
step_name: str,
control_predecessors: Dict[str, Set[str]],
disabled: Set[str],
) -> bool:
predecessors = control_predecessors.get(step_name)
if not predecessors:
return False
return all(predecessor in disabled for predecessor in predecessors)


def _strip_references(
step: Dict[str, Any], disabled_node_ids: Set[str]
) -> Dict[str, Any]:
result = {}
for key, value in step.items():
if key in RESERVED_STEP_KEYS:
result[key] = value
continue
cleaned, _ = _clean_value(value, disabled_node_ids)
if cleaned is _DELETE:
continue
result[key] = cleaned
return result


class _Delete:
pass


_DELETE = _Delete()


def _clean_value(value: Any, disabled_node_ids: Set[str]) -> Tuple[Any, bool]:
"""Return (cleaned_value, changed). ``_DELETE`` means drop the value."""
if isinstance(value, str):
if _string_references_disabled(value, disabled_node_ids):
return _DELETE, True
return value, False
if isinstance(value, list):
changed = False
filtered = []
for item in value:
cleaned, item_changed = _clean_value(item, disabled_node_ids)
if cleaned is _DELETE:
changed = True
continue
changed = changed or item_changed
filtered.append(cleaned)
if not filtered:
return _DELETE, True
return (filtered if changed else value), changed
if isinstance(value, dict):
changed = False
out = {}
for key, item in value.items():
cleaned, item_changed = _clean_value(item, disabled_node_ids)
if cleaned is _DELETE:
changed = True
continue
changed = changed or item_changed
out[key] = cleaned
if not out:
return _DELETE, True
return (out if changed else value), changed
return value, False


def _string_references_disabled(value: str, disabled_node_ids: Set[str]) -> bool:
return any(
value == node_id or value.startswith(f"{node_id}.")
for node_id in disabled_node_ids
)


def _selector_points_at_any(selector: str, disabled_node_ids: Set[str]) -> bool:
return _string_references_disabled(selector, disabled_node_ids)


def _find_step_refs(value: Any) -> List[str]:
if isinstance(value, str):
return STEP_REF_PATTERN.findall(value)
if isinstance(value, list):
return [ref for item in value for ref in _find_step_refs(item)]
if isinstance(value, dict):
return [ref for item in value.values() for ref in _find_step_refs(item)]
return []


def _step_name(step: Any) -> Optional[str]:
if not isinstance(step, dict):
return None
name = step.get("name") or step.get("id")
return name if isinstance(name, str) else None


def _index_manifests_by_type(
available_blocks: Iterable[BlockSpecification],
) -> Dict[str, Type[WorkflowBlockManifest]]:
result: Dict[str, Type[WorkflowBlockManifest]] = {}
for block in available_blocks:
manifest_class = block.manifest_class
type_field = manifest_class.model_fields.get("type")
if type_field is None:
continue
for type_identifier in get_args(type_field.annotation):
if isinstance(type_identifier, str):
result[type_identifier] = manifest_class
return result
Loading
Loading