Describe your environment
opentelemetry-api==1.44.0
opentelemetry-sdk==1.44.0
opentelemetry-instrumentation==0.65b0
opentelemetry-instrumentation-celery==0.65b0
opentelemetry-distro==0.65b0
celery==5.6.3
billiard==4.2.4
kombu==5.6.2
Python 3.12.13, linux/arm64 (container), Redis-compatible broker (valkey 9)
What happened?
When a Celery worker recycles its pool children - worker_max_tasks_per_child / --max-tasks-per-child - spans recorded by those children are never exported. The child is terminated as soon as its task returns, while BatchSpanProcessor is still holding that task's spans waiting on its schedule_delay (5s by default). Nothing flushes on the way out, so the queue dies with the process.
There is no error, no warning, and no dropped-span counter. The worker looks healthy, the tasks succeed, and the traces simply never arrive.
At max_tasks_per_child = 1 - which is a real configuration, not a corner case - this loses effectively everything: 10 tasks in, 1 span out.
Steps to Reproduce
Self-contained reproduction (compose + Dockerfile + three small scripts): https://github.com/syron/paperless-ngx-otel/tree/main/repro/celery-prefork
podman compose build && TASKS=10 podman compose run --rm repro
It runs one Celery task that opens one span, ten times, under five configurations, and counts what the console exporter emitted.
Expected Result
Spans recorded in a pool child are exported before that child exits, the same as in every other configuration. Ten tasks should yield ten spans.
Actual Result
|
Worker configuration |
run/repro.emit |
task-span |
| A |
celery --pool prefork |
10 |
10 |
| B |
celery --pool solo |
10 |
10 |
| D |
prefork + sitecustomize shim |
10 |
10 |
| E |
prefork + --max-tasks-per-child 1 |
1 |
1 |
Scenario C uses a bare os.fork() with no Celery at all, and exported all three of its spans: parent-before-fork, child-after-fork and parent-after-fork.
Only the recycling worker loses spans - 9 of 10. The one that survives is the final child, which exits with the worker rather than being recycled.
Additional context
The other scenarios exist to eliminate the explanations that look obvious first, and they all check out:
- The prefork pool is fine (A). Forked children export normally.
--pool solo is avoidance, not a fix (B). It has no children to recycle.
- The SDK's fork handling works (C).
os.register_at_fork restarts the batch processor's worker thread in the child, and a span created after a bare os.fork() exports.
- Attachment method is irrelevant (D).
opentelemetry-instrument and a sitecustomize.py on PYTHONPATH behave identically.
What remains is the recycle itself.
Why this is worth more than the setting suggests. Recycling children after N tasks is standard practice wherever tasks leak memory, much of the image-processing and ML-adjacent Celery population. And because the failure is silent, it does not diagnose as "child recycling". From the outside it looks like "the prefork pool doesn't export", which sends people to a workaround that costs them concurrency for the wrong reason. That is the conclusion I reached first, and it was wrong.
One concrete instance: paperless-ngx hardcodes CELERY_WORKER_MAX_TASKS_PER_CHILD = 1 and does not expose it as a setting, so every paperless deployment is in scenario E and cannot configure its way out.
Suggested fix. Celery already emits worker_process_shutdown in the child before it exits. opentelemetry-instrumentation-celery connects to worker_process_init today; connecting the matching shutdown signal and calling force_flush() on the tracer (and meter) provider would close this on an existing, well-defined path.
An argument could be made that this belongs in core instead - that BatchSpanProcessor should flush when its process is going away regardless of who owns the process - but the Celery signal is the narrower, more targeted fix and does not change behaviour for anyone else.
The same loss applies to metrics recorded in a recycled child, via the same mechanism.
Would you like to implement a fix?
Yes
Tip
React with 👍 to help prioritize this issue. Please use comments to provide useful context, avoiding +1 or me too, to help us triage it. Learn more here.
Describe your environment
opentelemetry-api==1.44.0
opentelemetry-sdk==1.44.0
opentelemetry-instrumentation==0.65b0
opentelemetry-instrumentation-celery==0.65b0
opentelemetry-distro==0.65b0
celery==5.6.3
billiard==4.2.4
kombu==5.6.2
Python 3.12.13, linux/arm64 (container), Redis-compatible broker (valkey 9)
What happened?
When a Celery worker recycles its pool children -
worker_max_tasks_per_child/--max-tasks-per-child- spans recorded by those children are never exported. The child is terminated as soon as its task returns, whileBatchSpanProcessoris still holding that task's spans waiting on itsschedule_delay(5s by default). Nothing flushes on the way out, so the queue dies with the process.There is no error, no warning, and no dropped-span counter. The worker looks healthy, the tasks succeed, and the traces simply never arrive.
At
max_tasks_per_child = 1- which is a real configuration, not a corner case - this loses effectively everything: 10 tasks in, 1 span out.Steps to Reproduce
Self-contained reproduction (compose + Dockerfile + three small scripts): https://github.com/syron/paperless-ngx-otel/tree/main/repro/celery-prefork
It runs one Celery task that opens one span, ten times, under five configurations, and counts what the console exporter emitted.
Expected Result
Spans recorded in a pool child are exported before that child exits, the same as in every other configuration. Ten tasks should yield ten spans.
Actual Result
run/repro.emittask-spancelery --pool preforkcelery --pool solositecustomizeshim--max-tasks-per-child 1Scenario C uses a bare
os.fork()with no Celery at all, and exported all three of its spans:parent-before-fork,child-after-forkandparent-after-fork.Only the recycling worker loses spans - 9 of 10. The one that survives is the final child, which exits with the worker rather than being recycled.
Additional context
The other scenarios exist to eliminate the explanations that look obvious first, and they all check out:
--pool solois avoidance, not a fix (B). It has no children to recycle.os.register_at_forkrestarts the batch processor's worker thread in the child, and a span created after a bareos.fork()exports.opentelemetry-instrumentand asitecustomize.pyonPYTHONPATHbehave identically.What remains is the recycle itself.
Why this is worth more than the setting suggests. Recycling children after N tasks is standard practice wherever tasks leak memory, much of the image-processing and ML-adjacent Celery population. And because the failure is silent, it does not diagnose as "child recycling". From the outside it looks like "the prefork pool doesn't export", which sends people to a workaround that costs them concurrency for the wrong reason. That is the conclusion I reached first, and it was wrong.
One concrete instance: paperless-ngx hardcodes
CELERY_WORKER_MAX_TASKS_PER_CHILD = 1and does not expose it as a setting, so every paperless deployment is in scenario E and cannot configure its way out.Suggested fix. Celery already emits
worker_process_shutdownin the child before it exits.opentelemetry-instrumentation-celeryconnects toworker_process_inittoday; connecting the matching shutdown signal and callingforce_flush()on the tracer (and meter) provider would close this on an existing, well-defined path.An argument could be made that this belongs in core instead - that
BatchSpanProcessorshould flush when its process is going away regardless of who owns the process - but the Celery signal is the narrower, more targeted fix and does not change behaviour for anyone else.The same loss applies to metrics recorded in a recycled child, via the same mechanism.
Would you like to implement a fix?
Yes
Tip
React with 👍 to help prioritize this issue. Please use comments to provide useful context, avoiding
+1orme too, to help us triage it. Learn more here.