This is an automated email from the ASF dual-hosted git repository.
o-nikolas pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 7c34d3097eb Prevent a broken job from stalling AWS Batch executor sync
(#71380)
7c34d3097eb is described below
commit 7c34d3097eba77910ec7ea4b121741704b890e50
Author: Alexander Oreshkevich <[email protected]>
AuthorDate: Tue Aug 18 06:19:16 2026 +0700
Prevent a broken job from stalling AWS Batch executor sync (#71380)
A deterministic per-job error in sync_running_jobs() aborts every
heartbeat before attempt_submit_jobs(), halting all task submission
until the scheduler is restarted. Seen in production: a job whose
bookkeeping was partially cleaned made pop_by_id() raise KeyError
forever, wedging the whole executor.
---
.../amazon/aws/executors/batch/batch_executor.py | 37 ++++-
.../providers/amazon/aws/executors/batch/utils.py | 14 ++
.../aws/executors/batch/test_batch_executor.py | 168 +++++++++++++++++++++
3 files changed, 214 insertions(+), 5 deletions(-)
diff --git
a/providers/amazon/src/airflow/providers/amazon/aws/executors/batch/batch_executor.py
b/providers/amazon/src/airflow/providers/amazon/aws/executors/batch/batch_executor.py
index 00c7dcb7881..454ba38c0a4 100644
---
a/providers/amazon/src/airflow/providers/amazon/aws/executors/batch/batch_executor.py
+++
b/providers/amazon/src/airflow/providers/amazon/aws/executors/batch/batch_executor.py
@@ -268,11 +268,38 @@ class AwsBatchExecutor(BaseExecutor):
self.log.debug("Active Workers: %s", describe_job_response)
for job in describe_job_response:
- if job.get_job_state() == State.FAILED:
- self._handle_failed_job(job)
- elif job.get_job_state() == State.SUCCESS:
- workload_key = self.active_workers.pop_by_id(job.job_id)
- self.success(workload_key)
+ # snapshot the key before handling the job: if the error strikes
after
+ # pop_by_id() already removed it, the collection can no longer
tell us
+ # whose workload it was, and the workload would be left with no
terminal state
+ workload_key = self.active_workers.id_to_key.get(job.job_id)
+ try:
+ if job.get_job_state() == State.FAILED:
+ self._handle_failed_job(job)
+ elif job.get_job_state() == State.SUCCESS:
+ self.success(self.active_workers.pop_by_id(job.job_id))
+ except (ClientError, NoCredentialsError):
+ # credential problems are executor-wide, not job-specific: let
sync() handle them
+ raise
+ except Exception:
+ self.log.exception(
+ "Evicting Batch job %s after an unexpected error while
syncing it.", job.job_id
+ )
+ self._evict_job(job.job_id, workload_key)
+
+ def _evict_job(self, job_id: str, workload_key: BatchJobWorkloadKey | None
= None) -> None:
+ """
+ Remove a job from the collection and fail its workload, tolerating
corrupted bookkeeping.
+
+ workload_key is the caller's snapshot of the mapping taken before the
error;
+ it is the fallback when the job was already removed from the
collection.
+ """
+ workload_key = self.active_workers.remove_job(job_id) or workload_key
+ if workload_key is None:
+ return
+ try:
+ self.fail(workload_key)
+ except Exception:
+ self.log.exception("Failed to fail workload %s of evicted Batch
job %s", workload_key, job_id)
def _handle_failed_job(self, job):
"""
diff --git
a/providers/amazon/src/airflow/providers/amazon/aws/executors/batch/utils.py
b/providers/amazon/src/airflow/providers/amazon/aws/executors/batch/utils.py
index 64685184902..d47fc93ff5b 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/executors/batch/utils.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/executors/batch/utils.py
@@ -128,6 +128,20 @@ class BatchJobCollection:
del self.id_to_failure_counts[job_id]
return workload_key
+ def remove_job(self, job_id: str) -> BatchJobWorkloadKey | None:
+ """
+ Remove a job from the collection, tolerating partially cleaned
bookkeeping.
+
+ Unlike pop_by_id, this never raises: every mapping is popped
defensively.
+ Returns the workload key if the job was still tracked, otherwise None.
+ """
+ workload_key = self.id_to_key.pop(job_id, None)
+ self.id_to_failure_counts.pop(job_id, None)
+ self.id_to_job_info.pop(job_id, None)
+ if workload_key is not None:
+ self.key_to_id.pop(workload_key, None)
+ return workload_key
+
def failure_count_by_id(self, job_id: str) -> int:
"""Get the number of times a job has failed given a Batch Job Id."""
return self.id_to_failure_counts[job_id]
diff --git
a/providers/amazon/tests/unit/amazon/aws/executors/batch/test_batch_executor.py
b/providers/amazon/tests/unit/amazon/aws/executors/batch/test_batch_executor.py
index 4bfd7c716ef..50a90757eae 100644
---
a/providers/amazon/tests/unit/amazon/aws/executors/batch/test_batch_executor.py
+++
b/providers/amazon/tests/unit/amazon/aws/executors/batch/test_batch_executor.py
@@ -146,6 +146,21 @@ class TestBatchJobCollection:
assert len(self.collection) == 1
assert self.collection.get_all_jobs() == [self.second_job_id]
+ def test_remove_job(self):
+ """Test remove_job() clears every mapping and returns the workload
key"""
+ assert self.collection.remove_job(self.first_job_id) is
self.first_airflow_key
+ assert self.collection.get_all_jobs() == [self.second_job_id]
+ assert self.first_job_id not in self.collection.id_to_failure_counts
+ assert self.first_job_id not in self.collection.id_to_job_info
+ assert self.first_airflow_key not in self.collection.key_to_id
+
+ def test_remove_job_tolerates_partially_cleaned_state(self):
+ """remove_job() must not raise where pop_by_id() would"""
+ del self.collection.key_to_id[self.first_airflow_key]
+ assert self.collection.remove_job(self.first_job_id) is
self.first_airflow_key
+ assert self.first_job_id not in self.collection.id_to_key
+ assert self.collection.remove_job("untracked-job-id") is None
+
class TestBatchJob:
"""Tests the BatchJob DTO"""
@@ -644,6 +659,159 @@ class TestAwsBatchExecutor:
fail_mock.assert_called_once()
assert success_mock.call_count == 0
+ @staticmethod
+ def _describe_jobs_response(*job_id_status_pairs: tuple[str, str]) -> dict:
+ return {
+ "jobs": [
+ {
+ "jobName": "some-job-name",
+ "jobId": job_id,
+ "jobQueue": "some-job-queue",
+ "status": status,
+ "statusReason": "",
+ "createdAt": dt.datetime.now().timestamp(),
+ "jobDefinition": "some-job-def",
+ }
+ for job_id, status in job_id_status_pairs
+ ]
+ }
+
+ @mock.patch.object(BaseExecutor, "fail")
+ @mock.patch.object(BaseExecutor, "success")
+ def test_sync_evicts_job_that_cannot_be_synced(self, success_mock,
fail_mock, mock_executor):
+ """
+ An error while handling one job must evict that job and fail its
workload,
+ without blocking the handling of the remaining jobs in the same cycle.
+ """
+ poisoned_key = mock.Mock(spec=TaskInstanceKey)
+ healthy_key = mock.Mock(spec=TaskInstanceKey)
+ for job_id, key in (("001", poisoned_key), ("002", healthy_key)):
+ mock_executor.active_workers.add_job(
+ job_id=job_id,
+ airflow_workload_key=key,
+ airflow_cmd="airflow_cmd",
+ queue="queue",
+ exec_config={},
+ attempt_number=1,
+ )
+ # Partially cleaned bookkeeping (e.g. left behind by a duplicate
submission for
+ # the same workload key) makes pop_by_id raise KeyError for this job
forever.
+ del mock_executor.active_workers.key_to_id[poisoned_key]
+ mock_executor.batch.describe_jobs.return_value =
self._describe_jobs_response(
+ ("001", "SUCCEEDED"), ("002", "SUCCEEDED")
+ )
+
+ mock_executor.sync_running_jobs()
+
+ fail_mock.assert_called_once_with(poisoned_key)
+ success_mock.assert_called_once_with(healthy_key)
+ assert len(mock_executor.active_workers) == 0
+ assert mock_executor.active_workers.get_all_jobs() == []
+
+ @mock.patch.object(BaseExecutor, "fail")
+ def test_sync_eviction_survives_fail_raising(self, fail_mock,
mock_executor, mock_airflow_key):
+ """Even fail() blowing up during eviction must not leave the job
tracked."""
+ airflow_key = mock_airflow_key()
+ mock_executor.active_workers.add_job(
+ job_id="001",
+ airflow_workload_key=airflow_key,
+ airflow_cmd="airflow_cmd",
+ queue="queue",
+ exec_config={},
+ attempt_number=1,
+ )
+ del mock_executor.active_workers.key_to_id[airflow_key]
+ mock_executor.batch.describe_jobs.return_value =
self._describe_jobs_response(("001", "SUCCEEDED"))
+ fail_mock.side_effect = RuntimeError("fail() also broken")
+
+ mock_executor.sync_running_jobs()
+
+ assert mock_executor.active_workers.get_all_jobs() == []
+ assert "001" not in mock_executor.active_workers.id_to_key
+
+ @mock.patch.object(BaseExecutor, "fail")
+ @mock.patch.object(BaseExecutor, "success")
+ def test_sync_eviction_fails_workload_already_removed_from_collection(
+ self, success_mock, fail_mock, mock_executor, mock_airflow_key
+ ):
+ """
+ When the error strikes after pop_by_id() already removed the job (here
success()
+ itself raising), the collection can no longer resolve the workload
key, so the
+ key snapshotted before handling must be used to fail the workload
instead of
+ leaving it with no terminal state.
+ """
+ airflow_key = mock_airflow_key()
+ mock_executor.active_workers.add_job(
+ job_id="001",
+ airflow_workload_key=airflow_key,
+ airflow_cmd="airflow_cmd",
+ queue="queue",
+ exec_config={},
+ attempt_number=1,
+ )
+ mock_executor.batch.describe_jobs.return_value =
self._describe_jobs_response(("001", "SUCCEEDED"))
+ success_mock.side_effect = RuntimeError("state change failed")
+
+ mock_executor.sync_running_jobs()
+
+ fail_mock.assert_called_once_with(airflow_key)
+ assert mock_executor.active_workers.get_all_jobs() == []
+ assert airflow_key not in mock_executor.active_workers.key_to_id
+
+ @mock.patch.object(BaseExecutor, "fail")
+ @mock.patch.object(BaseExecutor, "success")
+ def test_sync_reraises_credential_errors_instead_of_evicting(
+ self, success_mock, fail_mock, mock_executor, mock_airflow_key
+ ):
+ """
+ Credential problems are executor-wide, not job-specific: they must
propagate to
+ sync()'s connection handling rather than evict the job they happened
to surface on.
+ """
+ airflow_key = mock_airflow_key()
+ mock_executor.active_workers.add_job(
+ job_id="001",
+ airflow_workload_key=airflow_key,
+ airflow_cmd="airflow_cmd",
+ queue="queue",
+ exec_config={},
+ attempt_number=1,
+ )
+ mock_executor.batch.describe_jobs.return_value =
self._describe_jobs_response(("001", "SUCCEEDED"))
+ success_mock.side_effect = ClientError(
+ {"Error": {"Code": "ExpiredTokenException", "Message": "token
expired"}}, "SomeBotoOperation"
+ )
+
+ with pytest.raises(ClientError):
+ mock_executor.sync_running_jobs()
+
+ fail_mock.assert_not_called()
+
+ @mock.patch.object(AwsBatchExecutor, "attempt_submit_jobs")
+ @mock.patch.object(BaseExecutor, "fail")
+ def test_sync_submits_pending_jobs_despite_poisoned_job(
+ self, fail_mock, attempt_submit_jobs_mock, mock_executor,
mock_airflow_key
+ ):
+ """
+ The reported production impact: a job that deterministically errors
during sync
+ used to abort sync() before attempt_submit_jobs(), halting all task
submission
+ until a scheduler restart.
+ """
+ airflow_key = mock_airflow_key()
+ mock_executor.active_workers.add_job(
+ job_id="001",
+ airflow_workload_key=airflow_key,
+ airflow_cmd="airflow_cmd",
+ queue="queue",
+ exec_config={},
+ attempt_number=1,
+ )
+ del mock_executor.active_workers.key_to_id[airflow_key]
+ mock_executor.batch.describe_jobs.return_value =
self._describe_jobs_response(("001", "SUCCEEDED"))
+
+ mock_executor.sync()
+
+ attempt_submit_jobs_mock.assert_called_once()
+
def test_start_failure_with_invalid_permissions(self, set_env_vars):
executor = AwsBatchExecutor()