andrei-gromov opened a new issue, #74575:
URL: https://github.com/apache/airflow/issues/74575

   ### Under which category would you file this issue?
   
   Airflow Core
   
   ### Apache Airflow version
   
   3.3.2
   
   ### What happened and how to reproduce it?
   
   **Issue description**
   
   With the LocalExecutor, when the idle worker that currently holds the read 
lock of the executor's
   activity queue is killed (SIGKILL, which is what the kernel's OOM killer 
sends), the executor stops
   starting tasks. Every task scheduled afterwards stays `queued`. The 
scheduler keeps running and
   reports healthy, so nothing flags the problem. Only restarting the scheduler 
recovers.
   
   **Why it happens** (Airflow 3.3.2, 
`airflow-core/src/airflow/executors/local_executor.py`)
   
   - `:158-159`: `activity_queue` and `result_queue` are 
`multiprocessing.SimpleQueue()`.
   - `:167-170`: with the `fork` start method, `start()` forks all 
`parallelism` workers at once.
   - `:78-81`: each idle worker loops on `workload = input.get()`.
   - In CPython 3.13 (`Lib/multiprocessing/queues.py`), `SimpleQueue.__init__` 
creates
     `self._rlock = ctx.Lock()` (line 361) and `get()` is
     `with self._rlock: res = self._reader.recv_bytes()` (lines 383-385). So 
exactly one idle worker
     holds `_rlock` while it blocks reading the pipe, and every other idle 
worker waits for the lock.
     On the stand, one worker was in `anon_pipe_read` and the others in 
`futex_do_wait`.
   - `_rlock` is a semaphore-based `multiprocessing` lock. If its holder is 
killed, nothing releases
     it.
   - `_process_workloads()` (`:280-294`) only takes the write lock to `put()` 
the workload, so the
     scheduler's `put()` succeeds, and nobody can read it.
   - `_check_workers()` (`:172-202`) reaps the dead worker and, because work is 
outstanding, forks a
     replacement. The replacement calls `input.get()` too and waits for the 
same dead lock, so the
     executor believes it has `parallelism` workers. I saw the three other idle 
workers and the
     replacement all in `futex_do_wait`.
   
   The Python documentation warns about killing a process that is using a 
`Queue` (the data can become
   corrupted); `SimpleQueue` is documented as very close to a locked `Pipe`.
   
   **Steps to reproduce**
   
   1. Run `airflow scheduler` with `[core] executor = LocalExecutor`, Python 
3.13 on Linux (start
      method `fork`) and `parallelism = 4`. I used the official image, Docker 
Compose.
   2. Wait until all workers are idle. On the host, `ps -eo pid,args | grep 
'airflow worker -- LocalExecutor'`
      shows `<idle>` for each of them (the processes of the container are 
visible there).
   3. On the host, look at the kernel stack of each idle worker (for example 
`cat /proc/<pid>/stack`
      as root). One is blocked reading the pipe (`anon_pipe_read`); the others 
wait on the lock
      (`futex_do_wait`).
   4. `kill -9` the worker that is in `anon_pipe_read`.
   5. Trigger any DAG with one trivial task, for example `airflow dags trigger 
<dag_id>`.
   6. The task stays `queued` (I watched it for 180 s) while the scheduler 
reports healthy. After
      `docker compose restart airflow-scheduler` the same task runs.
   
   Control: killing an idle worker that waits on the lock (`futex_do_wait`) 
instead does not matter. The
   next task ran within 5 s, because a replacement worker is forked when work 
arrives.
   
   **The same mechanism in plain Python, without Airflow**
   
   ```python
   """A SimpleQueue reader that dies while it holds the read lock.
   
   python simplequeue_dead_reader.py holder   # kill the reader that holds the 
lock
   python simplequeue_dead_reader.py waiter   # control: kill one that waits 
for it
   """
   import multiprocessing as mp
   import os
   import signal
   import sys
   import time
   
   
   def reader(q, name):
       # what an idle LocalExecutor worker does: SimpleQueue.get()
       print(name, "got", q.get(), flush=True)
   
   
   if __name__ == "__main__":
       mp.set_start_method("fork")
       q = mp.SimpleQueue()
       w0 = mp.Process(target=reader, args=(q, "w0"))
       w0.start()
       time.sleep(1)  # w0 now holds q._rlock and blocks reading the pipe
       rest = [mp.Process(target=reader, args=(q, f"w{i}")) for i in (1, 2, 3)]
       for p in rest:
           p.start()  # these block on q._rlock
       time.sleep(1)
       victim = w0 if sys.argv[1] == "holder" else rest[0]
       os.kill(victim.pid, signal.SIGKILL)  # as the OOM killer would
       victim.join()
       q.put("job")
       time.sleep(3)
       taken = [p for p in [w0, *rest] if p is not victim and not p.is_alive()]
       print("job taken by a live reader:", bool(taken))
       for p in [w0, *rest]:
           p.kill()
   
   With `holder` it prints `job taken by a live reader: False`: the three live 
readers never get the
   job. With `waiter` the holder gets it and it prints `True`. I ran this on 
macOS with Python 3.13.5
   and 3.14.x (`fork`); I have not run it on Linux.
   
   ### What you think should happen instead?
   
   The death of one worker should not stop the executor. After 
`_check_workers()` reaps a dead worker it
   cannot know whether that worker held `_rlock`, so the executor stays exposed 
as long as the workers
   share one lock-protected `SimpleQueue`. I have not prototyped a fix. Two 
directions I can see:
   
   - When a worker dies, re-create `activity_queue` and its workers (and 
re-queue the workloads that
     were in the old pipe and not yet read), instead of only replacing the one 
process.
   - Do not make idle workers share a lock-protected read end at all, for 
example one pipe per worker
     with the executor handing each workload to an idle worker.
   
   Short of that, the state could at least be detected and logged: workloads 
are outstanding
   (`_unread_messages` > 0), the queue is not empty, and no worker has picked 
anything up for a while,
   instead of tasks sitting in `queued` while everything reports healthy. I 
waited 180 s and not
   `[scheduler] task_queued_timeout` (600 s by default), so I do not know what 
the stuck-in-queued
   handling would do with such a task, or whether it could recover while the 
workers stay blocked.
   
   ### Operating System
   
   Host: Ubuntu 26.04 LTS (arm64), kernel 7.0.0-30-generic. Container: the 
official
   `apache/airflow:3.3.2-python3.13` image (Debian-based; I did not record 
`/etc/os-release` from
   inside it).
   
   ### Deployment
   
   Docker-Compose
   
   ### Apache Airflow Provider(s)
   
   _No response_
   
   ### Versions of Apache Airflow Providers
   
   Not relevant (core executor issue).
   
   ### Official Helm Chart version
   
   Not Applicable
   
   ### Kubernetes Version
   
   Not Applicable
   
   ### Helm Chart configuration
   
   Not Applicable
   
   ### Docker Image customizations
   
   `apache/airflow:3.3.2-python3.13` plus `dlt[postgres]` and 
`astronomer-cosmos` (and a few small
   dependencies) installed into Airflow's environment, a separate virtualenv 
for dbt, and one Airflow
   listener plugin that writes `/proc/self/oom_score_adj` of task processes. 
None of it touches the
   executor or its queues, but I did not repeat the test on an unmodified image.
   
   ### Anything else?
   
   - I found this while testing which process the kernel's OOM killer should 
prefer in a
     memory-limited container (1280 MiB, `parallelism = 4`). SIGKILL is what 
the OOM killer sends, and
     the workers have the default `oom_score_adj` of 0 in my setup, so any of 
them can be picked. I
     reproduced the wedge with `kill -9` from the host; I did not catch the OOM 
killer choosing a
     lock-holding worker by itself.
   - It needs a worker to die at the moment it holds the lock, and with idle 
workers that is the one
     that is blocked reading the pipe. I tested only the `fork` start method.
   - `main` (4ca1f6693bb, 2026-10-11) still has the `SimpleQueue` pair and 
`input.get()`
     (`local_executor.py:170-171` and `:87`). Its `_check_workers()` 
(`:186-198`) now also fails the
     dead worker's task, but it does not touch the lock. I did not run `main`.
   - Related but different failures of the same queues: #67881 and #70526 (a 
full pipe blocks a
     write), #67870 (unbounded `join()` after a worker's `BrokenPipeError`). I 
did not find an
     existing issue about a dead lock holder.
   
   ### Are you willing to submit PR?
   
   - [ ] Yes I am willing to submit a PR!
   
   ### Code of Conduct
   
   - [x] I agree to follow this project's [Code of 
Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to