kacpermuda commented on code in PR #74577:
URL: https://github.com/apache/airflow/pull/74577#discussion_r4443491857
##########
providers/openlineage/src/airflow/providers/openlineage/conf.py:
##########
@@ -163,6 +163,12 @@ def dag_state_change_process_pool_size() -> int:
return conf.getint(_CONFIG_SECTION, "dag_state_change_process_pool_size",
fallback="1")
+@cache
+def api_server_max_pending_events() -> int:
Review Comment:
`api_server_max_pending_events` isn't registered in
`providers/openlineage/provider.yaml`, so there's no
description/default/version_added and it won't show in the config reference.
It's also a new user-facing knob that isn't in the PR description. More
broadly, #74544 is about forks and leaked processes, not queue growth. Can we
drop the semaphore and this option and do the bounded queue in a separate PR
where the drop-vs-block tradeoff gets its own discussion?
##########
airflow-core/src/airflow/api_fastapi/app.py:
##########
@@ -100,6 +101,13 @@ def _initialize_api_server_stats() -> None:
@asynccontextmanager
async def lifespan(app: FastAPI):
_initialize_api_server_stats()
+ os.environ["_AIRFLOW_COMPONENT"] = "api_server"
+ from airflow.listeners.listener import get_listener_manager
+
+ try:
+ get_listener_manager().hook.on_starting(component=app)
Review Comment:
This is the first time the API server fires `on_starting`/`before_stopping`,
and that's for every listener plugin, not only OpenLineage. Third-party
listeners may assume these hooks only come from a `Job` (`job.py`) and touch
`component.job_type` and similar. Worth a note in the PR description, and maybe
a significant newsfragment? The inline `get_listener_manager` import also needs
a reason (circular import?) or should move to module level.
##########
providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py:
##########
@@ -1117,41 +1146,76 @@ def _fork_execute(self, callable, callable_name: str):
os._exit(0)
@property
- def executor(self) -> ProcessPoolExecutor:
+ def executor(self) -> Executor:
+ self._check_pid()
+ if self._is_api_server:
+ if not self._thread_executor:
+ with self._lock:
+ if not self._thread_executor:
+ # The API server uses ThreadPoolExecutor with
max_workers=1 to serialize
+ # access to the module-level cached OpenLineageAdapter
(_get_process_adapter)
+ # across manual DAG run and task instance state change
emissions.
+ # Multiple threads must not access the adapter
concurrently because
+ # OpenLineageAdapter.get_or_create_openlineage_client
is not thread-safe.
+ # Scheduler uses ProcessPoolExecutor where each child
process has its
+ # own memory space and isolated adapter.
+ self._thread_executor = ThreadPoolExecutor(
+ max_workers=1,
+ thread_name_prefix="openlineage-api-executor",
+ )
+ return self._thread_executor
+
if not self._executor:
- self._executor = ProcessPoolExecutor(
- max_workers=conf.dag_state_change_process_pool_size(),
- initializer=_executor_initializer,
- )
+ with self._lock:
+ if not self._executor:
+ self._executor = ProcessPoolExecutor(
+ max_workers=conf.dag_state_change_process_pool_size(),
+ initializer=_executor_initializer,
+ )
return self._executor
@hookimpl
def on_starting(self, component) -> None:
self.log.debug("on_starting: %s", component.__class__.__name__)
+ if component.__class__.__name__ == "FastAPI" or getattr(component,
"__module__", "").startswith(
+ "fastapi"
+ ):
+ self._is_api_server = True
@hookimpl
def before_stopping(self, component) -> None:
self.log.debug("before_stopping: %s", component.__class__.__name__)
- if self._executor is None:
+ self._check_pid()
+ with self._lock:
+ thread_executor, self._thread_executor = self._thread_executor,
None
+ self._semaphore = None
+ process_executor, self._executor = self._executor, None
+
+ if thread_executor is not None:
+ try:
+ thread_executor.shutdown(wait=False, cancel_futures=True)
Review Comment:
`cancel_futures=True` on graceful shutdown silently drops queued lineage
events, whereas the scheduler path waits up to 30s. That might be the right
tradeoff here, but could we log how many were cancelled (or at least say in the
docstring/PR that they're dropped)? Otherwise it's a silent data loss that
on-call can't see.
##########
providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py:
##########
@@ -1310,21 +1374,64 @@ def on_dag_run_failed(self, dag_run: DagRun, msg: str)
-> None:
except BaseException as e:
self.log.warning("OpenLineage received exception in method
on_dag_run_failed", exc_info=e)
+ def _get_semaphore(self) -> threading.BoundedSemaphore | None:
+ if not self._is_api_server:
+ return None
+ if self._semaphore is None:
+ with self._lock:
+ if self._semaphore is None:
+ max_pending = conf.api_server_max_pending_events()
+ if max_pending > 0:
+ # Capacity includes 1 actively running thread plus
queue buffer
+ self._semaphore =
threading.BoundedSemaphore(max_pending)
+ return self._semaphore
+
def submit_callable(self, callable, *args, **kwargs):
Review Comment:
`submit_callable` is now ~55 lines with a nested try/except BaseException
and two retry branches (thread / process) that diverge in side effects: only
the thread branch resets `_semaphore` and passes `cancel_futures=True`. Can we
pull the "recreate executor if still the one that failed" step into a small
helper so the two paths are visibly the same?
##########
airflow-core/src/airflow/api_fastapi/app.py:
##########
@@ -100,6 +101,13 @@ def _initialize_api_server_stats() -> None:
@asynccontextmanager
async def lifespan(app: FastAPI):
_initialize_api_server_stats()
+ os.environ["_AIRFLOW_COMPONENT"] = "api_server"
Review Comment:
`_AIRFLOW_COMPONENT` isn't read anywhere else in the repo, so this
introduces a new process-wide env var that child processes inherit, and it's
popped unconditionally on shutdown. The listener already learns it's in the API
server via `on_starting(component=app)` right below, so can we drop the env var
and rely on that single signal? If `on_starting` raises, we log and the
listener stays in scheduler mode, which is the fork risk, but the fix for that
is a more robust detection, not a second global.
##########
providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py:
##########
@@ -1117,41 +1146,76 @@ def _fork_execute(self, callable, callable_name: str):
os._exit(0)
@property
- def executor(self) -> ProcessPoolExecutor:
+ def executor(self) -> Executor:
+ self._check_pid()
+ if self._is_api_server:
+ if not self._thread_executor:
+ with self._lock:
+ if not self._thread_executor:
+ # The API server uses ThreadPoolExecutor with
max_workers=1 to serialize
+ # access to the module-level cached OpenLineageAdapter
(_get_process_adapter)
+ # across manual DAG run and task instance state change
emissions.
+ # Multiple threads must not access the adapter
concurrently because
+ # OpenLineageAdapter.get_or_create_openlineage_client
is not thread-safe.
+ # Scheduler uses ProcessPoolExecutor where each child
process has its
+ # own memory space and isolated adapter.
+ self._thread_executor = ThreadPoolExecutor(
+ max_workers=1,
+ thread_name_prefix="openlineage-api-executor",
+ )
+ return self._thread_executor
+
if not self._executor:
- self._executor = ProcessPoolExecutor(
- max_workers=conf.dag_state_change_process_pool_size(),
- initializer=_executor_initializer,
- )
+ with self._lock:
+ if not self._executor:
+ self._executor = ProcessPoolExecutor(
+ max_workers=conf.dag_state_change_process_pool_size(),
+ initializer=_executor_initializer,
+ )
return self._executor
@hookimpl
def on_starting(self, component) -> None:
self.log.debug("on_starting: %s", component.__class__.__name__)
+ if component.__class__.__name__ == "FastAPI" or getattr(component,
"__module__", "").startswith(
Review Comment:
Matching on class name / `__module__.startswith("fastapi")` is brittle: a
`FastAPI` subclass defined elsewhere matches via the name only by luck, and
anything else with a fastapi-module class gets misclassified. Can we use
`isinstance(component, FastAPI)` (lazy import) instead? Also `_is_api_server`
as a property+setter over `_is_api_server_flag` seems heavier than a plain
attribute.
##########
providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py:
##########
@@ -1310,21 +1374,64 @@ def on_dag_run_failed(self, dag_run: DagRun, msg: str)
-> None:
except BaseException as e:
self.log.warning("OpenLineage received exception in method
on_dag_run_failed", exc_info=e)
+ def _get_semaphore(self) -> threading.BoundedSemaphore | None:
+ if not self._is_api_server:
+ return None
+ if self._semaphore is None:
+ with self._lock:
+ if self._semaphore is None:
+ max_pending = conf.api_server_max_pending_events()
+ if max_pending > 0:
+ # Capacity includes 1 actively running thread plus
queue buffer
+ self._semaphore =
threading.BoundedSemaphore(max_pending)
+ return self._semaphore
+
def submit_callable(self, callable, *args, **kwargs):
+ sem = self._get_semaphore()
+ if sem is not None and not sem.acquire(blocking=False):
+ max_pending = conf.api_server_max_pending_events()
+ self.log.warning(
+ "OpenLineage API server event queue saturated (limit=%d);
dropping event to prevent memory growth under collector latency/outage.",
Review Comment:
This logs one WARNING per dropped event, so during a collector outage it
becomes a hot-loop log line, and it re-reads conf just to format it. If this
stays, can we log once per N drops or keep a counter? Also the line is over
length and the inline `from concurrent.futures import Future` on L1397 should
be a top-level import.
##########
providers/openlineage/tests/unit/openlineage/plugins/test_listener.py:
##########
@@ -3267,3 +3269,393 @@ def _failing_emit():
listener.log.warning.assert_called_once()
assert "on_running" in listener.log.warning.call_args.args
+
+
@mock.patch("airflow.providers.openlineage.plugins.listener.ThreadPoolExecutor")
Review Comment:
Both `uses_thread_pool` tests patch out both executors, so they only prove
wiring. Can we add one test that submits through the real `ThreadPoolExecutor`
and checks the event reaches a fake transport? That's the behaviour that
matters for #74544. Also the repeated `MagicMock` dag-run setup could be a
fixture.
##########
providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py:
##########
@@ -1310,21 +1374,64 @@ def on_dag_run_failed(self, dag_run: DagRun, msg: str)
-> None:
except BaseException as e:
self.log.warning("OpenLineage received exception in method
on_dag_run_failed", exc_info=e)
+ def _get_semaphore(self) -> threading.BoundedSemaphore | None:
+ if not self._is_api_server:
+ return None
+ if self._semaphore is None:
+ with self._lock:
+ if self._semaphore is None:
+ max_pending = conf.api_server_max_pending_events()
+ if max_pending > 0:
+ # Capacity includes 1 actively running thread plus
queue buffer
Review Comment:
Capacity is tied to the semaphore object, but a retry in `submit_callable`
sets `self._semaphore = None` and a new one is built. In-flight futures release
the old semaphore while new submissions acquire the new one, so the effective
bound can briefly be up to 2x the limit. The named cap doesn't actually enforce
what it says across a recreate.
##########
providers/openlineage/src/airflow/providers/openlineage/plugins/listener.py:
##########
@@ -166,11 +167,40 @@ class OpenLineageListener:
"""OpenLineage listener sends events on task instance and dag run starts,
completes and failures."""
def __init__(self):
- self._executor = None
+ self._executor: ProcessPoolExecutor | None = None
+ self._thread_executor: ThreadPoolExecutor | None = None
+ self._semaphore: threading.BoundedSemaphore | None = None
+ self._is_api_server_flag: bool = False
+ self._pid: int = os.getpid()
+ self._lock = threading.Lock()
self.log = logging.getLogger(__name__)
self.extractor_manager = ExtractorManager()
self.adapter = OpenLineageAdapter()
+ @property
+ def _is_api_server(self) -> bool:
+ return self._is_api_server_flag or
os.environ.get("_AIRFLOW_COMPONENT") == "api_server"
+
+ @_is_api_server.setter
+ def _is_api_server(self, value: bool) -> None:
+ self._is_api_server_flag = value
+
+ def _check_pid(self) -> None:
+ """Reset executors if the process was forked (e.g. Gunicorn master to
worker)."""
+ current_pid = os.getpid()
+ if self._pid != current_pid:
+ # Fork occurred. The inherited lock might be held by a thread that
no longer exists in this child.
+ # Replace it with a fresh lock using double-checked locking to
avoid races between child threads.
+ new_lock = threading.Lock()
Review Comment:
`new_lock` is local to each calling thread, so `with new_lock:` serializes
nothing. Two threads hitting the first post-fork call both pass the `_pid`
check and both reset, and the second can wipe an executor the first just
created. Can we use `os.register_at_fork(after_in_child=...)` to reset state
once in the child, before any thread exists? That also removes `_check_pid()`
from the hot path of `executor` and `before_stopping`.
--
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]