Repository navigation
Conversation
… after a fork A child inherits the processor's ThreadPoolExecutor bookkeeping but none of its worker threads, and the inherited idle semaphore still reports a worker as available. _adjust_thread_count() therefore starts no thread, the submitted call is never run, and the child's first emit blocks forever on future.result() with no exception and no timeout. ConcurrentMultiSpanProcessor already guards against this by holding the pool behind _init_executor() and re-instantiating it from an os.register_at_fork(after_in_child=...) handler, kept collectable with a WeakMethod. Apply the same shape to the logs processor. Adds a subprocess fork test mirroring the existing logger_provider_resource_after_fork.py harness, and a test that runs everywhere asserting the re-instantiation hands out a fresh pool.
Pull request dashboard statusWaiting on reviewers · refreshed 2026-11-05 07:46 UTC Review the latest changes. Status above doesn't look right?
|
JoeyPatricio
left a comment
There was a problem hiding this comment.
Thanks for picking this up. The source change matches ConcurrentMultiSpanProcessor (sdk/trace/__init__.py:238-257), and I agree with leaving _lock alone to stay consistent with it. I ran the new tests on Linux / Python 3.14: 15 pass with the change, and both new tests fail on the parent commit. Two suggestions on the tests inline.
EasyCLA is the only failing check, so it needs signing before this can merge.
| script_path = Path(__file__).parent / "scripts" / "concurrent_multi_log_record_processor_after_fork.py" | ||
|
|
||
| try: | ||
| result = subprocess.run( |
There was a problem hiding this comment.
On a regression this leaves the hung child running. subprocess.run(timeout=60) kills the script but not the child it forked. I reverted the source change and ran the test, and the child process was still alive after pytest exited. The failure also takes 60s.
Could this mirror test_batch_span_processor_fork in tests/trace/test_span_processor.py? It uses a multiprocessing fork context and a pipe with a 5s poll, and kills the child when nothing comes back. That would also remove the 81-line script.
There was a problem hiding this comment.
Taken in 262a57e — answering here too, since I'd only replied in the PR comment and this thread still reads as open.
You were right that subprocess.run(timeout=60) kills the script but not the process the script forked, so a regression left a hung child behind. Rewritten to the test_batch_span_processor_fork shape: fork context, pipe, 5 s poll, process.kill() when nothing comes back. The 81-line script under scripts/ is deleted.
Against the parent commit:
FAILED ...::test_concurrent_multi_log_record_processor_fork
E AssertionError: The child process did not send any message after 5 seconds, it's very probably locked
1 failed, 1 passed in 7.64s
7.6 s instead of 60, and nothing survives the run. I kept one point from the old version as a comment: the main process has to emit before the fork so the ThreadPoolExecutor has actually started a worker — otherwise the child inherits an unused pool and the test passes without reaching the defect.
| self.assertEqual(1, mock.force_flush.call_count) | ||
| multi_log_record_processor.shutdown() | ||
|
|
||
| def test_init_executor_hands_out_a_fresh_pool(self): |
There was a problem hiding this comment.
This calls the private helper directly, and the fork test already covers what it checks. It also never shuts down the first executor. A test like the span side's test_processor_gc would cover the one part of the change the fork test doesn't, the WeakMethod guard that keeps the processor collectable.
There was a problem hiding this comment.
Agreed on both counts, done in 262a57e — replying here as well, since I'd only covered it in the PR comment.
It did duplicate the fork test's coverage and it never shut the first executor down. Replaced with a test_processor_gc mirroring the trace side.
One thing worth stating plainly, because it changes how you read the evidence: that test passes on the parent commit. No fork handler is registered there, so nothing retains the processor. It isn't a second regression test — it guards the WeakMethod specifically, which I verified by mutation rather than assertion:
# weak_reinit = WeakMethod(self._init_executor) -> a strong reference
FAILED ...::test_processor_gc
E AssertionError: <...ConcurrentMultiLogRecordProcessor object at 0x...> is not None :
The ConcurrentMultiLogRecordProcessor object created by this test wasn't garbage collected
So of the two: the fork test pins the bug, the gc test pins the part of the fix the fork test can't see.
…cess The subprocess harness left a hung child running on a regression: subprocess.run(timeout=60) kills the script it launched but not the process that script forked, and the failure took the full 60 s to surface. Use a fork context with a pipe and a 5 s poll, killing the child when nothing comes back, the same shape as test_batch_span_processor_fork on the trace side. The 81-line script is no longer needed. Against the parent commit the test now fails in 7.6 s with "the child process did not send any message after 5 seconds", and leaves nothing behind. Replace the test that called _init_executor directly with a test_processor_gc mirroring the trace side. The old one duplicated what the fork test already covers and never shut its first executor down, while the collectability of the processor was untested: the fork handler lives for the life of the process, so holding _init_executor as anything but a WeakMethod leaks every processor constructed. Swapping the WeakMethod for a strong reference fails the new test; it passes on the parent commit, where no handler is registered at all.
|
Thanks — both suggestions taken in The fork test now uses a
|
|
EasyCLA is signed and now reads pass — "You are authorized to contribute." That clears the only failing check. One thing I can't do from here: EasyCLA is still the only check that has run on this PR. On other recent PRs (#5772, #5769, #5768) there is also a State of the PR otherwise, so nothing is waiting on me:
|
chrikrah
left a comment
There was a problem hiding this comment.
@Vivek1898 I'd merge this at cd7796d. Your parent-commit and strong-reference results reproduce on CPython 3.12 as well as your 3.14.
$ .venv/bin/python -m pytest opentelemetry-sdk/tests/logs -q -p no:cacheprovider # cd7796d, Linux, CPython 3.12.6
122 passed, 41 warnings, 4 subtests passed in 3.03s
$ .venv/bin/python -m pytest opentelemetry-sdk/tests/logs/test_multi_log_processor.py -q -p no:cacheprovider
# _logs/_internal/__init__.py from 2fa016b (merge base), your tests kept
FAILED opentelemetry-sdk/tests/logs/test_multi_log_processor.py::TestConcurrentMultiLogRecordProcessor::test_concurrent_multi_log_record_processor_fork
1 failed, 14 passed, 2 warnings in 5.33s
# same command, cd7796d with your mutation re-run: WeakMethod(self._init_executor) -> a strong reference
FAILED opentelemetry-sdk/tests/logs/test_multi_log_processor.py::TestConcurrentMultiLogRecordProcessor::test_processor_gc
1 failed, 14 passed, 2 warnings in 0.32s
# not run: tox across the Python matrix, and any platform other than Linux
@emdneto the CI, changelog and Zizmor runs on cd7796d are all waiting on first-time-contributor approval, so EasyCLA is the only check that has reported here. Could you approve them so this can get a maintainer review?
|
Thanks for reproducing it on 3.12 as well as 3.14 — the strong-reference mutation result is the one I most wanted a second pair of eyes on, since that is the assertion doing the work in To be precise about the head: you reviewed Nothing is pending on me:
The one thing I still cannot do from here is the CI. EasyCLA remains the only check that has reported on this PR; the /dashboard route:reviewers |
This comment has been minimized.
This comment has been minimized.
…ulti-log-processor-fork
|
Merged current
|
chrikrah
left a comment
There was a problem hiding this comment.
@Vivek1898 still approving at c5402dd. Your point about #5784 holds here: with the new enabled() early return on main, the fork test still fails once the fix is gone.
$ .venv/bin/python -m pytest opentelemetry-sdk/tests/logs -q -p no:cacheprovider # export of c5402dd, CPython 3.12.6
129 passed, 41 warnings, 8 subtests passed in 5.74s
# _logs/_internal/__init__.py from merge base 925ffc8, your tests kept
$ .venv/bin/python -m pytest opentelemetry-sdk/tests/logs/test_multi_log_processor.py -q -p no:cacheprovider
FAILED ...::TestConcurrentMultiLogRecordProcessor::test_concurrent_multi_log_record_processor_fork
1 failed, 14 passed, 2 warnings in 5.92s
# not run: tox across the Python matrix, platforms other than Linux
I'll re-run it if main moves this file again before a merge.
|
Thanks for re-running it against |
Description
Fixes #5770.
ConcurrentMultiLogRecordProcessorkeeps aThreadPoolExecutorand neverre-instantiates it after a fork. A child inherits the executor's bookkeeping
but none of its worker threads, and the inherited
_idle_semaphorestillreports a worker as available — so
_adjust_thread_count()starts no thread,the submitted call is never run, and the child's first
emitblocks forever onfuture.result(). No exception, no timeout.ConcurrentMultiSpanProcessorinsdk/trace/__init__.pyalready handlesexactly this: it holds the pool behind
_init_executor()and registersos.register_at_fork(after_in_child=...)to re-instantiate it, guarded by aWeakMethodso the processor stays collectable. This applies the same shape tothe logs processor — same helper name, same guard, same comment — so the two
stay consistent.
Scope note: the
self._lockguardingadd_log_record_processoris alsoinherited and could in principle be held at fork time.
LoggerProviderre-creates its own locks in
_handle_fork, whileConcurrentMultiSpanProcessordoes not touch its lock. I left it alone to keep this to the reported defect
and consistent with the span twin — happy to add it if you'd like it covered.
Type of change
How Has This Been Tested?
Verified on Linux (the issue is
os.fork-only). Python 3.14, running againstthe working tree.
The reporter's repro script, unchanged:
pid=... emitted parent, before forkthenchild still blocked after 5spid=... emitted parent, before fork,pid=... emitted child,child exitedTwo tests added in
opentelemetry-sdk/tests/logs/test_multi_log_processor.py:test_emit_in_child_after_fork— runsscripts/concurrent_multi_log_record_processor_after_fork.pyin asubprocess, mirroring the existing
logger_provider_resource_after_fork.pyharness. It asserts the child's own
emitran and that the parent's executorstill works afterwards. Skipped where
os.fork/os.register_at_forkaremissing, and it passes a
timeout=tosubprocess.runso a regression failsthe test instead of hanging CI.
test_init_executor_hands_out_a_fresh_pool— runs everywhere; asserts there-instantiation yields a different executor, preserves
max_workers, andthat the processor still forwards an emit afterwards.
The fork script talks to its parent over an
os.pipe()rather than the sharedstdout, because parent and child writing to one inherited stdout buffer
interleaved non-deterministically while I was building it.
Results: whole file, 15 tests, pass. Both new tests fail without the source
change (the fork one times out on the child's
emit, which is the bug).