Skip to main content

runpod_sdk/serverless/
worker.rs

1//! In-container RunPod Serverless worker loop.
2
3use std::future::Future;
4use std::sync::{Arc, Mutex};
5use std::time::Duration;
6
7use reqwest::Client;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::{Error, Result};
12
13#[cfg(feature = "tracing")]
14const TRACING_TARGET: &str = "runpod_sdk::serverless::worker";
15
16/// A job acquired by a RunPod Serverless worker.
17#[derive(Debug, Clone, Deserialize)]
18pub struct WorkerJob {
19    /// RunPod request id.
20    pub id: String,
21    /// User-submitted payload from the endpoint request's `input` field.
22    pub input: Value,
23    /// Any additional fields RunPod includes with the job.
24    #[serde(flatten)]
25    pub extra: serde_json::Map<String, Value>,
26}
27
28/// Result returned to RunPod for a processed worker job.
29#[derive(Debug, Clone, Serialize)]
30#[serde(rename_all = "camelCase")]
31pub struct WorkerJobResult {
32    /// Successful job output.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub output: Option<Value>,
35    /// Failed job error message.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub error: Option<String>,
38    /// Ask RunPod to refresh the worker after returning this result.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub stop_pod: Option<bool>,
41}
42
43impl WorkerJobResult {
44    /// Build a successful job result.
45    pub fn output(output: impl Into<Value>) -> Self {
46        Self {
47            output: Some(output.into()),
48            error: None,
49            stop_pod: None,
50        }
51    }
52
53    /// Build a failed job result.
54    pub fn error(error: impl Into<String>) -> Self {
55        Self {
56            output: None,
57            error: Some(error.into()),
58            stop_pod: None,
59        }
60    }
61}
62
63/// Configuration for an in-container RunPod Serverless worker.
64#[derive(Debug, Clone)]
65pub struct WorkerConfig {
66    /// Worker id; defaults to `RUNPOD_POD_ID`.
67    pub worker_id: String,
68    /// Job acquisition URL from `RUNPOD_WEBHOOK_GET_JOB`.
69    pub get_job_url: String,
70    /// Job result URL from `RUNPOD_WEBHOOK_POST_OUTPUT`.
71    pub post_output_url: String,
72    /// Optional heartbeat URL from `RUNPOD_WEBHOOK_PING`.
73    pub ping_url: Option<String>,
74    /// Authorization value sent to RunPod worker endpoints.
75    pub api_key: Option<String>,
76    /// Poll concurrency. Defaults to 1.
77    pub concurrency: usize,
78    /// Heartbeat interval. Defaults to `RUNPOD_PING_INTERVAL` milliseconds or 10s.
79    pub ping_interval: Duration,
80    /// Request timeout for job polling and result posts.
81    pub request_timeout: Duration,
82}
83
84impl WorkerConfig {
85    /// Build worker config from RunPod Serverless environment variables.
86    pub fn from_env() -> Result<Self> {
87        let worker_id = std::env::var("RUNPOD_POD_ID")
88            .map_err(|_| Error::Job("RUNPOD_POD_ID is missing".to_string()))?;
89        let get_job_url = std::env::var("RUNPOD_WEBHOOK_GET_JOB")
90            .map_err(|_| Error::Job("RUNPOD_WEBHOOK_GET_JOB is missing".to_string()))?
91            .replace("$ID", &worker_id)
92            .replace("$RUNPOD_POD_ID", &worker_id);
93        let post_output_url = std::env::var("RUNPOD_WEBHOOK_POST_OUTPUT")
94            .map_err(|_| Error::Job("RUNPOD_WEBHOOK_POST_OUTPUT is missing".to_string()))?
95            .replace("$RUNPOD_POD_ID", &worker_id);
96        let ping_url = std::env::var("RUNPOD_WEBHOOK_PING").ok().map(|url| {
97            url.replace("$ID", &worker_id)
98                .replace("$RUNPOD_POD_ID", &worker_id)
99        });
100        let api_key = std::env::var("RUNPOD_AI_API_KEY").ok();
101        let concurrency = std::env::var("RUNPOD_WORKER_CONCURRENCY")
102            .ok()
103            .and_then(|value| value.parse().ok())
104            .unwrap_or(1);
105        let ping_interval = std::env::var("RUNPOD_PING_INTERVAL")
106            .ok()
107            .and_then(|value| value.parse::<u64>().ok())
108            .map(Duration::from_millis)
109            .unwrap_or(Duration::from_secs(10));
110        let request_timeout = std::env::var("RUNPOD_WORKER_REQUEST_TIMEOUT_SECONDS")
111            .ok()
112            .and_then(|value| value.parse::<u64>().ok())
113            .map(Duration::from_secs)
114            .unwrap_or(Duration::from_secs(600));
115
116        Ok(Self {
117            worker_id,
118            get_job_url,
119            post_output_url,
120            ping_url,
121            api_key,
122            concurrency,
123            ping_interval,
124            request_timeout,
125        })
126    }
127}
128
129/// RunPod Serverless worker loop.
130#[derive(Clone)]
131pub struct ServerlessWorker {
132    config: WorkerConfig,
133    client: Client,
134    active_jobs: Arc<Mutex<Vec<String>>>,
135}
136
137impl ServerlessWorker {
138    /// Create a worker from explicit configuration.
139    pub fn new(config: WorkerConfig) -> Result<Self> {
140        let client = Client::builder().timeout(config.request_timeout).build()?;
141        Ok(Self {
142            config,
143            client,
144            active_jobs: Arc::new(Mutex::new(Vec::new())),
145        })
146    }
147
148    /// Create a worker from RunPod Serverless environment variables.
149    pub fn from_env() -> Result<Self> {
150        Self::new(WorkerConfig::from_env()?)
151    }
152
153    /// Run the worker loop forever, with up to [`WorkerConfig::concurrency`]
154    /// jobs in flight at once.
155    ///
156    /// Spawns `concurrency` independent copies of the poll/process loop
157    /// (`concurrency` defaults to 1, from `RUNPOD_WORKER_CONCURRENCY` via
158    /// [`WorkerConfig::from_env`]) — each copy is still internally
159    /// sequential (`take_job` → await `handler` to completion →
160    /// `post_result` → repeat), but running `concurrency` of them side by
161    /// side lets that many jobs actually overlap, which matters for any
162    /// handler backed by a worker pool wider than one (e.g. multiple GPU
163    /// inference slots). A single heartbeat task is still shared across
164    /// all of them and reports every concurrently active job id (see
165    /// `start_heartbeat`/`active_jobs`).
166    ///
167    /// `handler` needs `Send + Sync + 'static` (and `Fut: Send + 'static`)
168    /// so it can be shared across the spawned tasks — virtually always
169    /// true already for realistic async worker handlers running on a
170    /// multi-threaded Tokio runtime.
171    pub async fn run<H, Fut>(&self, handler: H) -> Result<()>
172    where
173        H: Fn(WorkerJob) -> Fut + Send + Sync + 'static,
174        Fut: Future<Output = std::result::Result<Value, String>> + Send + 'static,
175    {
176        self.start_heartbeat();
177
178        let concurrency = self.config.concurrency.max(1);
179        let handler = Arc::new(handler);
180
181        let mut tasks = Vec::with_capacity(concurrency);
182        for _ in 0..concurrency {
183            let worker = self.clone();
184            let handler = Arc::clone(&handler);
185            tasks.push(tokio::spawn(async move {
186                worker.run_loop(handler.as_ref()).await
187            }));
188        }
189
190        for task in tasks {
191            task.await
192                .map_err(|error| Error::Job(format!("worker task panicked: {error}")))?;
193        }
194        Ok(())
195    }
196
197    /// The single-job-at-a-time poll/process loop `run` spawns
198    /// `concurrency` copies of. Never returns under normal operation — see
199    /// `run`'s doc comment.
200    ///
201    /// Prefetches the next job (`take_job(true)`, `job_in_progress=true` —
202    /// RunPod's own sanctioned "I'm still busy, but tell me what's next"
203    /// signal, the same one RunPod's official Python/Go workers use) while
204    /// `handler` is still running for the current job, instead of only
205    /// polling after `post_result` completes. Without this, a worker whose
206    /// handler is backed by a single-threaded resource (e.g. one dedicated
207    /// GPU inference thread — `concurrency` can't help there, since more
208    /// copies of this loop would just queue behind the same resource) sits
209    /// fully idle for a whole `take_job` HTTP round trip between every pair
210    /// of jobs, even when the queue already has the next job waiting. This
211    /// doesn't change `run`/`run_once`'s public contract — a caller driving
212    /// `run_once` directly (as the existing test suite does) still gets the
213    /// simple non-prefetching one-job-at-a-time behavior; only `run`'s
214    /// internal loop benefits.
215    async fn run_loop<H, Fut>(&self, handler: &H)
216    where
217        H: Fn(WorkerJob) -> Fut,
218        Fut: Future<Output = std::result::Result<Value, String>>,
219    {
220        let mut prefetched: Option<WorkerJob> = None;
221        loop {
222            let job = match prefetched.take() {
223                Some(job) => job,
224                None => match self.take_job(false).await {
225                    Ok(Some(job)) => job,
226                    Ok(None) => {
227                        tokio::time::sleep(Duration::from_secs(1)).await;
228                        continue;
229                    }
230                    Err(error) => {
231                        #[cfg(feature = "tracing")]
232                        tracing::warn!(target: TRACING_TARGET, error = %error, "failed to take RunPod job");
233                        #[cfg(not(feature = "tracing"))]
234                        let _ = &error;
235                        tokio::time::sleep(Duration::from_secs(1)).await;
236                        continue;
237                    }
238                },
239            };
240
241            #[cfg(feature = "tracing")]
242            tracing::info!(target: TRACING_TARGET, job_id = %job.id, "received RunPod worker job");
243
244            self.mark_job_active(&job.id);
245            let (handler_result, prefetch_result) =
246                tokio::join!(handler(job.clone()), self.take_job(true));
247            let result = match handler_result {
248                Ok(output) => WorkerJobResult::output(output),
249                Err(error) => WorkerJobResult::error(error),
250            };
251            match prefetch_result {
252                Ok(next) => prefetched = next,
253                Err(error) => {
254                    #[cfg(feature = "tracing")]
255                    tracing::warn!(target: TRACING_TARGET, error = %error, "failed to prefetch next RunPod job");
256                    #[cfg(not(feature = "tracing"))]
257                    let _ = &error;
258                }
259            }
260            let post_result = self.post_result(&job, &result, false).await;
261            self.mark_job_inactive(&job.id);
262            if let Err(error) = post_result {
263                #[cfg(feature = "tracing")]
264                tracing::warn!(target: TRACING_TARGET, error = %error, "failed to post RunPod job result");
265                #[cfg(not(feature = "tracing"))]
266                let _ = &error;
267            }
268        }
269    }
270
271    /// Poll for one job and process it if present.
272    ///
273    /// Returns `true` when a job was processed and `false` when RunPod returned
274    /// no job for this worker.
275    pub async fn run_once<H, Fut>(&self, handler: &H) -> Result<bool>
276    where
277        H: Fn(WorkerJob) -> Fut,
278        Fut: Future<Output = std::result::Result<Value, String>>,
279    {
280        let Some(job) = self.take_job(false).await? else {
281            return Ok(false);
282        };
283
284        #[cfg(feature = "tracing")]
285        tracing::info!(target: TRACING_TARGET, job_id = %job.id, "received RunPod worker job");
286
287        self.mark_job_active(&job.id);
288        let result = match handler(job.clone()).await {
289            Ok(output) => WorkerJobResult::output(output),
290            Err(error) => WorkerJobResult::error(error),
291        };
292        let post_result = self.post_result(&job, &result, false).await;
293        self.mark_job_inactive(&job.id);
294        post_result?;
295        Ok(true)
296    }
297
298    fn mark_job_active(&self, job_id: &str) {
299        if let Ok(mut jobs) = self.active_jobs.lock() {
300            jobs.push(job_id.to_string());
301        }
302    }
303
304    fn mark_job_inactive(&self, job_id: &str) {
305        if let Ok(mut jobs) = self.active_jobs.lock() {
306            jobs.retain(|active| active != job_id);
307        }
308    }
309
310    async fn take_job(&self, job_in_progress: bool) -> Result<Option<WorkerJob>> {
311        let url = append_query(
312            &self.config.get_job_url,
313            "job_in_progress",
314            if job_in_progress { "1" } else { "0" },
315        );
316        let mut request = self.client.get(url);
317        if let Some(api_key) = &self.config.api_key {
318            request = request.header("Authorization", api_key);
319        }
320
321        let response = request.send().await?;
322        match response.status().as_u16() {
323            204 | 400 => Ok(None),
324            429 => {
325                tokio::time::sleep(Duration::from_secs(5)).await;
326                Ok(None)
327            }
328            _ => {
329                let response = response.error_for_status()?;
330                let job: WorkerJob = response.json().await?;
331                Ok(Some(job))
332            }
333        }
334    }
335
336    async fn post_result(
337        &self,
338        job: &WorkerJob,
339        result: &WorkerJobResult,
340        is_stream: bool,
341    ) -> Result<()> {
342        let mut url = self.config.post_output_url.replace("$ID", &job.id);
343        url = append_query(&url, "isStream", if is_stream { "true" } else { "false" });
344        let body = serde_json::to_string(result)?;
345
346        let mut request = self
347            .client
348            .post(url)
349            .header("Content-Type", "application/x-www-form-urlencoded")
350            .header("charset", "utf-8")
351            .header("X-Request-ID", &job.id)
352            .body(body);
353        if let Some(api_key) = &self.config.api_key {
354            request = request.header("Authorization", api_key);
355        }
356
357        let response = request.send().await?;
358        if let Err(error) = response.error_for_status_ref() {
359            #[cfg(feature = "tracing")]
360            {
361                let status = response.status();
362                let body = response.text().await.unwrap_or_default();
363                tracing::warn!(
364                    target: TRACING_TARGET,
365                    job_id = %job.id,
366                    %status,
367                    body = %body,
368                    "RunPod worker result post failed"
369                );
370            }
371
372            return Err(error.into());
373        }
374
375        #[cfg(feature = "tracing")]
376        tracing::info!(target: TRACING_TARGET, job_id = %job.id, "RunPod worker result sent");
377
378        Ok(())
379    }
380
381    fn start_heartbeat(&self) {
382        let Some(ping_url) = self.config.ping_url.clone() else {
383            return;
384        };
385        let client = self.client.clone();
386        let api_key = self.config.api_key.clone();
387        let interval = self.config.ping_interval;
388        let active_jobs = Arc::clone(&self.active_jobs);
389
390        tokio::spawn(async move {
391            loop {
392                let mut request = client
393                    .get(&ping_url)
394                    .query(&[("runpod_version", env!("CARGO_PKG_VERSION"))]);
395                let job_ids = active_jobs
396                    .lock()
397                    .map(|jobs| jobs.clone())
398                    .unwrap_or_default();
399                if !job_ids.is_empty() {
400                    request = request.query(&[("job_id", job_ids.join(",").as_str())]);
401                }
402                if let Some(api_key) = &api_key {
403                    request = request.header("Authorization", api_key);
404                }
405
406                if let Err(error) = request.send().await {
407                    #[cfg(feature = "tracing")]
408                    tracing::warn!(target: TRACING_TARGET, error = %error, "RunPod heartbeat failed");
409                    #[cfg(not(feature = "tracing"))]
410                    let _ = &error;
411                }
412
413                tokio::time::sleep(interval).await;
414            }
415        });
416    }
417}
418
419fn append_query(url: &str, key: &str, value: &str) -> String {
420    let separator = if url.contains('?') { '&' } else { '?' };
421    format!("{url}{separator}{key}={value}")
422}