Skip to content

feat(function_pod): unify async_execute() concurrency paths (ITL-516)#224

Merged
eywalker merged 17 commits into
mainfrom
eywalker/itl-516-unify-functionpodasync_execute-and
Jul 11, 2026
Merged

feat(function_pod): unify async_execute() concurrency paths (ITL-516)#224
eywalker merged 17 commits into
mainfrom
eywalker/itl-516-unify-functionpodasync_execute-and

Conversation

@kurodo3

@kurodo3 kurodo3 Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Moves async_execute() from FunctionPod to _FunctionPodBase so all pod types (including CachedFunctionPod) inherit a single authoritative dispatch loop with observer hooks (on_data_start, on_data_end(cached=), on_data_crash)
  • Redesigns FunctionJobNode.async_execute() DB path as a 3-stage concurrent pipeline (route_inputsexecution_pod.async_execute()record_and_forward) using bounded intermediate channels for backpressure; no-DB path now delegates entirely to self._function_pod.async_execute()
  • Removes the orphan _async_execute_one_data method and duplicate semaphore+TaskGroup loop; adds defensive ignore_missing=True on all drop_meta_columns(_TAG_NODE_INPUT_REF) calls to guard against future call-site changes

Test 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, observer cached flag, ephemeral path, and semaphore-not-leaked on crash
  • tests/test_core/function_pod/test_function_pod_async_execute.py — observer hooks and backpressure via _FunctionPodBase.async_execute()
  • Full test suite: 4288 passed, 62 skipped, 6 xfailed (zero failures)

Linear

Fixes ITL-516
ITL-512

kurodo3 Bot and others added 9 commits July 9, 2026 19:24
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

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.59459% with 6 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/orcapod/core/nodes/function_node.py 93.58% 5 Missing ⚠️
src/orcapod/core/function_pod.py 96.96% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

kurodo3 Bot and others added 2 commits July 10, 2026 15:00
… 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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 _FunctionPodBase and 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.

Comment on lines +2211 to +2217
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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment on lines +2203 to +2209
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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 = None

Note: 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>
@kurodo3

kurodo3 Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Review response — commit 20a2c99

Addressed both Copilot comments from the automated review:

Comment 1: Stale input_store entries on computation crash

_NodeLabelObserver.on_data_crash now pops the correlation key from input_store after forwarding to the external observer. This prevents the dict from accumulating entries for crashed items over a long-running call.

New regression test: test_crashed_items_cleaned_from_input_store

Comment 2: Filtered items leave dangling input_store entries and skip _cached_output_datas update

_NodeLabelObserver.on_data_end now handles the out is None case by:

  1. Popping the correlation key from input_store
  2. Writing (clean_tag, None) into _cached_output_datas — restoring the behaviour of the old _async_process_data_internal which always stored the result even when None

One nuance: filtered items are intentionally not written to the pipeline DB (no add_pipeline_record call), matching the old code's behaviour. The async route_inputs() path therefore still recomputes filtered inputs on subsequent calls (they aren't in cached_by_base_entry_id which comes from the pipeline DB). The _cached_output_datas update is a correctness restoration for the sync iter_data() path.

New regression test: test_filtered_items_update_in_memory_cache

Both new tests are in tests/test_core/nodes/test_function_job_node_async_execute.py. Full suite: 2724 passed, 0 failures.

Comment thread src/orcapod/core/function_pod.py Outdated
@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())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Comment on lines +243 to +252
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]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/orcapod/core/nodes/function_node.py Outdated
Comment on lines +2215 to +2221
# 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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_internal behaviour of always writing (tag, None) to _cached_output_datas. Note: route_inputs() checks cached_by_base_entry_id (pipeline DB) and filtered items are never written to the pipeline DB, so subsequent async_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>
@kurodo3

kurodo3 Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Review response — commit 29000d3

WrappedFunctionPod.pod_config — removed getattr fallback

The defensive getattr(self._function_pod, "pod_config", PodConfig()) was left over from before pod_config was added to FunctionPodProtocol. Since the protocol now declares the property, self._function_pod.pod_config is a direct, type-safe call with no need for a fallback.

…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>
@kurodo3

kurodo3 Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

Review response — commit e82a062

Addressed both Copilot comments from the second automated review:

Comment 1: Test patches .call but async path uses .async_call

test_cache_hits_emitted_without_recomputation previously patched data_function.call. Since async_execute() dispatches through data_function.async_call(), any spurious recomputation via the async path would have gone undetected. Fixed by patching async_call instead.

Comment 2: Comment overstated the effect of the _cached_output_datas update

The comment in _NodeLabelObserver.on_data_end claimed the update prevented recomputation on the next call. That's only true for the sync iter_data() path. The async route_inputs() checks cached_by_base_entry_id (populated from the pipeline DB), and filtered items are never written to the pipeline DB, so the async path still re-sends them to the pod on subsequent calls. Comment updated to accurately describe both behaviors.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 calls self._function_pod.async_execute(...), but FunctionPodProtocol does not declare an async_execute method. This makes the new delegation path invisible to static type-checking and weakens the pod/node contract. Consider adding async_execute to FunctionPodProtocol (even if typed as Any/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]: ...

Comment on lines +114 to +121
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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +142 to +149
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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 -= 1

Both 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>
@kurodo3

kurodo3 Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

Review response — commit 603443f

Concurrent-count decrement before await (both test files)

Both concurrency tests previously decremented concurrent_count before await original_async_call(...), so any overlap that occurred during the underlying async call was invisible to the measurement. Fixed in both files by wrapping the sleep + underlying call in a try/finally so the counter stays elevated for the full task duration:

  • tests/test_core/function_pod/test_function_pod_async_execute.pyTestFunctionPodBaseAsyncExecuteBackpressure
  • tests/test_core/nodes/test_function_job_node_async_execute.pyTestFunctionJobNodeAsyncExecuteNoDB::test_max_concurrency_limits_tasks

@eywalker eywalker merged commit ffd1177 into main Jul 11, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants