awa_model/
queue_fanout.rs1use crate::job::InsertOpts;
2use crate::queue_storage::shard_for_ordering_key;
3use std::collections::HashSet;
4
5const DEFAULT_PHYSICAL_QUEUE_SUFFIX: &str = "__p";
6
7#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
9pub enum QueueFanoutError {
10 #[error("queue fanout logical queue must not be empty")]
11 EmptyLogicalQueue,
12 #[error("queue fanout width must be > 0")]
13 ZeroWidth,
14 #[error("queue fanout supports at most {max} physical queues; got {got}")]
15 TooManyPhysicalQueues { got: usize, max: usize },
16 #[error("queue fanout physical queue must not be empty")]
17 EmptyPhysicalQueue,
18 #[error("queue fanout physical queue '{queue}' is duplicated")]
19 DuplicatePhysicalQueue { queue: String },
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct QueueFanout {
30 logical_queue: String,
31 physical_queues: Vec<String>,
32}
33
34impl QueueFanout {
35 pub fn new(logical_queue: impl Into<String>, width: usize) -> Result<Self, QueueFanoutError> {
40 let logical_queue = logical_queue.into();
41 validate_logical_queue(&logical_queue)?;
42 validate_physical_count(width)?;
43
44 let physical_queues = if width == 1 {
45 vec![logical_queue.clone()]
46 } else {
47 (0..width)
48 .map(|idx| format!("{logical_queue}{DEFAULT_PHYSICAL_QUEUE_SUFFIX}{idx}"))
49 .collect()
50 };
51
52 Ok(Self {
53 logical_queue,
54 physical_queues,
55 })
56 }
57
58 pub fn from_physical_queues<I, S>(
64 logical_queue: impl Into<String>,
65 physical_queues: I,
66 ) -> Result<Self, QueueFanoutError>
67 where
68 I: IntoIterator<Item = S>,
69 S: Into<String>,
70 {
71 let logical_queue = logical_queue.into();
72 validate_logical_queue(&logical_queue)?;
73
74 let physical_queues: Vec<String> = physical_queues.into_iter().map(Into::into).collect();
75 validate_physical_count(physical_queues.len())?;
76 validate_physical_queues(&physical_queues)?;
77
78 Ok(Self {
79 logical_queue,
80 physical_queues,
81 })
82 }
83
84 pub fn logical_queue(&self) -> &str {
86 &self.logical_queue
87 }
88
89 pub fn physical_queues(&self) -> &[String] {
91 &self.physical_queues
92 }
93
94 pub fn width(&self) -> usize {
96 self.physical_queues.len()
97 }
98
99 pub fn queue_for_key(&self, key: impl AsRef<[u8]>) -> &str {
104 let shard = shard_for_ordering_key(key.as_ref(), self.width() as i16) as usize;
105 &self.physical_queues[shard]
106 }
107
108 pub fn queue_for_index(&self, index: usize) -> &str {
113 &self.physical_queues[index % self.width()]
114 }
115
116 pub fn route_opts_by_key(&self, mut opts: InsertOpts, key: impl AsRef<[u8]>) -> InsertOpts {
122 let key = key.as_ref();
123 opts.queue = self.queue_for_key(key).to_string();
124 opts.ordering_key = Some(key.to_vec());
125 opts
126 }
127
128 pub fn route_opts_by_index(&self, mut opts: InsertOpts, index: usize) -> InsertOpts {
130 opts.queue = self.queue_for_index(index).to_string();
131 opts
132 }
133}
134
135impl AsRef<[String]> for QueueFanout {
136 fn as_ref(&self) -> &[String] {
137 self.physical_queues()
138 }
139}
140
141impl<'a> IntoIterator for &'a QueueFanout {
142 type Item = &'a String;
143 type IntoIter = std::slice::Iter<'a, String>;
144
145 fn into_iter(self) -> Self::IntoIter {
146 self.physical_queues.iter()
147 }
148}
149
150fn validate_logical_queue(logical_queue: &str) -> Result<(), QueueFanoutError> {
151 if logical_queue.is_empty() {
152 return Err(QueueFanoutError::EmptyLogicalQueue);
153 }
154 Ok(())
155}
156
157fn validate_physical_count(count: usize) -> Result<(), QueueFanoutError> {
158 if count == 0 {
159 return Err(QueueFanoutError::ZeroWidth);
160 }
161
162 let max = i16::MAX as usize;
163 if count > max {
164 return Err(QueueFanoutError::TooManyPhysicalQueues { got: count, max });
165 }
166
167 Ok(())
168}
169
170fn validate_physical_queues(queues: &[String]) -> Result<(), QueueFanoutError> {
171 let mut seen = HashSet::with_capacity(queues.len());
172 for queue in queues {
173 if queue.is_empty() {
174 return Err(QueueFanoutError::EmptyPhysicalQueue);
175 }
176 if !seen.insert(queue.as_str()) {
177 return Err(QueueFanoutError::DuplicatePhysicalQueue {
178 queue: queue.clone(),
179 });
180 }
181 }
182 Ok(())
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn default_names_preserve_single_queue_shape() {
191 let fanout = QueueFanout::new("email", 1).expect("fanout should build");
192
193 assert_eq!(fanout.logical_queue(), "email");
194 assert_eq!(fanout.physical_queues(), &["email".to_string()]);
195 assert_eq!(fanout.queue_for_index(42), "email");
196 }
197
198 #[test]
199 fn default_names_are_stable_for_multiple_queues() {
200 let fanout = QueueFanout::new("email", 4).expect("fanout should build");
201
202 assert_eq!(
203 fanout.physical_queues(),
204 &[
205 "email__p0".to_string(),
206 "email__p1".to_string(),
207 "email__p2".to_string(),
208 "email__p3".to_string(),
209 ]
210 );
211 assert_eq!(fanout.queue_for_index(0), "email__p0");
212 assert_eq!(fanout.queue_for_index(5), "email__p1");
213 }
214
215 #[test]
216 fn key_routing_sets_queue_and_ordering_key() {
217 let fanout = QueueFanout::new("customer-updates", 4).expect("fanout should build");
218
219 let opts = fanout.route_opts_by_key(InsertOpts::default(), b"customer-42");
220
221 assert_eq!(fanout.queue_for_key(b"customer-42"), "customer-updates__p0");
222 assert_eq!(opts.queue, fanout.queue_for_key(b"customer-42"));
223 assert_eq!(opts.ordering_key.as_deref(), Some(&b"customer-42"[..]));
224 }
225
226 #[test]
227 fn explicit_queues_reject_empty_and_duplicate_names() {
228 let empty = QueueFanout::from_physical_queues("email", ["email-a", ""]);
229 assert!(matches!(empty, Err(QueueFanoutError::EmptyPhysicalQueue)));
230
231 let duplicate = QueueFanout::from_physical_queues("email", ["email-a", "email-a"]);
232 assert!(matches!(
233 duplicate,
234 Err(QueueFanoutError::DuplicatePhysicalQueue { queue }) if queue == "email-a"
235 ));
236 }
237
238 #[test]
239 fn explicit_queues_preserve_caller_order() {
240 let fanout =
241 QueueFanout::from_physical_queues("email", ["email-fast", "email-bulk", "email-slow"])
242 .expect("fanout should build");
243
244 assert_eq!(
245 fanout.physical_queues(),
246 &[
247 "email-fast".to_string(),
248 "email-bulk".to_string(),
249 "email-slow".to_string(),
250 ]
251 );
252 assert_eq!(fanout.queue_for_index(0), "email-fast");
253 assert_eq!(fanout.queue_for_index(4), "email-bulk");
254 }
255
256 #[test]
257 fn width_is_bounded_by_queue_storage_shard_type() {
258 let too_wide = QueueFanout::from_physical_queues(
259 "email",
260 (0..=(i16::MAX as usize)).map(|idx| format!("email-{idx}")),
261 );
262
263 assert!(matches!(
264 too_wide,
265 Err(QueueFanoutError::TooManyPhysicalQueues { got, max })
266 if got == i16::MAX as usize + 1 && max == i16::MAX as usize
267 ));
268 }
269}