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:
4141
4242import 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
4650server_image = modal .Image .debian_slim (python_version = "3.11" ).uv_pip_install (
4751 "fastapi[standard]~=0.115.14" ,
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.
6571SANDBOX_TIMEOUT_SECONDS = 5 * 60
6672SANDBOX_USE_DURATION_SECONDS = 2 * 60
6773POOL_SIZE = 3
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
8087class 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).
93102def 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 )
130139def 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`.
305334def 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 ("\n Claiming 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"\n Claiming a Sandbox using the Function at '{ claim_url } '..." )
@@ -354,6 +379,24 @@ def demo():
354379 time .sleep (2 )
355380 check ()
356381
382+ print ("\n When 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
358401def main ():
359402 parser = argparse .ArgumentParser (description = "Manage Sandbox pool" )
0 commit comments