Repository navigation
fix(media): don't hang flush() on media logged after shutdown() - #2035
Draft
DavidTraina wants to merge 1 commit into
Draft
DavidTraina wants to merge 1 commit into
DavidTraina wants to merge 1 commit into
Conversation
shutdown() stops the media upload consumers, but a client used afterwards (the same client, Langfuse(...) with the same key, or get_client()) still queues media found in span input, output and metadata. No consumer takes it, so flush() and every later shutdown() block forever in Queue.join(). Media logged while shutdown() stops the consumers can be stranded the same way. shutdown() now stops accepting media right after its own flush(), uploads what was queued since, stops the consumers, then marks done anything a thread that had already passed the check queued meanwhile. Later media is dropped with a warning, like scores created after shutdown().
DavidTraina
force-pushed
the
fix/media-after-shutdown-hang
branch
from
October 21, 2026 10:22
14dda84 to
9c4ecee
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Media logged after
shutdown()makesflush()block forever, and the nextshutdown()too, since it callsflush()first.shutdown()stops the media upload threads but keeps the resource manager registered and the tracer provider running.Langfuse(...)with the same public key andget_client()keep using it, and spans keep exporting. Media in their input, output or metadata still goes on the upload queue, where no consumer is left to take it, andflush()ends withself._media_upload_queue.join(). Media logged whileshutdown()stops the consumers can be stranded the same way: a consumer that is busy when it is paused exits after its current upload, and one that takes its shutdown signal first never reaches an upload queued behind it.This hits code that creates a client and calls
shutdown()at the end of each request or job: the first later request that logs media (aLangfuseMedia, or a base64 data URI in span input) hangs.Fix: as with scores created after
shutdown(), media found onceshutdown()has finished its ownflush()is dropped with a warning instead of queued, sojoin()does not wait on uploads that no consumer will take.MediaManager.stop_accepting_uploads()flips a flag, which_process_media()checks right beforeput().shutdown()flips it right after its ownflush(), not whenshutdown()starts, so media that flush finds is still uploaded. It then joins the media queue once more before stopping the consumers, so media queued between thatflush()and the stop is uploaded too.shutdown()callsMediaManager.drop_pending_uploads(), which marks done anything still on the queue. That covers a thread that read the flag just before it flipped and callsput()while the consumers stop.Unchanged:
flush(). Its latency matches main (below).shutdown(), or found whileshutdown()exports pending spans, is uploaded (POST, PUT, PATCH), along with pending spans and scores.shutdown()still force-flushes spans created after the first one.shutdown(),reset(), fork reinit, andLangfuse(...)/get_client()reusing the shut-down resources.put(),get_nowait(),task_done()andjoin().Media logged after
shutdown()is still not uploaded, as on main, where nothing takes it off the queue either. Its spans still export with the media reference.Not covered: a thread that reads the flag before it flips but calls
put()only aftershutdown()has returned, for example when a signal handler runsshutdown()between the two. Its upload is left on the queue, and the nextflush()hangs, as on main.Considered and not done
shutdown()starts, as for scores. The exporter finds base64 data URIs in span attributes only when it exports the span, which for the last spans happens duringshutdown()'s ownflush(). Stopping intake at the start skipped such a data URI (0 uploads, against 1 on main), and also media logged between thatflush()and the stop.put(), instead of the drain. It also closes the case above, but under gevent with the stdlibqueue.Queue(gevent before 25.4.1, orGEVENT_MONKEY_DISABLE_QUEUE_QUEUE=true), and with media logged both from greenlets and from native threads (the hub threadpool or OS threads), athreading.RLockthere made media logging hang in 8 of 8 runs, against 0 of 8 on main. Without the lock and without the drain, a stress run (8 threads logging media whileshutdown()runs) left a laterflush()hung in 1 of 1200 iterations on Python 3.14 and 1 of 300 on 3.11. With the drain: none of 1200, 300 and 300 (3.14, free-threaded 3.14t, 3.11).flush()'s media wait by consumer liveness instead. This fixes the hang too, including the case this PR does not cover, but every media job logged aftershutdown()then stays on the queue, bytes included, for the life of the process: 1000 spans with a 100 KB image each, logged aftershutdown(), kept 102 MB allocated (RSS 65 to 178 MB), against 1.4 MB with this PR. With a client and ashutdown()per request, that grows with every request until the 100,000-item queue cap. An event-driven wait also needs stdlib's undocumentedQueue.all_tasks_doneplus a separate path for gevent's patchedQueue, which lacks it. Using only the public queue API means polling, and the poll interval sets the cost: in one run with one pending 20 ms upload, the medianflush()was 28.5 ms on main, 109 ms with a 0.1 s poll and 38 ms with a 10 ms poll.Type of change
Verification
New tests in
tests/unit/test_resource_manager.py, all failing on main:test_flush_returns_when_media_is_logged_after_shutdown: on main,flush()was still blocked after 2 s.test_shutdown_uploads_media_logged_after_its_flush: logs two media items right aftershutdown()'s ownflush(), while the single consumer is busy with the first. On main, one was processed and the other was left on the queue.test_flush_returns_when_media_is_queued_while_consumers_stop: queues an upload right after the consumers stop, as a thread that read the flag just before it flipped would. On main, the nextflush()was still blocked after 2 s.Each runs the call on a thread with a timeout and marks leftover queue items done before asserting, so a regression fails instead of hanging the test or the autouse
reset(). Removing the flag check, the secondjoin()or the drain each fails exactly one of them.flush()latency, median of 200 calls, 3 interleaved runs (Python 3.14):Also checked with scripts that are not part of this PR, on Python 3.14, 3.14t (free-threaded) and 3.11 (gevent on 3.14 only):
shutdown(), alone and with a concurrentflush()or secondshutdown(): nothing hung, and every upload queued before the stop was sent exactly once (150 media: 150 POST, 150 PUT, 150 PATCH). On main, the cases with media hung at 4 of the steps.shutdown()delivers its media and score.monkey.patch_all():shutdown()hangs the nextflush()andshutdown()on main and not here (gevent 24.11.1, 25.9.1 and 26.9.0; 26.9.0 also withGEVENT_MONKEY_DISABLE_QUEUE_QUEUE=true).shutdown()uploaded everything queued before the stop, with no duplicates (25.9.1).Skipped:
tests/e2eandtests/live_providerneed a Langfuse server and provider keys; the behavior is covered by the unit tests.Checklist
code_review.md..env.templateif needed. (Not needed.)