2424from airflow .sdk .bases .operator import BaseOperatorMeta
2525
2626if TYPE_CHECKING :
27+ from collections .abc import Callable
28+
2729 from pydantic import JsonValue
2830
2931 from airflow .sdk .definitions .context import Context
@@ -101,26 +103,12 @@ def __init__(self, *, durable: bool = True, **kwargs: Any) -> None:
101103
102104 def execute_resumable (self , context : Context ) -> Any :
103105 """
104- Core of the resumable execution logic. Call this from execute() when reconnection is supported.
105-
106- On initial run: submits the job, persists the external ID to task_state_store, then polls.
106+ Crash-safe submit-and-poll for synchronous operators. Call from ``execute()``.
107107
108- Behaviour on retry:
109- - On retry with active job: skips submission, reconnects to the running job.
110- - On retry with succeeded job: skips submission and polling, returns result immediately.
111- - On retry with failed job: falls through and resubmits fresh.
112-
113- Known limitation: there is a small window between ``submit_job`` returning and
114- ``task_state_store.set`` completing. If the worker dies in that gap, the next retry still
115- holds the previous (terminal) ID and will resubmit a fresh job rather than reconnecting.
116- Closing this window would require atomic "submit + persist", which is not possible across
117- an external system boundary.
108+ Binds the operator's ``submit_job`` / ``get_job_status`` / … methods to
109+ :func:`resume_or_submit`, which owns the persist + three-state reconnect logic. See that
110+ function for the behaviour and its known submit-vs-persist limitation.
118111 """
119- if not self .durable :
120- external_id = self .submit_job (context )
121- self .poll_until_complete (external_id , context )
122- return self .get_job_result (external_id , context )
123-
124112 stats_tags = {"operator" : type (self ).__name__ }
125113 # The task is team-scoped in multi-team deployments; surface team_name on the
126114 # resumable_job metrics via the running task instance's stats tags (omitted when
@@ -129,85 +117,20 @@ def execute_resumable(self, context: Context) -> Any:
129117 if ti is not None and (team_name := ti .stats_tags .get ("team_name" )):
130118 stats_tags ["team_name" ] = team_name
131119
132- reconnect_to : Any = None
133- already_succeeded_id : Any = None
134-
135- with tracer .start_as_current_span ("resumable_job.resume_decision" ) as span :
136- span .set_attribute ("operator" , type (self ).__name__ )
137- span .set_attribute ("resumable.external_id_key" , self .external_id_key )
138-
139- task_state_store = context .get ("task_state_store" )
140-
141- if task_state_store is None :
142- span .set_attribute ("resumable.decision" , "no_task_state_store" )
143- self .log .warning (
144- "task_state_store not available in context, crash recovery is disabled for this run"
145- )
146- else :
147- external_id = task_state_store .get (self .external_id_key )
148- if external_id :
149- stats .incr ("resumable_job.reconnect_attempt" , tags = stats_tags )
150-
151- status = self .get_job_status (external_id , context )
152-
153- span .set_attribute ("resumable.external_id" , str (external_id ))
154- span .set_attribute ("resumable.prior_status" , status )
155-
156- if self .is_job_active (status ):
157- # Job is still running, skip submission and reconnect to it.
158- span .set_attribute ("resumable.decision" , "reconnect" )
159- stats .incr ("resumable_job.reconnect_success" , tags = stats_tags )
160- self .log .info (
161- "Reconnecting to existing job" ,
162- external_id_key = self .external_id_key ,
163- external_id = external_id ,
164- status = status ,
165- )
166- reconnect_to = external_id
167- elif self .is_job_succeeded (status ):
168- # Job already finished successfully, skip polling and return result directly.
169- span .set_attribute ("resumable.decision" , "already_succeeded" )
170- stats .incr ("resumable_job.already_succeeded" , tags = stats_tags )
171- self .log .info (
172- "Job already completed successfully, skipping resubmission" ,
173- external_id_key = self .external_id_key ,
174- external_id = external_id ,
175- )
176- already_succeeded_id = external_id
177- else :
178- # Job is in a terminal failed state, fall through and submit a new job.
179- span .set_attribute ("resumable.decision" , "terminal_resubmit" )
180- stats .incr ("resumable_job.terminal_resubmit" , tags = stats_tags )
181- self .log .warning (
182- "Prior job in terminal state, resubmitting fresh" ,
183- external_id_key = self .external_id_key ,
184- external_id = external_id ,
185- status = status ,
186- )
187- else :
188- span .set_attribute ("resumable.decision" , "fresh_submit" )
189- stats .incr ("resumable_job.fresh_submit" , tags = stats_tags )
190- self .log .debug (
191- "No stored external ID found; submitting fresh job" ,
192- external_id_key = self .external_id_key ,
193- )
194-
195- if reconnect_to is not None :
196- return self .poll_until_complete (reconnect_to , context )
197- if already_succeeded_id is not None :
198- return self .get_job_result (already_succeeded_id , context )
199- external_id = self .submit_job (context )
200-
201- if task_state_store is not None and external_id is not None :
202- task_state_store .set (self .external_id_key , external_id )
203- self .log .debug (
204- "Persisted external ID to task store" ,
205- external_id_key = self .external_id_key ,
206- external_id = external_id ,
207- )
208-
209- self .poll_until_complete (external_id , context )
210- return self .get_job_result (external_id , context )
120+ return resume_or_submit (
121+ durable = self .durable ,
122+ external_id_key = self .external_id_key ,
123+ task_state_store = context .get ("task_state_store" ),
124+ submit = lambda : self .submit_job (context ),
125+ get_status = lambda external_id : self .get_job_status (external_id , context ),
126+ is_active = self .is_job_active ,
127+ is_succeeded = self .is_job_succeeded ,
128+ poll = lambda external_id : self .poll_until_complete (external_id , context ),
129+ get_result = lambda external_id : self .get_job_result (external_id , context ),
130+ log = self .log ,
131+ operator_name = type (self ).__name__ ,
132+ stats_tags = stats_tags ,
133+ )
211134
212135 def submit_job (self , context : Context ) -> JsonValue :
213136 """
@@ -254,3 +177,114 @@ def poll_until_complete(self, external_id: JsonValue, context: Context) -> None:
254177 def get_job_result (self , external_id : JsonValue , context : Context ) -> Any :
255178 """Return the job result after completion. Return None if not applicable."""
256179 raise NotImplementedError
180+
181+
182+ def resume_or_submit (
183+ * ,
184+ durable : bool ,
185+ external_id_key : str ,
186+ task_state_store : Any ,
187+ submit : Callable [[], JsonValue ],
188+ get_status : Callable [[JsonValue ], str ],
189+ is_active : Callable [[str ], bool ],
190+ is_succeeded : Callable [[str ], bool ],
191+ poll : Callable [[JsonValue ], Any ],
192+ get_result : Callable [[JsonValue ], Any ],
193+ log : Logger ,
194+ operator_name : str ,
195+ stats_tags : dict [str , str ],
196+ ) -> Any :
197+ """
198+ Submit an external job and poll for it, reconnecting to a prior run on retry.
199+
200+ The reusable core of crash-safe submit-and-poll execution, independent of any operator.
201+ ``ResumableJobMixin`` wraps it for synchronous operators, but it can equally be driven from the
202+ task runner for operators whose wait happens outside ``execute()`` (e.g. ``TriggerDagRunOperator``,
203+ which raises an exception and is polled by the runner).
204+
205+ The callbacks are the external-system bindings: ``submit`` starts the job and returns its external
206+ id, ``get_status`` reads the raw backend status, ``is_active`` / ``is_succeeded`` classify it,
207+ ``poll`` blocks until terminal (raising on failure), ``get_result`` returns the result. On the
208+ first run the external id is persisted to ``task_state_store`` before polling; on retry it is read
209+ back and the job is reconnected (active), returned (succeeded), or resubmitted (terminal / missing).
210+
211+ Known limitation: there is a small window between ``submit`` returning and the persist completing.
212+ A crash in that gap resubmits fresh on the next retry rather than reconnecting; closing it would
213+ require an atomic "submit + persist", which is not possible across an external system boundary.
214+ """
215+ if not durable :
216+ external_id = submit ()
217+ poll (external_id )
218+ return get_result (external_id )
219+
220+ reconnect_to : Any = None
221+ already_succeeded_id : Any = None
222+
223+ with tracer .start_as_current_span ("resumable_job.resume_decision" ) as span :
224+ span .set_attribute ("operator" , operator_name )
225+ span .set_attribute ("resumable.external_id_key" , external_id_key )
226+
227+ if task_state_store is None :
228+ span .set_attribute ("resumable.decision" , "no_task_state_store" )
229+ log .warning ("task_state_store not available in context, crash recovery is disabled for this run" )
230+ else :
231+ external_id = task_state_store .get (external_id_key )
232+ if external_id :
233+ stats .incr ("resumable_job.reconnect_attempt" , tags = stats_tags )
234+
235+ status = get_status (external_id )
236+
237+ span .set_attribute ("resumable.external_id" , str (external_id ))
238+ span .set_attribute ("resumable.prior_status" , status )
239+
240+ if is_active (status ):
241+ span .set_attribute ("resumable.decision" , "reconnect" )
242+ stats .incr ("resumable_job.reconnect_success" , tags = stats_tags )
243+ log .info (
244+ "Reconnecting to existing job" ,
245+ external_id_key = external_id_key ,
246+ external_id = external_id ,
247+ status = status ,
248+ )
249+ reconnect_to = external_id
250+ elif is_succeeded (status ):
251+ span .set_attribute ("resumable.decision" , "already_succeeded" )
252+ stats .incr ("resumable_job.already_succeeded" , tags = stats_tags )
253+ log .info (
254+ "Job already completed successfully, skipping resubmission" ,
255+ external_id_key = external_id_key ,
256+ external_id = external_id ,
257+ )
258+ already_succeeded_id = external_id
259+ else :
260+ span .set_attribute ("resumable.decision" , "terminal_resubmit" )
261+ stats .incr ("resumable_job.terminal_resubmit" , tags = stats_tags )
262+ log .warning (
263+ "Prior job in terminal state, resubmitting fresh" ,
264+ external_id_key = external_id_key ,
265+ external_id = external_id ,
266+ status = status ,
267+ )
268+ else :
269+ span .set_attribute ("resumable.decision" , "fresh_submit" )
270+ stats .incr ("resumable_job.fresh_submit" , tags = stats_tags )
271+ log .debug (
272+ "No stored external ID found; submitting fresh job" ,
273+ external_id_key = external_id_key ,
274+ )
275+
276+ if reconnect_to is not None :
277+ return poll (reconnect_to )
278+ if already_succeeded_id is not None :
279+ return get_result (already_succeeded_id )
280+
281+ external_id = submit ()
282+ if task_state_store is not None and external_id is not None :
283+ task_state_store .set (external_id_key , external_id )
284+ log .debug (
285+ "Persisted external ID to task store" ,
286+ external_id_key = external_id_key ,
287+ external_id = external_id ,
288+ )
289+ poll (external_id )
290+ return get_result (external_id )
0 commit comments