Skip to content

Commit 3cd820c

Browse files
feat(serverless): run the user initializer alongside job intake
Startup work placed before runpod.serverless.start() ran outside the SDK's view: a model load that hung or crashed left requests sitting in IN_QUEUE until the TTL expired, with the reason buried in worker logs. Accept an optional initializer and init_timeout. The worker now runs the initializer as a fourth concurrent task, keeps taking requests, and holds handler execution until initialization finishes. If initialization fails, the worker reports the reason and its captured logs against the request it is holding through the existing job-done route, fails any request a long-poll returns afterwards, and exits so the platform respawns it under existing backoff.
1 parent d65dcdb commit 3cd820c

4 files changed

Lines changed: 609 additions & 3 deletions

File tree

‎runpod/serverless/__init__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,12 @@ def start(config: Dict[str, Any]):
145145
config["handler"] (Callable): The handler function to run.
146146
147147
config["rp_args"] (Dict[str, Any]): Arguments for the worker, populated by runtime arguments.
148+
149+
config["initializer"] (Callable, optional): Startup work that runs alongside job intake.
150+
The worker holds handler execution until the initializer finishes.
151+
152+
config["init_timeout"] (int, optional): Seconds to allow the initializer before
153+
treating it as a failure. Omit for no timeout.
148154
"""
149155
print(f"--- Starting Serverless Worker | Version {runpod_version} ---")
150156

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""
2+
runpod | serverless | initializer
3+
4+
Runs the user's startup initialization code concurrently with the job loop.
5+
The loop may take a request right away, but the handler is not called until the initializer
6+
finishes. On failure or timeout, the error + stdout/stderr are attached to
7+
the current request.
8+
9+
A sync/blocking initializer is offloaded to a worker thread so it does not starve the
10+
loop; an async one is awaited directly.
11+
"""
12+
13+
import asyncio
14+
import contextlib
15+
import contextvars
16+
import inspect
17+
import threading
18+
import traceback
19+
from collections.abc import Callable
20+
from typing import Any
21+
22+
from runpod.serverless.modules.rp_capture import MAX_CAPTURED_CHARS, clip
23+
from runpod.serverless.modules.rp_logger import RunPodLogger
24+
from runpod.serverless.modules.worker_state import WORKER_ID
25+
from runpod.version import __version__ as runpod_version
26+
27+
log = RunPodLogger()
28+
29+
INIT_FAILED_EVENT = "init_failed"
30+
31+
32+
class InitializerTimeout(Exception):
33+
"""Raised when the initializer exceeds `init_timeout`."""
34+
35+
36+
class InitializerError(Exception):
37+
"""Wraps any exception raised by the user's initializer."""
38+
39+
def __init__(self, original: BaseException):
40+
self.original = original
41+
super().__init__(str(original))
42+
43+
44+
def build_init_failed_payload(exc: BaseException, logs: str = "") -> dict[str, Any]:
45+
"""Failure reason as a structured dict, using the same core fields as a handler error
46+
(type, message, traceback). `logs` contains stdout/stderr."""
47+
original = getattr(exc, "original", exc)
48+
payload = {
49+
"event": INIT_FAILED_EVENT,
50+
"phase": "init",
51+
"error_type": type(original).__name__,
52+
"error_message": clip(str(original)),
53+
"error_traceback": clip(
54+
"".join(
55+
traceback.format_exception(
56+
type(original), original, original.__traceback__
57+
)
58+
)
59+
),
60+
"worker_id": WORKER_ID,
61+
"runpod_version": runpod_version,
62+
}
63+
if logs:
64+
payload["logs"] = logs[-MAX_CAPTURED_CHARS:]
65+
return payload
66+
67+
68+
async def _run_sync_in_daemon(fn: Callable) -> Any:
69+
"""Run a blocking callable on a daemon thread instead of `asyncio.to_thread` so if stuck,
70+
it can be abandoned and die without blocking executor shutdown or process exit."""
71+
loop = asyncio.get_running_loop()
72+
done = asyncio.Event()
73+
results: list[Any] = []
74+
errors: list[BaseException] = []
75+
ctx = contextvars.copy_context()
76+
77+
def worker():
78+
try:
79+
results.append(ctx.run(fn))
80+
except Exception as exc: # noqa: BLE001 - transferred to the event loop below
81+
errors.append(exc)
82+
except (
83+
KeyboardInterrupt,
84+
SystemExit,
85+
GeneratorExit,
86+
asyncio.CancelledError,
87+
) as exc:
88+
errors.append(exc)
89+
finally:
90+
with contextlib.suppress(RuntimeError):
91+
loop.call_soon_threadsafe(done.set)
92+
93+
threading.Thread(target=worker, name="rp-initializer", daemon=True).start()
94+
await done.wait()
95+
if errors:
96+
raise errors[0]
97+
return results[0] if results else None
98+
99+
100+
async def _invoke_initializer(initializer: Callable) -> None:
101+
if inspect.iscoroutinefunction(initializer) or inspect.iscoroutinefunction(
102+
initializer.__call__
103+
):
104+
result = initializer()
105+
else:
106+
result = await _run_sync_in_daemon(initializer)
107+
108+
if inspect.isawaitable(result):
109+
await result
110+
111+
112+
async def run_initializer_async(
113+
initializer: Callable, timeout: int | None = None
114+
) -> None:
115+
"""Run the initializer to completion inside the running event loop, raising
116+
`InitializerTimeout` on timeout or `InitializerError` for any other failure."""
117+
log.info("Initializer | init started")
118+
try:
119+
awaitable = _invoke_initializer(initializer)
120+
if timeout is not None:
121+
await asyncio.wait_for(awaitable, timeout=timeout)
122+
else:
123+
await awaitable
124+
except asyncio.TimeoutError as exc:
125+
raise InitializerTimeout(
126+
f"initializer exceeded init_timeout of {timeout}s"
127+
) from exc
128+
except (InitializerError, InitializerTimeout):
129+
raise
130+
except SystemExit as exc:
131+
raise InitializerError(exc) from exc
132+
except Exception as exc:
133+
raise InitializerError(exc) from exc
134+
log.info("Initializer | ready")

‎runpod/serverless/modules/rp_scale.py‎

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@
44
"""
55

66
import asyncio
7+
import json
78
import signal
89
import sys
910
import traceback
1011
from typing import Any, Dict, Set
1112

1213
from ...http_client import AsyncClientSession, ClientSession, TooManyRequests
14+
from .rp_capture import capture
15+
from .rp_http import send_result
1316
from .rp_job import _job_stop_url, get_job, get_stop_signals, handle_job
1417
from .rp_logger import RunPodLogger, _reset_batch_id, _set_batch_id
1518
from .worker_state import JobsProgress, IS_LOCAL_TEST
@@ -44,6 +47,8 @@ class JobScaler:
4447

4548
def __init__(self, config: Dict[str, Any]):
4649
self._shutdown_event = asyncio.Event()
50+
self._init_ready = asyncio.Event()
51+
self._init_error: dict[str, Any] | None = None
4752
self.current_concurrency = 1
4853
self.config = config
4954
self.job_progress = JobsProgress() # Cache the singleton instance
@@ -139,14 +144,26 @@ async def run(self):
139144
# Create an async session that will be closed when the worker is killed.
140145
async with AsyncClientSession() as session:
141146
# Create the worker's concurrent loops.
147+
init_task = asyncio.create_task(self._run_init())
142148
jobtake_task = asyncio.create_task(self.get_jobs(session))
143149
jobrun_task = asyncio.create_task(self.run_jobs(session))
144150
jobstop_task = asyncio.create_task(self.monitor_stop_signals(session))
145151

146-
tasks = [jobtake_task, jobrun_task, jobstop_task]
152+
# The initializer is not a loop: an initializer with no init_timeout can
153+
# block forever, so only the request loops decide when the worker stops.
154+
await asyncio.gather(jobtake_task, jobrun_task, jobstop_task)
147155

148-
# Run the worker's concurrent loops until shutdown.
149-
await asyncio.gather(*tasks)
156+
# Shutting down: abandon a still-running initializer. Its work sits on a
157+
# daemon thread, so dropping it here lets the process exit.
158+
init_task.cancel()
159+
try:
160+
await init_task
161+
except asyncio.CancelledError:
162+
pass
163+
164+
# If the initializer fails, let the platform respawn the worker.
165+
if self._init_error is not None:
166+
sys.exit(1)
150167

151168
def is_alive(self):
152169
"""
@@ -179,6 +196,10 @@ async def get_jobs(self, session: ClientSession):
179196
Adds jobs to the JobsQueue
180197
"""
181198
while self.is_alive():
199+
if self._init_error is not None:
200+
# Initialization is terminal for this worker. Request draining is
201+
# owned by run_jobs; job-take has no more work to do.
202+
return
182203
await self.set_scale()
183204

184205
jobs_needed = self.current_concurrency - self.current_occupancy()
@@ -200,6 +221,12 @@ async def get_jobs(self, session: ClientSession):
200221
log.debug("JobScaler.get_jobs | No jobs acquired.")
201222
continue
202223

224+
if self._init_error is not None:
225+
# If initialization fails, fail all in-flight requests.
226+
for job in acquired_jobs:
227+
await self._fail_job(session, job, self._init_error)
228+
return
229+
203230
for job in acquired_jobs:
204231
await self.jobs_queue.put(job)
205232
self.job_progress.add(job)
@@ -346,6 +373,21 @@ async def handle_job(self, session: ClientSession, job: dict):
346373
try:
347374
log.debug("Handling Job", job["id"])
348375

376+
# Hold the handler until initialization finishes.
377+
if self.config.get("initializer") is not None:
378+
if not await self._wait_for_init():
379+
log.warn(
380+
"Shutting down before initialization finished; leaving this "
381+
"request for another worker.",
382+
job["id"],
383+
)
384+
return
385+
386+
if self._init_error is not None:
387+
# If initialization fails, fail the current request and don't run the handler.
388+
await self._fail_job(session, job, self._init_error)
389+
return
390+
349391
await self.jobs_handler(session, self.config, job)
350392

351393
if self.config.get("refresh_worker", False):
@@ -369,3 +411,57 @@ async def handle_job(self, session: ClientSession, job: dict):
369411

370412
log.debug("Finished Job", job["id"])
371413
_reset_batch_id(batch_id_token)
414+
415+
async def _wait_for_init(self) -> bool:
416+
"""Wait for initialization to finish, or for the worker to start shutting down.
417+
Returns whether initialization actually finished."""
418+
ready = asyncio.create_task(self._init_ready.wait())
419+
stopping = asyncio.create_task(self._shutdown_event.wait())
420+
try:
421+
await asyncio.wait({ready, stopping}, return_when=asyncio.FIRST_COMPLETED)
422+
finally:
423+
ready.cancel()
424+
stopping.cancel()
425+
return self._init_ready.is_set()
426+
427+
async def _fail_job(
428+
self, session: ClientSession, job: dict, payload: Dict[str, Any]
429+
):
430+
"""Fail a request with a structured error (reason + logs)."""
431+
log.error(f"Failing job due to init failure. | {job['id']}")
432+
await send_result(session, {"error": json.dumps(payload)}, job, is_stream=False)
433+
434+
async def _run_init(self):
435+
"""Run initializer concurrently with the loop. Upon completion, opens the gate to run
436+
job handlers. On failure, records the reason, then drains and shuts the worker."""
437+
initializer = self.config.get("initializer")
438+
if initializer is None:
439+
self._init_ready.set()
440+
return
441+
442+
from .rp_initializer import (
443+
InitializerError,
444+
InitializerTimeout,
445+
build_init_failed_payload,
446+
run_initializer_async,
447+
)
448+
449+
try:
450+
with capture() as cap:
451+
try:
452+
await run_initializer_async(
453+
initializer, self.config.get("init_timeout")
454+
)
455+
except (InitializerError, InitializerTimeout) as exc:
456+
self._init_error = build_init_failed_payload(exc, cap.getvalue())
457+
if self._init_error is not None:
458+
log.error(f"init_failed | {json.dumps(self._init_error)}")
459+
finally:
460+
# Always release held handlers.
461+
self._init_ready.set()
462+
463+
if self._init_error is not None:
464+
# Stop long-running loops before waiting for acquired requests to drain.
465+
self.kill_worker()
466+
while self.current_occupancy() > 0:
467+
await asyncio.sleep(0.1)

0 commit comments

Comments
 (0)