Reconnect to the running Livy batch on retry instead of resubmitting - #68956
Conversation
When LivyOperator waits synchronously (deferrable=False with polling_interval > 0) and the worker is lost mid-poll, the retry currently posts a brand-new Livy batch, leaving the original Spark application running and duplicating the work. Subclass ResumableJobMixin so the batch id is persisted before polling and the retry reconnects to the in-flight batch. Deferrable and fire-and-forget (polling_interval=0) paths are unchanged.
The fallback exists only because ResumableJobMixin ships in airflow.sdk (Airflow 3) while the provider still targets apache-airflow>=2.11; the comment now says so and notes when it can be removed.
Five providers already ship a byte-identical fallback shim; keeping the wording and key aligned lets them be found and updated as one set.
potiuk
left a comment
There was a problem hiding this comment.
Real duplication bug: with deferrable=False and polling_interval > 0 the batch is held on the worker, so losing the worker mid-poll meant the retry posted a brand-new Livy batch while the original Spark application kept running. Duplicated work, doubled cluster cost, and nothing looks wrong afterwards because the retry succeeds.
I stacked this against the six providers that already adopted AIP-103 — redshift_data, spark_submit, databricks, bigquery, snowflake — and it matches the house pattern: same try/except ImportError fallback shim, external_id_key following the <system>_<id-noun> convention, and execute_resumable gated on a condition rather than applied unconditionally. The comment above the guard explaining why it exists and when it can go is a genuine improvement over the others, which just have the shim.
The thing I most expected to be wrong is handled by the mixin rather than here: a legitimately failed Spark job does not get reconnected to on retry — execute_resumable falls through and resubmits fresh — so ordinary retry semantics still work.
Two details worth crediting. Excluding the non-polling path is right, since there is nothing to reconnect to when the operator does not wait. And re-setting self._batch_id inside poll_until_complete so on_kill() can still delete the batch after a reconnect, where submit_job was skipped, is the kind of thing that usually only surfaces in production.
I pushed one small change: the fallback stub's docstring and external_id_key now match the other five providers, which ship a byte-identical shim. Keeping the wording aligned means the seven copies stay greppable as one set — which matters, because that is now seven copies of the same shim with no shared home in common.compat. Worth someone folding them together, though not in this PR.
Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting
Why
LivyOperatorwaiting synchronously (deferrable=Falsewithpolling_interval > 0) holds theSpark batch on the worker. If the worker is lost mid-poll, the retry posts a brand-new Livy
batch — the original Spark application keeps running and the work is duplicated.
ResumableJobMixin(Airflow 3.3, AIP-103) exists to make exactly this synchronous-wait pathcrash-safe: persist the external job id before polling, and on retry reconnect to the running job
instead of resubmitting. Livy is a clean fit — a synchronous submit-then-poll operator, the same
shape the mixin was built for and the
SparkSubmitOperatoralready uses.What
LivyOperatornow subclassesResumableJobMixinand routes its synchronous-poll path throughexecute_resumable:submit_jobposts the batch and returns its id;get_job_status/is_job_active/is_job_succeededclassify LivyBatchState;poll_until_completereuses the existingpoll_for_termination;get_job_resultpushes theapp_idXCom.task_state_storebefore polling, so a retry reads it back andreconnects to the running batch.
polling_interval=0, nothingto reconnect to) paths are untouched. An Airflow-2 stub keeps the provider importable on 2.x.
Crash-safety is opt-in through the mixin's
durableflag (default on); setdurable=Falseto keepthe always-resubmit behaviour.
Tests
A new
TestLivyOperatorResumablesuite (gated on Airflow 3.3+) covers fresh-submit-persists-before-poll,the three retry decisions (reconnect / return / resubmit) across real
BatchStatevalues, gracefuldegradation without a
task_state_store, anddurable=False. The existingLivyOperatorsuite isunchanged.
End-to-end (live, Breeze)
A real worker crash during the synchronous wait, against an in-memory Livy stand-in that counts
POST /batches. ALivyOperator(durable=True, deferrable=False, polling_interval=3)submits abatch; the worker is
SIGKILLed mid-poll; the scheduler retries. Attempt 2 reads the persistedbatch id back, reconnects to the still-running batch, and finishes it — with no second submit.
Raw
Risk
Only the synchronous-poll path changes; deferrable and fire-and-forget are byte-for-byte the same.
The reconnect logic is the shared mixin core, already covered by its own tests.
Was generative AI tooling used to co-author this PR?