Repository: Qiskit/qiskit-ibm-runtime
Pull request #736 (2023-03-03 01:30:32)

Use `ContextVar` for global variables to ensure thread safety
<!--
⚠️ The pull request title should be short detailed and understandable for all.
⚠️ Also, please add a release note file using reno if the change needs to be
  documented in the release notes.
⚠️ If your pull request fixes an open issue, please link to the issue.

✅ I have added the tests to cover my changes.
✅ I have updated the documentation accordingly.
✅ I have read the CONTRIBUTING document.
-->

### Summary

I am experimenting here with using [contextvars](https://docs.python.org/3/library/contextvars.html) rather than global variables for the session state, particularly when dealing with the session context manager.  With this change, the state of the session is stored in a thread-local variable rather than a global variable, which means that two threads can use the context manager without there being a race condition.

### Details and comments




coveralls: ## Pull Request Test Coverage Report for [Build 4319430546](https://coveralls.io/builds/57491805)

* **0** of **10**   **(100.0%)**  changed or added relevant lines in **1** file are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage increased (+**0.007%**) to **66.781%**

---



|  Totals | [![Coverage Status](https://coveralls.io/builds/57491805/badge)](https://coveralls.io/builds/57491805) |
| :-- | --: |
| Change from base [Build 4317042055](https://coveralls.io/builds/57482919): |  0.007% |
| Covered Lines: | 3299 |
| Relevant Lines: | 4940 |

---
##### 💛  - [Coveralls](https://coveralls.io)

coveralls: ## Pull Request Test Coverage Report for [Build 4319430546](https://coveralls.io/builds/57491805)

* **10** of **10**   **(100.0%)**  changed or added relevant lines in **1** file are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage increased (+**0.007%**) to **66.781%**

---



|  Totals | [![Coverage Status](https://coveralls.io/builds/57491805/badge)](https://coveralls.io/builds/57491805) |
| :-- | --: |
| Change from base [Build 4317042055](https://coveralls.io/builds/57482919): |  0.007% |
| Covered Lines: | 3299 |
| Relevant Lines: | 4940 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/qiskit_ibm_runtime/session.py b/qiskit_ibm_runtime/session.py
index 5530a427..a58a32bc 100644
--- a/qiskit_ibm_runtime/session.py
+++ b/qiskit_ibm_runtime/session.py
@@ -15,6 +15,7 @@
 from typing import Dict, Optional, Type, Union, Callable
 from types import TracebackType
 from functools import wraps
+from contextvars import ContextVar
 
 from qiskit.circuit import QuantumCircuit
 
@@ -236,16 +237,16 @@ class Session:
 
 
 # Default session
-_DEFAULT_SESSION: Optional[Session] = None
-_IN_SESSION_CM = False
+_DEFAULT_SESSION: ContextVar[Optional[Session]] = ContextVar(
+    "_DEFAULT_SESSION", default=None
+)
+_IN_SESSION_CM: ContextVar[bool] = ContextVar("_IN_SESSION_CM", default=False)
 
 
 def set_cm_session(session: Optional[Session]) -> None:
     """Set the context manager session."""
-    global _DEFAULT_SESSION  # pylint: disable=global-statement
-    global _IN_SESSION_CM  # pylint: disable=global-statement
-    _DEFAULT_SESSION = session
-    _IN_SESSION_CM = session is not None
+    _DEFAULT_SESSION.set(session)
+    _IN_SESSION_CM.set(session is not None)
 
 
 def get_default_session(
@@ -260,18 +261,17 @@ def get_default_session(
     """
     backend_name = backend.name if isinstance(backend, IBMBackend) else backend
 
-    global _DEFAULT_SESSION  # pylint: disable=global-statement
-    session = _DEFAULT_SESSION
+    session = _DEFAULT_SESSION.get()
     if (  # pylint: disable=too-many-boolean-expressions
-        _DEFAULT_SESSION is None
-        or not _DEFAULT_SESSION._active
-        or (backend_name is not None and _DEFAULT_SESSION._backend != backend_name)
-        or (service is not None and _DEFAULT_SESSION.service.channel != service.channel)
+        session is None
+        or not session._active
+        or (backend_name is not None and session._backend != backend_name)
+        or (service is not None and session.service.channel != service.channel)
     ):
         # Create a new session if one doesn't exist, or if the user wants to switch backend/channel.
         # Close the session only if all jobs are finished and you don't need to run more in the session.
-        if _DEFAULT_SESSION and not _IN_SESSION_CM and _DEFAULT_SESSION._active:
-            _DEFAULT_SESSION.close()
+        if session and not _IN_SESSION_CM.get() and session._active:
+            session.close()
         if service is None:
             service = (
                 backend.service
@@ -279,6 +279,6 @@ def get_default_session(
                 else QiskitRuntimeService()
             )
         session = Session(service=service, backend=backend)
-        if not _IN_SESSION_CM:
-            _DEFAULT_SESSION = session
+        if not _IN_SESSION_CM.get():
+            _DEFAULT_SESSION.set(session)
     return session


Test patch:
diff --git a/test/unit/test_ibm_primitives.py b/test/unit/test_ibm_primitives.py
index 2bc34586..e5d2d043 100644
--- a/test/unit/test_ibm_primitives.py
+++ b/test/unit/test_ibm_primitives.py
@@ -54,7 +54,7 @@ class TestPrimitives(IBMTestCase):
 
     def tearDown(self) -> None:
         super().tearDown()
-        session_pkg._DEFAULT_SESSION = None
+        session_pkg._DEFAULT_SESSION.set(None)
 
     def test_skip_transpilation(self):
         """Test skip_transpilation is hornored."""
@@ -198,7 +198,7 @@ class TestPrimitives(IBMTestCase):
             self.assertEqual(estimator.session, sampler.session)
         finally:
             # Ensure it's cleaned up or next test will fail.
-            session_pkg._DEFAULT_SESSION = None
+            session_pkg._DEFAULT_SESSION.set(None)
 
     def test_default_session_after_close(self):
         """Test a new default session is open after previous is closed."""
diff --git a/test/unit/test_session.py b/test/unit/test_session.py
index ba60fa8b..7c34ba79 100644
--- a/test/unit/test_session.py
+++ b/test/unit/test_session.py
@@ -26,7 +26,7 @@ class TestSession(IBMTestCase):
 
     def tearDown(self) -> None:
         super().tearDown()
-        session_pkg._DEFAULT_SESSION = None
+        session_pkg._DEFAULT_SESSION.set(None)
 
     @patch("qiskit_ibm_runtime.session.QiskitRuntimeService", autospec=True)
     def test_default_service(self, mock_service):