This document captures design decisions for improving the executor integration in the data function / function pod / function node execution chain. The changes address four areas:
with_optionssemantics — executors become immutable;with_options()always returns a new instance.execution_engine_optsownership — removed from FunctionNode; owned exclusively by the pipeline's executor-assignment logic.CachedFunctionPod— a new pod-level caching wrapper complementing the existingCachedDataFunction(data-level caching).- Type-safe executor dispatch via
Generic[E]+__init_subclass__— eliminates redundantisinstancechecks in the hot path by resolving the executor protocol once at class definition time.
DataFunctionExecutorBase.with_options() returns self by default. RayExecutor
overrides it to return a new instance. This is inconsistent — callers cannot rely on
with_options() being side-effect-free without checking the concrete type.
with_options() must always return a new executor instance, even when no options change.
This makes executors effectively immutable value objects after construction — the same
executor can be safely shared across nodes, and with_options() produces a node-specific
variant without mutating the original.
DataFunctionExecutorBase.with_options(): Default implementation returnscopy.copy(self)(shallow clone) instead ofself. Subclasses that carry mutable state (e.g. Ray handles) override to produce a properly configured new instance.DataFunctionExecutorProtocol.with_options(): Docstring updated to specify "returns a new executor instance".LocalExecutor.with_options(): Returns a newLocalExecutor(). Trivial since it carries no state.
FunctionNode.__init__ stores self.execution_engine_opts: dict[str, Any] | None = None.
This field is set externally (by the pipeline) and later read back during executor
assignment. The node becomes an awkward intermediary — it holds configuration that logically
belongs to the pipeline's executor-assignment step.
execution_engine_opts is removed from FunctionNode entirely. The pipeline's
apply_executor (or equivalent) logic is the sole owner: it reads per-node options from
the pipeline config, calls executor.with_options(**merged_opts), and sets the resulting
executor directly on the data function. The node never sees raw option dicts.
FunctionNode.__init__: Removeself.execution_engine_optsattribute.FunctionNode.from_descriptor: Stop reading/writingexecution_engine_optsfrom descriptors. (Backward-compatible break is acceptable per project policy — pre-v0.1.0.)- Pipeline executor assignment (in
pipeline/module): Merge pipeline-level and per-node options, callexecutor.with_options(**merged), then assign the resulting executor tonode.executor = configured_executor.
Caching exists only at the data-function level (CachedDataFunction), which wraps
call() / async_call() with DB lookup/insert. This works but cannot leverage tag
information (which is invisible to data functions).
Add a CachedFunctionPod that wraps a FunctionPod and intercepts at the
process_data(tag, data) level. This complements CachedDataFunction:
| Layer | CachedDataFunction |
CachedFunctionPod |
|---|---|---|
| Intercepts at | call(data) |
process_data(tag, data) |
| Has tag access | No | Yes |
| Cache key includes | Data content hash | Tag + data content hash |
| Delegates to | Wrapped DataFunction.call() |
Inner FunctionPod.process_data() |
Both are useful: CachedDataFunction deduplicates purely on data content;
CachedFunctionPod can incorporate tag metadata into cache decisions.
class CachedFunctionPod(WrappedFunctionPod):
"""Pod-level caching wrapper that intercepts process_data()."""
def __init__(
self,
function_pod: FunctionPodProtocol,
result_database: ArrowDatabaseProtocol,
record_path_prefix: tuple[str, ...] = (),
**kwargs,
) -> None:
super().__init__(function_pod, **kwargs)
self._result_database = result_database
self._record_path_prefix = record_path_prefix
def process_data(
self, tag: TagProtocol, data: DataProtocol
) -> tuple[TagProtocol, DataProtocol | None]:
# Cache key incorporates both tag and data content
cache_key = self._compute_cache_key(tag, data)
cached = self._lookup(cache_key)
if cached is not None:
return tag, cached
tag, output = self._function_pod.process_data(tag, data)
if output is not None:
self._store(cache_key, tag, output)
return tag, output- New file:
src/orcapod/core/cached_function_pod.pycontainingCachedFunctionPod. function_poddecorator: Addpod_cache_databaseparameter that wraps the pod inCachedFunctionPodwhen provided (distinct fromresult_databasewhich wraps the data function inCachedDataFunction).
Currently, each DataFunctionBase subclass that cares about executor-specific capabilities
must do isinstance checks in the hot path (call() / direct_call()). This is both
verbose and error-prone — forgetting to check means silent misuse.
In Rust, an enum Executor { Python(PythonExecutor), Container(ContainerExecutor) } with
match would give exhaustive, zero-cost dispatch. Python has no sum types with exhaustiveness
checking, but we can get close.
Use Generic[E] on DataFunctionBase combined with __init_subclass__ to resolve the
concrete executor protocol once at class definition time. The single isinstance check
moves to set_executor() (assignment boundary), and the hot path (call()) is clean.
from typing import Generic, TypeVar
E = TypeVar("E", bound=DataFunctionExecutorProtocol)
class DataFunctionBase(TraceableBase, Generic[E]):
_resolved_executor_protocol: ClassVar[type] # auto-set by __init_subclass__
_executor: E | None = None
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
for base in cls.__orig_bases__:
origin = typing.get_origin(base)
if origin is DataFunctionBase:
args = typing.get_args(base)
if args and not isinstance(args[0], TypeVar):
cls._resolved_executor_protocol = args[0]
return
def set_executor(self, executor: DataFunctionExecutorProtocol) -> None:
"""Single isinstance check at assignment boundary."""
proto = getattr(type(self), '_resolved_executor_protocol', None)
if proto is not None and not isinstance(executor, proto):
raise TypeError(
f"{type(self).__name__} requires {proto.__name__}, "
f"got {type(executor).__name__}"
)
self._executor = executor # type: ignore[assignment]Subclasses declare the executor type once via the generic parameter:
class PythonDataFunction(DataFunctionBase[PythonExecutorProtocol]):
# No _executor_protocol ClassVar needed — __init_subclass__ extracts it
# from Generic[PythonExecutorProtocol] automatically.
...__orig_bases__is set by Python's type machinery on every class that inherits from aGeneric. It contains the parameterized base (e.g.DataFunctionBase[PythonExecutorProtocol]).typing.get_args()extracts the type parameters.__init_subclass__runs at class definition time (import), not at instance creation. Zero per-instance overhead.- The
isinstance(args[0], TypeVar)guard skips intermediate abstract subclasses that haven't boundEyet (e.g.DataFunctionWrapper(DataFunctionBase[E])).
- Executor protocols used as type parameters must be decorated with
@runtime_checkable(already the case forDataFunctionExecutorProtocol). - Any new executor protocol (e.g.
PythonExecutorProtocol) needs@runtime_checkabletoo.
def call(self, data: DataProtocol) -> DataProtocol | None:
if self._executor is not None:
# self._executor is statically typed as E (e.g. PythonExecutorProtocol).
# No isinstance check needed — validated at set_executor() time.
return self._executor.execute(self, data)
return self.direct_call(data)DataFunctionBase: AddGeneric[E],__init_subclass__resolver,set_executor()method. Existingexecutorproperty setter delegates toset_executor().PythonDataFunction: Change toDataFunctionBase[PythonExecutorProtocol](or a more specificPythonExecutorProtocolif we introduce one).DataFunctionWrapper: Change toDataFunctionBase[E](remains generic, passes through).CachedDataFunction: Inherits fromDataFunctionWrapper— no changes needed since executor delegation already targets the wrapped leaf function.- Executor protocols: Ensure
@runtime_checkableon all protocols that will be used as generic parameters.
FunctionPod.data_function stays as a property on the protocol (as currently implemented).
It is understood to be read-only — callers should not replace the data function after
pod construction. The property exists for introspection and executor wiring, not mutation.
No code changes needed — this is a documentation/convention clarification.
- Update
DataFunctionExecutorBase.with_options()default to return a shallow copy. - Update
LocalExecutor.with_options()to returnLocalExecutor(). - Verify
RayExecutor.with_options()already returns a new instance (it does). - Remove
self.execution_engine_optsfromFunctionNode.__init__. - Remove
execution_engine_optsfromFunctionNode.from_descriptorread-only state. - Update pipeline executor-assignment logic to merge options externally and pass the configured executor in.
- Update affected tests.
- Add
Generic[E]and__init_subclass__toDataFunctionBase. - Update
executorsetter to delegate toset_executor()with__init_subclass__-resolved protocol check. - Parameterize
PythonDataFunctionasDataFunctionBase[DataFunctionExecutorProtocol](or a narrower protocol if we introduce executor-type-specific protocols later). - Parameterize
DataFunctionWrapperasDataFunctionBase[E]. - Ensure all executor protocols are
@runtime_checkable. - Update tests to verify type checking at assignment time.
- Create
src/orcapod/core/cached_function_pod.py. - Implement
CachedFunctionPod(WrappedFunctionPod)with tag-aware cache key computation. - Add
pod_cache_databaseparameter tofunction_poddecorator. - Add tests for pod-level vs data-level caching interaction.
- Update
orcapod-design.mdwith the new execution chain design. - Update
CLAUDE.mdarchitecture section if needed. - Check
DESIGN_ISSUES.mdfor any resolved issues.
- Executor-type-specific protocols: Currently all data functions accept the base
DataFunctionExecutorProtocol. If we later wantPythonDataFunctionto only accept executors with aPythonExecutorProtocol(which might requireexecute(func, args)rather thanexecute(pf, data)), theGeneric[E]mechanism already supports this — just parameterize with the narrower protocol. CachedFunctionPodcache key design: The exact composition of the cache key (which tag columns to include, whether to include system tags) needs detailed design during implementation. A reasonable default is tag content hash + data content hash.