Skip to content

Add async aget_conn and acreate_agent to PydanticAIHook - #74150

Merged
dabla merged 9 commits into
apache:mainfrom
dabla:feature/pydantic-ai-hook-async-conn
Oct 28, 2026
Merged

dabla merged 9 commits into
apache:mainfrom
dabla:feature/pydantic-ai-hook-async-conn

Conversation

@dabla

@dabla dabla commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

PydanticAIHook.get_conn() resolves its connection (and every connection of a fallback_conn_ids chain) with get_connection() and reads extra_dejson, whose secret masking sends MaskSecret to the supervisor synchronously, and a subclass's _get_provider_kwargs may read more (Snowflake's builds a SnowflakeHook). On the event loop of an async task with another async SDK call in flight, each of those raises DeadlockImminentError, 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() and acreate_agent() run the unchanged get_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 synchronous send() waits for the in-flight asend() 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 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() doesn't fetch it a second time (sync operators included).
  • The hook publishes its connection and the connection's extra together, so a second thread calling 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.
  • A docs section shows await hook.acreate_agent(...) / await agent.run(...), for Airflow 3.2.1 and later (3.2.0 reads a reply outside the comms lock; DeadlockImminentError exists from 3.3.1, before that the loop blocks).

This is also the first building block for running @task.agent / AgentOperator natively 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_agent with every argument forwarded (with and without spec_file), a second acreate_agent that 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, and aget_hook with its type check. Existing tests pass unchanged.


Was generative AI tooling used to co-author this PR?
  • Yes — Claude Opus 5.5

Generated-by: Claude Opus 5.5 following the guidelines

Comment thread uv.lock Resolved
dabla and others added 3 commits October 16, 2026 08:34
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
dabla force-pushed the feature/pydantic-ai-hook-async-conn branch from 274a7cd to 158be99 Compare October 16, 2026 09:02
@dabla
dabla requested a review from kaxil October 16, 2026 09:29
_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.
dabla added 2 commits October 24, 2026 21:37
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 kaxil left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.
@dabla

dabla commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

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.

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
…into dabla/agent-async/01-task-agent-aexecute
dabla added a commit that referenced this pull request Oct 28, 2026
…into dabla/agent-async/01-task-agent-aexecute
@dabla
dabla merged commit 49bdd34 into apache:main Oct 28, 2026
76 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants