Skip to main content

runledger_runtime/
observer.rs

1use std::any::Any;
2use std::future::Future;
3use std::panic::AssertUnwindSafe;
4use std::sync::Arc;
5use std::time::Duration;
6
7use async_trait::async_trait;
8use chrono::{DateTime, Utc};
9use futures_util::{FutureExt, StreamExt, stream::FuturesUnordered};
10use runledger_core::jobs::{JobDeadLetterReason, JobFailure, JobTypeName};
11use tracing::warn;
12use uuid::Uuid;
13
14#[cfg(test)]
15const OBSERVER_TIMEOUT: Duration = Duration::from_millis(100);
16#[cfg(not(test))]
17const OBSERVER_TIMEOUT: Duration = Duration::from_secs(10);
18
19#[derive(Debug, Clone)]
20#[non_exhaustive]
21pub struct ObservedJob {
22    pub job_id: Uuid,
23    pub job_type: JobTypeName,
24    pub organization_id: Option<Uuid>,
25    pub run_number: i32,
26    pub attempt: i32,
27    pub max_attempts: i32,
28    pub worker_id: String,
29}
30
31impl ObservedJob {
32    #[must_use]
33    pub fn new(
34        job_id: Uuid,
35        job_type: JobTypeName,
36        organization_id: Option<Uuid>,
37        run_number: i32,
38        attempt: i32,
39        max_attempts: i32,
40        worker_id: impl Into<String>,
41    ) -> Self {
42        Self {
43            job_id,
44            job_type,
45            organization_id,
46            run_number,
47            attempt,
48            max_attempts,
49            worker_id: worker_id.into(),
50        }
51    }
52}
53
54#[derive(Debug, Clone)]
55#[non_exhaustive]
56pub struct JobRunningEvent {
57    pub job: ObservedJob,
58}
59
60impl JobRunningEvent {
61    #[must_use]
62    pub fn new(job: ObservedJob) -> Self {
63        Self { job }
64    }
65}
66
67#[derive(Debug, Clone)]
68#[non_exhaustive]
69pub struct JobSucceededEvent {
70    pub job: ObservedJob,
71    pub duration: Duration,
72    pub progress_done: Option<i64>,
73    pub progress_total: Option<i64>,
74}
75
76impl JobSucceededEvent {
77    #[must_use]
78    pub fn new(
79        job: ObservedJob,
80        duration: Duration,
81        progress_done: Option<i64>,
82        progress_total: Option<i64>,
83    ) -> Self {
84        Self {
85            job,
86            duration,
87            progress_done,
88            progress_total,
89        }
90    }
91}
92
93#[derive(Debug, Clone)]
94#[non_exhaustive]
95pub struct JobContinuedEvent {
96    /// Identity of the successfully completed run slice.
97    pub job: ObservedJob,
98    pub duration: Duration,
99    pub next_run_number: i32,
100    pub next_run_at: DateTime<Utc>,
101    pub progress_done: Option<i64>,
102    pub progress_total: Option<i64>,
103}
104
105impl JobContinuedEvent {
106    #[must_use]
107    pub fn new(
108        job: ObservedJob,
109        duration: Duration,
110        next_run_number: i32,
111        next_run_at: DateTime<Utc>,
112        progress_done: Option<i64>,
113        progress_total: Option<i64>,
114    ) -> Self {
115        Self {
116            job,
117            duration,
118            next_run_number,
119            next_run_at,
120            progress_done,
121            progress_total,
122        }
123    }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
127#[non_exhaustive]
128pub enum JobFailureDisposition {
129    RetryScheduled {
130        retry_delay_ms: i32,
131        next_run_at: DateTime<Utc>,
132    },
133    DeadLettered {
134        reason: JobDeadLetterReason,
135    },
136    Unknown,
137}
138
139#[derive(Debug, Clone)]
140#[non_exhaustive]
141pub struct JobFailedEvent {
142    pub job: ObservedJob,
143    pub duration: Duration,
144    pub failure: JobFailure,
145    pub disposition: JobFailureDisposition,
146}
147
148impl JobFailedEvent {
149    #[must_use]
150    pub fn new(
151        job: ObservedJob,
152        duration: Duration,
153        failure: JobFailure,
154        disposition: JobFailureDisposition,
155    ) -> Self {
156        Self {
157            job,
158            duration,
159            failure,
160            disposition,
161        }
162    }
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166#[non_exhaustive]
167pub enum JobCompletionPersistenceOperation {
168    Success,
169    Continuation,
170    Failure,
171}
172
173#[derive(Debug, Clone)]
174#[non_exhaustive]
175pub struct JobCompletionPersistFailedEvent {
176    pub job: ObservedJob,
177    pub duration: Duration,
178    pub operation: JobCompletionPersistenceOperation,
179    pub error: String,
180}
181
182impl JobCompletionPersistFailedEvent {
183    #[must_use]
184    pub fn new(
185        job: ObservedJob,
186        duration: Duration,
187        operation: JobCompletionPersistenceOperation,
188        error: impl Into<String>,
189    ) -> Self {
190        Self {
191            job,
192            duration,
193            operation,
194            error: error.into(),
195        }
196    }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
200#[non_exhaustive]
201pub enum JobLeaseReapedDisposition {
202    ReleasedToPending,
203    RetryScheduled {
204        retry_delay_ms: i32,
205        next_run_at: DateTime<Utc>,
206    },
207    DeadLettered {
208        reason: JobDeadLetterReason,
209    },
210    Unknown,
211}
212
213#[derive(Debug, Clone)]
214#[non_exhaustive]
215pub struct JobLeaseLostEvent {
216    pub job: ObservedJob,
217    pub duration: Duration,
218    pub failure: JobFailure,
219}
220
221impl JobLeaseLostEvent {
222    #[must_use]
223    pub fn new(job: ObservedJob, duration: Duration, failure: JobFailure) -> Self {
224        Self {
225            job,
226            duration,
227            failure,
228        }
229    }
230}
231
232#[derive(Debug, Clone)]
233#[non_exhaustive]
234pub struct JobLeaseReapedEvent {
235    pub job: ObservedJob,
236    pub failure: JobFailure,
237    pub started_without_renewal_heartbeat: bool,
238    pub disposition: JobLeaseReapedDisposition,
239}
240
241impl JobLeaseReapedEvent {
242    #[must_use]
243    pub fn new(
244        job: ObservedJob,
245        failure: JobFailure,
246        started_without_renewal_heartbeat: bool,
247        disposition: JobLeaseReapedDisposition,
248    ) -> Self {
249        Self {
250            job,
251            failure,
252            started_without_renewal_heartbeat,
253            disposition,
254        }
255    }
256}
257
258#[async_trait]
259pub trait JobLifecycleObserver: Send + Sync {
260    async fn on_job_running(&self, _event: JobRunningEvent) {}
261
262    async fn on_job_continued(&self, _event: JobContinuedEvent) {}
263
264    async fn on_job_succeeded(&self, _event: JobSucceededEvent) {}
265
266    async fn on_job_failed(&self, _event: JobFailedEvent) {}
267
268    async fn on_job_completion_persist_failed(&self, _event: JobCompletionPersistFailedEvent) {}
269
270    async fn on_job_lease_lost(&self, _event: JobLeaseLostEvent) {}
271
272    async fn on_job_lease_reaped(&self, _event: JobLeaseReapedEvent) {}
273}
274
275#[derive(Clone, Default)]
276pub struct JobLifecycleObservers {
277    observers: Arc<Vec<Arc<dyn JobLifecycleObserver>>>,
278}
279
280impl JobLifecycleObservers {
281    #[must_use]
282    pub fn empty() -> Self {
283        Self::default()
284    }
285
286    #[must_use]
287    pub fn from_observer(observer: impl JobLifecycleObserver + 'static) -> Self {
288        Self {
289            observers: Arc::new(vec![Arc::new(observer)]),
290        }
291    }
292
293    #[must_use]
294    pub fn from_arc_observers(observers: Vec<Arc<dyn JobLifecycleObserver>>) -> Self {
295        Self {
296            observers: Arc::new(observers),
297        }
298    }
299
300    pub(crate) fn is_empty(&self) -> bool {
301        self.observers.is_empty()
302    }
303
304    pub(crate) async fn job_running(&self, event: JobRunningEvent) {
305        let job = event.job.clone();
306        self.notify_all_observers(
307            "on_job_running",
308            event,
309            &job,
310            |observer, event| async move {
311                observer.on_job_running(event).await;
312            },
313        )
314        .await;
315    }
316
317    pub(crate) async fn job_succeeded(&self, event: JobSucceededEvent) {
318        let job = event.job.clone();
319        self.notify_all_observers(
320            "on_job_succeeded",
321            event,
322            &job,
323            |observer, event| async move {
324                observer.on_job_succeeded(event).await;
325            },
326        )
327        .await;
328    }
329
330    pub(crate) async fn job_continued(&self, event: JobContinuedEvent) {
331        let job = event.job.clone();
332        self.notify_all_observers(
333            "on_job_continued",
334            event,
335            &job,
336            |observer, event| async move {
337                observer.on_job_continued(event).await;
338            },
339        )
340        .await;
341    }
342
343    pub(crate) async fn job_failed(&self, event: JobFailedEvent) {
344        let job = event.job.clone();
345        self.notify_all_observers("on_job_failed", event, &job, |observer, event| async move {
346            observer.on_job_failed(event).await;
347        })
348        .await;
349    }
350
351    pub(crate) async fn job_completion_persist_failed(
352        &self,
353        event: JobCompletionPersistFailedEvent,
354    ) {
355        let job = event.job.clone();
356        self.notify_all_observers(
357            "on_job_completion_persist_failed",
358            event,
359            &job,
360            |observer, event| async move {
361                observer.on_job_completion_persist_failed(event).await;
362            },
363        )
364        .await;
365    }
366
367    pub(crate) async fn job_lease_lost(&self, event: JobLeaseLostEvent) {
368        let job = event.job.clone();
369        self.notify_all_observers(
370            "on_job_lease_lost",
371            event,
372            &job,
373            |observer, event| async move {
374                observer.on_job_lease_lost(event).await;
375            },
376        )
377        .await;
378    }
379
380    pub(crate) async fn job_lease_reaped(&self, event: JobLeaseReapedEvent) {
381        let job = event.job.clone();
382        self.notify_all_observers(
383            "on_job_lease_reaped",
384            event,
385            &job,
386            |observer, event| async move {
387                observer.on_job_lease_reaped(event).await;
388            },
389        )
390        .await;
391    }
392
393    async fn notify_all_observers<E, F, Fut>(
394        &self,
395        callback_name: &'static str,
396        event: E,
397        job: &ObservedJob,
398        notify: F,
399    ) where
400        E: Clone,
401        F: Fn(Arc<dyn JobLifecycleObserver>, E) -> Fut,
402        Fut: Future<Output = ()> + Send,
403    {
404        let mut pending = FuturesUnordered::new();
405
406        for observer in self.observers.iter() {
407            let observer = Arc::clone(observer);
408            let job = ObserverJobLogContext::from(job);
409            let event = event.clone();
410            pending.push(notify_observer(callback_name, job, notify(observer, event)));
411        }
412
413        while pending.next().await.is_some() {}
414    }
415}
416
417#[derive(Debug)]
418struct ObserverJobLogContext {
419    job_id: Uuid,
420    job_type: String,
421    organization_id: Option<Uuid>,
422    run_number: i32,
423    attempt: i32,
424    max_attempts: i32,
425    worker_id: String,
426}
427
428impl From<&ObservedJob> for ObserverJobLogContext {
429    fn from(job: &ObservedJob) -> Self {
430        Self {
431            job_id: job.job_id,
432            job_type: job.job_type.to_string(),
433            organization_id: job.organization_id,
434            run_number: job.run_number,
435            attempt: job.attempt,
436            max_attempts: job.max_attempts,
437            worker_id: job.worker_id.clone(),
438        }
439    }
440}
441
442async fn notify_observer<F>(callback_name: &'static str, job: ObserverJobLogContext, future: F)
443where
444    F: Future<Output = ()> + Send,
445{
446    match tokio::time::timeout(OBSERVER_TIMEOUT, AssertUnwindSafe(future).catch_unwind()).await {
447        Ok(Ok(())) => {}
448        Ok(Err(panic_payload)) => {
449            let panic_message = panic_payload_message(&*panic_payload);
450            warn!(
451                callback_name,
452                job_id = %job.job_id,
453                job_type = %job.job_type,
454                organization_id = ?job.organization_id,
455                run_number = job.run_number,
456                attempt = job.attempt,
457                max_attempts = job.max_attempts,
458                worker_id = %job.worker_id,
459                panic = %panic_message,
460                "job lifecycle observer panicked"
461            );
462        }
463        Err(_) => {
464            warn!(
465                callback_name,
466                job_id = %job.job_id,
467                job_type = %job.job_type,
468                organization_id = ?job.organization_id,
469                run_number = job.run_number,
470                attempt = job.attempt,
471                max_attempts = job.max_attempts,
472                worker_id = %job.worker_id,
473                timeout_ms = OBSERVER_TIMEOUT.as_millis(),
474                "job lifecycle observer timed out"
475            );
476        }
477    }
478}
479
480fn panic_payload_message(panic_payload: &(dyn Any + Send)) -> String {
481    if let Some(message) = panic_payload.downcast_ref::<String>() {
482        return message.clone();
483    }
484
485    if let Some(message) = panic_payload.downcast_ref::<&'static str>() {
486        return (*message).to_string();
487    }
488
489    "non-string panic payload".to_string()
490}