kaxil commented on code in PR #70690:
URL: https://github.com/apache/airflow/pull/70690#discussion_r3676511255
##########
providers/celery/src/airflow/providers/celery/executors/celery_executor_utils.py:
##########
@@ -487,6 +484,46 @@ def fetch_celery_task_state(async_result: AsyncResult) ->
tuple[str, str | Excep
return async_result.task_id, ExceptionWithTraceback(e,
exception_traceback), None
+def _get_state_fetch_mp_context() -> multiprocessing.context.BaseContext:
+ """
+ Return the ``multiprocessing`` context for the bulk state-fetch pool.
+
+ ``fork`` is unsafe here because the pool is created from the
multi-threaded scheduler
+ process: a worker can inherit a mutex held by a thread that ``fork()`` did
not copy, then
+ block forever acquiring it, which stalls the scheduling loop until the
scheduler is
+ restarted. ``forkserver``/``spawn`` workers start without the parent's
locks.
+
+ Honours ``[celery] mp_start_method`` (then ``[core] mp_start_method``) so
an operator who
+ has deliberately pinned a method keeps control, and falls back to
``forkserver`` then
+ ``spawn``.
+ """
+ available = multiprocessing.get_all_start_methods()
+ configured = None
+ if AIRFLOW_V_3_3_PLUS:
+ from airflow.utils.process_utils import resolve_mp_start_method
+
+ configured = resolve_mp_start_method("celery")
+
+ if configured:
+ if configured not in available:
+ log.warning(
+ "Configured mp_start_method=%r is not available on this
platform (available: %s); "
+ "falling back to a non-fork start method for the Celery
state-fetch pool.",
+ configured,
+ available,
+ )
+ else:
+ if configured == "fork":
+ log.warning(
+ "mp_start_method is set to 'fork' for the Celery
state-fetch pool. Forking the "
+ "multi-threaded scheduler can deadlock a worker on an
inherited lock and stall "
+ "scheduling; prefer 'forkserver' or 'spawn'."
+ )
+ return multiprocessing.get_context(configured)
+
+ return multiprocessing.get_context("forkserver" if "forkserver" in
available else "spawn")
Review Comment:
The forkserver context here has no preload set, and
`_get_many_using_multiprocessing` builds a fresh pool on every `sync()`.
Children are forked from a forkserver that never imported this module, so each
pool pays a full airflow + celery import in every worker, and unlike fork it
doesn't get cheaper on later pools. Measured with a stand-in module whose
import costs 0.8s, 7 workers, 26 items, new pool per call:
```
fork: sync1=26.2ms sync2=17.1ms sync3=9.9ms
forkserver: sync1=873.3ms sync2=838.4ms sync3=841.4ms
```
Calling `multiprocessing.set_forkserver_preload([...])` first turns that
into 901ms / 19ms / 16ms. That is what `[celery] mp_forkserver_preload` and its
`[core]` fallback exist for, and `set_component_mp_start_method` already
applies them, but building the context directly here skips that. `rpc://` and
`mongodb://` result backends are neither KV nor DB, so they land in this pool
on every heartbeat, which is the same per-pool startup cost the forkserver
column in your table rejects for publishing. Setting the preload, or hoisting
the pool out of the per-sync `with`, would avoid it.
##########
providers/celery/src/airflow/providers/celery/executors/celery_executor.py:
##########
@@ -241,22 +229,13 @@ def _send_workloads(self, workload_tuples_to_send:
Sequence[WorkloadInCelery]):
def _send_workloads_to_celery(self, workload_tuples_to_send:
Sequence[WorkloadInCelery]):
from airflow.providers.celery.executors.celery_executor_utils import
send_workload_to_executor
- if len(workload_tuples_to_send) == 1 or self._sync_parallelism == 1:
- # One tuple, or max one process -> send it in the main thread.
- return list(map(send_workload_to_executor,
workload_tuples_to_send))
-
- # Use chunks instead of a work queue to reduce context switching
- # since workloads are roughly uniform in size.
- chunksize =
self._num_workloads_per_send_process(len(workload_tuples_to_send))
- num_processes = min(len(workload_tuples_to_send),
self._sync_parallelism)
-
- # Use ProcessPoolExecutor with team_name instead of workload objects
to avoid pickling issues.
- # Subprocesses reconstruct the team-specific Celery app from the team
name and existing config.
- with ProcessPoolExecutor(max_workers=num_processes) as send_pool:
- key_and_async_results = list(
- send_pool.map(send_workload_to_executor,
workload_tuples_to_send, chunksize=chunksize)
- )
- return key_and_async_results
+ # Publish in this process rather than in a worker pool. Publishing a
workload is a single
+ # short broker round-trip, so a pool costs more to start than the
sends it parallelizes at
+ # any batch size the scheduler produces ([scheduler] max_tis_per_query
defaults to 16).
+ # Forking the pool from the multi-threaded scheduler was also a
deadlock risk: a worker
+ # could inherit a mutex held by a thread fork() did not copy and block
forever acquiring
+ # it, stalling the scheduling loop with no way to recover short of a
restart.
+ return list(map(send_workload_to_executor, workload_tuples_to_send))
Review Comment:
The benchmarks all assume a healthy broker. Each send arms a `[celery]
operation_timeout` alarm (default 1.0s), so a serial batch's worst case is
`len(batch) * 1.0s` on the scheduler loop, where the pool divided it by
`sync_parallelism` (batch of 32 over 11 processes was chunksize 3, so ~3s).
`trigger_tasks` passes the executor's open slots, so batch tracks `[core]
parallelism` (default 32), and goes well past that for anyone who raised
`max_tis_per_query`. 32s without a heartbeat is over `[scheduler]
scheduler_health_check_threshold` (30), which is the 503 this PR is fixing, and
`task_publish_max_retries` (default 3) puts the timed-out keys back for the
next heartbeat to stall on again. Worth bounding total publish time per
heartbeat, or at least calling out the tradeoff?
##########
providers/celery/src/airflow/providers/celery/executors/celery_executor_utils.py:
##########
@@ -487,6 +484,46 @@ def fetch_celery_task_state(async_result: AsyncResult) ->
tuple[str, str | Excep
return async_result.task_id, ExceptionWithTraceback(e,
exception_traceback), None
+def _get_state_fetch_mp_context() -> multiprocessing.context.BaseContext:
+ """
+ Return the ``multiprocessing`` context for the bulk state-fetch pool.
+
+ ``fork`` is unsafe here because the pool is created from the
multi-threaded scheduler
+ process: a worker can inherit a mutex held by a thread that ``fork()`` did
not copy, then
+ block forever acquiring it, which stalls the scheduling loop until the
scheduler is
+ restarted. ``forkserver``/``spawn`` workers start without the parent's
locks.
+
+ Honours ``[celery] mp_start_method`` (then ``[core] mp_start_method``) so
an operator who
+ has deliberately pinned a method keeps control, and falls back to
``forkserver`` then
+ ``spawn``.
+ """
+ available = multiprocessing.get_all_start_methods()
+ configured = None
+ if AIRFLOW_V_3_3_PLUS:
Review Comment:
The provider still declares `apache-airflow>=2.11.0`, so on 2.11 and 3.0 to
3.2 `configured` stays `None` and the pool is pinned to forkserver/spawn with
no way to ask for `fork` back. The `provider.yaml` change in this PR tells
operators `[celery] mp_start_method` selects this pool's start method, which
won't be true on those versions. `resolve_mp_start_method` is only
`conf.get(section, ...) or conf.get("core", ...)`, so inlining that lookup with
`fallback=None` would keep the escape hatch on every version the provider
supports.
##########
providers/celery/src/airflow/providers/celery/executors/celery_executor_utils.py:
##########
@@ -407,13 +408,9 @@ def send_workload_to_executor(
"""
Send workload to executor (serialized and executed as a Celery task).
- This function runs either inline in the long-lived scheduler process
(single-workload or
- sync_parallelism=1 path) or in short-lived ProcessPoolExecutor
subprocesses (multi-workload
- path). To avoid pickling issues with team-specific Celery apps, we pass
the team_name and
- create the app at call time. The cached app lives for the duration of the
caller process, so
- the main benefit is the scheduler-inline path where the cache persists
across publish cycles.
- In the ProcessPoolExecutor path, each subprocess is recreated per publish
batch and the cache
- only lasts for that single batch.
+ This runs inline in the long-lived caller process (the scheduler, when
publishing). The
Review Comment:
`_get_celery_app_for_workload`'s docstring a few functions up still has the
old framing ("may run either inline in the scheduler process or in a publisher
subprocess"). Worth updating alongside this one.
--
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]