dheerajturaga opened a new pull request, #70690:
URL: https://github.com/apache/airflow/pull/70690

   `CeleryExecutor` could stop scheduling indefinitely, with `/health` 
returning 503
   until the scheduler was restarted manually.
   
   Publishing workloads forked a `ProcessPoolExecutor` from the scheduler, 
which is
   multi-threaded. `fork()` copies the state of every mutex but only the calling
   thread, so a worker could inherit a lock held by a thread that was never 
copied
   and block forever acquiring it. The parent then waited on that worker 
forever,
   the scheduling loop stopped heartbeating, and the job never recovered on its 
own.
   
   `py-spy dump` of a wedged scheduler:
   
   ```
   Thread (idle): "MainThread"
       __enter__ (multiprocessing/synchronize.py:95)      <-- 
SemLock.acquire(), never returns
       get (multiprocessing/queues.py:100)                <-- Queue.get(): 
"with self._rlock:"
       _process_worker (concurrent/futures/process.py:242)
       ...
       _send_workloads_to_celery (celery_executor.py:256)
       _send_workloads (celery_executor.py:203)
       trigger_tasks (base_executor.py:406)
       heartbeat (base_executor.py:311)
       _run_scheduler_loop (scheduler_job_runner.py:1693)
   ```
   
   CPython already warns about this from the same code path — running the 
existing
   (quarantined) `test_send_workloads_to_celery_hang` on `main` emits
   `This process is multi-threaded, use of fork() may lead to deadlocks in the 
child`.
   
   The existing `OPERATION_TIMEOUT` guard cannot catch it. That `timeout()` 
lives
   *inside* `send_workload_to_executor`, but the deadlock happens earlier, in 
the
   pool worker's bootstrap (`call_queue.get()`), before the target function is
   entered. A `SIGALRM` also cannot interrupt a process blocked in a C-level
   semaphore wait.
   
   ### Why remove the pool instead of changing the start method
   
   The obvious fix is to pass a non-`fork` `mp_context`. Measured against a live
   Redis broker (4 workers warm, `sync_parallelism` auto = 11), that is much 
worse
   than the status quo, because the pool is rebuilt on every publish:
   
   | workloads | inline | fork pool | forkserver pool | persistent fork |
   | --- | --- | --- | --- | --- |
   | 5 | **3.2ms** | 51.2ms | 392.7ms | 3.6ms |
   | 13 | **9.0ms** | 62.8ms | 2899ms | 6.4ms |
   | 26 | **15.6ms** | 87.0ms | **24433ms** | 8.7ms |
   | 128 | **72.4ms** | 186.4ms | 25981ms | 57.6ms |
   | 512 | 364ms | 280ms | 42443ms | **152ms** |
   
   Publishing a workload is a single short broker round-trip (~0.6ms), so the 
pool
   costs more to start than the sends it parallelizes. Inline beats the fork 
pool at
   every size up to 512 — and `[scheduler] max_tis_per_query` defaults to 16, so
   real publish batches are far below where the pool could ever win. A per-batch
   `forkserver`/`spawn` pool would turn an intermittent hang into a guaranteed
   multi-second stall on every publish.
   
   So publishing now happens in the scheduler process. That removes the hazard
   rather than making the fork safe, and deletes the pool lifecycle, chunk 
sizing,
   and `BrokenProcessPool` handling that a persistent pool would have required.
   
   `[celery] sync_parallelism` now applies only to fetching task state, which is
   what it documents ("How many processes CeleryExecutor uses to sync task 
state").
   Deployments that set it to `1` to work around this hang can drop that 
override.
   
   ### Bulk state fetch
   
   `BulkStateFetcher._get_many_using_multiprocessing` has the identical fork 
hazard
   but genuinely parallelizes blocking network reads, so it keeps its pool and 
asks
   for a non-`fork` start method instead. It honours `[celery] mp_start_method` 
then
   `[core] mp_start_method` so an operator who has deliberately pinned one keeps
   control; an explicit `fork` is respected with a warning rather than 
overridden.
   Unset, it selects `forkserver`, else `spawn`.
   
   Note this path is only reached when `[celery] result_backend` is neither a
   key-value store nor a database — Redis and DB backends route to
   `_get_many_from_kv_backend` / `_get_many_from_db_backend` and never fork.
   
   ### Tests
   
   `test_send_workloads_to_celery_hang` was quarantined *and* failing on 
`main`: its
   fixture passed a workload object in the tuple's `team_name` slot, so
   `send_workload_to_executor` raised `AttributeError: 'MockWorkload' object 
has no
   attribute 'upper'`. In the forked pool that crash happened out of sight, so 
the
   test was quarantined rather than diagnosed. The fixture is fixed and the test
   un-quarantined, since publishing no longer forks.
   
   Added: publishing never starts a worker process (patched `os.fork` and
   `ProcessPoolExecutor.__init__` both assert-fail), the state-fetch context
   resolution chain including `[celery]` precedence over `[core]` and both 
fallback
   warnings, and that `BulkStateFetcher` passes a non-`fork` context. Each new 
test
   fails without the source change.
   
   related: #8854 (a bulk publish API would remove this pool entirely)
   related: #70163 (adjacent CeleryExecutor hang, different cause)
   
   ---
   
   ##### Was generative AI tooling used to co-author this PR?
   
   - [X] Yes — Claude Code (Opus 5)
   
   Generated-by: Claude Code (Opus 5) following [the 
guidelines](https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions)
   


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