Repository: dwavesystems/dwave-cloud-client
Pull request #662 (2024-08-16 17:20:42)

Enable long polling strategy by default


Patch:
diff --git a/dwave/cloud/client/base.py b/dwave/cloud/client/base.py
index 4b30ee8..5c5f494 100644
--- a/dwave/cloud/client/base.py
+++ b/dwave/cloud/client/base.py
@@ -173,24 +173,49 @@ class Client(object):
         client_cert_key (str, optional):
             Path to client side certificate key file.
 
+        poll_strategy (str, 'backoff' | 'long-polling', default='long-polling'):
+            Polling strategy for problem status. Supported options are short polling 
+            with exponential back-off, configured with ``poll_backoff_*`` parameters,
+            and long polling, configured with ``poll_wait_time`` and ``poll_pause``
+            parameters.
+
+            .. versionadded:: 0.13.0
+                Added support for long polling strategy. Long polling is the new
+                default, but the ``"backoff"`` strategy can still be used if
+                desired.
+
         poll_backoff_min (float, default=0.05):
-            Problem status is polled with exponential back-off schedule.
-            Duration of the first interval (between first and second poll) is
+            When problem status is polled with exponential back-off schedule, the
+            duration of the first interval (between first and second poll) is
             set to ``poll_backoff_min`` seconds.
 
         poll_backoff_max (float, default=60):
-            Problem status is polled with exponential back-off schedule.
-            Maximum back-off period is limited to ``poll_backoff_max`` seconds.
+            When problem status is polled with exponential back-off schedule, the
+            maximum back-off period is limited to ``poll_backoff_max`` seconds.
 
         poll_backoff_base (float, default=1.3):
-            Problem status is polled with exponential back-off schedule.
-            The exponential function base is defined with ``poll_backoff_base``.
+            When problem status is polled with exponential back-off schedule,
+            the exponential function base is defined with ``poll_backoff_base``.
             Interval between ``poll_idx`` and ``poll_idx + 1`` is given with::
 
                 poll_backoff_min * poll_backoff_base ** poll_idx
 
             with upper bound set to ``poll_backoff_max``.
 
+        poll_wait_time (int, in range [0, 30], default=30):
+            When problem status is polled using long polling, this value sets the 
+            maximum time, in seconds, a long polling connection waits for the API 
+            response.
+
+            .. versionadded:: 0.13.0
+
+        poll_pause (float, default=0.0):
+            When problem status is polled using long polling, this value limits the 
+            delay, in seconds, between two successive long polling connections.
+            It's preferred to keep this value at zero.
+
+            .. versionadded:: 0.13.0
+
         http_retry_total (int, default=10):
             Total number of retries of failing idempotent HTTP requests to
             allow. Takes precedence over other counts.
@@ -314,7 +339,7 @@ class Client(object):
         'headers': None,
         'client_cert': None,
         'client_cert_key': None,
-        'poll_strategy': 'backoff',
+        'poll_strategy': 'long-polling',
         # poll back-off schedule defaults [sec]
         'poll_backoff_min': 0.05,
         'poll_backoff_max': 60,
@@ -1515,10 +1540,17 @@ class Client(object):
 
     def _poll_using_long_polling(self, future: Future) -> None:
         # we use problem submit time to prioritize polling of jobs submitted earlier
-        at = datetime_to_timestamp(future.time_created)
+        created_at = datetime_to_timestamp(future.time_created)
+
+        # don't enqueue for next poll if polling_timeout is exceeded by then
+        future_age = time.time() - created_at
+        if self.config.polling_timeout is not None and future_age > self.config.polling_timeout:
+            logger.debug("Polling timeout exceeded before next poll: %.2f sec > %.2f sec, aborting polling!",
+                         future_age, self.config.polling_timeout)
+            raise PollingTimeout
 
         # use the same priority queue as for backoff polling
-        self._poll_queue.put((at, future))
+        self._poll_queue.put((created_at, future))
 
     def _do_poll_problems(self):
         """Poll the server for the status of a set of problems.
diff --git a/dwave/cloud/config/models.py b/dwave/cloud/config/models.py
index cb1c3fb..21bedb5 100644
--- a/dwave/cloud/config/models.py
+++ b/dwave/cloud/config/models.py
@@ -152,7 +152,7 @@ class ClientConfig(BaseModel, GetterMixin):
     # note: discriminated unions are faster than unions, but we want to be able
     # to set a defautl value when `strategy` is not specified
     polling_schedule: Optional[Union[BackoffPollingSchedule,
-                                     LongPollingSchedule]] = BackoffPollingSchedule()
+                                     LongPollingSchedule]] = LongPollingSchedule()
     polling_timeout: Optional[float] = None
 
     # general http(s) connection params
@@ -317,7 +317,7 @@ _V1_CONFIG_DEFAULTS = {
     'headers': None,
     'client_cert': None,
     'client_cert_key': None,
-    'poll_strategy': 'backoff',
+    'poll_strategy': 'long-polling',
     # poll back-off schedule defaults
     'poll_backoff_min': 0.05,
     'poll_backoff_max': 60,
diff --git a/releasenotes/notes/default-to-long-polling-a3bb3254a42c794b.yaml b/releasenotes/notes/default-to-long-polling-a3bb3254a42c794b.yaml
new file mode 100644
index 0000000..f509bb4
--- /dev/null
+++ b/releasenotes/notes/default-to-long-polling-a3bb3254a42c794b.yaml
@@ -0,0 +1,17 @@
+---
+features:
+  - |
+    Poll for problem-status update using long-polling strategy by default.
+
+    Long-polling strategy enables faster status-change propagation to the client,
+    especially for longer jobs, or during heavy QPU usage.
+
+upgrade:
+  - |
+    Short polling with exponential backoff is not used by default anymore, but
+    it is still available. If for some reason you want to use it, set::
+
+      poll_strategy = backoff
+
+    configuration parameter in your config file, or the keyword argument in calls
+    to ``Client()`` or ``Client.from_config()``.


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 8e7af3a..fdff291 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -89,13 +89,14 @@ class TestConfigCreate(unittest.TestCase):
     def test_auto_create(self, mock_fetch_sapi_token):
         runner = CliRunner(mix_stderr=False)
         with runner.isolated_filesystem():
+            local_config_file = './dwave.conf'
             result = runner.invoke(cli, [
-                'config', 'create', '--config-file', 'dwave.conf', '--auto'
+                'config', 'create', '--config-file', local_config_file, '--auto'
             ])
             self.assertEqual(result.exit_code, 0)
 
             # load and verify config
-            config = load_config()
+            config = load_config(config_file=local_config_file)
             self.assertEqual(config.get('token'), 'auto-token')
 
     @parameterized.expand([
diff --git a/tests/test_client.py b/tests/test_client.py
index d3ad830..e30a83d 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -415,7 +415,7 @@ class ClientConstruction(unittest.TestCase):
                     self.assertEqual(client.config.solver, {})
 
                     self.assertEqual(client.config.connection_close, DEFAULTS['connection_close'])
-                    self.assertEqual(client.config.polling_schedule.backoff_min, DEFAULTS['poll_backoff_min'])
+                    self.assertEqual(client.config.polling_schedule.wait_time, DEFAULTS['poll_wait_time'])
 
     def test_defaults_as_kwarg(self):
         token = 'value'
@@ -549,8 +549,8 @@ class ClientConstruction(unittest.TestCase):
                 with dwave.cloud.Client.from_config() as client:
                     ps = client.config.polling_schedule
                     self.assertEqual(ps.strategy, Client.DEFAULTS['poll_strategy'])
-                    self.assertEqual(ps.backoff_min, Client.DEFAULTS['poll_backoff_min'])
-                    self.assertEqual(ps.backoff_max, Client.DEFAULTS['poll_backoff_max'])
+                    self.assertEqual(ps.wait_time, Client.DEFAULTS['poll_wait_time'])
+                    self.assertEqual(ps.pause, Client.DEFAULTS['poll_pause'])
 
         with self.subTest("backoff"):
             conf = {"poll_strategy": "backoff",
diff --git a/tests/test_mock_submission.py b/tests/test_mock_submission.py
index b317296..9800155 100644
--- a/tests/test_mock_submission.py
+++ b/tests/test_mock_submission.py
@@ -704,12 +704,11 @@ class DeleteEvent(Exception):
 
 
 @mock.patch('time.sleep', lambda *x: None)
-class MockCancel(unittest.TestCase):
+class MockCancel(MockSubmissionBase, unittest.TestCase):
     """Make sure cancel works at the two points in the process where it should."""
 
-    @classmethod
-    def setUpClass(cls):
-        cls.sapi = StructuredSapiMockResponses()
+    poll_strategy = "long-polling"
+    poll_params = dict(timeout=Client.DEFAULTS['poll_wait_time'])
 
     def test_cancel_with_id(self):
         """Make sure the cancel method submits to the right endpoint.
@@ -723,13 +722,14 @@ class MockCancel(unittest.TestCase):
         def create_mock_session(client):
             session = mock.Mock()
             reply_body = [self.sapi.continue_reply(id=submission_id, solver='solver')]
-            session.get = lambda path, **kwargs: choose_reply(path, {
-                'problems/?id={}'.format(submission_id): reply_body})
+            session.get = lambda path, **kwargs: choose_reply(path, fix_status_paths({
+                f'problems/?id={submission_id}': reply_body
+            }, **self.poll_params))
             session.delete = DeleteEvent.handle
             return session
 
         with mock.patch.object(Client, 'create_session', create_mock_session):
-            with Client(endpoint='endpoint', token='token') as client:
+            with Client(**self.config) as client:
                 solver = Solver(client, self.sapi.solver.data)
                 future = solver._retrieve_problem(submission_id)
                 future.cancel()
@@ -758,8 +758,9 @@ class MockCancel(unittest.TestCase):
             reply_body = [self.sapi.continue_reply(id=submission_id)]
 
             session = mock.Mock()
-            session.get = lambda path, **kwargs: choose_reply(path, {
-                'problems/?id={}'.format(submission_id): reply_body})
+            session.get = lambda path, **kwargs: choose_reply(path, fix_status_paths({
+                f'problems/?id={submission_id}': reply_body
+            }, **self.poll_params))
 
             def post(a, **kwargs):
                 release_reply.wait()
@@ -771,7 +772,7 @@ class MockCancel(unittest.TestCase):
             return session
 
         with mock.patch.object(Client, 'create_session', create_mock_session):
-            with Client(endpoint='endpoint', token='token') as client:
+            with Client(**self.config) as client:
                 solver = Solver(client, self.sapi.solver.data)
 
                 linear, quadratic = self.sapi.problem