diff --git a/google/cloud/spanner_v1/batch.py b/google/cloud/spanner_v1/batch.py index e70d214783..869cd8458a 100644 --- a/google/cloud/spanner_v1/batch.py +++ b/google/cloud/spanner_v1/batch.py @@ -13,6 +13,7 @@ # limitations under the License. """Context manager for Cloud Spanner batched writes.""" + import functools from typing import List, Optional @@ -242,6 +243,8 @@ def commit( observability_options=getattr(database, "observability_options", None), metadata=metadata, ) as span, MetricsCapture(): + nth_request = getattr(database, "_next_nth_request", 0) + attempt = AtomicCounter(0) def wrapped_method(): commit_request = CommitRequest( @@ -256,8 +259,8 @@ def wrapped_method(): # should be increased. attempt can only be increased if # we encounter UNAVAILABLE or INTERNAL. call_metadata, error_augmenter = database.with_error_augmentation( - getattr(database, "_next_nth_request", 0), - 1, + nth_request, + attempt.increment(), metadata, span, ) diff --git a/google/cloud/spanner_v1/client.py b/google/cloud/spanner_v1/client.py index 5f72905616..34db9dbd83 100644 --- a/google/cloud/spanner_v1/client.py +++ b/google/cloud/spanner_v1/client.py @@ -23,10 +23,12 @@ * a :class:`~google.cloud.spanner_v1.instance.Instance` owns a :class:`~google.cloud.spanner_v1.database.Database` """ + import grpc import os import logging import warnings +import threading from google.api_core.gapic_v1 import client_info from google.auth.credentials import AnonymousCredentials @@ -99,11 +101,50 @@ def _get_spanner_optimizer_statistics_package(): log = logging.getLogger(__name__) +_metrics_monitor_initialized = False +_metrics_monitor_lock = threading.Lock() + def _get_spanner_enable_builtin_metrics_env(): return os.getenv(SPANNER_DISABLE_BUILTIN_METRICS_ENV_VAR) != "true" +def _initialize_metrics(project, credentials): + """ + Initializes the Spanner built-in metrics. + + This function sets up the OpenTelemetry MeterProvider and the SpannerMetricsTracerFactory. + It uses a lock to ensure that initialization happens only once. + """ + global _metrics_monitor_initialized + if not _metrics_monitor_initialized: + with _metrics_monitor_lock: + if not _metrics_monitor_initialized: + meter_provider = metrics.NoOpMeterProvider() + try: + if not _get_spanner_emulator_host(): + meter_provider = MeterProvider( + metric_readers=[ + PeriodicExportingMetricReader( + CloudMonitoringMetricsExporter( + project_id=project, + credentials=credentials, + ), + export_interval_millis=METRIC_EXPORT_INTERVAL_MS, + ), + ] + ) + metrics.set_meter_provider(meter_provider) + SpannerMetricsTracerFactory() + _metrics_monitor_initialized = True + except Exception as e: + # log is already defined at module level + log.warning( + "Failed to initialize Spanner built-in metrics. Error: %s", + e, + ) + + class Client(ClientWithProject): """Client for interacting with Cloud Spanner API. @@ -252,30 +293,13 @@ def __init__( ): warnings.warn(_EMULATOR_HOST_HTTP_SCHEME) # Check flag to enable Spanner builtin metrics + global _metrics_monitor_initialized if ( _get_spanner_enable_builtin_metrics_env() and not disable_builtin_metrics and HAS_GOOGLE_CLOUD_MONITORING_INSTALLED ): - meter_provider = metrics.NoOpMeterProvider() - try: - if not _get_spanner_emulator_host(): - meter_provider = MeterProvider( - metric_readers=[ - PeriodicExportingMetricReader( - CloudMonitoringMetricsExporter( - project_id=project, credentials=credentials - ), - export_interval_millis=METRIC_EXPORT_INTERVAL_MS, - ), - ] - ) - metrics.set_meter_provider(meter_provider) - SpannerMetricsTracerFactory() - except Exception as e: - log.warning( - "Failed to initialize Spanner built-in metrics. Error: %s", e - ) + _initialize_metrics(project, credentials) else: SpannerMetricsTracerFactory(enabled=False) diff --git a/google/cloud/spanner_v1/metrics/metrics_capture.py b/google/cloud/spanner_v1/metrics/metrics_capture.py index 6197ae5257..069f6a0a65 100644 --- a/google/cloud/spanner_v1/metrics/metrics_capture.py +++ b/google/cloud/spanner_v1/metrics/metrics_capture.py @@ -23,6 +23,9 @@ from .spanner_metrics_tracer_factory import SpannerMetricsTracerFactory +from contextvars import Token + + class MetricsCapture: """Context manager for capturing metrics in Cloud Spanner operations. @@ -30,6 +33,8 @@ class MetricsCapture: the start and completion of metrics tracing for a given operation. """ + _token: Token + def __enter__(self): """Enter the runtime context related to this object. @@ -45,11 +50,13 @@ def __enter__(self): return self # Define a new metrics tracer for the new operation - SpannerMetricsTracerFactory.current_metrics_tracer = ( - factory.create_metrics_tracer() + # Set the context var and keep the token for reset + tracer = factory.create_metrics_tracer() + self._token = SpannerMetricsTracerFactory._current_metrics_tracer_ctx.set( + tracer ) - if SpannerMetricsTracerFactory.current_metrics_tracer: - SpannerMetricsTracerFactory.current_metrics_tracer.record_operation_start() + if tracer: + tracer.record_operation_start() return self def __exit__(self, exc_type, exc_value, traceback): @@ -70,6 +77,11 @@ def __exit__(self, exc_type, exc_value, traceback): if not SpannerMetricsTracerFactory().enabled: return False - if SpannerMetricsTracerFactory.current_metrics_tracer: - SpannerMetricsTracerFactory.current_metrics_tracer.record_operation_completion() + tracer = SpannerMetricsTracerFactory._current_metrics_tracer_ctx.get() + if tracer: + tracer.record_operation_completion() + + # Reset the context var using the token + if getattr(self, "_token", None): + SpannerMetricsTracerFactory._current_metrics_tracer_ctx.reset(self._token) return False # Propagate the exception if any diff --git a/google/cloud/spanner_v1/metrics/metrics_interceptor.py b/google/cloud/spanner_v1/metrics/metrics_interceptor.py index 4b55056dab..c8ad5e0d9f 100644 --- a/google/cloud/spanner_v1/metrics/metrics_interceptor.py +++ b/google/cloud/spanner_v1/metrics/metrics_interceptor.py @@ -97,22 +97,17 @@ def _set_metrics_tracer_attributes(self, resources: Dict[str, str]) -> None: Args: resources (Dict[str, str]): A dictionary containing project, instance, and database information. """ - if SpannerMetricsTracerFactory.current_metrics_tracer is None: + tracer = SpannerMetricsTracerFactory.get_current_tracer() + if tracer is None: return if resources: if "project" in resources: - SpannerMetricsTracerFactory.current_metrics_tracer.set_project( - resources["project"] - ) + tracer.set_project(resources["project"]) if "instance" in resources: - SpannerMetricsTracerFactory.current_metrics_tracer.set_instance( - resources["instance"] - ) + tracer.set_instance(resources["instance"]) if "database" in resources: - SpannerMetricsTracerFactory.current_metrics_tracer.set_database( - resources["database"] - ) + tracer.set_database(resources["database"]) def intercept(self, invoked_method, request_or_iterator, call_details): """Intercept gRPC calls to collect metrics. @@ -126,10 +121,8 @@ def intercept(self, invoked_method, request_or_iterator, call_details): The RPC response """ factory = SpannerMetricsTracerFactory() - if ( - SpannerMetricsTracerFactory.current_metrics_tracer is None - or not factory.enabled - ): + tracer = SpannerMetricsTracerFactory.get_current_tracer() + if tracer is None or not factory.enabled: return invoked_method(request_or_iterator, call_details) # Setup Metric Tracer attributes from call details @@ -142,15 +135,13 @@ def intercept(self, invoked_method, request_or_iterator, call_details): call_details.method, SPANNER_METHOD_PREFIX ).replace("/", ".") - SpannerMetricsTracerFactory.current_metrics_tracer.set_method(method_name) - SpannerMetricsTracerFactory.current_metrics_tracer.record_attempt_start() + tracer.set_method(method_name) + tracer.record_attempt_start() response = invoked_method(request_or_iterator, call_details) - SpannerMetricsTracerFactory.current_metrics_tracer.record_attempt_completion() + tracer.record_attempt_completion() # Process and send GFE metrics if enabled - if SpannerMetricsTracerFactory.current_metrics_tracer.gfe_enabled: + if tracer.gfe_enabled: metadata = response.initial_metadata() - SpannerMetricsTracerFactory.current_metrics_trace.record_gfe_metrics( - metadata - ) + tracer.record_gfe_metrics(metadata) return response diff --git a/google/cloud/spanner_v1/metrics/spanner_metrics_tracer_factory.py b/google/cloud/spanner_v1/metrics/spanner_metrics_tracer_factory.py index 9566e61a28..aa364f087b 100644 --- a/google/cloud/spanner_v1/metrics/spanner_metrics_tracer_factory.py +++ b/google/cloud/spanner_v1/metrics/spanner_metrics_tracer_factory.py @@ -19,6 +19,7 @@ import os import logging from .constants import SPANNER_SERVICE_NAME +import contextvars try: import mmh3 @@ -43,7 +44,9 @@ class SpannerMetricsTracerFactory(MetricsTracerFactory): """A factory for creating SpannerMetricsTracer instances.""" _metrics_tracer_factory: "SpannerMetricsTracerFactory" = None - current_metrics_tracer: MetricsTracer = None + _current_metrics_tracer_ctx = contextvars.ContextVar( + "current_metrics_tracer", default=None + ) def __new__( cls, enabled: bool = True, gfe_enabled: bool = False @@ -80,10 +83,18 @@ def __new__( cls._metrics_tracer_factory.gfe_enabled = gfe_enabled if cls._metrics_tracer_factory.enabled != enabled: - cls._metrics_tracer_factory.enabeld = enabled + cls._metrics_tracer_factory.enabled = enabled return cls._metrics_tracer_factory + @staticmethod + def get_current_tracer() -> MetricsTracer: + return SpannerMetricsTracerFactory._current_metrics_tracer_ctx.get() + + @property + def current_metrics_tracer(self) -> MetricsTracer: + return SpannerMetricsTracerFactory._current_metrics_tracer_ctx.get() + @staticmethod def _generate_client_uid() -> str: """Generate a client UID in the form of uuidv4@pid@hostname. diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000000..ffd984426e --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,13 @@ +import pytest +from unittest.mock import patch + + +@pytest.fixture(autouse=True) +def mock_periodic_exporting_metric_reader(): + """Globally mock PeriodicExportingMetricReader to prevent real network calls.""" + with patch( + "google.cloud.spanner_v1.client.PeriodicExportingMetricReader" + ) as mock_client_reader, patch( + "opentelemetry.sdk.metrics.export.PeriodicExportingMetricReader" + ): + yield mock_client_reader diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index ab00d45268..e988ed582e 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -255,28 +255,44 @@ def test_constructor_w_directed_read_options(self): expected_scopes, creds, directed_read_options=self.DIRECTED_READ_OPTIONS ) + @mock.patch("google.cloud.spanner_v1.client.metrics") + @mock.patch("google.cloud.spanner_v1.client.CloudMonitoringMetricsExporter") + @mock.patch("google.cloud.spanner_v1.client.PeriodicExportingMetricReader") + @mock.patch("google.cloud.spanner_v1.client.MeterProvider") @mock.patch("google.cloud.spanner_v1.client.SpannerMetricsTracerFactory") @mock.patch.dict(os.environ, {"SPANNER_DISABLE_BUILTIN_METRICS": "false"}) def test_constructor_w_metrics_initialization_error( - self, mock_spanner_metrics_factory + self, + mock_spanner_metrics_factory, + mock_meter_provider, + mock_periodic_reader, + mock_exporter, + mock_metrics, ): """ Test that Client constructor handles exceptions during metrics initialization and logs a warning. """ from google.cloud.spanner_v1.client import Client + from google.cloud.spanner_v1 import client as MUT + MUT._metrics_monitor_initialized = False mock_spanner_metrics_factory.side_effect = Exception("Metrics init failed") creds = build_scoped_credentials() - - with self.assertLogs("google.cloud.spanner_v1.client", level="WARNING") as log: - client = Client(project=self.PROJECT, credentials=creds) - self.assertIsNotNone(client) - self.assertIn( - "Failed to initialize Spanner built-in metrics. Error: Metrics init failed", - log.output[0], - ) - mock_spanner_metrics_factory.assert_called_once() + try: + with self.assertLogs( + "google.cloud.spanner_v1.client", level="WARNING" + ) as log: + client = Client(project=self.PROJECT, credentials=creds) + self.assertIsNotNone(client) + self.assertIn( + "Failed to initialize Spanner built-in metrics. Error: Metrics init failed", + log.output[0], + ) + mock_spanner_metrics_factory.assert_called_once() + mock_metrics.set_meter_provider.assert_called_once() + finally: + MUT._metrics_monitor_initialized = False @mock.patch("google.cloud.spanner_v1.client.SpannerMetricsTracerFactory") @mock.patch.dict(os.environ, {"SPANNER_DISABLE_BUILTIN_METRICS": "true"}) @@ -293,6 +309,58 @@ def test_constructor_w_disable_builtin_metrics_using_env( self.assertIsNotNone(client) mock_spanner_metrics_factory.assert_called_once_with(enabled=False) + @mock.patch("google.cloud.spanner_v1.client.metrics") + @mock.patch("google.cloud.spanner_v1.client.CloudMonitoringMetricsExporter") + @mock.patch("google.cloud.spanner_v1.client.PeriodicExportingMetricReader") + @mock.patch("google.cloud.spanner_v1.client.MeterProvider") + @mock.patch("google.cloud.spanner_v1.client.SpannerMetricsTracerFactory") + @mock.patch.dict(os.environ, {"SPANNER_DISABLE_BUILTIN_METRICS": "false"}) + def test_constructor_metrics_singleton_behavior( + self, + mock_spanner_metrics_factory, + mock_meter_provider, + mock_periodic_reader, + mock_exporter, + mock_metrics, + ): + """ + Test that metrics are only initialized once. + """ + from google.cloud.spanner_v1 import client as MUT + + # Reset global state for this test + MUT._metrics_monitor_initialized = False + try: + creds = build_scoped_credentials() + + # First client initialization + client1 = MUT.Client(project=self.PROJECT, credentials=creds) + self.assertIsNotNone(client1) + mock_metrics.set_meter_provider.assert_called_once() + mock_spanner_metrics_factory.assert_called_once() + + # Verify MeterProvider chain was created + mock_meter_provider.assert_called_once() + mock_periodic_reader.assert_called_once() + mock_exporter.assert_called_once() + + self.assertTrue(MUT._metrics_monitor_initialized) + + # Reset mocks to verify they are NOT called again + mock_metrics.set_meter_provider.reset_mock() + mock_spanner_metrics_factory.reset_mock() + mock_meter_provider.reset_mock() + + # Second client initialization + client2 = MUT.Client(project=self.PROJECT, credentials=creds) + self.assertIsNotNone(client2) + mock_metrics.set_meter_provider.assert_not_called() + mock_spanner_metrics_factory.assert_not_called() + mock_meter_provider.assert_not_called() + self.assertTrue(MUT._metrics_monitor_initialized) + finally: + MUT._metrics_monitor_initialized = False + @mock.patch("google.cloud.spanner_v1.client.SpannerMetricsTracerFactory") def test_constructor_w_disable_builtin_metrics_using_option( self, mock_spanner_metrics_factory diff --git a/tests/unit/test_metrics.py b/tests/unit/test_metrics.py index 5e37e7cfe2..ec0750e083 100644 --- a/tests/unit/test_metrics.py +++ b/tests/unit/test_metrics.py @@ -60,17 +60,30 @@ def patched_client(monkeypatch): if SpannerMetricsTracerFactory._metrics_tracer_factory is not None: SpannerMetricsTracerFactory._metrics_tracer_factory = None - client = Client( - project="test", - credentials=TestCredentials(), - # client_options={"api_endpoint": "none"} - ) - yield client + # Reset the global flag to ensure metrics initialization runs + from google.cloud.spanner_v1 import client as client_module + + client_module._metrics_monitor_initialized = False + + with patch( + "google.cloud.spanner_v1.metrics.metrics_exporter.MetricServiceClient" + ), patch( + "google.cloud.spanner_v1.metrics.metrics_exporter.CloudMonitoringMetricsExporter" + ), patch( + "opentelemetry.sdk.metrics.export.PeriodicExportingMetricReader" + ): + client = Client( + project="test", + credentials=TestCredentials(), + ) + yield client # Resetting metrics.set_meter_provider(metrics.NoOpMeterProvider()) SpannerMetricsTracerFactory._metrics_tracer_factory = None - SpannerMetricsTracerFactory.current_metrics_tracer = None + # Reset context var + ctx = SpannerMetricsTracerFactory._current_metrics_tracer_ctx + ctx.set(None) def test_metrics_emission_with_failure_attempt(patched_client): @@ -85,10 +98,14 @@ def test_metrics_emission_with_failure_attempt(patched_client): original_intercept = metrics_interceptor.intercept first_attempt = True + captured_tracer_list = [] + def mocked_raise(*args, **kwargs): raise ServiceUnavailable("Service Unavailable") def mocked_call(*args, **kwargs): + # Capture the tracer while it is active + captured_tracer_list.append(SpannerMetricsTracerFactory.get_current_tracer()) return _UnaryOutcome(MagicMock(), MagicMock()) def intercept_wrapper(invoked_method, request_or_iterator, call_details): @@ -106,11 +123,14 @@ def intercept_wrapper(invoked_method, request_or_iterator, call_details): metrics_interceptor.intercept = intercept_wrapper patch_path = "google.cloud.spanner_v1.metrics.metrics_exporter.CloudMonitoringMetricsExporter.export" + with patch(patch_path): with database.snapshot(): pass # Verify that the attempt count increased from the failed initial attempt - assert ( - SpannerMetricsTracerFactory.current_metrics_tracer.current_op.attempt_count - ) == 2 + # We use the captured tracer from the SUCCESSFUL attempt (the second one) + assert len(captured_tracer_list) > 0 + tracer = captured_tracer_list[0] + assert tracer is not None + assert tracer.current_op.attempt_count == 2 diff --git a/tests/unit/test_metrics_concurrency.py b/tests/unit/test_metrics_concurrency.py new file mode 100644 index 0000000000..ee9235068b --- /dev/null +++ b/tests/unit/test_metrics_concurrency.py @@ -0,0 +1,80 @@ +import threading +import time +import unittest +from google.cloud.spanner_v1.metrics.spanner_metrics_tracer_factory import ( + SpannerMetricsTracerFactory, +) +from google.cloud.spanner_v1.metrics.metrics_capture import MetricsCapture + + +class TestMetricsConcurrency(unittest.TestCase): + def setUp(self): + # Reset factory singleton + SpannerMetricsTracerFactory._metrics_tracer_factory = None + + def test_concurrent_tracers(self): + """Verify that concurrent threads have isolated tracers.""" + factory = SpannerMetricsTracerFactory(enabled=True) + # Ensure enabled + factory.enabled = True + + errors = [] + + def worker(idx): + try: + # Simulate a request workflow + with MetricsCapture(): + # Capture should have set a tracer + tracer = SpannerMetricsTracerFactory.get_current_tracer() + if tracer is None: + errors.append(f"Thread {idx}: Tracer is None inside Capture") + return + + # Set a unique attribute for this thread + project_name = f"project-{idx}" + tracer.set_project(project_name) + + # Simulate some work + time.sleep(0.01) + + # Verify verify we still have OUR tracer + current_tracer = SpannerMetricsTracerFactory.get_current_tracer() + if current_tracer.client_attributes["project_id"] != project_name: + errors.append( + f"Thread {idx}: Tracer project mismatch. Expected {project_name}, got {current_tracer.client_attributes.get('project_id')}" + ) + + # Check interceptor logic (simulated) + # Interceptor reads from factory.current_metrics_tracer + interceptor_tracer = ( + SpannerMetricsTracerFactory.get_current_tracer() + ) + if interceptor_tracer is not tracer: + errors.append(f"Thread {idx}: Interceptor tracer mismatch") + + except Exception as e: + errors.append(f"Thread {idx}: Exception {e}") + + threads = [] + for i in range(10): + t = threading.Thread(target=worker, args=(i,)) + threads.append(t) + t.start() + + for t in threads: + t.join() + + self.assertEqual(errors, [], f"Concurrency errors found: {errors}") + + def test_context_var_cleanup(self): + """Verify tracer is cleaned up after ContextVar reset.""" + SpannerMetricsTracerFactory(enabled=True) + + with MetricsCapture(): + self.assertIsNotNone(SpannerMetricsTracerFactory.get_current_tracer()) + + self.assertIsNone(SpannerMetricsTracerFactory.get_current_tracer()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_metrics_interceptor.py b/tests/unit/test_metrics_interceptor.py index e32003537f..e7beb5bdb5 100644 --- a/tests/unit/test_metrics_interceptor.py +++ b/tests/unit/test_metrics_interceptor.py @@ -26,6 +26,29 @@ def interceptor(): return MetricsInterceptor() +@pytest.fixture +def mock_tracer_ctx(): + tracer = MockMetricTracer() + token = SpannerMetricsTracerFactory._current_metrics_tracer_ctx.set(tracer) + yield tracer + SpannerMetricsTracerFactory._current_metrics_tracer_ctx.reset(token) + + +class MockMetricTracer: + def __init__(self): + self.project = None + self.instance = None + self.database = None + self.gfe_enabled = False + self.record_attempt_start = MagicMock() + self.record_attempt_completion = MagicMock() + self.set_method = MagicMock() + self.record_gfe_metrics = MagicMock() + self.set_project = MagicMock() + self.set_instance = MagicMock() + self.set_database = MagicMock() + + def test_parse_resource_path_valid(interceptor): path = "projects/my_project/instances/my_instance/databases/my_database" expected = { @@ -57,8 +80,8 @@ def test_extract_resource_from_path(interceptor): assert interceptor._extract_resource_from_path(metadata) == expected -def test_set_metrics_tracer_attributes(interceptor): - SpannerMetricsTracerFactory.current_metrics_tracer = MockMetricTracer() +def test_set_metrics_tracer_attributes(interceptor, mock_tracer_ctx): + # mock_tracer_ctx fixture sets the ContextVar resources = { "project": "my_project", "instance": "my_instance", @@ -66,20 +89,14 @@ def test_set_metrics_tracer_attributes(interceptor): } interceptor._set_metrics_tracer_attributes(resources) - assert SpannerMetricsTracerFactory.current_metrics_tracer.project == "my_project" - assert SpannerMetricsTracerFactory.current_metrics_tracer.instance == "my_instance" - assert SpannerMetricsTracerFactory.current_metrics_tracer.database == "my_database" + mock_tracer_ctx.set_project.assert_called_with("my_project") + mock_tracer_ctx.set_instance.assert_called_with("my_instance") + mock_tracer_ctx.set_database.assert_called_with("my_database") -def test_intercept_with_tracer(interceptor): - SpannerMetricsTracerFactory.current_metrics_tracer = MockMetricTracer() - SpannerMetricsTracerFactory.current_metrics_tracer.record_attempt_start = ( - MagicMock() - ) - SpannerMetricsTracerFactory.current_metrics_tracer.record_attempt_completion = ( - MagicMock() - ) - SpannerMetricsTracerFactory.current_metrics_tracer.gfe_enabled = False +def test_intercept_with_tracer(interceptor, mock_tracer_ctx): + # mock_tracer_ctx fixture sets the ContextVar + mock_tracer_ctx.gfe_enabled = False invoked_response = MagicMock() invoked_response.initial_metadata.return_value = {} @@ -97,32 +114,6 @@ def test_intercept_with_tracer(interceptor): response = interceptor.intercept(mock_invoked_method, "request", call_details) assert response == invoked_response - SpannerMetricsTracerFactory.current_metrics_tracer.record_attempt_start.assert_called_once() - SpannerMetricsTracerFactory.current_metrics_tracer.record_attempt_completion.assert_called_once() + mock_tracer_ctx.record_attempt_start.assert_called() + mock_tracer_ctx.record_attempt_completion.assert_called_once() mock_invoked_method.assert_called_once_with("request", call_details) - - -class MockMetricTracer: - def __init__(self): - self.project = None - self.instance = None - self.database = None - self.method = None - - def set_project(self, project): - self.project = project - - def set_instance(self, instance): - self.instance = instance - - def set_database(self, database): - self.database = database - - def set_method(self, method): - self.method = method - - def record_attempt_start(self): - pass - - def record_attempt_completion(self): - pass