feat: SLS-494 sdk supervises worker initialization code - #567
feat: SLS-494 sdk supervises worker initialization code#567jasonwang-runpod wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a supervised startup phase for serverless workers by adding an optional initializer callable and init_timeout to the serverless worker config, ensuring model/engine startup failures (or hangs) are surfaced quickly and the worker exits before taking jobs.
Changes:
- Added
rp_initializermodule to run an initializer with timeout handling, structuredinit_failedlogging, and best-effort platform reporting. - Updated the worker startup sequence to run initialization before starting the job loop.
- Added unit tests covering sync/async initializers, error wrapping, timeouts, reporting, and job-loop gating.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| tests/test_serverless/test_initializer.py | Adds unit tests for initializer execution, failure/timeout behavior, reporting, and gating before the job loop. |
| runpod/serverless/worker.py | Runs supervised initialization before starting the JobScaler/job loop. |
| runpod/serverless/modules/rp_initializer.py | Implements supervised initializer execution, timeout/error wrapping, structured logging, and best-effort reporting. |
| runpod/serverless/init.py | Documents new initializer and init_timeout config options in the serverless start() docstring. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
bugbot run |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (4)
runpod/serverless/modules/rp_job.py:347
run_job_generatorbounds the handler/traceback string viaclip(...), but then appends up to 16KB of captured logs afterward, which can defeat the intended bounding and inflate the streamed error payload. Consider clipping the final assembled error string instead.
error = clip(f"handler: {str(err)} \ntraceback: {traceback.format_exc()}")
if captured_logs:
error += f"\nlogs:\n{captured_logs}"
yield {"error": error}
runpod/serverless/modules/rp_initializer.py:96
- Docstring typo: “abandoned an die” should be “abandoned and die”.
"""Run a blocking callable on a daemon thread instead of `asyncio.to_thread` so if stuck,
it can be abandoned an die without blocking the executor shutdown and process exit."""
runpod/serverless/modules/rp_initializer.py:140
run_initializer_asynctreatstimeout=0as “no timeout” because it uses a truthiness check (if timeout:). If a user setsinit_timeout: 0expecting an immediate timeout, it will instead run unbounded.
if timeout:
await asyncio.wait_for(asyncio.ensure_future(awaitable), timeout=timeout)
else:
await awaitable
runpod/serverless/init.py:150
- The docstring says the
initializerruns “before the job loop begins”, but the implementation can still acquire jobs while the initializer runs (the gate only prevents the handler from executing). This wording is misleading for SDK users.
config["initializer"] (Callable, optional): Startup work before the job loop begins,
e.g. loading a model or starting an inference engine.
bbe3cb1 to
e28c5c2
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
runpod/serverless/modules/rp_initializer.py:133
- An initializer that raises
asyncio.TimeoutErroritself is always converted toInitializerTimeout; with no configured deadline this even reportsinitializer exceeded init_timeout of Nones. Track the invocation task and only classifyTimeoutErroras an SDK deadline whenwait_foractually cancelled that task, otherwise wrap the user's exception asInitializerError.
except asyncio.TimeoutError as exc:
raise InitializerTimeout(
f"initializer exceeded init_timeout of {timeout}s"
) from exc
runpod/serverless/modules/rp_http.py:127
- HTTP 4xx/5xx responses are currently consumed as if the handler-started signal succeeded, so the final server error is neither raised nor logged (and eligible failures may not be retried as intended). Pass
raise_for_status=True, matching_transmit, so the best-effort path still detects and logs rejected signals.
async with retry_client.post(
url,
data="{}",
headers={
"charset": "utf-8",
"Content-Type": "application/x-www-form-urlencoded",
},
) as client_response:
A handler that dies during a model load or a CUDA fault usually explains itself on stdout/stderr, but only the exception reached the platform, so the useful part was lost. Tee both streams into a per-context ring buffer and attach the tail to the error the worker reports. Every reported field is clipped so a huge message or log cannot push the job-done body past its limit.
7687e7d to
3cd820c
Compare
724fb11 to
7fc7681
Compare
|
|
||
| # If the initializer fails, let the platform respawn the worker. | ||
| if self._init_error is not None: | ||
| sys.exit(1) |
There was a problem hiding this comment.
my clanker tells me that sys.exit won't actually exit here if there are non-daemon threads still alive (e.g. ones vllm etc. spawn before init fails) — interpreter shutdown blocks joining them, so the worker can hang instead of exiting for respawn. probably should be os._exit(1) here like the fitness checks do (rp_fitness._terminate_unhealthy exists for exactly this reason)
9f85145 to
03c851c
Compare
03c851c to
573f3d2
Compare
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.
573f3d2 to
15caa9c
Compare
Problem
Currently, workers do their startup work (model load, engine start) before calling
runpod.serverless.start(), so the SDK never sees it. When that work hangs or crashes, the worker never takes a request - so the request is stuck inIN_QUEUEwith no obvious error until its TTL expires, and the reason is buried in worker logs. Users read this as "Runpod lost my request".Solution
Enable the SDK to supervise initialization code.
start()accepts an optionalinitializercallable andinit_timeout./job-doneroute, with the reason and the tail of the initializer's stdout/stderr, then exits so the platform respawns it under existing backoff.Every reported field is clipped so a large message or log shouldn't result in a large
job-donebody.Out of scope: Container failures (image pull, host OOM) have no request in hand and no SDK, so they cannot be reported per request.
Screenshots
Requests now fail fast and store init failure logs
Logs are still present in their own UI
Testing
uv run pytest: 674 passed.Ran real serverless endpoint tests on RTX 4090, with the SDK built from this branch. Validated that both init errors and handler errors now store failure logs on the request itself.
COMPLETED; a request was taken, held, and ran against an initialized workerFAILEDwithinit_failed+RuntimeError+ CUDA OOM text + traceback + stdout/stderrFAILED- the worker held no request yet, so it claimed one and failed it. Without doing this, the request waits out its TTL.init_timeout=20FAILEDwithInitializerTimeout,initializer exceeded init_timeout of 20sinit_timeoutIN_PROGRESSand is stuck until the platform'sexecutionTimeout exceeded. Omitting the timeout opts out of any SDK bound, by design.init_failed, classified ascuda_errorFAILEDwith the same reason within a second; none silently requeuedFAILEDwith the usual JSON error, now includes the stdout and stderrFAILEDafter one chunk; error keeps its original plain string shape with logs appended