Skip to content

OpenLineage: add execute_in_thread to emit task events without forking - #68708

Merged
mobuchowski merged 7 commits into
apache:mainfrom
mobuchowski:openlineage-execute-in-thread
Jul 22, 2026
Merged

OpenLineage: add execute_in_thread to emit task events without forking#68708
mobuchowski merged 7 commits into
apache:mainfrom
mobuchowski:openlineage-execute-in-thread

Conversation

@mobuchowski

@mobuchowskimobuchowski commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Why

By default the OpenLineage listener emits each task-level event from a forked child process (os.fork() with no exec). That child inherits the task runner's connection to the Airflow supervisor; if there are issues with that connection - it can hang.

There were a bunch of issues with that fixed in 3.2.x release - like #66574#65714#66573#67115#66572 - but we still support earlier ones like 3.1.x line.

What

Add an opt-in [openlineage] execute_in_thread option - by default False.

When enabled, task-level emission runs in a time-bounded daemon thread instead of forking: nothing is inherited, so a blocked emission can never strand the task, and the task runner waits at most [openlineage] execution_timeout for emission before proceeding.

Metadata extraction still runs in-process with full access to the task runtime, so Operators whose extractors resolve Connections, Variables or XComs keep working.

The default (fork) path is unchanged.

Verified on AWS MWAA and custom GKE Airflow environment.

Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: [Claude Code] following the guidelines

@kacpermudakacpermuda left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR adds a well-motivated opt-in thread-based emission path that cleanly solves the supervisor-connection hang for most cases, and the default fork path is unchanged. Two nits (see inline) document genuine semantic differences between fork and thread isolation that slightly qualify the "can never block" guarantee in the docstring — but neither causes a runtime failure under normal conditions.

Comment threadproviders/openlineage/src/airflow/providers/openlineage/plugins/listener.py Outdated
@kacpermuda

Copy link
Copy Markdown
Collaborator

Can we add some small paragraph about this in providers/openlineage/docs/troubleshooting.rst ? Can be helpful if we ever hit that again, to know what config to use. Also, let's clearly mention it there that it was observed on AF3.1, and may not be the case on other airflow versions since 3.2 has applied some fixes already

@kacpermudakacpermuda left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left few nit comments, looks solid otherwise - clean opt-in allowing a different execution mode, that can prevent some rare bug.

@abhipalsingh

Copy link
Copy Markdown

Adding a data point from the API server side, since this fork behavior isn't only a scheduler concern.

On Airflow 3.2.1 with apache-airflow-providers-openlineage==2.14.0 (pre-#65677), manual task-instance state changes via the REST
API (mark success/failed/skipped, clear) fire on_task_instance_* on the api-server — a multithreaded async process
(FastAPI/uvicorn under gunicorn). The listener's _fork_execute calls os.fork() from that multithreaded worker; a fraction of
children deadlock immediately on an inherited lock (py-spy showed them parked in futex_wait_queue, never reaching the post-fork setproctitle) and are never reaped → ~350–400 MB each → unbounded accumulation → api-server OOM.

#65677 helps the manual-state-change path (routes it through the ProcessPoolExecutor instead of a raw fork), but (a) that still forks a pool from the multithreaded async server, and (b) the natural-lifecycle handlers still use use_fork=True. So thread-based emission (this issue) is the cleaner fit for async contexts like the api-server, where os.fork() is fundamentally
unsafe.

We worked around it by disabling OpenLineage on the api-server (no transport configured there anyway), but big +1 for
execute_in_thread as the general fix.

@mobuchowski

Copy link
Copy Markdown
ContributorAuthor

@abhipalsingh we should not run any extraction on API server, so I think fix should be separate - to not fork anyway; but do it not behind the configuration flag.

I think it should be closer to what we do in the scheduler with ProcessPoolExecutor maybe.

By default the OpenLineage listener emits each task-level event from a
forked child process (os.fork() with no exec). That child inherits the
task runner's connection to the Airflow supervisor; if the child's event
emission blocks (e.g. a slow or unreachable lineage backend), the
inherited connection can be left in a state that prevents the task from
being marked complete, leaving it stuck in the `running` state.
Add an opt-in `[openlineage] execute_in_thread` option (default False).
When enabled, task-level emission runs in a time-bounded daemon thread
instead of forking: nothing is inherited, so a blocked emission can never
strand the task, and the task runner waits at most
`[openlineage] execution_timeout` for emission before proceeding.
Metadata extraction still runs in-process with full access to the task
runtime, so Operators whose extractors resolve Connections, Variables or
XComs keep working.
The default (fork) path is unchanged.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
…nfig
The update-providers-build-files prek hook regenerates get_provider_info.py
from provider.yaml. Adding execute_in_thread to provider.yaml requires the
generated file to be updated in sync.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
…avior
The thread path also reaches the Airflow supervisor during in-process
metadata extraction, through the shared SUPERVISOR_COMMS threading lock.
That lock prevents the byte interleaving that corrupted the protocol under
fork, but it does mean the task runner can briefly wait on the lock while an
abandoned emission thread finishes a round trip. Reword the config docs,
docstring, and inline comment to describe this accurately instead of
claiming emission can "never block" the task runner.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
The update-providers-build-files generator preserves provider.yaml option
order (execution_timeout then execute_in_thread), so place the generated
entry accordingly to keep the file in sync with the generator output.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
@mobuchowski
mobuchowskiforce-pushed the openlineage-execute-in-thread branch from 8cdb236 to dcfdbdfCompareJune 30, 2026 18:40
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
@uranusjr

Copy link
Copy Markdown
Member

Hmm, for OL context we need get_template_context() and extract_metadata(), which use Comms with a threading lock. This has been fine since forking makes the locking per-process. But with threading, the lock would apply throughout all the threads, making it much easier to deadlock and kill the entire process.

Maybe it would be worthwhile to add some sort of timeout mechanism so the threads can go away if they live for too long? This would also help with the OOM issue mentioned above.

Also, as Kacper mentioned, a doc addition would be nice.

@mobuchowski

Copy link
Copy Markdown
ContributorAuthor

@uranusjr there is already a timeout mechanism with

 thread.join(timeout=conf.execution_timeout())

But there's basically no good way to force it to kill the thread.

We can hold the lock, but not sure if that would actually cause a deadlock? After starting a thread we're basically waiting for it - the task is not running until it finishes, the thread serves just as a separation boundary, and as timeout mechanism - as was originally intended couple years ago, when it wasn't even a provider yet :)

https://github.com/OpenLineage/OpenLineage/pull/508/changes#diff-7f2edd8384d0bf82cd3ea7f1f7c421f0db3e5c64680aed676ffb0253fc289eccR52

However, the original reasons for having the timeout in the first place are not really true anymore with Airflow 3...

The options I'd think of is:

  • if we really don't want to have two threads potentially using comms, let's just thread off the event emission part - later in the OL code
  • just run synchronously in the main thread

@uranusjr

Copy link
Copy Markdown
Member

I don’t really have a concrete case in mind where deadlock would happen, but more like wondering in general the locking strategy could be problematic. Since there’s already a timeout to prevent things from breaking too badly, I think it’s reasonable to just do this for now and see what happens.

@mobuchowski

Copy link
Copy Markdown
ContributorAuthor

@uranusjr to add to it; it's an additional mechanism, explicitly opt-in right now. Also, could you approve?

@pawel-big-lebowski

Copy link
Copy Markdown

@uranusjr I went through the comments, and it looks like all review feedback has been addressed. If that's the case, would you mind approving and merging the PR? Thanks!

@uranusjruranusjr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could add a section in troubleshooting as mentioned in #68708 (comment)

Otherwise this looks good to me.

mobuchowskiand others added 2 commits July 22, 2026 11:49
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

@mobuchowski@kacpermuda@abhipalsingh@uranusjr@pawel-big-lebowski
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
OpenLineage: add execute_in_thread to emit task events without forking by mobuchowski · Pull Request #68708 · apache/airflow · GitHub
Skip to content

OpenLineage: add execute_in_thread to emit task events without forking - #68708

Merged
mobuchowski merged 7 commits into
apache:mainfrom
mobuchowski:openlineage-execute-in-thread
Jul 22, 2026
Merged

OpenLineage: add execute_in_thread to emit task events without forking#68708
mobuchowski merged 7 commits into
apache:mainfrom
mobuchowski:openlineage-execute-in-thread

Conversation

@mobuchowski

@mobuchowskimobuchowski commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Why

By default the OpenLineage listener emits each task-level event from a forked child process (os.fork() with no exec). That child inherits the task runner's connection to the Airflow supervisor; if there are issues with that connection - it can hang.

There were a bunch of issues with that fixed in 3.2.x release - like #66574#65714#66573#67115#66572 - but we still support earlier ones like 3.1.x line.

What

Add an opt-in [openlineage] execute_in_thread option - by default False.

When enabled, task-level emission runs in a time-bounded daemon thread instead of forking: nothing is inherited, so a blocked emission can never strand the task, and the task runner waits at most [openlineage] execution_timeout for emission before proceeding.

Metadata extraction still runs in-process with full access to the task runtime, so Operators whose extractors resolve Connections, Variables or XComs keep working.

The default (fork) path is unchanged.

Verified on AWS MWAA and custom GKE Airflow environment.

Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: [Claude Code] following the guidelines

@kacpermudakacpermuda left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR adds a well-motivated opt-in thread-based emission path that cleanly solves the supervisor-connection hang for most cases, and the default fork path is unchanged. Two nits (see inline) document genuine semantic differences between fork and thread isolation that slightly qualify the "can never block" guarantee in the docstring — but neither causes a runtime failure under normal conditions.

Comment threadproviders/openlineage/src/airflow/providers/openlineage/plugins/listener.py Outdated
@kacpermuda

Copy link
Copy Markdown
Collaborator

Can we add some small paragraph about this in providers/openlineage/docs/troubleshooting.rst ? Can be helpful if we ever hit that again, to know what config to use. Also, let's clearly mention it there that it was observed on AF3.1, and may not be the case on other airflow versions since 3.2 has applied some fixes already

@kacpermudakacpermuda left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left few nit comments, looks solid otherwise - clean opt-in allowing a different execution mode, that can prevent some rare bug.

@abhipalsingh

Copy link
Copy Markdown

Adding a data point from the API server side, since this fork behavior isn't only a scheduler concern.

On Airflow 3.2.1 with apache-airflow-providers-openlineage==2.14.0 (pre-#65677), manual task-instance state changes via the REST
API (mark success/failed/skipped, clear) fire on_task_instance_* on the api-server — a multithreaded async process
(FastAPI/uvicorn under gunicorn). The listener's _fork_execute calls os.fork() from that multithreaded worker; a fraction of
children deadlock immediately on an inherited lock (py-spy showed them parked in futex_wait_queue, never reaching the post-fork setproctitle) and are never reaped → ~350–400 MB each → unbounded accumulation → api-server OOM.

#65677 helps the manual-state-change path (routes it through the ProcessPoolExecutor instead of a raw fork), but (a) that still forks a pool from the multithreaded async server, and (b) the natural-lifecycle handlers still use use_fork=True. So thread-based emission (this issue) is the cleaner fit for async contexts like the api-server, where os.fork() is fundamentally
unsafe.

We worked around it by disabling OpenLineage on the api-server (no transport configured there anyway), but big +1 for
execute_in_thread as the general fix.

@mobuchowski

Copy link
Copy Markdown
ContributorAuthor

@abhipalsingh we should not run any extraction on API server, so I think fix should be separate - to not fork anyway; but do it not behind the configuration flag.

I think it should be closer to what we do in the scheduler with ProcessPoolExecutor maybe.

By default the OpenLineage listener emits each task-level event from a
forked child process (os.fork() with no exec). That child inherits the
task runner's connection to the Airflow supervisor; if the child's event
emission blocks (e.g. a slow or unreachable lineage backend), the
inherited connection can be left in a state that prevents the task from
being marked complete, leaving it stuck in the `running` state.
Add an opt-in `[openlineage] execute_in_thread` option (default False).
When enabled, task-level emission runs in a time-bounded daemon thread
instead of forking: nothing is inherited, so a blocked emission can never
strand the task, and the task runner waits at most
`[openlineage] execution_timeout` for emission before proceeding.
Metadata extraction still runs in-process with full access to the task
runtime, so Operators whose extractors resolve Connections, Variables or
XComs keep working.
The default (fork) path is unchanged.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
…nfig
The update-providers-build-files prek hook regenerates get_provider_info.py
from provider.yaml. Adding execute_in_thread to provider.yaml requires the
generated file to be updated in sync.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
…avior
The thread path also reaches the Airflow supervisor during in-process
metadata extraction, through the shared SUPERVISOR_COMMS threading lock.
That lock prevents the byte interleaving that corrupted the protocol under
fork, but it does mean the task runner can briefly wait on the lock while an
abandoned emission thread finishes a round trip. Reword the config docs,
docstring, and inline comment to describe this accurately instead of
claiming emission can "never block" the task runner.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
The update-providers-build-files generator preserves provider.yaml option
order (execution_timeout then execute_in_thread), so place the generated
entry accordingly to keep the file in sync with the generator output.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
@mobuchowski
mobuchowskiforce-pushed the openlineage-execute-in-thread branch from 8cdb236 to dcfdbdfCompareJune 30, 2026 18:40
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
@uranusjr

Copy link
Copy Markdown
Member

Hmm, for OL context we need get_template_context() and extract_metadata(), which use Comms with a threading lock. This has been fine since forking makes the locking per-process. But with threading, the lock would apply throughout all the threads, making it much easier to deadlock and kill the entire process.

Maybe it would be worthwhile to add some sort of timeout mechanism so the threads can go away if they live for too long? This would also help with the OOM issue mentioned above.

Also, as Kacper mentioned, a doc addition would be nice.

@mobuchowski

Copy link
Copy Markdown
ContributorAuthor

@uranusjr there is already a timeout mechanism with

 thread.join(timeout=conf.execution_timeout())

But there's basically no good way to force it to kill the thread.

We can hold the lock, but not sure if that would actually cause a deadlock? After starting a thread we're basically waiting for it - the task is not running until it finishes, the thread serves just as a separation boundary, and as timeout mechanism - as was originally intended couple years ago, when it wasn't even a provider yet :)

https://github.com/OpenLineage/OpenLineage/pull/508/changes#diff-7f2edd8384d0bf82cd3ea7f1f7c421f0db3e5c64680aed676ffb0253fc289eccR52

However, the original reasons for having the timeout in the first place are not really true anymore with Airflow 3...

The options I'd think of is:

  • if we really don't want to have two threads potentially using comms, let's just thread off the event emission part - later in the OL code
  • just run synchronously in the main thread

@uranusjr

Copy link
Copy Markdown
Member

I don’t really have a concrete case in mind where deadlock would happen, but more like wondering in general the locking strategy could be problematic. Since there’s already a timeout to prevent things from breaking too badly, I think it’s reasonable to just do this for now and see what happens.

@mobuchowski

Copy link
Copy Markdown
ContributorAuthor

@uranusjr to add to it; it's an additional mechanism, explicitly opt-in right now. Also, could you approve?

@pawel-big-lebowski

Copy link
Copy Markdown

@uranusjr I went through the comments, and it looks like all review feedback has been addressed. If that's the case, would you mind approving and merging the PR? Thanks!

@uranusjruranusjr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could add a section in troubleshooting as mentioned in #68708 (comment)

Otherwise this looks good to me.

mobuchowskiand others added 2 commits July 22, 2026 11:49
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

@mobuchowski@kacpermuda@abhipalsingh@uranusjr@pawel-big-lebowski
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' OpenLineage: add execute_in_thread to emit task events without forking by mobuchowski · Pull Request #68708 · apache/airflow · GitHub
Skip to content

OpenLineage: add execute_in_thread to emit task events without forking - #68708

Merged
mobuchowski merged 7 commits into
apache:mainfrom
mobuchowski:openlineage-execute-in-thread
Jul 22, 2026
Merged

OpenLineage: add execute_in_thread to emit task events without forking#68708
mobuchowski merged 7 commits into
apache:mainfrom
mobuchowski:openlineage-execute-in-thread

Conversation

@mobuchowski

@mobuchowskimobuchowski commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Why

By default the OpenLineage listener emits each task-level event from a forked child process (os.fork() with no exec). That child inherits the task runner's connection to the Airflow supervisor; if there are issues with that connection - it can hang.

There were a bunch of issues with that fixed in 3.2.x release - like #66574#65714#66573#67115#66572 - but we still support earlier ones like 3.1.x line.

What

Add an opt-in [openlineage] execute_in_thread option - by default False.

When enabled, task-level emission runs in a time-bounded daemon thread instead of forking: nothing is inherited, so a blocked emission can never strand the task, and the task runner waits at most [openlineage] execution_timeout for emission before proceeding.

Metadata extraction still runs in-process with full access to the task runtime, so Operators whose extractors resolve Connections, Variables or XComs keep working.

The default (fork) path is unchanged.

Verified on AWS MWAA and custom GKE Airflow environment.

Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: [Claude Code] following the guidelines

@kacpermudakacpermuda left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR adds a well-motivated opt-in thread-based emission path that cleanly solves the supervisor-connection hang for most cases, and the default fork path is unchanged. Two nits (see inline) document genuine semantic differences between fork and thread isolation that slightly qualify the "can never block" guarantee in the docstring — but neither causes a runtime failure under normal conditions.

Comment threadproviders/openlineage/src/airflow/providers/openlineage/plugins/listener.py Outdated
@kacpermuda

Copy link
Copy Markdown
Collaborator

Can we add some small paragraph about this in providers/openlineage/docs/troubleshooting.rst ? Can be helpful if we ever hit that again, to know what config to use. Also, let's clearly mention it there that it was observed on AF3.1, and may not be the case on other airflow versions since 3.2 has applied some fixes already

@kacpermudakacpermuda left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left few nit comments, looks solid otherwise - clean opt-in allowing a different execution mode, that can prevent some rare bug.

@abhipalsingh

Copy link
Copy Markdown

Adding a data point from the API server side, since this fork behavior isn't only a scheduler concern.

On Airflow 3.2.1 with apache-airflow-providers-openlineage==2.14.0 (pre-#65677), manual task-instance state changes via the REST
API (mark success/failed/skipped, clear) fire on_task_instance_* on the api-server — a multithreaded async process
(FastAPI/uvicorn under gunicorn). The listener's _fork_execute calls os.fork() from that multithreaded worker; a fraction of
children deadlock immediately on an inherited lock (py-spy showed them parked in futex_wait_queue, never reaching the post-fork setproctitle) and are never reaped → ~350–400 MB each → unbounded accumulation → api-server OOM.

#65677 helps the manual-state-change path (routes it through the ProcessPoolExecutor instead of a raw fork), but (a) that still forks a pool from the multithreaded async server, and (b) the natural-lifecycle handlers still use use_fork=True. So thread-based emission (this issue) is the cleaner fit for async contexts like the api-server, where os.fork() is fundamentally
unsafe.

We worked around it by disabling OpenLineage on the api-server (no transport configured there anyway), but big +1 for
execute_in_thread as the general fix.

@mobuchowski

Copy link
Copy Markdown
ContributorAuthor

@abhipalsingh we should not run any extraction on API server, so I think fix should be separate - to not fork anyway; but do it not behind the configuration flag.

I think it should be closer to what we do in the scheduler with ProcessPoolExecutor maybe.

By default the OpenLineage listener emits each task-level event from a
forked child process (os.fork() with no exec). That child inherits the
task runner's connection to the Airflow supervisor; if the child's event
emission blocks (e.g. a slow or unreachable lineage backend), the
inherited connection can be left in a state that prevents the task from
being marked complete, leaving it stuck in the `running` state.
Add an opt-in `[openlineage] execute_in_thread` option (default False).
When enabled, task-level emission runs in a time-bounded daemon thread
instead of forking: nothing is inherited, so a blocked emission can never
strand the task, and the task runner waits at most
`[openlineage] execution_timeout` for emission before proceeding.
Metadata extraction still runs in-process with full access to the task
runtime, so Operators whose extractors resolve Connections, Variables or
XComs keep working.
The default (fork) path is unchanged.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
…nfig
The update-providers-build-files prek hook regenerates get_provider_info.py
from provider.yaml. Adding execute_in_thread to provider.yaml requires the
generated file to be updated in sync.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
…avior
The thread path also reaches the Airflow supervisor during in-process
metadata extraction, through the shared SUPERVISOR_COMMS threading lock.
That lock prevents the byte interleaving that corrupted the protocol under
fork, but it does mean the task runner can briefly wait on the lock while an
abandoned emission thread finishes a round trip. Reword the config docs,
docstring, and inline comment to describe this accurately instead of
claiming emission can "never block" the task runner.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
The update-providers-build-files generator preserves provider.yaml option
order (execution_timeout then execute_in_thread), so place the generated
entry accordingly to keep the file in sync with the generator output.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
@mobuchowski
mobuchowskiforce-pushed the openlineage-execute-in-thread branch from 8cdb236 to dcfdbdfCompareJune 30, 2026 18:40
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
@uranusjr

Copy link
Copy Markdown
Member

Hmm, for OL context we need get_template_context() and extract_metadata(), which use Comms with a threading lock. This has been fine since forking makes the locking per-process. But with threading, the lock would apply throughout all the threads, making it much easier to deadlock and kill the entire process.

Maybe it would be worthwhile to add some sort of timeout mechanism so the threads can go away if they live for too long? This would also help with the OOM issue mentioned above.

Also, as Kacper mentioned, a doc addition would be nice.

@mobuchowski

Copy link
Copy Markdown
ContributorAuthor

@uranusjr there is already a timeout mechanism with

 thread.join(timeout=conf.execution_timeout())

But there's basically no good way to force it to kill the thread.

We can hold the lock, but not sure if that would actually cause a deadlock? After starting a thread we're basically waiting for it - the task is not running until it finishes, the thread serves just as a separation boundary, and as timeout mechanism - as was originally intended couple years ago, when it wasn't even a provider yet :)

https://github.com/OpenLineage/OpenLineage/pull/508/changes#diff-7f2edd8384d0bf82cd3ea7f1f7c421f0db3e5c64680aed676ffb0253fc289eccR52

However, the original reasons for having the timeout in the first place are not really true anymore with Airflow 3...

The options I'd think of is:

  • if we really don't want to have two threads potentially using comms, let's just thread off the event emission part - later in the OL code
  • just run synchronously in the main thread

@uranusjr

Copy link
Copy Markdown
Member

I don’t really have a concrete case in mind where deadlock would happen, but more like wondering in general the locking strategy could be problematic. Since there’s already a timeout to prevent things from breaking too badly, I think it’s reasonable to just do this for now and see what happens.

@mobuchowski

Copy link
Copy Markdown
ContributorAuthor

@uranusjr to add to it; it's an additional mechanism, explicitly opt-in right now. Also, could you approve?

@pawel-big-lebowski

Copy link
Copy Markdown

@uranusjr I went through the comments, and it looks like all review feedback has been addressed. If that's the case, would you mind approving and merging the PR? Thanks!

@uranusjruranusjr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could add a section in troubleshooting as mentioned in #68708 (comment)

Otherwise this looks good to me.

mobuchowskiand others added 2 commits July 22, 2026 11:49
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

@mobuchowski@kacpermuda@abhipalsingh@uranusjr@pawel-big-lebowski
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' OpenLineage: add execute_in_thread to emit task events without forking by mobuchowski · Pull Request #68708 · apache/airflow · GitHub
Skip to content

OpenLineage: add execute_in_thread to emit task events without forking - #68708

Merged
mobuchowski merged 7 commits into
apache:mainfrom
mobuchowski:openlineage-execute-in-thread
Jul 22, 2026
Merged

OpenLineage: add execute_in_thread to emit task events without forking#68708
mobuchowski merged 7 commits into
apache:mainfrom
mobuchowski:openlineage-execute-in-thread

Conversation

@mobuchowski

@mobuchowskimobuchowski commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Why

By default the OpenLineage listener emits each task-level event from a forked child process (os.fork() with no exec). That child inherits the task runner's connection to the Airflow supervisor; if there are issues with that connection - it can hang.

There were a bunch of issues with that fixed in 3.2.x release - like #66574#65714#66573#67115#66572 - but we still support earlier ones like 3.1.x line.

What

Add an opt-in [openlineage] execute_in_thread option - by default False.

When enabled, task-level emission runs in a time-bounded daemon thread instead of forking: nothing is inherited, so a blocked emission can never strand the task, and the task runner waits at most [openlineage] execution_timeout for emission before proceeding.

Metadata extraction still runs in-process with full access to the task runtime, so Operators whose extractors resolve Connections, Variables or XComs keep working.

The default (fork) path is unchanged.

Verified on AWS MWAA and custom GKE Airflow environment.

Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: [Claude Code] following the guidelines

@kacpermudakacpermuda left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR adds a well-motivated opt-in thread-based emission path that cleanly solves the supervisor-connection hang for most cases, and the default fork path is unchanged. Two nits (see inline) document genuine semantic differences between fork and thread isolation that slightly qualify the "can never block" guarantee in the docstring — but neither causes a runtime failure under normal conditions.

Comment threadproviders/openlineage/src/airflow/providers/openlineage/plugins/listener.py Outdated
@kacpermuda

Copy link
Copy Markdown
Collaborator

Can we add some small paragraph about this in providers/openlineage/docs/troubleshooting.rst ? Can be helpful if we ever hit that again, to know what config to use. Also, let's clearly mention it there that it was observed on AF3.1, and may not be the case on other airflow versions since 3.2 has applied some fixes already

@kacpermudakacpermuda left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left few nit comments, looks solid otherwise - clean opt-in allowing a different execution mode, that can prevent some rare bug.

@abhipalsingh

Copy link
Copy Markdown

Adding a data point from the API server side, since this fork behavior isn't only a scheduler concern.

On Airflow 3.2.1 with apache-airflow-providers-openlineage==2.14.0 (pre-#65677), manual task-instance state changes via the REST
API (mark success/failed/skipped, clear) fire on_task_instance_* on the api-server — a multithreaded async process
(FastAPI/uvicorn under gunicorn). The listener's _fork_execute calls os.fork() from that multithreaded worker; a fraction of
children deadlock immediately on an inherited lock (py-spy showed them parked in futex_wait_queue, never reaching the post-fork setproctitle) and are never reaped → ~350–400 MB each → unbounded accumulation → api-server OOM.

#65677 helps the manual-state-change path (routes it through the ProcessPoolExecutor instead of a raw fork), but (a) that still forks a pool from the multithreaded async server, and (b) the natural-lifecycle handlers still use use_fork=True. So thread-based emission (this issue) is the cleaner fit for async contexts like the api-server, where os.fork() is fundamentally
unsafe.

We worked around it by disabling OpenLineage on the api-server (no transport configured there anyway), but big +1 for
execute_in_thread as the general fix.

@mobuchowski

Copy link
Copy Markdown
ContributorAuthor

@abhipalsingh we should not run any extraction on API server, so I think fix should be separate - to not fork anyway; but do it not behind the configuration flag.

I think it should be closer to what we do in the scheduler with ProcessPoolExecutor maybe.

By default the OpenLineage listener emits each task-level event from a
forked child process (os.fork() with no exec). That child inherits the
task runner's connection to the Airflow supervisor; if the child's event
emission blocks (e.g. a slow or unreachable lineage backend), the
inherited connection can be left in a state that prevents the task from
being marked complete, leaving it stuck in the `running` state.
Add an opt-in `[openlineage] execute_in_thread` option (default False).
When enabled, task-level emission runs in a time-bounded daemon thread
instead of forking: nothing is inherited, so a blocked emission can never
strand the task, and the task runner waits at most
`[openlineage] execution_timeout` for emission before proceeding.
Metadata extraction still runs in-process with full access to the task
runtime, so Operators whose extractors resolve Connections, Variables or
XComs keep working.
The default (fork) path is unchanged.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
…nfig
The update-providers-build-files prek hook regenerates get_provider_info.py
from provider.yaml. Adding execute_in_thread to provider.yaml requires the
generated file to be updated in sync.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
…avior
The thread path also reaches the Airflow supervisor during in-process
metadata extraction, through the shared SUPERVISOR_COMMS threading lock.
That lock prevents the byte interleaving that corrupted the protocol under
fork, but it does mean the task runner can briefly wait on the lock while an
abandoned emission thread finishes a round trip. Reword the config docs,
docstring, and inline comment to describe this accurately instead of
claiming emission can "never block" the task runner.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
The update-providers-build-files generator preserves provider.yaml option
order (execution_timeout then execute_in_thread), so place the generated
entry accordingly to keep the file in sync with the generator output.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
@mobuchowski
mobuchowskiforce-pushed the openlineage-execute-in-thread branch from 8cdb236 to dcfdbdfCompareJune 30, 2026 18:40
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
@uranusjr

Copy link
Copy Markdown
Member

Hmm, for OL context we need get_template_context() and extract_metadata(), which use Comms with a threading lock. This has been fine since forking makes the locking per-process. But with threading, the lock would apply throughout all the threads, making it much easier to deadlock and kill the entire process.

Maybe it would be worthwhile to add some sort of timeout mechanism so the threads can go away if they live for too long? This would also help with the OOM issue mentioned above.

Also, as Kacper mentioned, a doc addition would be nice.

@mobuchowski

Copy link
Copy Markdown
ContributorAuthor

@uranusjr there is already a timeout mechanism with

 thread.join(timeout=conf.execution_timeout())

But there's basically no good way to force it to kill the thread.

We can hold the lock, but not sure if that would actually cause a deadlock? After starting a thread we're basically waiting for it - the task is not running until it finishes, the thread serves just as a separation boundary, and as timeout mechanism - as was originally intended couple years ago, when it wasn't even a provider yet :)

https://github.com/OpenLineage/OpenLineage/pull/508/changes#diff-7f2edd8384d0bf82cd3ea7f1f7c421f0db3e5c64680aed676ffb0253fc289eccR52

However, the original reasons for having the timeout in the first place are not really true anymore with Airflow 3...

The options I'd think of is:

  • if we really don't want to have two threads potentially using comms, let's just thread off the event emission part - later in the OL code
  • just run synchronously in the main thread

@uranusjr

Copy link
Copy Markdown
Member

I don’t really have a concrete case in mind where deadlock would happen, but more like wondering in general the locking strategy could be problematic. Since there’s already a timeout to prevent things from breaking too badly, I think it’s reasonable to just do this for now and see what happens.

@mobuchowski

Copy link
Copy Markdown
ContributorAuthor

@uranusjr to add to it; it's an additional mechanism, explicitly opt-in right now. Also, could you approve?

@pawel-big-lebowski

Copy link
Copy Markdown

@uranusjr I went through the comments, and it looks like all review feedback has been addressed. If that's the case, would you mind approving and merging the PR? Thanks!

@uranusjruranusjr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could add a section in troubleshooting as mentioned in #68708 (comment)

Otherwise this looks good to me.

mobuchowskiand others added 2 commits July 22, 2026 11:49
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

@mobuchowski@kacpermuda@abhipalsingh@uranusjr@pawel-big-lebowski
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' OpenLineage: add execute_in_thread to emit task events without forking by mobuchowski · Pull Request #68708 · apache/airflow · GitHub
Skip to content

OpenLineage: add execute_in_thread to emit task events without forking - #68708

Merged
mobuchowski merged 7 commits into
apache:mainfrom
mobuchowski:openlineage-execute-in-thread
Jul 22, 2026
Merged

OpenLineage: add execute_in_thread to emit task events without forking#68708
mobuchowski merged 7 commits into
apache:mainfrom
mobuchowski:openlineage-execute-in-thread

Conversation

@mobuchowski

@mobuchowskimobuchowski commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Why

By default the OpenLineage listener emits each task-level event from a forked child process (os.fork() with no exec). That child inherits the task runner's connection to the Airflow supervisor; if there are issues with that connection - it can hang.

There were a bunch of issues with that fixed in 3.2.x release - like #66574#65714#66573#67115#66572 - but we still support earlier ones like 3.1.x line.

What

Add an opt-in [openlineage] execute_in_thread option - by default False.

When enabled, task-level emission runs in a time-bounded daemon thread instead of forking: nothing is inherited, so a blocked emission can never strand the task, and the task runner waits at most [openlineage] execution_timeout for emission before proceeding.

Metadata extraction still runs in-process with full access to the task runtime, so Operators whose extractors resolve Connections, Variables or XComs keep working.

The default (fork) path is unchanged.

Verified on AWS MWAA and custom GKE Airflow environment.

Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: [Claude Code] following the guidelines

@kacpermudakacpermuda left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR adds a well-motivated opt-in thread-based emission path that cleanly solves the supervisor-connection hang for most cases, and the default fork path is unchanged. Two nits (see inline) document genuine semantic differences between fork and thread isolation that slightly qualify the "can never block" guarantee in the docstring — but neither causes a runtime failure under normal conditions.

Comment threadproviders/openlineage/src/airflow/providers/openlineage/plugins/listener.py Outdated
@kacpermuda

Copy link
Copy Markdown
Collaborator

Can we add some small paragraph about this in providers/openlineage/docs/troubleshooting.rst ? Can be helpful if we ever hit that again, to know what config to use. Also, let's clearly mention it there that it was observed on AF3.1, and may not be the case on other airflow versions since 3.2 has applied some fixes already

@kacpermudakacpermuda left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left few nit comments, looks solid otherwise - clean opt-in allowing a different execution mode, that can prevent some rare bug.

@abhipalsingh

Copy link
Copy Markdown

Adding a data point from the API server side, since this fork behavior isn't only a scheduler concern.

On Airflow 3.2.1 with apache-airflow-providers-openlineage==2.14.0 (pre-#65677), manual task-instance state changes via the REST
API (mark success/failed/skipped, clear) fire on_task_instance_* on the api-server — a multithreaded async process
(FastAPI/uvicorn under gunicorn). The listener's _fork_execute calls os.fork() from that multithreaded worker; a fraction of
children deadlock immediately on an inherited lock (py-spy showed them parked in futex_wait_queue, never reaching the post-fork setproctitle) and are never reaped → ~350–400 MB each → unbounded accumulation → api-server OOM.

#65677 helps the manual-state-change path (routes it through the ProcessPoolExecutor instead of a raw fork), but (a) that still forks a pool from the multithreaded async server, and (b) the natural-lifecycle handlers still use use_fork=True. So thread-based emission (this issue) is the cleaner fit for async contexts like the api-server, where os.fork() is fundamentally
unsafe.

We worked around it by disabling OpenLineage on the api-server (no transport configured there anyway), but big +1 for
execute_in_thread as the general fix.

@mobuchowski

Copy link
Copy Markdown
ContributorAuthor

@abhipalsingh we should not run any extraction on API server, so I think fix should be separate - to not fork anyway; but do it not behind the configuration flag.

I think it should be closer to what we do in the scheduler with ProcessPoolExecutor maybe.

By default the OpenLineage listener emits each task-level event from a
forked child process (os.fork() with no exec). That child inherits the
task runner's connection to the Airflow supervisor; if the child's event
emission blocks (e.g. a slow or unreachable lineage backend), the
inherited connection can be left in a state that prevents the task from
being marked complete, leaving it stuck in the `running` state.
Add an opt-in `[openlineage] execute_in_thread` option (default False).
When enabled, task-level emission runs in a time-bounded daemon thread
instead of forking: nothing is inherited, so a blocked emission can never
strand the task, and the task runner waits at most
`[openlineage] execution_timeout` for emission before proceeding.
Metadata extraction still runs in-process with full access to the task
runtime, so Operators whose extractors resolve Connections, Variables or
XComs keep working.
The default (fork) path is unchanged.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
…nfig
The update-providers-build-files prek hook regenerates get_provider_info.py
from provider.yaml. Adding execute_in_thread to provider.yaml requires the
generated file to be updated in sync.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
…avior
The thread path also reaches the Airflow supervisor during in-process
metadata extraction, through the shared SUPERVISOR_COMMS threading lock.
That lock prevents the byte interleaving that corrupted the protocol under
fork, but it does mean the task runner can briefly wait on the lock while an
abandoned emission thread finishes a round trip. Reword the config docs,
docstring, and inline comment to describe this accurately instead of
claiming emission can "never block" the task runner.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
The update-providers-build-files generator preserves provider.yaml option
order (execution_timeout then execute_in_thread), so place the generated
entry accordingly to keep the file in sync with the generator output.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
@mobuchowski
mobuchowskiforce-pushed the openlineage-execute-in-thread branch from 8cdb236 to dcfdbdfCompareJune 30, 2026 18:40
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
@uranusjr

Copy link
Copy Markdown
Member

Hmm, for OL context we need get_template_context() and extract_metadata(), which use Comms with a threading lock. This has been fine since forking makes the locking per-process. But with threading, the lock would apply throughout all the threads, making it much easier to deadlock and kill the entire process.

Maybe it would be worthwhile to add some sort of timeout mechanism so the threads can go away if they live for too long? This would also help with the OOM issue mentioned above.

Also, as Kacper mentioned, a doc addition would be nice.

@mobuchowski

Copy link
Copy Markdown
ContributorAuthor

@uranusjr there is already a timeout mechanism with

 thread.join(timeout=conf.execution_timeout())

But there's basically no good way to force it to kill the thread.

We can hold the lock, but not sure if that would actually cause a deadlock? After starting a thread we're basically waiting for it - the task is not running until it finishes, the thread serves just as a separation boundary, and as timeout mechanism - as was originally intended couple years ago, when it wasn't even a provider yet :)

https://github.com/OpenLineage/OpenLineage/pull/508/changes#diff-7f2edd8384d0bf82cd3ea7f1f7c421f0db3e5c64680aed676ffb0253fc289eccR52

However, the original reasons for having the timeout in the first place are not really true anymore with Airflow 3...

The options I'd think of is:

  • if we really don't want to have two threads potentially using comms, let's just thread off the event emission part - later in the OL code
  • just run synchronously in the main thread

@uranusjr

Copy link
Copy Markdown
Member

I don’t really have a concrete case in mind where deadlock would happen, but more like wondering in general the locking strategy could be problematic. Since there’s already a timeout to prevent things from breaking too badly, I think it’s reasonable to just do this for now and see what happens.

@mobuchowski

Copy link
Copy Markdown
ContributorAuthor

@uranusjr to add to it; it's an additional mechanism, explicitly opt-in right now. Also, could you approve?

@pawel-big-lebowski

Copy link
Copy Markdown

@uranusjr I went through the comments, and it looks like all review feedback has been addressed. If that's the case, would you mind approving and merging the PR? Thanks!

@uranusjruranusjr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could add a section in troubleshooting as mentioned in #68708 (comment)

Otherwise this looks good to me.

mobuchowskiand others added 2 commits July 22, 2026 11:49
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

@mobuchowski@kacpermuda@abhipalsingh@uranusjr@pawel-big-lebowski
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' OpenLineage: add execute_in_thread to emit task events without forking by mobuchowski · Pull Request #68708 · apache/airflow · GitHub
Skip to content

OpenLineage: add execute_in_thread to emit task events without forking - #68708

Merged
mobuchowski merged 7 commits into
apache:mainfrom
mobuchowski:openlineage-execute-in-thread
Jul 22, 2026
Merged

OpenLineage: add execute_in_thread to emit task events without forking#68708
mobuchowski merged 7 commits into
apache:mainfrom
mobuchowski:openlineage-execute-in-thread

Conversation

@mobuchowski

@mobuchowskimobuchowski commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Why

By default the OpenLineage listener emits each task-level event from a forked child process (os.fork() with no exec). That child inherits the task runner's connection to the Airflow supervisor; if there are issues with that connection - it can hang.

There were a bunch of issues with that fixed in 3.2.x release - like #66574#65714#66573#67115#66572 - but we still support earlier ones like 3.1.x line.

What

Add an opt-in [openlineage] execute_in_thread option - by default False.

When enabled, task-level emission runs in a time-bounded daemon thread instead of forking: nothing is inherited, so a blocked emission can never strand the task, and the task runner waits at most [openlineage] execution_timeout for emission before proceeding.

Metadata extraction still runs in-process with full access to the task runtime, so Operators whose extractors resolve Connections, Variables or XComs keep working.

The default (fork) path is unchanged.

Verified on AWS MWAA and custom GKE Airflow environment.

Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: [Claude Code] following the guidelines

@kacpermudakacpermuda left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR adds a well-motivated opt-in thread-based emission path that cleanly solves the supervisor-connection hang for most cases, and the default fork path is unchanged. Two nits (see inline) document genuine semantic differences between fork and thread isolation that slightly qualify the "can never block" guarantee in the docstring — but neither causes a runtime failure under normal conditions.

Comment threadproviders/openlineage/src/airflow/providers/openlineage/plugins/listener.py Outdated
@kacpermuda

Copy link
Copy Markdown
Collaborator

Can we add some small paragraph about this in providers/openlineage/docs/troubleshooting.rst ? Can be helpful if we ever hit that again, to know what config to use. Also, let's clearly mention it there that it was observed on AF3.1, and may not be the case on other airflow versions since 3.2 has applied some fixes already

@kacpermudakacpermuda left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left few nit comments, looks solid otherwise - clean opt-in allowing a different execution mode, that can prevent some rare bug.

@abhipalsingh

Copy link
Copy Markdown

Adding a data point from the API server side, since this fork behavior isn't only a scheduler concern.

On Airflow 3.2.1 with apache-airflow-providers-openlineage==2.14.0 (pre-#65677), manual task-instance state changes via the REST
API (mark success/failed/skipped, clear) fire on_task_instance_* on the api-server — a multithreaded async process
(FastAPI/uvicorn under gunicorn). The listener's _fork_execute calls os.fork() from that multithreaded worker; a fraction of
children deadlock immediately on an inherited lock (py-spy showed them parked in futex_wait_queue, never reaching the post-fork setproctitle) and are never reaped → ~350–400 MB each → unbounded accumulation → api-server OOM.

#65677 helps the manual-state-change path (routes it through the ProcessPoolExecutor instead of a raw fork), but (a) that still forks a pool from the multithreaded async server, and (b) the natural-lifecycle handlers still use use_fork=True. So thread-based emission (this issue) is the cleaner fit for async contexts like the api-server, where os.fork() is fundamentally
unsafe.

We worked around it by disabling OpenLineage on the api-server (no transport configured there anyway), but big +1 for
execute_in_thread as the general fix.

@mobuchowski

Copy link
Copy Markdown
ContributorAuthor

@abhipalsingh we should not run any extraction on API server, so I think fix should be separate - to not fork anyway; but do it not behind the configuration flag.

I think it should be closer to what we do in the scheduler with ProcessPoolExecutor maybe.

By default the OpenLineage listener emits each task-level event from a
forked child process (os.fork() with no exec). That child inherits the
task runner's connection to the Airflow supervisor; if the child's event
emission blocks (e.g. a slow or unreachable lineage backend), the
inherited connection can be left in a state that prevents the task from
being marked complete, leaving it stuck in the `running` state.
Add an opt-in `[openlineage] execute_in_thread` option (default False).
When enabled, task-level emission runs in a time-bounded daemon thread
instead of forking: nothing is inherited, so a blocked emission can never
strand the task, and the task runner waits at most
`[openlineage] execution_timeout` for emission before proceeding.
Metadata extraction still runs in-process with full access to the task
runtime, so Operators whose extractors resolve Connections, Variables or
XComs keep working.
The default (fork) path is unchanged.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
…nfig
The update-providers-build-files prek hook regenerates get_provider_info.py
from provider.yaml. Adding execute_in_thread to provider.yaml requires the
generated file to be updated in sync.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
…avior
The thread path also reaches the Airflow supervisor during in-process
metadata extraction, through the shared SUPERVISOR_COMMS threading lock.
That lock prevents the byte interleaving that corrupted the protocol under
fork, but it does mean the task runner can briefly wait on the lock while an
abandoned emission thread finishes a round trip. Reword the config docs,
docstring, and inline comment to describe this accurately instead of
claiming emission can "never block" the task runner.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
The update-providers-build-files generator preserves provider.yaml option
order (execution_timeout then execute_in_thread), so place the generated
entry accordingly to keep the file in sync with the generator output.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
@mobuchowski
mobuchowskiforce-pushed the openlineage-execute-in-thread branch from 8cdb236 to dcfdbdfCompareJune 30, 2026 18:40
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
@uranusjr

Copy link
Copy Markdown
Member

Hmm, for OL context we need get_template_context() and extract_metadata(), which use Comms with a threading lock. This has been fine since forking makes the locking per-process. But with threading, the lock would apply throughout all the threads, making it much easier to deadlock and kill the entire process.

Maybe it would be worthwhile to add some sort of timeout mechanism so the threads can go away if they live for too long? This would also help with the OOM issue mentioned above.

Also, as Kacper mentioned, a doc addition would be nice.

@mobuchowski

Copy link
Copy Markdown
ContributorAuthor

@uranusjr there is already a timeout mechanism with

 thread.join(timeout=conf.execution_timeout())

But there's basically no good way to force it to kill the thread.

We can hold the lock, but not sure if that would actually cause a deadlock? After starting a thread we're basically waiting for it - the task is not running until it finishes, the thread serves just as a separation boundary, and as timeout mechanism - as was originally intended couple years ago, when it wasn't even a provider yet :)

https://github.com/OpenLineage/OpenLineage/pull/508/changes#diff-7f2edd8384d0bf82cd3ea7f1f7c421f0db3e5c64680aed676ffb0253fc289eccR52

However, the original reasons for having the timeout in the first place are not really true anymore with Airflow 3...

The options I'd think of is:

  • if we really don't want to have two threads potentially using comms, let's just thread off the event emission part - later in the OL code
  • just run synchronously in the main thread

@uranusjr

Copy link
Copy Markdown
Member

I don’t really have a concrete case in mind where deadlock would happen, but more like wondering in general the locking strategy could be problematic. Since there’s already a timeout to prevent things from breaking too badly, I think it’s reasonable to just do this for now and see what happens.

@mobuchowski

Copy link
Copy Markdown
ContributorAuthor

@uranusjr to add to it; it's an additional mechanism, explicitly opt-in right now. Also, could you approve?

@pawel-big-lebowski

Copy link
Copy Markdown

@uranusjr I went through the comments, and it looks like all review feedback has been addressed. If that's the case, would you mind approving and merging the PR? Thanks!

@uranusjruranusjr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could add a section in troubleshooting as mentioned in #68708 (comment)

Otherwise this looks good to me.

mobuchowskiand others added 2 commits July 22, 2026 11:49
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

@mobuchowski@kacpermuda@abhipalsingh@uranusjr@pawel-big-lebowski
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); OpenLineage: add execute_in_thread to emit task events without forking by mobuchowski · Pull Request #68708 · apache/airflow · GitHub
Skip to content

OpenLineage: add execute_in_thread to emit task events without forking - #68708

Merged
mobuchowski merged 7 commits into
apache:mainfrom
mobuchowski:openlineage-execute-in-thread
Jul 22, 2026
Merged

OpenLineage: add execute_in_thread to emit task events without forking#68708
mobuchowski merged 7 commits into
apache:mainfrom
mobuchowski:openlineage-execute-in-thread

Conversation

@mobuchowski

@mobuchowskimobuchowski commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Why

By default the OpenLineage listener emits each task-level event from a forked child process (os.fork() with no exec). That child inherits the task runner's connection to the Airflow supervisor; if there are issues with that connection - it can hang.

There were a bunch of issues with that fixed in 3.2.x release - like #66574#65714#66573#67115#66572 - but we still support earlier ones like 3.1.x line.

What

Add an opt-in [openlineage] execute_in_thread option - by default False.

When enabled, task-level emission runs in a time-bounded daemon thread instead of forking: nothing is inherited, so a blocked emission can never strand the task, and the task runner waits at most [openlineage] execution_timeout for emission before proceeding.

Metadata extraction still runs in-process with full access to the task runtime, so Operators whose extractors resolve Connections, Variables or XComs keep working.

The default (fork) path is unchanged.

Verified on AWS MWAA and custom GKE Airflow environment.

Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: [Claude Code] following the guidelines

@kacpermudakacpermuda left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR adds a well-motivated opt-in thread-based emission path that cleanly solves the supervisor-connection hang for most cases, and the default fork path is unchanged. Two nits (see inline) document genuine semantic differences between fork and thread isolation that slightly qualify the "can never block" guarantee in the docstring — but neither causes a runtime failure under normal conditions.

Comment threadproviders/openlineage/src/airflow/providers/openlineage/plugins/listener.py Outdated
@kacpermuda

Copy link
Copy Markdown
Collaborator

Can we add some small paragraph about this in providers/openlineage/docs/troubleshooting.rst ? Can be helpful if we ever hit that again, to know what config to use. Also, let's clearly mention it there that it was observed on AF3.1, and may not be the case on other airflow versions since 3.2 has applied some fixes already

@kacpermudakacpermuda left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left few nit comments, looks solid otherwise - clean opt-in allowing a different execution mode, that can prevent some rare bug.

@abhipalsingh

Copy link
Copy Markdown

Adding a data point from the API server side, since this fork behavior isn't only a scheduler concern.

On Airflow 3.2.1 with apache-airflow-providers-openlineage==2.14.0 (pre-#65677), manual task-instance state changes via the REST
API (mark success/failed/skipped, clear) fire on_task_instance_* on the api-server — a multithreaded async process
(FastAPI/uvicorn under gunicorn). The listener's _fork_execute calls os.fork() from that multithreaded worker; a fraction of
children deadlock immediately on an inherited lock (py-spy showed them parked in futex_wait_queue, never reaching the post-fork setproctitle) and are never reaped → ~350–400 MB each → unbounded accumulation → api-server OOM.

#65677 helps the manual-state-change path (routes it through the ProcessPoolExecutor instead of a raw fork), but (a) that still forks a pool from the multithreaded async server, and (b) the natural-lifecycle handlers still use use_fork=True. So thread-based emission (this issue) is the cleaner fit for async contexts like the api-server, where os.fork() is fundamentally
unsafe.

We worked around it by disabling OpenLineage on the api-server (no transport configured there anyway), but big +1 for
execute_in_thread as the general fix.

@mobuchowski

Copy link
Copy Markdown
ContributorAuthor

@abhipalsingh we should not run any extraction on API server, so I think fix should be separate - to not fork anyway; but do it not behind the configuration flag.

I think it should be closer to what we do in the scheduler with ProcessPoolExecutor maybe.

By default the OpenLineage listener emits each task-level event from a
forked child process (os.fork() with no exec). That child inherits the
task runner's connection to the Airflow supervisor; if the child's event
emission blocks (e.g. a slow or unreachable lineage backend), the
inherited connection can be left in a state that prevents the task from
being marked complete, leaving it stuck in the `running` state.
Add an opt-in `[openlineage] execute_in_thread` option (default False).
When enabled, task-level emission runs in a time-bounded daemon thread
instead of forking: nothing is inherited, so a blocked emission can never
strand the task, and the task runner waits at most
`[openlineage] execution_timeout` for emission before proceeding.
Metadata extraction still runs in-process with full access to the task
runtime, so Operators whose extractors resolve Connections, Variables or
XComs keep working.
The default (fork) path is unchanged.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
…nfig
The update-providers-build-files prek hook regenerates get_provider_info.py
from provider.yaml. Adding execute_in_thread to provider.yaml requires the
generated file to be updated in sync.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
…avior
The thread path also reaches the Airflow supervisor during in-process
metadata extraction, through the shared SUPERVISOR_COMMS threading lock.
That lock prevents the byte interleaving that corrupted the protocol under
fork, but it does mean the task runner can briefly wait on the lock while an
abandoned emission thread finishes a round trip. Reword the config docs,
docstring, and inline comment to describe this accurately instead of
claiming emission can "never block" the task runner.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
The update-providers-build-files generator preserves provider.yaml option
order (execution_timeout then execute_in_thread), so place the generated
entry accordingly to keep the file in sync with the generator output.
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
@mobuchowski
mobuchowskiforce-pushed the openlineage-execute-in-thread branch from 8cdb236 to dcfdbdfCompareJune 30, 2026 18:40
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
@uranusjr

Copy link
Copy Markdown
Member

Hmm, for OL context we need get_template_context() and extract_metadata(), which use Comms with a threading lock. This has been fine since forking makes the locking per-process. But with threading, the lock would apply throughout all the threads, making it much easier to deadlock and kill the entire process.

Maybe it would be worthwhile to add some sort of timeout mechanism so the threads can go away if they live for too long? This would also help with the OOM issue mentioned above.

Also, as Kacper mentioned, a doc addition would be nice.

@mobuchowski

Copy link
Copy Markdown
ContributorAuthor

@uranusjr there is already a timeout mechanism with

 thread.join(timeout=conf.execution_timeout())

But there's basically no good way to force it to kill the thread.

We can hold the lock, but not sure if that would actually cause a deadlock? After starting a thread we're basically waiting for it - the task is not running until it finishes, the thread serves just as a separation boundary, and as timeout mechanism - as was originally intended couple years ago, when it wasn't even a provider yet :)

https://github.com/OpenLineage/OpenLineage/pull/508/changes#diff-7f2edd8384d0bf82cd3ea7f1f7c421f0db3e5c64680aed676ffb0253fc289eccR52

However, the original reasons for having the timeout in the first place are not really true anymore with Airflow 3...

The options I'd think of is:

  • if we really don't want to have two threads potentially using comms, let's just thread off the event emission part - later in the OL code
  • just run synchronously in the main thread

@uranusjr

Copy link
Copy Markdown
Member

I don’t really have a concrete case in mind where deadlock would happen, but more like wondering in general the locking strategy could be problematic. Since there’s already a timeout to prevent things from breaking too badly, I think it’s reasonable to just do this for now and see what happens.

@mobuchowski

Copy link
Copy Markdown
ContributorAuthor

@uranusjr to add to it; it's an additional mechanism, explicitly opt-in right now. Also, could you approve?

@pawel-big-lebowski

Copy link
Copy Markdown

@uranusjr I went through the comments, and it looks like all review feedback has been addressed. If that's the case, would you mind approving and merging the PR? Thanks!

@uranusjruranusjr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could add a section in troubleshooting as mentioned in #68708 (comment)

Otherwise this looks good to me.

mobuchowskiand others added 2 commits July 22, 2026 11:49
Signed-off-by: Maciej Obuchowski <maciej.obuchowski@datadoghq.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

@mobuchowski@kacpermuda@abhipalsingh@uranusjr@pawel-big-lebowski