Skip to main content

awa_worker/
context.rs

1pub use crate::runtime::ProgressState;
2use crate::storage::RuntimeStorage;
3use awa_model::{AwaError, CallbackConfig, JobRow, QueueStorage};
4use sqlx::PgPool;
5use std::any::Any;
6use std::collections::HashMap;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::Arc;
9use std::time::Duration;
10
11/// Proof that this job registered an external callback in the database.
12///
13/// The public `id` can be sent to the external system. `#[non_exhaustive]`
14/// keeps external callers from constructing this type directly.
15#[derive(Debug, Clone)]
16#[non_exhaustive]
17pub struct CallbackGuard {
18    pub id: uuid::Uuid,
19}
20
21impl CallbackGuard {
22    fn new(id: uuid::Uuid) -> Self {
23        Self { id }
24    }
25
26    /// Return the callback UUID persisted for this job.
27    pub fn id(&self) -> uuid::Uuid {
28        self.id
29    }
30
31    #[cfg(feature = "__python-bridge")]
32    #[doc(hidden)]
33    pub fn from_bridge_token(id: uuid::Uuid) -> Self {
34        Self::new(id)
35    }
36}
37
38#[doc(hidden)]
39pub type CallbackToken = CallbackGuard;
40
41/// Context passed to worker handlers during job execution.
42///
43/// Provides access to the job metadata, shared state (e.g., service dependencies),
44/// callback registration, and structured progress reporting.
45pub struct JobContext {
46    /// The raw job row from the database.
47    pub job: JobRow,
48    /// Cancellation flag — set to true when shutdown or deadline is signalled.
49    cancelled: Arc<AtomicBool>,
50    /// Shared state map for dependency injection.
51    state: Arc<HashMap<std::any::TypeId, Box<dyn Any + Send + Sync>>>,
52    /// Database pool for callback registration and progress flush.
53    pool: PgPool,
54    /// Active runtime storage backend.
55    storage: RuntimeStorage,
56    /// Shared progress buffer — written by handler, read by heartbeat service.
57    progress: Arc<std::sync::Mutex<ProgressState>>,
58}
59
60impl JobContext {
61    pub(crate) fn new(
62        job: JobRow,
63        cancelled: Arc<AtomicBool>,
64        state: Arc<HashMap<std::any::TypeId, Box<dyn Any + Send + Sync>>>,
65        pool: PgPool,
66        storage: RuntimeStorage,
67        progress: Arc<std::sync::Mutex<ProgressState>>,
68    ) -> Self {
69        Self {
70            job,
71            cancelled,
72            state,
73            pool,
74            storage,
75            progress,
76        }
77    }
78
79    #[doc(hidden)]
80    pub fn new_for_testing(
81        job: JobRow,
82        cancelled: Arc<AtomicBool>,
83        state: Arc<HashMap<std::any::TypeId, Box<dyn Any + Send + Sync>>>,
84        pool: PgPool,
85        progress: Arc<std::sync::Mutex<ProgressState>>,
86    ) -> Self {
87        Self::new(
88            job,
89            cancelled,
90            state,
91            pool,
92            RuntimeStorage::Canonical,
93            progress,
94        )
95    }
96
97    /// Check if this job's execution has been cancelled (shutdown or deadline).
98    pub fn is_cancelled(&self) -> bool {
99        self.cancelled.load(Ordering::SeqCst)
100    }
101
102    /// Clone the shared cancellation flag for language bridges.
103    pub fn cancellation_flag(&self) -> Arc<AtomicBool> {
104        self.cancelled.clone()
105    }
106
107    /// Signal cancellation for this job.
108    pub fn cancel(&self) {
109        self.cancelled.store(true, Ordering::SeqCst);
110    }
111
112    /// Extract a shared state value by type.
113    ///
114    /// State values are registered via `Client::builder().state(value)`.
115    /// Register the concrete type you want to extract:
116    ///
117    /// ```ignore
118    /// // Register with the type you'll extract:
119    /// let deps = Arc::new(MyDeps::new());
120    /// Client::builder(pool)
121    ///     .state(deps.clone())  // stores Arc<MyDeps>
122    ///     .build()?;
123    ///
124    /// // In handler — extract the same type:
125    /// let deps = ctx.extract::<Arc<MyDeps>>().unwrap();
126    /// ```
127    pub fn extract<T: Any + Send + Sync + Clone>(&self) -> Option<T> {
128        self.state
129            .get(&std::any::TypeId::of::<T>())
130            .and_then(|v| v.downcast_ref::<T>())
131            .cloned()
132    }
133
134    /// Get a reference to the database pool.
135    pub fn pool(&self) -> &PgPool {
136        &self.pool
137    }
138
139    /// Get the active queue-storage backend for this job, if any.
140    pub fn queue_storage_store(&self) -> Option<Arc<QueueStorage>> {
141        self.storage.queue_storage_store()
142    }
143
144    /// Register a callback for this job, writing the callback_id to the database
145    /// immediately.
146    ///
147    /// Call this BEFORE sending the callback_id to the external system to avoid
148    /// the race condition where the external system fires before the DB knows
149    /// about the callback.
150    ///
151    /// Returns a `CallbackGuard` whose `id` should be included in the URL or
152    /// payload sent to the external system.
153    pub async fn register_callback(&self, timeout: Duration) -> Result<CallbackGuard, AwaError> {
154        let callback_id = match &self.storage {
155            RuntimeStorage::Canonical => {
156                awa_model::admin::register_callback(
157                    &self.pool,
158                    self.job.id,
159                    self.job.run_lease,
160                    timeout,
161                )
162                .await?
163            }
164            RuntimeStorage::QueueStorage(runtime) => {
165                runtime
166                    .store
167                    .register_callback(&self.pool, self.job.id, self.job.run_lease, timeout)
168                    .await?
169            }
170        };
171        Ok(CallbackGuard::new(callback_id))
172    }
173
174    /// Register a callback with CEL expressions for automatic resolution.
175    ///
176    /// See [`CallbackConfig`] for expression semantics.
177    pub async fn register_callback_with_config(
178        &self,
179        timeout: Duration,
180        config: &CallbackConfig,
181    ) -> Result<CallbackGuard, AwaError> {
182        let callback_id = match &self.storage {
183            RuntimeStorage::Canonical => {
184                awa_model::admin::register_callback_with_config(
185                    &self.pool,
186                    self.job.id,
187                    self.job.run_lease,
188                    timeout,
189                    config,
190                )
191                .await?
192            }
193            RuntimeStorage::QueueStorage(runtime) => {
194                runtime
195                    .store
196                    .register_callback_with_config(
197                        &self.pool,
198                        self.job.id,
199                        self.job.run_lease,
200                        timeout,
201                        config,
202                    )
203                    .await?
204            }
205        };
206        Ok(CallbackGuard::new(callback_id))
207    }
208
209    /// Wait for an external callback to resolve, then resume with the payload.
210    ///
211    /// This enables sequential callbacks: the handler can register a callback,
212    /// wait for it, process the result, register another callback, wait again,
213    /// and so on — all within a single handler invocation.
214    ///
215    /// The handler's async task suspends while waiting. The job transitions to
216    /// `waiting_external`. When `resume_external(callback_id, payload)` is
217    /// called, the job transitions back to `running` and this method returns
218    /// the payload.
219    ///
220    /// The handler holds its permit (worker slot) during the wait. For very
221    /// long waits, consider whether the single-shot `WaitForCallback` pattern
222    /// (which releases the permit) is more appropriate.
223    ///
224    /// ```ignore
225    /// let token = ctx.register_callback(Duration::from_secs(3600)).await?;
226    /// send_to_external_system(token.id());
227    /// let payload = ctx.wait_for_callback(token).await?;
228    /// // Handler resumes here with the external system's response
229    /// ```
230    pub async fn wait_for_callback(
231        &self,
232        guard: CallbackGuard,
233    ) -> Result<serde_json::Value, AwaError> {
234        use awa_model::admin::CallbackPollResult;
235
236        let callback_id = guard.id();
237
238        let entered = match &self.storage {
239            RuntimeStorage::Canonical => {
240                awa_model::admin::enter_callback_wait(
241                    &self.pool,
242                    self.job.id,
243                    self.job.run_lease,
244                    callback_id,
245                )
246                .await?
247            }
248            RuntimeStorage::QueueStorage(runtime) => {
249                runtime
250                    .store
251                    .enter_callback_wait(&self.pool, self.job.id, self.job.run_lease, callback_id)
252                    .await?
253            }
254        };
255
256        let check_state = || async {
257            match &self.storage {
258                RuntimeStorage::Canonical => {
259                    awa_model::admin::check_callback_state(&self.pool, self.job.id, callback_id)
260                        .await
261                }
262                RuntimeStorage::QueueStorage(runtime) => {
263                    runtime
264                        .store
265                        .check_callback_state(&self.pool, self.job.id, callback_id)
266                        .await
267                }
268            }
269        };
270
271        if !entered {
272            match check_state().await? {
273                CallbackPollResult::Resolved(payload) => return Ok(payload),
274                CallbackPollResult::Pending => { /* Already in waiting_external */ }
275                CallbackPollResult::Stale {
276                    token,
277                    current,
278                    state,
279                } => {
280                    return Err(AwaError::Validation(format!(
281                        "wait_for_callback: token {token} is stale; current callback is {current} in state {state:?}"
282                    )));
283                }
284                CallbackPollResult::UnexpectedState { token, state } => {
285                    return Err(AwaError::Validation(format!(
286                        "wait_for_callback: job is not waiting on callback {token}; state={state:?}"
287                    )));
288                }
289                CallbackPollResult::NotFound => {
290                    return Err(AwaError::Validation(
291                        "job not found during callback wait".into(),
292                    ));
293                }
294            }
295        }
296
297        loop {
298            if self.is_cancelled() {
299                return Err(AwaError::Validation(
300                    "job cancelled while waiting for callback".into(),
301                ));
302            }
303
304            match check_state().await? {
305                CallbackPollResult::Resolved(payload) => return Ok(payload),
306                CallbackPollResult::Pending => {
307                    tokio::time::sleep(Duration::from_millis(200)).await;
308                }
309                CallbackPollResult::Stale {
310                    token,
311                    current,
312                    state,
313                } => {
314                    return Err(AwaError::Validation(format!(
315                        "wait_for_callback: token {token} is stale; current callback is {current} in state {state:?}"
316                    )));
317                }
318                CallbackPollResult::UnexpectedState { token, state } => {
319                    return Err(AwaError::Validation(format!(
320                        "job left wait_for_callback unexpectedly for token {token}: state={state:?}"
321                    )));
322                }
323                CallbackPollResult::NotFound => {
324                    return Err(AwaError::Validation(
325                        "job not found during callback wait".into(),
326                    ));
327                }
328            }
329        }
330    }
331
332    /// Set structured progress (0-100 with message). Sync — writes to in-memory buffer.
333    ///
334    /// `percent` is clamped to 0-100. For progress without a message, pass `""`.
335    pub fn set_progress(&self, percent: u8, message: &str) {
336        let mut guard = self.progress.lock().expect("progress lock poisoned");
337        guard.set_progress(percent, Some(message));
338    }
339
340    /// Shallow-merge keys into progress.metadata for checkpointing. Sync.
341    ///
342    /// `updates` must be a JSON object. Top-level keys overwrite; nested objects
343    /// are replaced, not deep-merged.
344    pub fn update_metadata(&self, updates: serde_json::Value) -> Result<(), AwaError> {
345        let obj = updates
346            .as_object()
347            .ok_or_else(|| AwaError::Validation("update_metadata requires a JSON object".into()))?;
348
349        let mut guard = self.progress.lock().expect("progress lock poisoned");
350        if !guard.merge_metadata(obj) {
351            return Err(AwaError::Validation(
352                "progress.metadata is not a JSON object; cannot merge".into(),
353            ));
354        }
355        Ok(())
356    }
357
358    /// Force immediate flush of pending progress to DB. For critical checkpoints.
359    ///
360    /// Does not return success until the progress has been durably written
361    /// or the job is no longer in running state (rescued/cancelled).
362    pub async fn flush_progress(&self) -> Result<(), AwaError> {
363        let (snapshot, target_generation) = {
364            let guard = self.progress.lock().expect("progress lock poisoned");
365            match guard.pending_snapshot() {
366                Some(pair) => pair,
367                None => return Ok(()),
368            }
369        };
370
371        match &self.storage {
372            RuntimeStorage::Canonical => {
373                let result = sqlx::query(
374                    r#"
375                    UPDATE awa.jobs_hot
376                    SET progress = $2
377                    WHERE id = $1 AND state = 'running' AND run_lease = $3
378                    "#,
379                )
380                .bind(self.job.id)
381                .bind(&snapshot)
382                .bind(self.job.run_lease)
383                .execute(&self.pool)
384                .await?;
385
386                if result.rows_affected() == 0 {
387                    return Ok(());
388                }
389            }
390            RuntimeStorage::QueueStorage(runtime) => {
391                runtime
392                    .store
393                    .flush_progress(
394                        &self.pool,
395                        self.job.id,
396                        self.job.run_lease,
397                        snapshot.clone(),
398                    )
399                    .await?;
400            }
401        }
402
403        let mut guard = self.progress.lock().expect("progress lock poisoned");
404        guard.ack(target_generation);
405
406        Ok(())
407    }
408
409    /// Get a clone of the shared progress state Arc (for Python bridge).
410    pub fn progress_buffer(&self) -> Arc<std::sync::Mutex<ProgressState>> {
411        self.progress.clone()
412    }
413}