Skip to content
Open
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
26 changes: 19 additions & 7 deletions docs/source/build-with-bentoml/services.rst
Original file line number Diff line number Diff line change
Expand Up @@ -326,30 +326,42 @@ 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.

BentoML operates by establishing a proxy service that directs all requests to the HTTP server initiated by the custom command. The default proxy port is ``8000``, specify a different one if the custom command is listening on another port:

.. 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

Expand All @@ -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
Expand Down
12 changes: 7 additions & 5 deletions docs/source/get-started/async-task-queues.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------------
Expand All @@ -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.")
Expand All @@ -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()
Expand Down
4 changes: 1 addition & 3 deletions src/_bentoml_impl/client/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/_bentoml_sdk/service/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
4 changes: 3 additions & 1 deletion src/bentoml/_internal/utils/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
65 changes: 65 additions & 0 deletions tests/e2e/bento_new_sdk/test_tasks.py
Original file line number Diff line number Diff line change
@@ -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"}
16 changes: 16 additions & 0 deletions tests/e2e/fixtures/tasks/service.py
Original file line number Diff line number Diff line change
@@ -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())
63 changes: 63 additions & 0 deletions tests/unit/_internal/utils/test_filesystem.py
Original file line number Diff line number Diff line change
@@ -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)
Loading