Skip to content

fix(tracing): clear the exporter's shutdown request when a processor attaches - #4684

Closed
rajarshidattapy wants to merge 4 commits into
openai:mainfrom
rajarshidattapy:fix/tracing-exporter-shutdown-reset
Closed

fix(tracing): clear the exporter's shutdown request when a processor attaches#4684
rajarshidattapy wants to merge 4 commits into
openai:mainfrom
rajarshidattapy:fix/tracing-exporter-shutdown-reset

Conversation

@rajarshidattapy

@rajarshidattapy rajarshidattapy commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Fixes #4683

Problem

BackendSpanExporter._shutdown_event is one-way. BatchTraceProcessor.shutdown(timeout=...)
sets it through _request_shutdown() so an in-flight export abandons its retry backoff
instead of sleeping past the shutdown deadline — but nothing ever clears it, and
default_exporter() caches a module-level singleton, so the exporter outlives the processor
that shut it down.

Every export through that exporter afterwards gives up on the first 5xx or network error
rather than retrying:

exporter = BackendSpanExporter(api_key="test_key", max_retries=3, ...)  # 504s from the mock client

first = BatchTraceProcessor(exporter=exporter)
first.shutdown(timeout=1.0)

second = BatchTraceProcessor(exporter=exporter)   # same singleton exporter
second._queue.put_nowait(span)
second.force_flush()
[non-fatal] Tracing: server error 504, retrying.
[non-fatal] Tracing: shutdown requested during retry backoff, giving up.
post attempts for a 504 batch (max_retries=3): 1

The batch is dropped, and the warning blames a shutdown that finished long ago.

Fix

Make ownership of the request explicit: the processor that requests the exporter's shutdown
releases it, and only once its own worker has stopped.

  • BackendSpanExporter counts outstanding requests: _request_shutdown() increments and
    sets the event, _reset_shutdown() decrements and clears it only when the count reaches
    zero, both under a lock. Processors share an exporter, so one processor's release must not
    speak for another's still-running worker.
  • BatchTraceProcessor requests at most once — the request and the release that balances it
    are both taken under a per-processor lock, since shutdown may be called from several
    threads — and releases it once its worker is done: at the end of _run, or in shutdown()
    itself whenever the worker is not running by the time it returns. Both lookups stay
    duck-typed like the existing _request_shutdown call, since TracingExporter requires
    neither.
  • The request is now made before the processor sets its own shutdown event, so a worker
    that exits immediately always sees that the request is owned.

Releasing on worker exit rather than when the next processor attaches is what keeps a
timed-out shutdown safe: a worker that outlived its shutdown(timeout=...) still owns the
cancellation, keeps abandoning its retries, and hands the exporter back only when it stops —
so a replacement processor can never resurrect the backoff of a worker that is supposed to be
going away. The existing
test_batch_trace_processor_shutdown_interrupts_exporter_retry_backoff continues to cover
the abort itself.

No public API changes.

Tests

Two regression tests in tests/test_trace_processor.py, both failing on main and passing
with this change:

  • test_new_processor_restores_exporter_retries_after_a_previous_shutdown — shuts a
    processor down with a timeout, attaches a new one to the same exporter, and asserts the
    next 504 batch is retried max_retries times instead of once (1 → 3).
  • test_worker_that_outlives_shutdown_keeps_the_exporter_cancelled — pins the ownership
    rule: with the worker stuck inside a request, shutdown(timeout=0.05) returns while it is
    still running, a replacement processor attaches, and the abandoned worker must still find
    the exporter cancelled, give up without retrying, and release the request as it exits.
  • test_concurrent_shutdowns_leave_the_exporter_request_balanced — pins the serialization:
    two threads shut the same processor down and rendezvous inside the window between the
    request check and the request itself, so an unsynchronized decision makes two requests
    against the single release the worker performs; the exporter's request count must be back
    to zero afterwards.
  • test_shared_exporter_stays_cancelled_until_every_shutdown_releases — pins the counting:
    two processors share an exporter, the first times out with its worker inside a request and
    the second shuts down cleanly; the shared cancellation must survive the second processor's
    release and lift only once the blocked worker has stopped too.

tests/test_trace_processor.py, tests/tracing/, tests/test_tracing.py and
tests/test_tracing_errors.py pass (tests/tracing/test_import_side_effects.py::test_core_imports_do_not_require_legacy_httpx
fails identically on main in my environment). ruff check, ruff format --check and
mypy are clean on both changed files.

…attaches

BackendSpanExporter._shutdown_event is one-way: BatchTraceProcessor.shutdown()
sets it through _request_shutdown() so an in-flight export abandons its retry
backoff, and nothing ever clears it. default_exporter() caches a module-level
singleton, so that instance outlives the processor that shut it down. Every
later export through it gave up on the first 5xx or network error instead of
retrying, and logged a warning blaming a shutdown that was already over.

Clear the request as a new processor attaches to the exporter, which is the
point where the exporter is known to be in service again. Kept duck-typed like
the existing _request_shutdown call, since TracingExporter requires neither.

Fixes openai#4683

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4fb0dd57a1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/agents/tracing/processors.py Outdated
…topped

Clearing the exporter's request as a new processor attached could take the
cancellation away from a worker that still owned it: if shutdown(timeout=...)
timed out while a worker was exporting a failed batch, and a replacement
BatchTraceProcessor was constructed before that worker reached
_sleep_before_retry, the worker slept through its backoff and kept retrying
after the shutdown that was meant to stop it had already returned.

Make ownership explicit instead. The processor that requests the shutdown
remembers it, and releases it only when its own worker is gone: at the end of
_run for a worker that stopped, or after the synchronous drain when no worker
was ever started. A worker abandoned by a timed-out shutdown therefore keeps
the exporter cancelled until it exits, and hands it back on the way out, so
the next processor still gets its retries.

The request is now made before the processor's own shutdown event is set, so
a worker that exits immediately always sees who owns it.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8760e90e7f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/agents/tracing/processors.py Outdated
Processors share an exporter, so a single exporter-wide flag let one release
speak for all of them: a provider shutting its processors down in turn could
time out on a processor whose worker was still inside an HTTP request, then
shut down a second processor whose worker exited and cleared the shared
cancellation. When the blocked request finally returned a 5xx, its worker
found the request gone and retried, after its own shutdown had returned.

Count the outstanding requests instead. _request_shutdown() increments and
sets, _reset_shutdown() decrements and only clears the event when the count
reaches zero, both under a lock. Each processor asks at most once, so its
single release balances its request and never takes the cancellation away
from another processor that still needs it.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 560d2b1bbe

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/agents/tracing/processors.py Outdated
BatchTraceProcessor.shutdown is safe to call from more than one thread, and
the check that kept the exporter request to one per processor was an
unsynchronized read-then-set: two concurrent callers could both find the flag
unset and both increment the exporter's request count, while the worker
released only one of them on its way out. The count never reached zero, so the
shared cancellation stayed in force and every later processor gave up on its
first transient failure instead of retrying.

Move the request behind a per-processor lock, alongside the release that
balances it, and release after a successful join as well -- covering a worker
that had already exited when the request was made, which would otherwise leave
nobody to release it.
@seratch

seratch commented Aug 27, 2026

Copy link
Copy Markdown
Member

Thanks for the careful concurrency work. I do not think we should merge this lifecycle contract as-is.

The reproducer establishes that exporter reuse after BatchTraceProcessor.shutdown(timeout=...) is constructible, but that post-shutdown reuse is not currently an established supported lifecycle. #4712 is simultaneously taking the opposite ownership direction by closing the default exporter on shutdown. We should not land both contracts independently.

My preference is one explicit terminal ownership model: the owner closes the default exporter, and a later tracing initialization creates a fresh exporter and processor. That avoids adding a ref-counted cross-processor cancellation protocol for a reuse path we have not committed to support. I suggest closing this PR for now while keeping the underlying lifecycle question open.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BackendSpanExporter._shutdown_event is never cleared, permanently disabling retries after one shutdown

2 participants