diff --git a/docs/source/build-with-bentoml/services.rst b/docs/source/build-with-bentoml/services.rst index c39f941b42a..dcda0f00da6 100644 --- a/docs/source/build-with-bentoml/services.rst +++ b/docs/source/build-with-bentoml/services.rst @@ -326,22 +326,34 @@ In some cases, you may want your Service to start using a custom process (for ex "uvicorn", "myapp:app", "--host", - "$BENTOML_HOST", + "127.0.0.1", "--port", - "$PORT", - ] + "8000", # must match http.proxy_port (default 8000) + ], + workers=1, ) class ExternalServer: pass +.. important:: + + The custom command must listen on ``http.proxy_port`` (``8000`` by default), because that is + where BentoML proxies requests. ``$VAR`` references in ``cmd`` are expanded from the process + environment, and BentoML does not define ``PORT`` or ``BENTOML_HOST`` itself — using them + without setting them (for example through ``envs``) fails at startup with + ``KeyError: 'PORT'``. + + Set ``workers=1`` unless the command can share a port. A Service with a custom command + defaults to ``min(16, cpu_count/2)`` workers, and only the first one starts the process. + Alternatively, compute the command at runtime: .. code-block:: python - @bentoml.service + @bentoml.service(workers=1) class ExternalServer: def __command__(self) -> list[str]: - return ["myserver", "--port", "$PORT"] + return ["myserver", "--port", "8000", "--model", self.model_path] Use this method when there are parameters whose values can only be determined at runtime. @@ -349,7 +361,7 @@ BentoML operates by establishing a proxy service that directs all requests to th .. code-block:: python - @bentoml.service(cmd=["myserver", "--port", "$PORT"], http={"proxy_port": 9000}) + @bentoml.service(cmd=["myserver", "--port", "9000"], http={"proxy_port": 9000}, workers=1) class ExternalServer: pass @@ -361,7 +373,7 @@ To achieve this, you can implement the ``__metrics__`` method in your Service cl .. code-block:: python - @bentoml.service(cmd=["myserver", "--port", "$PORT"]) + @bentoml.service(cmd=["myserver", "--port", "8000"], workers=1) class ExternalServer: def __metrics__(self, original_metrics: str) -> str: # Modify the original metrics as needed diff --git a/docs/source/get-started/async-task-queues.rst b/docs/source/get-started/async-task-queues.rst index 06b4722a2b9..1a55368bc32 100644 --- a/docs/source/get-started/async-task-queues.rst +++ b/docs/source/get-started/async-task-queues.rst @@ -45,8 +45,8 @@ Under the hood, BentoML automatically generates several endpoints for creating t - ``POST /submit``: Submit a task to the queue. A unique task identifier is returned immediately. - ``GET /status``: Get the status of a task given the task identifier. - ``GET /get``: Get the result of a task given the task identifier. -- ``POST /cancel``: Attempt to cancel a task given the task identifier, if the task hasn't started execution. -- ``PUT /retry``: Retry a task given the task identifier. +- ``PUT /cancel``: Attempt to cancel a task given the task identifier, if the task hasn't started execution. Not supported by the local development server. +- ``POST /retry``: Retry a task given the task identifier. Call a task endpoint -------------------- @@ -70,13 +70,15 @@ Async tasks can be submitted through the ``SyncHTTPClient`` or ``AsyncHTTPClient Once a task is submitted, the request is enqueued in the request queue and a unique task identifier is returned immediately, which can be used to get the status and retrieve the result. +The status is one of ``pending``, ``in_progress``, ``completed``, ``failed`` and ``canceled``. + .. code-block:: python # Use the following code at a later time status = task.get_status() - if status.value == 'success': + if status.value == 'completed': print("The task runs successfully. The result is", task.get()) - elif status.value == 'failure': + elif status.value == 'failed': print("The task run failed.") else: print("The task is still running.") @@ -86,7 +88,7 @@ Use ``retry()`` if a task fails or you need to rerun the task with the same para .. code-block:: python status = task.get_status() - if status.value == 'failure': + if status.value == 'failed': print("Task failed, retrying...") new_task = task.retry() new_status = new_task.get_status() diff --git a/src/_bentoml_impl/client/http.py b/src/_bentoml_impl/client/http.py index cfe64b4d779..b1235a37701 100644 --- a/src/_bentoml_impl/client/http.py +++ b/src/_bentoml_impl/client/http.py @@ -503,9 +503,7 @@ def _submit( data = resp.json() return Task(data["task_id"], __endpoint, self) finally: - for f in self._opened_files: - f.close() - self._opened_files.clear() + self._file_manager.close() def _get_task_result(self, __endpoint: ClientEndpoint, /, task_id: str) -> t.Any: resp = self.request( diff --git a/src/_bentoml_sdk/service/openapi.py b/src/_bentoml_sdk/service/openapi.py index 97b9d7ca8f2..0915eeb247b 100644 --- a/src/_bentoml_sdk/service/openapi.py +++ b/src/_bentoml_sdk/service/openapi.py @@ -85,7 +85,7 @@ def generate_spec(svc: Service[t.Any], *, openapi_version: str = "3.0.2"): class TaskStatusResponse(pydantic.BaseModel): task_id: str - status: t.Literal["in_progress", "success", "failure", "cancelled"] + status: t.Literal["pending", "in_progress", "completed", "failed", "canceled"] created_at: str executed_at: t.Optional[str] diff --git a/src/bentoml/_internal/utils/filesystem.py b/src/bentoml/_internal/utils/filesystem.py index c1a8e7006c1..c090ba26cc1 100644 --- a/src/bentoml/_internal/utils/filesystem.py +++ b/src/bentoml/_internal/utils/filesystem.py @@ -162,7 +162,9 @@ def resolve_user_filepath( raise ValueError( f"Accessing file outside of current working directory is not allowed: {_path}" ) - if any(part.startswith(".") for part in _path.parts): + # Only the part below cwd is user-supplied; an ancestor of cwd may legitimately be + # hidden (e.g. a bento built under BENTOML_HOME=~/.bentoml). + if any(part.startswith(".") for part in _path.relative_to(cwd).parts): raise ValueError(f"Accessing hidden files is not allowed: {_path}") if any(_path.is_relative_to(item) for item in ("/etc", "/proc")): raise ValueError(f"Accessing system files is not allowed: {_path}") diff --git a/tests/e2e/bento_new_sdk/test_tasks.py b/tests/e2e/bento_new_sdk/test_tasks.py new file mode 100644 index 00000000000..f17626cf333 --- /dev/null +++ b/tests/e2e/bento_new_sdk/test_tasks.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import time +import typing as t +from pathlib import Path + +import pytest + +import bentoml + +port = 35681 +TIMEOUT = 30 + + +def _wait_for_result(task: t.Any) -> str: + deadline = time.monotonic() + TIMEOUT + while time.monotonic() < deadline: + status = task.get_status().value + if status in ("completed", "failed", "canceled"): + return status + time.sleep(0.1) + raise AssertionError(f"task {task.id} still {task.get_status().value}") + + +def test_task_submit_status_get_retry(examples: Path) -> None: + with bentoml.serve(".", working_dir=str(examples / "tasks"), port=port) as server: + with bentoml.SyncHTTPClient(server.url, server_ready_timeout=100) as client: + task = client.shout.submit(text="hello") + assert _wait_for_result(task) == "completed" + assert task.get() == {"echo": "HELLO"} + + retried = task.retry() + assert retried.id != task.id + assert _wait_for_result(retried) == "completed" + assert retried.get() == {"echo": "HELLO"} + + +def test_task_submit_with_file(examples: Path, tmp_path: Path) -> None: + payload = tmp_path / "blob.txt" + payload.write_text("0123456789") + + with bentoml.serve( + ".", working_dir=str(examples / "tasks"), port=port + 1 + ) as server: + with bentoml.SyncHTTPClient(server.url, server_ready_timeout=100) as client: + task = client.measure.submit(blob=payload) + assert _wait_for_result(task) == "completed" + assert task.get() == 10 + + +@pytest.mark.asyncio +async def test_async_task_submit(examples: Path) -> None: + with bentoml.serve( + ".", working_dir=str(examples / "tasks"), port=port + 2 + ) as server: + async with bentoml.AsyncHTTPClient( + server.url, server_ready_timeout=100 + ) as client: + task = await client.shout.submit(text="async") + deadline = time.monotonic() + TIMEOUT + while time.monotonic() < deadline: + if (await task.get_status()).value == "completed": + break + time.sleep(0.1) + assert await task.get() == {"echo": "ASYNC"} diff --git a/tests/e2e/fixtures/tasks/service.py b/tests/e2e/fixtures/tasks/service.py new file mode 100644 index 00000000000..609d2c8c039 --- /dev/null +++ b/tests/e2e/fixtures/tasks/service.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from pathlib import Path + +import bentoml + + +@bentoml.service(workers=1) +class TaskService: + @bentoml.task + def shout(self, text: str) -> dict[str, str]: + return {"echo": text.upper()} + + @bentoml.task + def measure(self, blob: Path) -> int: + return len(blob.read_text()) diff --git a/tests/unit/_internal/utils/test_filesystem.py b/tests/unit/_internal/utils/test_filesystem.py new file mode 100644 index 00000000000..8e8168cea13 --- /dev/null +++ b/tests/unit/_internal/utils/test_filesystem.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import os + +import pytest + +from bentoml._internal.utils.filesystem import resolve_user_filepath + + +@pytest.fixture +def project(tmp_path, monkeypatch): + """A project directory that lives under a hidden ancestor, as a bento built with + BENTOML_HOME=~/.bentoml does.""" + root = tmp_path / ".hidden-home" / "proj" + (root / "sub").mkdir(parents=True) + (root / "requirements.txt").write_text("bentoml\n") + (root / ".secret").write_text("token\n") + (root / "sub" / ".env").write_text("KEY=value\n") + (tmp_path / "outside.txt").write_text("outside\n") + monkeypatch.chdir(root) + return root + + +def test_relative_path_under_hidden_ancestor(project): + assert resolve_user_filepath("requirements.txt", None) == str( + project / "requirements.txt" + ) + assert resolve_user_filepath("./requirements.txt", None) == str( + project / "requirements.txt" + ) + + +def test_relative_to_ctx(project): + (project / "sub" / "extra.txt").write_text("x\n") + assert resolve_user_filepath("extra.txt", os.path.join(str(project), "sub")) == str( + project / "sub" / "extra.txt" + ) + + +@pytest.mark.parametrize("path", [".secret", "sub/.env", "./.secret"]) +def test_hidden_files_below_cwd_are_rejected(project, path): + with pytest.raises(ValueError, match="hidden files"): + resolve_user_filepath(path, None) + + +def test_escaping_cwd_is_rejected(project): + with pytest.raises(ValueError, match="outside of current working directory"): + resolve_user_filepath("../../outside.txt", None) + + +def test_absolute_path_is_rejected(project): + with pytest.raises(ValueError, match="Absolute path"): + resolve_user_filepath(str(project / "requirements.txt"), None) + + +def test_absolute_path_allowed_when_insecure(project): + target = str(project / "requirements.txt") + assert resolve_user_filepath(target, None, secure=False) == target + + +def test_missing_file(project): + with pytest.raises(FileNotFoundError): + resolve_user_filepath("nope.txt", None)