Skip to main content

Execution Identity and Lineage with `ActionID`

Start with the root action identity

A task run needs two related names: the name of the current action and the name of the run that contains it. models.ActionID stores both, along with optional project, domain, and org identifiers. It is a frozen, keyword-only dataclass, so construct it with keyword arguments and do not mutate it afterward.

from flyte.models import ActionID

action = ActionID(
name="a0",
run_name="training-run",
project="my-project",
domain="development",
org="my-org",
)

name is required. If you omit run_name, ActionID.__post_init__ sets it to the action name. Thus, ActionID(name="x") has both name == "x" and run_name == "x"; it does not automatically become a child of some other run.

For a root identity without an explicit name, call ActionID.create_random():

from flyte.models import ActionID

action = ActionID.create_random()

create_random() obtains a name from generate_random_name() and passes that same value as both name and run_name. The explicit-name and random-name forms are both used by Run: the local path chooses create_random() when no run name was supplied, while the remote path constructs an action named "a0" and associates it with the configured run and project metadata (_run.py).

Preserve a run while creating an action

Use new_sub_action() when you need a child action with a supplied or randomly generated name:

child = action.new_sub_action("preprocess")
random_child = action.new_sub_action()

Both calls return a replacement value rather than modifying action. The implementation uses dataclasses.replace(self, name=name), so the child keeps the parent’s run_name, project, domain, and org. This is the important distinction from constructing ActionID(name="preprocess") directly: the direct construction defaults its run_name to "preprocess", whereas new_sub_action() keeps the existing run.

Carry the identity in TaskContext

Task code reads the current action through the task context rather than receiving an ActionID as an ordinary task input. TaskContext is also frozen and keyword-only. Its required execution fields are action, version, raw_data_path, output_path, run_base_dir, and report; it additionally carries optional input, checkpoint, code-bundle, image-cache, group, and arbitrary data state. Its execution mode defaults to "remote" and accepts "local", "remote", or "hybrid".

The run implementation creates and installs the context around run_task:

tctx = TaskContext(
action=action,
checkpoints=checkpoints,
code_bundle=code_bundle,
output_path=output_path,
version=version if version else "na",
raw_data_path=raw_data_path_obj,
compiled_image_cache=image_cache,
run_base_dir=self._run_base_dir,
report=flyte.report.Report(name=action.name),
)
async with ctx.replace_task_context(tctx):
return await run_task(tctx=tctx, controller=controller, task=obj, inputs=inputs)

Context.replace_task_context() returns a context containing the supplied TaskContext. The public flyte.ctx() function retrieves the current task context, so code running inside that installed context can inspect the identity with flyte.ctx().action.

Local runs use the same carrier but explicitly set mode="local":

tctx = TaskContext(
action=action,
checkpoints=Checkpoints(
prev_checkpoint_path=internal_ctx().raw_data.path,
checkpoint_path=internal_ctx().raw_data.path,
),
code_bundle=None,
output_path=self._metadata_path,
run_base_dir=self._metadata_path,
version="na",
raw_data_path=internal_ctx().raw_data,
compiled_image_cache=None,
report=Report(name=action.name),
mode="local",
)
with ctx.replace_task_context(tctx):
...

TaskContext.is_in_cluster() returns True only when mode == "remote"; "hybrid" is not treated as in-cluster. The local controller still uses the context for trace identity, but local mapping is not a remote child-submission path.

Update context state immutably

Call TaskContext.replace() to derive a context with changed fields. Its special handling for data copies the existing dictionary and merges the supplied keys:

new_tctx = tctx.replace(group_data=GroupData(name))

The group() context manager uses exactly this operation, installs the derived context for the duration of the block, and restores the surrounding context afterward. The group name can therefore participate in deterministic child naming without mutating the enclosing TaskContext.

For arbitrary task-context data, replace(data=...) merges into a copy. Passing data=None leaves the existing dictionary in place while applying any other replacements. Dictionary-style access uses tctx["key"]; missing keys return None because TaskContext.__getitem__ delegates to self.data.get(key).

Generate deterministic child actions

When a remote task submits another task, RemoteController.submit() first obtains the current TaskContext. It raises RuntimeSystemError("BadContext", "Task context not initialized") if no task context is installed. Otherwise it reads tctx.action, obtains an invocation sequence with generate_task_call_sequence(), and passes that sequence into submission:

current_action_id = tctx.action
task_call_seq = self.generate_task_call_sequence(_task, current_action_id)
async with self._parent_action_semaphore[unique_action_name(current_action_id)]:
return await self._submit(task_call_seq, _task, *args, **kwargs)

Inside the remote submission path, flyte-sdk serializes the task inputs and computes their hash. generate_sub_action_id_and_output_path() then combines the current action with the task identity, input hash, invocation sequence, and current group:

sub_action_id, sub_action_output_path = convert.generate_sub_action_id_and_output_path(
tctx, task_spec, inputs_hash, _task_call_seq
)

For a TaskSpec, the helper computes task_hash from deterministic serialization of the task specification. For a trace action, the caller can pass a task name string instead. The helper then calls ActionID.new_sub_action_from():

sub_action_id = current_action_id.new_sub_action_from(
task_hash=task_hash,
input_hash=inputs_hash,
group=tctx.group_data.name if tctx.group_data else None,
task_call_seq=invoke_seq,
)
sub_run_output_path = storage.join(current_output_path, sub_action_id.name)

The naming inputs, in source order, are:

{parent_action_name}-{input_hash}-{task_hash}-{task_call_seq}[-{group}]

The optional -{group} suffix is included only when group is truthy, so None and an empty string contribute the same thing. new_sub_action_from() encodes the MD5 digest of this component string with base36_encode() and uses the result as the child name. The generated name is deterministic when the parent name, hashes, sequence, and group are the same. MD5 here is used to produce the naming value; the method does not establish a security boundary.

The invocation sequence matters when the same parent invokes the same task repeatedly. The remote controller maintains that sequence per task object and parent key, with the sequence bookkeeping tied to Python task-object identity and process lifetime rather than a persisted global counter.

See the lineage in the submitted action and outputs

The deterministic child name is used in two places. First, generate_sub_action_id_and_output_path() appends it to tctx.run_base_dir, producing the child output directory. Second, the remote controller builds the wire action with that child name while preserving the parent and run metadata:

action = Action.from_task(
sub_action_id=identifier_pb2.ActionIdentifier(
name=sub_action_id.name,
run=identifier_pb2.RunIdentifier(
name=current_action_id.run_name,
project=current_action_id.project,
domain=current_action_id.domain,
org=current_action_id.org,
),
),
parent_action_name=current_action_id.name,
group_data=tctx.group_data,
task_spec=task_spec,
inputs_uri=inputs_uri,
run_output_base=tctx.run_base_dir,
cache_key=cache_key,
)

The resulting relationship is therefore:

TaskContext.action (parent)
└─ new_sub_action_from(...)
├─ child ActionIdentifier.name
├─ parent_action_name = parent.name
├─ RunIdentifier.name = parent.run_name
└─ child output path = run_base_dir / child.name

group_data is passed to Action.from_task() as well as included in the deterministic naming inputs. The task version and other execution resources remain available through the enclosing TaskContext; the child submission also uses tctx.run_base_dir as its run output base.

The local trace controller calls the same helper, passing the Python function name, serialized inputs, and sequence 0. This gives local trace records an ActionID and output path using the same naming mechanism, without implying that local execution submits remote actions.

Operational boundaries

  • Install a TaskContext before using remote child submission. RemoteController.submit() rejects a missing context with RuntimeSystemError("BadContext", "Task context not initialized"); local trace lookup similarly raises NotInTaskContextError when no task context exists.
  • Configure a run base directory for programmatic remote runs. _run.py raises ValueError when _run_base_dir is missing and shows flyte.with_runcontext(run_base_dir="s3://bucket/metadata/outputs") as the configuration form. That directory is the base for raw data and generated child output paths.
  • Treat both identity classes as immutable. Use new_sub_action() or new_sub_action_from() for ActionID children and TaskContext.replace() for context changes.
  • Choose ActionID.create_random() for an unnamed local root, an explicit ActionID(name=...) for a requested name, and new_sub_action_from() for a child whose name must be derived from task inputs and invocation lineage.
  • Contextual logging uses the two identity levels: ContextFilter prefixes messages with [action.run_name][action.name]. A stable run name therefore groups messages, while the action name identifies the current action within that run.