1use 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#[derive(Debug, Clone, Deserialize)]
18pub struct WorkerJob {
19 pub id: String,
21 pub input: Value,
23 #[serde(flatten)]
25 pub extra: serde_json::Map<String, Value>,
26}
27
28#[derive(Debug, Clone, Serialize)]
30#[serde(rename_all = "camelCase")]
31pub struct WorkerJobResult {
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub output: Option<Value>,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub error: Option<String>,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub stop_pod: Option<bool>,
41}
42
43impl WorkerJobResult {
44 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 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#[derive(Debug, Clone)]
65pub struct WorkerConfig {
66 pub worker_id: String,
68 pub get_job_url: String,
70 pub post_output_url: String,
72 pub ping_url: Option<String>,
74 pub api_key: Option<String>,
76 pub concurrency: usize,
78 pub ping_interval: Duration,
80 pub request_timeout: Duration,
82}
83
84impl WorkerConfig {
85 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#[derive(Clone)]
131pub struct ServerlessWorker {
132 config: WorkerConfig,
133 client: Client,
134 active_jobs: Arc<Mutex<Vec<String>>>,
135}
136
137impl ServerlessWorker {
138 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 pub fn from_env() -> Result<Self> {
150 Self::new(WorkerConfig::from_env()?)
151 }
152
153 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 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 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}