704 lines
24 KiB
Python
704 lines
24 KiB
Python
"""Tests for TurbopufferDatasink.
|
|
|
|
Organized by critical paths:
|
|
1. Constructor validation
|
|
2. Client initialization
|
|
3. Arrow table preparation
|
|
4. Single-namespace batching
|
|
5. Transform to Turbopuffer format
|
|
6. Retry logic
|
|
7. End-to-end write orchestration
|
|
8. Streaming behavior
|
|
9. Multi-namespace writes
|
|
10. Serialization
|
|
"""
|
|
|
|
import pickle
|
|
import sys
|
|
import time
|
|
import uuid
|
|
from typing import List
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pyarrow as pa
|
|
import pytest
|
|
from packaging.version import parse as parse_version
|
|
|
|
from ray.data._internal.datasource.turbopuffer_datasink import TurbopufferDatasink
|
|
from ray.data._internal.utils.arrow_utils import get_pyarrow_version
|
|
|
|
# Skip all tests if PyArrow version is less than 19.0
|
|
pytestmark = pytest.mark.skipif(
|
|
get_pyarrow_version() < parse_version("19.0.0"),
|
|
reason="TurbopufferDatasink tests require PyArrow >= 19.0",
|
|
)
|
|
|
|
# =============================================================================
|
|
# Fixtures
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def mock_turbopuffer_module(monkeypatch):
|
|
"""Provide a fake turbopuffer module so imports in the datasink succeed."""
|
|
fake_module = MagicMock()
|
|
fake_module.Turbopuffer = MagicMock()
|
|
with patch.dict(sys.modules, {"turbopuffer": fake_module}):
|
|
yield fake_module
|
|
|
|
|
|
@pytest.fixture
|
|
def sink():
|
|
"""Default sink with minimal required arguments."""
|
|
return TurbopufferDatasink(
|
|
namespace="default_ns",
|
|
region="gcp-us-central1",
|
|
api_key="test-api-key",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_client():
|
|
"""Mock Turbopuffer client with namespace support."""
|
|
client = MagicMock()
|
|
client.namespace.return_value = MagicMock()
|
|
return client
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_table():
|
|
"""Standard table with id and vector columns."""
|
|
return pa.table(
|
|
{
|
|
"id": [1, 2, 3],
|
|
"vector": [[0.1], [0.2], [0.3]],
|
|
}
|
|
)
|
|
|
|
|
|
def make_sink(**kwargs) -> TurbopufferDatasink:
|
|
"""Helper to construct a sink with minimal required arguments."""
|
|
params = {
|
|
"namespace": "default_ns",
|
|
"region": "gcp-us-central1",
|
|
"api_key": "test-api-key",
|
|
}
|
|
params.update(kwargs)
|
|
return TurbopufferDatasink(**params)
|
|
|
|
|
|
# =============================================================================
|
|
# 1. Constructor validation
|
|
# =============================================================================
|
|
|
|
|
|
class TestConstructorValidation:
|
|
"""Tests for constructor argument validation."""
|
|
|
|
def test_requires_namespace_or_namespace_column(self):
|
|
"""Must provide exactly one of namespace / namespace_column."""
|
|
with pytest.raises(ValueError, match="Either.*must be provided"):
|
|
TurbopufferDatasink(
|
|
region="gcp-us-central1",
|
|
api_key="k",
|
|
)
|
|
|
|
def test_rejects_both_namespace_and_namespace_column(self):
|
|
"""Cannot provide both namespace and namespace_column."""
|
|
with pytest.raises(ValueError, match="exactly one"):
|
|
TurbopufferDatasink(
|
|
namespace="ns",
|
|
namespace_column="ns_col",
|
|
region="gcp-us-central1",
|
|
api_key="k",
|
|
)
|
|
|
|
def test_namespace_column_cannot_be_id_or_vector(self):
|
|
"""namespace_column must not collide with id_column or vector_column."""
|
|
with pytest.raises(ValueError, match="namespace_column.*must not be the same"):
|
|
make_sink(namespace=None, namespace_column="id")
|
|
|
|
with pytest.raises(ValueError, match="namespace_column.*must not be the same"):
|
|
make_sink(namespace=None, namespace_column="vector")
|
|
|
|
def test_api_key_from_env(self, monkeypatch):
|
|
"""API key can come from environment variable."""
|
|
monkeypatch.delenv("TURBOPUFFER_API_KEY", raising=False)
|
|
|
|
# No api_key and no env var -> error
|
|
with pytest.raises(ValueError):
|
|
TurbopufferDatasink(namespace="ns", region="gcp-us-central1")
|
|
|
|
# With env var, init should succeed
|
|
monkeypatch.setenv("TURBOPUFFER_API_KEY", "env-api-key")
|
|
sink = TurbopufferDatasink(namespace="ns", region="gcp-us-central1")
|
|
assert sink.api_key == "env-api-key"
|
|
|
|
def test_rejects_same_id_and_vector_column(self):
|
|
"""id_column and vector_column must be distinct."""
|
|
with pytest.raises(ValueError, match="id_column and vector_column"):
|
|
make_sink(id_column="doc_id", vector_column="doc_id")
|
|
|
|
def test_accepts_region_only(self):
|
|
"""Constructor succeeds with region and no base_url."""
|
|
sink = make_sink(region="gcp-us-central1")
|
|
assert sink.region == "gcp-us-central1"
|
|
assert sink.base_url is None
|
|
|
|
def test_accepts_base_url_only(self):
|
|
"""Constructor succeeds with base_url and no region."""
|
|
sink = make_sink(
|
|
region=None,
|
|
base_url="https://gcp-us-central1.turbopuffer.com",
|
|
)
|
|
assert sink.base_url == "https://gcp-us-central1.turbopuffer.com"
|
|
assert sink.region is None
|
|
|
|
def test_rejects_both_region_and_base_url(self):
|
|
"""Cannot provide both region and base_url."""
|
|
with pytest.raises(ValueError, match="exactly one of 'region' or 'base_url'"):
|
|
make_sink(
|
|
region="gcp-us-central1",
|
|
base_url="https://gcp-us-central1.turbopuffer.com",
|
|
)
|
|
|
|
def test_rejects_neither_region_nor_base_url(self):
|
|
"""Must provide at least one of region or base_url."""
|
|
with pytest.raises(ValueError, match="Either 'region' or 'base_url'"):
|
|
TurbopufferDatasink(
|
|
namespace="ns",
|
|
api_key="k",
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# 2. Client initialization
|
|
# =============================================================================
|
|
|
|
|
|
class TestClientInitialization:
|
|
"""Tests for Turbopuffer client lazy initialization."""
|
|
|
|
def test_lazy_initialization(self, sink, mock_turbopuffer_module):
|
|
"""Client is created lazily and cached."""
|
|
client1 = sink._get_client()
|
|
client2 = sink._get_client()
|
|
|
|
assert client1 is client2
|
|
mock_turbopuffer_module.Turbopuffer.assert_called_once_with(
|
|
api_key="test-api-key",
|
|
region="gcp-us-central1",
|
|
)
|
|
|
|
def test_uses_explicit_region(self, mock_turbopuffer_module):
|
|
"""Client uses the configured region."""
|
|
sink = make_sink(region="custom-region")
|
|
sink._get_client()
|
|
|
|
mock_turbopuffer_module.Turbopuffer.assert_called_once_with(
|
|
api_key="test-api-key",
|
|
region="custom-region",
|
|
)
|
|
|
|
def test_uses_base_url(self, mock_turbopuffer_module):
|
|
"""Client uses base_url when region is not provided."""
|
|
sink = make_sink(
|
|
region=None,
|
|
base_url="https://gcp-us-central1.turbopuffer.com",
|
|
)
|
|
sink._get_client()
|
|
|
|
mock_turbopuffer_module.Turbopuffer.assert_called_once_with(
|
|
api_key="test-api-key",
|
|
base_url="https://gcp-us-central1.turbopuffer.com",
|
|
)
|
|
|
|
def test_base_url_does_not_pass_region(self, mock_turbopuffer_module):
|
|
"""When base_url is used, region is not passed to the client."""
|
|
sink = make_sink(
|
|
region=None,
|
|
base_url="https://custom.turbopuffer.com",
|
|
)
|
|
sink._get_client()
|
|
|
|
call_kwargs = mock_turbopuffer_module.Turbopuffer.call_args[1]
|
|
assert "region" not in call_kwargs
|
|
assert call_kwargs["base_url"] == "https://custom.turbopuffer.com"
|
|
|
|
def test_region_does_not_pass_base_url(self, mock_turbopuffer_module):
|
|
"""When region is used, base_url is not passed to the client."""
|
|
sink = make_sink(region="gcp-us-central1")
|
|
sink._get_client()
|
|
|
|
call_kwargs = mock_turbopuffer_module.Turbopuffer.call_args[1]
|
|
assert "base_url" not in call_kwargs
|
|
assert call_kwargs["region"] == "gcp-us-central1"
|
|
|
|
|
|
# =============================================================================
|
|
# 3. Arrow table preparation
|
|
# =============================================================================
|
|
|
|
|
|
class TestArrowTablePreparation:
|
|
"""Tests for _prepare_arrow_table."""
|
|
|
|
def test_renames_columns_and_filters_null_ids(self):
|
|
"""Custom columns are renamed and null IDs filtered."""
|
|
table = pa.table(
|
|
{
|
|
"doc_id": [1, 2, None],
|
|
"emb": [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
|
|
}
|
|
)
|
|
sink = make_sink(id_column="doc_id", vector_column="emb")
|
|
|
|
prepared = sink._prepare_arrow_table(table)
|
|
|
|
# Null ID row filtered, columns renamed to id/vector
|
|
expected = pa.table(
|
|
{
|
|
"id": [1, 2],
|
|
"vector": [[0.1, 0.2], [0.3, 0.4]],
|
|
}
|
|
)
|
|
assert prepared.equals(expected)
|
|
|
|
def test_missing_id_column_raises(self):
|
|
"""Missing custom ID column raises ValueError."""
|
|
table = pa.table({"other": [1, 2, 3]})
|
|
sink = make_sink(id_column="doc_id")
|
|
|
|
with pytest.raises(ValueError):
|
|
sink._prepare_arrow_table(table)
|
|
|
|
def test_missing_vector_column_raises(self):
|
|
"""Missing vector column raises ValueError."""
|
|
table = pa.table({"id": [1, 2, 3]})
|
|
sink = make_sink(vector_column="embedding")
|
|
|
|
with pytest.raises(ValueError, match="Vector column 'embedding' not found"):
|
|
sink._prepare_arrow_table(table)
|
|
|
|
@pytest.mark.parametrize(
|
|
"existing_col,custom_col,expected_match",
|
|
[
|
|
("id", "doc_id", "already has.*'id' column"),
|
|
("vector", "emb", "already has.*'vector' column"),
|
|
],
|
|
ids=["id_conflict", "vector_conflict"],
|
|
)
|
|
def test_conflicting_column_names_raise(
|
|
self, existing_col, custom_col, expected_match
|
|
):
|
|
"""Raise if table already has target column name."""
|
|
if existing_col == "id":
|
|
table = pa.table(
|
|
{"id": [1, 2], "doc_id": [10, 20], "vector": [[0.1], [0.2]]}
|
|
)
|
|
sink = make_sink(id_column="doc_id")
|
|
else:
|
|
table = pa.table(
|
|
{"id": [1, 2], "vector": [[0.1], [0.2]], "emb": [[0.3], [0.4]]}
|
|
)
|
|
sink = make_sink(vector_column="emb")
|
|
|
|
with pytest.raises(ValueError, match=expected_match):
|
|
sink._prepare_arrow_table(table)
|
|
|
|
|
|
# =============================================================================
|
|
# 4. Single-namespace batching
|
|
# =============================================================================
|
|
|
|
|
|
class TestSingleNamespaceBatching:
|
|
"""Tests for write batching behavior."""
|
|
|
|
def test_batches_by_batch_size(self, mock_client):
|
|
"""Large tables are split into batches."""
|
|
num_rows = 25
|
|
table = pa.table(
|
|
{
|
|
"id": list(range(num_rows)),
|
|
"vector": [[float(i)] for i in range(num_rows)],
|
|
}
|
|
)
|
|
sink = make_sink(batch_size=10)
|
|
batch_sizes: List[int] = []
|
|
|
|
def track_batch(ns, batch, namespace_name=None):
|
|
# batch is a RecordBatch, get its row count
|
|
batch_sizes.append(batch.num_rows)
|
|
|
|
with patch.object(sink, "_get_client", return_value=mock_client):
|
|
with patch.object(sink, "_write_batch_with_retry", side_effect=track_batch):
|
|
sink.write([table], ctx=None)
|
|
|
|
assert batch_sizes == [10, 10, 5]
|
|
|
|
def test_skips_empty_blocks(self, sink):
|
|
"""Empty blocks don't trigger namespace writes."""
|
|
empty_table = pa.table({"id": [], "vector": []})
|
|
|
|
with patch.object(sink, "_get_client") as mock_get_client:
|
|
with patch.object(sink, "_write_batch_with_retry") as mock_write:
|
|
mock_get_client.return_value = MagicMock()
|
|
sink.write([empty_table], ctx=None)
|
|
|
|
mock_write.assert_not_called()
|
|
|
|
|
|
# =============================================================================
|
|
# 5. Transform to Turbopuffer format
|
|
# =============================================================================
|
|
|
|
|
|
class TestTransformToTurbopufferFormat:
|
|
"""Tests for _transform_to_turbopuffer_format."""
|
|
|
|
def test_requires_id_column(self, sink):
|
|
"""Table must have 'id' column."""
|
|
table = pa.table({"col": [1, 2, 3]})
|
|
|
|
with pytest.raises(ValueError):
|
|
sink._transform_to_turbopuffer_format(table)
|
|
|
|
def test_converts_uuid_bytes_to_native_uuid(self, sink):
|
|
"""16-byte binary IDs become native uuid.UUID objects.
|
|
|
|
Per Turbopuffer performance docs, native UUIDs (16 bytes) are more
|
|
efficient than string UUIDs (36 bytes).
|
|
"""
|
|
u = uuid.uuid4()
|
|
|
|
# ID column must be binary(16) for UUID conversion
|
|
table = pa.table(
|
|
{
|
|
"id": pa.array([u.bytes], type=pa.binary(16)),
|
|
"vector": [[0.1, 0.2]],
|
|
}
|
|
)
|
|
|
|
columns = sink._transform_to_turbopuffer_format(table)
|
|
|
|
expected = {
|
|
"id": [u], # Native uuid.UUID, not bytes
|
|
"vector": [[0.1, 0.2]],
|
|
}
|
|
assert columns == expected
|
|
assert isinstance(columns["id"][0], uuid.UUID)
|
|
|
|
|
|
# =============================================================================
|
|
# 6. Retry logic
|
|
# =============================================================================
|
|
|
|
|
|
class TestRetryLogic:
|
|
"""Tests for _write_batch_with_retry."""
|
|
|
|
@pytest.fixture
|
|
def sample_batch(self):
|
|
"""A simple batch for retry tests."""
|
|
return pa.table({"id": [1], "vector": [[0.1]]})
|
|
|
|
def test_success_first_try(self, sink, sample_batch):
|
|
"""Successful write on first attempt."""
|
|
namespace = MagicMock()
|
|
|
|
sink._write_batch_with_retry(namespace, sample_batch)
|
|
|
|
namespace.write.assert_called_once_with(
|
|
upsert_columns={"id": [1], "vector": [[0.1]]},
|
|
schema=None,
|
|
distance_metric="cosine_distance",
|
|
)
|
|
|
|
def test_retries_then_succeeds(self, sink, sample_batch, monkeypatch):
|
|
"""Transient failures are retried."""
|
|
monkeypatch.setattr(time, "sleep", lambda _: None)
|
|
namespace = MagicMock()
|
|
attempts = {"count": 0}
|
|
|
|
def flaky_write(*args, **kwargs):
|
|
attempts["count"] += 1
|
|
if attempts["count"] < 3:
|
|
raise RuntimeError("temporary error")
|
|
|
|
namespace.write.side_effect = flaky_write
|
|
|
|
sink._write_batch_with_retry(namespace, sample_batch)
|
|
|
|
assert attempts["count"] == 3
|
|
|
|
def test_exhausts_retries_and_raises(self, sink, sample_batch, monkeypatch):
|
|
"""Persistent failures exhaust retries and raise."""
|
|
monkeypatch.setattr(time, "sleep", lambda _: None)
|
|
namespace = MagicMock()
|
|
namespace.write.side_effect = RuntimeError("persistent error")
|
|
|
|
with pytest.raises(RuntimeError, match="persistent error"):
|
|
sink._write_batch_with_retry(namespace, sample_batch)
|
|
|
|
assert namespace.write.call_count == 5 # max_attempts=5
|
|
|
|
@pytest.mark.parametrize(
|
|
"schema,distance_metric",
|
|
[
|
|
({"field": "value"}, "cosine_distance"),
|
|
(None, "euclidean_squared"),
|
|
({"type": "string"}, "euclidean_squared"),
|
|
],
|
|
ids=["with_schema", "alt_metric", "both"],
|
|
)
|
|
def test_configurable_options(self, schema, distance_metric):
|
|
"""Schema and distance_metric are passed to write."""
|
|
sink = make_sink(schema=schema, distance_metric=distance_metric)
|
|
namespace = MagicMock()
|
|
batch = pa.table({"id": [1], "vector": [[0.1]]})
|
|
|
|
sink._write_batch_with_retry(namespace, batch)
|
|
|
|
namespace.write.assert_called_once_with(
|
|
upsert_columns={"id": [1], "vector": [[0.1]]},
|
|
schema=schema,
|
|
distance_metric=distance_metric,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# 7. End-to-end write orchestration
|
|
# =============================================================================
|
|
|
|
|
|
class TestWriteOrchestration:
|
|
"""Tests for top-level write() method."""
|
|
|
|
def test_write_multiple_blocks(self, sink):
|
|
"""Multiple blocks are processed and written."""
|
|
blocks = [
|
|
pa.table({"id": [1, 2], "vector": [[1.0], [2.0]]}),
|
|
pa.table({"id": [3], "vector": [[3.0]]}),
|
|
]
|
|
write_calls = []
|
|
|
|
def track_write(ns, batch, namespace_name=None):
|
|
write_calls.append(batch.num_rows)
|
|
|
|
with patch.object(sink, "_get_client") as mock_get_client:
|
|
mock_client = MagicMock()
|
|
mock_get_client.return_value = mock_client
|
|
with patch.object(sink, "_write_batch_with_retry", side_effect=track_write):
|
|
sink.write(blocks, ctx=None)
|
|
|
|
# Two blocks written
|
|
assert len(write_calls) == 2
|
|
assert write_calls == [2, 1]
|
|
|
|
# Namespace accessed with correct name
|
|
mock_client.namespace.assert_called_with("default_ns")
|
|
|
|
|
|
# =============================================================================
|
|
# 8. Streaming behavior (memory efficiency)
|
|
# =============================================================================
|
|
|
|
|
|
class TestStreamingBehavior:
|
|
"""Tests for memory-efficient streaming writes."""
|
|
|
|
def test_processes_blocks_independently(self, sink):
|
|
"""Each block is processed and written separately."""
|
|
blocks = [pa.table({"id": [i], "vector": [[float(i)]]}) for i in range(5)]
|
|
write_counts = []
|
|
|
|
def track_write(ns, batch, namespace_name=None):
|
|
write_counts.append(batch.num_rows)
|
|
|
|
with patch.object(sink, "_get_client", return_value=MagicMock()):
|
|
with patch.object(sink, "_write_batch_with_retry", side_effect=track_write):
|
|
sink.write(blocks, ctx=None)
|
|
|
|
# 5 blocks → 5 writes of 1 row each
|
|
assert len(write_counts) == 5
|
|
assert all(c == 1 for c in write_counts)
|
|
|
|
|
|
# =============================================================================
|
|
# 9. Multi-namespace writes
|
|
# =============================================================================
|
|
|
|
|
|
class TestMultiNamespaceWrites:
|
|
"""Tests for namespace_column-driven multi-namespace writes."""
|
|
|
|
def test_routes_rows_to_correct_namespaces(self):
|
|
"""Rows are grouped by namespace_column and written to the right ns."""
|
|
sink = make_sink(namespace=None, namespace_column="tenant")
|
|
table = pa.table(
|
|
{
|
|
"tenant": ["ns_a", "ns_b", "ns_a", "ns_b"],
|
|
"id": [1, 2, 3, 4],
|
|
"vector": [[0.1], [0.2], [0.3], [0.4]],
|
|
}
|
|
)
|
|
|
|
writes = {} # namespace_name -> list of row counts
|
|
|
|
def track_write(ns, batch, namespace_name=None):
|
|
writes.setdefault(namespace_name, []).append(batch.num_rows)
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.namespace.return_value = MagicMock()
|
|
with patch.object(sink, "_get_client", return_value=mock_client):
|
|
with patch.object(sink, "_write_batch_with_retry", side_effect=track_write):
|
|
sink.write([table], ctx=None)
|
|
|
|
assert "ns_a" in writes
|
|
assert "ns_b" in writes
|
|
assert sum(writes["ns_a"]) == 2
|
|
assert sum(writes["ns_b"]) == 2
|
|
|
|
def test_drops_namespace_column_before_writing(self):
|
|
"""The namespace column is not included in the written data."""
|
|
sink = make_sink(namespace=None, namespace_column="tenant")
|
|
table = pa.table(
|
|
{
|
|
"tenant": ["ns_a"],
|
|
"id": [1],
|
|
"vector": [[0.1]],
|
|
}
|
|
)
|
|
|
|
written_batches = []
|
|
|
|
def capture_batch(ns, batch, namespace_name=None):
|
|
written_batches.append(batch)
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.namespace.return_value = MagicMock()
|
|
with patch.object(sink, "_get_client", return_value=mock_client):
|
|
with patch.object(
|
|
sink, "_write_batch_with_retry", side_effect=capture_batch
|
|
):
|
|
sink.write([table], ctx=None)
|
|
|
|
assert len(written_batches) == 1
|
|
assert "tenant" not in written_batches[0].column_names
|
|
assert "id" in written_batches[0].column_names
|
|
|
|
def test_missing_namespace_column_raises(self):
|
|
"""Missing namespace column in data raises ValueError."""
|
|
sink = make_sink(namespace=None, namespace_column="tenant")
|
|
table = pa.table(
|
|
{
|
|
"id": [1],
|
|
"vector": [[0.1]],
|
|
}
|
|
)
|
|
|
|
mock_client = MagicMock()
|
|
with patch.object(sink, "_get_client", return_value=mock_client):
|
|
with pytest.raises(ValueError, match="Namespace column.*not found"):
|
|
sink.write([table], ctx=None)
|
|
|
|
def test_null_namespace_values_raise(self):
|
|
"""Null values in namespace column raise ValueError."""
|
|
sink = make_sink(namespace=None, namespace_column="tenant")
|
|
table = pa.table(
|
|
{
|
|
"tenant": ["ns_a", None],
|
|
"id": [1, 2],
|
|
"vector": [[0.1], [0.2]],
|
|
}
|
|
)
|
|
|
|
mock_client = MagicMock()
|
|
with patch.object(sink, "_get_client", return_value=mock_client):
|
|
with pytest.raises(ValueError, match="contains null values"):
|
|
sink.write([table], ctx=None)
|
|
|
|
def test_skips_empty_blocks_in_multi_namespace(self):
|
|
"""Empty blocks are skipped in multi-namespace mode."""
|
|
sink = make_sink(namespace=None, namespace_column="tenant")
|
|
empty_table = pa.table(
|
|
{
|
|
"tenant": pa.array([], type=pa.string()),
|
|
"id": pa.array([], type=pa.int64()),
|
|
"vector": pa.array([], type=pa.list_(pa.float64())),
|
|
}
|
|
)
|
|
|
|
mock_client = MagicMock()
|
|
with patch.object(sink, "_get_client", return_value=mock_client):
|
|
with patch.object(sink, "_write_batch_with_retry") as mock_write:
|
|
sink.write([empty_table], ctx=None)
|
|
|
|
mock_write.assert_not_called()
|
|
|
|
|
|
# =============================================================================
|
|
# 10. Serialization behavior
|
|
# =============================================================================
|
|
|
|
|
|
class TestSerialization:
|
|
"""Tests for pickle serialization support."""
|
|
|
|
def test_preserves_configuration(self, sink, mock_turbopuffer_module):
|
|
"""Configuration is preserved after pickle round-trip."""
|
|
pickled = pickle.dumps(sink)
|
|
unpickled = pickle.loads(pickled)
|
|
|
|
assert unpickled.namespace == sink.namespace
|
|
assert unpickled.namespace_column == sink.namespace_column
|
|
assert unpickled.api_key == sink.api_key
|
|
assert unpickled.region == sink.region
|
|
assert unpickled.base_url == sink.base_url
|
|
assert unpickled.batch_size == sink.batch_size
|
|
assert unpickled._client is None
|
|
|
|
# Lazy initialization works after unpickling
|
|
client = unpickled._get_client()
|
|
assert client is not None
|
|
mock_turbopuffer_module.Turbopuffer.assert_called()
|
|
|
|
def test_preserves_namespace_column_configuration(self, mock_turbopuffer_module):
|
|
"""namespace_column configuration survives pickle round-trip."""
|
|
sink = make_sink(namespace=None, namespace_column="tenant")
|
|
pickled = pickle.dumps(sink)
|
|
unpickled = pickle.loads(pickled)
|
|
|
|
assert unpickled.namespace is None
|
|
assert unpickled.namespace_column == "tenant"
|
|
assert unpickled._client is None
|
|
|
|
def test_preserves_base_url_configuration(self, mock_turbopuffer_module):
|
|
"""base_url configuration survives pickle round-trip."""
|
|
sink = make_sink(
|
|
region=None,
|
|
base_url="https://gcp-us-central1.turbopuffer.com",
|
|
)
|
|
pickled = pickle.dumps(sink)
|
|
unpickled = pickle.loads(pickled)
|
|
|
|
assert unpickled.region is None
|
|
assert unpickled.base_url == "https://gcp-us-central1.turbopuffer.com"
|
|
assert unpickled._client is None
|
|
|
|
# Lazy initialization works and uses base_url
|
|
unpickled._get_client()
|
|
mock_turbopuffer_module.Turbopuffer.assert_called_once_with(
|
|
api_key="test-api-key",
|
|
base_url="https://gcp-us-central1.turbopuffer.com",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
sys.exit(pytest.main(["-v", __file__]))
|