|
This AIP proposes adding reconnection/resumption capability to operators that submit long-running external jobs (Databricks, EMR, Snowflake, dbt Cloud, etc.). When infrastructure disruptions occur (worker crashes, pod evictions), operators can preserve external job IDs and reconnect on retry instead of wastefully restarting. This eliminates wasted computation and reduces costs while following the established deferral pattern.
Key Benefits:
Problem: Wasted Computation from Infrastructure Disruptions
When operators submit long-running external jobs and monitor their completion, infrastructure disruptions force operators to cancel healthy jobs and restart from scratch.
Example: The 3-Hour Databricks Job
class DatabricksOperator(BaseOperator): def execute(self, context): job_id = self.submit_databricks_job() return self.poll_until_complete(job_id)
def on_kill(self): # No context - must always cancel if self.job_id: self.cancel_databricks_job(self.job_id) |
Timeline:
Waste: 2.5 hours computation + cluster costs + delayed delivery
The job was fine, only the Airflow worker was disrupted.
Current Behavior (Wasteful):
![]()
Proposed Behavior (Resumable):
![]()
Extend the existing deferral pattern with a checkpointing pattern:
These patterns are complementary and work together.
The implementation mirrors the existing deferral mechanism:
# Existing: Deferral raise TaskDeferred(trigger=..., method_name='execute_complete') → TaskInstanceState.DEFERRED → Scheduler calls execute_complete() when trigger fires # New: Checkpointing raise TaskCheckpointed(remote_job_id='job-123', method_name='resume_job') → TaskInstanceState.CHECKPOINTED → Scheduler calls resume_job() on retry |
Architecture Comparison:
![]()
![]()
![]()
# In airflow/exceptions.py @dataclass class TaskCheckpointed(BaseException): """ Signal to checkpoint remote job state for reconnection. Similar to TaskDeferred but for disruption resilience. """ remote_job_id: str resume_method: str kwargs: dict[str, Any] | None = None |
class TaskInstanceState(str, enum.Enum): ... DEFERRED = "deferred" CHECKPOINTED = "checkpointed" # NEW |
class BaseOperator: def checkpoint( self, *, remote_job_id: str, method_name: str, kwargs: dict[str, Any] | None = None, ) -> NoReturn: """ Preserve remote job for reconnection. Similar to defer() but called from on_kill(). """ from airflow.exceptions import TaskCheckpointed raise TaskCheckpointed( remote_job_id=remote_job_id, method_name=method_name, kwargs=kwargs ) |
Deferral Flow (existing):
execute() → raise TaskDeferred → task_runner catches → SUPERVISOR_COMMS.send(DeferTask) → Execution API → DB: state=DEFERRED, next_method → Triggerer monitors → Scheduler calls execute_complete() |
Checkpoint Flow (new, parallel design):
on_kill(context) → raise TaskCheckpointed → task_runner catches → SUPERVISOR_COMMS.send(CheckpointTask) → Execution API → DB: state=CHECKPOINTED, next_method, remote_job_id → Scheduler calls resume_job(context, remote_job_id) |
Before (current, wasteful):
class DatabricksOperator(BaseOperator): def execute(self, context): job_id = self.submit_databricks_job() return self.poll_until_complete(job_id)
def on_kill(self): if self.job_id: self.cancel_databricks_job(self.job_id) # Always cancels |
After (resumable):
class DatabricksOperator(BaseOperator): def execute(self, context): job_id = self.submit_databricks_job() self._current_job_id = job_id return self.poll_until_complete(job_id)
def on_kill(self, execution_context): if execution_context and execution_context.category == INFRASTRUCTURE: # Infrastructure disruption - preserve job self.checkpoint( remote_job_id=self._current_job_id, method_name='resume_job' ) else: # Timeout or user action - cancel self.cancel_databricks_job(self._current_job_id)
def resume_job(self, context, remote_job_id): """Called on retry - reconnect to existing job.""" status = self.get_job_status(remote_job_id)
if status in ["RUNNING", "PENDING"]: return self.poll_until_complete(remote_job_id) elif status == "SUCCESS": return self.get_job_results(remote_job_id) else: # Job failed/cancelled - start fresh return self.execute(context) |
Combines async efficiency (deferral) with disruption resilience (checkpointing):
class DatabricksOperator(BaseOperator): """Full example: Async monitoring + disruption resilience."""
def __init__(self, deferrable: bool = True, **kwargs): super().__init__(**kwargs) self.deferrable = deferrable self._current_job_id = None
def execute(self, context): """Submit job and start monitoring (PRE-DEFER PHASE - can be disrupted).""" job_id = self.submit_databricks_job() self._current_job_id = job_id
if self.deferrable: # Async: free worker slot self.defer( trigger=DatabricksJobTrigger(job_id=job_id), method_name='execute_complete' ) # DEFERRED PHASE: Worker freed, no disruption possible here else: # Sync: poll until complete return self.poll_until_complete(job_id)
def execute_complete(self, context, event): """Called when trigger fires - job is ALREADY COMPLETE (POST-DEFER PHASE).
If disrupted here, infrastructure auto-retry handles it (AIP-XX). No checkpoint needed - job is done, just re-fetch results. """ job_id = event['job_id']
if event.get('status') == 'SUCCESS': return self.get_job_results(job_id) else: raise AirflowException(f"Job {job_id} failed")
def on_kill(self, execution_context): """Smart cleanup - only checkpoint during PRE-DEFER phase.""" if not self._current_job_id: return
if execution_context and execution_context.category == INFRASTRUCTURE: # Infrastructure disruption during PRE-DEFER phase # Preserve job for reconnection # Note: execute_complete() disruptions handled by infra auto-retry self.checkpoint( remote_job_id=self._current_job_id, method_name='resume_job' ) else: # Timeout or user action - cancel self.cancel_databricks_job(self._current_job_id)
def resume_job(self, context, remote_job_id): """Reconnect to existing job after PRE-DEFER disruption.""" status = self.get_job_status(remote_job_id)
if status in ["RUNNING", "PENDING"]: self._current_job_id = remote_job_id # Resume in original mode if self.deferrable: # Re-defer to continue async monitoring self.defer( trigger=DatabricksJobTrigger(job_id=remote_job_id), method_name='execute_complete' ) else: return self.poll_until_complete(remote_job_id)
elif status == "SUCCESS": # Job completed while the worker was down! return self.get_job_results(remote_job_id)
else: # Job failed - start fresh return self.execute(context) |
Scenario 1: Individual Users – Long-Running External Jobs
Users: Data engineers running ETL, ML engineers training models, analysts running queries
Common Problem Pattern: Long-running external jobs (Databricks, SageMaker, Snowflake, EMR) get cancelled and restarted when Airflow workers are disrupted, even though the external jobs are healthy and progressing normally.
Typical Pattern:
With Resumable Operators:
User: Morgan manages Data Processing platform serving 100+ data teams
Scale Problem: Even if individual job waste seems small, it compounds across the platform:
Minimal:
Mitigations:
Positively Affected:
Not Affected:
This pattern has the potential to improve any operator that submits external jobs, e.g.:
No changes required. This is an operator-level enhancement that works transparently:
# Before: works but wasteful on disruptions databricks_task = DatabricksSubmitRunOperator( task_id='process_data', ... ) # After: same code, but now disruption-ready databricks_task = DatabricksSubmitRunOperator( task_id='process_data', ... ) |
Opt-in enhancement by implementing resume_job():
# Step 1: Track job ID def execute(self, context): job_id = self.submit_external_job() self._current_job_id = job_id # Track it return self.poll_until_complete(job_id) # Step 2: Smart on_kill() def on_kill(self, execution_context): if execution_context and execution_context.category == INFRASTRUCTURE: self.checkpoint( remote_job_id=self._current_job_id, method_name='resume_job' ) else: self.cancel_external_job(self._current_job_id) # Step 3: Implement resume logic def resume_job(self, context, remote_job_id): status = self.get_job_status(remote_job_id) if status in ["RUNNING", "PENDING"]: return self.poll_until_complete(remote_job_id) elif status == "SUCCESS": return self.get_job_results(remote_job_id) else: return self.execute(context) |
None, N/A