1#![allow(deprecated)] use crate::{
4 config::QueueConfig, db, error::QueueError, events::*, types::JobResult, JobContext,
5 JobHandler, QueueEventEmitter,
6};
7use rusqlite::Connection;
8use std::sync::{
9 atomic::{AtomicBool, Ordering},
10 Arc, Mutex,
11};
12
13#[derive(Debug)]
15pub struct ProcessedJob {
16 pub job_id: String,
18 pub job_data: serde_json::Value,
20 pub success: bool,
22 pub output: Option<String>,
24 pub error: Option<String>,
26}
27
28pub struct QueueExecutor {
34 config: QueueConfig,
35 pub(crate) db: Arc<Mutex<Connection>>,
36 paused: Arc<AtomicBool>,
37 shutdown: Arc<AtomicBool>,
38}
39
40impl QueueExecutor {
41 pub fn new(config: QueueConfig, db: Arc<Mutex<Connection>>) -> Self {
42 Self {
43 config,
44 db,
45 paused: Arc::new(AtomicBool::new(false)),
46 shutdown: Arc::new(AtomicBool::new(false)),
47 }
48 }
49
50 async fn job_details(
51 &self,
52 job_id: &str,
53 ) -> Result<Option<crate::types::QueueJobDetails>, QueueError> {
54 let job_id = job_id.to_string();
55 self.with_db(move |conn| db::get_job_details(conn, &job_id))
56 .await
57 }
58
59 async fn with_db<F, T>(&self, f: F) -> Result<T, QueueError>
61 where
62 F: FnOnce(&Connection) -> Result<T, anyhow::Error> + Send + 'static,
63 T: Send + 'static,
64 {
65 let db = Arc::clone(&self.db);
66 tokio::task::spawn_blocking(move || {
67 let conn = db.lock().map_err(|e| anyhow::anyhow!(e.to_string()))?;
68 f(&conn)
69 })
70 .await
71 .map_err(|e| QueueError::Other(e.to_string()))?
72 .map_err(QueueError::from)
73 }
74
75 #[allow(clippy::too_many_arguments)]
76 async fn persist_canonical_lineage_or_fail(
77 &self,
78 event_emitter: &Arc<dyn QueueEventEmitter>,
79 job_id: &str,
80 trace_id: Option<String>,
81 attempt_count: u32,
82 trace_ctx: Option<stack_ids::TraceCtx>,
83 attempt_id: Option<stack_ids::AttemptId>,
84 trial_id: Option<stack_ids::TrialId>,
85 ) -> Result<(), QueueError> {
86 let jid = job_id.to_string();
87 let aid = attempt_id.as_ref().map(|a| a.as_str().to_string());
88 let tid = trial_id.as_ref().map(|t| t.as_str().to_string());
89 let worker_id = self.config.worker_id.clone();
90
91 let persistence_error = match self
92 .with_db(move |conn| {
93 db::update_canonical_lineage(conn, &jid, aid.as_deref(), tid.as_deref())
94 })
95 .await
96 {
97 Ok(true) => return Ok(()),
98 Ok(false) => format!(
99 "Canonical lineage persistence failed before execution: job '{}' disappeared before attempt_id/trial_id could be stored",
100 job_id
101 ),
102 Err(error) => format!(
103 "Canonical lineage persistence failed before execution: {error}"
104 ),
105 };
106
107 let jid = job_id.to_string();
108 let error_for_db = persistence_error.clone();
109 self.with_db(move |conn| {
110 let marked = db::mark_failed_owned(conn, &jid, Some(&worker_id), &error_for_db)?;
111 if marked {
112 Ok(())
113 } else {
114 Err(anyhow::anyhow!(
115 "job could not be marked failed after canonical lineage persistence error"
116 ))
117 }
118 })
119 .await?;
120
121 event_emitter.emit_job_failed(JobFailedEvent {
122 job_id: job_id.to_string(),
123 error: persistence_error.clone(),
124 trace_id,
125 worker_id: Some(self.config.worker_id.clone()),
126 attempt_count: Some(attempt_count),
127 status: Some("failed".to_string()),
128 failure_class: Some("permanent".to_string()),
129 next_retry_at: None,
130 trace_ctx,
131 attempt_id,
132 trial_id,
133 });
134
135 Err(QueueError::Other(persistence_error))
136 }
137
138 pub fn spawn<H>(self: Arc<Self>, event_emitter: Arc<dyn QueueEventEmitter>)
148 where
149 H: JobHandler + 'static,
150 {
151 match tokio::runtime::Handle::try_current() {
152 Ok(handle) => {
153 handle.spawn(async move {
154 self.run_loop::<H>(event_emitter).await;
155 });
156 }
157 Err(_) => {
158 tracing::debug!(
159 "No tokio runtime on current thread, spawning queue executor on a new thread"
160 );
161 std::thread::Builder::new()
162 .name("queue-executor".into())
163 .spawn(move || {
164 let rt = tokio::runtime::Builder::new_current_thread()
165 .enable_all()
166 .build()
167 .expect("queue executor: failed to create tokio runtime");
168 rt.block_on(self.run_loop::<H>(event_emitter));
169 })
170 .expect("queue executor: failed to spawn thread");
171 }
172 }
173 }
174
175 pub fn spawn_on<H>(
180 self: Arc<Self>,
181 event_emitter: Arc<dyn QueueEventEmitter>,
182 handle: &tokio::runtime::Handle,
183 ) where
184 H: JobHandler + 'static,
185 {
186 handle.spawn(async move {
187 self.run_loop::<H>(event_emitter).await;
188 });
189 }
190
191 async fn run_loop<H>(&self, event_emitter: Arc<dyn QueueEventEmitter>)
192 where
193 H: JobHandler,
194 {
195 let mut consecutive_count: u32 = 0;
196
197 loop {
198 if self.shutdown.load(Ordering::Relaxed) {
200 tracing::info!("Queue executor shutting down");
201 break;
202 }
203
204 if self.paused.load(Ordering::Relaxed) {
206 tokio::time::sleep(self.config.poll_interval).await;
207 continue;
208 }
209
210 let stale_after_secs = self.config.stale_after.as_secs();
211 if stale_after_secs > 0 {
212 match self
213 .with_db(move |conn| db::reclaim_stale(conn, stale_after_secs))
214 .await
215 {
216 Ok(reclaimed) if reclaimed > 0 => {
217 tracing::warn!(reclaimed, "Reclaimed stale queue jobs");
218 }
219 Ok(_) => {}
220 Err(e) => tracing::error!("Failed to reclaim stale jobs: {:#}", e),
221 }
222 }
223
224 if self.config.max_consecutive > 0 && consecutive_count >= self.config.max_consecutive {
226 tracing::info!(
227 max_consecutive = self.config.max_consecutive,
228 cooldown = ?self.config.cooldown,
229 "Consecutive limit reached, cooling down"
230 );
231 tokio::time::sleep(self.config.cooldown).await;
232 consecutive_count = 0;
233 continue;
234 }
235
236 let worker_id = self.config.worker_id.clone();
237 let visibility_timeout_secs = self.config.stale_after.as_secs();
238 let claimed = match self
239 .with_db(move |conn| {
240 db::claim_with_lease(conn, &worker_id, visibility_timeout_secs)
241 })
242 .await
243 {
244 Ok(Some(job)) => job,
245 Ok(None) => {
246 consecutive_count = 0;
247 tokio::time::sleep(self.config.poll_interval).await;
248 continue;
249 }
250 Err(e) => {
251 tracing::error!("Failed to claim next job: {:#}", e);
252 tokio::time::sleep(self.config.poll_interval).await;
253 continue;
254 }
255 };
256
257 let (job_id, job_data) = claimed;
258 let job_details = self.job_details(&job_id).await.ok().flatten();
259 let trace_id = job_details
260 .as_ref()
261 .and_then(|details| details.trace_id.clone());
262 let attempt_count = job_details
263 .as_ref()
264 .map(|details| details.attempt_count)
265 .unwrap_or(1);
266
267 let trace_ctx = trace_id
269 .as_ref()
270 .map(stack_ids::TraceCtx::from_legacy_trace_id);
271 let attempt_id = job_details
273 .as_ref()
274 .and_then(|d| d.attempt_id.as_ref())
275 .map(|id| stack_ids::AttemptId::new(id.clone()))
276 .or_else(|| {
277 Some(stack_ids::AttemptId::new(format!(
278 "{}-attempt-{}",
279 job_id, attempt_count
280 )))
281 });
282 let trial_id = Some(stack_ids::TrialId::generate());
284
285 if let Err(error) = self
286 .persist_canonical_lineage_or_fail(
287 &event_emitter,
288 &job_id,
289 trace_id.clone(),
290 attempt_count,
291 trace_ctx.clone(),
292 attempt_id.clone(),
293 trial_id.clone(),
294 )
295 .await
296 {
297 tracing::error!(
298 job_id = %job_id,
299 error = %error,
300 "Failed to persist canonical lineage"
301 );
302 consecutive_count = 0;
303 continue;
304 }
305
306 consecutive_count += 1;
308
309 let job_handler: H = match serde_json::from_value(job_data) {
311 Ok(h) => h,
312 Err(e) => {
313 tracing::error!(job_id = %job_id, "Failed to deserialize job: {}", e);
314 let err_msg = format!("Deserialization failed: {}", e);
315 let jid = job_id.clone();
316 let msg = err_msg.clone();
317 let worker_id = self.config.worker_id.clone();
318 let _ = self
319 .with_db(move |conn| {
320 db::mark_failed_owned(conn, &jid, Some(&worker_id), &msg).map(|_| ())
321 })
322 .await;
323 event_emitter.emit_job_failed(JobFailedEvent {
324 job_id,
325 error: err_msg,
326 trace_id,
327 worker_id: Some(self.config.worker_id.clone()),
328 attempt_count: Some(attempt_count),
329 status: Some("failed".to_string()),
330 failure_class: Some("permanent".to_string()),
331 next_retry_at: None,
332 trace_ctx,
333 attempt_id,
334 trial_id,
335 });
336 continue;
337 }
338 };
339
340 let _result = self
342 .process_job::<H>(
343 &event_emitter,
344 &job_id,
345 job_handler,
346 trace_id,
347 attempt_count,
348 trace_ctx,
349 attempt_id,
350 trial_id,
351 )
352 .await;
353
354 if self.config.cooldown.as_secs() > 0 {
355 tokio::time::sleep(self.config.cooldown).await;
356 }
357 }
358 }
359
360 #[allow(clippy::too_many_arguments)]
361 async fn process_job<H>(
362 &self,
363 event_emitter: &Arc<dyn QueueEventEmitter>,
364 job_id: &str,
365 job_handler: H,
366 trace_id: Option<String>,
367 attempt_count: u32,
368 trace_ctx: Option<stack_ids::TraceCtx>,
369 attempt_id: Option<stack_ids::AttemptId>,
370 trial_id: Option<stack_ids::TrialId>,
371 ) -> Result<JobResult, QueueError>
372 where
373 H: JobHandler,
374 {
375 let worker_id = self.config.worker_id.clone();
376 event_emitter.emit_job_started(JobStartedEvent {
377 job_id: job_id.to_string(),
378 trace_id: trace_id.clone(),
379 worker_id: Some(worker_id.clone()),
380 attempt_count: Some(attempt_count),
381 status: Some("processing".to_string()),
382 trace_ctx: trace_ctx.clone(),
383 attempt_id: attempt_id.clone(),
384 trial_id: trial_id.clone(),
385 });
386
387 let ctx = JobContext {
389 job_id: job_id.to_string(),
390 trace_id: trace_id.clone(),
391 trace_ctx: trace_ctx.clone(),
392 attempt_id: attempt_id.clone(),
393 trial_id: trial_id.clone(),
394 worker_id: Some(worker_id.clone()),
395 attempt_count,
396 event_emitter: Arc::clone(event_emitter),
397 db: Arc::clone(&self.db),
398 };
399
400 let heartbeat_stop = Arc::new(AtomicBool::new(false));
401 if !self.config.heartbeat_interval.is_zero() {
402 let db = Arc::clone(&self.db);
403 let job_id = job_id.to_string();
404 let worker_id = worker_id.clone();
405 let stop = Arc::clone(&heartbeat_stop);
406 let heartbeat_interval = self.config.heartbeat_interval;
407 tokio::spawn(async move {
408 while !stop.load(Ordering::Relaxed) {
409 tokio::time::sleep(heartbeat_interval).await;
410 if stop.load(Ordering::Relaxed) {
411 break;
412 }
413 let db = Arc::clone(&db);
414 let job_id = job_id.clone();
415 let worker_id = worker_id.clone();
416 let _ = tokio::task::spawn_blocking(move || {
417 if let Ok(conn) = db.lock() {
418 let _ = db::heartbeat(&conn, &job_id, &worker_id);
419 }
420 })
421 .await;
422 }
423 });
424 }
425
426 let result = job_handler.execute(&ctx).await;
428 heartbeat_stop.store(true, Ordering::Relaxed);
429
430 let jid = job_id.to_string();
432 let cancelled = self
433 .with_db(move |conn| db::is_cancelled(conn, &jid))
434 .await
435 .unwrap_or(false);
436
437 match &result {
438 _ if cancelled || matches!(&result, Err(QueueError::Cancelled)) => {
439 event_emitter.emit_job_cancelled(JobCancelledEvent {
442 job_id: job_id.to_string(),
443 trace_id: trace_id.clone(),
444 worker_id: Some(worker_id.clone()),
445 attempt_count: Some(attempt_count),
446 status: Some("cancelled".to_string()),
447 trace_ctx: trace_ctx.clone(),
448 attempt_id: attempt_id.clone(),
449 trial_id: trial_id.clone(),
450 });
451 Err(QueueError::Cancelled)
452 }
453 Ok(job_result) if job_result.success => {
454 let jid = job_id.to_string();
455 let worker_id_for_update = worker_id.clone();
456 let updated = self
457 .with_db(move |conn| {
458 db::mark_completed_owned(conn, &jid, Some(&worker_id_for_update))
459 })
460 .await?;
461
462 if updated {
463 event_emitter.emit_job_completed(JobCompletedEvent {
464 job_id: job_id.to_string(),
465 output: job_result.output.clone(),
466 trace_id: trace_id.clone(),
467 worker_id: Some(worker_id.clone()),
468 attempt_count: Some(attempt_count),
469 status: Some("completed".to_string()),
470 trace_ctx: trace_ctx.clone(),
471 attempt_id: attempt_id.clone(),
472 trial_id: trial_id.clone(),
473 });
474 } else {
475 event_emitter.emit_job_cancelled(JobCancelledEvent {
477 job_id: job_id.to_string(),
478 trace_id: trace_id.clone(),
479 worker_id: Some(worker_id.clone()),
480 attempt_count: Some(attempt_count),
481 status: Some("cancelled".to_string()),
482 trace_ctx: trace_ctx.clone(),
483 attempt_id: attempt_id.clone(),
484 trial_id: trial_id.clone(),
485 });
486 }
487 Ok(job_result.clone())
488 }
489 Ok(job_result) => {
490 let error_msg = job_result
491 .error
492 .clone()
493 .unwrap_or_else(|| "Unknown error".to_string());
494 let jid = job_id.to_string();
495 let msg = error_msg.clone();
496 let failure_class = job_result
497 .failure_class
498 .clone()
499 .unwrap_or(crate::types::FailureClass::Permanent);
500 let failure_class_for_update = failure_class.clone();
501 let worker_id_for_update = worker_id.clone();
502 let max_retries = self.config.max_retries;
503 let updated = self
504 .with_db(move |conn| {
505 db::mark_failed_with_retry_owned(
506 conn,
507 &jid,
508 Some(&worker_id_for_update),
509 &msg,
510 &failure_class_for_update,
511 max_retries,
512 )
513 })
514 .await?;
515 let details = self.job_details(job_id).await.ok().flatten();
516
517 if updated {
518 event_emitter.emit_job_failed(JobFailedEvent {
519 job_id: job_id.to_string(),
520 error: error_msg,
521 trace_id: trace_id.clone(),
522 worker_id: Some(worker_id.clone()),
523 attempt_count: Some(attempt_count),
524 status: details
525 .as_ref()
526 .map(|details| details.status.as_str().to_string()),
527 failure_class: Some(failure_class.as_str().to_string()),
528 next_retry_at: details.and_then(|details| details.next_run_at),
529 trace_ctx: trace_ctx.clone(),
530 attempt_id: attempt_id.clone(),
531 trial_id: trial_id.clone(),
532 });
533 } else {
534 event_emitter.emit_job_cancelled(JobCancelledEvent {
535 job_id: job_id.to_string(),
536 trace_id: trace_id.clone(),
537 worker_id: Some(worker_id.clone()),
538 attempt_count: Some(attempt_count),
539 status: Some("cancelled".to_string()),
540 trace_ctx: trace_ctx.clone(),
541 attempt_id: attempt_id.clone(),
542 trial_id: trial_id.clone(),
543 });
544 }
545 Ok(job_result.clone())
546 }
547 Err(e) => {
548 let error_msg = e.to_string();
549 let jid = job_id.to_string();
550 let msg = error_msg.clone();
551 let worker_id_for_update = worker_id.clone();
552 let updated = self
553 .with_db(move |conn| {
554 db::mark_failed_owned(conn, &jid, Some(&worker_id_for_update), &msg)
555 })
556 .await?;
557
558 if updated {
559 event_emitter.emit_job_failed(JobFailedEvent {
560 job_id: job_id.to_string(),
561 error: error_msg.clone(),
562 trace_id: trace_id.clone(),
563 worker_id: Some(worker_id.clone()),
564 attempt_count: Some(attempt_count),
565 status: Some("failed".to_string()),
566 failure_class: Some("permanent".to_string()),
567 next_retry_at: None,
568 trace_ctx: trace_ctx.clone(),
569 attempt_id: attempt_id.clone(),
570 trial_id: trial_id.clone(),
571 });
572 } else {
573 event_emitter.emit_job_cancelled(JobCancelledEvent {
574 job_id: job_id.to_string(),
575 trace_id: trace_id.clone(),
576 worker_id: Some(worker_id),
577 attempt_count: Some(attempt_count),
578 status: Some("cancelled".to_string()),
579 trace_ctx,
580 attempt_id,
581 trial_id,
582 });
583 }
584 Err(QueueError::Execution(error_msg))
585 }
586 }
587 }
588
589 pub fn pause(&self) {
592 self.paused.store(true, Ordering::Relaxed);
593 }
594
595 pub fn resume(&self) {
597 self.paused.store(false, Ordering::Relaxed);
598 }
599
600 pub fn is_paused(&self) -> bool {
602 self.paused.load(Ordering::Relaxed)
603 }
604
605 pub fn shutdown(&self) {
609 self.shutdown.store(true, Ordering::Relaxed);
610 }
611
612 pub fn is_shutdown(&self) -> bool {
614 self.shutdown.load(Ordering::Relaxed)
615 }
616
617 pub async fn process_one<H>(
626 &self,
627 event_emitter: &Arc<dyn QueueEventEmitter>,
628 ) -> Result<Option<ProcessedJob>, QueueError>
629 where
630 H: JobHandler,
631 {
632 let worker_id = self.config.worker_id.clone();
633 let visibility_timeout_secs = self.config.stale_after.as_secs();
634 let claimed = self
635 .with_db(move |conn| db::claim_with_lease(conn, &worker_id, visibility_timeout_secs))
636 .await?;
637
638 let (job_id, job_data) = match claimed {
639 Some(job) => job,
640 None => return Ok(None),
641 };
642 let job_details = self.job_details(&job_id).await.ok().flatten();
643 let trace_id = job_details
644 .as_ref()
645 .and_then(|details| details.trace_id.clone());
646 let attempt_count = job_details
647 .as_ref()
648 .map(|details| details.attempt_count)
649 .unwrap_or(1);
650
651 let trace_ctx = trace_id
653 .as_ref()
654 .map(stack_ids::TraceCtx::from_legacy_trace_id);
655 let attempt_id = job_details
657 .as_ref()
658 .and_then(|d| d.attempt_id.as_ref())
659 .map(|id| stack_ids::AttemptId::new(id.clone()))
660 .or_else(|| {
661 Some(stack_ids::AttemptId::new(format!(
662 "{}-attempt-{}",
663 job_id, attempt_count
664 )))
665 });
666 let trial_id = Some(stack_ids::TrialId::generate());
668
669 self.persist_canonical_lineage_or_fail(
670 event_emitter,
671 &job_id,
672 trace_id.clone(),
673 attempt_count,
674 trace_ctx.clone(),
675 attempt_id.clone(),
676 trial_id.clone(),
677 )
678 .await?;
679
680 let job_handler: H = serde_json::from_value(job_data.clone()).map_err(|e| {
682 let jid = job_id.clone();
684 let msg = format!("Deserialization failed: {e}");
685 let db = Arc::clone(&self.db);
686 let worker_id = self.config.worker_id.clone();
687 let _ = std::thread::spawn(move || {
688 if let Ok(conn) = db.lock() {
689 let _ = db::mark_failed_owned(&conn, &jid, Some(&worker_id), &msg);
690 }
691 });
692 QueueError::Other(format!("Deserialization failed: {e}"))
693 })?;
694
695 let result = self
697 .process_job::<H>(
698 event_emitter,
699 &job_id,
700 job_handler,
701 trace_id,
702 attempt_count,
703 trace_ctx,
704 attempt_id,
705 trial_id,
706 )
707 .await;
708
709 match result {
710 Ok(job_result) => Ok(Some(ProcessedJob {
711 job_id,
712 job_data,
713 success: job_result.success,
714 output: job_result.output,
715 error: job_result.error,
716 })),
717 Err(QueueError::Cancelled) => Ok(Some(ProcessedJob {
718 job_id,
719 job_data,
720 success: false,
721 output: None,
722 error: Some("Cancelled".to_string()),
723 })),
724 Err(e) => Ok(Some(ProcessedJob {
725 job_id,
726 job_data,
727 success: false,
728 output: None,
729 error: Some(e.to_string()),
730 })),
731 }
732 }
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738 use crate::{db, JobContext, JobHandler, JobResult, QueueConfig, QueueEventEmitter};
739 use serde::{Deserialize, Serialize};
740 use std::sync::atomic::{AtomicUsize, Ordering};
741 use std::sync::{Arc, Mutex};
742
743 #[derive(Default)]
744 struct RecordingEmitter {
745 failed: Mutex<Vec<JobFailedEvent>>,
746 }
747
748 impl QueueEventEmitter for RecordingEmitter {
749 fn emit_job_started(&self, _event: JobStartedEvent) {}
750 fn emit_job_completed(&self, _event: JobCompletedEvent) {}
751 fn emit_job_failed(&self, event: JobFailedEvent) {
752 self.failed.lock().unwrap().push(event);
753 }
754 fn emit_job_progress(&self, _event: JobProgressEvent) {}
755 fn emit_job_cancelled(&self, _event: JobCancelledEvent) {}
756 }
757
758 static EXECUTION_CALLS: AtomicUsize = AtomicUsize::new(0);
759
760 #[derive(Clone, Serialize, Deserialize)]
761 struct CountingJob;
762
763 impl JobHandler for CountingJob {
764 async fn execute(&self, _ctx: &JobContext) -> Result<JobResult, QueueError> {
765 EXECUTION_CALLS.fetch_add(1, Ordering::SeqCst);
766 Ok(JobResult::success())
767 }
768 }
769
770 #[tokio::test]
771 async fn lineage_persistence_failure_aborts_execution_and_marks_job_failed() {
772 EXECUTION_CALLS.store(0, Ordering::SeqCst);
773
774 let conn = db::open_database(None).unwrap();
775 db::insert_job(&conn, "job-lineage-fail", 2, &serde_json::json!({})).unwrap();
776 conn.execute_batch(
777 "CREATE TRIGGER fail_lineage_update
778 BEFORE UPDATE OF attempt_id, trial_id ON queue_jobs
779 BEGIN
780 SELECT RAISE(FAIL, 'lineage write blocked');
781 END;",
782 )
783 .unwrap();
784
785 let db = Arc::new(Mutex::new(conn));
786 let config = QueueConfig::builder().with_worker_id("worker-test").build();
787 let executor = QueueExecutor::new(config, db.clone());
788 let emitter = Arc::new(RecordingEmitter::default());
789 let emitter_trait: Arc<dyn QueueEventEmitter> = emitter.clone();
790
791 let err = executor
792 .process_one::<CountingJob>(&emitter_trait)
793 .await
794 .unwrap_err();
795 assert!(
796 err.to_string()
797 .contains("Canonical lineage persistence failed before execution"),
798 "expected explicit lineage failure, got: {err}"
799 );
800 assert_eq!(
801 EXECUTION_CALLS.load(Ordering::SeqCst),
802 0,
803 "job handler must not run when canonical lineage persistence fails"
804 );
805
806 let conn = db.lock().unwrap();
807 let details = db::get_job_details(&conn, "job-lineage-fail")
808 .unwrap()
809 .expect("job details");
810 assert_eq!(details.status.as_str(), "failed");
811 assert!(
812 details
813 .error_message
814 .as_deref()
815 .unwrap_or_default()
816 .contains("Canonical lineage persistence failed before execution"),
817 "failure must be persisted on the job row"
818 );
819
820 let failed = emitter.failed.lock().unwrap();
821 assert_eq!(failed.len(), 1, "executor must emit a hard failure event");
822 assert!(failed[0].attempt_id.is_some());
823 assert!(failed[0].trial_id.is_some());
824 assert!(
825 failed[0]
826 .error
827 .contains("Canonical lineage persistence failed before execution"),
828 "event must surface the lineage persistence error"
829 );
830 }
831}