Skip to main content

awa_model/
partitioned_queue.rs

1use crate::job::InsertOpts;
2use crate::queue_storage::ordering_key_hash64;
3use std::collections::HashSet;
4
5const DEFAULT_PHYSICAL_QUEUE_SUFFIX: &str = "__p";
6const PARTITION_HASH_DOMAIN: u64 = 0x9d4d_1b2f_53a7_0c91;
7
8/// Errors returned when constructing a [`PartitionedQueue`].
9#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
10pub enum PartitionedQueueError {
11    #[error("partitioned queue logical queue must not be empty")]
12    EmptyLogicalQueue,
13    #[error("partitioned queue partitions must be > 0")]
14    ZeroPartitions,
15    #[error("partitioned queue supports at most {max} physical queues; got {got}")]
16    TooManyPhysicalQueues { got: usize, max: usize },
17    #[error("partitioned queue physical queue must not be empty")]
18    EmptyPhysicalQueue,
19    #[error("partitioned queue physical queue '{queue}' is duplicated")]
20    DuplicatePhysicalQueue { queue: String },
21}
22
23/// A deterministic set of physical queues for one hot logical queue.
24///
25/// Awa still stores and executes jobs from ordinary queue names. This helper
26/// gives producers and workers the same stable list of physical queues, so an
27/// application can partition one logical workload over several queues without
28/// hand-rolling naming and routing in every process.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct PartitionedQueue {
31    logical_queue: String,
32    physical_queues: Vec<String>,
33}
34
35impl PartitionedQueue {
36    /// Build a partitioned queue using Awa's default physical queue naming.
37    ///
38    /// One partition maps to the logical queue name itself. With more than one
39    /// partition, partition 0 is still the logical queue name and later
40    /// partitions are `{logical_queue}__p1`, `{logical_queue}__p2`, and so on.
41    /// That keeps direct enqueues to the logical name consumable during a
42    /// `1 -> N` rollout.
43    pub fn new(
44        logical_queue: impl Into<String>,
45        partitions: usize,
46    ) -> Result<Self, PartitionedQueueError> {
47        let logical_queue = logical_queue.into();
48        validate_logical_queue(&logical_queue)?;
49        validate_physical_count(partitions)?;
50
51        let mut physical_queues = Vec::with_capacity(partitions);
52        physical_queues.push(logical_queue.clone());
53        physical_queues.extend(
54            (1..partitions)
55                .map(|idx| format!("{logical_queue}{DEFAULT_PHYSICAL_QUEUE_SUFFIX}{idx}")),
56        );
57
58        Ok(Self {
59            logical_queue,
60            physical_queues,
61        })
62    }
63
64    /// Build a partitioned queue from explicit physical queue names.
65    ///
66    /// Use this when an application already has queue names it wants to keep,
67    /// or when migrating an existing manually-fanned-out deployment to the
68    /// shared helper.
69    pub fn from_physical_queues<I, S>(
70        logical_queue: impl Into<String>,
71        physical_queues: I,
72    ) -> Result<Self, PartitionedQueueError>
73    where
74        I: IntoIterator<Item = S>,
75        S: Into<String>,
76    {
77        let logical_queue = logical_queue.into();
78        validate_logical_queue(&logical_queue)?;
79
80        let physical_queues: Vec<String> = physical_queues.into_iter().map(Into::into).collect();
81        validate_physical_count(physical_queues.len())?;
82        validate_physical_queues(&physical_queues)?;
83
84        Ok(Self {
85            logical_queue,
86            physical_queues,
87        })
88    }
89
90    /// Logical queue name used by the application.
91    pub fn logical_queue(&self) -> &str {
92        &self.logical_queue
93    }
94
95    /// Physical queues that must be declared on worker runtimes.
96    pub fn physical_queues(&self) -> &[String] {
97        &self.physical_queues
98    }
99
100    /// Number of physical queues in the partitioned queue.
101    pub fn partitions(&self) -> usize {
102        self.physical_queues.len()
103    }
104
105    /// Select a physical queue by stable routing key.
106    ///
107    /// The same key always maps to the same physical queue. Partition routing
108    /// domain-separates the hash used for queue-storage enqueue shards so
109    /// `partitions == enqueue_shards` does not collapse keyed traffic onto one
110    /// shard inside each partition.
111    pub fn queue_for_key(&self, key: impl AsRef<[u8]>) -> &str {
112        let partition = partition_for_ordering_key(key.as_ref(), self.partitions());
113        &self.physical_queues[partition]
114    }
115
116    /// Select a physical queue by caller-supplied sequence number.
117    ///
118    /// This is useful for bulk producers that want round-robin partitioning and
119    /// do not need per-key ordering.
120    pub fn queue_for_index(&self, index: usize) -> &str {
121        &self.physical_queues[index % self.partitions()]
122    }
123
124    /// Return insert options routed by key.
125    ///
126    /// This sets both the physical queue and `ordering_key`, so per-key FIFO is
127    /// preserved even if the selected physical queue later uses multiple
128    /// queue-storage enqueue shards.
129    pub fn route_opts_by_key(&self, mut opts: InsertOpts, key: impl AsRef<[u8]>) -> InsertOpts {
130        let key = key.as_ref();
131        opts.queue = self.queue_for_key(key).to_string();
132        opts.ordering_key = Some(key.to_vec());
133        opts
134    }
135
136    /// Return insert options routed by round-robin index.
137    pub fn route_opts_by_index(&self, mut opts: InsertOpts, index: usize) -> InsertOpts {
138        opts.queue = self.queue_for_index(index).to_string();
139        opts
140    }
141}
142
143impl AsRef<[String]> for PartitionedQueue {
144    fn as_ref(&self) -> &[String] {
145        self.physical_queues()
146    }
147}
148
149impl<'a> IntoIterator for &'a PartitionedQueue {
150    type Item = &'a String;
151    type IntoIter = std::slice::Iter<'a, String>;
152
153    fn into_iter(self) -> Self::IntoIter {
154        self.physical_queues.iter()
155    }
156}
157
158fn validate_logical_queue(logical_queue: &str) -> Result<(), PartitionedQueueError> {
159    if logical_queue.is_empty() {
160        return Err(PartitionedQueueError::EmptyLogicalQueue);
161    }
162    Ok(())
163}
164
165fn validate_physical_count(count: usize) -> Result<(), PartitionedQueueError> {
166    if count == 0 {
167        return Err(PartitionedQueueError::ZeroPartitions);
168    }
169
170    let max = i16::MAX as usize;
171    if count > max {
172        return Err(PartitionedQueueError::TooManyPhysicalQueues { got: count, max });
173    }
174
175    Ok(())
176}
177
178/// Deterministically map an ordering key to a partition in `[0, partitions)`.
179///
180/// This uses the same portable base hash as `shard_for_ordering_key`, then
181/// applies a domain-separated SplitMix64 finalizer before taking the modulo.
182/// That keeps partition selection stable and portable without correlating it
183/// with the enqueue-shard modulo for the same key bytes.
184pub fn partition_for_ordering_key(ordering_key: &[u8], partitions: usize) -> usize {
185    if partitions <= 1 {
186        return 0;
187    }
188    (partition_hash64(ordering_key) % partitions as u64) as usize
189}
190
191/// Portable partition-routing hash over raw ordering-key bytes.
192///
193/// This is domain-separated from [`crate::queue_storage::ordering_key_hash64`]
194/// so keyed partition routing composes with queue-storage enqueue sharding.
195pub fn partition_hash64(ordering_key: &[u8]) -> u64 {
196    let mut value = ordering_key_hash64(ordering_key) ^ PARTITION_HASH_DOMAIN;
197    value ^= value >> 30;
198    value = value.wrapping_mul(0xbf58_476d_1ce4_e5b9);
199    value ^= value >> 27;
200    value = value.wrapping_mul(0x94d0_49bb_1331_11eb);
201    value ^ (value >> 31)
202}
203
204fn validate_physical_queues(queues: &[String]) -> Result<(), PartitionedQueueError> {
205    let mut seen = HashSet::with_capacity(queues.len());
206    for queue in queues {
207        if queue.is_empty() {
208            return Err(PartitionedQueueError::EmptyPhysicalQueue);
209        }
210        if !seen.insert(queue.as_str()) {
211            return Err(PartitionedQueueError::DuplicatePhysicalQueue {
212                queue: queue.clone(),
213            });
214        }
215    }
216    Ok(())
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn default_names_preserve_single_queue_shape() {
225        let queue = PartitionedQueue::new("email", 1).expect("partitioned queue should build");
226
227        assert_eq!(queue.logical_queue(), "email");
228        assert_eq!(queue.physical_queues(), &["email".to_string()]);
229        assert_eq!(queue.partitions(), 1);
230        assert_eq!(queue.queue_for_index(42), "email");
231    }
232
233    #[test]
234    fn default_names_are_stable_for_multiple_queues() {
235        let queue = PartitionedQueue::new("email", 4).expect("partitioned queue should build");
236
237        assert_eq!(
238            queue.physical_queues(),
239            &[
240                "email".to_string(),
241                "email__p1".to_string(),
242                "email__p2".to_string(),
243                "email__p3".to_string(),
244            ]
245        );
246        assert_eq!(queue.partitions(), 4);
247        assert_eq!(queue.queue_for_index(0), "email");
248        assert_eq!(queue.queue_for_index(5), "email__p1");
249    }
250
251    #[test]
252    fn key_routing_sets_queue_and_ordering_key() {
253        let queue =
254            PartitionedQueue::new("customer-updates", 4).expect("partitioned queue should build");
255
256        let opts = queue.route_opts_by_key(InsertOpts::default(), b"customer-42");
257
258        assert_eq!(opts.queue, queue.queue_for_key(b"customer-42"));
259        assert_eq!(opts.ordering_key.as_deref(), Some(&b"customer-42"[..]));
260    }
261
262    #[test]
263    fn partition_hash_is_domain_separated_from_enqueue_shard_hash() {
264        use crate::queue_storage::shard_for_ordering_key;
265
266        let partitions = 4;
267        let shards = 4;
268        let mut partition_shards = vec![HashSet::new(); partitions];
269        for idx in 0..20_000 {
270            let key = format!("customer-{idx}");
271            let partition = partition_for_ordering_key(key.as_bytes(), partitions);
272            let shard = shard_for_ordering_key(key.as_bytes(), shards);
273            partition_shards[partition].insert(shard);
274        }
275
276        for hits in partition_shards {
277            assert_eq!(hits.len(), shards as usize);
278        }
279    }
280
281    #[test]
282    fn explicit_queues_reject_empty_and_duplicate_names() {
283        let empty = PartitionedQueue::from_physical_queues("email", ["email-a", ""]);
284        assert!(matches!(
285            empty,
286            Err(PartitionedQueueError::EmptyPhysicalQueue)
287        ));
288
289        let duplicate = PartitionedQueue::from_physical_queues("email", ["email-a", "email-a"]);
290        assert!(matches!(
291            duplicate,
292            Err(PartitionedQueueError::DuplicatePhysicalQueue { queue }) if queue == "email-a"
293        ));
294    }
295
296    #[test]
297    fn explicit_queues_preserve_caller_order() {
298        let queue = PartitionedQueue::from_physical_queues(
299            "email",
300            ["email-fast", "email-bulk", "email-slow"],
301        )
302        .expect("partitioned queue should build");
303
304        assert_eq!(
305            queue.physical_queues(),
306            &[
307                "email-fast".to_string(),
308                "email-bulk".to_string(),
309                "email-slow".to_string(),
310            ]
311        );
312        assert_eq!(queue.queue_for_index(0), "email-fast");
313        assert_eq!(queue.queue_for_index(4), "email-bulk");
314    }
315
316    #[test]
317    fn partition_count_is_bounded_by_queue_storage_shard_type() {
318        let too_wide = PartitionedQueue::from_physical_queues(
319            "email",
320            (0..=(i16::MAX as usize)).map(|idx| format!("email-{idx}")),
321        );
322
323        assert!(matches!(
324            too_wide,
325            Err(PartitionedQueueError::TooManyPhysicalQueues { got, max })
326                if got == i16::MAX as usize + 1 && max == i16::MAX as usize
327        ));
328    }
329}