Fix async UDF event loop starvation in Jupyter#123
Conversation
Async UDFs were running directly in uvicorn's event loop via asyncio.create_task, competing with connection handling under heavy concurrent load. This caused unresponsiveness when running from Jupyter notebooks where the event loop is shared. The fix introduces a dedicated event loop in a background thread for async UDF execution. Coroutines are submitted via run_coroutine_threadsafe() and awaited from the server loop, isolating UDF work from HTTP I/O while preserving cooperative async scheduling between UDFs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cancel the concurrent.futures.Future in the UDF loop on disconnect/timeout so the coroutine is interrupted promptly, not just at the next cancel_on_event row check. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ture asyncio.create_task() requires a coroutine but asyncio.wrap_future() returns a Future. Use asyncio.ensure_future() which accepts both. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Newer pandas versions use StringDtype ('str') instead of 'object' for
string columns. Detect the actual dtype at import time and use it in
test assertions. Binary columns remain 'object'.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Prevents UDF event loop thread leaks when run_udf_app() is called repeatedly in Jupyter notebooks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tags matching v*-rc*, v*-test*, v*-alpha*, v*-beta* now trigger the full wheel build pipeline and create a pre-release GitHub Release with all wheels attached. Production releases also attach wheels to the existing release before publishing to PyPI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Cancel udf_future when func_task is in pending set after asyncio.wait - Cancel udf_future in finally block to ensure cleanup on any exit path - Wrap post-construction code in try/except to call app.shutdown() if validation, config, or registration fails after Application is created Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
gh release create doesn't need a git repo when not generating notes. Use --notes "" for empty release body with just assets attached. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Changed from push tag trigger (v*.*.*) to release event so it only runs on published releases, not rc/test/alpha/beta tags. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Move udf_future initialization before input_handler['load']() to prevent NameError in finally block if parsing raises - Lazily create UDF event loop on first async UDF invocation instead of unconditionally in __init__, avoiding wasted resources for sync-only or metadata-only usage - Guard shutdown() against None loop/thread Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
gh release create requires a git repo to determine the repository context even without --generate-notes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
After stopping the event loop and joining the thread, set both _udf_loop and _udf_thread back to None so that _get_udf_loop() can safely recreate them if called after shutdown. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Enable asset generation on test/rc/alpha/beta tag pushes without publishing to PyPI. Also prevents fusion-docs from triggering on pre-release tags. Ref: PR #123 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The dedicated shared event loop still caused starvation under concurrent async UDF calls. Switch to the same model used by sync UDFs: each request gets its own thread with asyncio.run(), eliminating loop contention. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Wraps the inner coroutine in _cancellable_run which polls cancel_event and raises CancelledError at the next await (~100ms), ensuring vector UDFs respect disconnect/timeout signals without waiting for completion. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace asyncio.run() with _run_with_graceful_shutdown() that drains pending callbacks before closing the loop, preventing RuntimeError from httpx/anyio TLS cleanup in async UDFs calling OpenAI/LangChain APIs. Add 17 unit tests covering graceful shutdown, cancellation timing, exception propagation, context variable isolation, and concurrent safety. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default mode and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 2f819f5. Configure here.
| if cancel_check in done: | ||
| task.cancel() | ||
| raise asyncio.CancelledError() | ||
| return task.result() |
There was a problem hiding this comment.
Successful UDF result discarded when cancel races completion
Medium Severity
_cancellable_run unconditionally prioritizes cancellation over a successful result when both tasks complete in the same event loop iteration. asyncio.wait(return_when=FIRST_COMPLETED) can return both task and cancel_check in done if they finish simultaneously. Because if cancel_check in done is checked without first checking whether task also succeeded, a completed UDF result is silently discarded and replaced with CancelledError. Checking task in done first (and returning its result) would preserve the successfully-computed value.
Reviewed by Cursor Bugbot for commit 2f819f5. Configure here.
Parses git tag suffixes (-test, -alpha, -beta, -rc) and patches pyproject.toml with the corresponding PEP 440 version before building wheels. Full releases (no suffix) are unaffected. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>


Summary
asyncio.create_task, competing with HTTP connection handlingrun_coroutine_threadsafe()and awaited from the server loopTest plan
test_ext_func.pytests pass🤖 Generated with Claude Code
Note
Medium Risk
Changes the external-function ASGI invoke path (threading, cancellation, teardown); CI/release behavior is lower risk but affects how packages ship.
Overview
Async external UDFs no longer run on the uvicorn event loop (or via nested
asyncio.runin-thread). Every invoke is scheduled withto_threadand runs inside_run_with_graceful_shutdown, which uses a dedicated event loop per call, drains pending tasks/callbacks before close (avoids “Event loop is closed” from httpx/anyio), and wraps the coroutine in_cancellable_runso athreading.Eventcan cancel work on disconnect/timeout.cancel_eventis set when the function task loses the race or infinally.Release automation:
publish.ymltriggers on pre-release tag pushes, rewritespyproject.tomlversion for test/alpha/beta/rc tags, creates a prerelease GitHub release with built wheels on tag push, uploads assets onrelease: published, and tightens when PyPI publish runs.fusion-docs.ymlruns on release publish and uploads docs withgithub.event.release.tag_name.Tests: new
test_udf_event_loop.pyfor shutdown/cancellation; pandas alltypes expectations use runtime string dtype where applicable.Reviewed by Cursor Bugbot for commit 9787e9b. Bugbot is set up for automated code reviews on this repo. Configure here.