feat(function_pod): unify async_execute() concurrency paths (ITL-516)#224
Conversation
Closes ITL-516 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…Base promotion Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…ver support Promotes async_execute() from FunctionPod to _FunctionPodBase so all pod types (FunctionPod, CachedFunctionPod) inherit the dispatch loop. Adds observer hooks (on_data_start, on_data_end, on_data_crash) per item. Adds pod_config property to _FunctionPodBase (default PodConfig()) and WrappedFunctionPod (delegates to inner pod) so concurrency limits are honoured through CachedFunctionPod. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…notation quotes Adds pod_config property to FunctionPodProtocol so WrappedFunctionPod can use self._function_pod.pod_config directly without getattr fallback. Removes redundant explicit string quotes from async_execute signature (file has __future__ annotations).
FunctionJobNode.async_execute() no longer reimplements the semaphore +TaskGroup dispatch loop for the no-DB path. Delegates directly to self._function_pod.async_execute(), matching the OperatorNode pattern. Deletes _async_execute_one_data (dead after delegation); its logic is inlined into the DB path's _process_one_db() closure so the DB path retains concurrency-controlled dispatch until Task 3 redesigns it. Semaphore setup moved from the shared preamble into the DB branch only. PipelineConfig, PodConfig, and resolve_concurrency imports are retained because they are still used in the DB branch. Updates test_function_node_async_uses_async_process_data_internal → test_function_node_async_delegates_to_pod to reflect that the no-DB path now routes through pod.async_process_data, not node._async_process_data_internal. Adds tests/test_core/nodes/test_function_job_node_async_execute.py with TestFunctionJobNodeAsyncExecuteNoDB (3 tests). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…pipeline FunctionJobNode.async_execute() now delegates computation to execution_pod.async_execute() (CachedFunctionPod inheriting the loop from _FunctionPodBase) via bounded compute_channel/result_channel. Three asyncio.TaskGroup tasks run concurrently: 1. route_inputs: cache hits -> output, misses -> compute_channel (stamped) 2. execution_pod.async_execute: compute_channel -> result_channel 3. record_and_forward: add_pipeline_record, strip key, emit to output Both persistent and ephemeral DB sub-paths use the same pipeline. Adds _TAG_NODE_INPUT_REF module constant (private, never in public API). Removes dead imports: PipelineConfig, PodConfig, resolve_concurrency. Also fixes _FunctionPodBase.async_execute() to call create_data_logger() and pass the returned logger to async_process_data(), restoring logging support for the DB execution path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…p_meta_columns The invariant that _NodeLabelObserver only receives stamped tags holds today, but drop_meta_columns in record_and_forward should be defensive against future call-site changes that could violate that invariant. Passing ignore_missing=True matches the create_data_logger pattern in the same class and avoids a hard crash if the key is ever absent. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
… forwarding on_data_start/on_data_end/on_data_crash were forwarding the stamped tag (containing _tag_node_input_ref) to external observers for cache-miss items. Apply drop_meta_columns(ignore_missing=True) before forwarding so observers never see internal correlation key metadata. Adds missing protocol method delegation. Adds regression test for observer callback key absence.
…od-level coverage
Removes TestAsyncExecuteBackpressure from test_regression_fixes.py
(tested an orphan FunctionPod method never called by the orchestrator).
Equivalent coverage lives in TestFunctionJobNodeAsyncExecuteNoDB and
TestFunctionPodBaseAsyncExecuteBackpressure. Moves TestFunctionPodBase-
AsyncExecuteObserver to dedicated file. Creates:
tests/test_core/function_pod/test_function_pod_async_execute.py:
observer hooks and backpressure for _FunctionPodBase.async_execute()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR refactors async execution so there is a single authoritative concurrency/backpressure loop for function pods, and FunctionJobNode.async_execute() becomes a DB-aware orchestrator that routes cache hits/misses through a bounded 3-stage pipeline.
Changes:
- Moved
async_execute()to_FunctionPodBaseand added per-item observer hooks (on_data_start,on_data_end(cached=...),on_data_crash) so all pod types share one dispatch loop. - Reworked
FunctionJobNode.async_execute()into a concurrent route → compute → record pipeline with bounded intermediate channels and correlation-key stamping/stripping. - Updated and added tests to cover no-DB delegation, DB cache hit/miss behavior, observer semantics, correlation-key stripping, and backpressure.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/orcapod/core/function_pod.py |
Centralizes async dispatch logic in _FunctionPodBase.async_execute() and introduces pod_config plumbing + observer hooks. |
src/orcapod/core/nodes/function_node.py |
Replaces duplicated async dispatch with delegation (no-DB) and a DB-aware 3-stage async pipeline with correlation stamping and stripping. |
src/orcapod/protocols/core_protocols/function_pod.py |
Extends the protocol with a pod_config property for consistent concurrency configuration. |
tests/test_core/test_regression_fixes.py |
Removes the now-orphaned backpressure regression test and renumbers the regression list. |
tests/test_core/nodes/test_function_job_node_async_execute.py |
New comprehensive tests for FunctionJobNode.async_execute() across no-DB, DB, cache hit/miss, observer, and ephemeral modes. |
tests/test_core/function_pod/test_function_pod_async_execute.py |
New tests for _FunctionPodBase.async_execute() observer hooks and backpressure/concurrency limits. |
tests/test_channels/test_node_async_execute.py |
Updates an assertion test to verify no-DB delegation to the pod’s async processing path. |
superpowers/specs/2026-07-09-itl-516-unify-async-execute-design.md |
Design spec documenting the unified async execution model and invariants. |
superpowers/plans/2026-07-10-itl-516-unify-async-execute.md |
Implementation plan for the refactor and associated tests/cleanup. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def on_data_crash( | ||
| self, lbl: str, tag: Any, data: Any, error: Any | ||
| ) -> None: | ||
| clean = tag.drop_meta_columns( | ||
| _TAG_NODE_INPUT_REF, ignore_missing=True | ||
| ) | ||
| ctx_obs.on_data_crash(node_label, clean, data, error) |
There was a problem hiding this comment.
Fixed in commit 20a2c99.
_NodeLabelObserver.on_data_crash now pops the correlation key from input_store after forwarding the event to the external observer:
correlation_key = tag.get_meta_value(_TAG_NODE_INPUT_REF, None)
if correlation_key is not None:
input_store.pop(correlation_key, None)A regression test test_crashed_items_cleaned_from_input_store confirms that a node with intermittent crashes completes correctly and produces the right results on a second run (where non-crashed items are cache hits).
| def on_data_end( | ||
| self, lbl: str, tag: Any, inp: Any, out: Any, *, cached: bool | ||
| ) -> None: | ||
| clean = tag.drop_meta_columns( | ||
| _TAG_NODE_INPUT_REF, ignore_missing=True | ||
| ) | ||
| ctx_obs.on_data_end(node_label, clean, inp, out, cached=cached) |
There was a problem hiding this comment.
Fixed in commit 20a2c99. Two issues addressed:
input_store accumulation: _NodeLabelObserver.on_data_end now pops the correlation key from input_store when out is None.
Missing _cached_output_datas update: The old _async_process_data_internal always wrote self._cached_output_datas[base_entry_id] = (tag_out, output_data) even for filtered items, so the sync iter_data() path saw the cached None result. The new pipeline path never called record_and_forward() for filtered items, so this update was lost. Now on_data_end does it directly when out is None:
if out is None:
correlation_key = tag.get_meta_value(_TAG_NODE_INPUT_REF, None)
if correlation_key is not None:
original = input_store.pop(correlation_key, None)
if original is not None:
orig_tag, orig_data = original
base_entry_id = fn_node.compute_base_entry_id(orig_tag, orig_data)
fn_node._cached_output_datas[base_entry_id] = (clean, None)
fn_node._cached_output_table = None
fn_node._cached_content_hash_column = NoneNote: filtered items are still not written to the pipeline DB (consistent with the old code — add_pipeline_record was also not called for None outputs in _async_process_data_internal). The async route_inputs() path therefore still recomputes filtered items on subsequent async_execute calls, same as before.
A regression test test_filtered_items_update_in_memory_cache verifies both the zero-output behaviour and the _cached_output_datas state.
…tems in DB pipeline Two issues in _NodeLabelObserver (PR #224 Copilot review): 1. Filtered items (output_data=None): when the pod filters an item, it calls on_data_end(out=None) but does not emit to result_channel, so record_and_forward() never sees the item and input_store[correlation_key] was never removed. Also, _cached_output_datas was not updated, breaking the old _async_process_data_internal behaviour of always storing (tag, None) for filtered inputs so iter_data() returns the cached None on the next call. Fix: on_data_end now pops the input_store entry and updates _cached_output_datas when out is None. 2. Crashed items: on_data_crash() never removed the input_store entry, leaving stale entries for the lifetime of the async_execute() call. Fix: on_data_crash() now pops the correlation key from input_store. Adds fn_node = self alias so _NodeLabelObserver methods can reference FunctionJobNode state without shadowing the inner class's self. Adds two regression tests: - test_filtered_items_update_in_memory_cache - test_crashed_items_cleaned_from_input_store Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Review response — commit 20a2c99Addressed both Copilot comments from the automated review: Comment 1: Stale
|
| @property | ||
| def pod_config(self) -> PodConfig: | ||
| """Delegate to the inner pod's config so CachedFunctionPod respects limits.""" | ||
| return getattr(self._function_pod, "pod_config", PodConfig()) |
There was a problem hiding this comment.
why is this necessary? If it's not clear function pod would contain pod_config, this should be FunctionPodProtocol should be amended with pod_config
There was a problem hiding this comment.
Fixed in commit 29000d3. FunctionPodProtocol already has pod_config (added in this PR), so the getattr fallback was unnecessary. Changed to a direct call:
@property
def pod_config(self) -> PodConfig:
return self._function_pod.pod_config| call_count = 0 | ||
| original_call = node._function_pod.data_function.call | ||
|
|
||
| def counted_call(data, **kw): | ||
| nonlocal call_count | ||
| call_count += 1 | ||
| return original_call(data, **kw) | ||
|
|
||
| node._function_pod.data_function.call = counted_call # type: ignore[method-assign] | ||
|
|
There was a problem hiding this comment.
Fixed in commit e82a062. The test now patches async_call instead of call, so any recomputation via the async path would increment call_count and fail the assertion:
original_async_call = node._function_pod.data_function.async_call
async def counted_async_call(data, **kw):
nonlocal call_count
call_count += 1
return await original_async_call(data, **kw)
node._function_pod.data_function.async_call = counted_async_call| # The function filtered this item (returned None). | ||
| # The pod does not emit to result_channel, so | ||
| # record_and_forward() never sees it. Pop the | ||
| # input_store entry and update the in-memory cache | ||
| # so this input is not recomputed on the next call | ||
| # (restoring the old _async_process_data_internal | ||
| # behaviour of always storing even None results). |
There was a problem hiding this comment.
Fixed in commit e82a062. The comment now correctly describes the scope of the update:
Update the in-memory cache so
iter_data()(the sync path) sees a cached None result — restoring the old_async_process_data_internalbehaviour of always writing(tag, None)to_cached_output_datas. Note:route_inputs()checkscached_by_base_entry_id(pipeline DB) and filtered items are never written to the pipeline DB, so subsequentasync_execute()calls still re-send filtered inputs to the pod.
…ionPod The getattr fallback was added when FunctionPodProtocol didn't yet include pod_config. Now that the property is part of the protocol, self._function_pod is guaranteed to have it and the defensive getattr is unnecessary. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Review response — commit 29000d3
|
…behavior Two follow-up fixes from second Copilot review: 1. Patch async_call instead of call in test_cache_hits_emitted_without_recomputation. async_execute() dispatches via data_function.async_call(), so patching .call() would miss any recomputation that went through the async path. 2. Correct the comment in _NodeLabelObserver.on_data_end for the out-is-None branch. The in-memory cache update only benefits the sync iter_data() path. The async route_inputs() checks cached_by_base_entry_id (pipeline DB), which never contains filtered items, so subsequent async_execute() calls still re-send them to the pod. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Review response — commit e82a062Addressed both Copilot comments from the second automated review: Comment 1: Test patches
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
src/orcapod/protocols/core_protocols/function_pod.py:34
FunctionJobNode.async_execute()now callsself._function_pod.async_execute(...), butFunctionPodProtocoldoes not declare anasync_executemethod. This makes the new delegation path invisible to static type-checking and weakens the pod/node contract. Consider addingasync_executetoFunctionPodProtocol(even if typed asAny/forward refs) so the protocol matches actual usage.
@property
def pod_config(self) -> PodConfig:
"""Per-pod executor configuration."""
...
def process_data(
self, tag: TagProtocol, data: DataProtocol
) -> tuple[TagProtocol, DataProtocol | None]: ...
async def async_process_data(
self, tag: TagProtocol, data: DataProtocol
) -> tuple[TagProtocol, DataProtocol | None]: ...
| async def patched_async_call(data, **kwargs): | ||
| nonlocal concurrent_count, max_observed | ||
| concurrent_count += 1 | ||
| max_observed = max(max_observed, concurrent_count) | ||
| await asyncio.sleep(0.01) | ||
| concurrent_count -= 1 | ||
| return await original_async_call(data, **kwargs) | ||
|
|
There was a problem hiding this comment.
Fixed in commit 603443f — same fix as the pod-level test: decrement moved into a finally block so the counter spans the full coroutine duration including the underlying original_async_call.
| async def patched(data, **kw): | ||
| nonlocal concurrent_count, max_observed | ||
| concurrent_count += 1 | ||
| max_observed = max(max_observed, concurrent_count) | ||
| await asyncio.sleep(0.01) | ||
| concurrent_count -= 1 | ||
| return await original_async_call(data, **kw) | ||
|
|
There was a problem hiding this comment.
Fixed in commit 603443f. The decrement is now in a finally block wrapping both the sleep and the underlying call, so concurrent_count stays elevated for the full task duration:
async def patched(data, **kw):
nonlocal concurrent_count, max_observed
concurrent_count += 1
max_observed = max(max_observed, concurrent_count)
try:
await asyncio.sleep(0.01)
return await original_async_call(data, **kw)
finally:
concurrent_count -= 1Both concurrency tests decremented concurrent_count before awaiting original_async_call, so any overlap that occurred during the underlying call was not counted. Moving the decrement to a finally block ensures the counter spans the full duration of the patched coroutine and the assertion is reliable even when the underlying call itself yields. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Review response — commit 603443fConcurrent-count decrement before await (both test files)Both concurrency tests previously decremented
|
Summary
async_execute()fromFunctionPodto_FunctionPodBaseso all pod types (includingCachedFunctionPod) inherit a single authoritative dispatch loop with observer hooks (on_data_start,on_data_end(cached=),on_data_crash)FunctionJobNode.async_execute()DB path as a 3-stage concurrent pipeline (route_inputs→execution_pod.async_execute()→record_and_forward) using bounded intermediate channels for backpressure; no-DB path now delegates entirely toself._function_pod.async_execute()_async_execute_one_datamethod and duplicate semaphore+TaskGroup loop; adds defensiveignore_missing=Trueon alldrop_meta_columns(_TAG_NODE_INPUT_REF)calls to guard against future call-site changesTest plan
tests/test_core/nodes/test_function_job_node_async_execute.py— 11 new tests covering no-DB delegation, DB cache hits/misses, correlation key absence from output tags, pipeline record writes, observercachedflag, ephemeral path, and semaphore-not-leaked on crashtests/test_core/function_pod/test_function_pod_async_execute.py— observer hooks and backpressure via_FunctionPodBase.async_execute()Linear
Fixes ITL-516
ITL-512