Skip to content

fix(sdk): flush concurrent span/log processors inline when executor shut down at exit - #5835

Open
RichardoMrMu wants to merge 27 commits into
open-telemetry:mainfrom
RichardoMrMu:fix/concurrent-processor-atexit-inline-flush
Open

RichardoMrMu wants to merge 27 commits into
open-telemetry:mainfrom
RichardoMrMu:fix/concurrent-processor-atexit-inline-flush

Conversation

@RichardoMrMu

Copy link
Copy Markdown
Contributor

Fixes #5568

Problem

TracerProvider.shutdown() and LoggerProvider.shutdown() are registered via atexit. CPython runs threading._shutdown() (which shuts down ThreadPoolExecutors) before the atexit queue. By the time the provider shutdown handlers run, the ThreadPoolExecutor owned by ConcurrentMultiSpanProcessor / ConcurrentMultiLogRecordProcessor is already closed, so executor.submit() raises RuntimeError: cannot schedule new futures after shutdown. The underlying BatchSpanProcessor / BatchLogRecordProcessor never get shutdown() called, and buffered telemetry is dropped at interpreter exit.

Fix

In _submit_and_await (trace) and _submit_and_wait (logs), wrap only the executor.submit() call in try/except RuntimeError. When the executor is already shut down/broken, run the callback inline so shutdown/on_end/on_emit still reach the underlying processors. A RuntimeError raised by the callback itself (while the executor is alive) is not caught and still propagates via future.result(). force_flush timeout behavior is intentionally left unchanged.

Tests

  • test_shutdown_runs_inline_after_executor_shutdown: executor shut down first, then shutdown() still calls each underlying processor.
  • test_on_end_runs_inline_after_executor_shutdown (trace): on_end runs inline after executor shutdown.
  • test_callback_runtime_error_propagates_not_swallowed (trace + logs): a callback raising RuntimeError still propagates out of shutdown().
pytest opentelemetry-sdk/tests/trace/test_span_processor.py opentelemetry-sdk/tests/logs/test_multi_log_processor.py
51 passed, 1 skipped (fork test, needs *nix), 0.73s

@RichardoMrMu
RichardoMrMu requested a review from a team as a code owner October 26, 2026 12:08
@opentelemetry-pr-dashboard

opentelemetry-pr-dashboard Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Pull request dashboard status

Waiting on reviewers · refreshed 2026-10-26 21:38 UTC

Review the latest changes.

Status above doesn't look right?
  • Just replied or pushed? Anything around or after the refresh time above may not be picked up yet — give it a few minutes.
  • Anything look wrong? Report it with what you expected; it helps us improve the dashboard.

RichardoMrMu and others added 25 commits October 26, 2026 20:11
…uble-invoke on submit RuntimeError

Adopt the contain+log error-handling contract for ConcurrentMultiSpanProcessor
and ConcurrentMultiLogRecordProcessor:

- Add a per-dispatch _CallbackAttempt that shares a lock-guarded 'started'
  claim between the queue worker and the inline fallback taken when
  executor.submit raises RuntimeError. This prevents the callback from
  running twice when Thread.start fails after the work item was already
  enqueued (the previous code ran it inline AND the queued worker ran it).
- Contain callback exceptions per processor (logger.exception) so a faulty
  processor does not block healthy siblings, leak out of shutdown(), or
  replace the caller's own exception.
- Wait for claimed attempts to finish before returning.

Tests:
- Replace the propagation assertion with containment + sibling-continues +
  application-exception-preserved assertions.
- Add a Thread.start-failure no-double-invoke test for trace and logs.
- Real subprocess atexit exit verified: a faulty sibling no longer blocks
  the healthy Batch processor's EXPORT/SHUTDOWN at interpreter exit.

Known limitations: no retry/delivery guarantee, no global exactly-once,
no bounded shutdown wait; force_flush path unchanged; logs self-diagnostics
recursion not separately exercised.
Remove protected _executor access in test teardown (pylint W0212) by shutting the concurrent processors down through their public shutdown() method, and collapse a double blank line flagged by ruff-format. No production behavior change; the callback containment, at-most-once dispatch and logging reentry isolation are already covered by the behavior tests.
… to avoid handler-lock deadlock

A real logging handler holds its own lock across emit(). When
ConcurrentMultiLogRecordProcessor.on_emit dispatches to its executor and then
blocks waiting on the futures, a worker that re-enters logging through the same
LoggingHandler deadlocks on the handler lock the dispatching thread still holds:
the dispatcher waits for the worker, the worker waits for the lock.

Mark the bridge boundary in LoggingHandler.emit with a thread-local and extend
the existing reentry guard so the multi-processor runs callbacks inline whenever
it is dispatched from inside that bridge, matching the callback reentry path.
Add regression tests using the real LoggingHandler bridge (not a synthetic
handler) for reentry inline dispatch and handler-raise isolation, asserting
business errors are contained and healthy siblings still receive records.
Wrap the executor-shutdown DEBUG diagnostic and the resolve/submit/future
exception logs in safe wrappers so that a raising user logging handler
cannot abort the inline fallback or starve healthy siblings. The DEBUG
record and business exception traceback are still emitted (via stderr
fallback if a handler raises); no log level is raised or diagnostic
dropped.
Add a handler that raises outward (no self-catch, DEBUG level, executor
pre-shutdown) to prove the executor-shutdown diagnostic and per-callback
exception logs no longer abort dispatch. Fails on 8b7e960, passes on the
safe-log wrapper fix.
Replace the hollow _BoomHandler (try/except pass) with a handler that
raises outward, and add a regression test for the callback-resolution
diagnostic path. Covers resolve/submit/future/executor-shutdown-debug
under a truly raising user handler.
Address precommit lint: ruff format both test files and add
# pylint: disable=protected-access where tests intentionally shut down
the internal executor to exercise the atexit inline path.
Patch executor.submit to enqueue via original then raise RuntimeError,
with an outward-raising DEBUG handler. Asserts at-most-once dispatch
(healthy/faulty each once), 2 enqueues, no escape.
Wait on enqueued futures before asserting, then executor.shutdown(wait=True),
and assert stderr contains the diagnostic fallback record.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Concurrent multi-processors never flush at interpreter exit

1 participant