Skip to content

build: bump grpcio limits and handle erratic gRPC channel creation #1913

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

Merged
merged 11 commits into from
Apr 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 22 additions & 20 deletions .github/workflows/ci_cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions doc/changelog.d/1913.dependencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
bump grpcio limits and handle erratic gRPC channel creation
12 changes: 6 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ dependencies = [
"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",
"grpcio-health-checking>=1.45.0,<2",
"matplotlib>=3,<4",
"numpy>=1.20.3,<3",
"Pint>=0.18,<1",
Expand Down Expand Up @@ -62,8 +62,8 @@ 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",
"grpcio-health-checking==1.71.0",
"matplotlib==3.10.1",
"numpy==2.2.4",
"Pint==0.24.4",
Expand Down Expand Up @@ -93,8 +93,8 @@ 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",
"grpcio-health-checking==1.71.0",
"ipyvtklink==0.2.3",
"jupyter_sphinx==0.5.3",
"jupytext==1.16.7",
Expand Down
5 changes: 5 additions & 0 deletions src/ansys/geometry/core/_grpc/_services/base/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 get_service_status(self, **kwargs) -> dict:
"""Get server status (i.e. healthy or not)."""
pass # pragma: no cover
13 changes: 13 additions & 0 deletions src/ansys/geometry/core/_grpc/_services/v0/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,16 @@ def get_logs(self, **kwargs) -> dict: # noqa: D102
logs[chunk.log_name] += chunk.log_chunk.decode()

return {"logs": logs}

@protect_grpc
def get_service_status(self, **kwargs) -> dict: # noqa: D102
from google.protobuf.empty_pb2 import Empty

# Create the request - assumes all inputs are valid and of the proper type
request = Empty()

# Call the gRPC service
response = self.stub.Health(request=request)

# Convert the response to a dictionary
return {"healthy": True if response.message == "I am healthy!" else False}
4 changes: 4 additions & 0 deletions src/ansys/geometry/core/_grpc/_services/v1/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 get_service_status(self, **kwargs) -> dict: # noqa: D102
raise NotImplementedError
79 changes: 57 additions & 22 deletions src/ansys/geometry/core/connection/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,41 @@
pass


def wait_until_healthy(channel: grpc.Channel, timeout: float):
def _create_geometry_channel(target: str) -> grpc.Channel:
"""Create a Geometry service gRPC channel.

Notes
-----
Contains specific options for the Geometry service.

Parameters
----------
target : str
Target of the channel. This is usually a string in the form of
``host:port``.

Returns
-------
~grpc.Channel
gRPC channel for the Geometry service.
"""
return grpc.insecure_channel(
target,
options=[
("grpc.max_receive_message_length", pygeom_defaults.MAX_MESSAGE_LENGTH),
("grpc.max_send_message_length", pygeom_defaults.MAX_MESSAGE_LENGTH),
],
)


def wait_until_healthy(channel: grpc.Channel | str, timeout: float) -> grpc.Channel:
"""Wait until a channel is healthy before returning.

Parameters
----------
channel : ~grpc.Channel
Channel that must be established and healthy.
channel : ~grpc.Channel | str
Channel that must be established and healthy. The target can also be
passed in. In that case, a channel is created using the default insecure channel.
timeout : float
Timeout in seconds. Attempts are made with the following backoff strategy:

Expand All @@ -70,14 +98,28 @@ def wait_until_healthy(channel: grpc.Channel, timeout: float):
------
TimeoutError
Raised when the total elapsed time exceeds the value for the ``timeout`` parameter.

Returns
-------
grpc.Channel
The channel that was passed in. This channel is guaranteed to be healthy.
If a string was passed in, a channel is created using the default insecure channel.
"""
t_max = time.time() + timeout
health_stub = health_pb2_grpc.HealthStub(channel)
request = health_pb2.HealthCheckRequest(service="")

t_out = 0.1

# If the channel is a string, create a channel using the default insecure channel
channel_creation_required = True if isinstance(channel, str) else False
tmp_channel = None

while time.time() < t_max:
try:
tmp_channel = (
_create_geometry_channel(channel) if channel_creation_required else channel
)
health_stub = health_pb2_grpc.HealthStub(tmp_channel)
request = health_pb2.HealthCheckRequest(service="")

out = health_stub.Check(request, timeout=t_out)
if out.status is health_pb2.HealthCheckResponse.SERVING:
break
Expand All @@ -91,11 +133,13 @@ def wait_until_healthy(channel: grpc.Channel, timeout: float):
t_out = t_max - t_now
continue
else:
target_str = channel._channel.target().decode()
target_str = tmp_channel._channel.target().decode()
raise TimeoutError(
f"Channel health check to target '{target_str}' timed out after {timeout} seconds."
)

return tmp_channel


class GrpcClient:
"""Wraps the gRPC connection for the Geometry service.
Expand Down Expand Up @@ -155,23 +199,15 @@ def __init__(
self._remote_instance = remote_instance
self._docker_instance = docker_instance
self._product_instance = product_instance
self._grpc_health_timeout = timeout

if channel:
# Used for PyPIM when directly providing a channel
self._channel = channel
self._target = str(channel)
self._channel = wait_until_healthy(channel, self._grpc_health_timeout)
else:
self._target = f"{host}:{port}"
self._channel = grpc.insecure_channel(
self._target,
options=[
("grpc.max_receive_message_length", pygeom_defaults.MAX_MESSAGE_LENGTH),
("grpc.max_send_message_length", pygeom_defaults.MAX_MESSAGE_LENGTH),
],
)

# do not finish initialization until channel is healthy
self._grpc_health_timeout = timeout
wait_until_healthy(self._channel, self._grpc_health_timeout)
self._channel = wait_until_healthy(self._target, self._grpc_health_timeout)

# Initialize the gRPC services
self._services = _GRPCServices(self._channel, version=proto_version)
Expand Down Expand Up @@ -263,9 +299,8 @@ def healthy(self) -> bool:
if self._closed:
return False
try:
wait_until_healthy(self._channel, self._grpc_health_timeout)
return True
except TimeoutError: # pragma: no cover
return self.services.admin.get_service_status().get("healthy")
except Exception: # pragma: no cover
return False

def __repr__(self) -> str:
Expand Down
Loading