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}