Repository navigation
Add async aget_conn and acreate_agent to PydanticAIHook - #74150
Merged
Merged
Conversation
1 task done
dabla
force-pushed
the
feature/pydantic-ai-hook-async-conn
branch
from
October 3, 2026 13:19
be87667 to
8dfbaf0
Compare
dabla
marked this pull request as ready for review
October 3, 2026 13:38
dabla
requested review from
Lee-W,
ashb,
gopidesupavan and
kaxil
as code owners
October 3, 2026 13:38
This was referenced Oct 3, 2026
dabla
force-pushed
the
feature/pydantic-ai-hook-async-conn
branch
from
October 14, 2026 21:42
f2e5781 to
274a7cd
Compare
kaxil
reviewed
Oct 16, 2026
get_conn() resolves the hook's connection and every fallback connection with get_connection() and reads extra_dejson, whose secret masking sends to the supervisor synchronously; inside an async task with another async SDK call in flight both raise DeadlockImminentError, although the agent the hook builds is async-native. aget_conn() fetches the connection and its fallbacks with get_async_connection() (extra from Connection.aextra_dejson(), apache#71890, Airflow 3.3.2+, or the raw extra via json.loads on older versions as in apache#72130), seeds the existing connection cache and builds the model with the unchanged get_conn(); acreate_agent() does the same before create_agent(), spec_file included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the local aextra_dejson fallback with the common.compat helper added in apache#74147, which masks the extra's secrets asynchronously on Airflow 3.3.2+ and reads extra_dejson from a worker thread on older versions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Prefetching the connections only covered the base hook's own lookups: a subclass's _get_provider_kwargs (Snowflake's builds a SnowflakeHook) still made synchronous Task SDK calls on the event loop, and on Airflow 3.1 to 3.3.1 get_async_connection itself reads extra_dejson synchronously. aget_conn() and acreate_agent() now run the unchanged get_conn() / create_agent() in asyncio.to_thread: from any thread other than the loop's, a synchronous send() waits for the in-flight asend() to release the comms lock instead of raising DeadlockImminentError. This drops the prefetch, the prefetched fallback cache and the extra parameter of _seed_connection. aget_hook() is the async counterpart of get_hook(), with the same connection type check, so an async operator can dispatch to the pydanticai* subclasses. get_hook() now caches the connection it fetched on the hook it returns, so the hook's first get_conn() does not fetch it again. The tests refuse the synchronous lookups only on the event loop, drive a subclass that reads another connection as primary and as fallback, and check that every argument of acreate_agent reaches the agent. The hook docs gain a section on using it from an async task, and uv.lock is back to main's.
dabla
force-pushed
the
feature/pydantic-ai-hook-async-conn
branch
from
October 16, 2026 09:02
274a7cd to
158be99
Compare
kaxil
reviewed
Oct 22, 2026
_get_conn_and_extra set _conn before reading extra_dejson, whose secret masking is a supervisor round trip. A second acreate_agent gathered on the same hook found _conn set and read the empty default extra, so it raised "No model specified" or dropped the fallback chain; a failed extra read left the hook half-seeded the same way. Both are now published once both are in hand. get_hook's type check already guarantees a PydanticAIHook, so the isinstance guard before seeding is gone, and _seed_connection's docstring describes its contract instead of one caller.
kaxil
reviewed
Oct 23, 2026
asyncio.to_thread shared the default executor with asend(), which holds the comms lock while a pool thread reads its reply. Enough gathered hook calls filled the pool: every thread blocked in send() on the lock and nothing was left to run the read, so the task hung. The three async methods now run on a single-worker executor of the hook module, with the caller's context variables copied because task logging binds them. One worker also serializes the hook's sends on Airflow 3.0 and 3.1, where the channel to the supervisor has no lock of its own. The docs section states Airflow 3.2.1 and later (3.2.0 reads the reply outside the lock) and that the loop blocks before 3.3.1, where DeadlockImminentError arrived.
kaxil
reviewed
Oct 27, 2026
kaxil
approved these changes
Oct 27, 2026
kaxil
left a comment
Member
There was a problem hiding this comment.
Thanks for sticking with this through four rounds, LGTM. Two optional test notes inline. It needs a rebase on main for the one-line infer_provider import conflict.
The context copy in _run_off_loop had no test: a plain functools.partial kept every async test green, while it is what carries the task instance fields the task runner binds for logging into the worker thread. A test now sets a context variable before each of the three async methods and checks the worker sees it. With one worker, the two gathered acreate_agent calls ran one after the other, so that test no longer exercised the publish order of _get_conn_and_extra: the old order kept it green. Two threads calling get_conn() on one hook replace it, and the note in _get_conn_and_extra names that case instead of gathered calls.
Contributor
Author
Thanks Kaxil for the numerous reviews, really appreciate the time and effort you put into it. |
dabla
added a commit
that referenced
this pull request
Oct 28, 2026
dabla
added a commit
that referenced
this pull request
Oct 28, 2026
This was referenced Oct 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PydanticAIHook.get_conn()resolves its connection (and every connection of afallback_conn_idschain) withget_connection()and readsextra_dejson, whose secret masking sendsMaskSecretto the supervisor synchronously, and a subclass's_get_provider_kwargsmay read more (Snowflake's builds aSnowflakeHook). On the event loop of an async task with another async SDK call in flight, each of those raisesDeadlockImminentError, although the pydantic-ai agent the hook builds is itself async-native (await agent.run(...)).This adds the async counterparts without a second resolution path:
aget_conn()andacreate_agent()run the unchangedget_conn()/create_agent()on a single-worker executor of the hook module, never on asyncio's default executor:asend()reads its reply on that executor while it holds the comms lock, so lookups filling it would hang the task. From any thread other than the loop's, a synchronoussend()waits for the in-flightasend()to release the comms lock instead of raising, so fallback chains,spec_file, model-id qualification and subclass overrides behave exactly as in the sync path; one worker also serializes the hook's sends on 3.0/3.1.aget_hook()is the async counterpart ofget_hook(), with the same connection type check, so an async operator can dispatch to thepydanticai*subclasses.get_hook()now caches the connection it fetched on the hook it returns, so the hook's firstget_conn()doesn't fetch it a second time (sync operators included).get_conn()never reads a half-seeded cache (it would otherwise find the connection next to the empty default extra while the first is still masking it), and a failed extra read leaves nothing behind.await hook.acreate_agent(...)/await agent.run(...), for Airflow 3.2.1 and later (3.2.0 reads a reply outside the comms lock;DeadlockImminentErrorexists from 3.3.1, before that the loop blocks).This is also the first building block for running
@task.agent/AgentOperatornatively async.Tests: the sync lookups fail when they run on the event loop. The new tests resolve a model, a fallback chain, a subclass whose provider kwargs read another connection (as primary and as fallback),
acreate_agentwith every argument forwarded (with and withoutspec_file), a secondacreate_agentthat fetches nothing again,get_conn()from two threads on one hook, a retry after a failed extra read, each method completing while the default executor is full, each method carrying the caller's context variables into its worker, two hooks never sending at the same time, andaget_hookwith its type check. Existing tests pass unchanged.Was generative AI tooling used to co-author this PR?
Generated-by: Claude Opus 5.5 following the guidelines