Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
cc4dbba
docs(specs): add ITL-516 async_execute unification design spec
kurodo3[bot] Jul 9, 2026
e7f9e9b
docs(specs): update ITL-516 spec with ephemeral path and _FunctionPod…
kurodo3[bot] Jul 10, 2026
89ba837
feat(function_pod): move async_execute to _FunctionPodBase with obser…
kurodo3[bot] Jul 10, 2026
f31d668
fix(function_pod): add pod_config to FunctionPodProtocol, clean up an…
kurodo3[bot] Jul 10, 2026
f1d048c
refactor(function_node): delegate no-DB async_execute to pod
kurodo3[bot] Jul 10, 2026
c300ae1
fix(test_function_job_node): remove unused InMemoryArrowDatabase import
kurodo3[bot] Jul 10, 2026
4241132
feat(function_node): redesign DB async_execute as 3-stage concurrent …
kurodo3[bot] Jul 10, 2026
fe3a68e
fix(function_node): use ignore_missing=True in record_and_forward dro…
kurodo3[bot] Jul 10, 2026
aacf816
docs(plans): add ITL-516 async_execute unification implementation plan
kurodo3[bot] Jul 10, 2026
c524450
fix(function_node): strip correlation key from _NodeLabelObserver tag…
kurodo3[bot] Jul 10, 2026
8868df6
test(async_execute): remove orphan backpressure test, add dedicated p…
kurodo3[bot] Jul 10, 2026
20a2c99
fix(function_node): clean up input_store and cache filtered/crashed i…
kurodo3[bot] Jul 10, 2026
5c0bd63
Merge branch 'main' into eywalker/itl-516-unify-functionpodasync_exec…
eywalker Jul 10, 2026
29000d3
refactor(function_pod): use direct .pod_config access in WrappedFunct…
kurodo3[bot] Jul 10, 2026
e82a062
fix(function_node): correct comment and test for filtered-item async …
kurodo3[bot] Jul 11, 2026
f1338f9
Merge branch 'main' into eywalker/itl-516-unify-functionpodasync_exec…
eywalker Jul 11, 2026
603443f
test(async_execute): move concurrent_count decrement into finally block
kurodo3[bot] Jul 11, 2026
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
119 changes: 74 additions & 45 deletions src/orcapod/core/function_pod.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
if TYPE_CHECKING:
import polars as pl
import pyarrow as pa
from orcapod.protocols.observability_protocols import ExecutionObserverProtocol
else:
pa = LazyModule("pyarrow")
pl = LazyModule("polars")
Expand Down Expand Up @@ -102,6 +103,11 @@ def executor(self, executor: DataFunctionExecutorProtocol | None) -> None:
"""Set or clear the executor on the underlying data function."""
self._data_function.executor = executor

@property
def pod_config(self) -> PodConfig:
"""Per-pod executor configuration. Defaults to no concurrency limits."""
return PodConfig()

def identity_structure(self) -> Any:
return self.data_function.identity_structure()

Expand Down Expand Up @@ -193,6 +199,69 @@ def handle_input_streams(self, *streams: StreamProtocol) -> StreamProtocol:
return joined_stream
return streams[0]

async def async_execute(
self,
inputs: Sequence[ReadableChannel[tuple[TagProtocol, DataProtocol]]],
output: WritableChannel[tuple[TagProtocol, DataProtocol]],
pipeline_config: PipelineConfig | None = None,
*,
observer: ExecutionObserverProtocol | None = None,
) -> None:
"""Streaming async execution with per-data concurrency control.

Each input (tag, data) is dispatched as an independent async task.
A semaphore limits how many tasks are in-flight concurrently.
Observer hooks fire per item: ``on_data_start`` before processing,
``on_data_end(cached=False)`` on success, ``on_data_crash`` on error.

Args:
inputs: Single-element sequence containing the input channel.
output: Writable channel for output (tag, data) pairs.
pipeline_config: Optional pipeline-level concurrency config.
observer: Optional observer for per-item lifecycle hooks.
"""
from orcapod.pipeline.observer import NoOpObserver

try:
pipeline_config = pipeline_config or PipelineConfig()
max_concurrency = resolve_concurrency(self.pod_config, pipeline_config)
obs = observer if observer is not None else NoOpObserver()
pod_label = self.label

sem = (
asyncio.Semaphore(max_concurrency)
if max_concurrency is not None
else None
)

async def process_one(tag: TagProtocol, data: DataProtocol) -> None:
obs.on_data_start(pod_label, tag, data)
pkt_logger = obs.create_data_logger(tag, data)
try:
out_tag, result_data = await self.async_process_data(
tag, data, logger=pkt_logger
)
except Exception as exc:
logger.debug(
"Data processing failed, skipping: %s", exc, exc_info=True
)
obs.on_data_crash(pod_label, tag, data, exc)
else:
obs.on_data_end(pod_label, tag, data, result_data, cached=False)
if result_data is not None:
await output.send((out_tag, result_data))
finally:
if sem is not None:
sem.release()

async with asyncio.TaskGroup() as tg:
async for tag, data in inputs[0]:
if sem is not None:
await sem.acquire()
tg.create_task(process_one(tag, data))
finally:
await output.close()

@abstractmethod
def process(
self, *streams: StreamProtocol, label: str | None = None
Expand Down Expand Up @@ -337,51 +406,6 @@ def from_config(

return cls(data_function=data_function, pod_config=pod_config)

# ------------------------------------------------------------------
# Async channel execution (streaming mode)
# ------------------------------------------------------------------

async def async_execute(
self,
inputs: Sequence[ReadableChannel[tuple[TagProtocol, DataProtocol]]],
output: WritableChannel[tuple[TagProtocol, DataProtocol]],
pipeline_config: PipelineConfig | None = None,
) -> None:
"""Streaming async execution with per-data concurrency control.

Each input (tag, data) is processed independently. A semaphore
controls how many data are in-flight concurrently.
"""
try:
pipeline_config = pipeline_config or PipelineConfig()
max_concurrency = resolve_concurrency(self._pod_config, pipeline_config)

sem = (
asyncio.Semaphore(max_concurrency)
if max_concurrency is not None
else None
)

async def process_one(tag: TagProtocol, data: DataProtocol) -> None:
try:
tag, result_data = await self.async_process_data(tag, data)
if result_data is not None:
await output.send((tag, result_data))
except Exception as e:
# Swallow data-level errors so remaining data continue.
logger.debug("Data processing failed, skipping: %s", e, exc_info=True)
finally:
if sem is not None:
sem.release()

async with asyncio.TaskGroup() as tg:
async for tag, data in inputs[0]:
if sem is not None:
await sem.acquire()
tg.create_task(process_one(tag, data))
finally:
await output.close()


class FunctionPodStream(StreamBase):
"""Recomputable stream wrapping a data function."""
Expand Down Expand Up @@ -769,6 +793,11 @@ def __init__(
def computed_label(self) -> str | None:
return self._function_pod.label

@property
def pod_config(self) -> PodConfig:
"""Delegate to the inner pod's config so CachedFunctionPod respects limits."""
return self._function_pod.pod_config

@property
def uri(self) -> tuple[str, ...]:
return self._function_pod.uri
Expand Down
Loading
Loading