Fix duplicate OTel metric streams after re-init and fork - #71804
Closed
dstandish wants to merge 3 commits into
Closed
Fix duplicate OTel metric streams after re-init and fork#71804dstandish wants to merge 3 commits into
dstandish wants to merge 3 commits into
Conversation
A forked child inherits both the atexit flush hook and the parent's MeterProvider, so at its own exit it flushed a pipeline it never recorded to: every metric the parent had accumulated was exported a second time, from a process that did not own it, as a second writer for the same cumulative series. Airflow forks per task, so that duplicate arrives once per fork. Rebuilding the pipeline on every get_otel_logger() call is the same root cause from the other side. The replaced provider's exporter thread keeps running under shutdown_on_exit=False while nothing records to its instruments, so it republishes frozen totals alongside the live stream. The scheduler reaches this because BaseExecutor.__init__ and SchedulerJobRunner each initialize stats in the same process. Making the provider per-process state answers both: only the process that built it ever flushes it, and no second pipeline is built to be left behind.
dstandish
force-pushed
the
otel-fork-metric-inheritance
branch
from
August 19, 2026 19:54
fe36710 to
31947fe
Compare
dstandish
force-pushed
the
otel-fork-metric-inheritance
branch
from
August 19, 2026 20:07
6745c1d to
7a53c7f
Compare
Owning the provider per process keeps the child from flushing what it inherited, but not from exporting it. The SDK registers register_at_fork(after_in_child=...) for every PeriodicExportingMetricReader, so the child restarts the exporter thread behind the inherited pipeline. Nothing in the child records to it, so it republishes the totals the parent held at the instant of the fork -- once per export interval, for as long as the child lives. A consumer sees two writers on one cumulative series: one climbing, one frozen. Airflow forks constantly, and the long-lived children make that permanent rather than momentary: LocalExecutor pool workers, the OpenLineage dag-state-change pool and the scheduler's log and health-check servers all fork from a scheduler whose pipeline is already live, then outlive many export cycles. Setting the reader's shutdown event alone is not enough, because the ticker publishes one last collection on its way out; dropping the collect callback keeps that final pass from carrying the parent's totals with it. A child that emits metrics of its own still builds its own pipeline, so this costs it nothing. A provider the SDK built for the deployment -- from OTEL_CONFIG_FILE or by an instrumentation agent -- reaches the child the same way, carrying the atexit shutdown that shutdown_on_exit=True registered for it. Its readers stay running, since on that path they are the only pipeline the child has, but the inherited copy of that hook is dropped so the child cannot dump the parent's state on the way out.
dstandish
force-pushed
the
otel-fork-metric-inheritance
branch
from
August 19, 2026 20:18
7a53c7f to
9a666df
Compare
1 task
Contributor
Author
|
Superseded by #71855, which carries the same two fixes — building the metrics pipeline once per Closing this one. Nothing here is lost: the fork coverage and the newsfragment have been carried Drafted-by: Claude Code (Opus 5) (no human review before posting) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The OTel metrics pipeline can end up with two writers on one cumulative series — one climbing,
one frozen — in two independent ways. This fixes both.
A second
get_otel_logger()call in the same process leaves the first pipeline running.Every
MeterProviderowns aPeriodicExportingMetricReaderwhose constructor starts a daemonexport thread, and
shutdown_on_exit=Falsemeans nothing reaps one that gets replaced. Itsinstruments stop being recorded to, so it republishes frozen totals under a different
start_time_unix_nanofor the life of the process. The scheduler reaches this on every start:BaseExecutor.__init__andSchedulerJobRunner._executeeach callstats.initialize(), with astats.incr("schedulerjob_start")in between that materialises the first pipeline. Fixed bybuilding the pipeline once per process rather than shutting down and replacing it — no second
pipeline to reap, and no cumulative reset partway through startup.
A forked child re-exports the pipeline it inherited.
fork()copies the parent's provider,and the SDK registers
register_at_fork(after_in_child=...)for everyPeriodicExportingMetricReader, so the child restarts the exporter thread behind it. Nothing inthe child records to that pipeline, so it republishes the totals the parent held at the instant
of the fork, once per export interval, for as long as the child lives. Airflow forks constantly
and the long-lived children make it permanent: LocalExecutor pool workers, the OpenLineage
dag-state-change
ProcessPoolExecutor, and the scheduler's log and health-check servers all forkfrom a scheduler whose pipeline is already live.
Measured in the child, at a 200 ms export interval over a ~1 s child lifetime — exports of a
metric only the parent ever recorded:
Setting the reader's shutdown event alone is not enough: the ticker publishes one last collection
on its way out. Dropping the collect callback keeps that final pass from carrying the parent's
totals with it. A child that emits metrics of its own still builds its own pipeline, which the
test covers.
A provider the SDK built for the deployment reaches the child the same way. A
MeterProviderfrom
OTEL_CONFIG_FILE, or from anopentelemetry-instrumentagent, defaults toshutdown_on_exit=Trueand registers its own atexit shutdown (_internal/__init__.py:503). A forkhands that registration to the child, which then dumps everything the parent accumulated on its way
out. Its readers are left running — on that path they are the only pipeline the child has, and
declarative configuration is the sole source of SDK construction, so the child cannot rebuild what
it would lose — but the inherited copy of the exit hook is dropped.
Only the provider's own
_metric_readersare ever stopped, never the class-level_all_metric_readersthat every provider in the process shares, so an agent's pipeline keepsworking in forked children. Both properties have tests.
The first commit is Jed Cunningham's.
related: #71800 — that PR fixes the first defect above by shutting down the replaced provider
instead. The two approaches are alternatives for that half; the fork half is independent of it,
and I measured that #71800 alone leaves 4 stale exports in a child that never re-inits and 1 in a
child that does (the final collect on the way out of
shutdown()).Was generative AI tooling used to co-author this PR?
Generated-by: Claude Code (Opus 5) following the guidelines