"""Tests for Databricks Unity Catalog datasource.""" import json import os import re import tempfile import uuid from contextlib import contextmanager from dataclasses import dataclass from unittest import mock import pandas as pd import pyarrow as pa import pytest import ray import ray.cloudpickle as pickle from ray.data._internal.datasource.databricks_credentials import ( DatabricksCredentialProvider, StaticCredentialProvider, ) from ray.data._internal.datasource.databricks_uc_datasource import ( DatabricksUCDatasource, ) from ray.data._internal.util import rows_same from ray.data.tests.datasource.databricks_test_utils import ( MockResponse, RefreshableCredentialProvider, ) from ray.tests.conftest import * # noqa # ============================================================================= # Dataclasses for mock objects # ============================================================================= @dataclass class MockChunk: """Mock chunk data for testing.""" index: int row_count: int byte_count: int data: bytes # ============================================================================= # Mock credential providers for testing # ============================================================================= class TokenTrackingProvider(DatabricksCredentialProvider): """A credential provider that returns incrementing tokens to track fetches.""" def __init__(self): self.token_fetch_count = 0 def get_token(self) -> str: self.token_fetch_count += 1 return f"token_{self.token_fetch_count}" def get_host(self) -> str: return "test_host" def invalidate(self) -> None: pass # ============================================================================= # Pytest fixtures # ============================================================================= @pytest.fixture def databricks_env(): """Fixture that sets up Databricks environment variables.""" with mock.patch.dict( os.environ, {"DATABRICKS_HOST": "test_host", "DATABRICKS_TOKEN": "test_token"}, ): yield @pytest.fixture def refreshable_credential_provider(): """Fixture that provides a refreshable credential provider.""" return RefreshableCredentialProvider(host="test_host") @pytest.fixture def token_tracking_provider(): """Fixture that provides a token tracking credential provider.""" return TokenTrackingProvider() @pytest.fixture def requests_mocker(): """Fixture that mocks requests.get and requests.post.""" with mock.patch("requests.get") as mock_get: with mock.patch("requests.post") as mock_post: yield {"get": mock_get, "post": mock_post} @pytest.fixture def test_data(): """Fixture that provides test DataFrame and configuration.""" return { "expected_df": pd.DataFrame( { "c1": range(10000), "c2": [f"str{i}" for i in range(10000)], } ), "token": "test_token", "warehouse_id": "test_warehouse_id", "catalog": "catalog1", "schema": "db1", "query": "select * from table1", "rows_per_chunk": 700, } # ============================================================================= # Helper functions # ============================================================================= def create_mock_chunks(df: pd.DataFrame, rows_per_chunk: int) -> list[MockChunk]: """Create mock chunks from a DataFrame.""" chunks = [] num_rows = len(df) cur_pos = 0 index = 0 while cur_pos < num_rows: chunk_rows = min(rows_per_chunk, num_rows - cur_pos) chunk_df = df[cur_pos : cur_pos + chunk_rows] chunk_pa_table = pa.Table.from_pandas(chunk_df) sink = pa.BufferOutputStream() with pa.ipc.new_stream(sink, chunk_pa_table.schema) as writer: writer.write_table(chunk_pa_table) chunks.append( MockChunk( index=index, row_count=chunk_rows, byte_count=len(sink.getvalue()), data=sink.getvalue(), ) ) index += 1 cur_pos += rows_per_chunk return chunks # ============================================================================= # Test classes # ============================================================================= class TestDatabricksUCDatasourceIntegration: """Integration tests for DatabricksUCDatasource.""" _MOCK_ENV_VAR = "RAY_DATABRICKS_UC_DATASOURCE_READ_FN_MOCK_TEST_SETUP_FN_PATH" @contextmanager def _setup_mock(self, test_data: dict, mock_chunks: list[MockChunk]): """Set up mocks for integration tests.""" chunk_meta_json = [ { "chunk_index": chunk.index, "row_count": chunk.row_count, "byte_count": chunk.byte_count, } for chunk in mock_chunks ] chunk_meta_json.reverse() valid_statement_ids = set() def request_post_mock(url, data=None, json=None, **kwargs): import json as jsonlib headers = kwargs["headers"] if url == "https://test_shard/api/2.0/sql/statements/": assert headers == { "Content-Type": "application/json", "Authorization": f"Bearer {test_data['token']}", } assert jsonlib.loads(data) == { "statement": test_data["query"], "warehouse_id": test_data["warehouse_id"], "wait_timeout": "0s", "disposition": "EXTERNAL_LINKS", "format": "ARROW_STREAM", "catalog": test_data["catalog"], "schema": test_data["schema"], } statement_id = uuid.uuid4().hex valid_statement_ids.add(statement_id) return MockResponse( status_code=200, content=b"", _json_data={ "statement_id": statement_id, "status": {"state": "PENDING"}, }, ) assert False, "Invalid request." def request_get_mock(url, params=None, **kwargs): headers = kwargs["headers"] if match := re.match( r"^https://test_shard/api/2\.0/sql/statements/([^/]*)/$", url ): statement_id = match.group(1) assert headers == { "Content-Type": "application/json", "Authorization": f"Bearer {test_data['token']}", } assert statement_id in valid_statement_ids return MockResponse( status_code=200, _json_data={ "status": {"state": "SUCCEEDED"}, "manifest": { "truncated": False, "chunks": chunk_meta_json, }, }, ) if match := re.match( r"^https://test_shard/api/2\.0/sql/" r"statements/([^/]*)/result/chunks/([^/]*)$", url, ): assert headers == { "Content-Type": "application/json", "Authorization": f"Bearer {test_data['token']}", } chunk_index = match.group(2) external_link = f"https://test_external_link/{chunk_index}" return MockResponse( status_code=200, _json_data={"external_links": [{"external_link": external_link}]}, ) if match := re.match(r"^https://test_external_link/([^/]*)$", url): assert headers is None chunk_index = int(match.group(1)) return MockResponse( status_code=200, content=mock_chunks[chunk_index].data, ) assert False, "Invalid request." with ( mock.patch("requests.get", request_get_mock), mock.patch("requests.post", request_post_mock), mock.patch.dict( os.environ, { "DATABRICKS_HOST": "test_shard", "DATABRICKS_TOKEN": test_data["token"], }, ), ): yield @contextmanager def _setup_integration_test(self, test_data: dict): """Set up complete integration test environment with mocks and Ray.""" mock_chunks = create_mock_chunks( test_data["expected_df"], test_data["rows_per_chunk"] ) setup_mock_fn_path = os.path.join(tempfile.mkdtemp(), "setup_mock_fn.pkl") with open(setup_mock_fn_path, "wb") as fp: pickle.dump(lambda: self._setup_mock(test_data, mock_chunks), fp) with ( self._setup_mock(test_data, mock_chunks), mock.patch.dict(os.environ, {self._MOCK_ENV_VAR: setup_mock_fn_path}), ): ray.shutdown() ray.init() yield def test_read_with_table_name(self, test_data): """Test reading data using table name.""" with self._setup_integration_test(test_data): result = ray.data.read_databricks_tables( warehouse_id=test_data["warehouse_id"], table="table1", catalog=test_data["catalog"], schema=test_data["schema"], override_num_blocks=5, ).to_pandas() assert rows_same(result, test_data["expected_df"]) def test_read_with_sql_query(self, test_data): """Test reading data using SQL query.""" with self._setup_integration_test(test_data): result = ray.data.read_databricks_tables( warehouse_id=test_data["warehouse_id"], query=test_data["query"], catalog=test_data["catalog"], schema=test_data["schema"], override_num_blocks=5, ).to_pandas() assert rows_same(result, test_data["expected_df"]) @pytest.mark.parametrize("num_blocks", [5, 100]) def test_read_with_different_parallelism(self, test_data, num_blocks): """Test reading data with different parallelism settings.""" with self._setup_integration_test(test_data): result = ray.data.read_databricks_tables( warehouse_id=test_data["warehouse_id"], query=test_data["query"], catalog=test_data["catalog"], schema=test_data["schema"], override_num_blocks=num_blocks, ).to_pandas() assert rows_same(result, test_data["expected_df"]) class TestDatabricksUCDatasourceCredentials: """Tests for credential provider handling.""" def test_schema_name_does_not_shadow_datasource_fields(self, requests_mocker): """Test that schema name is stored without using the `schema` attribute. This is a regression test for https://github.com/ray-project/ray/issues/46481. """ requests_mocker["post"].return_value = mock.Mock( status_code=200, raise_for_status=lambda: None, json=lambda: {"statement_id": "test_stmt", "status": {"state": "PENDING"}}, ) requests_mocker["get"].return_value = mock.Mock( status_code=200, raise_for_status=lambda: None, json=lambda: { "status": {"state": "SUCCEEDED"}, "manifest": {"truncated": False, "chunks": []}, }, ) provider = StaticCredentialProvider(token="my_provider_token", host="test_host") datasource = DatabricksUCDatasource( warehouse_id="test_warehouse", catalog="test_catalog", schema="test_schema", query="SELECT 1", credential_provider=provider, ) assert datasource.schema_name == "test_schema" assert "schema" not in datasource.__dict__ call_kwargs = requests_mocker["post"].call_args[1] payload = json.loads(call_kwargs["data"]) assert payload["schema"] == "test_schema" def test_with_credential_provider(self, requests_mocker): """Test DatabricksUCDatasource with credential_provider parameter.""" requests_mocker["post"].return_value = mock.Mock( status_code=200, raise_for_status=lambda: None, json=lambda: {"statement_id": "test_stmt", "status": {"state": "PENDING"}}, ) requests_mocker["get"].return_value = mock.Mock( status_code=200, raise_for_status=lambda: None, json=lambda: { "status": {"state": "SUCCEEDED"}, "manifest": {"truncated": False}, }, ) provider = StaticCredentialProvider(token="my_provider_token", host="test_host") _datasource = DatabricksUCDatasource( warehouse_id="test_warehouse", catalog="test_catalog", schema="test_schema", query="SELECT 1", credential_provider=provider, ) # Verify the token from provider was used in requests call_kwargs = requests_mocker["post"].call_args[1] assert "Authorization" in call_kwargs["headers"] assert "Bearer my_provider_token" in call_kwargs["headers"]["Authorization"] def test_fresh_token_per_request(self, requests_mocker, token_tracking_provider): """Test that fresh tokens are fetched for each request during polling.""" tokens_used = [] def capture_post(url, *args, **kwargs): tokens_used.append(kwargs["headers"]["Authorization"]) return mock.Mock( status_code=200, raise_for_status=lambda: None, json=lambda: { "statement_id": "test_stmt", "status": {"state": "PENDING"}, }, ) poll_count = [0] def capture_get(url, *args, **kwargs): tokens_used.append(kwargs["headers"]["Authorization"]) poll_count[0] += 1 state = "PENDING" if poll_count[0] < 3 else "SUCCEEDED" return mock.Mock( status_code=200, raise_for_status=lambda: None, json=lambda: { "status": {"state": state}, "manifest": {"truncated": False, "chunks": []}, }, ) requests_mocker["post"].side_effect = capture_post requests_mocker["get"].side_effect = capture_get DatabricksUCDatasource( warehouse_id="test_warehouse", catalog="test_catalog", schema="test_schema", query="SELECT 1", credential_provider=token_tracking_provider, ) # Verify fresh token was fetched for each request: # 1 POST (statement creation) + 3 GETs (polling) assert token_tracking_provider.token_fetch_count == 4 assert tokens_used == [ "Bearer token_1", # POST "Bearer token_2", # GET poll 1 "Bearer token_3", # GET poll 2 "Bearer token_4", # GET poll 3 ] class TestDatabricksUCDatasource401Retry: """Tests for 401 retry behavior.""" def test_401_during_initial_post( self, requests_mocker, refreshable_credential_provider ): """Test that 401 during initial POST triggers credential invalidation and retry.""" post_call_count = [0] post_headers_captured = [] def post_side_effect(url, *args, **kwargs): post_call_count[0] += 1 headers = kwargs.get("headers", {}) post_headers_captured.append(headers.get("Authorization", "")) # First POST returns 401 if post_call_count[0] == 1: return mock.Mock(status_code=401) # Retry succeeds return mock.Mock( status_code=200, json=lambda: { "statement_id": "test_stmt", "status": {"state": "SUCCEEDED"}, "manifest": {"truncated": False, "chunks": []}, }, ) requests_mocker["post"].side_effect = post_side_effect DatabricksUCDatasource( warehouse_id="test_warehouse", catalog="test_catalog", schema="test_schema", query="SELECT 1", credential_provider=refreshable_credential_provider, ) # Verify retry occurred assert ( post_call_count[0] == 2 ), "Expected POST to be called twice (initial + retry)" # Verify invalidate was called assert refreshable_credential_provider.invalidate_count == 1 # Verify first request used expired token, retry used refreshed token assert "expired_token" in post_headers_captured[0] assert "refreshed_token" in post_headers_captured[1] def test_401_during_polling(self, requests_mocker, refreshable_credential_provider): """Test that 401 during polling triggers credential invalidation and retry.""" poll_call_count = [0] poll_headers_captured = [] requests_mocker["post"].return_value = mock.Mock( status_code=200, json=lambda: { "statement_id": "test_stmt", "status": {"state": "PENDING"}, }, ) def get_side_effect(url, *args, **kwargs): poll_call_count[0] += 1 headers = kwargs.get("headers", {}) poll_headers_captured.append(headers.get("Authorization", "")) # First poll returns 401 with expired token if poll_call_count[0] == 1: return mock.Mock(status_code=401) # Retry succeeds return mock.Mock( status_code=200, json=lambda: { "status": {"state": "SUCCEEDED"}, "manifest": {"truncated": False, "chunks": []}, }, ) requests_mocker["get"].side_effect = get_side_effect DatabricksUCDatasource( warehouse_id="test_warehouse", catalog="test_catalog", schema="test_schema", query="SELECT 1", credential_provider=refreshable_credential_provider, ) # Verify retry occurred assert ( poll_call_count[0] == 2 ), "Expected GET to be called twice (initial + retry)" # Verify invalidate was called once assert refreshable_credential_provider.invalidate_count == 1 # Verify first request used expired token, retry used refreshed token assert "expired_token" in poll_headers_captured[0] assert "refreshed_token" in poll_headers_captured[1] def test_401_during_chunk_fetch( self, requests_mocker, refreshable_credential_provider ): """Test that 401 during chunk fetch triggers credential invalidation and retry.""" chunk_fetch_count = [0] chunk_fetch_headers = [] # Create Arrow data for external URL response table = pa.Table.from_pydict({"col1": [1, 2, 3]}) sink = pa.BufferOutputStream() with pa.ipc.new_stream(sink, table.schema) as writer: writer.write_table(table) arrow_data = sink.getvalue().to_pybytes() # POST for statement creation succeeds requests_mocker["post"].return_value = mock.Mock( status_code=200, json=lambda: { "statement_id": "test_stmt", "status": {"state": "SUCCEEDED"}, "manifest": { "truncated": False, "chunks": [{"chunk_index": 0, "row_count": 10, "byte_count": 100}], }, }, ) def get_side_effect(url, *args, **kwargs): headers = kwargs.get("headers", {}) # External URL fetch (no auth headers) if url.startswith("https://external/"): return mock.Mock(status_code=200, content=arrow_data) if "/result/chunks/" in url: chunk_fetch_count[0] += 1 chunk_fetch_headers.append(headers.get("Authorization", "")) # First chunk fetch returns 401 if chunk_fetch_count[0] == 1: return mock.Mock(status_code=401) # Retry succeeds return mock.Mock( status_code=200, json=lambda: { "external_links": [{"external_link": "https://external/data"}] }, ) else: # Polling response (already succeeded in POST) return mock.Mock( status_code=200, json=lambda: { "status": {"state": "SUCCEEDED"}, "manifest": { "truncated": False, "chunks": [ {"chunk_index": 0, "row_count": 10, "byte_count": 100} ], }, }, ) requests_mocker["get"].side_effect = get_side_effect # Create datasource datasource = DatabricksUCDatasource( warehouse_id="test_warehouse", catalog="test_catalog", schema="test_schema", query="SELECT 1", credential_provider=refreshable_credential_provider, ) # Get read tasks and execute the read function to trigger chunk fetch read_tasks = datasource.get_read_tasks(parallelism=1) assert len(read_tasks) == 1 # Execute the read function - this triggers chunk fetch read_fn = read_tasks[0].read_fn results = list(read_fn()) # Verify chunk fetch retry occurred assert ( chunk_fetch_count[0] == 2 ), "Expected chunk fetch to be called twice (initial + retry)" # Verify invalidate was called during chunk fetch assert refreshable_credential_provider.invalidate_count == 1 # Verify first chunk fetch used expired token, retry used refreshed token assert "expired_token" in chunk_fetch_headers[0] assert "refreshed_token" in chunk_fetch_headers[1] # Verify we got results assert len(results) == 1 class TestDatabricksUCDatasourceEmptyResult: """Tests for empty result handling.""" def test_empty_result_returns_zero_count(self, requests_mocker, databricks_env): """Test that empty result returns zero count.""" def post_mock(url, *args, **kwargs): return MockResponse( status_code=200, _json_data={ "statement_id": "test_stmt", "status": {"state": "PENDING"}, }, ) def get_mock(url, *args, **kwargs): return MockResponse( status_code=200, _json_data={ "status": {"state": "SUCCEEDED"}, "manifest": {"truncated": False}, }, ) requests_mocker["post"].side_effect = post_mock requests_mocker["get"].side_effect = get_mock ds = ray.data.read_databricks_tables( warehouse_id="dummy_warehouse", query="select * from dummy_table", catalog="dummy_catalog", schema="dummy_schema", override_num_blocks=1, ) assert ds.count() == 0 if __name__ == "__main__": import sys sys.exit(pytest.main(["-v", __file__]))