Skip to main content

awa_worker/
context.rs

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