Skip to main content

apiplant_queue/
lib.rs

1//! # apiplant-queue
2//!
3//! Work that happens *after* the response, without a broker.
4//!
5//! A function calls `publish("order.paid", …)`, the request returns, and some
6//! milliseconds later another function runs with that message. Nothing new is
7//! deployed to make that work: the transport is the Postgres the app already
8//! has.
9//!
10//! ## Two halves, for two different reasons
11//!
12//! Publishing does two things, and it is worth being clear about which does
13//! what — because the usual mistake is to build only one of them:
14//!
15//! * **A row in `queue_message`.** This is the message. It survives a restart,
16//!   it records that an attempt failed and when the next one is due, and it is
17//!   there to be looked at when somebody asks why a welcome email never went
18//!   out. Everything about *reliability* lives here.
19//! * **A `NOTIFY`.** This is only a tap on the shoulder. It carries no payload
20//!   worth trusting and losing it costs nothing but latency, because the sweep
21//!   in [`Queue::claim`] would have found the row anyway. Everything about
22//!   *promptness* lives here.
23//!
24//! A design with only the notification (the tempting one — no table, no
25//! migration) drops every message published while nothing was listening, and
26//! has nowhere to put "this failed, try again in 20 seconds". A design with
27//! only the table polls, and a one-second poll is both too slow and too chatty.
28//! Together they are a queue.
29//!
30//! ## What is guaranteed
31//!
32//! **At-least-once.** A handler that succeeds and then dies before its row is
33//! marked `done` runs again when the lease expires. This is not a rough edge to
34//! be fixed later; it is the only honest guarantee a queue can give without the
35//! handler taking part, since "did my side effect happen?" is a question only
36//! the handler can answer. Write handlers that can run twice — check for the
37//! row you were going to insert, use the message id as an idempotency key, make
38//! the update the same update. `billing_event` exists for exactly this reason.
39//!
40//! **One subscriber, one claim.** Rows are taken with `FOR UPDATE SKIP LOCKED`,
41//! so N replicas share the work rather than each doing all of it, and no two
42//! ever hold the same message.
43//!
44//! **Order is not promised.** Messages are claimed oldest-first, but two
45//! replicas handling two messages will finish in whatever order they finish.
46//! A topic that needs strict ordering wants one subscriber and `batch = 1`,
47//! and even then a retry moves a message behind its successors.
48
49use apiplant_core::{App, QueuesConfig};
50use apiplant_db::Db;
51use sea_orm::sea_query::Value as SqlValue;
52use sea_orm::{ConnectionTrait, DatabaseBackend, DatabaseConnection, Statement};
53use serde::Serialize;
54use serde_json::{json, Value};
55
56mod listener;
57pub use listener::Listener;
58
59/// What went wrong handling a message.
60#[derive(Debug, thiserror::Error)]
61pub enum QueueError {
62    /// The request a function made isn't a queue operation.
63    #[error("invalid queue request: {0}")]
64    Request(String),
65
66    /// The database refused, or was unreachable.
67    #[error("queue: {0}")]
68    Backend(String),
69}
70
71impl From<sea_orm::DbErr> for QueueError {
72    fn from(e: sea_orm::DbErr) -> Self {
73        QueueError::Backend(e.to_string())
74    }
75}
76
77/// A published message, as the publisher hears about it.
78#[derive(Debug, Clone, Serialize)]
79pub struct Publication {
80    /// Id of the first row written. A message with several subscribers has
81    /// several rows and several ids; this is the one to quote in a log line.
82    pub id: String,
83    pub topic: String,
84    /// How many subscribers it was queued for. **Zero is not an error** — the
85    /// message is still recorded — but it is almost always a typo in a topic
86    /// name, so a publisher that cares should look at it.
87    pub delivered: usize,
88}
89
90/// One message, claimed and waiting to be handled.
91#[derive(Debug, Clone)]
92pub struct Delivery {
93    pub id: String,
94    pub topic: String,
95    /// The function that subscribed to this topic.
96    pub subscriber: String,
97    /// What the publisher sent.
98    pub payload: Value,
99    /// Which attempt this is, counting from 1.
100    pub attempts: u32,
101    /// The principal that published it, or empty.
102    pub published_by: String,
103}
104
105impl Delivery {
106    /// The delivery context a handler sees through its hook, alongside the
107    /// payload it gets as input.
108    ///
109    /// `attempts` is the field worth writing a handler against: a message on
110    /// its fourth attempt is one whose side effects may already have happened.
111    pub fn context(&self) -> Value {
112        json!({
113            "event": "message",
114            "topic": self.topic,
115            "message_id": self.id,
116            "subscriber": self.subscriber,
117            "attempts": self.attempts,
118            "principal_id": self.published_by,
119            "published_by": self.published_by,
120        })
121    }
122}
123
124/// The app's queue: publish here, claim from here.
125///
126/// Cloning is cheap — the [`DatabaseConnection`] inside is a pooled handle —
127/// so every worker holds one.
128#[derive(Clone)]
129pub struct Queue {
130    conn: DatabaseConnection,
131    /// The physical table, resolved from the app's `queue_message` resource so
132    /// that an app which overrides it with its own resource still works.
133    table: String,
134    config: QueuesConfig,
135}
136
137impl std::fmt::Debug for Queue {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.debug_struct("Queue")
140            .field("table", &self.table)
141            .field("topics", &self.config.subscribe.keys().collect::<Vec<_>>())
142            .finish()
143    }
144}
145
146impl Queue {
147    /// The queue for an app. Never fails and is never optional: `publish` works
148    /// in an app whose `main.toml` says nothing about queues, because the table
149    /// is a built-in and a message with no subscriber is still worth recording.
150    pub fn new(db: &Db, app: &App) -> Self {
151        let table = app
152            .resources
153            .get("queue_message")
154            .map(|r| r.table_name())
155            .unwrap_or_else(|| "apiplant_queue_message".to_string());
156        Queue {
157            conn: db.connection().clone(),
158            table,
159            config: app.config.queues.clone(),
160        }
161    }
162
163    pub fn config(&self) -> &QueuesConfig {
164        &self.config
165    }
166
167    /// Every topic this app subscribes to.
168    pub fn topics(&self) -> Vec<String> {
169        self.config.subscribe.keys().cloned().collect()
170    }
171
172    /// Add the index the claim query needs, if it isn't there.
173    ///
174    /// Not left to the migrator: the migrator's job is to make columns match
175    /// the resource declarations, and this index is not a property of the
176    /// schema but of one query — the `(status, available_at)` lookup every
177    /// subscriber runs on every sweep, which is a sequential scan of the whole
178    /// ledger without it. Retention keeps that table small in a healthy app and
179    /// large in exactly the app that is having a bad day.
180    pub async fn prepare(&self) -> Result<(), QueueError> {
181        let sql = format!(
182            "CREATE INDEX IF NOT EXISTS {index} ON {table} (status, available_at)",
183            index = quote(&format!("idx_{}_claim", self.table))?,
184            table = quote(&self.table)?,
185        );
186        self.execute_sql(sql, vec![]).await?;
187        Ok(())
188    }
189
190    /// Publish a message: one row per subscriber, then one notification.
191    ///
192    /// The order matters and is not an accident. The rows are committed first,
193    /// so a subscriber woken by the notification always finds them; notifying
194    /// first would race, and the loser would be a wakeup for work that isn't
195    /// visible yet — which looks exactly like a queue that randomly adds 30
196    /// seconds of latency.
197    pub async fn publish(
198        &self,
199        topic: &str,
200        message: &Value,
201        published_by: &str,
202    ) -> Result<Publication, QueueError> {
203        let topic = topic.trim();
204        if !QueuesConfig::valid_topic(topic) {
205            return Err(QueueError::Request(format!(
206                "`{topic}` is not a topic: use letters, digits, `.`, `_`, `-` or `:`"
207            )));
208        }
209
210        let subscribers = self.config.subscribers(topic);
211        // A topic nobody listens to still gets a row. The alternative is
212        // publishing into silence, and then the only evidence that a message
213        // was ever sent is the absence of its effect — which is the hardest
214        // kind of bug to be handed.
215        let rows: Vec<&str> = match subscribers.is_empty() {
216            true => vec![""],
217            false => subscribers.iter().map(String::as_str).collect(),
218        };
219
220        let mut ids = Vec::with_capacity(rows.len());
221        for subscriber in &rows {
222            let id = uuid::Uuid::new_v4();
223            // A row for nobody is born finished: it is a record, not work.
224            let status = match subscriber.is_empty() {
225                true => "done",
226                false => "pending",
227            };
228            let sql = format!(
229                "INSERT INTO {table} \
230                 (\"id\", \"topic\", \"subscriber\", \"status\", \"payload\", \"attempts\", \
231                  \"available_at\", \"processed_at\", \"published_by\", \"created_at\", \"updated_at\") \
232                 VALUES ($1, $2, $3, $4, $5, 0, now(), \
233                         CASE WHEN $4 = 'done' THEN now() ELSE NULL END, $6, now(), now())",
234                table = quote(&self.table)?,
235            );
236            self.execute_sql(
237                sql,
238                vec![
239                    SqlValue::from(id),
240                    SqlValue::from(topic.to_string()),
241                    SqlValue::from(subscriber.to_string()),
242                    SqlValue::from(status.to_string()),
243                    SqlValue::from(message.clone()),
244                    SqlValue::from(published_by.to_string()),
245                ],
246            )
247            .await?;
248            ids.push(id.to_string());
249        }
250
251        if subscribers.is_empty() {
252            tracing::warn!(
253                topic,
254                "published to a topic nothing subscribes to — the message is recorded in \
255                 queue_message but no function will run; check [queues.subscribe]"
256            );
257        } else {
258            // Only now, and only when there is something to find.
259            self.notify(topic).await?;
260        }
261
262        Ok(Publication {
263            id: ids.first().cloned().unwrap_or_default(),
264            topic: topic.to_string(),
265            delivered: subscribers.len(),
266        })
267    }
268
269    /// Wake every listening subscriber, in this process and every other.
270    ///
271    /// Failing to notify is logged, not returned: the message is already
272    /// committed, so the worst case is that it waits for the next sweep instead
273    /// of running now. Turning that into an error would fail a request whose
274    /// work is safely queued.
275    async fn notify(&self, topic: &str) -> Result<(), QueueError> {
276        let sql = "SELECT pg_notify($1, $2)".to_string();
277        let result = self
278            .execute_sql(
279                sql,
280                vec![
281                    SqlValue::from(self.config.channel()),
282                    SqlValue::from(topic.to_string()),
283                ],
284            )
285            .await;
286        if let Err(e) = result {
287            tracing::warn!(topic, error = %e, "could not notify subscribers; the message will be picked up by the next sweep");
288        }
289        Ok(())
290    }
291
292    /// Take up to `[queues] batch` messages for this app's topics.
293    ///
294    /// One statement, and that is the point: the `SELECT … FOR UPDATE SKIP
295    /// LOCKED` runs inside the `UPDATE`'s own transaction, so the rows are
296    /// claimed and the locks released in a single commit. Holding a transaction
297    /// open across the handler instead would mean one database connection tied
298    /// up per in-flight message, and a long handler blocking `VACUUM` on the
299    /// whole table.
300    ///
301    /// The `running` rows a dead worker left behind are swept back in by
302    /// [`Queue::reclaim`] rather than here, so a stuck message costs a lease
303    /// rather than being invisible.
304    pub async fn claim(&self, worker: &str) -> Result<Vec<Delivery>, QueueError> {
305        let topics = self.topics();
306        if topics.is_empty() {
307            return Ok(Vec::new());
308        }
309        let table = quote(&self.table)?;
310        // `= ANY($1)` rather than an IN-list built by string concatenation:
311        // topics come from config, but the day one is templated from anything
312        // else this is already the safe shape.
313        let sql = format!(
314            "UPDATE {table} SET \
315                \"status\" = 'running', \
316                \"attempts\" = \"attempts\" + 1, \
317                \"claimed_at\" = now(), \
318                \"claimed_by\" = $2, \
319                \"updated_at\" = now() \
320             WHERE \"id\" IN ( \
321                SELECT \"id\" FROM {table} \
322                WHERE \"status\" = 'pending' \
323                  AND \"available_at\" <= now() \
324                  AND \"subscriber\" <> '' \
325                  AND \"topic\" = ANY($1) \
326                ORDER BY \"available_at\" \
327                FOR UPDATE SKIP LOCKED \
328                LIMIT {limit} \
329             ) \
330             RETURNING \"id\"::text AS id, \"topic\", \"subscriber\", \"payload\", \
331                       \"attempts\", coalesce(\"published_by\", '') AS published_by",
332            limit = self.config.batch.max(1),
333        );
334
335        let rows = self
336            .conn
337            .query_all(Statement::from_sql_and_values(
338                DatabaseBackend::Postgres,
339                sql,
340                vec![topic_array(&topics), SqlValue::from(worker.to_string())],
341            ))
342            .await?;
343
344        rows.into_iter()
345            .map(|row| {
346                Ok(Delivery {
347                    id: row.try_get::<String>("", "id")?,
348                    topic: row.try_get::<String>("", "topic")?,
349                    subscriber: row.try_get::<String>("", "subscriber")?,
350                    payload: row.try_get::<Value>("", "payload")?,
351                    attempts: row.try_get::<i32>("", "attempts")?.max(0) as u32,
352                    published_by: row.try_get::<String>("", "published_by")?,
353                })
354            })
355            .collect()
356    }
357
358    /// Seconds until the next scheduled message becomes claimable, if there is
359    /// one waiting.
360    ///
361    /// This is what makes a retry honour the backoff it was given rather than
362    /// the poll interval. A failure schedules itself for `now() + 10s`, but
363    /// nothing publishes when a backoff expires — there is no `NOTIFY` for "a
364    /// timer went off" — so a subscriber that always waited the full
365    /// `poll_secs` would round every retry up to the next 30-second boundary.
366    /// Asking the database when to come back costs one indexed query per cycle
367    /// and makes the configured number mean what it says.
368    ///
369    /// `None` means nothing is scheduled, and the caller should wait its full
370    /// interval. `Some(0)` means something is due now.
371    pub async fn next_due(&self) -> Result<Option<u64>, QueueError> {
372        let topics = self.topics();
373        if topics.is_empty() {
374            return Ok(None);
375        }
376        let sql = format!(
377            "SELECT ceil(extract(epoch FROM (min(\"available_at\") - now())))::bigint AS wait \
378             FROM {table} \
379             WHERE \"status\" = 'pending' AND \"subscriber\" <> '' AND \"topic\" = ANY($1)",
380            table = quote(&self.table)?,
381        );
382        let row = self
383            .conn
384            .query_one(Statement::from_sql_and_values(
385                DatabaseBackend::Postgres,
386                sql,
387                vec![topic_array(&topics)],
388            ))
389            .await?;
390        // `min()` over no rows is NULL, which is "nothing is waiting" — not
391        // "come back immediately".
392        let wait: Option<i64> = match row {
393            Some(row) => row.try_get("", "wait").ok(),
394            None => None,
395        };
396        Ok(wait.map(|seconds| seconds.max(0) as u64))
397    }
398
399    /// Mark a message handled.
400    pub async fn complete(&self, id: &str) -> Result<(), QueueError> {
401        let sql = format!(
402            "UPDATE {table} SET \"status\" = 'done', \"processed_at\" = now(), \
403                    \"claimed_by\" = NULL, \"updated_at\" = now() \
404             WHERE \"id\" = $1::uuid",
405            table = quote(&self.table)?,
406        );
407        self.execute_sql(sql, vec![SqlValue::from(id.to_string())])
408            .await?;
409        Ok(())
410    }
411
412    /// Record a failed attempt: schedule the retry, or give up.
413    ///
414    /// Giving up leaves the row `failed` with its error rather than deleting
415    /// it. A dead-letter you have to go and look at is the point — a queue that
416    /// quietly discards what it could not handle is a queue that loses orders.
417    pub async fn fail(&self, delivery: &Delivery, error: &str) -> Result<bool, QueueError> {
418        let exhausted = delivery.attempts >= self.config.max_attempts.max(1);
419        let delay = self.config.retry_delay_secs(delivery.attempts);
420
421        let sql = match exhausted {
422            true => format!(
423                "UPDATE {table} SET \"status\" = 'failed', \"error\" = $2, \
424                        \"processed_at\" = now(), \"claimed_by\" = NULL, \"updated_at\" = now() \
425                 WHERE \"id\" = $1::uuid",
426                table = quote(&self.table)?,
427            ),
428            false => format!(
429                "UPDATE {table} SET \"status\" = 'pending', \"error\" = $2, \
430                        \"available_at\" = now() + make_interval(secs => {delay}), \
431                        \"claimed_by\" = NULL, \"updated_at\" = now() \
432                 WHERE \"id\" = $1::uuid",
433                table = quote(&self.table)?,
434            ),
435        };
436        self.execute_sql(
437            sql,
438            vec![
439                SqlValue::from(delivery.id.clone()),
440                SqlValue::from(truncate(error, 4000)),
441            ],
442        )
443        .await?;
444
445        match exhausted {
446            true => tracing::error!(
447                topic = %delivery.topic,
448                subscriber = %delivery.subscriber,
449                message_id = %delivery.id,
450                attempts = delivery.attempts,
451                %error,
452                "message failed for the last time — left in queue_message with status 'failed'"
453            ),
454            false => tracing::warn!(
455                topic = %delivery.topic,
456                subscriber = %delivery.subscriber,
457                message_id = %delivery.id,
458                attempt = delivery.attempts,
459                retry_in_secs = delay,
460                %error,
461                "message failed; will retry"
462            ),
463        }
464        Ok(!exhausted)
465    }
466
467    /// Offer up messages whose handler never came back.
468    ///
469    /// Returns how many were taken back. The attempt is *not* undone: a handler
470    /// that reliably kills its process — the classic out-of-memory loop — has
471    /// spent an attempt, and will run out of them and land in the dead-letter
472    /// instead of retrying until somebody notices the restart count.
473    pub async fn reclaim(&self) -> Result<u64, QueueError> {
474        let sql = format!(
475            "UPDATE {table} SET \"status\" = 'pending', \"claimed_by\" = NULL, \
476                    \"error\" = 'the subscriber holding this message stopped responding', \
477                    \"updated_at\" = now() \
478             WHERE \"status\" = 'running' \
479               AND \"claimed_at\" < now() - make_interval(secs => {lease})",
480            table = quote(&self.table)?,
481            lease = self.config.lease_secs.max(1),
482        );
483        let affected = self.execute_sql(sql, vec![]).await?;
484        if affected > 0 {
485            tracing::warn!(
486                messages = affected,
487                "reclaimed messages whose subscriber died mid-handler"
488            );
489        }
490        Ok(affected)
491    }
492
493    /// Delete handled messages older than `[queues] retain_hours`. `0` keeps
494    /// them forever.
495    ///
496    /// Only `done` rows. A `failed` row is the whole reason the ledger exists
497    /// and is never swept — if it were, the dead-letter would empty itself
498    /// overnight and the evidence would go with it.
499    pub async fn prune(&self) -> Result<u64, QueueError> {
500        if self.config.retain_hours == 0 {
501            return Ok(0);
502        }
503        let sql = format!(
504            "DELETE FROM {table} WHERE \"status\" = 'done' \
505             AND \"processed_at\" < now() - make_interval(hours => {hours})",
506            table = quote(&self.table)?,
507            hours = self.config.retain_hours,
508        );
509        self.execute_sql(sql, vec![]).await
510    }
511
512    /// Run one operation on behalf of a function. The JSON surface behind
513    /// [`HostApi::publish`](apiplant_abi::HostApi::publish).
514    pub async fn execute(&self, request: &str, published_by: &str) -> Result<Value, QueueError> {
515        let request: Value = serde_json::from_str(request)
516            .map_err(|e| QueueError::Request(format!("not JSON: {e}")))?;
517
518        let op = request
519            .get("op")
520            .and_then(Value::as_str)
521            .unwrap_or("publish");
522        match op {
523            "publish" => {
524                let topic = request
525                    .get("topic")
526                    .and_then(Value::as_str)
527                    .ok_or_else(|| QueueError::Request("`topic` is required".into()))?;
528                // A publish with no message is a signal, and a signal is a
529                // perfectly good message — `{}` rather than an error.
530                let message = request.get("message").cloned().unwrap_or_else(|| json!({}));
531                let publication = self.publish(topic, &message, published_by).await?;
532                Ok(serde_json::to_value(publication).unwrap_or(Value::Null))
533            }
534            other => Err(QueueError::Request(format!(
535                "`{other}` is not a queue operation; expected `publish`"
536            ))),
537        }
538    }
539
540    async fn execute_sql(&self, sql: String, params: Vec<SqlValue>) -> Result<u64, QueueError> {
541        let result = self
542            .conn
543            .execute(Statement::from_sql_and_values(
544                DatabaseBackend::Postgres,
545                sql,
546                params,
547            ))
548            .await?;
549        Ok(result.rows_affected())
550    }
551}
552
553/// A `text[]` parameter for the topic filter.
554fn topic_array(topics: &[String]) -> SqlValue {
555    SqlValue::Array(
556        sea_orm::sea_query::ArrayType::String,
557        Some(Box::new(
558            topics
559                .iter()
560                .map(|t| SqlValue::from(t.clone()))
561                .collect::<Vec<_>>(),
562        )),
563    )
564}
565
566/// Quote an identifier for interpolation into SQL.
567///
568/// The table name comes from the app's own resource declaration rather than
569/// from a request, but it is still the one thing here that is pasted into a
570/// statement rather than bound — so it is checked rather than trusted.
571fn quote(ident: &str) -> Result<String, QueueError> {
572    if ident.is_empty() || !ident.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
573        return Err(QueueError::Backend(format!(
574            "`{ident}` is not a usable table name"
575        )));
576    }
577    Ok(format!("\"{ident}\""))
578}
579
580/// Keep an error message inside the column, on a character boundary.
581fn truncate(text: &str, max: usize) -> String {
582    match text.len() <= max {
583        true => text.to_string(),
584        false => {
585            let mut end = max;
586            while end > 0 && !text.is_char_boundary(end) {
587                end -= 1;
588            }
589            format!("{}…", &text[..end])
590        }
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    #[test]
599    fn a_topic_is_an_identifier_not_free_text() {
600        assert!(QueuesConfig::valid_topic("order.paid"));
601        assert!(QueuesConfig::valid_topic("user:signed_up"));
602        assert!(QueuesConfig::valid_topic("a-b_c.d:e"));
603
604        assert!(!QueuesConfig::valid_topic(""));
605        assert!(!QueuesConfig::valid_topic("   "));
606        assert!(!QueuesConfig::valid_topic("order paid"));
607        assert!(!QueuesConfig::valid_topic("order'; DROP TABLE"));
608        assert!(!QueuesConfig::valid_topic(&"x".repeat(201)));
609    }
610
611    #[test]
612    fn only_identifiers_can_be_interpolated_as_a_table() {
613        assert_eq!(
614            quote("apiplant_queue_message").unwrap(),
615            "\"apiplant_queue_message\""
616        );
617        assert!(quote("").is_err());
618        assert!(quote("queue\"; DROP TABLE x --").is_err());
619        assert!(quote("public.queue").is_err());
620    }
621
622    /// The backoff is what stops a broken downstream being hammered, so its
623    /// shape matters: doubling, and capped so a retry can't be scheduled past
624    /// the point anyone is still watching.
625    #[test]
626    fn the_retry_backoff_doubles_and_is_capped() {
627        let config = QueuesConfig {
628            retry_backoff_secs: 10,
629            ..QueuesConfig::default()
630        };
631        assert_eq!(config.retry_delay_secs(1), 10);
632        assert_eq!(config.retry_delay_secs(2), 20);
633        assert_eq!(config.retry_delay_secs(3), 40);
634        assert_eq!(config.retry_delay_secs(4), 80);
635        // An hour, however many attempts have gone by — including absurd ones,
636        // which must not overflow into a tiny delay.
637        assert_eq!(config.retry_delay_secs(50), 3600);
638        assert_eq!(config.retry_delay_secs(u32::MAX), 3600);
639    }
640
641    #[test]
642    fn a_long_error_is_truncated_on_a_character_boundary() {
643        let long = "é".repeat(3000);
644        let cut = truncate(&long, 4000);
645        // 4000 bytes of message plus the three-byte ellipsis.
646        assert!(cut.len() <= 4003, "{} bytes", cut.len());
647        assert!(cut.ends_with('…'));
648        // The point of the boundary walk: this must not panic or produce
649        // invalid UTF-8.
650        assert!(cut.chars().count() > 1);
651    }
652
653    #[test]
654    fn subscriptions_are_read_as_one_name_or_several() {
655        let config: QueuesConfig = toml::from_str(
656            r#"
657            [subscribe]
658            "order.paid" = "fulfil"
659            "user.signed_up" = ["welcome", "crm_sync"]
660            "ignored" = []
661        "#,
662        )
663        .unwrap();
664        assert_eq!(config.subscribers("order.paid"), ["fulfil"]);
665        assert_eq!(
666            config.subscribers("user.signed_up"),
667            ["welcome", "crm_sync"]
668        );
669        // A topic with no subscriber is not a subscription.
670        assert!(config.subscribers("ignored").is_empty());
671        assert!(config.subscribers("never.declared").is_empty());
672    }
673}