1use std::borrow::Cow;
2
3use apalis_core::error::BoxDynError;
4
5#[derive(Debug, thiserror::Error)]
7#[non_exhaustive]
8pub enum Error {
9 #[error("database error while {operation}: {source}{hint}", hint = database_hint(source))]
11 Database {
12 operation: Cow<'static, str>,
14 #[source]
16 source: diesel::result::Error,
17 },
18
19 #[error(
21 "failed to acquire PostgreSQL connection from r2d2 pool: {0}; check that DATABASE_URL points to a reachable PostgreSQL server and that the pool has enough connections"
22 )]
23 Pool(#[from] diesel::r2d2::PoolError),
24
25 #[error("blocking task failed: {0}")]
27 Blocking(#[source] BoxDynError),
28
29 #[error("failed to run embedded migrations: {0}")]
31 Migration(#[source] BoxDynError),
32
33 #[error("failed to convert database row into an Apalis task: {0}")]
35 Row(#[source] BoxDynError),
36
37 #[error("invalid argument: {0}")]
39 InvalidArgument(String),
40
41 #[error(
60 "idempotency_key conflict in queue `{job_type}`: keys {conflicting_keys:?} collided with the unique constraint; {total} task(s) in the batch were all rolled back"
61 )]
62 IdempotencyConflict {
63 job_type: String,
65 conflicting_keys: Vec<String>,
68 total: usize,
70 },
71
72 #[error("failed to decode task payload or result with the configured codec: {0}")]
74 Decode(#[source] BoxDynError),
75
76 #[error("json error: {0}")]
78 Json(#[from] serde_json::Error),
79
80 #[error(
82 "task metadata is missing required field `{0}`; this usually means the task did not go through the expected poll/lock/ack lifecycle"
83 )]
84 MissingField(&'static str),
85
86 #[error("worker registration already exists or is being registered concurrently: {0}")]
88 AlreadyRegistered(String),
89
90 #[error("task not found while {operation} (task_id: {task_id}, queue: {queue}); {hint}")]
92 TaskNotFound {
93 operation: Cow<'static, str>,
95 task_id: String,
97 queue: String,
99 hint: &'static str,
101 },
102
103 #[error(
105 "stale acknowledgement for task {task_id} in queue {queue} by worker {worker_id}; the task is no longer Running with the same lock owner, attempt, and lock timestamp"
106 )]
107 StaleAcknowledgement {
108 task_id: String,
110 queue: String,
112 worker_id: String,
114 },
115
116 #[error(
118 "worker not registered while {operation} (worker_id: {worker_id}, queue: {queue}); {hint}"
119 )]
120 WorkerNotRegistered {
121 operation: Cow<'static, str>,
123 worker_id: String,
125 queue: String,
127 hint: &'static str,
129 },
130
131 #[error(
133 "PostgreSQL notification listener failed: {0}; polling fallback can still fetch jobs, but LISTEN/NOTIFY wakeups are disabled until the stream is recreated"
134 )]
135 NotifyListener(String),
136
137 #[error("sink buffer is full; call poll_ready before start_send (capacity: {0})")]
139 SinkBufferFull(usize),
140}
141
142impl From<diesel::result::Error> for Error {
152 fn from(source: diesel::result::Error) -> Self {
153 Self::Database {
154 operation: Cow::Borrowed(
155 "diesel transaction begin/commit/rollback (unlabeled — use map_err inside the closure)",
156 ),
157 source,
158 }
159 }
160}
161
162impl Error {
163 pub(crate) fn database(
164 operation: impl Into<Cow<'static, str>>,
165 ) -> impl FnOnce(diesel::result::Error) -> Self {
166 let operation = operation.into();
167 move |source| Self::Database { operation, source }
168 }
169
170 pub(crate) fn task_not_found(
171 operation: impl Into<Cow<'static, str>>,
172 task_id: impl Into<String>,
173 queue: Option<String>,
174 hint: &'static str,
175 ) -> Self {
176 Self::TaskNotFound {
177 operation: operation.into(),
178 task_id: task_id.into(),
179 queue: queue.unwrap_or_else(|| "<not constrained>".to_owned()),
180 hint,
181 }
182 }
183
184 pub(crate) fn stale_acknowledgement(
185 task_id: impl Into<String>,
186 queue: impl Into<String>,
187 worker_id: impl Into<String>,
188 ) -> Self {
189 Self::StaleAcknowledgement {
190 task_id: task_id.into(),
191 queue: queue.into(),
192 worker_id: worker_id.into(),
193 }
194 }
195
196 pub(crate) fn worker_not_registered(
197 operation: impl Into<Cow<'static, str>>,
198 worker_id: impl Into<String>,
199 queue: impl Into<String>,
200 hint: &'static str,
201 ) -> Self {
202 Self::WorkerNotRegistered {
203 operation: operation.into(),
204 worker_id: worker_id.into(),
205 queue: queue.into(),
206 hint,
207 }
208 }
209
210 pub(crate) fn idempotency_conflict(
211 job_type: impl Into<String>,
212 conflicting_keys: Vec<String>,
213 total: usize,
214 ) -> Self {
215 Self::IdempotencyConflict {
216 job_type: job_type.into(),
217 conflicting_keys,
218 total,
219 }
220 }
221}
222
223fn database_hint(error: &diesel::result::Error) -> &'static str {
224 use diesel::result::Error as DieselError;
225 match error {
226 DieselError::DatabaseError(_, info) => {
232 if matches!(info.table_name(), Some(name) if name == "jobs")
233 && matches!(
234 info.constraint_name(),
235 Some(name)
236 if name == "jobs_lock_by_worker_type_fkey"
237 || name == "jobs_lock_by_fkey"
238 )
239 {
240 return "; register the worker for this queue before locking or acknowledging jobs";
241 }
242 let message = info.message();
246 if message.contains("apalis.jobs")
247 && (message.contains("does not exist") || message.contains("relation"))
248 {
249 "; run apalis_diesel_postgres::setup(&pool).await before using the storage"
250 } else if message.contains("foreign key")
251 || message.contains("jobs_lock_by_worker_type_fkey")
252 || message.contains("jobs_lock_by_fkey")
253 {
254 "; register the worker for this queue before locking or acknowledging jobs"
255 } else {
256 ""
257 }
258 }
259 _ => "",
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use diesel::result::{DatabaseErrorInformation, DatabaseErrorKind, Error as DieselError};
267 use lets_expect::{AssertionError, AssertionResult, *};
268 use std::error::Error as StdError;
269
270 struct StubInfo {
271 message: &'static str,
272 table_name: Option<&'static str>,
273 constraint_name: Option<&'static str>,
274 }
275
276 impl DatabaseErrorInformation for StubInfo {
277 fn message(&self) -> &str {
278 self.message
279 }
280 fn details(&self) -> Option<&str> {
281 None
282 }
283 fn hint(&self) -> Option<&str> {
284 None
285 }
286 fn table_name(&self) -> Option<&str> {
287 self.table_name
288 }
289 fn column_name(&self) -> Option<&str> {
290 None
291 }
292 fn constraint_name(&self) -> Option<&str> {
293 self.constraint_name
294 }
295 fn statement_position(&self) -> Option<i32> {
296 None
297 }
298 }
299
300 fn database_error_with(
301 message: &'static str,
302 table_name: Option<&'static str>,
303 constraint_name: Option<&'static str>,
304 ) -> DieselError {
305 DieselError::DatabaseError(
306 DatabaseErrorKind::Unknown,
307 Box::new(StubInfo {
308 message,
309 table_name,
310 constraint_name,
311 }),
312 )
313 }
314
315 fn hint_for(
316 message: &'static str,
317 table_name: Option<&'static str>,
318 constraint_name: Option<&'static str>,
319 ) -> &'static str {
320 database_hint(&database_error_with(message, table_name, constraint_name))
321 }
322
323 fn non_database_hint() -> &'static str {
324 database_hint(&DieselError::NotFound)
325 }
326
327 fn json_error() -> serde_json::Error {
328 serde_json::from_str::<serde_json::Value>("not json").unwrap_err()
329 }
330
331 fn boxed_error(message: &'static str) -> BoxDynError {
332 Box::new(std::io::Error::other(message))
333 }
334
335 fn database_error() -> Error {
336 Error::Database {
337 operation: Cow::Borrowed("fetching jobs"),
338 source: diesel::result::Error::NotFound,
339 }
340 }
341
342 fn database_error_with_setup_hint() -> Error {
347 Error::Database {
348 operation: Cow::Borrowed("locking task"),
349 source: database_error_with("relation \"apalis.jobs\" does not exist", None, None),
350 }
351 }
352
353 fn displays_as(expected: &'static str) -> impl Fn(&Error) -> AssertionResult {
354 move |error| {
355 let actual = error.to_string();
356 if actual == expected {
357 Ok(())
358 } else {
359 Err(AssertionError::new(vec![format!(
360 "expected display {expected:?}, got {actual:?}"
361 )]))
362 }
363 }
364 }
365
366 fn has_source_containing(expected: &'static str) -> impl Fn(&Error) -> AssertionResult {
367 move |error| match StdError::source(error) {
368 Some(source) if source.to_string().contains(expected) => Ok(()),
369 Some(source) => Err(AssertionError::new(vec![format!(
370 "expected source containing {expected:?}, got {:?}",
371 source.to_string()
372 )])),
373 None => Err(AssertionError::new(vec![format!(
374 "expected source containing {expected:?}, got no source"
375 )])),
376 }
377 }
378
379 fn has_no_source(error: &Error) -> AssertionResult {
380 match StdError::source(error) {
381 None => Ok(()),
382 Some(source) => Err(AssertionError::new(vec![format!(
383 "expected no source, got {:?}",
384 source.to_string()
385 )])),
386 }
387 }
388
389 fn is_task_not_found(error: &Error) -> AssertionResult {
390 match error {
391 Error::TaskNotFound {
392 operation,
393 task_id,
394 queue,
395 ..
396 } if *operation == "locking task" && task_id == "task-1" && queue == "queue-1" => {
397 Ok(())
398 }
399 other => Err(AssertionError::new(vec![format!(
400 "expected task not found, got {other:?}"
401 )])),
402 }
403 }
404
405 fn is_idempotency_conflict(error: &Error) -> AssertionResult {
406 match error {
407 Error::IdempotencyConflict {
408 job_type,
409 conflicting_keys,
410 total,
411 } if job_type == "emails"
412 && conflicting_keys.len() == 2
413 && conflicting_keys[0] == "k-1"
414 && conflicting_keys[1] == "k-2"
415 && *total == 3 =>
416 {
417 Ok(())
418 }
419 other => Err(AssertionError::new(vec![format!(
420 "expected idempotency conflict {{emails, [k-1, k-2], 3}}, got {other:?}"
421 )])),
422 }
423 }
424
425 lets_expect! {
426 expect(database_error()) {
427 to displays_the_operation_context { displays_as("database error while fetching jobs: Record not found") }
428 to exposes_the_database_error_as_the_source { has_source_containing("Record not found") }
429 }
430
431 expect(database_error_with_setup_hint()) {
432 to displays_the_operation_context_with_the_setup_hint_appended {
433 displays_as("database error while locking task: relation \"apalis.jobs\" does not exist; run apalis_diesel_postgres::setup(&pool).await before using the storage")
434 }
435 }
436
437 expect(Error::Blocking(boxed_error("join cancelled"))) {
438 to displays_the_blocking_error { displays_as("blocking task failed: join cancelled") }
439 to exposes_the_blocking_error_as_the_source { has_source_containing("join cancelled") }
440 }
441
442 expect(Error::Migration(boxed_error("missing migration"))) {
443 to displays_the_migration_error { displays_as("failed to run embedded migrations: missing migration") }
444 to exposes_the_migration_error_as_the_source { has_source_containing("missing migration") }
445 }
446
447 expect(Error::Row(boxed_error("bad task row"))) {
448 to displays_the_row_conversion_error { displays_as("failed to convert database row into an Apalis task: bad task row") }
449 to exposes_the_row_error_as_the_source { has_source_containing("bad task row") }
450 }
451
452 expect(Error::Decode(boxed_error("bad payload"))) {
453 to displays_the_decode_error { displays_as("failed to decode task payload or result with the configured codec: bad payload") }
454 to exposes_the_decode_error_as_the_source { has_source_containing("bad payload") }
455 }
456
457 expect(Error::Json(json_error())) {
458 to displays_the_json_error { displays_as("json error: expected ident at line 1 column 2") }
459 to exposes_the_json_error_as_the_source { has_source_containing("expected ident") }
460 }
461
462 expect(Error::MissingField("run_at")) {
463 to displays_the_missing_field_error { displays_as("task metadata is missing required field `run_at`; this usually means the task did not go through the expected poll/lock/ack lifecycle") }
464 to has_no_error_source { has_no_source }
465 }
466
467 expect(Error::AlreadyRegistered("worker-1".to_string())) {
468 to displays_the_registration_error { displays_as("worker registration already exists or is being registered concurrently: worker-1") }
469 to has_no_error_source { has_no_source }
470 }
471
472 expect(Error::idempotency_conflict(
473 "emails",
474 vec!["k-1".to_owned(), "k-2".to_owned()],
475 3,
476 )) {
477 to identifies_the_conflicting_queue_and_keys { is_idempotency_conflict }
478 to displays_the_conflict_and_rollback_semantics { displays_as("idempotency_key conflict in queue `emails`: keys [\"k-1\", \"k-2\"] collided with the unique constraint; 3 task(s) in the batch were all rolled back") }
479 to has_no_error_source { has_no_source }
480 }
481
482 expect(Error::idempotency_conflict("emails", vec![], 0)) {
487 to displays_an_empty_conflict_batch {
488 displays_as("idempotency_key conflict in queue `emails`: keys [] collided with the unique constraint; 0 task(s) in the batch were all rolled back")
489 }
490 to has_no_error_source { has_no_source }
491 }
492
493 expect(Error::task_not_found(
494 "locking task",
495 "task-1",
496 Some("queue-1".to_owned()),
497 "the task may be delayed, already locked by another worker, completed, or in another queue",
498 )) {
499 to returns_a_contextual_task_not_found_error { is_task_not_found }
500 to displays_the_next_step { displays_as("task not found while locking task (task_id: task-1, queue: queue-1); the task may be delayed, already locked by another worker, completed, or in another queue") }
501 to has_no_error_source { has_no_source }
502 }
503
504 expect(Error::task_not_found(
505 "locking task",
506 "task-1",
507 None,
508 "the task may be delayed, already locked by another worker, or completed",
509 )) {
510 when queue_is_not_constrained {
511 to renders_the_unconstrained_placeholder {
516 displays_as("task not found while locking task (task_id: task-1, queue: <not constrained>); the task may be delayed, already locked by another worker, or completed")
517 }
518 }
519 }
520
521 expect(Error::stale_acknowledgement("task-1", "queue-1", "worker-1")) {
522 to displays_the_ack_conflict { displays_as("stale acknowledgement for task task-1 in queue queue-1 by worker worker-1; the task is no longer Running with the same lock owner, attempt, and lock timestamp") }
523 to has_no_error_source { has_no_source }
524 }
525
526 expect(Error::worker_not_registered(
527 "updating worker heartbeat",
528 "worker-1",
529 "queue-1",
530 "recreate the worker stream so registration can run again",
531 )) {
532 to displays_the_worker_registration_problem { displays_as("worker not registered while updating worker heartbeat (worker_id: worker-1, queue: queue-1); recreate the worker stream so registration can run again") }
533 to has_no_error_source { has_no_source }
534 }
535
536 expect(Error::NotifyListener("LISTEN failed".to_owned())) {
537 to displays_the_notify_degradation { displays_as("PostgreSQL notification listener failed: LISTEN failed; polling fallback can still fetch jobs, but LISTEN/NOTIFY wakeups are disabled until the stream is recreated") }
538 to has_no_error_source { has_no_source }
539 }
540
541 expect(Error::SinkBufferFull(1)) {
542 to displays_the_sink_buffer_error { displays_as("sink buffer is full; call poll_ready before start_send (capacity: 1)") }
543 to has_no_error_source { has_no_source }
544 }
545
546 expect(Error::InvalidArgument("limit 99 exceeds i32::MAX".to_owned())) {
547 to displays_the_invalid_argument_error {
548 displays_as("invalid argument: limit 99 exceeds i32::MAX")
549 }
550 to has_no_error_source { has_no_source }
551 }
552
553 expect(Error::from(diesel::result::Error::NotFound)) {
554 to labels_the_unlabeled_transaction_path {
555 displays_as("database error while diesel transaction begin/commit/rollback (unlabeled — use map_err inside the closure): Record not found")
556 }
557 to exposes_the_diesel_error_as_the_source { has_source_containing("Record not found") }
558 }
559
560 expect(non_database_hint()) {
561 when diesel_error_is_not_a_database_variant {
562 to returns_no_hint { equal("") }
563 }
564 }
565
566 expect(hint_for(message, table_name, constraint_name)) {
567 let message = "irrelevant";
568 let table_name: Option<&'static str> = None;
569 let constraint_name: Option<&'static str> = None;
570
571 when structured_info_points_at_the_worker_type_foreign_key {
572 let table_name = Some("jobs");
573 let constraint_name = Some("jobs_lock_by_worker_type_fkey");
574 to recommends_registering_the_worker {
575 equal(
576 "; register the worker for this queue before locking or acknowledging jobs",
577 )
578 }
579 }
580
581 when structured_info_points_at_the_legacy_lock_by_foreign_key {
582 let table_name = Some("jobs");
583 let constraint_name = Some("jobs_lock_by_fkey");
584 to recommends_registering_the_worker_via_the_legacy_constraint {
585 equal(
586 "; register the worker for this queue before locking or acknowledging jobs",
587 )
588 }
589 }
590
591 when message_indicates_a_missing_apalis_jobs_relation_with_does_not_exist {
592 let message = "relation \"apalis.jobs\" does not exist";
593 to recommends_running_setup {
594 equal(
595 "; run apalis_diesel_postgres::setup(&pool).await before using the storage",
596 )
597 }
598 }
599
600 when message_indicates_a_missing_apalis_jobs_relation_via_the_word_relation {
601 let message = "missing relation apalis.jobs from schema";
602 to recommends_running_setup_via_the_relation_match {
603 equal(
604 "; run apalis_diesel_postgres::setup(&pool).await before using the storage",
605 )
606 }
607 }
608
609 when message_mentions_a_generic_foreign_key_violation {
610 let message = "foreign key constraint violated";
611 to recommends_registering_the_worker_via_message {
612 equal(
613 "; register the worker for this queue before locking or acknowledging jobs",
614 )
615 }
616 }
617
618 when message_mentions_the_worker_foreign_key_by_name {
619 let message = "jobs_lock_by_worker_type_fkey conflict";
620 to recommends_registering_the_worker_via_named_constraint {
621 equal(
622 "; register the worker for this queue before locking or acknowledging jobs",
623 )
624 }
625 }
626
627 when message_mentions_the_legacy_foreign_key_by_name {
628 let message = "jobs_lock_by_fkey conflict";
629 to recommends_registering_the_worker_via_legacy_named_constraint {
630 equal(
631 "; register the worker for this queue before locking or acknowledging jobs",
632 )
633 }
634 }
635
636 when message_is_unrelated_to_any_known_signal {
637 let message = "deadlock detected on update";
638 to returns_no_hint { equal("") }
639 }
640
641 when message_names_apalis_jobs_without_a_missing_relation_signal {
649 let message = "could not obtain lock on apalis.jobs";
650 to returns_no_hint { equal("") }
651 }
652
653 when message_signals_a_missing_relation_for_a_different_table {
654 let message = "relation \"audit_log\" does not exist";
655 to returns_no_hint { equal("") }
656 }
657
658 when structured_constraint_matches_an_fk_name_but_table_is_not_jobs {
659 let table_name = Some("custom_mirror");
665 let constraint_name = Some("jobs_lock_by_worker_type_fkey");
666 let message = "deadlock detected on update";
667 to falls_through_to_message_matching_and_returns_no_hint { equal("") }
668 }
669
670 when structured_table_is_jobs_but_constraint_is_an_unrelated_name {
671 let table_name = Some("jobs");
675 let constraint_name = Some("jobs_status_check");
676 let message = "violates check constraint";
677 to falls_through_to_message_matching_and_returns_no_hint { equal("") }
678 }
679 }
680 }
681}