Repository navigation
fix(media): don't hang flush() after a repeated shutdown() - #2033
Merged
hassiebp merged 2 commits intoOct 21, 2026
Merged
Conversation
Each shutdown() puts one sentinel per media upload consumer on the media queue, and a sentinel counted as an unfinished task until a consumer took it. The first shutdown() stops the consumers, so the sentinels of a repeated or concurrent shutdown() were never taken and flush() blocked forever in the media queue's join(). Mark each sentinel done when it is put, so the queue's join() waits only for uploads.
DavidTraina
marked this pull request as ready for review
October 21, 2026 03:45
5 tasks done
hassiebp
approved these changes
Oct 21, 2026
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?
Calling
shutdown()more than once on a client, one call after another or from two threads at once, makes the nextflush()orshutdown()block forever.You don't need to call
shutdown()twice on the same object to hit this. Aftershutdown(),Langfuse(...)with the same public key andget_client()return a client backed by the same shut-down resource manager. So code that creates a client and callsshutdown()at the end of each request or job hangs on the third request.Cause.
shutdown()stops the media upload consumers by putting one shutdown sentinel per consumer on the media upload queue. Each sentinel counts as an unfinished task until a consumer takes it and callstask_done(). The firstshutdown()'s sentinels are taken and the consumers exit. A second or concurrentshutdown()puts more sentinels on a queue that no consumer reads anymore.unfinished_tasksnever returns to 0, so the_media_upload_queue.join()inflush()waits forever. The score queue is not affected, because its wait already stops once no score consumer is alive.Fix.
MediaManager.signal_shutdown()marks each sentinel done right after putting it, and the consumer no longer callstask_done()when it takes one. A sentinel is a wake-up signal, not an upload, sojoin()no longer waits for it. Uploads are counted exactly as before. The change uses onlyput()andtask_done(), so the media queue behaves the same with gevent's monkey-patchedQueue.This also fixes a sentinel left behind by a single
shutdown(). If an upload is enqueued betweenshutdown()'s ownflush()and its shutdown signal, the consumer finishes that upload, sees that it has been paused, and exits without taking its sentinel. On main, every laterflush()then hangs as well.Unchanged
shutdown()still delivers pending spans, media (create, upload, status update) and scores, and a consumer blocked onget()still wakes immediately.shutdown()still force-flushes spans created after the first one.atexithandler,get_client(), re-creating a client, andLangfuseResourceManager.reset().shutdown()has signalled the consumers is never uploaded, soflush()still waits for it. That needs a separate change.Considered and not done
shutdown(). This also fixes both reported cases. But_at_fork_reinitwould have to replace the lock, because a fork while another thread holds it would deadlock the child. An existing test's fake consumers would need changes. And it misses the single-shutdown()case above.shutdown()run only once. A latershutdown()would then stop flushing spans created after the first one, and code that callsshutdown()at the end of every request relies on that.flush()stop waiting on the media queue once no media consumer is alive, as it does for scores. This would also cover media logged after shutdown, which would then be dropped instead of hanging.all_tasks_done. gevent'sQueuelacks it and needsjoin(timeout=...)instead, so that means two code paths.flush()with pending media in a local benchmark.Type of change
Verification
List the main commands you ran:
tests/unit/test_resource_manager.py::test_flush_returns_after_repeated_shutdown[sequential|concurrent]runs twoshutdown()calls, thenflush(), each on a thread withjoin(timeout=...). An unhandled exception in any thread also fails it.flush()is still blocked after 2 s). Teardown still finishes, because the test acknowledges the leftover sentinels.flush()and 2shutdown()threads on one client, then one moreflush(). Main hung in 5 of 5 iterations; this branch hung in 0 of 40.monkey.patch_all(), Python 3.14,queue.Queueis gevent'sQueue). On main and on this branch,flush()under gevent fails earlier, in the score-queue wait (all_tasks_donedoes not exist on gevent'sQueue). This PR does not touch that wait.Falsetwice; this branch printsTruetwice.GEVENT_MONKEY_DISABLE_QUEUE_QUEUE=true(stdlibQueueon patched threading) and no stand-in, this branch also returns in the sequential, concurrent and 4 flush + 2 shutdown cases.gevent check
shutdown()still sends the pending span, media (POST, PUT, PATCH) and score.shutdown()still exports spans created after the first one.shutdown()case above no longer leavesunfinished_tasksat 1.reset()followed by a new client works.atexit-only exit work.ruff format --check .on the whole repo reportslangfuse/_client/resource_manager.py(trailing whitespace inflush()), on main as well. This PR does not touch that file.Checklist
code_review.md..env.templateif needed. (No public API change; nothing to update.)

Confidence Score: 5/5
The PR appears safe to merge; no actionable issues were found.Summary
Moves shutdown-sentinel task counting from the media consumer to
signal_shutdown(). Repeated shutdown calls no longer leave unfinished signals that blockflush().Reviews (1) · Last reviewed commit: "test(media): fail the repeated-shutdown ..." · Reviewed by Greptile