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 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)]
132#[non_exhaustive]
133pub enum JobFailureDisposition {
134 RetryScheduled {
136 retry_delay_ms: i32,
138 next_run_at: DateTime<Utc>,
140 },
141 RetryScheduledAt {
143 requested_retry_at: DateTime<Utc>,
146 next_run_at: DateTime<Utc>,
148 },
149 DeadLettered { reason: JobDeadLetterReason },
151 Unknown,
153}
154
155#[derive(Debug, Clone)]
156#[non_exhaustive]
157pub struct JobFailedEvent {
158 pub job: ObservedJob,
159 pub duration: Duration,
160 pub failure: JobFailure,
162 pub disposition: JobFailureDisposition,
164}
165
166impl JobFailedEvent {
167 #[must_use]
168 pub fn new(
169 job: ObservedJob,
170 duration: Duration,
171 failure: JobFailure,
172 disposition: JobFailureDisposition,
173 ) -> Self {
174 Self {
175 job,
176 duration,
177 failure,
178 disposition,
179 }
180 }
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184#[non_exhaustive]
185pub enum JobCompletionPersistenceOperation {
186 Success,
187 Continuation,
188 Failure,
189}
190
191#[derive(Debug, Clone)]
192#[non_exhaustive]
193pub struct JobCompletionPersistFailedEvent {
194 pub job: ObservedJob,
195 pub duration: Duration,
196 pub operation: JobCompletionPersistenceOperation,
197 pub error: String,
198}
199
200impl JobCompletionPersistFailedEvent {
201 #[must_use]
202 pub fn new(
203 job: ObservedJob,
204 duration: Duration,
205 operation: JobCompletionPersistenceOperation,
206 error: impl Into<String>,
207 ) -> Self {
208 Self {
209 job,
210 duration,
211 operation,
212 error: error.into(),
213 }
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
218#[non_exhaustive]
219pub enum JobLeaseReapedDisposition {
220 ReleasedToPending,
221 RetryScheduled {
222 retry_delay_ms: i32,
223 next_run_at: DateTime<Utc>,
224 },
225 DeadLettered {
226 reason: JobDeadLetterReason,
227 },
228 Unknown,
229}
230
231#[derive(Debug, Clone)]
232#[non_exhaustive]
233pub struct JobLeaseLostEvent {
234 pub job: ObservedJob,
235 pub duration: Duration,
236 pub failure: JobFailure,
237}
238
239impl JobLeaseLostEvent {
240 #[must_use]
241 pub fn new(job: ObservedJob, duration: Duration, failure: JobFailure) -> Self {
242 Self {
243 job,
244 duration,
245 failure,
246 }
247 }
248}
249
250#[derive(Debug, Clone)]
251#[non_exhaustive]
252pub struct JobLeaseReapedEvent {
253 pub job: ObservedJob,
254 pub failure: JobFailure,
255 pub started_without_renewal_heartbeat: bool,
256 pub disposition: JobLeaseReapedDisposition,
257}
258
259impl JobLeaseReapedEvent {
260 #[must_use]
261 pub fn new(
262 job: ObservedJob,
263 failure: JobFailure,
264 started_without_renewal_heartbeat: bool,
265 disposition: JobLeaseReapedDisposition,
266 ) -> Self {
267 Self {
268 job,
269 failure,
270 started_without_renewal_heartbeat,
271 disposition,
272 }
273 }
274}
275
276#[async_trait]
277pub trait JobLifecycleObserver: Send + Sync {
278 async fn on_job_running(&self, _event: JobRunningEvent) {}
279
280 async fn on_job_continued(&self, _event: JobContinuedEvent) {}
281
282 async fn on_job_succeeded(&self, _event: JobSucceededEvent) {}
283
284 async fn on_job_failed(&self, _event: JobFailedEvent) {}
285
286 async fn on_job_completion_persist_failed(&self, _event: JobCompletionPersistFailedEvent) {}
287
288 async fn on_job_lease_lost(&self, _event: JobLeaseLostEvent) {}
289
290 async fn on_job_lease_reaped(&self, _event: JobLeaseReapedEvent) {}
291}
292
293#[derive(Clone, Default)]
294pub struct JobLifecycleObservers {
295 observers: Arc<Vec<Arc<dyn JobLifecycleObserver>>>,
296}
297
298impl JobLifecycleObservers {
299 #[must_use]
300 pub fn empty() -> Self {
301 Self::default()
302 }
303
304 #[must_use]
305 pub fn from_observer(observer: impl JobLifecycleObserver + 'static) -> Self {
306 Self {
307 observers: Arc::new(vec![Arc::new(observer)]),
308 }
309 }
310
311 #[must_use]
312 pub fn from_arc_observers(observers: Vec<Arc<dyn JobLifecycleObserver>>) -> Self {
313 Self {
314 observers: Arc::new(observers),
315 }
316 }
317
318 pub(crate) fn is_empty(&self) -> bool {
319 self.observers.is_empty()
320 }
321
322 pub(crate) async fn job_running(&self, event: JobRunningEvent) {
323 let job = event.job.clone();
324 self.notify_all_observers(
325 "on_job_running",
326 event,
327 &job,
328 |observer, event| async move {
329 observer.on_job_running(event).await;
330 },
331 )
332 .await;
333 }
334
335 pub(crate) async fn job_succeeded(&self, event: JobSucceededEvent) {
336 let job = event.job.clone();
337 self.notify_all_observers(
338 "on_job_succeeded",
339 event,
340 &job,
341 |observer, event| async move {
342 observer.on_job_succeeded(event).await;
343 },
344 )
345 .await;
346 }
347
348 pub(crate) async fn job_continued(&self, event: JobContinuedEvent) {
349 let job = event.job.clone();
350 self.notify_all_observers(
351 "on_job_continued",
352 event,
353 &job,
354 |observer, event| async move {
355 observer.on_job_continued(event).await;
356 },
357 )
358 .await;
359 }
360
361 pub(crate) async fn job_failed(&self, event: JobFailedEvent) {
362 let job = event.job.clone();
363 self.notify_all_observers("on_job_failed", event, &job, |observer, event| async move {
364 observer.on_job_failed(event).await;
365 })
366 .await;
367 }
368
369 pub(crate) async fn job_completion_persist_failed(
370 &self,
371 event: JobCompletionPersistFailedEvent,
372 ) {
373 let job = event.job.clone();
374 self.notify_all_observers(
375 "on_job_completion_persist_failed",
376 event,
377 &job,
378 |observer, event| async move {
379 observer.on_job_completion_persist_failed(event).await;
380 },
381 )
382 .await;
383 }
384
385 pub(crate) async fn job_lease_lost(&self, event: JobLeaseLostEvent) {
386 let job = event.job.clone();
387 self.notify_all_observers(
388 "on_job_lease_lost",
389 event,
390 &job,
391 |observer, event| async move {
392 observer.on_job_lease_lost(event).await;
393 },
394 )
395 .await;
396 }
397
398 pub(crate) async fn job_lease_reaped(&self, event: JobLeaseReapedEvent) {
399 let job = event.job.clone();
400 self.notify_all_observers(
401 "on_job_lease_reaped",
402 event,
403 &job,
404 |observer, event| async move {
405 observer.on_job_lease_reaped(event).await;
406 },
407 )
408 .await;
409 }
410
411 async fn notify_all_observers<E, F, Fut>(
412 &self,
413 callback_name: &'static str,
414 event: E,
415 job: &ObservedJob,
416 notify: F,
417 ) where
418 E: Clone,
419 F: Fn(Arc<dyn JobLifecycleObserver>, E) -> Fut,
420 Fut: Future<Output = ()> + Send,
421 {
422 let mut pending = FuturesUnordered::new();
423
424 for observer in self.observers.iter() {
425 let observer = Arc::clone(observer);
426 let job = ObserverJobLogContext::from(job);
427 let event = event.clone();
428 pending.push(notify_observer(callback_name, job, notify(observer, event)));
429 }
430
431 while pending.next().await.is_some() {}
432 }
433}
434
435#[derive(Debug)]
436struct ObserverJobLogContext {
437 job_id: Uuid,
438 job_type: String,
439 organization_id: Option<Uuid>,
440 run_number: i32,
441 attempt: i32,
442 max_attempts: i32,
443 worker_id: String,
444}
445
446impl From<&ObservedJob> for ObserverJobLogContext {
447 fn from(job: &ObservedJob) -> Self {
448 Self {
449 job_id: job.job_id,
450 job_type: job.job_type.to_string(),
451 organization_id: job.organization_id,
452 run_number: job.run_number,
453 attempt: job.attempt,
454 max_attempts: job.max_attempts,
455 worker_id: job.worker_id.clone(),
456 }
457 }
458}
459
460async fn notify_observer<F>(callback_name: &'static str, job: ObserverJobLogContext, future: F)
461where
462 F: Future<Output = ()> + Send,
463{
464 match tokio::time::timeout(OBSERVER_TIMEOUT, AssertUnwindSafe(future).catch_unwind()).await {
465 Ok(Ok(())) => {}
466 Ok(Err(panic_payload)) => {
467 let panic_message = panic_payload_message(&*panic_payload);
468 warn!(
469 callback_name,
470 job_id = %job.job_id,
471 job_type = %job.job_type,
472 organization_id = ?job.organization_id,
473 run_number = job.run_number,
474 attempt = job.attempt,
475 max_attempts = job.max_attempts,
476 worker_id = %job.worker_id,
477 panic = %panic_message,
478 "job lifecycle observer panicked"
479 );
480 }
481 Err(_) => {
482 warn!(
483 callback_name,
484 job_id = %job.job_id,
485 job_type = %job.job_type,
486 organization_id = ?job.organization_id,
487 run_number = job.run_number,
488 attempt = job.attempt,
489 max_attempts = job.max_attempts,
490 worker_id = %job.worker_id,
491 timeout_ms = OBSERVER_TIMEOUT.as_millis(),
492 "job lifecycle observer timed out"
493 );
494 }
495 }
496}
497
498fn panic_payload_message(panic_payload: &(dyn Any + Send)) -> String {
499 if let Some(message) = panic_payload.downcast_ref::<String>() {
500 return message.clone();
501 }
502
503 if let Some(message) = panic_payload.downcast_ref::<&'static str>() {
504 return (*message).to_string();
505 }
506
507 "non-string panic payload".to_string()
508}