Repository: dwavesystems/dwave-cloud-client
Pull request #663 (2024-08-16 20:26:41)

Cache dynamic solver metadata and treat `status` as dynamic property


Patch:
diff --git a/benchmarks/benchmarks.py b/benchmarks/benchmarks.py
index 36e18ef..5c1989f 100644
--- a/benchmarks/benchmarks.py
+++ b/benchmarks/benchmarks.py
@@ -196,7 +196,7 @@ class SolverMetadataJSONDecode:
 
 
 class SolverSelection:
-    version = "2"
+    version = "3"
 
     def setup(self):
         self.client = Client(token='mock')
@@ -204,18 +204,23 @@ class SolverSelection:
 
         static_data = deepcopy(solvers_data)
         for solver in static_data:
+            del solver['status']
             del solver['avg_load']
 
-        dynamic_data = [{"id": s['id'], "avg_load": s['avg_load']} for s in solvers_data]
+        dynamic_data = [{
+            "id": s['id'],
+            "status": s['status'],
+            "avg_load": s['avg_load']
+        } for s in solvers_data]
 
         static_conf = TypeAdapter(List[SolverConfiguration]).validate_python(static_data)
         dynamic_conf = TypeAdapter(List[SolverConfiguration]).validate_python(dynamic_data)
 
         class mock_session:
             def list_solvers(self, filter=None, **kwargs):
-                if filter == 'none,+id,+avg_load':
+                if filter == 'none,+id,+status,+avg_load':
                     return dynamic_conf
-                elif filter == 'all,-avg_load':
+                elif filter == 'all,-status,-avg_load':
                     return static_conf
                 else:
                     raise ValueError
diff --git a/dwave/cloud/client/base.py b/dwave/cloud/client/base.py
index 5c5f494..5b74533 100644
--- a/dwave/cloud/client/base.py
+++ b/dwave/cloud/client/base.py
@@ -377,9 +377,12 @@ class Client(object):
     _POLL_GROUP_TIMEFRAME = 2
 
     # Downloaded solver definition cache config
+    _DEFAULT_SOLVERS_STATIC_PART_MAXAGE = 3600  # 1 hour
+    _DEFAULT_SOLVERS_DYNAMIC_PART_MAXAGE = 900  # 15 min
     _DEFAULT_SOLVERS_CACHE_CONFIG = dict(
         enabled=True,
-        maxage=3600,    # 1 hour heuristic maxage (cache-control in response overrides it)
+        # heuristic maxage (cache-control in response overrides it)
+        maxage=_DEFAULT_SOLVERS_STATIC_PART_MAXAGE,
     )
 
     # Downloaded region metadata cache maxage [sec]
@@ -743,8 +746,8 @@ class Client(object):
                        refresh_: Optional[bool] = False,
                        ) -> List[Union[StructuredSolver, UnstructuredSolver]]:
 
-        static_fields = 'all,-avg_load'
-        dynamic_fields = 'none,+id,+avg_load'
+        static_fields = 'all,-status,-avg_load'
+        dynamic_fields = 'none,+id,+status,+avg_load'
 
         if name is not None:
             logger.info("Fetching definition of a solver with name=%r", name)
@@ -752,12 +755,14 @@ class Client(object):
             try:
                 solver = self.solvers_session.get_solver(
                     solver_id=name, filter=static_fields, refresh_=refresh_)
-                load = self.solvers_session.get_solver(
-                    solver_id=name, filter=dynamic_fields, no_cache_=True)
+                status = self.solvers_session.get_solver(
+                    solver_id=name, filter=dynamic_fields, refresh_=refresh_,
+                    maxage_=self._DEFAULT_SOLVERS_DYNAMIC_PART_MAXAGE)
+
+                # merge static and dynamic properties
+                solver.status = status.get('status')
+                solver.avg_load = status.get('avg_load')
 
-                # note: avg_load may not exist in solver data
-                if (avg_load := load.get('avg_load')) is not None:
-                    solver.avg_load = avg_load
                 solvers = [solver]
 
             except api.exceptions.ResourceNotFoundError as exc:
@@ -768,14 +773,16 @@ class Client(object):
 
             solvers = self.solvers_session.list_solvers(
                 filter=static_fields, refresh_=refresh_)
-            loads = {solver.id: solver.get('avg_load')
-                     for solver
-                     in self.solvers_session.list_solvers(
-                         filter=dynamic_fields, no_cache_=True)}
+            dynamic = self.solvers_session.list_solvers(
+                filter=dynamic_fields, refresh_=refresh_,
+                maxage_=self._DEFAULT_SOLVERS_DYNAMIC_PART_MAXAGE)
 
+            # add dynamic properties to static solvers
+            # note: allow solver list mismatch
+            statuses = {solver.id: solver for solver in dynamic}
             for solver in solvers:
-                if (avg_load := loads.get(solver.id)) is not None:
-                    solver.avg_load = avg_load
+                solver.status = statuses.get(solver.id, {}).get('status', 'offline')
+                solver.avg_load = statuses.get(solver.id, {}).get('avg_load', 0)
 
         logger.info("Received solver data for %d solver(s).", len(solvers))
         if logger.isEnabledFor(logging.TRACE):
diff --git a/releasenotes/notes/treat-status-solver-field-as-dynamic-0b34ff2125c5f972.yaml b/releasenotes/notes/treat-status-solver-field-as-dynamic-0b34ff2125c5f972.yaml
new file mode 100644
index 0000000..f09b8fd
--- /dev/null
+++ b/releasenotes/notes/treat-status-solver-field-as-dynamic-0b34ff2125c5f972.yaml
@@ -0,0 +1,11 @@
+---
+fixes:
+  - |
+    During solver metadata filtering, treat solver ``status`` as a dynamic field.
+    This is to handle an edge case when solver is marked ``offline``, but it's
+    still available via API.
+
+features:
+  - |
+    Cache dynamic solver metadata for up to 15 minutes, unless defined
+    differently by API cache control response header.


Test patch:
diff --git a/tests/test_mock_solver_loading.py b/tests/test_mock_solver_loading.py
index b2c7ef0..cc80a78 100644
--- a/tests/test_mock_solver_loading.py
+++ b/tests/test_mock_solver_loading.py
@@ -14,6 +14,7 @@
 
 """Try to load solver data from mock servers."""
 
+import time
 import unittest
 import unittest.mock
 from urllib.parse import urlencode
@@ -49,12 +50,14 @@ def solver_data(id_, status="ONLINE", avg_load=0.1, cat='qpu', incomplete=False,
         del obj['properties']['supported_problem_types']
 
     if subset == 'static':
+        del obj['status']
         del obj['avg_load']
 
     elif subset == 'dynamic':
         obj = {
             "id": id_,
-            "avg_load": obj["avg_load"]
+            "status": status,
+            "avg_load": avg_load
         }
 
     elif subset != 'all':
@@ -105,10 +108,10 @@ class MockSolverLoading(unittest.TestCase):
         def add_mock_solver(m, url, name, incomplete=False):
             m.get(url, complete_qs=True, json=solver_data(name, incomplete=incomplete),
                   request_headers=valid_token_headers, headers=solver_content_type)
-            m.get(f"{url}?{urlencode(dict(filter='all,-avg_load'))}",
+            m.get(f"{url}?{urlencode(dict(filter='all,-status,-avg_load'))}",
                   json=solver_data(name, subset='static', incomplete=incomplete),
                   request_headers=valid_token_headers, headers=solver_content_type)
-            m.get(f"{url}?{urlencode(dict(filter='none,+id,+avg_load'))}",
+            m.get(f"{url}?{urlencode(dict(filter='none,+id,+status,+avg_load'))}",
                   json=solver_data(name, subset='dynamic', incomplete=incomplete),
                   request_headers=valid_token_headers, headers=solver_content_type)
 
@@ -118,19 +121,19 @@ class MockSolverLoading(unittest.TestCase):
 
         m.get(solver4_truncated_url,
               text='{"id', request_headers=valid_token_headers, headers=solver_content_type)
-        m.get(f"{solver4_truncated_url}?{urlencode(dict(filter='all,-avg_load'))}",
+        m.get(f"{solver4_truncated_url}?{urlencode(dict(filter='all,-status,-avg_load'))}",
               text='{"id', request_headers=valid_token_headers, headers=solver_content_type)
-        m.get(f"{solver4_truncated_url}?{urlencode(dict(filter='none,+id,+avg_load'))}",
+        m.get(f"{solver4_truncated_url}?{urlencode(dict(filter='none,+id,+status,+avg_load'))}",
               text='{"id', request_headers=valid_token_headers, headers=solver_content_type)
 
         m.get(all_solvers_url,
               json=[solver_data(self.solver1_name), solver_data(self.solver2_name)],
               request_headers=valid_token_headers, headers=solvers_content_type)
-        m.get(f"{all_solvers_url}?{urlencode(dict(filter='all,-avg_load'))}",
+        m.get(f"{all_solvers_url}?{urlencode(dict(filter='all,-status,-avg_load'))}",
               json=[solver_data(self.solver1_name, subset='static'),
                     solver_data(self.solver2_name, subset='static')],
               request_headers=valid_token_headers, headers=solvers_content_type)
-        m.get(f"{all_solvers_url}?{urlencode(dict(filter='none,+id,+avg_load'))}",
+        m.get(f"{all_solvers_url}?{urlencode(dict(filter='none,+id,+status,+avg_load'))}",
               json=[solver_data(self.solver1_name, subset='dynamic'),
                     solver_data(self.solver2_name, subset='dynamic')],
               request_headers=valid_token_headers, headers=solvers_content_type)
@@ -188,7 +191,10 @@ class MockSolverLoading(unittest.TestCase):
 
     def test_solvers_cache(self):
         with unittest.mock.patch.multiple(
-                Client, _DEFAULT_SOLVERS_CACHE_CONFIG=dict(maxage=60, store={})):
+                Client,
+                _DEFAULT_SOLVERS_STATIC_PART_MAXAGE=60,
+                _DEFAULT_SOLVERS_DYNAMIC_PART_MAXAGE=10,
+                _DEFAULT_SOLVERS_CACHE_CONFIG=dict(maxage=60, store={})):
 
             with Client(endpoint=self.endpoint, token=self.token) as client:
 
@@ -200,13 +206,13 @@ class MockSolverLoading(unittest.TestCase):
                     self.assertEqual(len(solvers), 2)
                     self.assertEqual(self.mocker.call_count, 2)
 
-                with self.subTest("cache hit for static data"):
+                with self.subTest("cache hit"):
                     self.mocker.reset_mock()
 
                     solvers = client.get_solvers()
 
                     self.assertEqual(len(solvers), 2)
-                    self.assertEqual(self.mocker.call_count, 1)
+                    self.assertEqual(self.mocker.call_count, 0)
 
                 with self.subTest("cache refresh forced"):
                     self.mocker.reset_mock()
@@ -216,6 +222,19 @@ class MockSolverLoading(unittest.TestCase):
                     self.assertEqual(len(solvers), 2)
                     self.assertEqual(self.mocker.call_count, 2)
 
+                with self.subTest("cache updated for dynamic parts"):
+                    with unittest.mock.patch(
+                        'dwave.cloud.api.client.epochnow',
+                        lambda: time.time() + client._DEFAULT_SOLVERS_DYNAMIC_PART_MAXAGE + 1
+                    ):
+                        self.mocker.reset_mock()
+
+                        solvers = client.get_solvers()
+
+                        self.assertEqual(len(solvers), 2)
+                        self.assertEqual(self.mocker.call_count, 1)
+
+
     def test_load_missing_solver(self):
         with Client(endpoint=self.endpoint, token=self.token) as client:
             with self.assertRaises(SolverNotFoundError):