Skip to main content

apalis_diesel_postgres/
error.rs

1use std::borrow::Cow;
2
3use apalis_core::error::BoxDynError;
4
5/// Error type returned by the Diesel PostgreSQL backend.
6#[derive(Debug, thiserror::Error)]
7#[non_exhaustive]
8pub enum Error {
9    /// Diesel query failed while running a named backend operation.
10    #[error("database error while {operation}: {source}{hint}", hint = database_hint(source))]
11    Database {
12        /// Backend operation that was running when Diesel returned the error.
13        operation: Cow<'static, str>,
14        /// Original Diesel error.
15        #[source]
16        source: diesel::result::Error,
17    },
18
19    /// Acquiring a pooled connection failed.
20    #[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    /// A blocking runtime task failed to complete.
26    #[error("blocking task failed: {0}")]
27    Blocking(#[source] BoxDynError),
28
29    /// Database migrations failed.
30    #[error("failed to run embedded migrations: {0}")]
31    Migration(#[source] BoxDynError),
32
33    /// A task row could not be converted into an Apalis task.
34    #[error("failed to convert database row into an Apalis task: {0}")]
35    Row(#[source] BoxDynError),
36
37    /// A caller-supplied argument was out of range for the backend.
38    #[error("invalid argument: {0}")]
39    InvalidArgument(String),
40
41    /// One or more tasks in an enqueue batch collided with the
42    /// `(job_type, idempotency_key)` unique constraint.
43    ///
44    /// The **whole batch is rolled back, not only the colliding rows**: the
45    /// duplicates are dropped by `ON CONFLICT DO NOTHING`, then the post-insert
46    /// check rolls the batch's SAVEPOINT back, so every task in the batch is
47    /// undone — including the non-conflicting ones. A surrounding (outer)
48    /// transaction stays alive, so the caller can decide whether to commit the
49    /// rest of its work or roll back. `conflicting_keys` lists exactly which
50    /// `idempotency_key`s collided. A key collides either against an
51    /// already-stored row or against another task **in the same batch**, so to
52    /// re-enqueue, *deduplicate* rather than drop: keep exactly one task per
53    /// conflicting key (dropping every task carrying the key would silently
54    /// lose intra-batch duplicates that have no stored row yet) and resubmit;
55    /// keys that still collide on the retry are duplicates of stored rows and
56    /// can then be dropped. Match this variant instead of the
57    /// [`Error::InvalidArgument`] message text to tell a benign duplicate apart
58    /// from a real failure.
59    #[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        /// Queue (`job_type`) the conflicting batch targeted.
64        job_type: String,
65        /// The distinct `idempotency_key`s that collided — either against an
66        /// already-stored row or another task in the same batch.
67        conflicting_keys: Vec<String>,
68        /// Total tasks in the batch; all of them were rolled back.
69        total: usize,
70    },
71
72    /// A task payload or task result could not be decoded.
73    #[error("failed to decode task payload or result with the configured codec: {0}")]
74    Decode(#[source] BoxDynError),
75
76    /// JSON encoding or decoding failed.
77    #[error("json error: {0}")]
78    Json(#[from] serde_json::Error),
79
80    /// A required task field was missing.
81    #[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    /// A worker or queue registration already exists.
87    #[error("worker registration already exists or is being registered concurrently: {0}")]
88    AlreadyRegistered(String),
89
90    /// A task could not be locked because it was absent or not currently lockable.
91    #[error("task not found while {operation} (task_id: {task_id}, queue: {queue}); {hint}")]
92    TaskNotFound {
93        /// Backend operation that failed.
94        operation: Cow<'static, str>,
95        /// Task id involved in the operation.
96        task_id: String,
97        /// Queue involved in the operation, or a placeholder when unconstrained.
98        queue: String,
99        /// Human-readable next step.
100        hint: &'static str,
101    },
102
103    /// A task acknowledgement no longer matches the stored lock state.
104    #[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 involved in the acknowledgement.
109        task_id: String,
110        /// Queue involved in the acknowledgement.
111        queue: String,
112        /// Worker id involved in the acknowledgement.
113        worker_id: String,
114    },
115
116    /// A worker heartbeat could not be recorded because the worker row is absent.
117    #[error(
118        "worker not registered while {operation} (worker_id: {worker_id}, queue: {queue}); {hint}"
119    )]
120    WorkerNotRegistered {
121        /// Backend operation that failed.
122        operation: Cow<'static, str>,
123        /// Worker id involved in the operation.
124        worker_id: String,
125        /// Queue involved in the operation.
126        queue: String,
127        /// Human-readable next step.
128        hint: &'static str,
129    },
130
131    /// PostgreSQL notification listener failed.
132    #[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    /// A sink producer attempted to send without observing backpressure.
138    #[error("sink buffer is full; call poll_ready before start_send (capacity: {0})")]
139    SinkBufferFull(usize),
140}
141
142// `diesel::Connection::transaction` requires the closure error type to be
143// `From<diesel::result::Error>` so its transaction_manager can lift begin /
144// commit / rollback failures (`connection/transaction_manager.rs`) into our
145// error type. **Inside the closure**, every Diesel call should still use
146// `.map_err(Error::database("specific op"))` so the operation label reflects
147// the failing statement; this `From` only fires when an unhandled
148// `diesel::result::Error` reaches `transaction()` itself (begin/commit/
149// rollback, or an inner statement whose `?` was not explicitly mapped). The
150// generic label below makes the fallback path unambiguous in logs.
151impl 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        // Locale-independent: diesel maps undefined_table errors into NotFound
227        // variants for queries that expect a result; but structured DatabaseError
228        // matches happen here. Prefer `table_name()` and `constraint_name()`
229        // (locale-independent) over `message()` substring matching, which fails
230        // on non-English PostgreSQL servers.
231        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            // Fallback: message-based detection for installations where neither
243            // table_name nor constraint_name is populated (e.g. when the
244            // relation itself does not yet exist).
245            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    /// A `Database` error whose diesel source carries a non-empty
343    /// `database_hint` (the missing-`apalis.jobs`-relation signal), so the
344    /// Display assertion exercises the `{source}{hint}` concatenation with a
345    /// NON-empty hint — the path `database_error()` (empty hint) cannot reach.
346    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        // Empty conflicting_keys is the boundary of the Vec axis. In production
483        // this state is effectively unreachable (only keyed submissions can
484        // collide), so this leaf pins the pure Display rendering of the `[]`
485        // branch rather than a reachable conflict.
486        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                // Mirrors the unscoped `lock_task` path (queries/fetch.rs):
512                // a `None` queue both renders the `<not constrained>` placeholder
513                // and drops the "or in another queue" reason, which cannot apply
514                // when the lock is not filtered by queue.
515                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            // The setup-hint predicate is a conjunction: BOTH the table-name
642            // signal ("apalis.jobs") AND a missing-relation signal
643            // ("does not exist" / "relation") must be present. These two
644            // siblings pin each operand alone so the `&&` cannot silently
645            // degrade to `||` — which would over-eagerly recommend `setup` on
646            // any unrelated relation/lookup error or on a benign mention of the
647            // jobs table.
648            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                // The combined predicate at error.rs:177-184 requires BOTH
660                // table_name == "jobs" AND constraint matching a known FK.
661                // When the table differs (e.g. a custom mirror table that
662                // happens to reuse the FK name) the structured arm must NOT
663                // fire; the message-based fallback then decides.
664                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                // Sibling to the two FK-matching `when`s above: table is
672                // "jobs" but the constraint is something else (e.g. a check
673                // constraint). The structured arm must NOT fire.
674                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}