Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions langfuse/_client/resource_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -596,11 +596,11 @@ def flush(self) -> None:
for score_consumer in self._score_consumers:
if score_consumer.is_alive():
score_consumer.wake()

self._join_score_queue()
langfuse_logger.debug("Successfully flushed score queue")

self._media_upload_queue.join()
self._join_media_upload_queue()
langfuse_logger.debug("Successfully flushed media upload queue")

def _join_score_queue(self) -> None:
Expand All @@ -623,6 +623,27 @@ def _join_score_queue(self) -> None:

score_queue.all_tasks_done.wait(timeout=0.1)

def _join_media_upload_queue(self) -> None:
"""Wait until the media upload queue is drained or no consumer is left.

Queue.join() would block forever when the consumers stop first, e.g.
when shutdown() runs concurrently or media is enqueued after shutdown.
"""
media_upload_queue = self._media_upload_queue

with media_upload_queue.all_tasks_done:
while media_upload_queue.unfinished_tasks:
if not any(c.is_alive() for c in self._media_upload_consumers):
langfuse_logger.debug(
"Media upload consumers stopped with %s queue item(s) "
"unprocessed",
media_upload_queue.unfinished_tasks,
)

return

media_upload_queue.all_tasks_done.wait(timeout=0.1)

def shutdown(self) -> None:
self._shutdown = True

Expand Down
81 changes: 80 additions & 1 deletion tests/unit/test_resource_manager.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Test the LangfuseResourceManager and get_client() function."""

import logging
from queue import Queue
import threading
from queue import Empty, Queue
from types import SimpleNamespace
from typing import Sequence
from unittest.mock import Mock
Expand All @@ -14,6 +15,7 @@
from langfuse._client.resource_manager import LangfuseResourceManager
from langfuse._task_manager.media_manager import MediaManager
from langfuse._task_manager.media_upload_consumer import MediaUploadConsumer
from langfuse._task_manager.media_upload_queue import UploadMediaJob
from langfuse._task_manager.score_consumer import ScoreConsumer
from langfuse.types import MaskOtelSpansResult

Expand Down Expand Up @@ -471,3 +473,80 @@ def signal_shutdown(self, *, count):
("join", 0),
("join", 1),
]


def test_flush_returns_when_media_consumers_stopped_with_pending_items():
"""flush() must not block on the media queue when no consumer is left.

Mirrors the score-queue guard: Queue.join() blocks forever when media
upload consumers have stopped with items still pending, e.g. when media is
enqueued after shutdown() or shutdown() runs concurrently.
"""
with LangfuseResourceManager._lock:
LangfuseResourceManager._instances.clear()

client = Langfuse(
public_key="pk-flush-media-join",
secret_key="sk-flush-media-join",
span_exporter=NoOpSpanExporter(),
)
rm = client._resources
assert rm is not None
assert len(rm._media_upload_consumers) > 0

# Simulate a shutdown race: consumers stopped with an item still pending.
# Consumers poll with a 1s timeout, so an unbounded join is safe here.
for consumer in rm._media_upload_consumers:
consumer.pause()
for consumer in rm._media_upload_consumers:
consumer.join()
assert not consumer.is_alive()
Comment on lines +499 to +503

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Shutdown race lacks a test

The new test stops every media consumer before calling flush(), so it never reaches the timed wait in _join_media_upload_queue(). It would still pass if that wait became untimed, bringing back the hang when the last consumer exits without another queue notification.

Add a test that keeps a consumer alive until the wait starts, then stops it with an unfinished item. This is a non-blocking coverage improvement for the concurrent-shutdown case.

Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/test_resource_manager.py
Line: 499-503

Comment:
**Shutdown race lacks a test**

The new test stops every media consumer before calling `flush()`, so it never reaches the timed wait in `_join_media_upload_queue()`. It would still pass if that wait became untimed, bringing back the hang when the last consumer exits without another queue notification.

Add a test that keeps a consumer alive until the wait starts, then stops it with an unfinished item. This is a non-blocking coverage improvement for the concurrent-shutdown case.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


rm._media_upload_queue.put(
UploadMediaJob(
media_id="test-media-id",
content_type="image/png",
content_length=3,
content_bytes=b"abc",
content_sha256_hash="",
trace_id="test-trace-id",
observation_id=None,
dataset_id=None,
dataset_item_id=None,
field="input",
)
)

flushed = threading.Event()

def _flush():
rm.flush()
flushed.set()

flush_thread = threading.Thread(target=_flush, daemon=True)
flush_thread.start()
flush_thread.join(timeout=5.0)
hung = not flushed.is_set()

# Unblock flush() on unfixed code so the thread does not linger. Never
# blocks: on fixed code the guard already returned and this just drains.
try:
rm._media_upload_queue.get_nowait()
except Empty:
pass
else:
rm._media_upload_queue.task_done()
flush_thread.join(timeout=5.0)

client.shutdown()

# shutdown() enqueues one sentinel per consumer. Drain them so a later
# flush() (e.g. from the autouse fixture) cannot hang on unfixed code.
while True:
try:
rm._media_upload_queue.get_nowait()
except Empty:
break
rm._media_upload_queue.task_done()

assert not hung, "flush() blocked on the media upload queue with no live consumer"