Skip to main content

Understanding the Execution Model: Runs and Actions

When you trigger an execution on a remote Flyte or Union cluster, track its execution state, or query previously executed runs, you interact with three core abstractions: Tasks, Runs, and Actions.

In flyte-sdk, these objects define the remote execution lifecycle and data flow:

  • A Task represents a deployed, reusable task template registered on the control plane.
  • A Run represents a top-level execution instance initiated on the control plane.
  • An Action represents the actual unit of execution within a run. A run contains a root action, and complex runs can spawn child actions forming an execution tree.

The Execution Model Hierarchy​

The relationship between Tasks, Runs, and Actions follows a structured hierarchy:

+-------------------------------------------------------------+
| Task (remote._task.Task / TaskDetails) |
| - Registered definition: interface, resources, cache, etc. |
+-------------------------------------------------------------+
|
Trigger Execution
v
+-------------------------------------------------------------+
| Run (remote._run.Run / RunDetails) |
| - Top-level execution session |
| - Tracks overall execution status and web console URL |
| - Contains root Action: self.action = Action(...) |
+-------------------------------------------------------------+
|
Executes as Action Tree
v
+-------------------------------------------------------------+
| Action (remote._action.Action / ActionDetails) |
| - Root Action (ID: \{run_name\}) |
| - Sub-actions / Child Tasks spawned during execution |
| - Manages attempts, runtime, logs, inputs & outputs |
+-------------------------------------------------------------+

When you initialize a Run in remote._run.Run, it validates that the protobuf definition includes an action:

if not self.pb2.HasField("action"):
raise RuntimeError("Run does not have an action")
self.action = Action(self.pb2.action)

Every Run instance encapsulates a root Action instance (self.action) and delegates phase checks, waiting logic, log viewers, and state streaming to it.


Managing Remote Executions with Run and RunDetails​

When you trigger a remote run with flyte.run(...) or fetch an existing execution by name, you work with Run and RunDetails.

Fetching and Inspecting Runs​

To fetch a run by its unique execution name and inspect its state:

import flyte.remote as remote

# Retrieve a remote run by name
run = remote.Run.get("a1b2c3d4e5f6g7h8")

print(f"Run Name: \{run.name\}")
print(f"Current Phase: \{run.phase\}")
print(f"Console URL: \{run.url\}")
print(f"Is Finished: \{run.done()\}")

To list runs within the currently configured organization, project, and domain:

# List the latest 10 runs sorted descending by creation time
for r in remote.Run.listall(sort_by=("created_at", "desc"), limit=10):
print(f"Run \{r.name\} -> \{r.phase\}")

Waiting, Streaming Logs, and Aborting​

Run provides user-facing methods for monitoring and control:

# Wait for the run to reach a terminal state with a Rich terminal progress bar
run.wait()

# Or wait only until the execution transitions into the RUNNING state
run.wait(wait_for="running")

# View terminal logs for the run
run.show_logs(max_lines=50, show_ts=True)

# Terminate / abort an in-flight run
run.abort()

Under the hood:

  • run.wait() creates a rich.progress.Progress spinner and streams updates from self.watch(cache_data_on_done=True).
  • run.show_logs(...) delegates to self.action.show_logs(...), which initializes a log viewer via Logs.create_viewer(...).
  • run.abort() sends an AbortRunRequest to the gRPC run_service. If the run is not found (grpc.StatusCode.NOT_FOUND), the exception is caught and ignored so aborting is idempotent.

Detailed Run Metadata with RunDetails​

While Run contains basic execution metadata, calling run.details() or remote.RunDetails.get(name) fetches the full RunDetails protobuf containing execution specifications (run_spec):

details = run.details()

print(f"Labels: \{details.pb2.run_spec.labels\}")
print(f"Annotations: \{details.pb2.run_spec.annotations\}")
print(f"Interruptible: \{details.pb2.run_spec.interruptible\}")
print(f"Cache Overwrite: \{details.pb2.run_spec.overwrite_cache\}")

Execution Units: Action and ActionDetails​

An Action represents a specific execution step. For single-task executions, the run consists of a single root action. For workflow executions or sub-task launches, child actions execute under the parent run.

Inspecting Actions within a Run​

To list all child actions associated with a specific run:

from flyte.remote import Action, ActionDetails

# List all actions under a given run
for action in Action.listall(for_run_name="a1b2c3d4e5f6g7h8"):
print(f"Action: \{action.name\}, Task: \{action.task_name\}, Phase: \{action.phase\}")

To fetch a specific action directly:

# Fetch an action by run_name and action name
action_details = ActionDetails.get(run_name="a1b2c3d4e5f6g7h8", name="a0")
print(f"Action Status: \{action_details.phase\}")
print(f"Attempts: \{action_details.attempts\}")
print(f"Runtime Duration: \{action_details.runtime\}")

Action Phases and Lifecycle​

An action transitions through phases defined in run_definition_pb2.Phase:

  • PHASE_UNDEFINED
  • PHASE_QUEUED
  • PHASE_INITIALIZING
  • PHASE_RUNNING
  • PHASE_SUCCEEDED (terminal)
  • PHASE_FAILED (terminal)
  • PHASE_ABORTED (terminal)
  • PHASE_TIMED_OUT (terminal)

The action.done() and action_details.done() methods determine whether an action has reached a terminal state.

Streaming Live Action Updates​

ActionDetails.watch(action_id) opens a server-streaming gRPC call (WatchActionDetails) to receive real-time updates:

import asyncio
from flyte.remote import Action

async def stream_action(action: Action):
async for details in action.watch(cache_data_on_done=True):
print(f"Action \{details.name\} is now in phase: \{details.phase\} (Attempt \{details.attempts\})")
if details.error_info:
print(f"Failure reason: \{details.error_info\}")

Working with Inputs and Outputs: ActionInputs and ActionOutputs​

When a task or action executes, its inputs and outputs are managed remotely and converted into native Python objects by ActionDetails.

Accessing Inputs and Outputs​

You can retrieve inputs and outputs from either RunDetails or ActionDetails:

details = run.details()

# Retrieve inputs as ActionInputs (a UserDict subclass containing deserialized values)
inputs = details.inputs()
for key, value in inputs.items():
print(f"Input '\{key\}': \{value\}")

# Retrieve outputs as ActionOutputs (a tuple subclass containing deserialized values)
outputs = details.outputs()
for item in outputs:
print(f"Output: \{item\}")

Output Availability Constraints​

Calling .outputs() triggers an internal call to _cache_data(), which requests action data from run_service.GetActionData and converts protobuf literals into native Python types using flyte._internal.runtime.convert.

If the action has not yet reached a terminal state, calling outputs() raises a RuntimeError:

try:
outputs = details.outputs()
except RuntimeError as e:
# "Action is not in a terminal state, outputs are not available. Please wait for the action to complete."
print("Execution in progress; outputs cannot be fetched yet.")

Referencing and Overriding Remote Tasks: Task and TaskDetails​

The remote._task.Task class allows you to fetch, inspect, and invoke pre-deployed tasks registered on the Flyte/Union control plane.

Fetching Remote Tasks​

Task.get(...) returns a LazyEntity that defers loading the full task definition until it is explicitly called or fetched:

from flyte.remote import Task

# Fetch by exact version
lazy_task = Task.get(name="my_project.workflows.data_prep", version="v1.0.0")

# Fetch by latest version
latest_task = Task.get(name="my_project.workflows.data_prep", auto_version="latest")

# Fetch matching the current execution context (only valid inside an active task)
context_task = Task.get(name="my_project.workflows.data_prep", auto_version="current")

auto_version="current" can only be used when executing inside an active task context where flyte.ctx().version is set. If used outside a task context, it raises a ValueError.

Overriding Task Specifications​

You can override runtime resources, retries, timeouts, environment variables, or secrets on a reference task before execution:

import flyte
from flyte.remote import Task

task_ref = Task.get(name="my_model_trainer", auto_version="latest")

# Override execution attributes
task_ref.override(
resources=flyte.Resources(cpu="4", memory="16Gi"),
retries=3,
timeout=flyte.Timeout(hours=2),
env_vars=\{"ENVIRONMENT": "production"\},
)

TaskDetails.override() modifies the underlying task_definition_pb2.TaskDetails protobuf template:

  • resources updates container.resources.requests and limits.
  • env_vars updates container.env.
  • retries updates metadata.retries.
  • timeout updates metadata.timeout.
  • secrets updates security_context.secrets.

Calling Remote Tasks inside Workflows or Dynamic Tasks​

When a LazyEntity or TaskDetails is invoked within a task execution context, it forwards keyword arguments to the controller to schedule a sub-action:

import flyte
from flyte.remote import Task

remote_step = Task.get(name="data_processing_step", auto_version="latest")

@flyte.task
def parent_task(raw_data_uri: str) -> str:
# Invoking a reference task requires keyword arguments
result = remote_step(input_path=raw_data_uri)
return result

Note: Reference tasks only support keyword arguments. Passing positional arguments raises flyte.errors.ReferenceTaskError.


Dual Sync and Async APIs with @syncify​

Methods across Run, RunDetails, Action, ActionDetails, and Task are decorated with @syncify. This decorator enables seamless use in both synchronous blocking scripts and asynchronous event loops.

Synchronous Usage​

In standard Python scripts, call methods directly without await:

import flyte.remote as remote

# Synchronous execution
run = remote.Run.get("a1b2c3d4e5f6g7h8")
run.wait()
details = run.details()
outputs = details.outputs()

Asynchronous Usage​

Inside an async function or coroutine, access the underlying async implementation using the .aio attribute:

import asyncio
import flyte.remote as remote

async def main():
# Asynchronous execution using .aio
run = await remote.Run.get.aio("a1b2c3d4e5f6g7h8")
await run.wait.aio()
details = await run.details.aio()
outputs = await details.outputs()

asyncio.run(main())

Calling await run.wait() directly without .aio inside an async context will not work; use .aio to access the coroutine version.