From a5efbb80480c63c91f6fd5ea77b73c6709d985a2 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Mon, 9 Dec 2024 16:10:16 -0800 Subject: [PATCH 01/56] start of compression --- .../langsmith/_internal/_background_thread.py | 45 +++++++++++++++++ python/langsmith/_internal/_operations.py | 34 +++++++++++++ python/langsmith/client.py | 48 ++++++++++++++++--- 3 files changed, 120 insertions(+), 7 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index c0f3d46ab..a53209abf 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -1,14 +1,18 @@ from __future__ import annotations import functools +import io import logging import sys import threading +import time import weakref +import zstandard as zstd from queue import Empty, Queue from typing import ( TYPE_CHECKING, List, + Optional, Union, cast, ) @@ -88,6 +92,34 @@ def _tracing_thread_drain_queue( return next_batch +def _tracing_thread_drain_compressed_buffer( + client: Client, + runs_limit: int = 100, + max_buffer_size: int = 50 * 1024 * 1024 +) -> Optional[bytes]: + with client._buffer_lock: + current_size = client.tracing_queue.tell() + + # Check if we should send now + if not (client._run_count >= runs_limit or current_size >= max_buffer_size): + return None + + # Write final boundary and close compression stream + client.compressor_writer.write(f'--{client.boundary}--\r\n'.encode()) + client.compressor_writer.flush() + client.compressor_writer.close() + + client.tracing_queue.seek(0) + data = client.tracing_queue.getvalue() + + # Reinitialize for next batch + client.tracing_queue = io.BytesIO() + client.compressor = zstd.ZstdCompressor() + client.compressor_writer = client.compressor.stream_writer( + client.tracing_queue, closefd=False) + client._run_count = 0 + return data + def _tracing_thread_handle_batch( client: Client, tracing_queue: Queue, @@ -199,6 +231,19 @@ def keep_thread_active() -> bool: ): _tracing_thread_handle_batch(client, tracing_queue, next_batch, use_multipart) +def tracing_control_thread_func_compress(client_ref: weakref.ref[Client]) -> None: + client = client_ref() + if client is None: + return + + while True: + result = _tracing_thread_drain_compressed_buffer(client) + if result is not None: + time.sleep(0.150) # Simulate call to backend + else: + time.sleep(0.1) # Avoid busy-waiting if no data ready + + def _tracing_sub_thread_func( client_ref: weakref.ref[Client], diff --git a/python/langsmith/_internal/_operations.py b/python/langsmith/_internal/_operations.py index 66decff0f..96b33a6af 100644 --- a/python/langsmith/_internal/_operations.py +++ b/python/langsmith/_internal/_operations.py @@ -3,6 +3,8 @@ import itertools import logging import uuid +import io +import zstandard from typing import Literal, Optional, Union, cast from langsmith import schemas as ls_schemas @@ -271,3 +273,35 @@ def serialized_run_operation_to_multipart_parts_and_context( acc_parts, f"trace={op.trace_id},id={op.id}", ) + + +def write_multipart_parts_to_compressor( + parts_and_context: MultipartPartsAndContext, + compressor_writer: zstandard.ZstdCompressorWriter, + boundary: str +) -> None: + compressor_writer.write(f'--{boundary}\r\n'.encode()) + + for part_name, (filename, data, content_type, headers) in parts_and_context.parts: + part_header = f'Content-Disposition: form-data; name="{part_name}"' + if filename: + part_header += f'; filename="{filename}"' + part_header += f'\r\nContent-Type: {content_type}\r\n' + + for header_name, header_value in headers.items(): + part_header += f'{header_name}: {header_value}\r\n' + + part_header += '\r\n' + compressor_writer.write(part_header.encode()) + + if isinstance(data, (bytes, bytearray)): + with memoryview(data) as view: + chunk_size = 1024 * 1024 # 1MB chunks + for i in range(0, len(view), chunk_size): + chunk = view[i:i + chunk_size] + compressor_writer.write(chunk) + else: + compressor_writer.write(str(data).encode()) + + compressor_writer.write(b'\r\n') + diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 1d27e7c99..324753e86 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -33,6 +33,7 @@ import uuid import warnings import weakref +import zstandard from inspect import signature from queue import PriorityQueue from typing import ( @@ -75,6 +76,7 @@ ) from langsmith._internal._background_thread import ( tracing_control_thread_func as _tracing_control_thread_func, + tracing_control_thread_func_compress as _tracing_control_thread_func_compress ) from langsmith._internal._beta_decorator import warn_beta from langsmith._internal._constants import ( @@ -94,6 +96,7 @@ serialize_run_dict, serialized_feedback_operation_to_multipart_parts_and_context, serialized_run_operation_to_multipart_parts_and_context, + write_multipart_parts_to_compressor, ) from langsmith._internal._serde import dumps_json as _dumps_json @@ -489,6 +492,15 @@ def __init__( # Create a session and register a finalizer to close it session_ = session if session else requests.Session() self.session = session_ + self.compress_traces = os.getenv("LANGSMITH_COMPRESS_TRACES") == "true" + if self.compress_traces: + self.compressor = zstandard.ZstdCompressor() + self.compressor_writer = self.compressor.stream_writer( + self.tracing_queue, closefd=False) + self.tracing_queue = io.BytesIO() + self._buffer_lock = threading.Lock() + self._run_count = 0 + self._info = ( info if info is None or isinstance(info, ls_schemas.LangSmithInfo) @@ -497,7 +509,14 @@ def __init__( weakref.finalize(self, close_session, self.session) atexit.register(close_session, session_) # Initialize auto batching - if auto_batch_tracing: + if auto_batch_tracing and self.compress_traces: + threading.Thread( + target=_tracing_control_thread_func_compress, + # arg must be a weakref to self to avoid the Thread object + # preventing garbage collection of the Client object + args=(weakref.ref(self),), + ).start() + elif auto_batch_tracing: self.tracing_queue: Optional[PriorityQueue] = PriorityQueue() threading.Thread( @@ -1291,9 +1310,17 @@ def create_run( self._pyo3_client.create_run(run_create) elif self.tracing_queue is not None: serialized_op = serialize_run_dict("post", run_create) - self.tracing_queue.put( - TracingQueueItem(run_create["dotted_order"], serialized_op) - ) + if self.compress_traces: + multipart_form = self.serialized_run_operation_to_multipart_parts_and_context( + serialized_op) + with self._buffer_lock: + write_multipart_parts_to_compressor( + multipart_form, self.compressor_writer, self.boundary) + self._run_count += 1 + else: + self.tracing_queue.put( + TracingQueueItem(run_create["dotted_order"], serialized_op) + ) else: # Neither Rust nor Python batch ingestion is configured, # fall back to the non-batch approach. @@ -1755,9 +1782,16 @@ def update_run( if use_multipart and self.tracing_queue is not None: # not collecting attachments currently, use empty dict serialized_op = serialize_run_dict(operation="patch", payload=data) - self.tracing_queue.put( - TracingQueueItem(data["dotted_order"], serialized_op) - ) + if self.compress_traces: + multipart_form = serialized_run_operation_to_multipart_parts_and_context(serialized_op) + with self._buffer_lock: + write_multipart_parts_to_compressor( + multipart_form, self.compressor_writer, self.boundary) + self._run_count += 1 + else: + self.tracing_queue.put( + TracingQueueItem(data["dotted_order"], serialized_op) + ) else: self._update_run(data) From c10a8ad6d4fe1d1aedbef0b46dfdf881a1f25367 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Mon, 9 Dec 2024 16:25:40 -0800 Subject: [PATCH 02/56] manually encode --- python/langsmith/_internal/_operations.py | 21 +++++++++++---------- python/langsmith/client.py | 6 +++--- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/python/langsmith/_internal/_operations.py b/python/langsmith/_internal/_operations.py index 96b33a6af..a19640289 100644 --- a/python/langsmith/_internal/_operations.py +++ b/python/langsmith/_internal/_operations.py @@ -275,25 +275,26 @@ def serialized_run_operation_to_multipart_parts_and_context( ) -def write_multipart_parts_to_compressor( - parts_and_context: MultipartPartsAndContext, +def compress_multipart_parts_and_context( + parts_and_context: MultipartPartsAndContext, compressor_writer: zstandard.ZstdCompressorWriter, boundary: str ) -> None: - compressor_writer.write(f'--{boundary}\r\n'.encode()) - for part_name, (filename, data, content_type, headers) in parts_and_context.parts: - part_header = f'Content-Disposition: form-data; name="{part_name}"' + part_header = f'--{boundary}\r\n' + part_header += f'Content-Disposition: form-data; name="{part_name}"' + if filename: part_header += f'; filename="{filename}"' + part_header += f'\r\nContent-Type: {content_type}\r\n' - + for header_name, header_value in headers.items(): part_header += f'{header_name}: {header_value}\r\n' - + part_header += '\r\n' compressor_writer.write(part_header.encode()) - + if isinstance(data, (bytes, bytearray)): with memoryview(data) as view: chunk_size = 1024 * 1024 # 1MB chunks @@ -302,6 +303,6 @@ def write_multipart_parts_to_compressor( compressor_writer.write(chunk) else: compressor_writer.write(str(data).encode()) - + + # Write part terminator compressor_writer.write(b'\r\n') - diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 324753e86..b0d6a88ae 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -96,7 +96,7 @@ serialize_run_dict, serialized_feedback_operation_to_multipart_parts_and_context, serialized_run_operation_to_multipart_parts_and_context, - write_multipart_parts_to_compressor, + compress_multipart_parts_and_context, ) from langsmith._internal._serde import dumps_json as _dumps_json @@ -1314,7 +1314,7 @@ def create_run( multipart_form = self.serialized_run_operation_to_multipart_parts_and_context( serialized_op) with self._buffer_lock: - write_multipart_parts_to_compressor( + compress_multipart_parts_and_context( multipart_form, self.compressor_writer, self.boundary) self._run_count += 1 else: @@ -1785,7 +1785,7 @@ def update_run( if self.compress_traces: multipart_form = serialized_run_operation_to_multipart_parts_and_context(serialized_op) with self._buffer_lock: - write_multipart_parts_to_compressor( + compress_multipart_parts_and_context( multipart_form, self.compressor_writer, self.boundary) self._run_count += 1 else: From d456776c4d128d2077eab11ad31deecd3da42bd1 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Mon, 9 Dec 2024 16:40:19 -0800 Subject: [PATCH 03/56] set boundary --- python/langsmith/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index b0d6a88ae..daf113bb5 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -494,6 +494,7 @@ def __init__( self.session = session_ self.compress_traces = os.getenv("LANGSMITH_COMPRESS_TRACES") == "true" if self.compress_traces: + self.boundary = BOUNDARY self.compressor = zstandard.ZstdCompressor() self.compressor_writer = self.compressor.stream_writer( self.tracing_queue, closefd=False) From 0359235b15a9b773ce96beee41d852d62013eb41 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Mon, 9 Dec 2024 17:11:16 -0800 Subject: [PATCH 04/56] add zstandard --- python/poetry.lock | 206 +++++++++++++++++++++++++++++++++++++++++- python/pyproject.toml | 1 + 2 files changed, 205 insertions(+), 2 deletions(-) diff --git a/python/poetry.lock b/python/poetry.lock index 77d4b5c08..de2ec943d 100644 --- a/python/poetry.lock +++ b/python/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "annotated-types" @@ -109,6 +109,85 @@ files = [ {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, ] +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "charset-normalizer" version = "3.4.0" @@ -1197,6 +1276,17 @@ files = [ {file = "py_spy-0.3.14-py2.py3-none-win_amd64.whl", hash = "sha256:8f5b311d09f3a8e33dbd0d44fc6e37b715e8e0c7efefafcda8bfd63b31ab5a31"}, ] +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + [[package]] name = "pydantic" version = "2.9.2" @@ -2106,6 +2196,118 @@ idna = ">=2.0" multidict = ">=4.0" propcache = ">=0.2.0" +[[package]] +name = "zstandard" +version = "0.23.0" +description = "Zstandard bindings for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "zstandard-0.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bf0a05b6059c0528477fba9054d09179beb63744355cab9f38059548fedd46a9"}, + {file = "zstandard-0.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fc9ca1c9718cb3b06634c7c8dec57d24e9438b2aa9a0f02b8bb36bf478538880"}, + {file = "zstandard-0.23.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77da4c6bfa20dd5ea25cbf12c76f181a8e8cd7ea231c673828d0386b1740b8dc"}, + {file = "zstandard-0.23.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2170c7e0367dde86a2647ed5b6f57394ea7f53545746104c6b09fc1f4223573"}, + {file = "zstandard-0.23.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c16842b846a8d2a145223f520b7e18b57c8f476924bda92aeee3a88d11cfc391"}, + {file = "zstandard-0.23.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:157e89ceb4054029a289fb504c98c6a9fe8010f1680de0201b3eb5dc20aa6d9e"}, + {file = "zstandard-0.23.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:203d236f4c94cd8379d1ea61db2fce20730b4c38d7f1c34506a31b34edc87bdd"}, + {file = "zstandard-0.23.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dc5d1a49d3f8262be192589a4b72f0d03b72dcf46c51ad5852a4fdc67be7b9e4"}, + {file = "zstandard-0.23.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:752bf8a74412b9892f4e5b58f2f890a039f57037f52c89a740757ebd807f33ea"}, + {file = "zstandard-0.23.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:80080816b4f52a9d886e67f1f96912891074903238fe54f2de8b786f86baded2"}, + {file = "zstandard-0.23.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84433dddea68571a6d6bd4fbf8ff398236031149116a7fff6f777ff95cad3df9"}, + {file = "zstandard-0.23.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ab19a2d91963ed9e42b4e8d77cd847ae8381576585bad79dbd0a8837a9f6620a"}, + {file = "zstandard-0.23.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:59556bf80a7094d0cfb9f5e50bb2db27fefb75d5138bb16fb052b61b0e0eeeb0"}, + {file = "zstandard-0.23.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:27d3ef2252d2e62476389ca8f9b0cf2bbafb082a3b6bfe9d90cbcbb5529ecf7c"}, + {file = "zstandard-0.23.0-cp310-cp310-win32.whl", hash = "sha256:5d41d5e025f1e0bccae4928981e71b2334c60f580bdc8345f824e7c0a4c2a813"}, + {file = "zstandard-0.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:519fbf169dfac1222a76ba8861ef4ac7f0530c35dd79ba5727014613f91613d4"}, + {file = "zstandard-0.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:34895a41273ad33347b2fc70e1bff4240556de3c46c6ea430a7ed91f9042aa4e"}, + {file = "zstandard-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:77ea385f7dd5b5676d7fd943292ffa18fbf5c72ba98f7d09fc1fb9e819b34c23"}, + {file = "zstandard-0.23.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:983b6efd649723474f29ed42e1467f90a35a74793437d0bc64a5bf482bedfa0a"}, + {file = "zstandard-0.23.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80a539906390591dd39ebb8d773771dc4db82ace6372c4d41e2d293f8e32b8db"}, + {file = "zstandard-0.23.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:445e4cb5048b04e90ce96a79b4b63140e3f4ab5f662321975679b5f6360b90e2"}, + {file = "zstandard-0.23.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd30d9c67d13d891f2360b2a120186729c111238ac63b43dbd37a5a40670b8ca"}, + {file = "zstandard-0.23.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d20fd853fbb5807c8e84c136c278827b6167ded66c72ec6f9a14b863d809211c"}, + {file = "zstandard-0.23.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ed1708dbf4d2e3a1c5c69110ba2b4eb6678262028afd6c6fbcc5a8dac9cda68e"}, + {file = "zstandard-0.23.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:be9b5b8659dff1f913039c2feee1aca499cfbc19e98fa12bc85e037c17ec6ca5"}, + {file = "zstandard-0.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:65308f4b4890aa12d9b6ad9f2844b7ee42c7f7a4fd3390425b242ffc57498f48"}, + {file = "zstandard-0.23.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:98da17ce9cbf3bfe4617e836d561e433f871129e3a7ac16d6ef4c680f13a839c"}, + {file = "zstandard-0.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8ed7d27cb56b3e058d3cf684d7200703bcae623e1dcc06ed1e18ecda39fee003"}, + {file = "zstandard-0.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:b69bb4f51daf461b15e7b3db033160937d3ff88303a7bc808c67bbc1eaf98c78"}, + {file = "zstandard-0.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:034b88913ecc1b097f528e42b539453fa82c3557e414b3de9d5632c80439a473"}, + {file = "zstandard-0.23.0-cp311-cp311-win32.whl", hash = "sha256:f2d4380bf5f62daabd7b751ea2339c1a21d1c9463f1feb7fc2bdcea2c29c3160"}, + {file = "zstandard-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:62136da96a973bd2557f06ddd4e8e807f9e13cbb0bfb9cc06cfe6d98ea90dfe0"}, + {file = "zstandard-0.23.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b4567955a6bc1b20e9c31612e615af6b53733491aeaa19a6b3b37f3b65477094"}, + {file = "zstandard-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e172f57cd78c20f13a3415cc8dfe24bf388614324d25539146594c16d78fcc8"}, + {file = "zstandard-0.23.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0e166f698c5a3e914947388c162be2583e0c638a4703fc6a543e23a88dea3c1"}, + {file = "zstandard-0.23.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12a289832e520c6bd4dcaad68e944b86da3bad0d339ef7989fb7e88f92e96072"}, + {file = "zstandard-0.23.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d50d31bfedd53a928fed6707b15a8dbeef011bb6366297cc435accc888b27c20"}, + {file = "zstandard-0.23.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72c68dda124a1a138340fb62fa21b9bf4848437d9ca60bd35db36f2d3345f373"}, + {file = "zstandard-0.23.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53dd9d5e3d29f95acd5de6802e909ada8d8d8cfa37a3ac64836f3bc4bc5512db"}, + {file = "zstandard-0.23.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6a41c120c3dbc0d81a8e8adc73312d668cd34acd7725f036992b1b72d22c1772"}, + {file = "zstandard-0.23.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:40b33d93c6eddf02d2c19f5773196068d875c41ca25730e8288e9b672897c105"}, + {file = "zstandard-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9206649ec587e6b02bd124fb7799b86cddec350f6f6c14bc82a2b70183e708ba"}, + {file = "zstandard-0.23.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76e79bc28a65f467e0409098fa2c4376931fd3207fbeb6b956c7c476d53746dd"}, + {file = "zstandard-0.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:66b689c107857eceabf2cf3d3fc699c3c0fe8ccd18df2219d978c0283e4c508a"}, + {file = "zstandard-0.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9c236e635582742fee16603042553d276cca506e824fa2e6489db04039521e90"}, + {file = "zstandard-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8fffdbd9d1408006baaf02f1068d7dd1f016c6bcb7538682622c556e7b68e35"}, + {file = "zstandard-0.23.0-cp312-cp312-win32.whl", hash = "sha256:dc1d33abb8a0d754ea4763bad944fd965d3d95b5baef6b121c0c9013eaf1907d"}, + {file = "zstandard-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:64585e1dba664dc67c7cdabd56c1e5685233fbb1fc1966cfba2a340ec0dfff7b"}, + {file = "zstandard-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:576856e8594e6649aee06ddbfc738fec6a834f7c85bf7cadd1c53d4a58186ef9"}, + {file = "zstandard-0.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38302b78a850ff82656beaddeb0bb989a0322a8bbb1bf1ab10c17506681d772a"}, + {file = "zstandard-0.23.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2240ddc86b74966c34554c49d00eaafa8200a18d3a5b6ffbf7da63b11d74ee2"}, + {file = "zstandard-0.23.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ef230a8fd217a2015bc91b74f6b3b7d6522ba48be29ad4ea0ca3a3775bf7dd5"}, + {file = "zstandard-0.23.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:774d45b1fac1461f48698a9d4b5fa19a69d47ece02fa469825b442263f04021f"}, + {file = "zstandard-0.23.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f77fa49079891a4aab203d0b1744acc85577ed16d767b52fc089d83faf8d8ed"}, + {file = "zstandard-0.23.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac184f87ff521f4840e6ea0b10c0ec90c6b1dcd0bad2f1e4a9a1b4fa177982ea"}, + {file = "zstandard-0.23.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c363b53e257246a954ebc7c488304b5592b9c53fbe74d03bc1c64dda153fb847"}, + {file = "zstandard-0.23.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e7792606d606c8df5277c32ccb58f29b9b8603bf83b48639b7aedf6df4fe8171"}, + {file = "zstandard-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a0817825b900fcd43ac5d05b8b3079937073d2b1ff9cf89427590718b70dd840"}, + {file = "zstandard-0.23.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9da6bc32faac9a293ddfdcb9108d4b20416219461e4ec64dfea8383cac186690"}, + {file = "zstandard-0.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fd7699e8fd9969f455ef2926221e0233f81a2542921471382e77a9e2f2b57f4b"}, + {file = "zstandard-0.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d477ed829077cd945b01fc3115edd132c47e6540ddcd96ca169facff28173057"}, + {file = "zstandard-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ce8b52c5987b3e34d5674b0ab529a4602b632ebab0a93b07bfb4dfc8f8a33"}, + {file = "zstandard-0.23.0-cp313-cp313-win32.whl", hash = "sha256:a9b07268d0c3ca5c170a385a0ab9fb7fdd9f5fd866be004c4ea39e44edce47dd"}, + {file = "zstandard-0.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:f3513916e8c645d0610815c257cbfd3242adfd5c4cfa78be514e5a3ebb42a41b"}, + {file = "zstandard-0.23.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2ef3775758346d9ac6214123887d25c7061c92afe1f2b354f9388e9e4d48acfc"}, + {file = "zstandard-0.23.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4051e406288b8cdbb993798b9a45c59a4896b6ecee2f875424ec10276a895740"}, + {file = "zstandard-0.23.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2d1a054f8f0a191004675755448d12be47fa9bebbcffa3cdf01db19f2d30a54"}, + {file = "zstandard-0.23.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f83fa6cae3fff8e98691248c9320356971b59678a17f20656a9e59cd32cee6d8"}, + {file = "zstandard-0.23.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32ba3b5ccde2d581b1e6aa952c836a6291e8435d788f656fe5976445865ae045"}, + {file = "zstandard-0.23.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f146f50723defec2975fb7e388ae3a024eb7151542d1599527ec2aa9cacb152"}, + {file = "zstandard-0.23.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1bfe8de1da6d104f15a60d4a8a768288f66aa953bbe00d027398b93fb9680b26"}, + {file = "zstandard-0.23.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:29a2bc7c1b09b0af938b7a8343174b987ae021705acabcbae560166567f5a8db"}, + {file = "zstandard-0.23.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:61f89436cbfede4bc4e91b4397eaa3e2108ebe96d05e93d6ccc95ab5714be512"}, + {file = "zstandard-0.23.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:53ea7cdc96c6eb56e76bb06894bcfb5dfa93b7adcf59d61c6b92674e24e2dd5e"}, + {file = "zstandard-0.23.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:a4ae99c57668ca1e78597d8b06d5af837f377f340f4cce993b551b2d7731778d"}, + {file = "zstandard-0.23.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:379b378ae694ba78cef921581ebd420c938936a153ded602c4fea612b7eaa90d"}, + {file = "zstandard-0.23.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:50a80baba0285386f97ea36239855f6020ce452456605f262b2d33ac35c7770b"}, + {file = "zstandard-0.23.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:61062387ad820c654b6a6b5f0b94484fa19515e0c5116faf29f41a6bc91ded6e"}, + {file = "zstandard-0.23.0-cp38-cp38-win32.whl", hash = "sha256:b8c0bd73aeac689beacd4e7667d48c299f61b959475cdbb91e7d3d88d27c56b9"}, + {file = "zstandard-0.23.0-cp38-cp38-win_amd64.whl", hash = "sha256:a05e6d6218461eb1b4771d973728f0133b2a4613a6779995df557f70794fd60f"}, + {file = "zstandard-0.23.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3aa014d55c3af933c1315eb4bb06dd0459661cc0b15cd61077afa6489bec63bb"}, + {file = "zstandard-0.23.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7f0804bb3799414af278e9ad51be25edf67f78f916e08afdb983e74161b916"}, + {file = "zstandard-0.23.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb2b1ecfef1e67897d336de3a0e3f52478182d6a47eda86cbd42504c5cbd009a"}, + {file = "zstandard-0.23.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:837bb6764be6919963ef41235fd56a6486b132ea64afe5fafb4cb279ac44f259"}, + {file = "zstandard-0.23.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1516c8c37d3a053b01c1c15b182f3b5f5eef19ced9b930b684a73bad121addf4"}, + {file = "zstandard-0.23.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48ef6a43b1846f6025dde6ed9fee0c24e1149c1c25f7fb0a0585572b2f3adc58"}, + {file = "zstandard-0.23.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11e3bf3c924853a2d5835b24f03eeba7fc9b07d8ca499e247e06ff5676461a15"}, + {file = "zstandard-0.23.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2fb4535137de7e244c230e24f9d1ec194f61721c86ebea04e1581d9d06ea1269"}, + {file = "zstandard-0.23.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8c24f21fa2af4bb9f2c492a86fe0c34e6d2c63812a839590edaf177b7398f700"}, + {file = "zstandard-0.23.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a8c86881813a78a6f4508ef9daf9d4995b8ac2d147dcb1a450448941398091c9"}, + {file = "zstandard-0.23.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fe3b385d996ee0822fd46528d9f0443b880d4d05528fd26a9119a54ec3f91c69"}, + {file = "zstandard-0.23.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:82d17e94d735c99621bf8ebf9995f870a6b3e6d14543b99e201ae046dfe7de70"}, + {file = "zstandard-0.23.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c7c517d74bea1a6afd39aa612fa025e6b8011982a0897768a2f7c8ab4ebb78a2"}, + {file = "zstandard-0.23.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fd7e0f1cfb70eb2f95a19b472ee7ad6d9a0a992ec0ae53286870c104ca939e5"}, + {file = "zstandard-0.23.0-cp39-cp39-win32.whl", hash = "sha256:43da0f0092281bf501f9c5f6f3b4c975a8a0ea82de49ba3f7100e64d422a1274"}, + {file = "zstandard-0.23.0-cp39-cp39-win_amd64.whl", hash = "sha256:f8346bfa098532bc1fb6c7ef06783e969d87a99dd1d2a5a18a892c1d7a643c58"}, + {file = "zstandard-0.23.0.tar.gz", hash = "sha256:b2d8c62d08e7255f68f7a740bae85b3c9b8e5466baa9cbf7f57f1cde0ac6bc09"}, +] + +[package.dependencies] +cffi = {version = ">=1.11", markers = "platform_python_implementation == \"PyPy\""} + +[package.extras] +cffi = ["cffi (>=1.11)"] + [extras] langsmith-pyo3 = ["langsmith-pyo3"] vcr = [] @@ -2113,4 +2315,4 @@ vcr = [] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0" -content-hash = "c7acc8c8f123bf7968b265a0f0cdd0b679d88559bfbff33488bff25bb4f54f0f" +content-hash = "7b8c702e50f6ae0f5a81bd2da1d6e0277f7fd28c5d02b2d62571573d3ae9f358" diff --git a/python/pyproject.toml b/python/pyproject.toml index a831ff0df..89df9dff8 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -37,6 +37,7 @@ requests-toolbelt = "^1.0.0" # Enabled via `langsmith_pyo3` extra: `pip install langsmith[langsmith_pyo3]`. langsmith-pyo3 = { version = "^0.1.0rc2", optional = true } +zstandard = "^0.23.0" [tool.poetry.group.dev.dependencies] pytest = "^7.3.1" From 08bce98a88512cf00383e454806b428314421688 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Mon, 9 Dec 2024 17:16:06 -0800 Subject: [PATCH 05/56] set limits from config --- python/langsmith/_internal/_background_thread.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index a53209abf..f7ebbc884 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -94,14 +94,14 @@ def _tracing_thread_drain_queue( def _tracing_thread_drain_compressed_buffer( client: Client, - runs_limit: int = 100, - max_buffer_size: int = 50 * 1024 * 1024 + size_limit: int = 100, + size_limit_bytes: int = 50 * 1024 * 1024 ) -> Optional[bytes]: with client._buffer_lock: current_size = client.tracing_queue.tell() # Check if we should send now - if not (client._run_count >= runs_limit or current_size >= max_buffer_size): + if not (client._run_count >= size_limit or current_size >= size_limit_bytes): return None # Write final boundary and close compression stream @@ -155,7 +155,7 @@ def _ensure_ingest_config( ) -> ls_schemas.BatchIngestConfig: default_config = ls_schemas.BatchIngestConfig( use_multipart_endpoint=False, - size_limit_bytes=None, # Note this field is not used here + size_limit_bytes=50 * 1024 * 1024, size_limit=100, scale_up_nthreads_limit=_AUTO_SCALE_UP_NTHREADS_LIMIT, scale_up_qsize_trigger=_AUTO_SCALE_UP_QSIZE_TRIGGER, @@ -235,9 +235,12 @@ def tracing_control_thread_func_compress(client_ref: weakref.ref[Client]) -> Non client = client_ref() if client is None: return - + batch_ingest_config = _ensure_ingest_config(client.info) + size_limit: int = batch_ingest_config["size_limit"] + size_limit_bytes: int = batch_ingest_config["size_limit_bytes"] + while True: - result = _tracing_thread_drain_compressed_buffer(client) + result = _tracing_thread_drain_compressed_buffer(client, size_limit, size_limit_bytes) if result is not None: time.sleep(0.150) # Simulate call to backend else: From 800472a01ae80e24044fb8341307c8cbf3d37d00 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Mon, 9 Dec 2024 17:22:35 -0800 Subject: [PATCH 06/56] add slots --- python/langsmith/client.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index daf113bb5..c6fb5714d 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -391,6 +391,13 @@ class Client: "_settings", "_manual_cleanup", "_pyo3_client", + "compress_traces", + "boundary", + "compressor", + "compressor_writer", + "tracing_queue", + "_run_count", + "_buffer_lock", ] def __init__( From d4e45e1bcde471848745dd21f3c288912bd343c5 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Mon, 9 Dec 2024 17:26:41 -0800 Subject: [PATCH 07/56] lint --- python/langsmith/_internal/_background_thread.py | 6 ++++-- python/langsmith/_internal/_operations.py | 4 ++-- python/langsmith/client.py | 8 +++++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index f7ebbc884..5e986ac9e 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -7,7 +7,6 @@ import threading import time import weakref -import zstandard as zstd from queue import Empty, Queue from typing import ( TYPE_CHECKING, @@ -17,6 +16,8 @@ cast, ) +import zstandard as zstd + from langsmith import schemas as ls_schemas from langsmith._internal._constants import ( _AUTO_SCALE_DOWN_NEMPTY_TRIGGER, @@ -240,7 +241,8 @@ def tracing_control_thread_func_compress(client_ref: weakref.ref[Client]) -> Non size_limit_bytes: int = batch_ingest_config["size_limit_bytes"] while True: - result = _tracing_thread_drain_compressed_buffer(client, size_limit, size_limit_bytes) + result = _tracing_thread_drain_compressed_buffer( + client, size_limit, size_limit_bytes) if result is not None: time.sleep(0.150) # Simulate call to backend else: diff --git a/python/langsmith/_internal/_operations.py b/python/langsmith/_internal/_operations.py index a19640289..9e4c190ae 100644 --- a/python/langsmith/_internal/_operations.py +++ b/python/langsmith/_internal/_operations.py @@ -3,10 +3,10 @@ import itertools import logging import uuid -import io -import zstandard from typing import Literal, Optional, Union, cast +import zstandard + from langsmith import schemas as ls_schemas from langsmith._internal import _orjson from langsmith._internal._multipart import MultipartPart, MultipartPartsAndContext diff --git a/python/langsmith/client.py b/python/langsmith/client.py index c6fb5714d..ea8350bd1 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -33,7 +33,6 @@ import uuid import warnings import weakref -import zstandard from inspect import signature from queue import PriorityQueue from typing import ( @@ -58,6 +57,7 @@ from urllib import parse as urllib_parse import requests +import zstandard from requests import adapters as requests_adapters from requests_toolbelt import ( # type: ignore[import-untyped] multipart as rqtb_multipart, @@ -76,7 +76,9 @@ ) from langsmith._internal._background_thread import ( tracing_control_thread_func as _tracing_control_thread_func, - tracing_control_thread_func_compress as _tracing_control_thread_func_compress +) +from langsmith._internal._background_thread import ( + tracing_control_thread_func_compress as _tracing_control_thread_func_compress, ) from langsmith._internal._beta_decorator import warn_beta from langsmith._internal._constants import ( @@ -92,11 +94,11 @@ SerializedFeedbackOperation, SerializedRunOperation, combine_serialized_queue_operations, + compress_multipart_parts_and_context, serialize_feedback_dict, serialize_run_dict, serialized_feedback_operation_to_multipart_parts_and_context, serialized_run_operation_to_multipart_parts_and_context, - compress_multipart_parts_and_context, ) from langsmith._internal._serde import dumps_json as _dumps_json From 46c87408f89339ddb2cd6d65604d37a7598cf43f Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Mon, 9 Dec 2024 17:58:41 -0800 Subject: [PATCH 08/56] fix mypy --- python/langsmith/_internal/_background_thread.py | 2 +- python/langsmith/_internal/_operations.py | 2 +- python/langsmith/client.py | 13 ++++++------- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 5e986ac9e..0a1d36c61 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -238,7 +238,7 @@ def tracing_control_thread_func_compress(client_ref: weakref.ref[Client]) -> Non return batch_ingest_config = _ensure_ingest_config(client.info) size_limit: int = batch_ingest_config["size_limit"] - size_limit_bytes: int = batch_ingest_config["size_limit_bytes"] + size_limit_bytes: int | None = batch_ingest_config["size_limit_bytes"] while True: result = _tracing_thread_drain_compressed_buffer( diff --git a/python/langsmith/_internal/_operations.py b/python/langsmith/_internal/_operations.py index 9e4c190ae..a6afaa1a0 100644 --- a/python/langsmith/_internal/_operations.py +++ b/python/langsmith/_internal/_operations.py @@ -277,7 +277,7 @@ def serialized_run_operation_to_multipart_parts_and_context( def compress_multipart_parts_and_context( parts_and_context: MultipartPartsAndContext, - compressor_writer: zstandard.ZstdCompressorWriter, + compressor_writer: zstandard.ZstdCompressionWriter, boundary: str ) -> None: for part_name, (filename, data, content_type, headers) in parts_and_context.parts: diff --git a/python/langsmith/client.py b/python/langsmith/client.py index ea8350bd1..ddc389223 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -397,7 +397,6 @@ class Client: "boundary", "compressor", "compressor_writer", - "tracing_queue", "_run_count", "_buffer_lock", ] @@ -504,12 +503,12 @@ def __init__( self.compress_traces = os.getenv("LANGSMITH_COMPRESS_TRACES") == "true" if self.compress_traces: self.boundary = BOUNDARY - self.compressor = zstandard.ZstdCompressor() - self.compressor_writer = self.compressor.stream_writer( + self.compressor: zstandard.ZstdCompressor = zstandard.ZstdCompressor() + self.tracing_queue: io.BytesIO = io.BytesIO() + self.compressor_writer: zstandard.ZstdCompressionWriter = self.compressor.stream_writer( self.tracing_queue, closefd=False) - self.tracing_queue = io.BytesIO() - self._buffer_lock = threading.Lock() - self._run_count = 0 + self._buffer_lock: threading.Lock = threading.Lock() + self._run_count: int = 0 self._info = ( info @@ -1321,7 +1320,7 @@ def create_run( elif self.tracing_queue is not None: serialized_op = serialize_run_dict("post", run_create) if self.compress_traces: - multipart_form = self.serialized_run_operation_to_multipart_parts_and_context( + multipart_form = serialized_run_operation_to_multipart_parts_and_context( serialized_op) with self._buffer_lock: compress_multipart_parts_and_context( From a5cba8d968a5d3070e1bfbbe405257a6557968e5 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Mon, 9 Dec 2024 18:08:32 -0800 Subject: [PATCH 09/56] implement correct timeouts --- .../langsmith/_internal/_background_thread.py | 40 +++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 0a1d36c61..0805859e6 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -240,13 +240,39 @@ def tracing_control_thread_func_compress(client_ref: weakref.ref[Client]) -> Non size_limit: int = batch_ingest_config["size_limit"] size_limit_bytes: int | None = batch_ingest_config["size_limit_bytes"] - while True: - result = _tracing_thread_drain_compressed_buffer( - client, size_limit, size_limit_bytes) - if result is not None: - time.sleep(0.150) # Simulate call to backend - else: - time.sleep(0.1) # Avoid busy-waiting if no data ready + def keep_thread_active() -> bool: + # if `client.cleanup()` was called, stop thread + if not client or ( + hasattr(client, "_manual_cleanup") + and client._manual_cleanup + ): + return False + if not threading.main_thread().is_alive(): + # main thread is dead. should not be active + return False + return True + + while keep_thread_active(): + try: + result = _tracing_thread_drain_compressed_buffer( + client, size_limit, size_limit_bytes) + if result is not None: + # Simulating backend call + time.sleep(0.25) + else: + time.sleep(0.05) + except Exception: + logger.error("Error in tracing compression thread", exc_info=True) + time.sleep(0.1) # Wait before retrying on error + + # Drain the buffer on exit + try: + final_result = _tracing_thread_drain_compressed_buffer( + client, size_limit=1, size_limit_bytes=1) # Force final drain + if final_result is not None: + time.sleep(0.25) # backend call simulation + except Exception: + logger.error("Error in final buffer drain", exc_info=True) From c32cb0cecb417f81e18372af23b6dfcb2ebf8375 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Mon, 9 Dec 2024 19:16:09 -0800 Subject: [PATCH 10/56] stream instead of read data from buffer --- .../langsmith/_internal/_background_thread.py | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 0805859e6..d0a385f5a 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -10,6 +10,7 @@ from queue import Empty, Queue from typing import ( TYPE_CHECKING, + Iterable, List, Optional, Union, @@ -94,10 +95,10 @@ def _tracing_thread_drain_queue( def _tracing_thread_drain_compressed_buffer( - client: Client, + client: "Client", size_limit: int = 100, size_limit_bytes: int = 50 * 1024 * 1024 -) -> Optional[bytes]: +) -> Optional[Iterable[bytes]]: with client._buffer_lock: current_size = client.tracing_queue.tell() @@ -111,7 +112,14 @@ def _tracing_thread_drain_compressed_buffer( client.compressor_writer.close() client.tracing_queue.seek(0) - data = client.tracing_queue.getvalue() + + def data_stream() -> Iterable[bytes]: + chunk_size = 65536 + while True: + chunk = client.tracing_queue.read(chunk_size) + if not chunk: + break + yield chunk # Reinitialize for next batch client.tracing_queue = io.BytesIO() @@ -119,7 +127,8 @@ def _tracing_thread_drain_compressed_buffer( client.compressor_writer = client.compressor.stream_writer( client.tracing_queue, closefd=False) client._run_count = 0 - return data + + return data_stream() def _tracing_thread_handle_batch( client: Client, @@ -254,11 +263,11 @@ def keep_thread_active() -> bool: while keep_thread_active(): try: - result = _tracing_thread_drain_compressed_buffer( + data_stream = _tracing_thread_drain_compressed_buffer( client, size_limit, size_limit_bytes) - if result is not None: - # Simulating backend call - time.sleep(0.25) + if data_stream is not None: + for chunk in data_stream: + time.sleep(0.150) # Backend call simulation else: time.sleep(0.05) except Exception: @@ -267,10 +276,11 @@ def keep_thread_active() -> bool: # Drain the buffer on exit try: - final_result = _tracing_thread_drain_compressed_buffer( + final_data_stream = _tracing_thread_drain_compressed_buffer( client, size_limit=1, size_limit_bytes=1) # Force final drain - if final_result is not None: - time.sleep(0.25) # backend call simulation + if final_data_stream is not None: + for chunk in final_data_stream: + time.sleep(0.150) # Final backend calls except Exception: logger.error("Error in final buffer drain", exc_info=True) From 2aafc203da66523ffc3ca8f8f6631145c3089a5a Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Mon, 9 Dec 2024 19:21:40 -0800 Subject: [PATCH 11/56] fix client type --- python/langsmith/_internal/_background_thread.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index d0a385f5a..8ad31a0a5 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -95,7 +95,7 @@ def _tracing_thread_drain_queue( def _tracing_thread_drain_compressed_buffer( - client: "Client", + client: Client, size_limit: int = 100, size_limit_bytes: int = 50 * 1024 * 1024 ) -> Optional[Iterable[bytes]]: From 03e4e0209d2bc30f6ee41aadaa1e40058c98f5ee Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Mon, 9 Dec 2024 22:28:50 -0800 Subject: [PATCH 12/56] separate compressed buffer from tracing queue --- .../langsmith/_internal/_background_thread.py | 14 ++++--- python/langsmith/client.py | 37 ++++++++++--------- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 8ad31a0a5..932c80ec9 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -100,7 +100,7 @@ def _tracing_thread_drain_compressed_buffer( size_limit_bytes: int = 50 * 1024 * 1024 ) -> Optional[Iterable[bytes]]: with client._buffer_lock: - current_size = client.tracing_queue.tell() + current_size = client.compressed_runs_buffer.tell() # Check if we should send now if not (client._run_count >= size_limit or current_size >= size_limit_bytes): @@ -111,21 +111,21 @@ def _tracing_thread_drain_compressed_buffer( client.compressor_writer.flush() client.compressor_writer.close() - client.tracing_queue.seek(0) + client.compressed_runs_buffer.seek(0) def data_stream() -> Iterable[bytes]: chunk_size = 65536 while True: - chunk = client.tracing_queue.read(chunk_size) + chunk = client.compressed_runs_buffer.read(chunk_size) if not chunk: break yield chunk # Reinitialize for next batch - client.tracing_queue = io.BytesIO() + client.compressed_runs_buffer = io.BytesIO() client.compressor = zstd.ZstdCompressor() client.compressor_writer = client.compressor.stream_writer( - client.tracing_queue, closefd=False) + client.compressed_runs_buffer, closefd=False) client._run_count = 0 return data_stream() @@ -247,7 +247,9 @@ def tracing_control_thread_func_compress(client_ref: weakref.ref[Client]) -> Non return batch_ingest_config = _ensure_ingest_config(client.info) size_limit: int = batch_ingest_config["size_limit"] - size_limit_bytes: int | None = batch_ingest_config["size_limit_bytes"] + size_limit_bytes = batch_ingest_config.get("size_limit_bytes", 50 * 1024 * 1024) + assert size_limit_bytes is not None + def keep_thread_active() -> bool: # if `client.cleanup()` was called, stop thread diff --git a/python/langsmith/client.py b/python/langsmith/client.py index ddc389223..7be479953 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -399,6 +399,7 @@ class Client: "compressor_writer", "_run_count", "_buffer_lock", + "compressed_runs_buffer", ] def __init__( @@ -504,9 +505,9 @@ def __init__( if self.compress_traces: self.boundary = BOUNDARY self.compressor: zstandard.ZstdCompressor = zstandard.ZstdCompressor() - self.tracing_queue: io.BytesIO = io.BytesIO() + self.compressed_runs_buffer: io.BytesIO = io.BytesIO() self.compressor_writer: zstandard.ZstdCompressionWriter = self.compressor.stream_writer( - self.tracing_queue, closefd=False) + self.compressed_runs_buffer, closefd=False) self._buffer_lock: threading.Lock = threading.Lock() self._run_count: int = 0 @@ -1317,19 +1318,19 @@ def create_run( ): if self._pyo3_client is not None: self._pyo3_client.create_run(run_create) + if self.compressed_runs_buffer is not None: + serialized_op = serialize_run_dict("post", run_create) + multipart_form = serialized_run_operation_to_multipart_parts_and_context( + serialized_op) + with self._buffer_lock: + compress_multipart_parts_and_context( + multipart_form, self.compressor_writer, self.boundary) + self._run_count += 1 elif self.tracing_queue is not None: serialized_op = serialize_run_dict("post", run_create) - if self.compress_traces: - multipart_form = serialized_run_operation_to_multipart_parts_and_context( - serialized_op) - with self._buffer_lock: - compress_multipart_parts_and_context( - multipart_form, self.compressor_writer, self.boundary) - self._run_count += 1 - else: - self.tracing_queue.put( - TracingQueueItem(run_create["dotted_order"], serialized_op) - ) + self.tracing_queue.put( + TracingQueueItem(run_create["dotted_order"], serialized_op) + ) else: # Neither Rust nor Python batch ingestion is configured, # fall back to the non-batch approach. @@ -1766,6 +1767,7 @@ def update_run( data["attachments"] = attachments use_multipart = ( self.tracing_queue is not None + or self.compressed_runs_buffer is not None # batch ingest requires trace_id and dotted_order to be set and data["trace_id"] is not None and data["dotted_order"] is not None @@ -1788,19 +1790,20 @@ def update_run( data["events"] = events if data["extra"]: self._insert_runtime_env([data]) - if use_multipart and self.tracing_queue is not None: - # not collecting attachments currently, use empty dict + if use_multipart: serialized_op = serialize_run_dict(operation="patch", payload=data) - if self.compress_traces: + if self.compressed_runs_buffer is not None: multipart_form = serialized_run_operation_to_multipart_parts_and_context(serialized_op) with self._buffer_lock: compress_multipart_parts_and_context( multipart_form, self.compressor_writer, self.boundary) self._run_count += 1 - else: + elif self.tracing_queue is not None: self.tracing_queue.put( TracingQueueItem(data["dotted_order"], serialized_op) ) + else: + self._update_run(data) else: self._update_run(data) From c95253632ed6f6fbef5a67bb5e89ac62136edc10 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Mon, 9 Dec 2024 22:30:13 -0800 Subject: [PATCH 13/56] clean up update multipart --- python/langsmith/client.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 7be479953..86fc1f01b 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -1790,20 +1790,21 @@ def update_run( data["events"] = events if data["extra"]: self._insert_runtime_env([data]) - if use_multipart: - serialized_op = serialize_run_dict(operation="patch", payload=data) - if self.compressed_runs_buffer is not None: - multipart_form = serialized_run_operation_to_multipart_parts_and_context(serialized_op) - with self._buffer_lock: - compress_multipart_parts_and_context( - multipart_form, self.compressor_writer, self.boundary) - self._run_count += 1 - elif self.tracing_queue is not None: - self.tracing_queue.put( - TracingQueueItem(data["dotted_order"], serialized_op) - ) - else: - self._update_run(data) + if not use_multipart: + self._update_run(data) + return + + serialized_op = serialize_run_dict(operation="patch", payload=data) + if self.compressed_runs_buffer is not None: + multipart_form = serialized_run_operation_to_multipart_parts_and_context(serialized_op) + with self._buffer_lock: + compress_multipart_parts_and_context( + multipart_form, self.compressor_writer, self.boundary) + self._run_count += 1 + elif self.tracing_queue is not None: + self.tracing_queue.put( + TracingQueueItem(data["dotted_order"], serialized_op) + ) else: self._update_run(data) From c3fdd361624dce82242324566a97bee7f798f209 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Tue, 10 Dec 2024 09:54:36 -0800 Subject: [PATCH 14/56] address comments --- .../langsmith/_internal/_background_thread.py | 24 +++++++++---------- python/langsmith/_internal/_operations.py | 4 ++-- python/langsmith/client.py | 7 +++--- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 932c80ec9..ef74f7393 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -111,24 +111,24 @@ def _tracing_thread_drain_compressed_buffer( client.compressor_writer.flush() client.compressor_writer.close() - client.compressed_runs_buffer.seek(0) - - def data_stream() -> Iterable[bytes]: - chunk_size = 65536 - while True: - chunk = client.compressed_runs_buffer.read(chunk_size) - if not chunk: - break - yield chunk + filled_buffer = client.compressed_runs_buffer # Reinitialize for next batch client.compressed_runs_buffer = io.BytesIO() - client.compressor = zstd.ZstdCompressor() - client.compressor_writer = client.compressor.stream_writer( + client.compressor_writer = zstd.ZstdCompressor(level=3).stream_writer( client.compressed_runs_buffer, closefd=False) client._run_count = 0 - return data_stream() + filled_buffer.seek(0) + def data_stream() -> Iterable[bytes]: + chunk_size = 65536 + while True: + chunk = filled_buffer.read(chunk_size) + if not chunk: + break + yield chunk + + return data_stream() def _tracing_thread_handle_batch( client: Client, diff --git a/python/langsmith/_internal/_operations.py b/python/langsmith/_internal/_operations.py index a6afaa1a0..f3325063c 100644 --- a/python/langsmith/_internal/_operations.py +++ b/python/langsmith/_internal/_operations.py @@ -5,7 +5,7 @@ import uuid from typing import Literal, Optional, Union, cast -import zstandard +import zstandard as zstd from langsmith import schemas as ls_schemas from langsmith._internal import _orjson @@ -277,7 +277,7 @@ def serialized_run_operation_to_multipart_parts_and_context( def compress_multipart_parts_and_context( parts_and_context: MultipartPartsAndContext, - compressor_writer: zstandard.ZstdCompressionWriter, + compressor_writer: zstd.ZstdCompressionWriter, boundary: str ) -> None: for part_name, (filename, data, content_type, headers) in parts_and_context.parts: diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 86fc1f01b..a409c873d 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -57,7 +57,7 @@ from urllib import parse as urllib_parse import requests -import zstandard +import zstandard as zstd from requests import adapters as requests_adapters from requests_toolbelt import ( # type: ignore[import-untyped] multipart as rqtb_multipart, @@ -501,12 +501,11 @@ def __init__( # Create a session and register a finalizer to close it session_ = session if session else requests.Session() self.session = session_ - self.compress_traces = os.getenv("LANGSMITH_COMPRESS_TRACES") == "true" + self.compress_traces = ls_utils.get_env_var("USE_RUN_COMPRESSION") if self.compress_traces: self.boundary = BOUNDARY - self.compressor: zstandard.ZstdCompressor = zstandard.ZstdCompressor() self.compressed_runs_buffer: io.BytesIO = io.BytesIO() - self.compressor_writer: zstandard.ZstdCompressionWriter = self.compressor.stream_writer( + self.compressor_writer: zstd.ZstdCompressionWriter = zstd.ZstdCompressor().stream_writer( self.compressed_runs_buffer, closefd=False) self._buffer_lock: threading.Lock = threading.Lock() self._run_count: int = 0 From d6b186d22cb4accfc8f2738956a1b186eb65d247 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Tue, 10 Dec 2024 10:35:02 -0800 Subject: [PATCH 15/56] just write directly to compressor instead of streaming --- python/langsmith/_internal/_operations.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/python/langsmith/_internal/_operations.py b/python/langsmith/_internal/_operations.py index f3325063c..23f8e443d 100644 --- a/python/langsmith/_internal/_operations.py +++ b/python/langsmith/_internal/_operations.py @@ -296,11 +296,7 @@ def compress_multipart_parts_and_context( compressor_writer.write(part_header.encode()) if isinstance(data, (bytes, bytearray)): - with memoryview(data) as view: - chunk_size = 1024 * 1024 # 1MB chunks - for i in range(0, len(view), chunk_size): - chunk = view[i:i + chunk_size] - compressor_writer.write(chunk) + compressor_writer.write(data) else: compressor_writer.write(str(data).encode()) From f061c0b93dba99f9fecf0fcf929423e9602a303c Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Tue, 10 Dec 2024 11:12:59 -0800 Subject: [PATCH 16/56] set trcing queue to none --- python/langsmith/client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index a409c873d..80d21ac48 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -509,6 +509,8 @@ def __init__( self.compressed_runs_buffer, closefd=False) self._buffer_lock: threading.Lock = threading.Lock() self._run_count: int = 0 + else: + self.compressed_runs_buffer = None self._info = ( info @@ -519,6 +521,7 @@ def __init__( atexit.register(close_session, session_) # Initialize auto batching if auto_batch_tracing and self.compress_traces: + self.tracing_queue = None threading.Thread( target=_tracing_control_thread_func_compress, # arg must be a weakref to self to avoid the Thread object From 8a0a60e43555390f7b90a172f641bef6c6294498 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Tue, 10 Dec 2024 14:43:55 -0800 Subject: [PATCH 17/56] send multipart req --- .../langsmith/_internal/_background_thread.py | 6 +-- python/langsmith/client.py | 50 +++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index ef74f7393..7ac4a14fe 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -268,8 +268,7 @@ def keep_thread_active() -> bool: data_stream = _tracing_thread_drain_compressed_buffer( client, size_limit, size_limit_bytes) if data_stream is not None: - for chunk in data_stream: - time.sleep(0.150) # Backend call simulation + client._send_compressed_multipart_req(data_stream) else: time.sleep(0.05) except Exception: @@ -281,8 +280,7 @@ def keep_thread_active() -> bool: final_data_stream = _tracing_thread_drain_compressed_buffer( client, size_limit=1, size_limit_bytes=1) # Force final drain if final_data_stream is not None: - for chunk in final_data_stream: - time.sleep(0.150) # Final backend calls + client._send_compressed_multipart_req(final_data_stream) except Exception: logger.error("Error in final buffer drain", exc_info=True) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 80d21ac48..50eb72852 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -1665,6 +1665,7 @@ def multipart_ingest( def _send_multipart_req(self, acc: MultipartPartsAndContext, *, attempts: int = 3): parts = acc.parts _context = acc.context + print(f"Sending multipart request with context: {_context}") for api_url, api_key in self._write_api_urls.items(): for idx in range(1, attempts + 1): try: @@ -1708,6 +1709,55 @@ def _send_multipart_req(self, acc: MultipartPartsAndContext, *, attempts: int = logger.warning(f"Failed to multipart ingest runs: {repr(e)}") # do not retry by default return + + + def _send_compressed_multipart_req(self, data_stream, *, attempts: int = 3): + """Send a zstd-compressed multipart form data stream to the backend using similar retry logic + as _send_multipart_req.""" + + _context = {} + + for api_url, api_key in self._write_api_urls.items(): + for idx in range(1, attempts + 1): + try: + headers = { + **self._headers, + "X-API-KEY": api_key, + "Content-Type": f"multipart/form-data; boundary={self.boundary}", + "Content-Encoding": "zstd", + } + + self.request_with_retries( + "POST", + f"{api_url}/runs/multipart", + request_kwargs={ + "data": data_stream, + "headers": headers, + }, + stop_after_attempt=1, + _context=_context, + ) + break + except ls_utils.LangSmithConflictError: + break + except ( + ls_utils.LangSmithConnectionError, + ls_utils.LangSmithRequestTimeout, + ls_utils.LangSmithAPIError, + ) as exc: + if idx == attempts: + logger.warning(f"Failed to send compressed multipart ingest: {exc}") + else: + continue + except Exception as e: + try: + exc_desc_lines = traceback.format_exception_only(type(e), e) + exc_desc = "".join(exc_desc_lines).rstrip() + logger.warning(f"Failed to send compressed multipart ingest: {exc_desc}") + except Exception: + logger.warning(f"Failed to send compressed multipart ingest: {repr(e)}") + # Do not retry by default after unknown exceptions + return def update_run( self, From b2113ba10cafd11cb10a84dfeafb831c3b8267e5 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Tue, 10 Dec 2024 16:55:45 -0800 Subject: [PATCH 18/56] remove flush --- python/langsmith/_internal/_background_thread.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 7ac4a14fe..f3c378895 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -108,7 +108,6 @@ def _tracing_thread_drain_compressed_buffer( # Write final boundary and close compression stream client.compressor_writer.write(f'--{client.boundary}--\r\n'.encode()) - client.compressor_writer.flush() client.compressor_writer.close() filled_buffer = client.compressed_runs_buffer From 982429a2408f836a7ddc67b039ee7e7afa0d692d Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Tue, 10 Dec 2024 16:57:20 -0800 Subject: [PATCH 19/56] remove print --- python/langsmith/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 50eb72852..c2a106851 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -1665,7 +1665,6 @@ def multipart_ingest( def _send_multipart_req(self, acc: MultipartPartsAndContext, *, attempts: int = 3): parts = acc.parts _context = acc.context - print(f"Sending multipart request with context: {_context}") for api_url, api_key in self._write_api_urls.items(): for idx in range(1, attempts + 1): try: From 5b58940b180509c70910f119a94c93a79d5f0789 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Tue, 10 Dec 2024 17:04:33 -0800 Subject: [PATCH 20/56] set compression level --- python/langsmith/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index c2a106851..acd442715 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -505,7 +505,7 @@ def __init__( if self.compress_traces: self.boundary = BOUNDARY self.compressed_runs_buffer: io.BytesIO = io.BytesIO() - self.compressor_writer: zstd.ZstdCompressionWriter = zstd.ZstdCompressor().stream_writer( + self.compressor_writer: zstd.ZstdCompressionWriter = zstd.ZstdCompressor(level=3).stream_writer( self.compressed_runs_buffer, closefd=False) self._buffer_lock: threading.Lock = threading.Lock() self._run_count: int = 0 From f2e5ed98f992402c8f146c097a5de8286bbcd6d9 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Tue, 10 Dec 2024 17:07:19 -0800 Subject: [PATCH 21/56] remove prints --- python/langsmith/_internal/_background_thread.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index f3c378895..d700db018 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -102,7 +102,6 @@ def _tracing_thread_drain_compressed_buffer( with client._buffer_lock: current_size = client.compressed_runs_buffer.tell() - # Check if we should send now if not (client._run_count >= size_limit or current_size >= size_limit_bytes): return None @@ -112,7 +111,6 @@ def _tracing_thread_drain_compressed_buffer( filled_buffer = client.compressed_runs_buffer - # Reinitialize for next batch client.compressed_runs_buffer = io.BytesIO() client.compressor_writer = zstd.ZstdCompressor(level=3).stream_writer( client.compressed_runs_buffer, closefd=False) From e7dc6bcfbe40571952bd0caf194ac225a289b18c Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Tue, 10 Dec 2024 17:17:54 -0800 Subject: [PATCH 22/56] pass buffer directly to request --- python/langsmith/_internal/_background_thread.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index d700db018..ebe51d358 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -98,7 +98,7 @@ def _tracing_thread_drain_compressed_buffer( client: Client, size_limit: int = 100, size_limit_bytes: int = 50 * 1024 * 1024 -) -> Optional[Iterable[bytes]]: +) -> Optional[bytes]: with client._buffer_lock: current_size = client.compressed_runs_buffer.tell() @@ -117,15 +117,7 @@ def _tracing_thread_drain_compressed_buffer( client._run_count = 0 filled_buffer.seek(0) - def data_stream() -> Iterable[bytes]: - chunk_size = 65536 - while True: - chunk = filled_buffer.read(chunk_size) - if not chunk: - break - yield chunk - - return data_stream() + return filled_buffer def _tracing_thread_handle_batch( client: Client, From fb8fa96b9a680e64e9763f77ae8c8b7ab041c8b3 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Wed, 11 Dec 2024 08:34:50 -0800 Subject: [PATCH 23/56] lint --- python/langsmith/_internal/_background_thread.py | 1 - python/langsmith/client.py | 5 +++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index ebe51d358..eb05fd849 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -10,7 +10,6 @@ from queue import Empty, Queue from typing import ( TYPE_CHECKING, - Iterable, List, Optional, Union, diff --git a/python/langsmith/client.py b/python/langsmith/client.py index eebdd7945..e8e7f5386 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -1712,9 +1712,10 @@ def _send_multipart_req(self, acc: MultipartPartsAndContext, *, attempts: int = def _send_compressed_multipart_req(self, data_stream, *, attempts: int = 3): - """Send a zstd-compressed multipart form data stream to the backend using similar retry logic - as _send_multipart_req.""" + """Send a zstd-compressed multipart form data stream to the backend. + Uses similar retry logic as _send_multipart_req. + """ _context = {} for api_url, api_key in self._write_api_urls.items(): From 9d0a4ec7ab4d2085874d76ced8e358dba525a366 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Wed, 11 Dec 2024 09:01:23 -0800 Subject: [PATCH 24/56] my fixes --- python/langsmith/_internal/_background_thread.py | 4 +++- python/langsmith/client.py | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index eb05fd849..d9d21ce36 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -97,7 +97,9 @@ def _tracing_thread_drain_compressed_buffer( client: Client, size_limit: int = 100, size_limit_bytes: int = 50 * 1024 * 1024 -) -> Optional[bytes]: +) -> Optional[io.BytesIO]: + assert client.compressed_runs_buffer is not None + assert client.compressor_writer is not None with client._buffer_lock: current_size = client.compressed_runs_buffer.tell() diff --git a/python/langsmith/client.py b/python/langsmith/client.py index e8e7f5386..7fa1df534 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -506,7 +506,7 @@ def __init__( self.compress_traces = ls_utils.get_env_var("USE_RUN_COMPRESSION") if self.compress_traces: self.boundary = BOUNDARY - self.compressed_runs_buffer: io.BytesIO = io.BytesIO() + self.compressed_runs_buffer: Optional[io.BytesIO] = io.BytesIO() self.compressor_writer: zstd.ZstdCompressionWriter = zstd.ZstdCompressor(level=3).stream_writer( self.compressed_runs_buffer, closefd=False) self._buffer_lock: threading.Lock = threading.Lock() @@ -523,7 +523,7 @@ def __init__( atexit.register(close_session, session_) # Initialize auto batching if auto_batch_tracing and self.compress_traces: - self.tracing_queue = None + self.tracing_queue: Optional[PriorityQueue] = None threading.Thread( target=_tracing_control_thread_func_compress, # arg must be a weakref to self to avoid the Thread object @@ -531,7 +531,7 @@ def __init__( args=(weakref.ref(self),), ).start() elif auto_batch_tracing: - self.tracing_queue: Optional[PriorityQueue] = PriorityQueue() + self.tracing_queue = PriorityQueue() threading.Thread( target=_tracing_control_thread_func, @@ -1716,7 +1716,7 @@ def _send_compressed_multipart_req(self, data_stream, *, attempts: int = 3): Uses similar retry logic as _send_multipart_req. """ - _context = {} + _context: str = "" for api_url, api_key in self._write_api_urls.items(): for idx in range(1, attempts + 1): From f3e79711941d8dc747e015a3af6d90c669b05361 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Wed, 11 Dec 2024 09:07:54 -0800 Subject: [PATCH 25/56] black reformatting --- .../langsmith/_internal/_background_thread.py | 22 +++++----- python/langsmith/_internal/_operations.py | 26 +++++------ python/langsmith/client.py | 43 +++++++++++++------ 3 files changed, 53 insertions(+), 38 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index d9d21ce36..25155ccf9 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -94,9 +94,7 @@ def _tracing_thread_drain_queue( def _tracing_thread_drain_compressed_buffer( - client: Client, - size_limit: int = 100, - size_limit_bytes: int = 50 * 1024 * 1024 + client: Client, size_limit: int = 100, size_limit_bytes: int = 50 * 1024 * 1024 ) -> Optional[io.BytesIO]: assert client.compressed_runs_buffer is not None assert client.compressor_writer is not None @@ -107,19 +105,21 @@ def _tracing_thread_drain_compressed_buffer( return None # Write final boundary and close compression stream - client.compressor_writer.write(f'--{client.boundary}--\r\n'.encode()) + client.compressor_writer.write(f"--{client.boundary}--\r\n".encode()) client.compressor_writer.close() filled_buffer = client.compressed_runs_buffer client.compressed_runs_buffer = io.BytesIO() client.compressor_writer = zstd.ZstdCompressor(level=3).stream_writer( - client.compressed_runs_buffer, closefd=False) + client.compressed_runs_buffer, closefd=False + ) client._run_count = 0 filled_buffer.seek(0) return filled_buffer + def _tracing_thread_handle_batch( client: Client, tracing_queue: Queue, @@ -231,6 +231,7 @@ def keep_thread_active() -> bool: ): _tracing_thread_handle_batch(client, tracing_queue, next_batch, use_multipart) + def tracing_control_thread_func_compress(client_ref: weakref.ref[Client]) -> None: client = client_ref() if client is None: @@ -240,12 +241,10 @@ def tracing_control_thread_func_compress(client_ref: weakref.ref[Client]) -> Non size_limit_bytes = batch_ingest_config.get("size_limit_bytes", 50 * 1024 * 1024) assert size_limit_bytes is not None - def keep_thread_active() -> bool: # if `client.cleanup()` was called, stop thread if not client or ( - hasattr(client, "_manual_cleanup") - and client._manual_cleanup + hasattr(client, "_manual_cleanup") and client._manual_cleanup ): return False if not threading.main_thread().is_alive(): @@ -256,7 +255,8 @@ def keep_thread_active() -> bool: while keep_thread_active(): try: data_stream = _tracing_thread_drain_compressed_buffer( - client, size_limit, size_limit_bytes) + client, size_limit, size_limit_bytes + ) if data_stream is not None: client._send_compressed_multipart_req(data_stream) else: @@ -268,14 +268,14 @@ def keep_thread_active() -> bool: # Drain the buffer on exit try: final_data_stream = _tracing_thread_drain_compressed_buffer( - client, size_limit=1, size_limit_bytes=1) # Force final drain + client, size_limit=1, size_limit_bytes=1 + ) # Force final drain if final_data_stream is not None: client._send_compressed_multipart_req(final_data_stream) except Exception: logger.error("Error in final buffer drain", exc_info=True) - def _tracing_sub_thread_func( client_ref: weakref.ref[Client], use_multipart: bool, diff --git a/python/langsmith/_internal/_operations.py b/python/langsmith/_internal/_operations.py index 23f8e443d..fca108796 100644 --- a/python/langsmith/_internal/_operations.py +++ b/python/langsmith/_internal/_operations.py @@ -276,29 +276,29 @@ def serialized_run_operation_to_multipart_parts_and_context( def compress_multipart_parts_and_context( - parts_and_context: MultipartPartsAndContext, + parts_and_context: MultipartPartsAndContext, compressor_writer: zstd.ZstdCompressionWriter, - boundary: str + boundary: str, ) -> None: for part_name, (filename, data, content_type, headers) in parts_and_context.parts: - part_header = f'--{boundary}\r\n' + part_header = f"--{boundary}\r\n" part_header += f'Content-Disposition: form-data; name="{part_name}"' - + if filename: part_header += f'; filename="{filename}"' - - part_header += f'\r\nContent-Type: {content_type}\r\n' - + + part_header += f"\r\nContent-Type: {content_type}\r\n" + for header_name, header_value in headers.items(): - part_header += f'{header_name}: {header_value}\r\n' - - part_header += '\r\n' + part_header += f"{header_name}: {header_value}\r\n" + + part_header += "\r\n" compressor_writer.write(part_header.encode()) - + if isinstance(data, (bytes, bytearray)): compressor_writer.write(data) else: compressor_writer.write(str(data).encode()) - + # Write part terminator - compressor_writer.write(b'\r\n') + compressor_writer.write(b"\r\n") diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 7fa1df534..100912e18 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -507,13 +507,14 @@ def __init__( if self.compress_traces: self.boundary = BOUNDARY self.compressed_runs_buffer: Optional[io.BytesIO] = io.BytesIO() - self.compressor_writer: zstd.ZstdCompressionWriter = zstd.ZstdCompressor(level=3).stream_writer( - self.compressed_runs_buffer, closefd=False) + self.compressor_writer: zstd.ZstdCompressionWriter = zstd.ZstdCompressor( + level=3 + ).stream_writer(self.compressed_runs_buffer, closefd=False) self._buffer_lock: threading.Lock = threading.Lock() self._run_count: int = 0 else: self.compressed_runs_buffer = None - + self._info = ( info if info is None or isinstance(info, ls_schemas.LangSmithInfo) @@ -1323,11 +1324,15 @@ def create_run( self._pyo3_client.create_run(run_create) if self.compressed_runs_buffer is not None: serialized_op = serialize_run_dict("post", run_create) - multipart_form = serialized_run_operation_to_multipart_parts_and_context( - serialized_op) + multipart_form = ( + serialized_run_operation_to_multipart_parts_and_context( + serialized_op + ) + ) with self._buffer_lock: compress_multipart_parts_and_context( - multipart_form, self.compressor_writer, self.boundary) + multipart_form, self.compressor_writer, self.boundary + ) self._run_count += 1 elif self.tracing_queue is not None: serialized_op = serialize_run_dict("post", run_create) @@ -1709,15 +1714,14 @@ def _send_multipart_req(self, acc: MultipartPartsAndContext, *, attempts: int = logger.warning(f"Failed to multipart ingest runs: {repr(e)}") # do not retry by default return - - + def _send_compressed_multipart_req(self, data_stream, *, attempts: int = 3): """Send a zstd-compressed multipart form data stream to the backend. Uses similar retry logic as _send_multipart_req. """ _context: str = "" - + for api_url, api_key in self._write_api_urls.items(): for idx in range(1, attempts + 1): try: @@ -1747,16 +1751,22 @@ def _send_compressed_multipart_req(self, data_stream, *, attempts: int = 3): ls_utils.LangSmithAPIError, ) as exc: if idx == attempts: - logger.warning(f"Failed to send compressed multipart ingest: {exc}") + logger.warning( + f"Failed to send compressed multipart ingest: {exc}" + ) else: continue except Exception as e: try: exc_desc_lines = traceback.format_exception_only(type(e), e) exc_desc = "".join(exc_desc_lines).rstrip() - logger.warning(f"Failed to send compressed multipart ingest: {exc_desc}") + logger.warning( + f"Failed to send compressed multipart ingest: {exc_desc}" + ) except Exception: - logger.warning(f"Failed to send compressed multipart ingest: {repr(e)}") + logger.warning( + f"Failed to send compressed multipart ingest: {repr(e)}" + ) # Do not retry by default after unknown exceptions return @@ -1847,10 +1857,15 @@ def update_run( elif use_multipart: serialized_op = serialize_run_dict(operation="patch", payload=data) if self.compressed_runs_buffer is not None: - multipart_form = serialized_run_operation_to_multipart_parts_and_context(serialized_op) + multipart_form = ( + serialized_run_operation_to_multipart_parts_and_context( + serialized_op + ) + ) with self._buffer_lock: compress_multipart_parts_and_context( - multipart_form, self.compressor_writer, self.boundary) + multipart_form, self.compressor_writer, self.boundary + ) self._run_count += 1 elif self.tracing_queue is not None: self.tracing_queue.put( From 066b9b6a2448b33619f51f696c0c46b6db21898b Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Wed, 11 Dec 2024 13:34:36 -0800 Subject: [PATCH 26/56] multithreaded compression --- python/langsmith/_internal/_background_thread.py | 2 +- python/langsmith/client.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 25155ccf9..499ed79bc 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -111,7 +111,7 @@ def _tracing_thread_drain_compressed_buffer( filled_buffer = client.compressed_runs_buffer client.compressed_runs_buffer = io.BytesIO() - client.compressor_writer = zstd.ZstdCompressor(level=3).stream_writer( + client.compressor_writer = zstd.ZstdCompressor(level=3, threads=-1).stream_writer( client.compressed_runs_buffer, closefd=False ) client._run_count = 0 diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 100912e18..89e2c9858 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -508,7 +508,7 @@ def __init__( self.boundary = BOUNDARY self.compressed_runs_buffer: Optional[io.BytesIO] = io.BytesIO() self.compressor_writer: zstd.ZstdCompressionWriter = zstd.ZstdCompressor( - level=3 + level=3, threads=-1 ).stream_writer(self.compressed_runs_buffer, closefd=False) self._buffer_lock: threading.Lock = threading.Lock() self._run_count: int = 0 From ec1566041d65d50f491154f7c5d6f1b66da2344c Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Wed, 11 Dec 2024 16:33:47 -0800 Subject: [PATCH 27/56] add parallel works for sending multipart req --- .../langsmith/_internal/_background_thread.py | 52 ++++++++++++++++--- python/langsmith/client.py | 9 ++-- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 499ed79bc..2b80220c6 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -17,7 +17,7 @@ ) import zstandard as zstd - +import os from langsmith import schemas as ls_schemas from langsmith._internal._constants import ( _AUTO_SCALE_DOWN_NEMPTY_TRIGGER, @@ -94,7 +94,7 @@ def _tracing_thread_drain_queue( def _tracing_thread_drain_compressed_buffer( - client: Client, size_limit: int = 100, size_limit_bytes: int = 50 * 1024 * 1024 + client: Client, size_limit: int = 100, size_limit_bytes: int = 65_536 ) -> Optional[io.BytesIO]: assert client.compressed_runs_buffer is not None assert client.compressor_writer is not None @@ -155,7 +155,7 @@ def _ensure_ingest_config( ) -> ls_schemas.BatchIngestConfig: default_config = ls_schemas.BatchIngestConfig( use_multipart_endpoint=False, - size_limit_bytes=50 * 1024 * 1024, + size_limit_bytes=None, # Note this field is not used here size_limit=100, scale_up_nthreads_limit=_AUTO_SCALE_UP_NTHREADS_LIMIT, scale_up_qsize_trigger=_AUTO_SCALE_UP_QSIZE_TRIGGER, @@ -231,16 +231,44 @@ def keep_thread_active() -> bool: ): _tracing_thread_handle_batch(client, tracing_queue, next_batch, use_multipart) +def _worker_thread_func(client: Client, request_queue: Queue) -> None: + """Worker thread function that processes requests from the queue""" + while True: + try: + data_stream = request_queue.get() + + if data_stream is None: + break + + client._send_compressed_multipart_req(data_stream) + + except Exception: + logger.error("Error in worker thread processing request", exc_info=True) + finally: + request_queue.task_done() -def tracing_control_thread_func_compress(client_ref: weakref.ref[Client]) -> None: +def tracing_control_thread_func_compress_parallel(client_ref: weakref.ref[Client]) -> None: client = client_ref() if client is None: return + batch_ingest_config = _ensure_ingest_config(client.info) size_limit: int = batch_ingest_config["size_limit"] - size_limit_bytes = batch_ingest_config.get("size_limit_bytes", 50 * 1024 * 1024) + size_limit_bytes = batch_ingest_config.get("size_limit_bytes", 65_536) assert size_limit_bytes is not None + num_workers = min(4, os.cpu_count()) + request_queue: Queue = Queue(maxsize=num_workers * 2) + workers = [] + + for _ in range(num_workers): + worker = threading.Thread( + target=_worker_thread_func, + args=(client, request_queue), + ) + worker.start() + workers.append(worker) + def keep_thread_active() -> bool: # if `client.cleanup()` was called, stop thread if not client or ( @@ -258,7 +286,7 @@ def keep_thread_active() -> bool: client, size_limit, size_limit_bytes ) if data_stream is not None: - client._send_compressed_multipart_req(data_stream) + request_queue.put(data_stream) else: time.sleep(0.05) except Exception: @@ -271,9 +299,17 @@ def keep_thread_active() -> bool: client, size_limit=1, size_limit_bytes=1 ) # Force final drain if final_data_stream is not None: - client._send_compressed_multipart_req(final_data_stream) + request_queue.put(final_data_stream) + + request_queue.join() + + for _ in workers: + request_queue.put(None) + for worker in workers: + worker.join() + except Exception: - logger.error("Error in final buffer drain", exc_info=True) + logger.error("Error in final cleanup", exc_info=True) def _tracing_sub_thread_func( diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 89e2c9858..a72dedf3c 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -79,7 +79,7 @@ tracing_control_thread_func as _tracing_control_thread_func, ) from langsmith._internal._background_thread import ( - tracing_control_thread_func_compress as _tracing_control_thread_func_compress, + tracing_control_thread_func_compress_parallel as _tracing_control_thread_func_compress_parallel, ) from langsmith._internal._beta_decorator import warn_beta from langsmith._internal._constants import ( @@ -526,7 +526,7 @@ def __init__( if auto_batch_tracing and self.compress_traces: self.tracing_queue: Optional[PriorityQueue] = None threading.Thread( - target=_tracing_control_thread_func_compress, + target=_tracing_control_thread_func_compress_parallel, # arg must be a weakref to self to avoid the Thread object # preventing garbage collection of the Client object args=(weakref.ref(self),), @@ -1716,10 +1716,7 @@ def _send_multipart_req(self, acc: MultipartPartsAndContext, *, attempts: int = return def _send_compressed_multipart_req(self, data_stream, *, attempts: int = 3): - """Send a zstd-compressed multipart form data stream to the backend. - - Uses similar retry logic as _send_multipart_req. - """ + """Send a zstd-compressed multipart form data stream to the backend.""" _context: str = "" for api_url, api_key in self._write_api_urls.items(): From 35e9843f64e6c235136150258de51e5a8a8e46ee Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Wed, 11 Dec 2024 16:55:17 -0800 Subject: [PATCH 28/56] reformatting --- python/langsmith/_internal/_background_thread.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 2b80220c6..a856df557 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -3,6 +3,7 @@ import functools import io import logging +import os import sys import threading import time @@ -17,7 +18,7 @@ ) import zstandard as zstd -import os + from langsmith import schemas as ls_schemas from langsmith._internal._constants import ( _AUTO_SCALE_DOWN_NEMPTY_TRIGGER, @@ -111,7 +112,9 @@ def _tracing_thread_drain_compressed_buffer( filled_buffer = client.compressed_runs_buffer client.compressed_runs_buffer = io.BytesIO() - client.compressor_writer = zstd.ZstdCompressor(level=3, threads=-1).stream_writer( + client.compressor_writer = zstd.ZstdCompressor( + level=3, threads=-1 + ).stream_writer( client.compressed_runs_buffer, closefd=False ) client._run_count = 0 @@ -232,7 +235,6 @@ def keep_thread_active() -> bool: _tracing_thread_handle_batch(client, tracing_queue, next_batch, use_multipart) def _worker_thread_func(client: Client, request_queue: Queue) -> None: - """Worker thread function that processes requests from the queue""" while True: try: data_stream = request_queue.get() @@ -247,7 +249,8 @@ def _worker_thread_func(client: Client, request_queue: Queue) -> None: finally: request_queue.task_done() -def tracing_control_thread_func_compress_parallel(client_ref: weakref.ref[Client]) -> None: +def tracing_control_thread_func_compress_parallel(client_ref: weakref.ref[Client] + ) -> None: client = client_ref() if client is None: return From 59b5f27d4e66ee6297067cf62946c68139bca1f4 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Wed, 11 Dec 2024 16:55:32 -0800 Subject: [PATCH 29/56] black reformat --- .../langsmith/_internal/_background_thread.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index a856df557..2e8c599d3 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -114,9 +114,7 @@ def _tracing_thread_drain_compressed_buffer( client.compressed_runs_buffer = io.BytesIO() client.compressor_writer = zstd.ZstdCompressor( level=3, threads=-1 - ).stream_writer( - client.compressed_runs_buffer, closefd=False - ) + ).stream_writer(client.compressed_runs_buffer, closefd=False) client._run_count = 0 filled_buffer.seek(0) @@ -234,23 +232,26 @@ def keep_thread_active() -> bool: ): _tracing_thread_handle_batch(client, tracing_queue, next_batch, use_multipart) + def _worker_thread_func(client: Client, request_queue: Queue) -> None: while True: try: data_stream = request_queue.get() - + if data_stream is None: break - + client._send_compressed_multipart_req(data_stream) - + except Exception: logger.error("Error in worker thread processing request", exc_info=True) finally: request_queue.task_done() -def tracing_control_thread_func_compress_parallel(client_ref: weakref.ref[Client] - ) -> None: + +def tracing_control_thread_func_compress_parallel( + client_ref: weakref.ref[Client], +) -> None: client = client_ref() if client is None: return @@ -263,7 +264,7 @@ def tracing_control_thread_func_compress_parallel(client_ref: weakref.ref[Client num_workers = min(4, os.cpu_count()) request_queue: Queue = Queue(maxsize=num_workers * 2) workers = [] - + for _ in range(num_workers): worker = threading.Thread( target=_worker_thread_func, From ce44b253d4344ce523018cc0b5a6f61f2a43f972 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Wed, 11 Dec 2024 16:58:44 -0800 Subject: [PATCH 30/56] mypy --- python/langsmith/_internal/_background_thread.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 2e8c599d3..8e967d63e 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -261,7 +261,7 @@ def tracing_control_thread_func_compress_parallel( size_limit_bytes = batch_ingest_config.get("size_limit_bytes", 65_536) assert size_limit_bytes is not None - num_workers = min(4, os.cpu_count()) + num_workers = min(4, os.cpu_count() or 1) request_queue: Queue = Queue(maxsize=num_workers * 2) workers = [] From 181f83960e68c2651c47dbab883165d2f3ff582c Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Thu, 12 Dec 2024 10:29:04 -0800 Subject: [PATCH 31/56] increase payload size to 20mb --- python/langsmith/_internal/_background_thread.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 8e967d63e..54013c7a6 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -95,7 +95,7 @@ def _tracing_thread_drain_queue( def _tracing_thread_drain_compressed_buffer( - client: Client, size_limit: int = 100, size_limit_bytes: int = 65_536 + client: Client, size_limit: int = 100, size_limit_bytes: int = 20_971_520 ) -> Optional[io.BytesIO]: assert client.compressed_runs_buffer is not None assert client.compressor_writer is not None @@ -258,7 +258,7 @@ def tracing_control_thread_func_compress_parallel( batch_ingest_config = _ensure_ingest_config(client.info) size_limit: int = batch_ingest_config["size_limit"] - size_limit_bytes = batch_ingest_config.get("size_limit_bytes", 65_536) + size_limit_bytes = batch_ingest_config.get("size_limit_bytes", 20_971_520) assert size_limit_bytes is not None num_workers = min(4, os.cpu_count() or 1) From 0120dce1b0935b9abb69e3e3004c60cd126af2d9 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Thu, 12 Dec 2024 10:50:46 -0800 Subject: [PATCH 32/56] use multipart --- python/langsmith/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index a72dedf3c..391f2be74 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -1824,8 +1824,8 @@ def update_run( if attachments: data["attachments"] = attachments use_multipart = ( - self.tracing_queue is not None - or self.compressed_runs_buffer is not None + (self.tracing_queue is not None + or self.compressed_runs_buffer is not None) # batch ingest requires trace_id and dotted_order to be set and data["trace_id"] is not None and data["dotted_order"] is not None From e2ada65f8b6e4dba6fbfc4ff5480e77d40adfd16 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Thu, 12 Dec 2024 14:26:02 -0800 Subject: [PATCH 33/56] use threadpoolexecutor --- .../langsmith/_internal/_background_thread.py | 56 +++++++------------ 1 file changed, 19 insertions(+), 37 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 54013c7a6..6b8bbdcd7 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -8,6 +8,8 @@ import threading import time import weakref +from multiprocessing import cpu_count +import concurrent.futures from queue import Empty, Queue from typing import ( TYPE_CHECKING, @@ -36,6 +38,7 @@ logger = logging.getLogger("langsmith.client") +HTTP_REQUEST_THREAD_POOL = concurrent.futures.ThreadPoolExecutor(max_workers=cpu_count()) @functools.total_ordering class TracingQueueItem: @@ -233,22 +236,6 @@ def keep_thread_active() -> bool: _tracing_thread_handle_batch(client, tracing_queue, next_batch, use_multipart) -def _worker_thread_func(client: Client, request_queue: Queue) -> None: - while True: - try: - data_stream = request_queue.get() - - if data_stream is None: - break - - client._send_compressed_multipart_req(data_stream) - - except Exception: - logger.error("Error in worker thread processing request", exc_info=True) - finally: - request_queue.task_done() - - def tracing_control_thread_func_compress_parallel( client_ref: weakref.ref[Client], ) -> None: @@ -261,18 +248,6 @@ def tracing_control_thread_func_compress_parallel( size_limit_bytes = batch_ingest_config.get("size_limit_bytes", 20_971_520) assert size_limit_bytes is not None - num_workers = min(4, os.cpu_count() or 1) - request_queue: Queue = Queue(maxsize=num_workers * 2) - workers = [] - - for _ in range(num_workers): - worker = threading.Thread( - target=_worker_thread_func, - args=(client, request_queue), - ) - worker.start() - workers.append(worker) - def keep_thread_active() -> bool: # if `client.cleanup()` was called, stop thread if not client or ( @@ -290,7 +265,13 @@ def keep_thread_active() -> bool: client, size_limit, size_limit_bytes ) if data_stream is not None: - request_queue.put(data_stream) + try: + HTTP_REQUEST_THREAD_POOL.submit( + client._send_compressed_multipart_req, data_stream + ) + print("submitted request") + except RuntimeError: + client._send_compressed_multipart_req(data_stream) else: time.sleep(0.05) except Exception: @@ -303,15 +284,16 @@ def keep_thread_active() -> bool: client, size_limit=1, size_limit_bytes=1 ) # Force final drain if final_data_stream is not None: - request_queue.put(final_data_stream) - - request_queue.join() - - for _ in workers: - request_queue.put(None) - for worker in workers: - worker.join() + try: + concurrent.futures.wait( + HTTP_REQUEST_THREAD_POOL.submit( + client._send_compressed_multipart_req, final_data_stream + ) + ) + except RuntimeError: + client._send_compressed_multipart_req(final_data_stream) + except Exception: logger.error("Error in final cleanup", exc_info=True) From 25f4f19aa3ad5ebea11da8c95e1140a6ca181bb8 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Thu, 12 Dec 2024 15:41:00 -0800 Subject: [PATCH 34/56] fix thread garbage collection --- python/langsmith/_internal/_background_thread.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 6b8bbdcd7..31513272a 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -247,6 +247,7 @@ def tracing_control_thread_func_compress_parallel( size_limit: int = batch_ingest_config["size_limit"] size_limit_bytes = batch_ingest_config.get("size_limit_bytes", 20_971_520) assert size_limit_bytes is not None + num_known_refs = 3 def keep_thread_active() -> bool: # if `client.cleanup()` was called, stop thread @@ -257,7 +258,19 @@ def keep_thread_active() -> bool: if not threading.main_thread().is_alive(): # main thread is dead. should not be active return False - return True + if hasattr(sys, "getrefcount"): + # check if client refs count indicates we're the only remaining + # reference to the client + + # Count active threads + thread_pool = HTTP_REQUEST_THREAD_POOL._threads + active_count = sum(1 for thread in thread_pool if thread is not None and thread.is_alive()) + + return sys.getrefcount(client) > num_known_refs + active_count + else: + # in PyPy, there is no sys.getrefcount attribute + # for now, keep thread alive + return True while keep_thread_active(): try: @@ -269,7 +282,6 @@ def keep_thread_active() -> bool: HTTP_REQUEST_THREAD_POOL.submit( client._send_compressed_multipart_req, data_stream ) - print("submitted request") except RuntimeError: client._send_compressed_multipart_req(data_stream) else: From 5b0cca4fffc563958d1208f0c91fabaac0327cd9 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Thu, 12 Dec 2024 15:49:40 -0800 Subject: [PATCH 35/56] add flush method --- python/langsmith/client.py | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 391f2be74..14f18ae9e 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -1887,6 +1887,45 @@ def _update_run(self, run_update: dict) -> None: }, ) + + def flush_compressed_runs(self, attempts: int = 3) -> None: + """ + Forcefully flush the currently buffered compressed runs. + """ + if not self.compress_traces or self.compressed_runs_buffer is None: + return + + # Attempt to drain and send any remaining data + from langsmith._internal._background_thread import _tracing_thread_drain_compressed_buffer, HTTP_REQUEST_THREAD_POOL + + final_data_stream = _tracing_thread_drain_compressed_buffer( + self, size_limit=1, size_limit_bytes=1 + ) + + if final_data_stream is not None: + # We have data to send + future = None + try: + future = HTTP_REQUEST_THREAD_POOL.submit( + self._send_compressed_multipart_req, final_data_stream, attempts=attempts + ) + except RuntimeError: + # In case the ThreadPoolExecutor is already shutdown + self._send_compressed_multipart_req(final_data_stream, attempts=attempts) + + # If we got a future, wait for it to complete + if future is not None: + cf.wait([future]) + + def flush(self) -> None: + """ + A convenience method to flush either queue or compressed buffer, depending on mode. + """ + if self.compress_traces and self.compressed_runs_buffer is not None: + self.flush_compressed_runs() + elif self.tracing_queue is not None: + self.tracing_queue.join() + def _load_child_runs(self, run: ls_schemas.Run) -> ls_schemas.Run: """Load child runs for a given run. From f59f7be596ba9243f9e66543832dd6d9dfbf5c38 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Thu, 12 Dec 2024 15:52:43 -0800 Subject: [PATCH 36/56] return early --- python/langsmith/client.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 14f18ae9e..36b4c45da 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -1902,20 +1902,22 @@ def flush_compressed_runs(self, attempts: int = 3) -> None: self, size_limit=1, size_limit_bytes=1 ) - if final_data_stream is not None: - # We have data to send - future = None - try: - future = HTTP_REQUEST_THREAD_POOL.submit( - self._send_compressed_multipart_req, final_data_stream, attempts=attempts - ) - except RuntimeError: - # In case the ThreadPoolExecutor is already shutdown - self._send_compressed_multipart_req(final_data_stream, attempts=attempts) + if final_data_stream is None: + return + + # We have data to send + future = None + try: + future = HTTP_REQUEST_THREAD_POOL.submit( + self._send_compressed_multipart_req, final_data_stream, attempts=attempts + ) + except RuntimeError: + # In case the ThreadPoolExecutor is already shutdown + self._send_compressed_multipart_req(final_data_stream, attempts=attempts) - # If we got a future, wait for it to complete - if future is not None: - cf.wait([future]) + # If we got a future, wait for it to complete + if future is not None: + cf.wait([future]) def flush(self) -> None: """ From b76e662caa6a1b0f9402b9c3c741c51d16c12581 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Thu, 12 Dec 2024 16:00:29 -0800 Subject: [PATCH 37/56] lint --- .../langsmith/_internal/_background_thread.py | 15 +++++++------ python/langsmith/client.py | 21 +++++++++---------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 31513272a..4964e4258 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -1,15 +1,14 @@ from __future__ import annotations +import concurrent.futures import functools import io import logging -import os import sys import threading import time import weakref from multiprocessing import cpu_count -import concurrent.futures from queue import Empty, Queue from typing import ( TYPE_CHECKING, @@ -38,7 +37,10 @@ logger = logging.getLogger("langsmith.client") -HTTP_REQUEST_THREAD_POOL = concurrent.futures.ThreadPoolExecutor(max_workers=cpu_count()) +HTTP_REQUEST_THREAD_POOL = concurrent.futures.ThreadPoolExecutor( + max_workers=cpu_count() +) + @functools.total_ordering class TracingQueueItem: @@ -261,10 +263,12 @@ def keep_thread_active() -> bool: if hasattr(sys, "getrefcount"): # check if client refs count indicates we're the only remaining # reference to the client - + # Count active threads thread_pool = HTTP_REQUEST_THREAD_POOL._threads - active_count = sum(1 for thread in thread_pool if thread is not None and thread.is_alive()) + active_count = sum( + 1 for thread in thread_pool if thread is not None and thread.is_alive() + ) return sys.getrefcount(client) > num_known_refs + active_count else: @@ -305,7 +309,6 @@ def keep_thread_active() -> bool: except RuntimeError: client._send_compressed_multipart_req(final_data_stream) - except Exception: logger.error("Error in final cleanup", exc_info=True) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 36b4c45da..e09c49b6d 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -1824,8 +1824,7 @@ def update_run( if attachments: data["attachments"] = attachments use_multipart = ( - (self.tracing_queue is not None - or self.compressed_runs_buffer is not None) + (self.tracing_queue is not None or self.compressed_runs_buffer is not None) # batch ingest requires trace_id and dotted_order to be set and data["trace_id"] is not None and data["dotted_order"] is not None @@ -1887,16 +1886,16 @@ def _update_run(self, run_update: dict) -> None: }, ) - def flush_compressed_runs(self, attempts: int = 3) -> None: - """ - Forcefully flush the currently buffered compressed runs. - """ + """Force flush the currently buffered compressed runs.""" if not self.compress_traces or self.compressed_runs_buffer is None: return # Attempt to drain and send any remaining data - from langsmith._internal._background_thread import _tracing_thread_drain_compressed_buffer, HTTP_REQUEST_THREAD_POOL + from langsmith._internal._background_thread import ( + HTTP_REQUEST_THREAD_POOL, + _tracing_thread_drain_compressed_buffer, + ) final_data_stream = _tracing_thread_drain_compressed_buffer( self, size_limit=1, size_limit_bytes=1 @@ -1909,7 +1908,9 @@ def flush_compressed_runs(self, attempts: int = 3) -> None: future = None try: future = HTTP_REQUEST_THREAD_POOL.submit( - self._send_compressed_multipart_req, final_data_stream, attempts=attempts + self._send_compressed_multipart_req, + final_data_stream, + attempts=attempts, ) except RuntimeError: # In case the ThreadPoolExecutor is already shutdown @@ -1920,9 +1921,7 @@ def flush_compressed_runs(self, attempts: int = 3) -> None: cf.wait([future]) def flush(self) -> None: - """ - A convenience method to flush either queue or compressed buffer, depending on mode. - """ + """Flush either queue or compressed buffer, depending on mode.""" if self.compress_traces and self.compressed_runs_buffer is not None: self.flush_compressed_runs() elif self.tracing_queue is not None: From efa4bd6520d7de1f3bb97cdbaddfb25fa63f8c3b Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Thu, 12 Dec 2024 16:04:11 -0800 Subject: [PATCH 38/56] wait --- python/langsmith/_internal/_background_thread.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 4964e4258..61413b869 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -302,9 +302,11 @@ def keep_thread_active() -> bool: if final_data_stream is not None: try: concurrent.futures.wait( - HTTP_REQUEST_THREAD_POOL.submit( - client._send_compressed_multipart_req, final_data_stream - ) + [ + HTTP_REQUEST_THREAD_POOL.submit( + client._send_compressed_multipart_req, final_data_stream + ) + ] ) except RuntimeError: client._send_compressed_multipart_req(final_data_stream) From 0e06bde7e45769dc7d7c92b406043f6126587296 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Fri, 13 Dec 2024 11:25:37 -0800 Subject: [PATCH 39/56] signal bg threads data is available instead of sleeping --- .../langsmith/_internal/_background_thread.py | 37 ++++++++++--------- python/langsmith/client.py | 4 ++ 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 61413b869..7a594980b 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -6,7 +6,6 @@ import logging import sys import threading -import time import weakref from multiprocessing import cpu_count from queue import Empty, Queue @@ -276,23 +275,25 @@ def keep_thread_active() -> bool: # for now, keep thread alive return True - while keep_thread_active(): - try: - data_stream = _tracing_thread_drain_compressed_buffer( - client, size_limit, size_limit_bytes - ) - if data_stream is not None: - try: - HTTP_REQUEST_THREAD_POOL.submit( - client._send_compressed_multipart_req, data_stream - ) - except RuntimeError: - client._send_compressed_multipart_req(data_stream) - else: - time.sleep(0.05) - except Exception: - logger.error("Error in tracing compression thread", exc_info=True) - time.sleep(0.1) # Wait before retrying on error + while True: + triggered = client._data_available_event.wait(timeout=0.05) + if not keep_thread_active(): + break + if not triggered: + continue + client._data_available_event.clear() + + data_stream = _tracing_thread_drain_compressed_buffer( + client, size_limit, size_limit_bytes + ) + + if data_stream is not None: + try: + HTTP_REQUEST_THREAD_POOL.submit( + client._send_compressed_multipart_req, data_stream + ) + except RuntimeError: + client._send_compressed_multipart_req(data_stream) # Drain the buffer on exit try: diff --git a/python/langsmith/client.py b/python/langsmith/client.py index e09c49b6d..759fad1f2 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -402,6 +402,7 @@ class Client: "_run_count", "_buffer_lock", "compressed_runs_buffer", + "_data_available_event", ] def __init__( @@ -511,6 +512,7 @@ def __init__( level=3, threads=-1 ).stream_writer(self.compressed_runs_buffer, closefd=False) self._buffer_lock: threading.Lock = threading.Lock() + self._data_available_event = threading.Event() self._run_count: int = 0 else: self.compressed_runs_buffer = None @@ -1334,6 +1336,7 @@ def create_run( multipart_form, self.compressor_writer, self.boundary ) self._run_count += 1 + self._data_available_event.set() elif self.tracing_queue is not None: serialized_op = serialize_run_dict("post", run_create) self.tracing_queue.put( @@ -1863,6 +1866,7 @@ def update_run( multipart_form, self.compressor_writer, self.boundary ) self._run_count += 1 + self._data_available_event.set() elif self.tracing_queue is not None: self.tracing_queue.put( TracingQueueItem(data["dotted_order"], serialized_op) From f997895a9724095b794bd72dfc0278147c861586 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Fri, 13 Dec 2024 11:41:16 -0800 Subject: [PATCH 40/56] improve buffer checks --- python/langsmith/_internal/_background_thread.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 7a594980b..fce8ee601 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -106,7 +106,16 @@ def _tracing_thread_drain_compressed_buffer( with client._buffer_lock: current_size = client.compressed_runs_buffer.tell() - if not (client._run_count >= size_limit or current_size >= size_limit_bytes): + if size_limit is not None and size_limit <= 0: + raise ValueError(f"size_limit must be positive; got {size_limit}") + if size_limit_bytes is not None and size_limit_bytes < 0: + raise ValueError( + f"size_limit_bytes must be nonnegative; got {size_limit_bytes}" + ) + + if (size_limit_bytes is None or current_size < size_limit_bytes) and ( + size_limit is None or len(client._run_count) < size_limit + ): return None # Write final boundary and close compression stream From fbc217f8a383598914e678deae6fae5872d38e34 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Fri, 13 Dec 2024 12:22:49 -0800 Subject: [PATCH 41/56] mypy --- python/langsmith/_internal/_background_thread.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index fce8ee601..8257ecf14 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -99,7 +99,7 @@ def _tracing_thread_drain_queue( def _tracing_thread_drain_compressed_buffer( - client: Client, size_limit: int = 100, size_limit_bytes: int = 20_971_520 + client: Client, size_limit: int = 100, size_limit_bytes: int | None = 20_971_520 ) -> Optional[io.BytesIO]: assert client.compressed_runs_buffer is not None assert client.compressor_writer is not None @@ -114,7 +114,7 @@ def _tracing_thread_drain_compressed_buffer( ) if (size_limit_bytes is None or current_size < size_limit_bytes) and ( - size_limit is None or len(client._run_count) < size_limit + size_limit is None or client._run_count < size_limit ): return None @@ -256,7 +256,6 @@ def tracing_control_thread_func_compress_parallel( batch_ingest_config = _ensure_ingest_config(client.info) size_limit: int = batch_ingest_config["size_limit"] size_limit_bytes = batch_ingest_config.get("size_limit_bytes", 20_971_520) - assert size_limit_bytes is not None num_known_refs = 3 def keep_thread_active() -> bool: From 7b6c201f59665359bc88ae16dec4e378a2631463 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Wed, 18 Dec 2024 14:06:43 -1000 Subject: [PATCH 42/56] Use more threads for backend requests --- python/langsmith/_internal/_background_thread.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 8257ecf14..33be46e57 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -37,7 +37,7 @@ logger = logging.getLogger("langsmith.client") HTTP_REQUEST_THREAD_POOL = concurrent.futures.ThreadPoolExecutor( - max_workers=cpu_count() + max_workers=cpu_count()*3 ) From 35d46edf070db7742960555d58b7904b235d8325 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Wed, 18 Dec 2024 14:25:47 -1000 Subject: [PATCH 43/56] fix futures waiting --- python/langsmith/_internal/_background_thread.py | 3 ++- python/langsmith/client.py | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 33be46e57..e2fdb2bda 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -297,9 +297,10 @@ def keep_thread_active() -> bool: if data_stream is not None: try: - HTTP_REQUEST_THREAD_POOL.submit( + future = HTTP_REQUEST_THREAD_POOL.submit( client._send_compressed_multipart_req, data_stream ) + client._futures.add(future) except RuntimeError: client._send_compressed_multipart_req(data_stream) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 759fad1f2..d45919697 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -403,6 +403,7 @@ class Client: "_buffer_lock", "compressed_runs_buffer", "_data_available_event", + "_futures", ] def __init__( @@ -506,6 +507,7 @@ def __init__( self.session = session_ self.compress_traces = ls_utils.get_env_var("USE_RUN_COMPRESSION") if self.compress_traces: + self._futures: set[cf.Future] = set() self.boundary = BOUNDARY self.compressed_runs_buffer: Optional[io.BytesIO] = io.BytesIO() self.compressor_writer: zstd.ZstdCompressionWriter = zstd.ZstdCompressor( @@ -1916,13 +1918,16 @@ def flush_compressed_runs(self, attempts: int = 3) -> None: final_data_stream, attempts=attempts, ) + self._futures.add(future) except RuntimeError: # In case the ThreadPoolExecutor is already shutdown self._send_compressed_multipart_req(final_data_stream, attempts=attempts) # If we got a future, wait for it to complete - if future is not None: - cf.wait([future]) + if self._futures: + done, _ = cf.wait(self._futures, return_when=cf.ALL_COMPLETED) + # Remove completed futures + self._futures.difference_update(done) def flush(self) -> None: """Flush either queue or compressed buffer, depending on mode.""" From dbef2ec3afb47339830448db113769ea33682820 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Thu, 19 Dec 2024 10:23:50 -0800 Subject: [PATCH 44/56] remove unused slot --- python/langsmith/_internal/_background_thread.py | 4 ++-- python/langsmith/client.py | 11 +++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index e2fdb2bda..ae98101f8 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -37,7 +37,7 @@ logger = logging.getLogger("langsmith.client") HTTP_REQUEST_THREAD_POOL = concurrent.futures.ThreadPoolExecutor( - max_workers=cpu_count()*3 + max_workers=cpu_count() * 3 ) @@ -119,7 +119,7 @@ def _tracing_thread_drain_compressed_buffer( return None # Write final boundary and close compression stream - client.compressor_writer.write(f"--{client.boundary}--\r\n".encode()) + client.compressor_writer.write(f"--{client._boundary}--\r\n".encode()) client.compressor_writer.close() filled_buffer = client.compressed_runs_buffer diff --git a/python/langsmith/client.py b/python/langsmith/client.py index d45919697..600f4d1aa 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -396,8 +396,7 @@ class Client: "_manual_cleanup", "_pyo3_client", "compress_traces", - "boundary", - "compressor", + "_boundary", "compressor_writer", "_run_count", "_buffer_lock", @@ -508,7 +507,7 @@ def __init__( self.compress_traces = ls_utils.get_env_var("USE_RUN_COMPRESSION") if self.compress_traces: self._futures: set[cf.Future] = set() - self.boundary = BOUNDARY + self._boundary = BOUNDARY self.compressed_runs_buffer: Optional[io.BytesIO] = io.BytesIO() self.compressor_writer: zstd.ZstdCompressionWriter = zstd.ZstdCompressor( level=3, threads=-1 @@ -1335,7 +1334,7 @@ def create_run( ) with self._buffer_lock: compress_multipart_parts_and_context( - multipart_form, self.compressor_writer, self.boundary + multipart_form, self.compressor_writer, self._boundary ) self._run_count += 1 self._data_available_event.set() @@ -1730,7 +1729,7 @@ def _send_compressed_multipart_req(self, data_stream, *, attempts: int = 3): headers = { **self._headers, "X-API-KEY": api_key, - "Content-Type": f"multipart/form-data; boundary={self.boundary}", + "Content-Type": f"multipart/form-data; boundary={self._boundary}", "Content-Encoding": "zstd", } @@ -1865,7 +1864,7 @@ def update_run( ) with self._buffer_lock: compress_multipart_parts_and_context( - multipart_form, self.compressor_writer, self.boundary + multipart_form, self.compressor_writer, self._boundary ) self._run_count += 1 self._data_available_event.set() From 6fad59626d313d8918cc3d6655a5c6fc3eeec876 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Thu, 19 Dec 2024 10:34:35 -0800 Subject: [PATCH 45/56] Flush background threads --- .../langsmith/_internal/_background_thread.py | 8 ++--- python/langsmith/client.py | 32 +++++++++---------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index ae98101f8..6a3d715db 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -1,6 +1,6 @@ from __future__ import annotations -import concurrent.futures +import concurrent.futures as cf import functools import io import logging @@ -36,9 +36,7 @@ logger = logging.getLogger("langsmith.client") -HTTP_REQUEST_THREAD_POOL = concurrent.futures.ThreadPoolExecutor( - max_workers=cpu_count() * 3 -) +HTTP_REQUEST_THREAD_POOL = cf.ThreadPoolExecutor(max_workers=cpu_count() * 3) @functools.total_ordering @@ -311,7 +309,7 @@ def keep_thread_active() -> bool: ) # Force final drain if final_data_stream is not None: try: - concurrent.futures.wait( + cf.wait( [ HTTP_REQUEST_THREAD_POOL.submit( client._send_compressed_multipart_req, final_data_stream diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 600f4d1aa..02c31cccc 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -1906,25 +1906,25 @@ def flush_compressed_runs(self, attempts: int = 3) -> None: self, size_limit=1, size_limit_bytes=1 ) - if final_data_stream is None: - return - - # We have data to send - future = None - try: - future = HTTP_REQUEST_THREAD_POOL.submit( - self._send_compressed_multipart_req, - final_data_stream, - attempts=attempts, - ) - self._futures.add(future) - except RuntimeError: - # In case the ThreadPoolExecutor is already shutdown - self._send_compressed_multipart_req(final_data_stream, attempts=attempts) + if final_data_stream is not None: + # We have data to send + future = None + try: + future = HTTP_REQUEST_THREAD_POOL.submit( + self._send_compressed_multipart_req, + final_data_stream, + attempts=attempts, + ) + self._futures.add(future) + except RuntimeError: + # In case the ThreadPoolExecutor is already shutdown + self._send_compressed_multipart_req( + final_data_stream, attempts=attempts + ) # If we got a future, wait for it to complete if self._futures: - done, _ = cf.wait(self._futures, return_when=cf.ALL_COMPLETED) + done, _ = cf.wait(self._futures) # Remove completed futures self._futures.difference_update(done) From 5cc947a90ae61e1ac2c8761fd59816e730659188 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Thu, 19 Dec 2024 13:04:24 -0800 Subject: [PATCH 46/56] make boundary constant --- python/langsmith/_internal/_background_thread.py | 3 ++- python/langsmith/_internal/_constants.py | 3 +++ python/langsmith/client.py | 14 ++++++-------- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 6a3d715db..e595c5b73 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -24,6 +24,7 @@ _AUTO_SCALE_DOWN_NEMPTY_TRIGGER, _AUTO_SCALE_UP_NTHREADS_LIMIT, _AUTO_SCALE_UP_QSIZE_TRIGGER, + _BOUNDARY, ) from langsmith._internal._operations import ( SerializedFeedbackOperation, @@ -117,7 +118,7 @@ def _tracing_thread_drain_compressed_buffer( return None # Write final boundary and close compression stream - client.compressor_writer.write(f"--{client._boundary}--\r\n".encode()) + client.compressor_writer.write(f"--{_BOUNDARY}--\r\n".encode()) client.compressor_writer.close() filled_buffer = client.compressed_runs_buffer diff --git a/python/langsmith/_internal/_constants.py b/python/langsmith/_internal/_constants.py index 1703d9e88..43505024f 100644 --- a/python/langsmith/_internal/_constants.py +++ b/python/langsmith/_internal/_constants.py @@ -1,5 +1,8 @@ +import uuid + _SIZE_LIMIT_BYTES = 20_971_520 # 20MB by default _AUTO_SCALE_UP_QSIZE_TRIGGER = 200 _AUTO_SCALE_UP_NTHREADS_LIMIT = 32 _AUTO_SCALE_DOWN_NEMPTY_TRIGGER = 4 _BLOCKSIZE_BYTES = 1024 * 1024 # 1MB +_BOUNDARY = uuid.uuid4().hex diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 02c31cccc..576892943 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -86,6 +86,7 @@ _AUTO_SCALE_UP_NTHREADS_LIMIT, _BLOCKSIZE_BYTES, _SIZE_LIMIT_BYTES, + _BOUNDARY, ) from langsmith._internal._multipart import ( MultipartPart, @@ -141,7 +142,6 @@ class ZoneInfo: # type: ignore[no-redef] X_API_KEY = "x-api-key" WARNED_ATTACHMENTS = False EMPTY_SEQ: tuple[Dict, ...] = () -BOUNDARY = uuid.uuid4().hex URLLIB3_SUPPORTS_BLOCKSIZE = "key_blocksize" in signature(PoolKey).parameters @@ -396,7 +396,6 @@ class Client: "_manual_cleanup", "_pyo3_client", "compress_traces", - "_boundary", "compressor_writer", "_run_count", "_buffer_lock", @@ -507,7 +506,6 @@ def __init__( self.compress_traces = ls_utils.get_env_var("USE_RUN_COMPRESSION") if self.compress_traces: self._futures: set[cf.Future] = set() - self._boundary = BOUNDARY self.compressed_runs_buffer: Optional[io.BytesIO] = io.BytesIO() self.compressor_writer: zstd.ZstdCompressionWriter = zstd.ZstdCompressor( level=3, threads=-1 @@ -1334,7 +1332,7 @@ def create_run( ) with self._buffer_lock: compress_multipart_parts_and_context( - multipart_form, self.compressor_writer, self._boundary + multipart_form, self.compressor_writer, _BOUNDARY ) self._run_count += 1 self._data_available_event.set() @@ -1678,7 +1676,7 @@ def _send_multipart_req(self, acc: MultipartPartsAndContext, *, attempts: int = for api_url, api_key in self._write_api_urls.items(): for idx in range(1, attempts + 1): try: - encoder = rqtb_multipart.MultipartEncoder(parts, boundary=BOUNDARY) + encoder = rqtb_multipart.MultipartEncoder(parts, boundary=_BOUNDARY) if encoder.len <= 20_000_000: # ~20 MB data = encoder.to_string() else: @@ -1729,7 +1727,7 @@ def _send_compressed_multipart_req(self, data_stream, *, attempts: int = 3): headers = { **self._headers, "X-API-KEY": api_key, - "Content-Type": f"multipart/form-data; boundary={self._boundary}", + "Content-Type": f"multipart/form-data; boundary={_BOUNDARY}", "Content-Encoding": "zstd", } @@ -1864,7 +1862,7 @@ def update_run( ) with self._buffer_lock: compress_multipart_parts_and_context( - multipart_form, self.compressor_writer, self._boundary + multipart_form, self.compressor_writer, _BOUNDARY ) self._run_count += 1 self._data_available_event.set() @@ -3767,7 +3765,7 @@ def _prepare_multipart_data( ) ) - encoder = rqtb_multipart.MultipartEncoder(parts, boundary=BOUNDARY) + encoder = rqtb_multipart.MultipartEncoder(parts, boundary=_BOUNDARY) if encoder.len <= 20_000_000: # ~20 MB data = encoder.to_string() else: From d4b2aa4bb18011575ba81c15ba5deeb427d0ffdc Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Thu, 19 Dec 2024 13:06:28 -0800 Subject: [PATCH 47/56] Remove slot for bool val --- python/langsmith/client.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 576892943..0035308fd 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -395,7 +395,6 @@ class Client: "_settings", "_manual_cleanup", "_pyo3_client", - "compress_traces", "compressor_writer", "_run_count", "_buffer_lock", @@ -503,8 +502,7 @@ def __init__( # Create a session and register a finalizer to close it session_ = session if session else requests.Session() self.session = session_ - self.compress_traces = ls_utils.get_env_var("USE_RUN_COMPRESSION") - if self.compress_traces: + if self.ls_utils.get_env_var("USE_RUN_COMPRESSION"): self._futures: set[cf.Future] = set() self.compressed_runs_buffer: Optional[io.BytesIO] = io.BytesIO() self.compressor_writer: zstd.ZstdCompressionWriter = zstd.ZstdCompressor( @@ -524,7 +522,7 @@ def __init__( weakref.finalize(self, close_session, self.session) atexit.register(close_session, session_) # Initialize auto batching - if auto_batch_tracing and self.compress_traces: + if auto_batch_tracing and self.compressed_runs_buffer is not None: self.tracing_queue: Optional[PriorityQueue] = None threading.Thread( target=_tracing_control_thread_func_compress_parallel, @@ -1891,7 +1889,7 @@ def _update_run(self, run_update: dict) -> None: def flush_compressed_runs(self, attempts: int = 3) -> None: """Force flush the currently buffered compressed runs.""" - if not self.compress_traces or self.compressed_runs_buffer is None: + if self.compressed_runs_buffer is None: return # Attempt to drain and send any remaining data @@ -1928,7 +1926,7 @@ def flush_compressed_runs(self, attempts: int = 3) -> None: def flush(self) -> None: """Flush either queue or compressed buffer, depending on mode.""" - if self.compress_traces and self.compressed_runs_buffer is not None: + if self.compressed_runs_buffer is not None: self.flush_compressed_runs() elif self.tracing_queue is not None: self.tracing_queue.join() From 63e55f76364eec62e155cf4a8b0e3c42b295185d Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Thu, 19 Dec 2024 13:08:14 -0800 Subject: [PATCH 48/56] Use a single join() rather than copying the header strings --- python/langsmith/_internal/_operations.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/python/langsmith/_internal/_operations.py b/python/langsmith/_internal/_operations.py index fca108796..f75411084 100644 --- a/python/langsmith/_internal/_operations.py +++ b/python/langsmith/_internal/_operations.py @@ -274,26 +274,27 @@ def serialized_run_operation_to_multipart_parts_and_context( f"trace={op.trace_id},id={op.id}", ) - def compress_multipart_parts_and_context( parts_and_context: MultipartPartsAndContext, compressor_writer: zstd.ZstdCompressionWriter, boundary: str, ) -> None: for part_name, (filename, data, content_type, headers) in parts_and_context.parts: - part_header = f"--{boundary}\r\n" - part_header += f'Content-Disposition: form-data; name="{part_name}"' + header_parts = [ + f"--{boundary}\r\n", + f'Content-Disposition: form-data; name="{part_name}"' + ] if filename: - part_header += f'; filename="{filename}"' - - part_header += f"\r\nContent-Type: {content_type}\r\n" + header_parts.append(f'; filename="{filename}"') - for header_name, header_value in headers.items(): - part_header += f"{header_name}: {header_value}\r\n" + header_parts.extend([ + f"\r\nContent-Type: {content_type}\r\n", + *[f"{k}: {v}\r\n" for k, v in headers.items()], + "\r\n" + ]) - part_header += "\r\n" - compressor_writer.write(part_header.encode()) + compressor_writer.write("".join(header_parts).encode()) if isinstance(data, (bytes, bytearray)): compressor_writer.write(data) From 874c748ad0e78fce20c6bc4ba6ad18bab2bb16a7 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Thu, 19 Dec 2024 13:13:34 -0800 Subject: [PATCH 49/56] Add zstandard license --- python/docs/templates/zstandard/COPYRIGHT.txt | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 python/docs/templates/zstandard/COPYRIGHT.txt diff --git a/python/docs/templates/zstandard/COPYRIGHT.txt b/python/docs/templates/zstandard/COPYRIGHT.txt new file mode 100644 index 000000000..912c1883e --- /dev/null +++ b/python/docs/templates/zstandard/COPYRIGHT.txt @@ -0,0 +1,27 @@ +Copyright (c) 2016, Gregory Szorc +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors +may be used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file From 0b3d6b8490b41ccb5ac6025d1d2d3cdfb2ef7fb0 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Thu, 19 Dec 2024 17:06:23 -0800 Subject: [PATCH 50/56] lint --- python/langsmith/_internal/_operations.py | 15 +++++++++------ python/langsmith/client.py | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/python/langsmith/_internal/_operations.py b/python/langsmith/_internal/_operations.py index f75411084..904a20d3b 100644 --- a/python/langsmith/_internal/_operations.py +++ b/python/langsmith/_internal/_operations.py @@ -274,6 +274,7 @@ def serialized_run_operation_to_multipart_parts_and_context( f"trace={op.trace_id},id={op.id}", ) + def compress_multipart_parts_and_context( parts_and_context: MultipartPartsAndContext, compressor_writer: zstd.ZstdCompressionWriter, @@ -282,17 +283,19 @@ def compress_multipart_parts_and_context( for part_name, (filename, data, content_type, headers) in parts_and_context.parts: header_parts = [ f"--{boundary}\r\n", - f'Content-Disposition: form-data; name="{part_name}"' + f'Content-Disposition: form-data; name="{part_name}"', ] if filename: header_parts.append(f'; filename="{filename}"') - header_parts.extend([ - f"\r\nContent-Type: {content_type}\r\n", - *[f"{k}: {v}\r\n" for k, v in headers.items()], - "\r\n" - ]) + header_parts.extend( + [ + f"\r\nContent-Type: {content_type}\r\n", + *[f"{k}: {v}\r\n" for k, v in headers.items()], + "\r\n", + ] + ) compressor_writer.write("".join(header_parts).encode()) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 0035308fd..a273cf8e2 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -85,8 +85,8 @@ from langsmith._internal._constants import ( _AUTO_SCALE_UP_NTHREADS_LIMIT, _BLOCKSIZE_BYTES, - _SIZE_LIMIT_BYTES, _BOUNDARY, + _SIZE_LIMIT_BYTES, ) from langsmith._internal._multipart import ( MultipartPart, From 773993945ca05e48b1c9bb785ef0d4e45c639734 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Thu, 19 Dec 2024 17:34:50 -0800 Subject: [PATCH 51/56] Create compressed runs object --- .../langsmith/_internal/_background_thread.py | 23 ++++------ .../langsmith/_internal/_compressed_runs.py | 21 +++++++++ python/langsmith/client.py | 46 +++++++++---------- 3 files changed, 50 insertions(+), 40 deletions(-) create mode 100644 python/langsmith/_internal/_compressed_runs.py diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index e595c5b73..ef83ac23f 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -17,8 +17,6 @@ cast, ) -import zstandard as zstd - from langsmith import schemas as ls_schemas from langsmith._internal._constants import ( _AUTO_SCALE_DOWN_NEMPTY_TRIGGER, @@ -100,10 +98,9 @@ def _tracing_thread_drain_queue( def _tracing_thread_drain_compressed_buffer( client: Client, size_limit: int = 100, size_limit_bytes: int | None = 20_971_520 ) -> Optional[io.BytesIO]: - assert client.compressed_runs_buffer is not None - assert client.compressor_writer is not None - with client._buffer_lock: - current_size = client.compressed_runs_buffer.tell() + assert client.compressed_runs is not None + with client.compressed_runs.lock: + current_size = client.compressed_runs.buffer.tell() if size_limit is not None and size_limit <= 0: raise ValueError(f"size_limit must be positive; got {size_limit}") @@ -113,21 +110,17 @@ def _tracing_thread_drain_compressed_buffer( ) if (size_limit_bytes is None or current_size < size_limit_bytes) and ( - size_limit is None or client._run_count < size_limit + size_limit is None or client.compressed_runs.run_count < size_limit ): return None # Write final boundary and close compression stream - client.compressor_writer.write(f"--{_BOUNDARY}--\r\n".encode()) - client.compressor_writer.close() + client.compressed_runs.compressor_writer.write(f"--{_BOUNDARY}--\r\n".encode()) + client.compressed_runs.compressor_writer.close() - filled_buffer = client.compressed_runs_buffer + filled_buffer = client.compressed_runs.buffer - client.compressed_runs_buffer = io.BytesIO() - client.compressor_writer = zstd.ZstdCompressor( - level=3, threads=-1 - ).stream_writer(client.compressed_runs_buffer, closefd=False) - client._run_count = 0 + client.compressed_runs.reset() filled_buffer.seek(0) return filled_buffer diff --git a/python/langsmith/_internal/_compressed_runs.py b/python/langsmith/_internal/_compressed_runs.py new file mode 100644 index 000000000..3fa0fba7d --- /dev/null +++ b/python/langsmith/_internal/_compressed_runs.py @@ -0,0 +1,21 @@ +import io +import threading + +import zstandard as zstd + + +class CompressedRuns: + def __init__(self): + self.buffer = io.BytesIO() + self.run_count = 0 + self.lock = threading.Lock() + self.compressor_writer = zstd.ZstdCompressor(level=3, threads=-1).stream_writer( + self.buffer, closefd=False + ) + + def reset(self): + self.buffer = io.BytesIO() + self.run_count = 0 + self.compressor_writer = zstd.ZstdCompressor(level=3, threads=-1).stream_writer( + self.buffer, closefd=False + ) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index a273cf8e2..0fc2181f5 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -58,7 +58,6 @@ from urllib import parse as urllib_parse import requests -import zstandard as zstd from requests import adapters as requests_adapters from requests_toolbelt import ( # type: ignore[import-untyped] multipart as rqtb_multipart, @@ -82,6 +81,7 @@ tracing_control_thread_func_compress_parallel as _tracing_control_thread_func_compress_parallel, ) from langsmith._internal._beta_decorator import warn_beta +from langsmith._internal._compressed_runs import CompressedRuns from langsmith._internal._constants import ( _AUTO_SCALE_UP_NTHREADS_LIMIT, _BLOCKSIZE_BYTES, @@ -395,10 +395,7 @@ class Client: "_settings", "_manual_cleanup", "_pyo3_client", - "compressor_writer", - "_run_count", - "_buffer_lock", - "compressed_runs_buffer", + "compressed_runs", "_data_available_event", "_futures", ] @@ -502,17 +499,12 @@ def __init__( # Create a session and register a finalizer to close it session_ = session if session else requests.Session() self.session = session_ - if self.ls_utils.get_env_var("USE_RUN_COMPRESSION"): + if ls_utils.get_env_var("USE_RUN_COMPRESSION"): self._futures: set[cf.Future] = set() - self.compressed_runs_buffer: Optional[io.BytesIO] = io.BytesIO() - self.compressor_writer: zstd.ZstdCompressionWriter = zstd.ZstdCompressor( - level=3, threads=-1 - ).stream_writer(self.compressed_runs_buffer, closefd=False) - self._buffer_lock: threading.Lock = threading.Lock() + self.compressed_runs: Optional[CompressedRuns] = CompressedRuns() self._data_available_event = threading.Event() - self._run_count: int = 0 else: - self.compressed_runs_buffer = None + self.compressed_runs = None self._info = ( info @@ -522,7 +514,7 @@ def __init__( weakref.finalize(self, close_session, self.session) atexit.register(close_session, session_) # Initialize auto batching - if auto_batch_tracing and self.compressed_runs_buffer is not None: + if auto_batch_tracing and self.compressed_runs is not None: self.tracing_queue: Optional[PriorityQueue] = None threading.Thread( target=_tracing_control_thread_func_compress_parallel, @@ -1321,18 +1313,20 @@ def create_run( ): if self._pyo3_client is not None: self._pyo3_client.create_run(run_create) - if self.compressed_runs_buffer is not None: + if self.compressed_runs is not None: serialized_op = serialize_run_dict("post", run_create) multipart_form = ( serialized_run_operation_to_multipart_parts_and_context( serialized_op ) ) - with self._buffer_lock: + with self.compressed_runs.lock: compress_multipart_parts_and_context( - multipart_form, self.compressor_writer, _BOUNDARY + multipart_form, + self.compressed_runs.compressor_writer, + _BOUNDARY, ) - self._run_count += 1 + self.compressed_runs.run_count += 1 self._data_available_event.set() elif self.tracing_queue is not None: serialized_op = serialize_run_dict("post", run_create) @@ -1824,7 +1818,7 @@ def update_run( if attachments: data["attachments"] = attachments use_multipart = ( - (self.tracing_queue is not None or self.compressed_runs_buffer is not None) + (self.tracing_queue is not None or self.compressed_runs is not None) # batch ingest requires trace_id and dotted_order to be set and data["trace_id"] is not None and data["dotted_order"] is not None @@ -1852,17 +1846,19 @@ def update_run( self._pyo3_client.update_run(data) elif use_multipart: serialized_op = serialize_run_dict(operation="patch", payload=data) - if self.compressed_runs_buffer is not None: + if self.compressed_runs is not None: multipart_form = ( serialized_run_operation_to_multipart_parts_and_context( serialized_op ) ) - with self._buffer_lock: + with self.compressed_runs.lock: compress_multipart_parts_and_context( - multipart_form, self.compressor_writer, _BOUNDARY + multipart_form, + self.compressed_runs.compressor_writer, + _BOUNDARY, ) - self._run_count += 1 + self.compressed_runs.run_count += 1 self._data_available_event.set() elif self.tracing_queue is not None: self.tracing_queue.put( @@ -1889,7 +1885,7 @@ def _update_run(self, run_update: dict) -> None: def flush_compressed_runs(self, attempts: int = 3) -> None: """Force flush the currently buffered compressed runs.""" - if self.compressed_runs_buffer is None: + if self.compressed_runs is None: return # Attempt to drain and send any remaining data @@ -1926,7 +1922,7 @@ def flush_compressed_runs(self, attempts: int = 3) -> None: def flush(self) -> None: """Flush either queue or compressed buffer, depending on mode.""" - if self.compressed_runs_buffer is not None: + if self.compressed_runs is not None: self.flush_compressed_runs() elif self.tracing_queue is not None: self.tracing_queue.join() From 3ec9b6eec4e2544d00dfd7d1798f743ed1b1ab51 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Thu, 19 Dec 2024 18:17:01 -0800 Subject: [PATCH 52/56] Make zstd optional --- .../langsmith/_internal/_compressed_runs.py | 21 ++++++++++++++++--- python/langsmith/_internal/_operations.py | 10 +++++++-- python/pyproject.toml | 4 +++- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/python/langsmith/_internal/_compressed_runs.py b/python/langsmith/_internal/_compressed_runs.py index 3fa0fba7d..3abda15d0 100644 --- a/python/langsmith/_internal/_compressed_runs.py +++ b/python/langsmith/_internal/_compressed_runs.py @@ -1,7 +1,12 @@ import io import threading -import zstandard as zstd +try: + from zstandard import ZstdCompressor + + HAVE_ZSTD = True +except ImportError: + HAVE_ZSTD = False class CompressedRuns: @@ -9,13 +14,23 @@ def __init__(self): self.buffer = io.BytesIO() self.run_count = 0 self.lock = threading.Lock() - self.compressor_writer = zstd.ZstdCompressor(level=3, threads=-1).stream_writer( + if not HAVE_ZSTD: + raise ImportError( + "zstandard package required for compression. " + "Install with 'pip install langsmith[compression]'" + ) + self.compressor_writer = ZstdCompressor(level=3, threads=-1).stream_writer( self.buffer, closefd=False ) def reset(self): self.buffer = io.BytesIO() self.run_count = 0 - self.compressor_writer = zstd.ZstdCompressor(level=3, threads=-1).stream_writer( + if not HAVE_ZSTD: + raise ImportError( + "zstandard package required for compression. " + "Install with 'pip install langsmith[compression]'" + ) + self.compressor_writer = ZstdCompressor(level=3, threads=-1).stream_writer( self.buffer, closefd=False ) diff --git a/python/langsmith/_internal/_operations.py b/python/langsmith/_internal/_operations.py index 904a20d3b..23e2d2c1d 100644 --- a/python/langsmith/_internal/_operations.py +++ b/python/langsmith/_internal/_operations.py @@ -5,7 +5,13 @@ import uuid from typing import Literal, Optional, Union, cast -import zstandard as zstd +try: + from zstandard import ZstdCompressionWriter +except ImportError: + + class ZstdCompressionWriter: # type: ignore[no-redef] + """only used for typing checks.""" + from langsmith import schemas as ls_schemas from langsmith._internal import _orjson @@ -277,7 +283,7 @@ def serialized_run_operation_to_multipart_parts_and_context( def compress_multipart_parts_and_context( parts_and_context: MultipartPartsAndContext, - compressor_writer: zstd.ZstdCompressionWriter, + compressor_writer: ZstdCompressionWriter, boundary: str, ) -> None: for part_name, (filename, data, content_type, headers) in parts_and_context.parts: diff --git a/python/pyproject.toml b/python/pyproject.toml index 05763c889..106e2a7b2 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -37,7 +37,8 @@ requests-toolbelt = "^1.0.0" # Enabled via `langsmith_pyo3` extra: `pip install langsmith[langsmith_pyo3]`. langsmith-pyo3 = { version = "^0.1.0rc2", optional = true } -zstandard = "^0.23.0" +# Enabled via `compression` extra: `pip install langsmith[compression]`. +zstandard = { version = "^0.23.0", optional = true } [tool.poetry.group.dev.dependencies] pytest = "^7.3.1" @@ -76,6 +77,7 @@ pytest-socket = "^0.7.0" [tool.poetry.extras] vcr = ["vcrpy"] langsmith_pyo3 = ["langsmith-pyo3"] +compression = ["zstandard"] [build-system] requires = ["poetry-core"] From f9aac67d39fb7f34691f975d24246b3433d18d87 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Thu, 19 Dec 2024 18:23:34 -0800 Subject: [PATCH 53/56] Make zstandard level configurable --- python/langsmith/_internal/_compressed_runs.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/python/langsmith/_internal/_compressed_runs.py b/python/langsmith/_internal/_compressed_runs.py index 3abda15d0..7871ebffc 100644 --- a/python/langsmith/_internal/_compressed_runs.py +++ b/python/langsmith/_internal/_compressed_runs.py @@ -8,6 +8,10 @@ except ImportError: HAVE_ZSTD = False +from langsmith import utils as ls_utils + +compression_level = ls_utils.get_env_var("RUN_COMPRESSION_LEVEL", 3) + class CompressedRuns: def __init__(self): @@ -19,9 +23,9 @@ def __init__(self): "zstandard package required for compression. " "Install with 'pip install langsmith[compression]'" ) - self.compressor_writer = ZstdCompressor(level=3, threads=-1).stream_writer( - self.buffer, closefd=False - ) + self.compressor_writer = ZstdCompressor( + level=compression_level, threads=-1 + ).stream_writer(self.buffer, closefd=False) def reset(self): self.buffer = io.BytesIO() @@ -31,6 +35,6 @@ def reset(self): "zstandard package required for compression. " "Install with 'pip install langsmith[compression]'" ) - self.compressor_writer = ZstdCompressor(level=3, threads=-1).stream_writer( - self.buffer, closefd=False - ) + self.compressor_writer = ZstdCompressor( + level=compression_level, threads=-1 + ).stream_writer(self.buffer, closefd=False) From 2ac7a3556310374ea5d1816a890b9f23b4bb837d Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Fri, 20 Dec 2024 11:50:12 -0500 Subject: [PATCH 54/56] mypy ignore optional imports --- python/langsmith/_internal/_compressed_runs.py | 2 +- python/langsmith/_internal/_operations.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/langsmith/_internal/_compressed_runs.py b/python/langsmith/_internal/_compressed_runs.py index 7871ebffc..2b838f3f1 100644 --- a/python/langsmith/_internal/_compressed_runs.py +++ b/python/langsmith/_internal/_compressed_runs.py @@ -2,7 +2,7 @@ import threading try: - from zstandard import ZstdCompressor + from zstandard import ZstdCompressor # type: ignore[import] HAVE_ZSTD = True except ImportError: diff --git a/python/langsmith/_internal/_operations.py b/python/langsmith/_internal/_operations.py index 23e2d2c1d..5393c3222 100644 --- a/python/langsmith/_internal/_operations.py +++ b/python/langsmith/_internal/_operations.py @@ -6,7 +6,7 @@ from typing import Literal, Optional, Union, cast try: - from zstandard import ZstdCompressionWriter + from zstandard import ZstdCompressionWriter # type: ignore[import] except ImportError: class ZstdCompressionWriter: # type: ignore[no-redef] From 3b291c3f54502edd5775128ef72ca3d31f34202c Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Fri, 20 Dec 2024 11:51:01 -0500 Subject: [PATCH 55/56] lint --- python/langsmith/_internal/_compressed_runs.py | 2 +- python/langsmith/_internal/_operations.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/langsmith/_internal/_compressed_runs.py b/python/langsmith/_internal/_compressed_runs.py index 2b838f3f1..a30fbb7a1 100644 --- a/python/langsmith/_internal/_compressed_runs.py +++ b/python/langsmith/_internal/_compressed_runs.py @@ -2,7 +2,7 @@ import threading try: - from zstandard import ZstdCompressor # type: ignore[import] + from zstandard import ZstdCompressor # type: ignore[import] HAVE_ZSTD = True except ImportError: diff --git a/python/langsmith/_internal/_operations.py b/python/langsmith/_internal/_operations.py index 5393c3222..8f9771804 100644 --- a/python/langsmith/_internal/_operations.py +++ b/python/langsmith/_internal/_operations.py @@ -6,7 +6,7 @@ from typing import Literal, Optional, Union, cast try: - from zstandard import ZstdCompressionWriter # type: ignore[import] + from zstandard import ZstdCompressionWriter # type: ignore[import] except ImportError: class ZstdCompressionWriter: # type: ignore[no-redef] From c64fb92f65bb369028621048709334bbe4dadc47 Mon Sep 17 00:00:00 2001 From: Angus Jelinek Date: Fri, 20 Dec 2024 12:46:47 -0500 Subject: [PATCH 56/56] poetry lock --- python/poetry.lock | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/python/poetry.lock b/python/poetry.lock index de2ec943d..a31c97af7 100644 --- a/python/poetry.lock +++ b/python/poetry.lock @@ -113,7 +113,7 @@ files = [ name = "cffi" version = "1.17.1" description = "Foreign Function Interface for Python calling C code." -optional = false +optional = true python-versions = ">=3.8" files = [ {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, @@ -1280,7 +1280,7 @@ files = [ name = "pycparser" version = "2.22" description = "C parser in Python" -optional = false +optional = true python-versions = ">=3.8" files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, @@ -2200,7 +2200,7 @@ propcache = ">=0.2.0" name = "zstandard" version = "0.23.0" description = "Zstandard bindings for Python" -optional = false +optional = true python-versions = ">=3.8" files = [ {file = "zstandard-0.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bf0a05b6059c0528477fba9054d09179beb63744355cab9f38059548fedd46a9"}, @@ -2309,10 +2309,11 @@ cffi = {version = ">=1.11", markers = "platform_python_implementation == \"PyPy\ cffi = ["cffi (>=1.11)"] [extras] +compression = ["zstandard"] langsmith-pyo3 = ["langsmith-pyo3"] vcr = [] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0" -content-hash = "7b8c702e50f6ae0f5a81bd2da1d6e0277f7fd28c5d02b2d62571573d3ae9f358" +content-hash = "19b288e10f9d6c040798efb74ae2778103abdc87c298b8c3eb21f7685db56642"