Add a deferrable batch execution mode to common.ai - #72938
Conversation
b26d90c to
abb4834
Compare
abb4834 to
a75c911
Compare
Provider batch APIs run at roughly half price with a 24h SLA, but a pipeline built on @task.llm had no way to use them. Switching meant the author owned JSONL construction, upload, chunking to provider limits, polling, and result retrieval by hand. pydantic-ai exposes no batch abstraction, so there is no Agent path for batch work. Batch is submit/poll/fetch spanning hours rather than request/response in a loop, which means the agent loop, tool execution, HITL approval, and message_history have no meaning there. A dedicated deferrable operator and decorator therefore fit better than a mode flag on @task.llm. Results land in object storage as JSONL with only a small manifest in XCom, because a batch can hold 100k items. Submission state is written before the paid call and keyed on the run so that a retry reattaches to the in-flight batch instead of buying a second one; XCom cannot hold that state because the server clears a task instance's XCom at the start of every attempt.
Cancelled batches no longer re-attach as a success: whatever finished is landed, the task raises LLMBatchCancelledError, and the state is cleared so the next attempt submits fresh. A provider-side "failed" batch clears its state too, so retries resubmit instead of re-attaching to a dead batch forever. State is kept after a successful landing so a clear re-lands the same results for free. A failed orphan lookup raises instead of resubmitting, and a stale batch is cancelled through the connection that submitted it. OpenAI requests send max_completion_tokens (max_tokens is rejected by reasoning models such as gpt-5), batch_expired error-file lines land as expired rather than errored, result files stream through the SDK's streaming response, and one malformed line no longer aborts the merge. The Anthropic adapter reads the per-item error message from the nested ErrorResponse the SDK actually returns. Non-object output_type schemas are wrapped so both providers accept them, and an empty completion is invalid_output, not success. Dispatch is pluggable through register_adapter() or an entry point, and each adapter declares the connection types it can authenticate from. The trigger and the sync poll loop share one BatchPoller, adapters are closed deterministically, timeout messages name the budget and the batch's fate, policy arguments are validated at parse time, model_id falls back to the connection's Model field, and @task.llm_batch no longer Jinja-renders the returned prompts. Docs cover the manifest and row schemas, per-outcome state handling, gateway routing and the vendor-operator comparison.
5001e56 to
3d8b88c
Compare
…ider-specific parameters
|
End-to-end runs against the real OpenAI Batch API and the real Anthropic Message Batches API, on Airflow OpenAI
All 12 requests came back Task log for the operator: submit on the worker, one deferral, trigger fires, results landed with per-bucket counts. XCom for the Clearing a finished task re-attaches to the recorded batch and re-lands the same file in a few seconds, with no new submission: Sample rows from the landed JSONL ( {"custom_id": "4fdf6648f4efda69-0", "index": 0, "status": "success", "output": ["loved", "would buy again", "positive"], "raw_output": null, "error": null, "model": "gpt-5-mini-2025-08-07", "usage": {"input_tokens": 61, "output_tokens": 89}, "finish_reason": "stop"}
{"custom_id": "4fdf6648f4efda69-1", "index": 1, "status": "success", "output": ["broke", "one-use", "disappointed"], "raw_output": null, "error": null, "model": "gpt-5-mini-2025-08-07", "usage": {"input_tokens": 62, "output_tokens": 89}, "finish_reason": "stop"}Anthropic
All 12 requests Triggerer polling and landing for the mixed-model decorator task: Rows from the mixed-model batch, showing the per-request model override landing on Sonnet while the rest ran on Haiku: {"index": 0, "status": "success", "output": "The reviewer highly enjoyed the product and plans to purchase it again in the future.", "model": "claude-haiku-4-5-20251001", "usage": {"input_tokens": 30, "output_tokens": 19}, "finish_reason": "end_turn"}
{"index": 1, "status": "success", "output": "Cet article s'est cassé après une seule utilisation, ce qui est très décevant.", "model": "claude-haiku-4-5-20251001", "usage": {"input_tokens": 33, "output_tokens": 27}, "finish_reason": "end_turn"}
{"index": 2, "status": "success", "output": "Product works as advertised but is unremarkable.", "model": "claude-sonnet-4-5-20250929", "usage": {"input_tokens": 32, "output_tokens": 14}, "finish_reason": "end_turn"}Timing
Changes that came out of the runsBoth are in the latest commit. The operator now logs a line when it re-attaches to a recorded batch and another when it lands results; before this a successful landing was silent in the task log. The operator guide gained a "Provider-specific parameters" section with a per-provider translation table for One thing to note from the Anthropic log above: every status poll shows up as an INFO line ( |
…spelling and provider.yaml guide entry







Provider batch APIs run at roughly half price with a 24h SLA, but a pipeline built on
@task.llmhad no way to use them. Switching meant the author owned JSONL construction, upload, chunking to provider limits, polling, and result retrieval by hand.pydantic-ai exposes no batch abstraction, so there is no
Agentpath for batch work. Batch is submit/poll/fetch spanning hours rather than request/response in a loop, so the agent loop, tool execution, HITL approval andmessage_historyhave no meaning there. A dedicated deferrable operator and decorator fit better than a mode flag on@task.llm.Summary
LLMBatchOperator/@task.llm_batchsubmit a list of prompts as one OpenAI or Anthropic batch job, defer while it runs, and land results as JSONL on object storage. The XCom value is a small manifest (counts per outcome, result URI, provenance); a batch can hold 100k items, so results never go through XCom. Structured output goes through a provider-neutral JSON Schema (OpenAIresponse_format, Anthropic forced tool call), and a response that does not match lands as aninvalid_outputrow rather than failing the task.Retry safety is the cost-critical part. A retry or clear computes the same identity key (dag, task, run, map index, never try number) and re-attaches to the recorded batch when the input fingerprint still matches. The state lives on the same object storage as the results, because the server clears a task instance's XCom at the start of every attempt. Intent is recorded before the paid submit call, so a crash between "request sent" and "response recorded" is recoverable on OpenAI through batch metadata; Anthropic offers no such lookup and falls through to an explicit
on_orphaned_intentpolicy.Design rationale
A cancelled batch fails the task instead of landing as a success. On Airflow 3.3+ a UI clear runs the trigger's
on_kill, which cancels the batch under the defaultcancel_on_kill=True. If the next attempt re-attached and treatedcancelledlikecompleted, a clear would produce a green task with a manifest of mostlymissingrows. Whatever finished is landed, the task raisesLLMBatchCancelledError, and the state is cleared so the next attempt submits fresh. A provider-sidefailed(OpenAI rejected the input before running anything) clears the state for the same reason. Every other outcome keeps the state, so a clear after success re-lands the same results at no cost.The vendor SDKs are called directly rather than through the vendor providers' hooks.
OpenAIHookhas no batch listing or file download, and a dependency fromcommon.aionapache-airflow-providers-openaiwould drag its Airflow 2 floor along. The cost is that this package tracks theopenaiandanthropicSDK versions in parallel with those providers; the pins match theirs (openai>=2.45.0, the floorpydantic-ai-slim[openai]already imposes, andanthropic>=1.0.0).Adapters are pluggable.
BatchAdapteris the contract; dispatch resolves themodel_idprefix through the built-in table,register_adapter(), or an entry point in theairflow.providers.common.ai.batch_adaptersgroup, and each adapter declares the connection types it can authenticate from. Bedrock and Vertex batch (S3/GCS based, different SDK shapes) can ship inside their own provider packages without touching this one.OpenAI requests send
max_completion_tokens.max_tokensis deprecated and rejected by reasoning models such as gpt-5, which is the model the example uses; with the defaultfail_on_partial_error=Falsean all-rejected batch would otherwise complete green with zero usable rows.@task.llm_batchdoes not Jinja-render the returned prompts, unlike@task.llm. Batch inputs are bulk text the Dag author did not write, where a stray{{or{%would either fail the whole batch or resolvevar/connaccessors against Airflow secrets. Anything dynamic belongs in the callable, which receives the task context.Gotchas
cancel_on_killin deferrable mode needs Airflow 3.3+, the first version whose triggerer calls a trigger'son_kill. On 3.0 to 3.2 a killed deferred task's batch keeps running and a clear re-attaches to it.cancel_on_timeout=Falseleaves the batch running and billing; a retry after the original budget elapsed gets a freshtimeoutmeasured from the retry, soretriesandretry_delaybound the total wait.pydanticaiconnection on the public endpoint, or an OpenAI-compatible gateway (LiteLLM exposes/v1/filesand/v1/batchesand routes to those backends behind it).Was generative AI tooling used to co-author this PR?
{pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.