Repository: Qiskit/qiskit-ibm-runtime (python)

Issue:
Session Budgeting: add a way for users to specify max_time a user wants to run a session
**What is the expected feature or enhancement?**
Add a way for users to specify `max_time` in seconds a user wants to run a session at the primitive level.

Sampler:
```
with Sampler(...., max_time=1800) as sampler:
    ...
```

Estimator:
```
with Estimator(...., max_time=1800) as estimator:
    ...
```

Take this `max_time` value and set it as the `max_execution_time` for the first job in the session when calling `service.run`. The session will automatically close when this time limit exceeds. This time will be shared by all jobs in the session. For example, out of 1800 secs, job A can use up 700 secs, job B can use up 1000 secs and job C has to complete in 100 secs, otherwise session will close.

cc: @tmittal947 @renier 

Pull request description:
Add support to specify max_time for runtime session
<!--
⚠️ 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
Users should be able to specify maximum amount of time for a runtime session.

max_time can be in seconds (int) or "2h 20m 30s" (string).

Estimator
```
with Estimator(circuits, observables, service, options, max_time=200800) as estimator:
    result = estimator(...)
```

Sampler
```
with Sampler(circuits, service, options, max_time="2h 20m 30s") as sampler:
    result = sampler(...)
```

### Details and comments
Fixes #373 

On hold since we have to finalize the position of the max_time parameter in lieu of other major breaking changes coming up.

Patch:
diff --git a/qiskit_ibm_runtime/estimator.py b/qiskit_ibm_runtime/estimator.py
index 725ee320..1cc96e49 100644
--- a/qiskit_ibm_runtime/estimator.py
+++ b/qiskit_ibm_runtime/estimator.py
@@ -23,6 +23,7 @@ from .exceptions import IBMInputValueError
 from .ibm_backend import IBMBackend
 from .qiskit_runtime_service import QiskitRuntimeService
 from .runtime_session import RuntimeSession
+from .utils.converters import hms_to_seconds
 
 
 class Estimator(BaseEstimator):
@@ -141,6 +142,7 @@ class Estimator(BaseEstimator):
         skip_transpilation: Optional[bool] = False,
         transpilation_settings: Optional[Dict] = None,
         resilience_settings: Optional[Dict] = None,
+        max_time: Optional[Union[int, str]] = None,
     ):
         """Initializes the Estimator primitive.
 
@@ -206,6 +208,10 @@ class Estimator(BaseEstimator):
                     * 3: even heavier resilience
                     If ``None``, level 0 will be chosen as default.
 
+            max_time: (EXPERIMENTAL setting, can break between releases without warning)
+                Maximum amount of time, a runtime session can be open before being
+                forcibly closed. Can be specified as seconds (int) or a string like "2h 30m 40s".
+
         Raises:
             IBMInputValueError: If an input value is invalid.
         """
@@ -247,8 +253,18 @@ class Estimator(BaseEstimator):
             program_id="estimator",
             inputs=inputs,
             options=options,
+            max_time=self.calculate_max_time(max_time=max_time),
         )
 
+    def calculate_max_time(self, max_time: Optional[Union[int, str]] = None) -> int:
+        """Calculate max_time in seconds from hour minute seconds string. Ex: 2h 30m 40s"""
+        try:
+            return hms_to_seconds(max_time) if isinstance(max_time, str) else max_time
+        except IBMInputValueError as input_value_error:
+            raise IBMInputValueError(
+                "Invalid value given for max_time.", input_value_error.message
+            )
+
     def _call(
         self,
         circuits: Sequence[int],
diff --git a/qiskit_ibm_runtime/runtime_session.py b/qiskit_ibm_runtime/runtime_session.py
index 254334da..b85ef31f 100644
--- a/qiskit_ibm_runtime/runtime_session.py
+++ b/qiskit_ibm_runtime/runtime_session.py
@@ -45,6 +45,7 @@ class RuntimeSession:
         program_id: str,
         inputs: Union[Dict, ParameterNamespace],
         options: Optional[Union[RuntimeOptions, Dict]] = None,
+        max_time: Optional[int] = None,
     ):
         """RuntimeSession constructor.
         Args:
@@ -62,6 +63,7 @@ class RuntimeSession:
         self._session_id: Optional[str] = None
         self._active = True
         self._start_session = True
+        self._max_time = max_time
 
     @_active_session
     def write(self, **kwargs: Dict) -> None:
@@ -78,6 +80,7 @@ class RuntimeSession:
             self._session_id = self._job.job_id
         else:
             self._start_session = False
+            self._max_time = None
             self._job = self._run(inputs=inputs)
 
     def _run(self, inputs: Union[Dict, ParameterNamespace]) -> RuntimeJob:
@@ -88,6 +91,7 @@ class RuntimeSession:
             inputs=inputs,
             session_id=self._session_id,
             start_session=self._start_session,
+            max_execution_time=self._max_time,
         )
 
     @_active_session
diff --git a/qiskit_ibm_runtime/sampler.py b/qiskit_ibm_runtime/sampler.py
index 8295edc6..a9075a4c 100644
--- a/qiskit_ibm_runtime/sampler.py
+++ b/qiskit_ibm_runtime/sampler.py
@@ -22,6 +22,7 @@ from .exceptions import IBMInputValueError
 from .ibm_backend import IBMBackend
 from .qiskit_runtime_service import QiskitRuntimeService
 from .runtime_session import RuntimeSession
+from .utils.converters import hms_to_seconds
 
 
 class Sampler(BaseSampler):
@@ -122,6 +123,7 @@ class Sampler(BaseSampler):
         skip_transpilation: Optional[bool] = False,
         transpilation_settings: Optional[Dict] = None,
         resilience_settings: Optional[Dict] = None,
+        max_time: Optional[Union[int, str]] = None,
     ):
         """Initializes the Sampler primitive.
 
@@ -184,6 +186,10 @@ class Sampler(BaseSampler):
                     * 3: even heavier resilience
                     If ``None``, level 0 will be chosen as default.
 
+            max_time: (EXPERIMENTAL setting, can break between releases without warning)
+                Maximum amount of time, a runtime session can be open before being
+                forcibly closed. Can be specified as seconds (int) or a string like "2h 30m 40s".
+
         Raises:
             IBMInputValueError: If an input value is invalid.
         """
@@ -223,8 +229,18 @@ class Sampler(BaseSampler):
             program_id="sampler",
             inputs=inputs,
             options=options,
+            max_time=self.calculate_max_time(max_time=max_time),
         )
 
+    def calculate_max_time(self, max_time: Optional[Union[int, str]] = None) -> int:
+        """Calculate max_time in seconds from hour minute seconds string. Ex: 2h 30m 40s"""
+        try:
+            return hms_to_seconds(max_time) if isinstance(max_time, str) else max_time
+        except IBMInputValueError as input_value_error:
+            raise IBMInputValueError(
+                "Invalid value given for max_time.", input_value_error.message
+            )
+
     def _call(
         self,
         circuits: Sequence[int],
diff --git a/qiskit_ibm_runtime/utils/converters.py b/qiskit_ibm_runtime/utils/converters.py
index 117e8ec0..5caad839 100644
--- a/qiskit_ibm_runtime/utils/converters.py
+++ b/qiskit_ibm_runtime/utils/converters.py
@@ -12,12 +12,12 @@
 
 """Utilities related to conversion."""
 
-from typing import Union, Tuple, Any, Optional
+from typing import Union, Tuple, Any
 from datetime import datetime, timedelta, timezone
 from math import ceil
+from dateutil import tz, parser
 
-import dateutil.parser
-from dateutil import tz
+from ..exceptions import IBMInputValueError
 
 
 def utc_to_local(utc_dt: Union[datetime, str]) -> datetime:
@@ -33,7 +33,7 @@ def utc_to_local(utc_dt: Union[datetime, str]) -> datetime:
         TypeError: If the input parameter value is not valid.
     """
     if isinstance(utc_dt, str):
-        utc_dt = dateutil.parser.parse(utc_dt)
+        utc_dt = parser.parse(utc_dt)
     if not isinstance(utc_dt, datetime):
         raise TypeError("Input `utc_dt` is not string or datetime.")
     utc_dt = utc_dt.replace(tzinfo=timezone.utc)  # type: ignore[arg-type]
@@ -54,7 +54,7 @@ def local_to_utc(local_dt: Union[datetime, str]) -> datetime:
         TypeError: If the input parameter value is not valid.
     """
     if isinstance(local_dt, str):
-        local_dt = dateutil.parser.parse(local_dt)
+        local_dt = parser.parse(local_dt)
     if not isinstance(local_dt, datetime):
         raise TypeError("Input `local_dt` is not string or datetime.")
 
@@ -135,3 +135,27 @@ def duration_difference(date_time: datetime) -> str:
     elif time_tuple[3]:
         time_str += "{} sec".format(time_tuple[3])
     return time_str
+
+
+def hms_to_seconds(hms: str) -> int:
+    """Convert duration specified as hours minutes seconds to seconds.
+
+    Args:
+        hms: The string input duration (in hours minutes seconds). Ex: 2h 10m 20s
+
+    Returns:
+        Total seconds (int) in the duration.
+
+    Raises:
+        IBMInputValueError: when the given hms string is in an invalid format
+    """
+    try:
+        date_time = parser.parse(hms)
+        hours = date_time.hour
+        minutes = date_time.minute
+        seconds = date_time.second
+        return int(
+            timedelta(hours=hours, minutes=minutes, seconds=seconds).total_seconds()
+        )
+    except parser.ParserError as parser_error:
+        raise IBMInputValueError(str(parser_error))


Test patch:
diff --git a/test/unit/test_utils_converters.py b/test/unit/test_utils_converters.py
new file mode 100644
index 00000000..e9948b93
--- /dev/null
+++ b/test/unit/test_utils_converters.py
@@ -0,0 +1,47 @@
+# This code is part of Qiskit.
+#
+# (C) Copyright IBM 2022.
+#
+# This code is licensed under the Apache License, Version 2.0. You may
+# obtain a copy of this license in the LICENSE.txt file in the root directory
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Tests for the methods utils.converters file."""
+
+from qiskit_ibm_runtime.exceptions import IBMInputValueError
+from qiskit_ibm_runtime.utils.converters import hms_to_seconds
+from ..ibm_test_case import IBMTestCase
+
+
+class TestUtilsConverters(IBMTestCase):
+    """Tests for the methods utils.converters file."""
+
+    def test_hms_to_seconds(self):
+        """Test converting hours minutes seconds (string) to seconds (int)."""
+        valid_strings = [
+            ("2h", 7200),
+            ("50m", 3000),
+            ("20s", 20),
+            ("1h 30m", 5400),
+            ("3h 30m 30s", 12630),
+            ("3h 30s", 10830),
+            ("3h30m30s", 12630),
+        ]
+        invalid_strings = [
+            "2d",
+            "24h",
+            "60m",
+            "60s",
+            "2w",
+        ]
+        for valid_string in valid_strings:
+            with self.subTest(valid_string=valid_string):
+                self.assertEqual(hms_to_seconds(valid_string[0]), valid_string[1])
+        for invalid_string in invalid_strings:
+            with self.subTest(invalid_string=invalid_string):
+                with self.assertRaises(IBMInputValueError):
+                    hms_to_seconds(invalid_string)