Skip to main content

mailrs_outbound_queue/worker/
mod.rs

1//! Background delivery worker: polls the outbound queue, signs, and
2//! delivers to remote MX hosts.
3//!
4//! Sub-modules:
5//! - [`delivery`] — per-domain orchestration (MX resolve, retry/bounce,
6//!   DSN enqueue).
7//! - [`smtp`] — per-MX SMTP delivery with STARTTLS / DANE policy
8//!   handling. [`TlsPolicy`] is re-exported from here.
9
10use std::collections::HashMap;
11use std::sync::Arc;
12
13use hickory_resolver::TokioResolver;
14use mailrs_dkim::HickoryDkimResolver;
15use sqlx::PgPool;
16
17use crate::dkim_sign::{self, DkimSignConfig};
18use crate::queue::{self, QueuedMessage};
19use crate::{DeliveryEventSender};
20
21mod delivery;
22mod smtp;
23
24pub use delivery::deliver_domain_static;
25pub use smtp::{TlsPolicy, try_deliver_via_mx, try_deliver_via_mx_with_tls};
26
27/// Delivery worker configuration.
28#[derive(Debug, Clone)]
29pub struct WorkerConfig {
30    /// Polling cadence when no Valkey notify wakeup is available.
31    pub poll_interval_secs: u64,
32    /// Max queue rows fetched per poll tick.
33    pub batch_size: u32,
34    /// Cap on retry attempts before a row flips to `Bounced`.
35    pub max_attempts: u32,
36    /// Max concurrent destination domains delivered in parallel.
37    pub max_concurrent_domains: usize,
38    /// Max messages reused on a single SMTP connection (RFC 5321
39    /// recommends pipelining).
40    pub max_messages_per_connection: usize,
41}
42
43impl Default for WorkerConfig {
44    fn default() -> Self {
45        Self {
46            poll_interval_secs: 30,
47            batch_size: 50,
48            max_attempts: 8,
49            max_concurrent_domains: 8,
50            max_messages_per_connection: 50,
51        }
52    }
53}
54
55/// group queued messages by target domain for efficient delivery
56pub fn group_by_domain(messages: Vec<QueuedMessage>) -> HashMap<String, Vec<QueuedMessage>> {
57    let mut groups: HashMap<String, Vec<QueuedMessage>> = HashMap::new();
58    for msg in messages {
59        groups.entry(msg.domain.clone()).or_default().push(msg);
60    }
61    groups
62}
63
64/// background delivery worker that polls the queue and delivers messages
65pub struct DeliveryWorker {
66    config: WorkerConfig,
67    pool: PgPool,
68    resolver: TokioResolver,
69    hostname: String,
70    dkim: Option<DkimSignConfig>,
71    /// DKIM/ARC verify resolver — reuses the same hickory binding as
72    /// `resolver`, wrapped in the shape `mailrs-dkim` / `mailrs-arc`
73    /// expect. Used by ARC sealing for the verify-then-seal flow.
74    dkim_resolver: Arc<HickoryDkimResolver>,
75    event_sender: Option<DeliveryEventSender>,
76    valkey_url: Option<String>,
77}
78
79impl DeliveryWorker {
80    /// Construct a delivery worker with the given config + dependencies.
81    pub fn new(
82        config: WorkerConfig,
83        pool: PgPool,
84        resolver: TokioResolver,
85        hostname: String,
86    ) -> Self {
87        let dkim_resolver = Arc::new(HickoryDkimResolver::new(resolver.clone()));
88
89        Self {
90            config,
91            pool,
92            resolver,
93            hostname,
94            dkim: None,
95            dkim_resolver,
96            event_sender: None,
97            valkey_url: None,
98        }
99    }
100
101    /// Configure DKIM signing for outbound messages.
102    pub fn with_dkim(mut self, dkim: DkimSignConfig) -> Self {
103        self.dkim = Some(dkim);
104        self
105    }
106
107    /// Attach a [`DeliveryEventSender`] callback for external observers.
108    pub fn with_event_sender(mut self, sender: DeliveryEventSender) -> Self {
109        self.event_sender = Some(sender);
110        self
111    }
112
113    /// Set the Valkey URL to subscribe to `queue:notify` for fast wakeup.
114    pub fn with_valkey(mut self, url: String) -> Self {
115        self.valkey_url = Some(url);
116        self
117    }
118
119    /// Run the worker loop until `shutdown` signals.
120    pub async fn run(&self, mut shutdown: tokio::sync::watch::Receiver<bool>) {
121        tracing::info!(
122            "delivery worker started (poll_interval={}s)",
123            self.config.poll_interval_secs
124        );
125
126        // try to subscribe to Valkey queue:notify for fast wakeup
127        let mut notify_rx = self.spawn_valkey_listener();
128
129        loop {
130            tokio::select! {
131                _ = tokio::time::sleep(std::time::Duration::from_secs(self.config.poll_interval_secs)) => {}
132                _ = wait_for_notify(&mut notify_rx) => {}
133                _ = shutdown.changed() => {
134                    if *shutdown.borrow() {
135                        tracing::info!("delivery worker shutting down");
136                        return;
137                    }
138                }
139            }
140
141            if let Err(e) = self.poll_and_deliver().await {
142                tracing::error!("delivery worker error: {e}");
143            }
144        }
145    }
146
147    fn spawn_valkey_listener(&self) -> Option<tokio::sync::mpsc::Receiver<()>> {
148        let url = self.valkey_url.as_ref()?;
149        let (tx, rx) = tokio::sync::mpsc::channel(16);
150        let url = url.clone();
151        tokio::spawn(async move {
152            loop {
153                match redis::Client::open(url.as_str()) {
154                    Ok(client) => match client.get_async_pubsub().await {
155                        Ok(mut pubsub) => {
156                            if pubsub.subscribe("queue:notify").await.is_err() {
157                                tokio::time::sleep(std::time::Duration::from_secs(5)).await;
158                                continue;
159                            }
160                            tracing::info!("delivery worker subscribed to queue:notify");
161                            use futures_util::StreamExt;
162                            let mut stream = pubsub.on_message();
163                            while let Some(_msg) = stream.next().await {
164                                let _ = tx.try_send(());
165                            }
166                        }
167                        Err(e) => {
168                            tracing::warn!("valkey pubsub connect failed: {e}");
169                        }
170                    },
171                    Err(e) => {
172                        tracing::warn!("valkey client create failed: {e}");
173                    }
174                }
175                tokio::time::sleep(std::time::Duration::from_secs(5)).await;
176            }
177        });
178        Some(rx)
179    }
180
181    async fn poll_and_deliver(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
182        let now = chrono::Utc::now().timestamp();
183
184        // recover stale inflight messages (worker crash recovery)
185        let recovered = queue::recover_stale_inflight(&self.pool, now).await?;
186        if recovered > 0 {
187            tracing::warn!("recovered {recovered} stale inflight messages");
188        }
189
190        // Publish queue-depth gauges every poll tick. Cheap: two
191        // count(*) on an indexed `status` column. Lets ops dashboards
192        // alert on a queue that won't drain (delivery wedged) or one
193        // that's growing faster than we can flush.
194        if let Ok(pending) = queue::count_pending(&self.pool).await {
195            metrics::gauge!("mailrs_outbound_queue_depth", "status" => "pending")
196                .set(pending as f64);
197        }
198        if let Ok(inflight) = queue::count_inflight(&self.pool).await {
199            metrics::gauge!("mailrs_outbound_queue_depth", "status" => "inflight")
200                .set(inflight as f64);
201        }
202
203        // Atomic SKIP LOCKED claim + inflight transition in one
204        // statement: collapses the previous SELECT + N per-row
205        // UPDATEs (N+1 roundtrips, N+1 WAL fsyncs) into a single
206        // roundtrip and single fsync per batch, and prevents
207        // duplicate delivery in multi-worker setups (each pending
208        // row goes to at most one worker).
209        let messages = queue::claim_for_delivery(&self.pool, now, self.config.batch_size).await?;
210
211        if messages.is_empty() {
212            return Ok(());
213        }
214
215        tracing::info!("claimed {} messages for delivery", messages.len());
216
217        // apply ARC sealing (for forwarded messages) + DKIM signing.
218        //
219        // Both steps are independent across messages. ARC sealing is
220        // async (awaits DNS lookups when reconstructing the
221        // authentication-results chain); DKIM signing is CPU-bound
222        // (RSA-SHA256 over the canonicalised message). Previously
223        // these ran one-after-another for the whole batch, paying
224        // ARC's DNS RTT × N + RSA-sign × N sequentially.
225        //
226        // Now: `buffer_unordered(8)` runs up to 8 messages' ARC+DKIM
227        // concurrently. DKIM sign goes through `spawn_blocking` to
228        // keep CPU work off the tokio reactor thread — multiple
229        // signs can land on different blocking-pool threads. The
230        // ordering of the returned batch is no longer guaranteed,
231        // which is fine because each `QueuedMessage` is independent
232        // and the downstream `group_by_domain` re-sorts anyway.
233        let messages: Vec<QueuedMessage> = if let Some(ref dkim) = self.dkim {
234            use futures_util::stream::{self, StreamExt};
235            let dkim = dkim.clone();
236            let dkim_resolver = self.dkim_resolver.clone();
237            stream::iter(messages)
238                .map(|mut msg| {
239                    let dkim = dkim.clone();
240                    let dkim_resolver = dkim_resolver.clone();
241                    async move {
242                        // ARC seal forwarded messages before DKIM signing.
243                        if msg.is_forwarded {
244                            match dkim_sign::arc_seal_message(
245                                &dkim,
246                                dkim_resolver.as_ref(),
247                                &msg.message_data,
248                            )
249                            .await
250                            {
251                                Ok(sealed) => msg.message_data = sealed,
252                                Err(e) => {
253                                    tracing::warn!("ARC sealing failed for msg {}: {e}", msg.id)
254                                }
255                            }
256                        }
257                        // DKIM sign: RSA-SHA256 is CPU-bound; run on
258                        // the blocking pool so the reactor stays
259                        // responsive for the other in-flight signs.
260                        let data = std::mem::take(&mut msg.message_data);
261                        let dkim_for_sign = dkim.clone();
262                        match tokio::task::spawn_blocking(move || dkim_for_sign.sign(&data)).await {
263                            Ok(Ok(signed)) => msg.message_data = signed,
264                            Ok(Err(e)) => {
265                                tracing::warn!("DKIM signing failed for msg {}: {e}", msg.id)
266                            }
267                            Err(e) => tracing::warn!(
268                                "DKIM signing task join failed for msg {}: {e}",
269                                msg.id
270                            ),
271                        }
272                        msg
273                    }
274                })
275                .buffer_unordered(8)
276                .collect()
277                .await
278        } else {
279            messages
280        };
281
282        let groups = group_by_domain(messages);
283        let pool = self.pool.clone();
284        let semaphore = Arc::new(tokio::sync::Semaphore::new(
285            self.config.max_concurrent_domains,
286        ));
287
288        let mut handles = Vec::new();
289        for (domain, domain_messages) in groups {
290            let sem = semaphore.clone();
291            let pool = pool.clone();
292            let resolver = self.resolver.clone();
293            let hostname = self.hostname.clone();
294            let max_per_conn = self.config.max_messages_per_connection;
295            let event_sender = self.event_sender.clone();
296
297            handles.push(tokio::spawn(async move {
298                let _permit = sem.acquire().await.unwrap();
299                deliver_domain_static(
300                    &resolver,
301                    &hostname,
302                    &domain,
303                    domain_messages,
304                    &pool,
305                    25,
306                    max_per_conn,
307                    event_sender.as_ref(),
308                )
309                .await;
310            }));
311        }
312
313        for handle in handles {
314            let _ = handle.await;
315        }
316
317        Ok(())
318    }
319}
320
321/// wait for a Valkey notify signal, or never resolve if no listener
322async fn wait_for_notify(rx: &mut Option<tokio::sync::mpsc::Receiver<()>>) {
323    match rx {
324        Some(r) => {
325            r.recv().await;
326        }
327        None => std::future::pending().await,
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use crate::queue::QueueStatus;
335
336    fn make_msg(id: i64, domain: &str) -> QueuedMessage {
337        QueuedMessage {
338            id,
339            sender: "sender@example.com".into(),
340            recipient: format!("rcpt@{domain}"),
341            domain: domain.into(),
342            message_data: vec![],
343            status: QueueStatus::Pending,
344            attempts: 0,
345            max_attempts: 8,
346            next_retry: 0,
347            last_error: None,
348            message_id: None,
349            created_at: 0,
350            updated_at: 0,
351            is_forwarded: false,
352        }
353    }
354
355    #[test]
356    fn group_by_domain_groups() {
357        let messages = vec![
358            make_msg(1, "a.com"),
359            make_msg(2, "b.com"),
360            make_msg(3, "a.com"),
361        ];
362        let groups = group_by_domain(messages);
363        assert_eq!(groups.len(), 2);
364        assert_eq!(groups["a.com"].len(), 2);
365        assert_eq!(groups["b.com"].len(), 1);
366    }
367
368    #[test]
369    fn group_by_domain_empty() {
370        let groups = group_by_domain(vec![]);
371        assert!(groups.is_empty());
372    }
373
374    #[test]
375    fn delivery_worker_config_defaults() {
376        let cfg = WorkerConfig::default();
377        assert_eq!(cfg.poll_interval_secs, 30);
378        assert_eq!(cfg.batch_size, 50);
379        assert_eq!(cfg.max_attempts, 8);
380        assert_eq!(cfg.max_concurrent_domains, 8);
381        assert_eq!(cfg.max_messages_per_connection, 50);
382    }
383
384    #[test]
385    fn group_by_domain_single_domain() {
386        let messages = vec![
387            make_msg(1, "a.com"),
388            make_msg(2, "a.com"),
389            make_msg(3, "a.com"),
390        ];
391        let groups = group_by_domain(messages);
392        assert_eq!(groups.len(), 1);
393        assert_eq!(groups["a.com"].len(), 3);
394    }
395
396    #[test]
397    fn group_by_domain_preserves_order_within_group() {
398        let messages = vec![
399            make_msg(10, "x.com"),
400            make_msg(20, "y.com"),
401            make_msg(30, "x.com"),
402        ];
403        let groups = group_by_domain(messages);
404        let x_ids: Vec<i64> = groups["x.com"].iter().map(|m| m.id).collect();
405        assert_eq!(x_ids, vec![10, 30]);
406    }
407
408    #[test]
409    fn group_by_domain_many_domains() {
410        let messages: Vec<QueuedMessage> = (0..100)
411            .map(|i| make_msg(i, &format!("domain{}.com", i % 10)))
412            .collect();
413        let groups = group_by_domain(messages);
414        assert_eq!(groups.len(), 10);
415        for v in groups.values() {
416            assert_eq!(v.len(), 10);
417        }
418    }
419
420    #[test]
421    fn worker_config_clone() {
422        let cfg = WorkerConfig::default();
423        let c2 = cfg.clone();
424        assert_eq!(c2.poll_interval_secs, cfg.poll_interval_secs);
425        assert_eq!(c2.batch_size, cfg.batch_size);
426    }
427
428    #[test]
429    fn group_by_domain_message_fields_intact() {
430        let msg = QueuedMessage {
431            id: 99,
432            sender: "orig@example.com".into(),
433            recipient: "dest@target.com".into(),
434            domain: "target.com".into(),
435            message_data: vec![0xde, 0xad],
436            status: QueueStatus::Pending,
437            attempts: 2,
438            max_attempts: 5,
439            next_retry: 12345,
440            last_error: Some("timeout".into()),
441            message_id: Some("mid99".into()),
442            created_at: 111,
443            updated_at: 222,
444            is_forwarded: true,
445        };
446        let groups = group_by_domain(vec![msg]);
447        let got = &groups["target.com"][0];
448        assert_eq!(got.id, 99);
449        assert_eq!(got.sender, "orig@example.com");
450        assert_eq!(got.attempts, 2);
451        assert_eq!(got.message_data, vec![0xde, 0xad]);
452        assert!(got.is_forwarded);
453        assert_eq!(got.last_error, Some("timeout".into()));
454    }
455
456    #[test]
457    fn tls_policy_equality() {
458        assert_eq!(TlsPolicy::Opportunistic, TlsPolicy::Opportunistic);
459        assert_eq!(TlsPolicy::Require, TlsPolicy::Require);
460        assert_ne!(TlsPolicy::Opportunistic, TlsPolicy::Require);
461    }
462
463    #[test]
464    fn tls_policy_debug() {
465        let dbg = format!("{:?}", TlsPolicy::Opportunistic);
466        assert!(dbg.contains("Opportunistic"));
467        let dbg = format!("{:?}", TlsPolicy::Require);
468        assert!(dbg.contains("Require"));
469    }
470
471    #[test]
472    fn tls_policy_clone() {
473        let p = TlsPolicy::Require;
474        let p2 = p;
475        assert_eq!(p, p2);
476    }
477
478    #[test]
479    fn tls_policy_copy_semantics() {
480        // TlsPolicy is Copy — original is still usable after assignment
481        let a = TlsPolicy::Opportunistic;
482        let b = a;
483        let c = a; // a still usable after copy to b
484        assert_eq!(a, b);
485        assert_eq!(b, c);
486    }
487
488    #[test]
489    fn tls_policy_all_variants_distinct() {
490        let variants = [TlsPolicy::Opportunistic, TlsPolicy::Require];
491        for (i, a) in variants.iter().enumerate() {
492            for (j, b) in variants.iter().enumerate() {
493                if i == j {
494                    assert_eq!(a, b);
495                } else {
496                    assert_ne!(a, b);
497                }
498            }
499        }
500    }
501
502    #[test]
503    fn worker_config_custom_values() {
504        let cfg = WorkerConfig {
505            poll_interval_secs: 10,
506            batch_size: 100,
507            max_attempts: 3,
508            max_concurrent_domains: 16,
509            max_messages_per_connection: 25,
510        };
511        assert_eq!(cfg.poll_interval_secs, 10);
512        assert_eq!(cfg.batch_size, 100);
513        assert_eq!(cfg.max_attempts, 3);
514        assert_eq!(cfg.max_concurrent_domains, 16);
515        assert_eq!(cfg.max_messages_per_connection, 25);
516    }
517
518    #[test]
519    fn worker_config_debug_format() {
520        let cfg = WorkerConfig::default();
521        let dbg = format!("{:?}", cfg);
522        assert!(dbg.contains("WorkerConfig"));
523        assert!(dbg.contains("poll_interval_secs"));
524        assert!(dbg.contains("batch_size"));
525    }
526
527    #[test]
528    fn group_by_domain_unicode_domains() {
529        let messages = vec![
530            make_msg(1, "xn--e1afmapc.xn--p1ai"), // punycode domain
531            make_msg(2, "xn--e1afmapc.xn--p1ai"),
532            make_msg(3, "example.jp"),
533        ];
534        let groups = group_by_domain(messages);
535        assert_eq!(groups.len(), 2);
536        assert_eq!(groups["xn--e1afmapc.xn--p1ai"].len(), 2);
537        assert_eq!(groups["example.jp"].len(), 1);
538    }
539
540    #[test]
541    fn group_by_domain_all_unique_domains() {
542        let messages: Vec<QueuedMessage> =
543            (0..50).map(|i| make_msg(i, &format!("d{i}.com"))).collect();
544        let groups = group_by_domain(messages);
545        assert_eq!(groups.len(), 50);
546        for v in groups.values() {
547            assert_eq!(v.len(), 1);
548        }
549    }
550
551    #[test]
552    fn group_by_domain_domain_with_subdomains() {
553        // subdomains are distinct from parent domain
554        let messages = vec![
555            make_msg(1, "example.com"),
556            make_msg(2, "mail.example.com"),
557            make_msg(3, "example.com"),
558        ];
559        let groups = group_by_domain(messages);
560        assert_eq!(groups.len(), 2);
561        assert_eq!(groups["example.com"].len(), 2);
562        assert_eq!(groups["mail.example.com"].len(), 1);
563    }
564
565    /// helper: extract sender domain the same way enqueue_dsn does
566    fn extract_sender_domain(sender: &str) -> &str {
567        sender.rsplit_once('@').map(|(_, d)| d).unwrap_or("unknown")
568    }
569
570    #[test]
571    fn sender_domain_extraction_normal() {
572        assert_eq!(extract_sender_domain("user@example.com"), "example.com");
573    }
574
575    #[test]
576    fn sender_domain_extraction_no_at() {
577        assert_eq!(extract_sender_domain("noatsign"), "unknown");
578    }
579
580    #[test]
581    fn sender_domain_extraction_multiple_at() {
582        // rsplit_once splits at the last @
583        assert_eq!(extract_sender_domain("user@sub@example.com"), "example.com");
584    }
585
586    #[test]
587    fn sender_domain_extraction_empty() {
588        assert_eq!(extract_sender_domain(""), "unknown");
589    }
590
591    #[test]
592    fn sender_domain_extraction_at_only() {
593        assert_eq!(extract_sender_domain("@"), "");
594    }
595
596    #[test]
597    fn dsn_skip_empty_sender() {
598        // enqueue_dsn skips when sender is empty — verify the condition
599        let msg = make_msg(1, "example.com");
600        assert!(
601            msg.sender != "<>" && !msg.sender.is_empty(),
602            "test setup: msg has a real sender"
603        );
604
605        // empty sender should be skipped
606        let empty_sender = "";
607        assert!(empty_sender.is_empty() || empty_sender == "<>");
608
609        // null sender should be skipped
610        let null_sender = "<>";
611        assert!(null_sender.is_empty() || null_sender == "<>");
612    }
613
614    #[test]
615    fn dsn_skip_null_sender() {
616        // the "<>" check prevents infinite bounce loops (RFC 3461)
617        let null_sender = "<>";
618        let empty_sender = "";
619        let real_sender = "user@example.com";
620
621        // should skip (bounce-of-bounce prevention)
622        assert!(null_sender == "<>" || null_sender.is_empty());
623        assert!(empty_sender == "<>" || empty_sender.is_empty());
624
625        // should not skip
626        assert!(real_sender != "<>" && !real_sender.is_empty());
627    }
628
629    #[test]
630    fn retry_delay_integration_with_group_delivery() {
631        // verify retry delay for each attempt matches what the worker uses
632        use crate::retry::retry_delay_secs;
633        for attempt in 0..10u32 {
634            let delay = retry_delay_secs(attempt);
635            assert!(
636                delay >= 60,
637                "delay at attempt {attempt} should be at least 60s"
638            );
639            assert!(
640                delay <= 28800,
641                "delay at attempt {attempt} should be capped at 28800s"
642            );
643        }
644    }
645
646    #[test]
647    fn should_bounce_integration_with_worker_defaults() {
648        // with default max_attempts=8, bounces start at attempt 8
649        use crate::retry::should_bounce;
650        let max = WorkerConfig::default().max_attempts;
651        for attempt in 0..max {
652            assert!(
653                !should_bounce(attempt, max),
654                "attempt {attempt} should not bounce"
655            );
656        }
657        assert!(should_bounce(max, max), "attempt {max} should bounce");
658        assert!(
659            should_bounce(max + 1, max),
660            "attempt {} should bounce",
661            max + 1
662        );
663    }
664
665    #[test]
666    fn make_msg_helper_defaults() {
667        let msg = make_msg(42, "test.org");
668        assert_eq!(msg.id, 42);
669        assert_eq!(msg.domain, "test.org");
670        assert_eq!(msg.recipient, "rcpt@test.org");
671        assert_eq!(msg.sender, "sender@example.com");
672        assert_eq!(msg.status, QueueStatus::Pending);
673        assert_eq!(msg.attempts, 0);
674        assert_eq!(msg.max_attempts, 8);
675        assert!(!msg.is_forwarded);
676        assert!(msg.last_error.is_none());
677        assert!(msg.message_id.is_none());
678    }
679
680    #[test]
681    fn group_by_domain_large_batch() {
682        // simulate a realistic batch size matching worker config
683        let batch_size = WorkerConfig::default().batch_size;
684        let messages: Vec<QueuedMessage> = (0..batch_size as i64)
685            .map(|i| make_msg(i, &format!("domain{}.com", i % 5)))
686            .collect();
687        let groups = group_by_domain(messages);
688        assert_eq!(groups.len(), 5);
689        let total: usize = groups.values().map(|v| v.len()).sum();
690        assert_eq!(total, batch_size as usize);
691    }
692
693    #[test]
694    fn group_by_domain_ids_are_all_present() {
695        let messages = vec![
696            make_msg(100, "a.com"),
697            make_msg(200, "b.com"),
698            make_msg(300, "a.com"),
699            make_msg(400, "c.com"),
700            make_msg(500, "b.com"),
701        ];
702        let groups = group_by_domain(messages);
703        let mut all_ids: Vec<i64> = groups
704            .values()
705            .flat_map(|v| v.iter().map(|m| m.id))
706            .collect();
707        all_ids.sort();
708        assert_eq!(all_ids, vec![100, 200, 300, 400, 500]);
709    }
710}