Skip to content

Commit 9bfe68b

Browse files
authored
fix(sdk): Various improvements to SDK UX (#136)
#### Relevant issue or PR n/a #### Description of changes Various bits and pieces that improve UX when working with the Tesseract SDK + Python API. - Poll more aggressively when serving Tesseracts to reduce startup delay. - Improve error message when trying to serve a Tesseract multiple times. - Pass a fixed serving port to docker-compose. This fixes a bug where the port would change after a Tesseract restarts on failure, making the restarted container unreachable. - Ensure served Tesseracts are always torn down when Python exits (e.g. when we exit before Tesseract objects go out of scope). This fixes a bug where `__del__` was called too late (internal machinery was already torn down), so `teardown` would fail, leaving dangling resources that even persist after reboot. - Disable served Tesseracts to restart even after restarting the Docker daemon (such as during a system reboot). Now we only restart on error, but not if the daemon is stopped. - Allow string input paths to `tesseract_core.build_tesseract`. #### Testing done Manual #### License - [x] By submitting this pull request, I confirm that my contribution is made under the terms of the [Apache 2.0 license](https://pasteurlabs.github.io/tesseract/LICENSE). - [x] I sign the Developer Certificate of Origin below by adding my name and email address to the `Signed-off-by` line. <details> <summary><b>Developer Certificate of Origin</b></summary> ```text Developer Certificate of Origin Version 1.1 Copyright (C) 2004, 2006 The Linux Foundation and its contributors. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Developer's Certificate of Origin 1.1 By making a contribution to this project, I certify that: (a) The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file; or (b) The contribution is based upon previous work that, to the best of my knowledge, is covered under an appropriate open source license and I have the right under that license to submit that work with modifications, whether created in whole or in part by me, under the same open source license (unless I am permitted to submit under a different license), as indicated in the file; or (c) The contribution was provided directly to me by some other person who certified (a), (b) or (c) and I have not modified it. (d) I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open source license(s) involved. ``` </details> Signed-off-by: Dion Häfner <dion.haefner@simulation.science>
1 parent 0656332 commit 9bfe68b

File tree

7 files changed

+62
-37
lines changed

7 files changed

+62
-37
lines changed

tesseract_core/sdk/cli.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -356,10 +356,10 @@ def init(
356356
engine.init_api(target_dir, name, recipe=recipe)
357357

358358

359-
def _validate_port(port: str | None) -> str:
359+
def _validate_port(port: str | None) -> str | None:
360360
"""Validate port input."""
361361
if port is None:
362-
return ""
362+
return None
363363

364364
port = port.strip()
365365
if "-" in port:
@@ -438,19 +438,23 @@ def serve(
438438
command, as well as a list of all containers spawned and their respective
439439
ports.
440440
"""
441-
if port and len(image_names) > 1:
442-
# TODO: Docker compose with multiple ports is not supported until
443-
# docker/compose#7188 is resolved.
444-
raise typer.BadParameter(
445-
(
446-
"Port specification only works if 1 Tesseract is being served."
447-
f"Currently serving `{len(image_names)}` Tesseracts."
448-
),
449-
param_hint="image_names",
450-
)
441+
if port is not None:
442+
if len(image_names) > 1:
443+
# TODO: Docker compose with multiple ports is not supported until
444+
# docker/compose#7188 is resolved.
445+
raise typer.BadParameter(
446+
(
447+
"Port specification only works if exactly one Tesseract is being served. "
448+
f"Currently serving `{len(image_names)}` Tesseracts."
449+
),
450+
param_hint="image_names",
451+
)
452+
ports = [port]
453+
else:
454+
ports = None
451455

452456
try:
453-
project_id = engine.serve(image_names, port, volume, gpus)
457+
project_id = engine.serve(image_names, ports, volume, gpus)
454458
containers = engine.project_containers(project_id)
455459
_display_container_meta(containers)
456460
logger.info(

tesseract_core/sdk/engine.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,16 @@ def wrapper_needs_docker(*args: Any, **kwargs: Any) -> None:
124124
return wrapper_needs_docker
125125

126126

127+
def get_free_port() -> int:
128+
"""Find a free port to use for HTTP."""
129+
import socket
130+
from contextlib import closing
131+
132+
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
133+
s.bind(("localhost", 0))
134+
return s.getsockname()[1]
135+
136+
127137
def parse_requirements(
128138
filename: str | Path,
129139
session: PipSession | None = None,
@@ -417,7 +427,7 @@ def init_api(
417427

418428

419429
def build_tesseract(
420-
src_dir: Path,
430+
src_dir: str | Path,
421431
image_tag: str | None,
422432
build_dir: Path | None = None,
423433
inject_ssh: bool = False,
@@ -443,6 +453,8 @@ def build_tesseract(
443453
docker.models.images.Image representing the built Tesseract image,
444454
or path to build directory if `generate_only` is True.
445455
"""
456+
src_dir = Path(src_dir)
457+
446458
validate_tesseract_api(src_dir)
447459
config = get_config(src_dir)
448460

@@ -567,7 +579,7 @@ def _is_tesseract(
567579

568580
def serve(
569581
images: list[str | docker.models.images.Image],
570-
port: str = "",
582+
ports: list[str] | None = None,
571583
volumes: list[str] | None = None,
572584
gpus: list[str] | None = None,
573585
debug: bool = False,
@@ -579,7 +591,7 @@ def serve(
579591
Args:
580592
images: a list of Tesseract image IDs as strings or `docker`'s
581593
Image object.
582-
port: port or port range to serve the tesseract on.
594+
ports: port or port range to serve each Tesseract on.
583595
volumes: list of paths to mount in the Tesseract container.
584596
gpus: IDs of host Nvidia GPUs to make available to the Tesseracts.
585597
debug: whether to enable debug mode.
@@ -606,7 +618,12 @@ def serve(
606618
raise ValueError(f"Input ID {image.id} is not a valid Tesseract")
607619
image_ids.append(image.id)
608620

609-
template = _create_docker_compose_template(image_ids, port, volumes, gpus, debug)
621+
if ports is not None and len(ports) != len(image_ids):
622+
raise ValueError(
623+
f"Number of ports ({len(ports)}) must match number of images ({len(image_ids)})"
624+
)
625+
626+
template = _create_docker_compose_template(image_ids, ports, volumes, gpus, debug)
610627
compose_fname = _create_compose_fname()
611628

612629
with tempfile.NamedTemporaryFile(
@@ -684,17 +701,15 @@ def _docker_compose_up(compose_fpath: str, project_name: str) -> bool:
684701

685702
def _create_docker_compose_template(
686703
image_ids: list[str],
687-
port: str = "",
704+
ports: list[str] | None = None,
688705
volumes: list[str] | None = None,
689706
gpus: list[str] | None = None,
690707
debug: bool = False,
691708
) -> str:
692709
"""Create Docker Compose template."""
693710
services = []
694-
if not port:
695-
port = "8000"
696-
else:
697-
port = f"{port}:8000"
711+
if ports is None:
712+
ports = [str(get_free_port()) for _ in range(len(image_ids))]
698713

699714
gpu_settings = None
700715
if gpus:
@@ -703,11 +718,11 @@ def _create_docker_compose_template(
703718
else:
704719
gpu_settings = f"device_ids: {gpus}"
705720

706-
for image_id in image_ids:
721+
for image_id, port in zip(image_ids, ports, strict=True):
707722
service = {
708723
"name": _create_compose_service_id(image_id),
709724
"image": image_id,
710-
"port": port,
725+
"port": f"{port}:8000",
711726
"volumes": volumes,
712727
"gpus": gpu_settings,
713728
"environment": {

tesseract_core/sdk/templates/docker-compose.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ services:
22
{% for service in services %}
33
{{ service.name }}:
44
image: {{ service.image }}
5-
restart: always
5+
restart: unless-stopped
66
entrypoint: tesseract-runtime serve
77
ports:
88
- {{ service.port }}
@@ -14,8 +14,8 @@ services:
1414
{% endif %}
1515
healthcheck:
1616
test: python -c "import requests; requests.get('http://localhost:8000/health').raise_for_status()" || exit 1
17-
interval: 3s
18-
retries: 10
17+
interval: 0.1s
18+
retries: 300
1919
start_period: 1s
2020
networks:
2121
- multi-tesseract-network

tesseract_core/sdk/tesseract.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# SPDX-License-Identifier: Apache-2.0
33
from __future__ import annotations
44

5+
import atexit
56
import base64
67
import json
78
import subprocess
@@ -173,9 +174,7 @@ def __enter__(self) -> Tesseract:
173174
This will start the Tesseract server if it is not already running.
174175
"""
175176
if self._serve_context is not None:
176-
raise RuntimeError(
177-
"Cannot nest the same `with Tesseract ...` context manager."
178-
)
177+
raise RuntimeError("Cannot serve the same Tesseract multiple times.")
179178

180179
if self._client is not None:
181180
# Tesseract is already being served -> no-op
@@ -208,7 +207,7 @@ def server_logs(self) -> str:
208207
return self._lastlog
209208
return engine.logs(self._serve_context["container_id"])
210209

211-
def serve(self, port: str = "") -> None:
210+
def serve(self, port: str | None = None) -> None:
212211
"""Serve the Tesseract.
213212
214213
Args:
@@ -232,6 +231,7 @@ def serve(self, port: str = "") -> None:
232231
)
233232
self._lastlog = None
234233
self._client = HTTPClient(f"http://localhost:{served_port}")
234+
atexit.register(self.teardown)
235235

236236
def teardown(self) -> None:
237237
"""Teardown the Tesseract.
@@ -244,6 +244,7 @@ def teardown(self) -> None:
244244
engine.teardown(self._serve_context["project_id"])
245245
self._client = None
246246
self._serve_context = None
247+
atexit.unregister(self.teardown)
247248

248249
def __del__(self) -> None:
249250
"""Destructor for the Tesseract class.
@@ -256,13 +257,18 @@ def __del__(self) -> None:
256257
@staticmethod
257258
def _serve(
258259
image: str,
259-
port: str = "",
260+
port: str | None = None,
260261
volumes: list[str] | None = None,
261262
gpus: list[str] | None = None,
262263
debug: bool = False,
263264
) -> tuple[str, str, int]:
265+
if port is not None:
266+
ports = [port]
267+
else:
268+
ports = None
269+
264270
project_id = engine.serve(
265-
[image], port=port, volumes=volumes, gpus=gpus, debug=debug
271+
[image], ports=ports, volumes=volumes, gpus=gpus, debug=debug
266272
)
267273

268274
command = ["docker", "compose", "-p", project_id, "ps", "--format", "json"]

tests/endtoend_tests/test_endtoend.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ def test_tesseract_serve_ports_error(built_image_name):
272272
)
273273
assert run_res.exit_code
274274
assert (
275-
"Port specification only works if 1 Tesseract is being served."
275+
"Port specification only works if exactly one Tesseract is being served."
276276
in run_res.stderr
277277
)
278278

tests/endtoend_tests/test_tesseract_sdk.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def test_apply(built_image_name, dummy_tesseract_location, free_port):
4040

4141
# Test URL access
4242
tesseract_url = f"http://localhost:{free_port}"
43-
served_tesseract = engine.serve([built_image_name], port=str(free_port))
43+
served_tesseract = engine.serve([built_image_name], ports=[str(free_port)])
4444
try:
4545
vecadd = Tesseract(tesseract_url)
4646
out = vecadd.apply(inputs)
@@ -102,7 +102,7 @@ def served_tesseract_remote(built_image_name):
102102
sock.close()
103103
# Serve the Tesseract image
104104
tesseract_url = f"http://localhost:{free_port}"
105-
served_tesseract = engine.serve([built_image_name], port=str(free_port))
105+
served_tesseract = engine.serve([built_image_name], ports=[str(free_port)])
106106
try:
107107
yield tesseract_url
108108
finally:

tests/sdk_tests/test_tesseract.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ def test_serve_lifecycle(mock_serving, mock_clients):
107107
pass
108108

109109
mock_serving["serve_mock"].assert_called_with(
110-
["sometesseract:0.2.3"], port="", volumes=None, gpus=None, debug=True
110+
["sometesseract:0.2.3"], ports=None, volumes=None, gpus=None, debug=True
111111
)
112112

113113
mock_serving["teardown_mock"].assert_called_with("proj-id-123")

0 commit comments

Comments
 (0)