diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index b102728cca..326b601574 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -481,11 +481,6 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Pull and launch geometry service - run: | - docker pull ${{ env.ANSRV_GEO_IMAGE_MINREQS }} - docker run --detach --name ${{ env.GEO_CONT_NAME }} -e LICENSE_SERVER=${{ env.ANSRV_GEO_LICENSE_SERVER }} -p ${{ env.ANSRV_GEO_PORT }}:50051 ${{ env.ANSRV_GEO_IMAGE_MINREQS }} - - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -500,6 +495,12 @@ jobs: pip install -e .[all,tests-minimal] pip install pytest + - name: Start Geometry service and verify start + run: | + docker pull ${{ env.ANSRV_GEO_IMAGE_MINREQS }} + docker run --detach --name ${{ env.GEO_CONT_NAME }} -e LICENSE_SERVER=${{ env.ANSRV_GEO_LICENSE_SERVER }} -p ${{ env.ANSRV_GEO_PORT }}:50051 ${{ env.ANSRV_GEO_IMAGE_MINREQS }} + python -c "from ansys.geometry.core.connection.validate import validate; validate()" + - name: Run pytest run: | pytest -v @@ -529,11 +530,6 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Pull and launch geometry service - run: | - docker pull ${{ env.ANSRV_GEO_IMAGE_MINREQS }} - docker run --detach --name ${{ env.GEO_CONT_NAME }} -e LICENSE_SERVER=${{ env.ANSRV_GEO_LICENSE_SERVER }} -p ${{ env.ANSRV_GEO_PORT }}:50051 ${{ env.ANSRV_GEO_IMAGE_MINREQS }} - - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -551,6 +547,12 @@ jobs: # Installing docker (needed for the tests) pip install docker + - name: Start Geometry service and verify start + run: | + docker pull ${{ env.ANSRV_GEO_IMAGE_MINREQS }} + docker run --detach --name ${{ env.GEO_CONT_NAME }} -e LICENSE_SERVER=${{ env.ANSRV_GEO_LICENSE_SERVER }} -p ${{ env.ANSRV_GEO_PORT }}:50051 ${{ env.ANSRV_GEO_IMAGE_MINREQS }} + python -c "from ansys.geometry.core.connection.validate import validate; validate()" + - name: Run pytest run: | pytest -v -c pytest-nographics.ini @@ -562,7 +564,6 @@ jobs: docker logs ${{ env.GEO_CONT_NAME }} docker rm ${{ env.GEO_CONT_NAME }} - package: name: Package library needs: [testing-windows, testing-linux, testing-min-reqs, testing-no-graphics, docs] @@ -691,17 +692,12 @@ jobs: run: echo "ANSRV_GEO_LICENSE_SERVER=${{ secrets.INTERNAL_LICENSE_SERVER }}" | Out-File -FilePath $env:GITHUB_ENV -Append - - name: Launch Geometry service - run: | - docker run --detach --name ${{ env.GEO_CONT_NAME }} -e LICENSE_SERVER=${{ env.ANSRV_GEO_LICENSE_SERVER }} -p ${{ env.ANSRV_GEO_PORT }}:50051 ghcr.io/ansys/geometry:windows-tmp - - name: Validate connection using PyAnsys Geometry run: | python -m venv .venv .\.venv\Scripts\Activate.ps1 python -m pip install --upgrade pip pip install -e .[tests] - python -c "from ansys.geometry.core.connection.validate import validate; validate()" - name: Restore images cache uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 @@ -710,6 +706,12 @@ jobs: key: pyvista-image-cache-${{ runner.os }}-v-${{ env.RESET_IMAGE_CACHE }}-${{ hashFiles('pyproject.toml') }} restore-keys: pyvista-image-cache-${{ runner.os }}-v-${{ env.RESET_IMAGE_CACHE }} + - name: Start Geometry service and verify start + run: | + .\.venv\Scripts\Activate.ps1 + docker run --detach --name ${{ env.GEO_CONT_NAME }} -e LICENSE_SERVER=${{ env.ANSRV_GEO_LICENSE_SERVER }} -p ${{ env.ANSRV_GEO_PORT }}:50051 ghcr.io/ansys/geometry:windows-tmp + python -c "from ansys.geometry.core.connection.validate import validate; validate()" + - name: Testing run: | .\.venv\Scripts\Activate.ps1 @@ -778,14 +780,14 @@ jobs: run: | docker build -f linux/coreservice/Dockerfile -t ghcr.io/ansys/geometry:linux-tmp . - - name: Launch Geometry service - run: | - docker run --detach --name ${{ env.GEO_CONT_NAME }} -e LICENSE_SERVER=${{ env.ANSRV_GEO_LICENSE_SERVER }} -p ${{ env.ANSRV_GEO_PORT }}:50051 ghcr.io/ansys/geometry:linux-tmp - - name: Validate connection using PyAnsys Geometry run: | python -m pip install --upgrade pip pip install -e .[tests] + + - name: Start Geometry service and verify start + run: | + docker run --detach --name ${{ env.GEO_CONT_NAME }} -e LICENSE_SERVER=${{ env.ANSRV_GEO_LICENSE_SERVER }} -p ${{ env.ANSRV_GEO_PORT }}:50051 ghcr.io/ansys/geometry:linux-tmp python -c "from ansys.geometry.core.connection.validate import validate; validate()" - name: Restore images cache diff --git a/doc/changelog.d/1904.dependencies.md b/doc/changelog.d/1904.dependencies.md new file mode 100644 index 0000000000..ae1469a338 --- /dev/null +++ b/doc/changelog.d/1904.dependencies.md @@ -0,0 +1 @@ +bump grpcio dependencies \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4d0481f012..ba017eee39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,16 +24,15 @@ classifiers = [ ] dependencies = [ - "ansys-api-geometry==0.4.56", + "ansys-api-geometry==0.4.57", "ansys-tools-path>=0.3,<1", "beartype>=0.11.0,<0.21", "geomdl>=5,<6", - "grpcio>=1.35.0,<1.68", - "grpcio-health-checking>=1.45.0,<1.68", + "grpcio>=1.35.0,<2", "matplotlib>=3,<4", "numpy>=1.20.3,<3", "Pint>=0.18,<1", - "protobuf>=3.20.2,<6", + "protobuf>=3.20.2,<7", "requests>=2,<3", "scipy>=1.7.3,<2", "semver>=3,<4", @@ -62,12 +61,11 @@ tests = [ "beartype==0.20.2", "docker==7.1.0", "geomdl==5.3.1", - "grpcio==1.67.1", - "grpcio-health-checking==1.67.1", + "grpcio==1.71.0", "matplotlib==3.10.1", "numpy==2.2.4", "Pint==0.24.4", - "protobuf==5.29.3", + "protobuf==6.30.2", "pygltflib==1.16.3", "pytest==8.3.5", "pytest-cov==6.1.1", @@ -93,8 +91,7 @@ doc = [ "beartype==0.20.2", "docker==7.1.0", "geomdl==5.3.1", - "grpcio==1.67.1", - "grpcio-health-checking==1.67.1", + "grpcio==1.71.0", "ipyvtklink==0.2.3", "jupyter_sphinx==0.5.3", "jupytext==1.16.7", @@ -108,7 +105,7 @@ doc = [ "panel==1.6.1", "pdf2image==1.17.0", "Pint==0.24.4", - "protobuf==5.29.3", + "protobuf==6.30.2", "pygltflib==1.16.3", "pyvista[jupyter]==0.44.2", "quarto-cli==1.6.42", diff --git a/src/ansys/geometry/core/_grpc/_services/_service.py b/src/ansys/geometry/core/_grpc/_services/_service.py index 26a106279e..6916057df9 100644 --- a/src/ansys/geometry/core/_grpc/_services/_service.py +++ b/src/ansys/geometry/core/_grpc/_services/_service.py @@ -50,7 +50,12 @@ class _GRPCServices: version is used. """ - def __init__(self, channel: grpc.Channel, version: GeometryApiProtos | str | None = None): + def __init__( + self, + channel: grpc.Channel, + version: GeometryApiProtos | str | None = None, + timeout: float = 5.0, + ): """ Initialize the GRPCServices class. @@ -61,9 +66,11 @@ def __init__(self, channel: grpc.Channel, version: GeometryApiProtos | str | Non version : GeometryApiProtos | str | None The version of the gRPC API protocol to use. If None, the latest version is used. + timeout : float + The timeout in seconds for the health check. Default is 5 seconds. """ # Set the proto version to be used - self.version = set_proto_version(channel, version) + self.version = set_proto_version(channel, version, timeout) self.channel = channel # Lazy load all the services diff --git a/src/ansys/geometry/core/_grpc/_services/base/admin.py b/src/ansys/geometry/core/_grpc/_services/base/admin.py index d725c67d04..96c54738b2 100644 --- a/src/ansys/geometry/core/_grpc/_services/base/admin.py +++ b/src/ansys/geometry/core/_grpc/_services/base/admin.py @@ -48,3 +48,8 @@ def get_backend(self, **kwargs) -> dict: def get_logs(self, **kwargs) -> dict: """Get server logs.""" pass # pragma: no cover + + @abstractmethod + def wait_until_healthy(self, **kwargs) -> dict: + """Wait until the server is healthy.""" + pass diff --git a/src/ansys/geometry/core/_grpc/_services/v0/admin.py b/src/ansys/geometry/core/_grpc/_services/v0/admin.py index 1b0f2afc8f..b9ebad93fb 100644 --- a/src/ansys/geometry/core/_grpc/_services/v0/admin.py +++ b/src/ansys/geometry/core/_grpc/_services/v0/admin.py @@ -21,8 +21,6 @@ # SOFTWARE. """Module containing the admin service implementation for v0.""" -import warnings - import grpc import semver @@ -53,13 +51,7 @@ def __init__(self, channel: grpc.Channel): # noqa: D102 @protect_grpc def get_backend(self, **kwargs) -> dict: # noqa: D102 - # TODO: Remove this context and filter once the protobuf UserWarning is downgraded to INFO - # https://github.com/grpc/grpc/issues/37609 - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", "Protobuf gencode version", UserWarning, "google.protobuf.runtime_version" - ) - from google.protobuf.empty_pb2 import Empty + from google.protobuf.empty_pb2 import Empty # Create the request - assumes all inputs are valid and of the proper type request = Empty() @@ -104,3 +96,48 @@ def get_logs(self, **kwargs) -> dict: # noqa: D102 logs[chunk.log_name] += chunk.log_chunk.decode() return {"logs": logs} + + @protect_grpc + def wait_until_healthy(self, **kwargs) -> dict: # noqa: D102 + from concurrent.futures import ThreadPoolExecutor + import time + + from ansys.api.dbu.v0.admin_pb2 import HealthResponse + + def _get_health_status() -> HealthResponse: + """Get the health status of the server.""" + from google.protobuf.empty_pb2 import Empty + + return self.stub.Health(Empty()) + + t_max = time.time() + kwargs["timeout"] + t_out = 0.1 + while time.time() < t_max: + try: + result = None + # Use ThreadPoolExecutor to run the health check in a separate thread + with ThreadPoolExecutor() as executor: + future = executor.submit(_get_health_status) + try: + result = future.result(timeout=t_out) # seconds + except (TimeoutError, grpc.RpcError) as e: + # Timeout error, try again + raise TimeoutError(f"Timeout/connection error... {e}") + if result: + return {"healthy": True if result.message == "I am healthy!" else False} + + except TimeoutError: + # Duplicate timeout and try again + t_now = time.time() + t_out *= 2 + # If we have time to try again, continue.. but if we don't, + # just try for the remaining time + if t_now + t_out > t_max: + t_out = t_max - t_now + continue + + # If we reach here, the server is not healthy + target_str = kwargs["target"] + raise TimeoutError( + f"Channel health check to target '{target_str}' timed out after {kwargs['timeout']} seconds." # noqa: E501 + ) diff --git a/src/ansys/geometry/core/_grpc/_services/v1/admin.py b/src/ansys/geometry/core/_grpc/_services/v1/admin.py index 3f4890d0eb..3cf91fd6f6 100644 --- a/src/ansys/geometry/core/_grpc/_services/v1/admin.py +++ b/src/ansys/geometry/core/_grpc/_services/v1/admin.py @@ -54,3 +54,7 @@ def get_backend(self, **kwargs) -> dict: # noqa: D102 @protect_grpc def get_logs(self, **kwargs) -> dict: # noqa: D102 raise NotImplementedError + + @protect_grpc + def wait_until_healthy(self, **kwargs): # noqa: D102 + raise NotImplementedError diff --git a/src/ansys/geometry/core/_grpc/_version.py b/src/ansys/geometry/core/_grpc/_version.py index 8a9850d280..6f05757e11 100644 --- a/src/ansys/geometry/core/_grpc/_version.py +++ b/src/ansys/geometry/core/_grpc/_version.py @@ -23,28 +23,31 @@ from enum import Enum, unique -from google.protobuf.empty_pb2 import Empty import grpc # ATTEMPT v0 IMPORT try: - import ansys.api.dbu.v0.admin_pb2_grpc as dbu_v0_admin_pb2_grpc + import ansys.api.dbu.v0.admin_pb2_grpc # noqa: F401 + + from ._services.v0.admin import GRPCAdminServiceV0 as V0AdminService except ImportError: - dbu_v0_admin_pb2_grpc = None + V0AdminService = None # ATTEMPT v1 IMPORT try: - import ansys.api.dbu.v1.admin_pb2_grpc as dbu_v1_admin_pb2_grpc + import ansys.api.dbu.v1.admin_pb2_grpc # noqa: F401 + + from ._services.v1.admin import GRPCAdminServiceV1 as V1AdminService except ImportError: - dbu_v1_admin_pb2_grpc = None + V1AdminService = None @unique class GeometryApiProtos(Enum): """Enumeration of the supported versions of the gRPC API protocol.""" - V0 = 0, dbu_v0_admin_pb2_grpc - V1 = 1, dbu_v1_admin_pb2_grpc + V0 = 0, V0AdminService + V1 = 1, V1AdminService @staticmethod def get_latest_version() -> "GeometryApiProtos": @@ -68,7 +71,7 @@ def from_string(version_string: str) -> "GeometryApiProtos": return version raise ValueError(f"Invalid version string: {version_string}") - def verify_supported(self, channel: grpc.Channel) -> bool: + def verify_supported(self, channel: grpc.Channel, timeout: float) -> bool: """Check if the version is supported. Notes @@ -85,20 +88,22 @@ def verify_supported(self, channel: grpc.Channel) -> bool: bool True if the server supports the version, otherwise False. """ - pb2_grpc = self.value[1] - if pb2_grpc is None: + from ._services.base.admin import GRPCAdminService + + AdminService: type[GRPCAdminService] | None = self.value[1] # noqa: N806 + if AdminService is None: return False try: - admin_stub = pb2_grpc.AdminStub(channel) - admin_stub.Health(Empty()) + admin_service = AdminService(channel) + admin_service.wait_until_healthy(timeout=timeout, target=channel._target) return True except grpc.RpcError: return False def set_proto_version( - channel: grpc.Channel, version: GeometryApiProtos | str | None = None + channel: grpc.Channel, version: GeometryApiProtos | str | None = None, timeout: float = 5.0 ) -> "GeometryApiProtos": """Set the version of the gRPC API protocol used by the server. @@ -109,6 +114,8 @@ def set_proto_version( version : GeometryApiProtos | str | None The version of the gRPC API protocol to use. If None, the latest version is used. + timeout : float + The timeout in seconds for the health check. Default is 5 seconds. Returns ------- @@ -120,14 +127,14 @@ def set_proto_version( version = GeometryApiProtos.from_string(version) # Check the server supports the requested version (if specified) - if version and not version.verify_supported(channel): + if version and not version.verify_supported(channel, timeout): raise ValueError(f"Server does not support the requested version: {version.name}") # If no version specified... Attempt to use all of them, starting # with the latest version if version is None: version = GeometryApiProtos.get_latest_version() - while not version.verify_supported(channel): + while not version.verify_supported(channel, timeout): version = GeometryApiProtos.from_int_value(version.value[0] - 1) # Return the version diff --git a/src/ansys/geometry/core/connection/client.py b/src/ansys/geometry/core/connection/client.py index 8474d24c13..e473b48a42 100644 --- a/src/ansys/geometry/core/connection/client.py +++ b/src/ansys/geometry/core/connection/client.py @@ -24,13 +24,10 @@ import atexit import logging from pathlib import Path -import time from typing import Optional from beartype import beartype as check_input_types import grpc -from grpc._channel import _InactiveRpcError -from grpc_health.v1 import health_pb2, health_pb2_grpc import semver from ansys.geometry.core._grpc._services._service import _GRPCServices @@ -48,55 +45,6 @@ pass -def wait_until_healthy(channel: grpc.Channel, timeout: float): - """Wait until a channel is healthy before returning. - - Parameters - ---------- - channel : ~grpc.Channel - Channel that must be established and healthy. - timeout : float - Timeout in seconds. Attempts are made with the following backoff strategy: - - * Starts with 0.1 seconds. - * If the attempt fails, double the timeout. - * This is repeated until the next timeoff exceeds the - value for the remaining time. In that case, a final attempt - is made with the remaining time. - * If the total elapsed time exceeds the value for the ``timeout`` parameter, - a ``TimeoutError`` is raised. - - Raises - ------ - TimeoutError - Raised when the total elapsed time exceeds the value for the ``timeout`` parameter. - """ - t_max = time.time() + timeout - health_stub = health_pb2_grpc.HealthStub(channel) - request = health_pb2.HealthCheckRequest(service="") - - t_out = 0.1 - while time.time() < t_max: - try: - out = health_stub.Check(request, timeout=t_out) - if out.status is health_pb2.HealthCheckResponse.SERVING: - break - except _InactiveRpcError: - # Duplicate timeout and try again - t_now = time.time() - t_out *= 2 - # If we have time to try again, continue.. but if we don't, - # just try for the remaining time - if t_now + t_out > t_max: - t_out = t_max - t_now - continue - else: - target_str = channel._channel.target().decode() - raise TimeoutError( - f"Channel health check to target '{target_str}' timed out after {timeout} seconds." - ) - - class GrpcClient: """Wraps the gRPC connection for the Geometry service. @@ -158,7 +106,7 @@ def __init__( if channel: # Used for PyPIM when directly providing a channel self._channel = channel - self._target = str(channel) + self._target = str(channel._target) else: self._target = f"{host}:{port}" self._channel = grpc.insecure_channel( @@ -166,15 +114,17 @@ def __init__( options=[ ("grpc.max_receive_message_length", pygeom_defaults.MAX_MESSAGE_LENGTH), ("grpc.max_send_message_length", pygeom_defaults.MAX_MESSAGE_LENGTH), + ("grpc.keepalive_permit_without_calls", 1), ], ) # do not finish initialization until channel is healthy self._grpc_health_timeout = timeout - wait_until_healthy(self._channel, self._grpc_health_timeout) # Initialize the gRPC services - self._services = _GRPCServices(self._channel, version=proto_version) + self._services = _GRPCServices( + self._channel, version=proto_version, timeout=self._grpc_health_timeout + ) # Once connection with the client is established, create a logger self._log = LOG.add_instance_logger( @@ -263,8 +213,10 @@ def healthy(self) -> bool: if self._closed: return False try: - wait_until_healthy(self._channel, self._grpc_health_timeout) - return True + response = self._services.admin.wait_until_healthy( + timeout=self._grpc_health_timeout, target=self._target + ) + return response["healthy"] except TimeoutError: # pragma: no cover return False diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 9e24cd9e53..31d3b13e99 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -157,6 +157,10 @@ def session_modeler(docker_instance): except OSError: pass + import time + + time.sleep(10) + modeler = Modeler( docker_instance=docker_instance, logging_level=logging.DEBUG, logging_file=log_file_path ) diff --git a/tests/test_connection.py b/tests/test_connection.py index 55d8195e99..6abc9a1363 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -27,7 +27,7 @@ import pytest from ansys.geometry.core.connection.backend import ApiVersions -from ansys.geometry.core.connection.client import GrpcClient, wait_until_healthy +from ansys.geometry.core.connection.client import GrpcClient from ansys.geometry.core.connection.conversions import ( frame_to_grpc_frame, plane_to_grpc_plane, @@ -50,7 +50,7 @@ def test_wait_until_healthy(): # create a bogus channel channel = grpc.insecure_channel("9.0.0.1:80") with pytest.raises(TimeoutError): - wait_until_healthy(channel, timeout=1) + GrpcClient(channel=channel, timeout=1) def test_invalid_inputs():