Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion airflow-core/src/airflow/jobs/scheduler_job_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -1663,7 +1663,7 @@ def process_executor_events(
task = dag.get_task(ti.task_id)
except Exception:
cls.logger().exception("Marking task instance %s as %s", ti, state)
ti.set_state(state)
ti.set_state(state, session=session)
continue
ti.task = task
if task.has_on_retry_callback or task.has_on_failure_callback:
Expand Down
35 changes: 35 additions & 0 deletions airflow-core/tests/unit/jobs/test_scheduler_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,41 @@ def test_executor_event_diagnostic_distinguishes_locked_row_from_nonprocessable_
else:
scalars.assert_not_called()

def test_process_executor_events_sets_state_in_callers_transaction(self, dag_maker):
"""
Setting a task instance's state must not commit the caller's transaction.

``settings.Session`` is scoped, so ``ti.set_state()`` without ``session`` resolved to the
scheduler's own session and ``create_session()`` committed and closed it on exit. That released
the scheduler's row locks mid-batch and detached the task instances still to be processed, so
changes made to them afterwards were never written.

This covers the Dag-not-found path: the Dag can't be loaded, so the task instance is marked
with the executor's reported state directly, without a session it would otherwise resolve to
the scheduler's own scoped session and commit early.
"""
session = settings.Session()
with dag_maker(dag_id="test_executor_events_callers_transaction", fileloc="/test_path1/"):
task1 = EmptyOperator(task_id="test_task", retries=2)
ti1 = dag_maker.create_dagrun().get_task_instance(task1.task_id)
ti1.state = TaskInstanceState.QUEUED
session.merge(ti1)
session.commit()

executor = MockExecutor(do_update=False)
job_runner = SchedulerJobRunner(Job(), executors=[executor])
job_runner.scheduler_dag_bag = mock.MagicMock()
job_runner.scheduler_dag_bag.get_dag_for_run.side_effect = Exception("failed")
executor.event_buffer[TaskInstanceUuid(ti1.id)] = State.FAILED, None

job_runner._process_executor_events(executor=executor, session=session)
ti1.refresh_from_db(session=session)
assert ti1.state == TaskInstanceState.FAILED
session.rollback()

ti1.refresh_from_db(session=session)
assert ti1.state == TaskInstanceState.QUEUED

@mock.patch("airflow.jobs.scheduler_job_runner.TaskCallbackRequest")
@mock.patch("airflow._shared.observability.metrics.stats._get_backend")
def test_process_executor_events_with_no_callback(self, mock_get_backend, mock_task_callback, dag_maker):
Expand Down