Skip to content

Commit 12373d9

Browse files
pyg410wochinge
andauthored
fix(resource_manager): reinitialize consumer threads after os.fork() (#1658)
* fix(resource_manager): reinitialize consumer threads after os.fork() Signed-off-by: 박영규 <pyg410@naver.com> * fix(resource_manager): catch and log errors during fork child consumer reinitialization Signed-off-by: 박영규 <pyg410@naver.com> * fix(resource_manager): reinitialize _lock after fork to prevent deadlock in child process Signed-off-by: 박영규 <pyg410@naver.com> * fix(resource_manager): recreate HTTP clients after fork to prevent process-unsafe socket sharing Signed-off-by: 박영규 <pyg410@naver.com> * fix(resource_manager): preserve custom httpx_client after fork instead of replacing it Signed-off-by: 박영규 <pyg410@naver.com> * fix(resource_manager): defer post-fork reinitialization to avoid segfault Signed-off-by: 박영규 <pyg410@naver.com> * fix(resource_manager): skip macOS proxy discovery after fork to prevent segfault Signed-off-by: 박영규 <pyg410@naver.com> * fix(resource_manager): reuse client initialization after fork * fix(client): delegate api access through resources --------- Signed-off-by: 박영규 <pyg410@naver.com> Co-authored-by: Tobias Wochinger <tobias.wochinger@clickhouse.com>
1 parent 9c13e44 commit 12373d9

4 files changed

Lines changed: 482 additions & 68 deletions

File tree

langfuse/_client/client.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
from langfuse._utils.parse_error import handle_fern_exception
9696
from langfuse._utils.prompt_cache import PromptCache
9797
from langfuse.api import (
98+
AsyncLangfuseAPI,
9899
CreateChatPromptRequest,
99100
CreateChatPromptType,
100101
CreateTextPromptRequest,
@@ -105,6 +106,7 @@
105106
DatasetStatus,
106107
DeleteDatasetRunResponse,
107108
Error,
109+
LangfuseAPI,
108110
MapValue,
109111
NotFoundError,
110112
PaginatedDatasetRuns,
@@ -176,6 +178,13 @@ class Langfuse:
176178
host (Optional[str]): Deprecated. Use base_url instead. The Langfuse API host URL. Defaults to "https://cloud.langfuse.com".
177179
timeout (Optional[int]): Timeout in seconds for API requests. Defaults to 5 seconds.
178180
httpx_client (Optional[httpx.Client]): Custom httpx client for making non-tracing HTTP requests. If not provided, a default client will be created.
181+
**Fork safety**: ``httpx.Client`` is thread-safe but not process-safe. When using
182+
``fork()``-based servers (e.g. Gunicorn with ``--preload``), the SDK automatically
183+
recreates its internally-managed HTTP client in child processes after fork. A custom
184+
``httpx_client`` is intentionally left as-is (the fork-inherited copy is reused), so
185+
you retain the opportunity to handle process-safety yourself — for example by
186+
registering your own ``os.register_at_fork(after_in_child=...)`` handler to close and
187+
reopen connections on the custom client.
179188
debug (bool): Enable debug logging. Defaults to False. Can also be set via LANGFUSE_DEBUG environment variable.
180189
tracing_enabled (Optional[bool]): Enable or disable tracing. Defaults to True. Can also be set via LANGFUSE_TRACING_ENABLED environment variable.
181190
flush_at (Optional[int]): Number of spans to batch before sending to the API. Defaults to 512. Can also be set via LANGFUSE_FLUSH_AT environment variable.
@@ -409,8 +418,34 @@ def __init__(
409418
if self._tracing_enabled and self._resources.tracer is not None
410419
else otel_trace_api.NoOpTracer()
411420
)
412-
self.api = self._resources.api
413-
self.async_api = self._resources.async_api
421+
422+
@property
423+
def api(self) -> LangfuseAPI:
424+
if self._resources is None:
425+
raise AttributeError("Langfuse client is not initialized")
426+
427+
return self._resources.api
428+
429+
@api.setter
430+
def api(self, value: LangfuseAPI) -> None:
431+
if self._resources is None:
432+
raise AttributeError("Langfuse client is not initialized")
433+
434+
self._resources.api = value
435+
436+
@property
437+
def async_api(self) -> AsyncLangfuseAPI:
438+
if self._resources is None:
439+
raise AttributeError("Langfuse client is not initialized")
440+
441+
return self._resources.async_api
442+
443+
@async_api.setter
444+
def async_api(self, value: AsyncLangfuseAPI) -> None:
445+
if self._resources is None:
446+
raise AttributeError("Langfuse client is not initialized")
447+
448+
self._resources.async_api = value
414449

415450
@overload
416451
def start_observation(

langfuse/_client/resource_manager.py

Lines changed: 193 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616

1717
import atexit
1818
import os
19+
import sys
1920
import threading
21+
import urllib.request
22+
import weakref
2023
from queue import Full, Queue
2124
from typing import Any, Callable, Dict, List, Optional, cast
2225

@@ -79,6 +82,10 @@ class LangfuseResourceManager:
7982

8083
_instances: Dict[str, "LangfuseResourceManager"] = {}
8184
_lock = threading.RLock()
85+
_otel_tracer: Tracer
86+
_media_manager: MediaManager
87+
_media_upload_consumers: List[MediaUploadConsumer]
88+
_ingestion_consumers: List[ScoreIngestionConsumer]
8289

8390
@classmethod
8491
def get_singleton_httpx_client(cls) -> Optional[httpx.Client]:
@@ -201,6 +208,7 @@ def _initialize_instance(
201208
self.mask = mask
202209
self.mask_otel_spans = mask_otel_spans
203210
self.environment = environment
211+
self._shutdown = False
204212

205213
# Store additional client settings for get_client() to use
206214
self.timeout = timeout
@@ -216,60 +224,19 @@ def _initialize_instance(
216224
self.span_exporter = span_exporter
217225
self.tracer_provider: Optional[TracerProvider] = None
218226

219-
# API Clients
220-
221-
## API clients must be singletons because the underlying HTTPX clients
222-
## use connection pools with limited capacity. Creating multiple instances
223-
## could exhaust the OS's maximum number of available TCP sockets (file descriptors),
224-
## leading to connection errors.
225-
if httpx_client is not None:
226-
self.httpx_client = httpx_client
227-
else:
228-
# Create a new httpx client with additional_headers if provided
229-
client_headers = additional_headers if additional_headers else {}
230-
self.httpx_client = httpx.Client(timeout=timeout, headers=client_headers)
231-
232-
self.api = LangfuseAPI(
233-
base_url=base_url,
234-
username=self.public_key,
235-
password=secret_key,
236-
x_langfuse_sdk_name="python",
237-
x_langfuse_sdk_version=langfuse_version,
238-
x_langfuse_public_key=self.public_key,
239-
httpx_client=self.httpx_client,
240-
timeout=timeout,
241-
)
242-
self.async_api = AsyncLangfuseAPI(
243-
base_url=base_url,
244-
username=self.public_key,
245-
password=secret_key,
246-
x_langfuse_sdk_name="python",
247-
x_langfuse_sdk_version=langfuse_version,
248-
x_langfuse_public_key=self.public_key,
249-
timeout=timeout,
250-
)
251-
score_ingestion_client = LangfuseClient(
252-
public_key=self.public_key,
253-
secret_key=secret_key,
254-
base_url=base_url,
255-
version=langfuse_version,
256-
timeout=timeout or 20,
257-
session=self.httpx_client,
258-
)
227+
self._custom_httpx_client = httpx_client
228+
self._init_api_clients()
259229

260230
# Media
261231
self._media_upload_enabled = os.environ.get(
262232
LANGFUSE_MEDIA_UPLOAD_ENABLED, "True"
263233
).lower() not in ("false", "0")
264234

265-
self._media_upload_queue: Queue[Any] = Queue(100_000)
266-
self._media_manager = MediaManager(
267-
api_client=self.api,
268-
httpx_client=self.httpx_client,
269-
media_upload_queue=self._media_upload_queue,
270-
max_retries=3,
235+
self._media_upload_thread_count = media_upload_thread_count or max(
236+
int(os.getenv(LANGFUSE_MEDIA_UPLOAD_THREAD_COUNT, 1)), 1
271237
)
272-
self._media_upload_consumers = []
238+
239+
self._init_media_manager()
273240

274241
# OTEL Tracer
275242
if tracing_enabled:
@@ -303,48 +270,207 @@ def _initialize_instance(
303270
attributes={"public_key": self.public_key},
304271
)
305272

306-
media_upload_thread_count = media_upload_thread_count or max(
307-
int(os.getenv(LANGFUSE_MEDIA_UPLOAD_THREAD_COUNT, 1)), 1
273+
self._init_consumer_threads()
274+
275+
# Prompt cache
276+
self.prompt_cache = PromptCache()
277+
278+
# Register shutdown handler
279+
atexit.register(self.shutdown)
280+
281+
# Register fork handler to reinitialize consumer threads in child process.
282+
# When using Gunicorn with --preload, os.fork() copies memory but not threads
283+
# (POSIX.1: https://pubs.opengroup.org/onlinepubs/9699919799/functions/fork.html).
284+
# Without this, media upload and score ingestion threads are lost after fork,
285+
# causing silent data loss.
286+
#
287+
# Note: LangfuseSpanProcessor (BatchSpanProcessor) already handles fork-safety
288+
# for span export via its own os.register_at_fork. This handler covers the
289+
# remaining background threads managed by LangfuseResourceManager.
290+
#
291+
# weakref.WeakMethod prevents os.register_at_fork from holding a permanent strong
292+
# reference to this instance, which would block garbage collection.
293+
# See: https://github.com/open-telemetry/opentelemetry-python/blob/main/opentelemetry-sdk/src/opentelemetry/sdk/_shared_internal/__init__.py
294+
if hasattr(os, "register_at_fork"):
295+
weak_reinit = weakref.WeakMethod(self._at_fork_reinit)
296+
os.register_at_fork(
297+
# Walrus operator resolves the weak reference once and stores it in
298+
# a temporary variable before calling it. This avoids a TOCTOU window
299+
# where GC could collect the referent between checking for None and
300+
# invoking the method.
301+
after_in_child=lambda: (m := weak_reinit()) and m()
302+
)
303+
304+
langfuse_logger.info(
305+
f"Startup: Langfuse tracer successfully initialized | "
306+
f"public_key={self.public_key} | "
307+
f"base_url={base_url} | "
308+
f"environment={environment or 'default'} | "
309+
f"sample_rate={sample_rate if sample_rate is not None else 1.0} | "
310+
f"media_threads={self._media_upload_thread_count}"
311+
)
312+
313+
def _init_media_manager(self) -> None:
314+
"""Initialize or reset media upload state while preserving manager references."""
315+
self._media_upload_queue: Queue[Any] = Queue(100_000)
316+
if hasattr(self, "_media_manager"):
317+
self._media_manager.reinitialize(
318+
api_client=self.api,
319+
httpx_client=self.httpx_client,
320+
media_upload_queue=self._media_upload_queue,
321+
)
322+
else:
323+
self._media_manager = MediaManager(
324+
api_client=self.api,
325+
httpx_client=self.httpx_client,
326+
media_upload_queue=self._media_upload_queue,
327+
max_retries=3,
328+
)
329+
330+
self._media_upload_consumers = []
331+
332+
def _init_api_clients(self) -> None:
333+
"""Initialize HTTP-backed API clients.
334+
335+
Internally-managed httpx clients are recreated when this method is
336+
called after fork. Caller-provided clients are preserved because their
337+
lifecycle belongs to the caller.
338+
"""
339+
if self._custom_httpx_client is not None:
340+
self.httpx_client = self._custom_httpx_client
341+
else:
342+
client_headers = self.additional_headers if self.additional_headers else {}
343+
self.httpx_client = httpx.Client(
344+
timeout=self.timeout, headers=client_headers
345+
)
346+
347+
self.api = LangfuseAPI(
348+
base_url=self.base_url,
349+
username=self.public_key,
350+
password=self.secret_key,
351+
x_langfuse_sdk_name="python",
352+
x_langfuse_sdk_version=langfuse_version,
353+
x_langfuse_public_key=self.public_key,
354+
httpx_client=self.httpx_client,
355+
timeout=self.timeout,
356+
)
357+
self.async_api = AsyncLangfuseAPI(
358+
base_url=self.base_url,
359+
username=self.public_key,
360+
password=self.secret_key,
361+
x_langfuse_sdk_name="python",
362+
x_langfuse_sdk_version=langfuse_version,
363+
x_langfuse_public_key=self.public_key,
364+
timeout=self.timeout,
365+
)
366+
self._score_ingestion_client = LangfuseClient(
367+
public_key=self.public_key,
368+
secret_key=self.secret_key,
369+
base_url=self.base_url,
370+
version=langfuse_version,
371+
timeout=self.timeout or 20,
372+
session=self.httpx_client,
308373
)
309374

375+
def _init_consumer_threads(self) -> None:
376+
"""Initialize media upload and score ingestion consumer threads."""
310377
if self._media_upload_enabled:
311-
for i in range(media_upload_thread_count):
378+
for i in range(self._media_upload_thread_count):
312379
media_upload_consumer = MediaUploadConsumer(
313380
identifier=i,
314381
media_manager=self._media_manager,
315382
)
316383
media_upload_consumer.start()
317384
self._media_upload_consumers.append(media_upload_consumer)
318385

319-
# Prompt cache
320-
self.prompt_cache = PromptCache()
321-
322386
# Score ingestion
323387
self._score_ingestion_queue: Queue[Any] = Queue(100_000)
324388
self._ingestion_consumers = []
325389

326390
ingestion_consumer = ScoreIngestionConsumer(
327391
ingestion_queue=self._score_ingestion_queue,
328392
identifier=0,
329-
client=score_ingestion_client,
330-
flush_at=flush_at,
331-
flush_interval=flush_interval,
393+
client=self._score_ingestion_client,
394+
flush_at=self.flush_at,
395+
flush_interval=self.flush_interval,
332396
max_retries=3,
333397
public_key=self.public_key,
334398
)
335399
ingestion_consumer.start()
336400
self._ingestion_consumers.append(ingestion_consumer)
337401

338-
# Register shutdown handler
339-
atexit.register(self.shutdown)
402+
def _at_fork_reinit(self) -> None:
403+
"""Reinitialize consumer threads after fork in child process.
340404
341-
langfuse_logger.info(
342-
f"Startup: Langfuse tracer successfully initialized | "
343-
f"public_key={self.public_key} | "
344-
f"base_url={base_url} | "
345-
f"environment={environment or 'default'} | "
346-
f"sample_rate={sample_rate if sample_rate is not None else 1.0} | "
347-
f"media_threads={media_upload_thread_count or 1}"
405+
Called automatically via os.register_at_fork() after fork().
406+
Necessary for Gunicorn --preload deployments where os.fork() is used:
407+
threads are not copied to child processes (POSIX standard), so without
408+
reinitialization, the child process has no consumer threads and all
409+
media upload and score ingestion events are silently lost.
410+
411+
Note: LangfuseSpanProcessor (BatchSpanProcessor) handles span export
412+
fork-safety separately via its own os.register_at_fork handler.
413+
414+
Skipped if shutdown() was already called on this instance, to avoid
415+
restarting threads on an intentionally torn-down manager.
416+
"""
417+
# The class-level lock may have been held by a thread in the parent at fork time.
418+
# That thread does not exist in the child, so the lock can never be released and
419+
# any attempt to acquire it would deadlock. Replace it before the shutdown check:
420+
# the lock is class-level state needed by the child (e.g. to create a new client)
421+
# even if this particular instance was already shut down.
422+
LangfuseResourceManager._lock = threading.RLock()
423+
424+
if self._shutdown:
425+
return
426+
427+
if sys.platform == "darwin" and not urllib.request.getproxies_environment():
428+
# urllib proxy discovery falls back to macOS SystemConfiguration APIs that
429+
# are not safe to invoke after fork(). Setting no_proxy="*" makes httpx and
430+
# requests skip that lookup entirely in this child process. Skipped when
431+
# proxies are configured via environment variables: urllib then never touches
432+
# SystemConfiguration (no segfault risk), and overriding no_proxy would
433+
# disable the user's proxy setup process-wide.
434+
os.environ["no_proxy"] = "*"
435+
os.environ["NO_PROXY"] = "*"
436+
437+
langfuse_logger.debug(
438+
f"[PID {os.getpid()}] Fork detected: reinitializing Langfuse consumer threads."
439+
)
440+
441+
# Queues are intentionally recreated after fork. Items enqueued before fork
442+
# belong to the preloaded parent process and must not be processed by every
443+
# worker — otherwise uploads/scores would be duplicated across workers.
444+
#
445+
# Internally-managed httpx clients must also be recreated: fork() duplicates the
446+
# parent's connection pool (TCP socket file descriptors) into the child. Both
447+
# processes then share the same underlying sockets, causing data corruption and
448+
# SSL/TLS state mismatch under concurrent use. Fresh clients start with an empty
449+
# pool owned solely by this child process.
450+
#
451+
# Custom httpx clients provided by the caller are NOT recreated. The fork-inherited
452+
# copy is reused as-is, giving the caller the opportunity to handle process-safety
453+
# themselves (e.g. by registering their own os.register_at_fork handler).
454+
try:
455+
self._init_api_clients()
456+
except Exception as e:
457+
langfuse_logger.error(
458+
f"[PID {os.getpid()}] Failed to recreate HTTP clients after fork: {e}. "
459+
f"Network requests may fail in this worker."
460+
)
461+
462+
try:
463+
self._init_media_manager()
464+
self._init_consumer_threads()
465+
self.prompt_cache = PromptCache()
466+
except Exception as e:
467+
langfuse_logger.error(
468+
f"[PID {os.getpid()}] Failed to reinitialize consumer threads after fork: {e}. "
469+
f"Media upload, score ingestion, and prompt cache refresh will be unavailable in this worker."
470+
)
471+
472+
langfuse_logger.debug(
473+
f"[PID {os.getpid()}] Langfuse consumer threads and prompt cache reinitialized after fork"
348474
)
349475

350476
@classmethod
@@ -486,6 +612,8 @@ def flush(self) -> None:
486612
langfuse_logger.debug("Successfully flushed media upload queue")
487613

488614
def shutdown(self) -> None:
615+
self._shutdown = True
616+
489617
# Unregister the atexit handler first
490618
atexit.unregister(self.shutdown)
491619

0 commit comments

Comments
 (0)