Skip to content

opentelemetry-instrumentation-celery: spans lost when worker_max_tasks_per_child recycles the pool child #4916

Description

@syron

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions