Operators and Executions¶
Operators declare reusable transformations over artifacts. At runtime, Jayrun executes each operator through an invocation-specific proxy that supplies artifact data, configuration, resources, and the four operational interfaces without mutating the operator declaration.
This chapter defines the construction and execution contract for jayrun.BaseOperator, including synchronous and asynchronous execution, multiple outputs, repetition, immutability, and operator-local failure behavior. Context lifecycle operations are documented separately under Context Interface.
BaseOperator¶
Every operator subclasses jayrun.BaseOperator, declares its fields in __init__(), and implements jayrun.BaseOperator.execute():
from jayrun import Artifact, ArtifactField, BaseOperator
class Transform(BaseOperator):
def __init__(
self,
*,
input_data: Artifact,
outputs: tuple[Artifact | None, ...],
name: str | None = None,
description: str | None = None,
) -> None:
super().__init__(name=name, description=description)
self.input_data = ArtifactField(required=True)
self.outputs = (ArtifactField(required=True),)
def execute(self) -> object:
return self.input_data.value * 2
The constructed object is a declaration. Jayrun does not call execute() on that object directly. It compiles the method and fields into the graph, then creates a fresh execution proxy for each step session.
Constructor contract¶
An operator constructor must:
Accept declaration arguments by keyword.
Call
super().__init__(name=name, description=description).Assign every input
jayrun.ArtifactField,jayrun.ConfigField, andjayrun.ResourceFielddirectly toself.Assign exactly one tuple of output fields to
self.outputs.When
self.outputsis non-empty, expose anoutputsconstructor argument containing the artifacts orNonebindings for those fields.
Input artifact arguments are matched to field attribute names. If the declaration assigns self.input_data, the constructor must accept input_data. For a non-empty output group, the outputs argument must have the same length and order as self.outputs. A class that declares self.outputs = () does not need an outputs constructor argument.
source = Artifact(name="source")
result = Artifact(name="result")
operator = Transform(
input_data=source,
outputs=(result,),
name="transform",
)
Important
Constructor attributes describe the declaration only. Runtime computation may rely on declared fields and injected operational interfaces; arbitrary instance state is not copied to the execution proxy.
Invalid declarations fail during construction. Typical failures include a missing outputs argument for a non-empty output group, a non-tuple self.outputs, mismatched output counts, missing artifact arguments, repeated use of one artifact within an input or output group, or an operator with no connected input. An operator does not need a connected output.
The field types are part of Jayrun’s data model, not operator-specific value containers:
ArtifactField defines artifact input and output ports;
ConfigField defines context-scoped configuration requirements;
ResourceField defines runtime-managed dependencies; and
Data defines the runtime object injected for every resolved field.
Declaring inputs and outputs¶
Declare each input as a direct jayrun.ArtifactField attribute. Declare outputs only inside self.outputs:
class Combine(BaseOperator):
def __init__(
self,
*,
left: Artifact,
right: Artifact,
outputs: tuple[Artifact | None, ...],
name: str | None = None,
description: str | None = None,
) -> None:
super().__init__(name=name, description=description)
self.left = ArtifactField(required=True)
self.right = ArtifactField(required=True)
self.outputs = (ArtifactField(required=True),)
def execute(self) -> object:
return self.left.value + self.right.value
At runtime, an artifact field becomes jayrun.Data. Read its payload through .value and its location through .placement. See Data for automatic wrapping, placement metadata, and ownership-specific lifetime.
An operator must have at least one input bound to an artifact. Outputs are optional: self.outputs may be empty, individual output fields may be bound to None, and a non-empty output group may be entirely unbound. Within the input group and within the output group, one artifact cannot be bound to several fields. An input and an output may use the same artifact; this regenerates that artifact.
For the exact required behavior of input and output fields, see ArtifactField. In particular, ArtifactField.required controls input binding only and is ignored for outputs.
Terminal and side-effect operators¶
An operator that writes to a file, database, API, message broker, or another external sink can declare no outputs:
class SaveResult(BaseOperator):
def __init__(
self,
*,
result: Artifact,
name: str | None = None,
description: str | None = None,
) -> None:
super().__init__(name=name, description=description)
self.result = ArtifactField(required=True)
self.outputs = ()
def execute(self) -> None:
save_to_disk(self.result.value)
Construct it without an outputs argument:
save = SaveResult(result=result)
For a reusable operator class with a fixed output schema, keep the declared fields and bind any or all positions to None:
operation = OptionalExport(
input_data=data,
outputs=(exported, None),
)
sink = OptionalExport(
input_data=data,
outputs=(None, None),
)
An empty output group is clearest for an intrinsically terminal operator. An all-unbound group is useful when the same operator class is sometimes connected to graph artifacts and sometimes used only for side effects. See Unbound outputs and unavailable values for the declaration-level distinction.
Artifact-field properties describe compatibility at graph-validation time. See Artifact properties for where properties are declared and Artifact-property validation for their complete matching rules.
Declaring configuration fields¶
Configuration is context-scoped. Declare each value as a direct jayrun.ConfigField attribute:
from jayrun import ConfigField
self.factor = ConfigField(
value_type=float,
required=False,
default=1.0,
)
Provide values through jayrun.ConfigContext.set(). During execution, a configured field is injected as jayrun.Data:
def execute(self) -> object:
return self.input_data.value * self.factor.value
An optional field with neither a supplied value nor a default is injected as None. Test the field itself before accessing .value in that case.
See ConfigField for field declaration, ConfigDefinition for graph-local identity, and jayrun.ConfigContext.set() for supplying values.
Configuration must describe the run, not accumulate mutable execution state. Use artifacts for declared data flow and self.context.store() for observational values that later executions or a supervisor must inspect.
Declaring resource fields¶
Declare a runtime-managed dependency with jayrun.ResourceField:
from jayrun import ResourceField
self.service = ResourceField(
required=True,
parallel_safe=True,
)
The graph binds the field to a resource declaration. When the resource is available, Jayrun injects its loaded jayrun.Data into the execution proxy:
def execute(self) -> object:
return self.service.value.process(self.input_data.value)
parallel_safe=True declares that the same loaded resource may be acquired by concurrent executions. Set it to False when access to that resource instance must be serialized.
Only bound resource fields are injected. See ResourceField for the field contract, ResourceDefinition for graph-local identity, and Resources for setup, caching, eviction, teardown, and binding.
Synchronous execution¶
Define a normal method for synchronous work:
def execute(self) -> object:
return self.input_data.value * 2
Jayrun detects the method during graph compilation and dispatches it through the configured thread executor. The operator may call the synchronous operational-interface methods directly.
Synchronous execution is appropriate for blocking libraries and ordinary Python callables. Thread execution does not make application payloads thread-safe; shared objects and parallel-safe resources must provide their own synchronization where necessary.
Asynchronous execution¶
Define execute() with async def for cooperative asynchronous work:
async def execute(self) -> object:
response = await client.fetch(self.input_data.value)
return response
Jayrun dispatches coroutine functions on the runtime event loop. Await application coroutines normally; do not block the event-loop thread with synchronous I/O or long CPU-bound work.
See Denoise Images with FastAPI for one graph that combines an asynchronous HTTP operator with a synchronous NumPy operator.
Operational-interface calls such as self.context.store(), self.execution.repeat(), and self.context.stop() remain synchronous and must not be awaited. See Operational Interfaces for their contracts.
Warning
Returning a coroutine from a synchronous def execute() does not make the operator asynchronous. Declare the method with async def so graph compilation selects event-loop execution.
Output return contract¶
Declare one output field per returned value and preserve the same order:
class Split(BaseOperator):
def __init__(
self,
*,
input_data: Artifact,
outputs: tuple[Artifact | None, ...],
name: str | None = None,
description: str | None = None,
) -> None:
super().__init__(name=name, description=description)
self.input_data = ArtifactField(required=True)
self.outputs = (
ArtifactField(required=True),
ArtifactField(required=True),
)
def execute(self) -> tuple[object, object]:
midpoint = len(self.input_data.value) // 2
left = self.input_data.value[:midpoint]
right = self.input_data.value[midpoint:]
return left, right
The return contract depends on connected outputs:
When no output field is connected,
execute()must returnNone. This covers bothself.outputs = ()and a non-empty group bound entirely toNone.With at least one connected output and one declared output field, return one non-tuple value.
With at least one connected output and several declared output fields, return a tuple with one position per declared field. Values at unbound positions are ignored and should conventionally be
None.Every raw output is wrapped in
jayrun.Datawith CPU placement.Return
Dataexplicitly to preserve non-default placement metadata.
A bound output that returns None publishes an unavailable value for its declared artifact and can disable its downstream route; see Conditional routing. An output field bound to None has no graph artifact and produces no result, validation edge, or route.
Important
A tuple returned by execute() is interpreted as multiple outputs. To return a tuple as the payload of one output, return Data(value=payload_tuple).
Returning a value when no output is connected, or returning the wrong number of positions for a connected multi-output declaration, raises a ValueError inside the execution. It then follows the normal retry and failure policy.
Repeat semantics¶
An operator requests another execution through OperatorExecutionInterface.repeat():
def execute(self) -> object:
result = refine(self.input_data.value)
if not converged(result):
self.execution.repeat()
return result
The request takes effect after execute() returns successfully. Jayrun stores the returned output, refreshes the operator’s bound artifact fields from that output, increments OperatorExecutionInterface.number, and schedules the same step session again. Downstream steps remain blocked until repetition ends.
jayrun.settings.ContextSettings.max_repeats counts additional executions after the initial one. For example, max_repeats=2 permits at most three total executions. None permits unbounded repetition, so the operator must eventually stop requesting it.
When the repeat limit has been reached, a further request is ignored and the latest result becomes the operator’s final output. Context-stored values remain available across repetitions, while each repetition receives its own execution number and retry attempts.
Reserved runtime names¶
The following attributes are reserved for operational interfaces injected only on runtime proxies:
Name |
Capability |
|---|---|
|
Diagnostics, numbering, and repetition for this step session |
|
Context identity, records, and lifecycle requests |
|
Authorized context runs and supervising waits |
|
Accelerator-capacity requests |
Operator subclasses must not declare or assign these names. Names beginning with _runtime_ are also reserved for framework execution state. Declaring a reserved name on a subclass raises TypeError; assigning one to a declaration raises AttributeError.
See Operational Interfaces for availability and capability restrictions.
Operator immutability¶
After construction, operator declarations are immutable. Assigning or deleting an attribute raises AttributeError. Declarative fields and self.outputs also cannot be reassigned during construction after their first assignment.
Immutability protects one declaration from cross-context mutation. Runtime values are attached to a separate execution proxy, not to the reusable operator object.
Reusable operator design¶
A reusable operator should make all execution dependencies explicit:
Artifact fields carry graph data.
Config fields carry context-specific parameters.
Resource fields carry runtime-managed services or state.
Operational interfaces provide scoped records, lifecycle requests, and placement.
Module-level pure functions may contain reusable computation helpers.
requirementsmay declare external package requirements for graph validation.
Do not use constructor attributes as hidden runtime inputs, cache results on self, or depend on one submission having executed before another. The execution proxy exposes declared fields and injected interfaces, not arbitrary declaration state or helper methods attached to the operator instance.
Keep execute() independent of application-owned threads and background tasks. If it creates temporary work, join or await that work before returning so the execution interfaces and acquired resources remain within their invocation lifetime.
Operator failures and retries¶
Exceptions raised by synchronous or asynchronous execute() are captured as execution failures. Jayrun retries a failure only when it matches the effective jayrun.settings.RetryPolicy and the attempt count remains below max_attempts.
The per-context retry policy overrides the engine retry policy when supplied. max_attempts includes the initial attempt. If max_attempts is greater than one and retry_on is empty, Jayrun retries ordinary Exception subclasses. Each repeated execution receives a fresh retry budget.
Retries do not roll back values already stored through self.context, but they discard the failed attempt’s outputs and placement requests. Execution reports retain the separate attempts and their failure records.
Warning
Retries can repeat external side effects. Operators that write to external systems should use idempotent operations, transactional boundaries, or application-level deduplication.
When the retry policy does not match or the attempt limit is exhausted, the context fails and records the operator as the failed step.
This local section is necessary because retries affect the design of execute(). The complete failure categories, context policy, fail-fast behavior, and runtime escalation belong to Failure and Reliability Model.
API reference¶
- class jayrun.BaseOperator(*, name=None, description=None)¶
Abstract base class for immutable operator declarations.
- jayrun.BaseOperator.requirements: tuple[str, ...]¶
Class-level external requirements declared by the operator.
- jayrun.BaseOperator.outputs: tuple[jayrun.ArtifactField, ...]¶
Ordered output-field declarations. Subclasses must assign this tuple in
__init__(); it may be empty.
- jayrun.BaseOperator.execute() object | tuple[object, ...] | None¶
Execute one operator invocation.
Subclasses must implement either a synchronous method or an asynchronous coroutine method. Return
Nonewhen no output is connected; otherwise follow the declared output-field order.
- jayrun.BaseOperator.config_fields: tuple[jayrun.ConfigField, ...]¶
Configuration fields in declaration order.
- jayrun.BaseOperator.resource_fields: tuple[jayrun.ResourceField, ...]¶
Resource fields in declaration order.
- jayrun.BaseOperator.declared_artifact_fields: tuple[jayrun.ArtifactField, ...]¶
Input artifact fields in declaration order, including optional unbound fields.
- jayrun.BaseOperator.bound_artifact_fields: tuple[jayrun.ArtifactField, ...]¶
Input artifact fields bound to artifact declarations.
- jayrun.BaseOperator.input_artifacts: tuple[jayrun.Artifact, ...]¶
Artifacts bound to operator inputs.
- jayrun.BaseOperator.output_artifacts: tuple[jayrun.Artifact, ...]¶
Artifacts bound to active operator outputs.
- jayrun.BaseOperator.display_name: str¶
Explicit operator name, or the subclass name when no name was supplied.
jayrun.ResourceField and its acquisition behavior are documented in Resources.
jayrun.settings.RetryPolicy is documented in Execution Settings.
See Execution Interface for diagnostics and repetition, and Context Interface for stored values and lifecycle requests.
Next, read Graph Construction to combine operator declarations, followed by Graph Validation for artifact-contract diagnostics.