-
Notifications
You must be signed in to change notification settings - Fork 226
change Python grpc client so wait loop happens in Python #1857
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,10 +2,15 @@ | |
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import os | ||
| import threading | ||
| import time | ||
|
|
||
| import pytest | ||
|
|
||
| from cuopt.grpc.client.grpc_client import ( | ||
| _WAIT_POLL_INTERVAL_S, | ||
| _wait_poll_loop, | ||
| ) | ||
| from cuopt.grpc.linear_programming import ( | ||
| Client, | ||
| GrpcError, | ||
|
|
@@ -75,6 +80,133 @@ def _assert_demo_lp_solution(client): | |
| client.delete(job_id) | ||
|
|
||
|
|
||
| class TestWaitPollLoop: | ||
| def test_returns_immediately_when_already_terminal(self, monkeypatch): | ||
| sleeps = [] | ||
| monkeypatch.setattr(time, "sleep", sleeps.append) | ||
| status = _wait_poll_loop( | ||
| lambda job_id: JobStatus.COMPLETED, "job", 0, GrpcError | ||
| ) | ||
| assert status is JobStatus.COMPLETED | ||
| assert sleeps == [] | ||
|
|
||
| def test_sleeps_between_in_flight_polls(self, monkeypatch): | ||
| polls = [] | ||
| sleeps = [] | ||
|
|
||
| def get_status(job_id): | ||
| polls.append(job_id) | ||
| if len(polls) < 3: | ||
| return JobStatus.PROCESSING | ||
| return JobStatus.CANCELLED | ||
|
|
||
| monkeypatch.setattr(time, "sleep", sleeps.append) | ||
| status = _wait_poll_loop(get_status, "abc", 0, GrpcError) | ||
| assert status is JobStatus.CANCELLED | ||
| assert polls == ["abc", "abc", "abc"] | ||
| assert sleeps == [_WAIT_POLL_INTERVAL_S, _WAIT_POLL_INTERVAL_S] | ||
|
|
||
| def test_timeout_raises_after_deadline(self, monkeypatch): | ||
| ticks = iter([100.0, 100.0, 101.0]) | ||
| monkeypatch.setattr(time, "monotonic", lambda: next(ticks)) | ||
| monkeypatch.setattr(time, "sleep", lambda seconds: None) | ||
| with pytest.raises( | ||
| GrpcError, match="Timeout waiting for job completion" | ||
| ): | ||
| _wait_poll_loop( | ||
| lambda job_id: JobStatus.QUEUED, "job", 1, GrpcError | ||
| ) | ||
|
|
||
| def test_rejects_negative_timeout(self): | ||
| with pytest.raises( | ||
| GrpcError, match="timeout_seconds must be non-negative" | ||
| ): | ||
| _wait_poll_loop( | ||
| lambda job_id: JobStatus.PROCESSING, "job", -1, GrpcError | ||
| ) | ||
|
|
||
| def test_other_thread_runs_during_sleep(self): | ||
| started = threading.Event() | ||
| progressed = threading.Event() | ||
| polls = {"n": 0} | ||
|
|
||
| def get_status(job_id): | ||
| polls["n"] += 1 | ||
| if polls["n"] == 1: | ||
| started.set() | ||
| return JobStatus.PROCESSING | ||
| assert progressed.is_set() | ||
| return JobStatus.COMPLETED | ||
|
|
||
| def worker(): | ||
| assert started.wait(timeout=2) | ||
| progressed.set() | ||
|
|
||
| thread = threading.Thread(target=worker) | ||
| thread.start() | ||
| status = _wait_poll_loop( | ||
| get_status, | ||
| "job", | ||
| 0, | ||
| GrpcError, | ||
| poll_interval_s=0.05, | ||
| ) | ||
| thread.join(timeout=2) | ||
| assert status is JobStatus.COMPLETED | ||
| assert progressed.is_set() | ||
|
|
||
| def test_client_wait_releases_gil(self, monkeypatch): | ||
| """A GIL-bound spinner must progress during Client.wait's poll sleep. | ||
|
|
||
| Hits are counted only inside the sleep, so this fails if wait() still | ||
| called the C++ WaitForCompletion/sleep path that holds the GIL. | ||
| """ | ||
| import cuopt.grpc.client.grpc_client as grpc_mod | ||
|
|
||
| polls = {"n": 0} | ||
|
|
||
| def fake_status(self, job_id): | ||
| polls["n"] += 1 | ||
| if polls["n"] == 1: | ||
| return JobStatus.PROCESSING | ||
| return JobStatus.COMPLETED | ||
|
|
||
| monkeypatch.setattr(Client, "status", fake_status) | ||
| client = Client.__new__(Client) | ||
|
Comment on lines
+174
to
+175
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
# Description: Check Cython version/build flags and existing monkeypatch usage on cdef classes.
set -euo pipefail
fd -H -t f -e toml -e cfg -e txt 'pyproject|setup|requirements' python | xargs rg -n 'cython|Cython' || true
fd -t f 'setup.py' python --exec rg -n 'language_level|compiler_directives|limited_api|Extension\(' {} || true
# Existing tests that patch attributes on compiled classes
rg -n -C3 'monkeypatch\.setattr\(\s*(Client|RoutingClient|DataModel|SolverSettings)\b' python || true
# Confirm Client is a cdef class
rg -n -C2 '^cdef class Client' python/cuopt/cuopt/grpc/client/grpc_client.pyxRepository: NVIDIA/cuopt Length of output: 1014 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- Client declarations and wait implementation ---'
sed -n '240,380p' python/cuopt/cuopt/grpc/client/grpc_client.pyx
printf '%s\n' '--- Test context ---'
sed -n '130,215p' python/cuopt/cuopt/tests/linear_programming/test_grpc_client.py
printf '%s\n' '--- Build configuration ---'
sed -n '1,180p' python/cuopt/pyproject.toml
fd -H -t f -i 'setup.py' python --exec sh -c 'echo "--- {}"; sed -n "1,220p" "$1"' sh {} \;Repository: NVIDIA/cuopt Length of output: 11988 🤖 get_repo_knowledge executed:
Length of output: 42189 🌐 Web query:
💡 Result: Cython cdef classes (extension types) are designed for performance and memory efficiency by storing attributes in C structs rather than Python dictionaries, which fundamentally impacts their behavior regarding monkeypatching, attributes, and method overriding [1][2]. Monkeypatching and Class Attributes By default, cdef classes are "immutable" from the perspective of standard Python monkeypatching; you cannot add attributes to them or rebind their methods at runtime [1][3]. Because they lack a dict by default, attempting to assign new attributes to the class or its instances will generally raise a TypeError [1][4][3]. To enable monkeypatching or dynamic attributes, you must explicitly add a dict member to the cdef class definition [4][5]: cdef class MyClass: cdef dict dict Alternatively, if you need to replace the entire class type at runtime, define the cdef class with an internal name and assign it to a Python-accessible variable [6]: cdef class _MyClass: pass MyClass = _MyClass # You can now rebind 'MyClass' at runtime Method Overriding The ability to override methods depends on how they are declared: 1. cdef methods: These are C-level virtual functions, not Python methods. They cannot be overridden by Python def methods [7]. They can only be overridden by other cdef or cpdef methods in subclasses [8][7]. 2. cpdef methods: These create both a C-level virtual entry and a Python wrapper. They can be overridden by Python def methods in subclasses, though this introduces a small performance overhead compared to pure cdef methods [2][9]. 3. def methods: These are standard Python methods and can be overridden by subclasses using standard Python inheritance rules [8][9]. If you attempt to monkeypatch a method onto a cdef class at runtime (e.g., Class.method = new_method), it will fail with a TypeError unless you have enabled dict support or specifically designed the class to be a standard Python class wrapper [3][7]. Even with dict, cdef methods are often baked into the C-level virtual table and may not be easily replaceable via Python-space monkeypatching [7]. Citations:
Override 🤖 Prompt for AI Agents |
||
|
|
||
| hits = {"n": 0} | ||
| during_sleep = [] | ||
| stop = threading.Event() | ||
|
|
||
| def spinner(): | ||
| while not stop.is_set(): | ||
| hits["n"] += 1 | ||
|
|
||
| real_sleep = time.sleep | ||
|
|
||
| def tracking_sleep(seconds): | ||
| assert seconds == _WAIT_POLL_INTERVAL_S | ||
| before = hits["n"] | ||
| real_sleep(0.15) | ||
| during_sleep.append(hits["n"] - before) | ||
|
|
||
| monkeypatch.setattr(grpc_mod.time, "sleep", tracking_sleep) | ||
| thread = threading.Thread(target=spinner) | ||
| thread.start() | ||
| try: | ||
| status = client.wait("job") | ||
| finally: | ||
| stop.set() | ||
| thread.join(timeout=2) | ||
|
|
||
| assert status is JobStatus.COMPLETED | ||
| assert during_sleep, "wait() never slept between status polls" | ||
| assert during_sleep[0] > 1000, ( | ||
| "spinner made no progress during wait sleep; GIL likely held " | ||
| f"(hits={during_sleep[0]})" | ||
| ) | ||
|
|
||
|
|
||
| class TestTlsConfig: | ||
| def test_mtls_requires_both_client_materials(self): | ||
| pem = "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----" | ||
|
|
@@ -272,7 +404,7 @@ def get_solution( | |
| job_id = client.submit(problem, settings) | ||
| client.start_incumbent_stream(job_id, settings=settings) | ||
|
|
||
| terminal = _poll_until_complete(client, job_id, _MIP_NAMES) | ||
| terminal = client.wait(job_id, timeout=120) | ||
| assert terminal == JobStatus.COMPLETED | ||
| client.join_incumbent_stream(job_id) | ||
|
|
||
|
|
@@ -284,6 +416,70 @@ def get_solution( | |
| assert solution is not None | ||
| client.delete(job_id) | ||
|
|
||
| def test_mip_incumbent_stream_live_during_wait(self, grpc_server): | ||
| """Incumbent callbacks must fire during wait(), not in a burst after. | ||
|
|
||
| The 2-variable MIP in test_mip_incumbent_stream finishes too fast to | ||
| tell. swath1 with a time limit stays PROCESSING long enough that a | ||
| GIL-holding wait() would delay every callback until join(). | ||
| """ | ||
| if not os.path.isfile(_SWATH1_MPS): | ||
| pytest.skip(f"dataset not found: {_SWATH1_MPS}") | ||
|
|
||
| class TimedIncumbents(GetSolutionCallback): | ||
| def __init__(self): | ||
| super().__init__() | ||
| self.times = [] | ||
| self.costs = [] | ||
|
|
||
| def get_solution( | ||
| self, solution, solution_cost, solution_bound, user_data | ||
| ): | ||
| self.times.append(time.monotonic()) | ||
| self.costs.append(float(solution_cost[0])) | ||
|
|
||
| collector = TimedIncumbents() | ||
| settings = SolverSettings() | ||
| settings.set_mip_callback(collector, None) | ||
| settings.set_parameter(CUOPT_TIME_LIMIT, 8) | ||
|
|
||
| client = Client("localhost", grpc_server) | ||
| job_id = client.submit(Read(_SWATH1_MPS), settings) | ||
| client.start_incumbent_stream( | ||
| job_id, settings=settings, poll_interval_ms=200 | ||
| ) | ||
| try: | ||
| terminal = client.wait(job_id, timeout=30) | ||
| wait_end = time.monotonic() | ||
| client.join_incumbent_stream(job_id) | ||
| finally: | ||
| client.delete(job_id) | ||
|
|
||
| if terminal != JobStatus.COMPLETED: | ||
| pytest.skip(f"job did not complete ({terminal.name})") | ||
| if len(collector.times) < 2: | ||
| pytest.skip( | ||
| "need >=2 incumbents to test live delivery, got " | ||
| f"{len(collector.times)}" | ||
| ) | ||
|
|
||
| n_before = sum(t < wait_end for t in collector.times) | ||
| spread = max(collector.times) - min(collector.times) | ||
| lag = wait_end - min(collector.times) | ||
| print( | ||
| f"incumbents={len(collector.times)} before_wait={n_before} " | ||
| f"spread={spread:.3f}s first_to_wait_end={lag:.3f}s" | ||
| ) | ||
| assert n_before >= 1, ( | ||
| f"all {len(collector.times)} incumbents arrived at/after " | ||
| f"wait() returned (spread={spread:.4f}s); GIL likely held" | ||
| ) | ||
| assert spread > 0.15, ( | ||
| f"incumbent timestamps clustered in {spread:.4f}s " | ||
| f"(n={len(collector.times)}, lag_to_wait_end={lag:.4f}s); " | ||
| "likely dumped as a burst at completion" | ||
| ) | ||
|
|
||
|
|
||
| @pytest.mark.xdist_group(name="grpc_server") | ||
| @pytest.mark.filterwarnings("ignore::DeprecationWarning") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Remove the scheduling race from this assertion.
get_statuscall 2 asserts that the worker thread already setprogressed. The worker only has the 50 ms sleep window to start and run. On a loaded runner, thread startup can exceed 50 ms, and the test then fails inside the polling loop. Wait on the event instead, so the test proves progress without depending on scheduler latency.♻️ Proposed change
if polls["n"] == 1: started.set() return JobStatus.PROCESSING - assert progressed.is_set() + assert progressed.wait(timeout=2) return JobStatus.COMPLETEDAs per path instructions: "ensure tests cover edge cases and are not timing-flaky".
📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Path instructions