Skip to content

Commit 79d8aed

Browse files
committed
Project import generated by Copybara.
GitOrigin-RevId: 0a53a4498cb42e9f50aafc488b055e30f155a7c8
1 parent 7ad1a62 commit 79d8aed

1 file changed

Lines changed: 67 additions & 24 deletions

File tree

13_sandboxes/sandbox_pool.py

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
# a Sandbox with enough time left.
2323
#
2424
# Each Sandbox is configured with a
25-
# [readiness probe](https://modal.com/docs/guide/sandbox#readiness-probes) so we can
25+
# [readiness probe](https://modal.com/docs/guide/sandboxes#readiness-probes) so we can
2626
# reliably wait for the server to be ready before adding it to the pool.
2727
#
2828
# It's structured into two Apps:
@@ -41,7 +41,11 @@
4141

4242
import modal
4343

44-
app = modal.App("example-sandbox-pool")
44+
APP_NAME = "example-sandbox-pool"
45+
SANDBOX_APP_NAME = "example-sandbox-pool-sandboxes"
46+
POOL_QUEUE_NAME = "example-sandbox-pool-queue"
47+
48+
app = modal.App(APP_NAME)
4549

4650
server_image = modal.Image.debian_slim(python_version="3.11").uv_pip_install(
4751
"fastapi[standard]~=0.115.14",
@@ -61,7 +65,9 @@
6165
# 2 minutes, meaning that if a Sandbox has less than 2 minutes left it's considered
6266
# to be expiring too soon and will be terminated.
6367
#
64-
# You'll want to adjust these values depending on your use case.
68+
# You'll want to adjust these values depending on your use case. We don't set
69+
# `idle_timeout`: pooled Sandboxes are idle by definition, so it would terminate them
70+
# before they can be claimed.
6571
SANDBOX_TIMEOUT_SECONDS = 5 * 60
6672
SANDBOX_USE_DURATION_SECONDS = 2 * 60
6773
POOL_SIZE = 3
@@ -71,11 +77,12 @@
7177
# ## Main implementation
7278

7379
# We keep track of all warm Sandboxes in a Modal Queue of `SandboxReference` objects.
74-
pool_queue = modal.Queue.from_name(
75-
"example-sandbox-pool-sandboxes", create_if_missing=True
76-
)
80+
pool_queue = modal.Queue.from_name(POOL_QUEUE_NAME, create_if_missing=True)
7781

7882

83+
# Modal doesn't expose a Sandbox's remaining lifetime, so we track it ourselves.
84+
# `expires_at` is approximate: it's computed after `create` returns, and only
85+
# accounts for the wall-clock timeout, not other ways a Sandbox can die.
7986
@dataclass
8087
class SandboxReference:
8188
id: str
@@ -85,11 +92,13 @@ class SandboxReference:
8592

8693
# ### Health check
8794
#
88-
# We add a simple health check that just ensures that the server in the Sandbox is
89-
# running and responding to requests.
95+
# We run a health check to determine 3 types of statuses: readiness
96+
# (`wait_until_ready`, once at creation), health (`is_healthy`, below), and remaining
97+
# lifetime (`expires_at`, not health at all). `is_still_good` combines the last two.
9098
#
91-
# If you just want to ensure the sandbox is running you could for example check
92-
# `sb.poll() is not None` instead.
99+
# `is_healthy` returns false on three types of failures: the Sandbox is gone, the server
100+
# crashed, or the Tunnel is flaky. To tell them apart, check the Sandbox itself
101+
# (`sb.poll()` returns `None` while it's running, i.e. not finished).
93102
def is_healthy(url: str) -> bool:
94103
"""Check if a Sandbox is healthy by verifying the server responds to requests."""
95104
import requests
@@ -128,9 +137,7 @@ def is_still_good(sr: SandboxReference, check_health: bool) -> bool:
128137
@app.function(image=server_image, retries=3)
129138
@modal.concurrent(max_inputs=20)
130139
def add_sandbox_to_queue() -> None:
131-
sandbox_app = modal.App.lookup(
132-
"example-sandbox-pool-sandboxes", create_if_missing=True
133-
)
140+
sandbox_app = modal.App.lookup(SANDBOX_APP_NAME, create_if_missing=True)
134141

135142
sandbox_cmd = ["python", "-m", "http.server", "8080"]
136143
sb = modal.Sandbox.create(
@@ -144,11 +151,28 @@ def add_sandbox_to_queue() -> None:
144151
),
145152
)
146153
expires_at = int(time.time()) + SANDBOX_TIMEOUT_SECONDS
147-
sb.wait_until_ready(timeout=READINESS_PROBE_TIMEOUT_SECONDS)
148-
url = sb.tunnels()[SANDBOX_SERVER_PORT].url
149154

150-
pool_queue.put(SandboxReference(id=sb.object_id, url=url, expires_at=expires_at))
151-
sb.detach()
155+
# A failed probe or tunnel lookup doesn't terminate the Sandbox, so we do it here.
156+
# Otherwise it keeps running untracked until its timeout expires, and `retries=3`
157+
# above turns each invocation into up to four orphans.
158+
pooled = False
159+
try:
160+
sb.wait_until_ready(timeout=READINESS_PROBE_TIMEOUT_SECONDS)
161+
url = sb.tunnels()[SANDBOX_SERVER_PORT].url
162+
pool_queue.put(
163+
SandboxReference(id=sb.object_id, url=url, expires_at=expires_at)
164+
)
165+
pooled = True
166+
except modal.exception.TimeoutError as exc:
167+
print(f"Sandbox '{sb.object_id}' timed out before it was ready: {exc}")
168+
raise # let the Function's retries create a fresh Sandbox
169+
except modal.exception.ConflictError as exc:
170+
print(f"Sandbox '{sb.object_id}' finished before it was ready: {exc}")
171+
raise
172+
finally:
173+
if not pooled:
174+
sb.terminate()
175+
sb.detach()
152176

153177

154178
# We also have a utility function that can be `.spawn()`ed to terminate Sandboxes.
@@ -176,6 +200,11 @@ def terminate_sandboxes(sandbox_ids: list[str]) -> int:
176200
#
177201
# The Web Function proxies to `claim_sandbox` using a `.local()` invocation,
178202
# which runs in the same container without additional latency.
203+
#
204+
# Health checks run before the URL is returned, yet a claimed Sandbox can still die or
205+
# expire before the caller connects. `SANDBOX_USE_DURATION_SECONDS` buffers against
206+
# expiry, but callers should be ready to claim again on a connection error. Passing
207+
# `check_health=false` bypasses the health check and may return a dead Sandbox.
179208

180209

181210
@app.function(image=server_image)
@@ -303,9 +332,7 @@ def check():
303332
#
304333
# Run it with `python 13_sandboxes/sandbox_pool.py claim`.
305334
def claim() -> None:
306-
deployed_claim_sandbox = modal.Function.from_name(
307-
"example-sandbox-pool", "claim_sandbox"
308-
)
335+
deployed_claim_sandbox = modal.Function.from_name(APP_NAME, "claim_sandbox")
309336
print(deployed_claim_sandbox.remote())
310337

311338

@@ -323,9 +350,7 @@ def demo():
323350
check()
324351

325352
print("\nClaiming a Sandbox using the `claim_sandbox` Function...")
326-
deployed_claim_sandbox = modal.Function.from_name(
327-
"example-sandbox-pool", "claim_sandbox"
328-
)
353+
deployed_claim_sandbox = modal.Function.from_name(APP_NAME, "claim_sandbox")
329354
sandbox_url = deployed_claim_sandbox.remote()
330355
print(f"Claimed Sandbox URL: {sandbox_url}")
331356

@@ -338,7 +363,7 @@ def demo():
338363
check()
339364

340365
deployed_web_function = modal.Function.from_name(
341-
"example-sandbox-pool", "claim_sandbox_web_function"
366+
APP_NAME, "claim_sandbox_web_function"
342367
)
343368
claim_url = deployed_web_function.get_web_url()
344369
print(f"\nClaiming a Sandbox using the Function at '{claim_url}'...")
@@ -354,6 +379,24 @@ def demo():
354379
time.sleep(2)
355380
check()
356381

382+
print("\nWhen you're done, stop the App to clean up:")
383+
print(f" modal app stop {APP_NAME}")
384+
385+
386+
# ### Clean up
387+
#
388+
# `deploy` and `demo` leave the App deployed, so `maintain_pool` keeps refilling the
389+
# pool. Stopping it halts the schedule, after which the Sandboxes expire on their own
390+
# within `SANDBOX_TIMEOUT_SECONDS`:
391+
#
392+
# ```
393+
# modal app stop example-sandbox-pool
394+
# modal queue delete example-sandbox-pool-queue
395+
# ```
396+
#
397+
# See [Managing deployments](https://modal.com/docs/guide/managing-deployments) for
398+
# more on stopping Apps.
399+
357400

358401
def main():
359402
parser = argparse.ArgumentParser(description="Manage Sandbox pool")

0 commit comments

Comments
 (0)