Run each async udf in its own thread#124
Conversation
2f24109 to
53e6c9a
Compare
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>
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>
- 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>
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>
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>
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>
Co-authored-by: Cursor <cursoragent@cursor.com>
53e6c9a to
78a96f8
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort 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 78a96f8. Configure here.
| - uses: actions/checkout@v3 | ||
|
|
||
| - name: Patch version for pre-release tag | ||
| if: github.event_name == 'push' |
There was a problem hiding this comment.
Release rebuild skips version patch
Medium Severity
The new pre-release pyproject.toml rewrite runs only when github.event_name == 'push', but the same workflow also runs on release: published and publishes to PyPI from that run’s freshly built artifacts. A published GitHub Release for a pre-release tag can therefore ship wheels/sdists whose metadata was never converted to a valid PEP 440 version.
Reviewed by Cursor Bugbot for commit 78a96f8. Configure here.
There was a problem hiding this comment.
This isn't an issue. The remapping of wheel names should only happen with pre-release pushes, not the actual releases.


Note
Medium Risk
Changes core UDF dispatch and asyncio/thread lifecycle (including background tasks outliving requests); publish workflow behavior affects release versioning and when PyPI uploads run.
Overview
Async external UDFs no longer run on the ASGI event loop or spin up a fresh loop per call via
asyncio.run(). Every invoke now runs in a thread pool worker with a persistent per-thread event loop, so loop-bound clients (e.g.httpxpools) can be reused and teardown “event loop is closed” errors are avoided.Cancellation is wired through
_cancellable_run(pollscancel_event) and the handler setscancel_eventwhen the worker task is still pending on timeout/disconnect, plus infinally, so cancellation reaches code inside the worker loop—not only the outerasynciotask.Publish workflow: on pre-release tag pushes,
pyproject.tomlis rewritten to PEP 440 versions (-testN→.devN,-alpha/-beta/-rc→a/b/rc). PyPI publish runs onreleaseorworkflow_dispatchwithpublish_pypi, not on tag push alone.Adds
test_udf_event_loop.pycovering loop reuse, cancellation timing, exceptions, context vars, and concurrency.Reviewed by Cursor Bugbot for commit 78a96f8. Bugbot is set up for automated code reviews on this repo. Configure here.