Skip to content

Commit 342ce1f

Browse files
committed
Make ResumableJobMixin's reconnect core reusable outside execute()
TriggerDagRunOperator's durable wait lives in the task runner (it raises DagRunTriggerException and is polled there), not in execute(), so it cannot use ResumableJobMixin and re-implements the persist-and-reconnect logic by hand. Lift the mixin's core into a standalone resume_or_submit() so the same implementation can be driven from the runner now and the triggerer later, instead of duplicated per integration point.
1 parent 76f9210 commit 342ce1f

1 file changed

Lines changed: 131 additions & 97 deletions

File tree

task-sdk/src/airflow/sdk/bases/resumablejobmixin.py

Lines changed: 131 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
from airflow.sdk.bases.operator import BaseOperatorMeta
2525

2626
if 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

Comments
 (0)