Files
wehub-resource-sync bf2343b7e4
Integration Tests - MySQL + Elasticsearch / Detect Changes (push) Has been cancelled
Integration Tests - MySQL + Elasticsearch / integration-tests-mysql-elasticsearch (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / integration-tests-postgres-elasticsearch-redis (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / integration-tests-postgres-opensearch (push) Has been cancelled
Java Checkstyle / java-checkstyle (push) Has been cancelled
Maven Collate Tests / maven-collate-ci (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests-status (push) Has been cancelled
Publish Package to Maven Central Repository / publish-maven-packages (push) Has been cancelled
OpenMetadata Service Unit Tests / Detect Changes (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (push) Has been cancelled
OpenMetadata Service Unit Tests / k8s_operator-unit-tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:35:45 +08:00

640 lines
23 KiB
Python

# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for the OperationMetricsState singleton and tracking utilities"""
import threading
import time
import pytest
from metadata.utils.operation_metrics import (
OperationMetricsState,
OperationSummary,
RunningStatistics,
TrackOperation,
track_operation,
track_operation_context,
)
from metadata.utils.singleton import Singleton
class TestRunningStatistics:
"""Tests for the RunningStatistics class (Welford's algorithm)"""
def test_empty_statistics(self) -> None:
stats = RunningStatistics()
assert stats.count == 0
assert stats.total == 0.0
assert stats.mean == 0.0
assert stats.min_val is None
assert stats.max_val is None
def test_single_value(self) -> None:
stats = RunningStatistics()
stats.add(10.0)
assert stats.count == 1
assert stats.total == 10.0
assert stats.mean == 10.0
assert stats.min_val == 10.0
assert stats.max_val == 10.0
def test_multiple_values(self) -> None:
stats = RunningStatistics()
stats.add(10.0)
stats.add(20.0)
stats.add(30.0)
assert stats.count == 3
assert stats.total == 60.0
assert stats.mean == 20.0
assert stats.min_val == 10.0
assert stats.max_val == 30.0
def test_running_mean_accuracy(self) -> None:
stats = RunningStatistics()
values = [1.0, 2.0, 3.0, 4.0, 5.0, 100.0]
for v in values:
stats.add(v)
expected_mean = sum(values) / len(values)
assert abs(stats.mean - expected_mean) < 0.0001
def test_merge_empty_into_empty(self) -> None:
stats1 = RunningStatistics()
stats2 = RunningStatistics()
stats1.merge(stats2)
assert stats1.count == 0
def test_merge_values_into_empty(self) -> None:
stats1 = RunningStatistics()
stats2 = RunningStatistics()
stats2.add(10.0)
stats2.add(20.0)
stats1.merge(stats2)
assert stats1.count == 2
assert stats1.total == 30.0
assert stats1.mean == 15.0
def test_merge_two_populated_stats(self) -> None:
stats1 = RunningStatistics()
stats1.add(10.0)
stats1.add(20.0)
stats2 = RunningStatistics()
stats2.add(30.0)
stats2.add(40.0)
stats1.merge(stats2)
assert stats1.count == 4
assert stats1.total == 100.0
assert stats1.mean == 25.0
assert stats1.min_val == 10.0
assert stats1.max_val == 40.0
def test_merge_updates_min_max(self) -> None:
stats1 = RunningStatistics()
stats1.add(50.0)
stats2 = RunningStatistics()
stats2.add(10.0)
stats2.add(100.0)
stats1.merge(stats2)
assert stats1.min_val == 10.0
assert stats1.max_val == 100.0
def test_to_summary_dict(self) -> None:
stats = RunningStatistics()
stats.add(10.0)
stats.add(20.0)
stats.add(30.0)
result = stats.to_summary_dict()
assert result["count"] == 3
assert result["totalTimeMs"] == 60.0
assert result["avgTimeMs"] == 20.0
assert result["minTimeMs"] == 10.0
assert result["maxTimeMs"] == 30.0
class TestOperationSummary:
"""Tests for OperationSummary model"""
def test_to_dict_format(self) -> None:
summary = OperationSummary(
count=10,
total_time_ms=100.0,
avg_time_ms=10.0,
min_time_ms=5.0,
max_time_ms=15.0,
)
result = summary.to_dict()
assert result["count"] == 10
assert result["totalTimeMs"] == 100.0
assert result["avgTimeMs"] == 10.0
assert result["minTimeMs"] == 5.0
assert result["maxTimeMs"] == 15.0
def test_defaults(self) -> None:
summary = OperationSummary()
assert summary.count == 0
assert summary.total_time_ms == 0.0
assert summary.avg_time_ms == 0.0
assert summary.min_time_ms is None
assert summary.max_time_ms is None
class TestOperationMetricsState:
"""Tests for OperationMetricsState singleton"""
@pytest.fixture(autouse=True)
def reset_singleton(self):
"""Reset singleton state before each test"""
Singleton.clear_all()
yield
Singleton.clear_all()
def test_singleton_returns_same_instance(self) -> None:
metrics1 = OperationMetricsState()
metrics2 = OperationMetricsState()
assert metrics1 is metrics2
def test_record_operation_basic(self) -> None:
metrics = OperationMetricsState()
metrics.record_operation("db_queries", "SELECT", 10.5)
metrics.merge_all_threads()
summary = metrics.get_summary()
assert "db_queries" in summary
assert "SELECT" in summary["db_queries"]
assert "_default" in summary["db_queries"]["SELECT"]
def test_record_operation_with_entity_type(self) -> None:
metrics = OperationMetricsState()
metrics.record_operation("db_queries", "SELECT", 10.5, "Table")
metrics.record_operation("db_queries", "SELECT", 5.5, "Table")
summary = metrics.get_summary()
assert summary["db_queries"]["SELECT"]["Table"]["count"] == 2
assert summary["db_queries"]["SELECT"]["Table"]["totalTimeMs"] == 16.0
assert summary["db_queries"]["SELECT"]["Table"]["avgTimeMs"] == 8.0
def test_record_operation_multiple_categories(self) -> None:
metrics = OperationMetricsState()
metrics.record_operation("db_queries", "SELECT", 10.0)
metrics.record_operation("api_calls", "GET:/dashboards", 50.0)
metrics.record_operation("entity_operations", "yield_table", 5.0)
summary = metrics.get_summary()
assert "db_queries" in summary
assert "api_calls" in summary
assert "entity_operations" in summary
def test_summary_statistics(self) -> None:
metrics = OperationMetricsState()
metrics.record_operation("db_queries", "SELECT", 10.0, "Table")
metrics.record_operation("db_queries", "SELECT", 20.0, "Table")
metrics.record_operation("db_queries", "SELECT", 30.0, "Table")
summary = metrics.get_summary()
table_stats = summary["db_queries"]["SELECT"]["Table"]
assert table_stats["count"] == 3
assert table_stats["totalTimeMs"] == 60.0
assert table_stats["avgTimeMs"] == 20.0
assert table_stats["minTimeMs"] == 10.0
assert table_stats["maxTimeMs"] == 30.0
def test_get_flat_summary(self) -> None:
metrics = OperationMetricsState()
metrics.record_operation("db_queries", "SELECT", 10.0)
metrics.record_operation("db_queries", "INSERT", 20.0)
metrics.record_operation("api_calls", "GET", 50.0)
flat = metrics.get_flat_summary()
assert flat["db_queries_count"] == 2
assert flat["db_queries_total_ms"] == 30.0
assert flat["api_calls_count"] == 1
assert flat["api_calls_total_ms"] == 50.0
def test_reset_clears_all_metrics(self) -> None:
metrics = OperationMetricsState()
metrics.record_operation("db_queries", "SELECT", 10.0)
metrics.reset()
summary = metrics.get_summary()
assert len(summary) == 0
def test_merge_thread_metrics(self) -> None:
metrics = OperationMetricsState()
metrics.record_operation("db_queries", "SELECT", 10.0)
current_thread_id = threading.get_ident()
metrics.merge_thread_metrics(current_thread_id)
summary = metrics.get_summary()
assert summary["db_queries"]["SELECT"]["_default"]["count"] == 1
def test_merge_all_threads(self) -> None:
metrics = OperationMetricsState()
results = [] # noqa: F841
def record_in_thread(category, operation, duration):
metrics.record_operation(category, operation, duration)
threads = []
for i in range(5):
t = threading.Thread(target=record_in_thread, args=("db_queries", "SELECT", float(i + 1)))
threads.append(t)
t.start()
for t in threads:
t.join()
metrics.merge_all_threads()
summary = metrics.get_summary()
assert summary["db_queries"]["SELECT"]["_default"]["count"] == 5
assert summary["db_queries"]["SELECT"]["_default"]["totalTimeMs"] == 15.0
def test_thread_isolation_before_merge(self) -> None:
metrics = OperationMetricsState()
barrier = threading.Barrier(2)
results = {"thread1_count": 0, "thread2_count": 0} # noqa: F841
def thread1_work():
metrics.record_operation("db_queries", "SELECT", 10.0)
barrier.wait() # Wait for both threads to record
metrics.merge_thread_metrics()
def thread2_work():
metrics.record_operation("db_queries", "SELECT", 20.0)
barrier.wait() # Wait for both threads to record
metrics.merge_thread_metrics()
t1 = threading.Thread(target=thread1_work)
t2 = threading.Thread(target=thread2_work)
t1.start()
t2.start()
t1.join()
t2.join()
summary = metrics.get_summary()
assert summary["db_queries"]["SELECT"]["_default"]["count"] == 2
class TestTrackOperationDecorator:
"""Tests for the @track_operation decorator"""
@pytest.fixture(autouse=True)
def reset_singleton(self):
"""Reset singleton state before each test"""
Singleton.clear_all()
yield
Singleton.clear_all()
def test_decorator_records_operation(self) -> None:
@track_operation(category="db_queries", operation="SELECT", entity_type="Table")
def fetch_data():
time.sleep(0.01)
return "data"
result = fetch_data()
assert result == "data"
metrics = OperationMetricsState()
summary = metrics.get_summary()
assert summary["db_queries"]["SELECT"]["Table"]["count"] == 1
assert summary["db_queries"]["SELECT"]["Table"]["avgTimeMs"] >= 10 # At least 10ms
def test_decorator_uses_function_name_when_no_operation(self) -> None:
@track_operation(category="entity_operations")
def yield_tables():
return ["table1", "table2"]
result = yield_tables() # noqa: F841
metrics = OperationMetricsState()
summary = metrics.get_summary()
assert "yield_tables" in summary["entity_operations"]
def test_decorator_records_even_on_exception(self) -> None:
@track_operation(category="db_queries", operation="SELECT")
def failing_query():
raise ValueError("Query failed")
with pytest.raises(ValueError):
failing_query()
metrics = OperationMetricsState()
summary = metrics.get_summary()
assert summary["db_queries"]["SELECT"]["_default"]["count"] == 1
class TestTrackOperationContextManager:
"""Tests for the TrackOperation context manager class"""
@pytest.fixture(autouse=True)
def reset_singleton(self):
"""Reset singleton state before each test"""
Singleton.clear_all()
yield
Singleton.clear_all()
def test_context_manager_records_operation(self) -> None:
with TrackOperation("api_calls", "GET:/users", "User"):
time.sleep(0.01)
metrics = OperationMetricsState()
summary = metrics.get_summary()
assert summary["api_calls"]["GET:/users"]["User"]["count"] == 1
assert summary["api_calls"]["GET:/users"]["User"]["avgTimeMs"] >= 10
def test_context_manager_without_entity_type(self) -> None:
with TrackOperation("api_calls", "POST:/data"):
pass
metrics = OperationMetricsState()
summary = metrics.get_summary()
assert summary["api_calls"]["POST:/data"]["_default"]["count"] == 1
def test_context_manager_records_on_exception(self) -> None:
with pytest.raises(RuntimeError): # noqa: SIM117
with TrackOperation("api_calls", "GET:/error"):
raise RuntimeError("API Error")
metrics = OperationMetricsState()
summary = metrics.get_summary()
assert summary["api_calls"]["GET:/error"]["_default"]["count"] == 1
class TestTrackOperationContextFunction:
"""Tests for the track_operation_context generator-based context manager"""
@pytest.fixture(autouse=True)
def reset_singleton(self):
"""Reset singleton state before each test"""
Singleton.clear_all()
yield
Singleton.clear_all()
def test_context_function_records_operation(self) -> None:
with track_operation_context("entity_operations", "yield_column", "Column"):
time.sleep(0.01)
metrics = OperationMetricsState()
summary = metrics.get_summary()
assert summary["entity_operations"]["yield_column"]["Column"]["count"] == 1
def test_context_function_records_on_exception(self) -> None:
with pytest.raises(ValueError): # noqa: SIM117
with track_operation_context("entity_operations", "yield_table"):
raise ValueError("Processing failed")
metrics = OperationMetricsState()
summary = metrics.get_summary()
assert summary["entity_operations"]["yield_table"]["_default"]["count"] == 1
class TestMultiThreadedOperations:
"""Integration tests for multithreaded operation metrics"""
@pytest.fixture(autouse=True)
def reset_singleton(self):
"""Reset singleton state before each test"""
Singleton.clear_all()
yield
Singleton.clear_all()
def test_high_concurrency_accuracy(self) -> None:
metrics = OperationMetricsState()
num_threads = 10
ops_per_thread = 100
def record_operations():
for i in range(ops_per_thread):
metrics.record_operation("db_queries", "SELECT", float(i))
metrics.merge_thread_metrics()
threads = [threading.Thread(target=record_operations) for _ in range(num_threads)]
for t in threads:
t.start()
for t in threads:
t.join()
summary = metrics.get_summary()
total_ops = summary["db_queries"]["SELECT"]["_default"]["count"]
assert total_ops == num_threads * ops_per_thread
def test_multiple_operations_per_thread(self) -> None:
metrics = OperationMetricsState()
def process_entities():
for entity_type in ["Database", "Schema", "Table"]:
for _ in range(10):
metrics.record_operation("entity_operations", "yield_entity", 1.0, entity_type)
metrics.merge_thread_metrics()
threads = [threading.Thread(target=process_entities) for _ in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
summary = metrics.get_summary()
for entity_type in ["Database", "Schema", "Table"]:
assert summary["entity_operations"]["yield_entity"][entity_type]["count"] == 50
class TestWorkflowTiming:
"""Tests for workflow-level source vs stage timing"""
@pytest.fixture(autouse=True)
def reset_singleton(self):
"""Reset singleton state before each test"""
Singleton.clear_all()
yield
Singleton.clear_all()
def test_get_workflow_timing_empty(self) -> None:
metrics = OperationMetricsState()
timing = metrics.get_workflow_timing()
assert timing["source"]["total_ms"] == 0.0
assert timing["source"]["call_count"] == 0
assert timing["stage"]["total_ms"] == 0.0
assert timing["stage"]["call_count"] == 0
def test_get_workflow_timing_source_only(self) -> None:
metrics = OperationMetricsState()
metrics.record_operation("source_fetch", "yield_table", 100.0, "Table")
metrics.record_operation("source_fetch", "yield_table", 150.0, "Table")
metrics.record_operation("source_fetch", "yield_database", 50.0, "Database")
timing = metrics.get_workflow_timing()
assert timing["source"]["total_ms"] == 300.0
assert timing["source"]["call_count"] == 3
assert timing["source"]["by_entity_type"]["Table"]["total_ms"] == 250.0
assert timing["source"]["by_entity_type"]["Table"]["call_count"] == 2
assert timing["source"]["by_entity_type"]["Database"]["total_ms"] == 50.0
assert timing["stage"]["total_ms"] == 0.0
def test_get_workflow_timing_stage_only(self) -> None:
metrics = OperationMetricsState()
metrics.record_operation("stage_process", "yield_table", 200.0, "Table")
metrics.record_operation("stage_process", "yield_schema", 100.0, "Schema")
timing = metrics.get_workflow_timing()
assert timing["source"]["total_ms"] == 0.0
assert timing["stage"]["total_ms"] == 300.0
assert timing["stage"]["call_count"] == 2
assert timing["stage"]["by_entity_type"]["Table"]["total_ms"] == 200.0
assert timing["stage"]["by_entity_type"]["Schema"]["total_ms"] == 100.0
def test_get_workflow_timing_both_source_and_stage(self) -> None:
metrics = OperationMetricsState()
# Simulate source fetch time
metrics.record_operation("source_fetch", "yield_database", 500.0, "Database")
metrics.record_operation("source_fetch", "yield_table", 1000.0, "Table")
# Simulate stage processing time
metrics.record_operation("stage_process", "yield_database", 100.0, "Database")
metrics.record_operation("stage_process", "yield_table", 200.0, "Table")
timing = metrics.get_workflow_timing()
# Verify source timing
assert timing["source"]["total_ms"] == 1500.0
assert timing["source"]["call_count"] == 2
# Verify stage timing
assert timing["stage"]["total_ms"] == 300.0
assert timing["stage"]["call_count"] == 2
# Verify source time > stage time (typical for API-based connectors)
assert timing["source"]["total_ms"] > timing["stage"]["total_ms"]
def test_get_workflow_timing_multithreaded(self) -> None:
metrics = OperationMetricsState()
def simulate_worker():
metrics.record_operation("source_fetch", "yield_table", 100.0, "Table")
metrics.record_operation("stage_process", "yield_table", 50.0, "Table")
metrics.merge_thread_metrics()
threads = [threading.Thread(target=simulate_worker) for _ in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
timing = metrics.get_workflow_timing()
assert timing["source"]["total_ms"] == 500.0
assert timing["source"]["call_count"] == 5
assert timing["stage"]["total_ms"] == 250.0
assert timing["stage"]["call_count"] == 5
def test_get_workflow_timing_ignores_other_categories(self) -> None:
metrics = OperationMetricsState()
metrics.record_operation("source_fetch", "yield_table", 100.0, "Table")
metrics.record_operation("stage_process", "yield_table", 50.0, "Table")
metrics.record_operation("db_queries", "SELECT", 1000.0, "Table")
metrics.record_operation("api_calls", "GET:/users", 500.0)
timing = metrics.get_workflow_timing()
# Only source_fetch and stage_process should be included in source/stage
assert timing["source"]["total_ms"] == 100.0
assert timing["stage"]["total_ms"] == 50.0
def test_get_workflow_timing_with_source_queries_and_api(self) -> None:
metrics = OperationMetricsState()
metrics.record_operation("source_fetch", "yield_table", 100.0, "Table")
metrics.record_operation("source_db_queries", "SELECT", 50.0, "Table")
metrics.record_operation("source_db_queries", "DESCRIBE", 30.0, "Table")
metrics.record_operation("source_api_calls", "GET:/dashboards", 80.0, "Dashboard")
timing = metrics.get_workflow_timing()
assert timing["source"]["total_ms"] == 100.0
assert timing["source_db_queries"]["total_ms"] == 80.0
assert timing["source_db_queries"]["call_count"] == 2
assert timing["source_db_queries"]["by_operation"]["SELECT"]["total_ms"] == 50.0
assert timing["source_api_calls"]["total_ms"] == 80.0
assert timing["source_api_calls"]["call_count"] == 1
assert timing["source_api_calls"]["by_operation"]["GET:/dashboards"]["total_ms"] == 80.0
class TestRunContext:
"""Tests for run context management"""
@pytest.fixture(autouse=True)
def reset_singleton(self):
"""Reset singleton state before each test"""
Singleton.clear_all()
yield
Singleton.clear_all()
def test_set_run_context(self) -> None:
metrics = OperationMetricsState()
metrics.set_run_context(run_id="test-run-123", pipeline_fqn="service.pipeline")
context = metrics.get_run_context()
assert context["run_id"] == "test-run-123"
assert context["pipeline_fqn"] == "service.pipeline"
def test_set_run_context_resets_metrics_on_new_run(self) -> None:
metrics = OperationMetricsState()
metrics.set_run_context(run_id="run-1")
metrics.record_operation("db_queries", "SELECT", 10.0)
# Setting a new run_id should clear old metrics
metrics.set_run_context(run_id="run-2")
summary = metrics.get_summary()
assert len(summary) == 0
def test_set_run_context_preserves_metrics_on_same_run(self) -> None:
metrics = OperationMetricsState()
metrics.set_run_context(run_id="run-1")
metrics.record_operation("db_queries", "SELECT", 10.0)
# Setting the same run_id should preserve metrics
metrics.set_run_context(run_id="run-1")
summary = metrics.get_summary()
assert "db_queries" in summary
assert summary["db_queries"]["SELECT"]["_default"]["count"] == 1
def test_reset_clears_run_context(self) -> None:
metrics = OperationMetricsState()
metrics.set_run_context(run_id="test-run", pipeline_fqn="service.pipeline")
metrics.reset()
context = metrics.get_run_context()
assert context["run_id"] is None
assert context["pipeline_fqn"] is None
def test_get_run_context_default_values(self) -> None:
metrics = OperationMetricsState()
context = metrics.get_run_context()
assert context["run_id"] is None
assert context["pipeline_fqn"] is None