Skip to content

Commit 0cbb5bf

Browse files
authored
Merge pull request #3 from runpod-workers/zeke/con-1751-alpha-feedback-apptask-pods-keep-running-and-billing-after
fix: bind worker volumes and acknowledge task pod termination
2 parents 6d37c5f + ccfe0b3 commit 0cbb5bf

10 files changed

Lines changed: 447 additions & 139 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
- uses: actions/checkout@v7
2525
with:
2626
repository: runpod/runpod-python
27-
ref: feat/apps-sdk
27+
ref: main
2828
path: runpod-python
2929

3030
- uses: actions/setup-python@v6
@@ -81,7 +81,7 @@ jobs:
8181
tags: runpod-sdk-runtime:test
8282
build-args: |
8383
BASE_IMAGE=python:3.12-slim
84-
RUNPOD_PACKAGE_SPEC=https://github.com/runpod/runpod-python/archive/refs/heads/feat/apps-sdk.tar.gz
84+
RUNPOD_PACKAGE_SPEC=https://github.com/runpod/runpod-python/archive/refs/heads/main.tar.gz
8585
cache-from: type=gha,scope=queue-test
8686
cache-to: type=gha,mode=max,scope=queue-test
8787

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,11 @@
11
# Changelog
2+
3+
## Unreleased
4+
5+
### Features
6+
7+
* Initialize resolved volume bindings before user code in queue, API, and task workers.
8+
9+
### Bug Fixes
10+
11+
* Keep active tasks running indefinitely; retry failed idle cleanup and exit only after acknowledged pod deletion.

‎README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,27 @@ python -m runpod_sdk_runtime.task.runner
3838

3939
`RUNPOD_RUNTIME_KIND` selects `queue` or `api` when running the shared bootstrap.
4040

41+
## Storage bindings
42+
43+
Apps provisioning supplies `RUNPOD_MOUNTS`, a JSON array of bindings with `kind`
44+
(`network` or `global`), declared `reference`, resolved volume `id`, and absolute
45+
`path`. Queue, API, and task workers install these before importing user code.
46+
The compatible Apps SDK's `volume.path` uses this worker-level map; request
47+
payloads cannot replace it. Missing configuration means no mounts; malformed
48+
configuration fails startup.
49+
50+
## Task cleanup
51+
52+
Active background jobs and inline executions can run indefinitely, including
53+
after the client disconnects. The watchdog checks for idle cleanup every 15
54+
seconds. `RUNPOD_TASK_IDLE_TIMEOUT` (600 seconds by default) applies only to
55+
pods waiting for submission or holding uncollected results.
56+
57+
Self-deletion requires `RUNPOD_API_KEY` and `RUNPOD_POD_ID`. The runtime exits
58+
only after GraphQL acknowledges `podTerminate` without errors. Transient
59+
failures receive three bounded attempts; any failed deletion leaves the runtime
60+
alive for the next watchdog pass. Container exit alone does not stop pod billing.
61+
4162
## Development
4263

4364
Install the Apps SDK checkout first, then install this project:

‎src/runpod_sdk_runtime/api/server.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
import sys
3030
from typing import Any, Optional
3131

32+
from runpod_sdk_runtime.mounts import configure_mounts
33+
3234
log = logging.getLogger("runpod_sdk_runtime.api")
3335

3436
APP_DIR = os.environ.get("RUNPOD_APP_DIR", "/app")
@@ -293,6 +295,7 @@ def _build_live_app() -> Any:
293295

294296

295297
def build_app() -> Any:
298+
configure_mounts()
296299
if _is_deployed():
297300
handle = _load_api_handle()
298301
if handle._cls is not None:

‎src/runpod_sdk_runtime/mounts.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""install provisioning-owned filesystem bindings at worker startup."""
2+
3+
import json
4+
import os
5+
6+
7+
def configure_mounts() -> None:
8+
"""initialize the sdk after bootstrap, before loading any user code."""
9+
from runpod.apps.volume import _configure_mounts
10+
11+
try:
12+
bindings = json.loads(os.environ.get("RUNPOD_MOUNTS", "[]"))
13+
except json.JSONDecodeError as exc:
14+
raise RuntimeError("RUNPOD_MOUNTS must contain a valid JSON array") from exc
15+
_configure_mounts(bindings)

‎src/runpod_sdk_runtime/queue/worker.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424

2525
import runpod
2626

27+
from runpod_sdk_runtime.mounts import configure_mounts
28+
2729
APP_DIR = os.environ.get("RUNPOD_APP_DIR", "/app")
2830
MANIFEST_NAME = "runpod_manifest.json"
2931

@@ -173,6 +175,7 @@ def _worker_config(handler) -> dict:
173175

174176

175177
def main() -> None:
178+
configure_mounts()
176179
if _is_deployed():
177180
handle = _load_deployed_handle()
178181
_run_init(handle)

‎src/runpod_sdk_runtime/task/runner.py‎

Lines changed: 139 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -20,41 +20,53 @@
2020
"""
2121

2222
import json
23+
import math
2324
import os
2425
import sys
2526
import threading
27+
import time
28+
import urllib.error
29+
import urllib.request
2630
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
2731

2832
from runpod_sdk_runtime.executor import execute_request
33+
from runpod_sdk_runtime.mounts import configure_mounts
2934

3035
PORT = int(os.environ.get("RUNPOD_TASK_PORT", "8080"))
3136
TOKEN = os.environ.get("RUNPOD_TASK_TOKEN", "")
3237

33-
# watchdog: self-terminate when the client is clearly gone. RUNNING
34-
# jobs are never killed (terminateAfter is the runaway backstop);
35-
# state NONE means the client died before submitting, while DONE means
36-
# the result sat uncollected. normal flows poll every ~2s, so these never fire
37-
# for a live client.
38+
# active work is exempt from idle cleanup.
3839
IDLE_TIMEOUT = float(os.environ.get("RUNPOD_TASK_IDLE_TIMEOUT", "600"))
3940
WATCHDOG_INTERVAL = 15.0
41+
TERMINATE_ATTEMPTS = 3
42+
TERMINATE_TIMEOUT = 5.0
4043

4144
# single background job slot for /submit + /result
4245
_job_lock = threading.Lock()
4346
_job_state = {"status": "NONE", "response": None}
4447
_last_contact = {"ts": None} # set at server start
45-
# inline /execute requests in flight; the watchdog must not terminate
46-
# the pod while one runs (long executes outlive the idle timeout)
48+
# inline requests are protected from idle cleanup while running.
4749
_inline_executions = {"count": 0}
50+
_execution_deadline = None
51+
_watchdog_wakeup = threading.Event()
4852

4953

5054
def _touch_contact():
5155
import time
5256

53-
_last_contact["ts"] = time.time()
57+
_last_contact["ts"] = time.monotonic()
58+
59+
60+
def _set_timeout(timeout):
61+
global _execution_deadline
62+
if timeout is not None:
63+
deadline = time.monotonic() + timeout
64+
if _execution_deadline is None or deadline < _execution_deadline:
65+
_execution_deadline = deadline
66+
_watchdog_wakeup.set()
5467

5568

5669
def _should_self_terminate(status, last_contact, now, idle_timeout, inline=0):
57-
"""the watchdog decision: kill only provably-abandoned pods."""
5870
if status == "RUNNING" or inline > 0:
5971
return False
6072
if last_contact is None:
@@ -63,57 +75,101 @@ def _should_self_terminate(status, last_contact, now, idle_timeout, inline=0):
6375

6476

6577
def _terminate_self():
66-
"""terminate this pod via the injected pod-scoped api key.
67-
68-
every pod carries a RUNPOD_API_KEY scoped to itself; podTerminate
69-
with it removes the pod entirely. exiting the process is the
70-
fallback (stops the workload; terminateAfter finishes the job).
71-
"""
72-
import json as _json
73-
import urllib.request
74-
78+
"""exit only after the control plane acknowledges pod deletion."""
7579
pod_id = os.environ.get("RUNPOD_POD_ID")
7680
api_key = os.environ.get("RUNPOD_API_KEY")
77-
if pod_id and api_key:
81+
if not pod_id or not api_key:
82+
sys.stderr.write(
83+
"[task-runner] cannot delete pod: missing RUNPOD_POD_ID or RUNPOD_API_KEY\n"
84+
)
85+
return False
86+
api_base = os.environ.get("RUNPOD_API_BASE_URL", "https://api.runpod.io")
87+
payload = json.dumps(
88+
{
89+
"query": (
90+
"mutation podTerminate($input: PodTerminateInput!) "
91+
"{ podTerminate(input: $input) }"
92+
),
93+
"variables": {"input": {"podId": pod_id}},
94+
}
95+
).encode()
96+
request = urllib.request.Request(
97+
f"{api_base.rstrip('/')}/graphql",
98+
data=payload,
99+
headers={
100+
"Content-Type": "application/json",
101+
"Authorization": f"Bearer {api_key}",
102+
},
103+
)
104+
for attempt in range(TERMINATE_ATTEMPTS):
105+
retryable = False
78106
try:
79-
api_base = os.environ.get("RUNPOD_API_BASE_URL", "https://api.runpod.io")
80-
payload = _json.dumps(
81-
{
82-
"query": (
83-
"mutation podTerminate($input: PodTerminateInput!) "
84-
"{ podTerminate(input: $input) }"
85-
),
86-
"variables": {"input": {"podId": pod_id}},
87-
}
88-
).encode()
89-
request = urllib.request.Request(
90-
f"{api_base}/graphql",
91-
data=payload,
92-
headers={
93-
"Content-Type": "application/json",
94-
"Authorization": f"Bearer {api_key}",
95-
},
107+
with urllib.request.urlopen(request, timeout=TERMINATE_TIMEOUT) as response: # noqa: S310
108+
body = json.load(response)
109+
errors = body.get("errors") if isinstance(body, dict) else None
110+
data = body.get("data") if isinstance(body, dict) else None
111+
if (
112+
not errors
113+
and isinstance(data, dict)
114+
and "podTerminate" in data
115+
and data["podTerminate"] is None
116+
):
117+
sys.stderr.write(f"[task-runner] pod {pod_id} deletion acknowledged\n")
118+
os._exit(0)
119+
return True
120+
if isinstance(errors, list) and errors:
121+
retryable = all(
122+
isinstance(error, dict)
123+
and isinstance(error.get("extensions"), dict)
124+
and error["extensions"].get("code")
125+
in {
126+
"INTERNAL_SERVER_ERROR",
127+
"SERVICE_UNAVAILABLE",
128+
"TOO_MANY_REQUESTS",
129+
"TIMEOUT",
130+
}
131+
for error in errors
132+
)
133+
failure = "graphql errors" if errors else "missing deletion acknowledgment"
134+
except urllib.error.HTTPError as exc:
135+
retryable = exc.code == 429 or 500 <= exc.code < 600
136+
failure = f"http {exc.code}"
137+
exc.close()
138+
except (urllib.error.URLError, OSError) as exc:
139+
retryable = True
140+
failure = str(exc)
141+
except (ValueError, TypeError) as exc:
142+
failure = f"invalid deletion response: {exc}"
143+
if not retryable or attempt == TERMINATE_ATTEMPTS - 1:
144+
sys.stderr.write(
145+
f"[task-runner] pod {pod_id} deletion failed: {failure}; "
146+
"keeping runtime alive for watchdog retry\n"
96147
)
97-
urllib.request.urlopen(request, timeout=30) # noqa: S310
98-
sys.stderr.write("[task-runner] self-terminated (abandoned)\n")
99-
except Exception: # noqa: BLE001 - fall through to process exit
100-
pass
101-
os._exit(0)
148+
return False
149+
time.sleep(0.5 * (2**attempt))
150+
return False
102151

103152

104153
def _watchdog():
105154
import time
106155

107156
while True:
108-
time.sleep(WATCHDOG_INTERVAL)
109-
if _should_self_terminate(
110-
_job_state["status"],
111-
_last_contact["ts"],
112-
time.time(),
113-
IDLE_TIMEOUT,
114-
inline=_inline_executions["count"],
115-
):
116-
_terminate_self()
157+
with _job_lock:
158+
now = time.monotonic()
159+
expired = _execution_deadline is not None and now >= _execution_deadline
160+
if expired or _should_self_terminate(
161+
_job_state["status"],
162+
_last_contact["ts"],
163+
now,
164+
IDLE_TIMEOUT,
165+
inline=_inline_executions["count"],
166+
):
167+
_terminate_self()
168+
delay = WATCHDOG_INTERVAL
169+
if _execution_deadline is not None and _execution_deadline > now:
170+
delay = min(delay, _execution_deadline - now)
171+
_watchdog_wakeup.clear()
172+
_watchdog_wakeup.wait(delay)
117173

118174

119175
class Handler(BaseHTTPRequestHandler):
@@ -160,21 +216,47 @@ def do_POST(self): # noqa: N802 - BaseHTTPRequestHandler api
160216
if not self._authed():
161217
self._send(401, {"error": "unauthorized"})
162218
return
219+
if self.path not in ("/execute", "/submit", "/timeout"):
220+
self._send(404, {"error": "not found"})
221+
return
222+
try:
223+
request = self._read_request()
224+
except (ValueError, TypeError):
225+
self._send(400, {"error": "invalid request"})
226+
return
227+
if not isinstance(request, dict):
228+
self._send(400, {"error": "invalid request"})
229+
return
230+
timeout = request.get("timeout")
231+
if timeout is not None and (
232+
isinstance(timeout, bool)
233+
or not isinstance(timeout, (int, float))
234+
or not math.isfinite(timeout)
235+
or timeout < 0
236+
):
237+
self._send(400, {"error": "timeout must be a finite non-negative number"})
238+
return
239+
if self.path == "/timeout":
240+
with _job_lock:
241+
_set_timeout(timeout)
242+
self._send(200, {"timeout": timeout})
243+
return
163244
if self.path == "/execute":
164245
with _job_lock:
246+
_set_timeout(timeout)
165247
_inline_executions["count"] += 1
166248
try:
167-
self._send(200, execute_request(self._read_request()))
249+
self._send(200, execute_request(request))
168250
finally:
169251
with _job_lock:
170252
_inline_executions["count"] -= 1
171253
return
172254
if self.path == "/submit":
173-
request = self._read_request()
174255
with _job_lock:
175256
if _job_state["status"] == "RUNNING":
176257
self._send(409, {"error": "a job is already running"})
177258
return
259+
_set_timeout(timeout)
178260
_job_state["status"] = "RUNNING"
179261
_job_state["response"] = None
180262

@@ -185,9 +267,11 @@ def run():
185267
_job_state["response"] = response
186268

187269
threading.Thread(target=run, daemon=True).start()
188-
self._send(200, {"status": "RUNNING"})
270+
response = {"status": "RUNNING"}
271+
if timeout is not None:
272+
response["timeout"] = timeout
273+
self._send(200, response)
189274
return
190-
self._send(404, {"error": "not found"})
191275

192276
def log_message(self, format, *args): # noqa: A002 - stdlib signature
193277
# request logging is noise in container logs (dev sessions
@@ -199,6 +283,7 @@ def main() -> None:
199283
if not TOKEN:
200284
sys.stderr.write("[task-runner] RUNPOD_TASK_TOKEN not set, exiting\n")
201285
sys.exit(1)
286+
configure_mounts()
202287
_touch_contact()
203288
threading.Thread(target=_watchdog, daemon=True).start()
204289
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)

0 commit comments

Comments
 (0)