Skip to main content

ruststream_gcp_pubsub/
message.rs

1//! [`PubSubMessage`] and the mapping between `RustStream` headers and Pub/Sub attributes.
2//!
3//! Message attributes carry headers directly - no envelope format is invented - and the partition
4//! key rides the message's ordering key in both directions.
5
6// Without the `testing` feature a settlement handle has one variant, so a `match` on it has a
7// single arm; the matches stay so that the in-process arm has its place when the feature is on.
8#![cfg_attr(
9    not(feature = "testing"),
10    allow(clippy::infallible_destructuring_match)
11)]
12
13use std::collections::HashMap;
14use std::future::{Future, ready};
15use std::time::Duration;
16
17use bytes::{Bytes, BytesMut};
18use google_cloud_pubsub::model::Message as GcpMessage;
19use google_cloud_pubsub::subscriber::handler::Handler;
20#[cfg(feature = "testing")]
21use ruststream::RawMessage;
22use ruststream::{AckError, HeaderMap, IncomingMessage, Partitioned, Str};
23use tokio::time::sleep;
24
25use crate::error::{PubSubError, box_err};
26#[cfg(feature = "testing")]
27use crate::in_process::Settlement;
28use crate::runtime_slot::RuntimeSlot;
29use crate::subscription::DeliveryLimits;
30
31/// Header carrying the partition key, mapped onto the message's ordering key.
32///
33/// Mirrors the in-memory broker's convention, so services can switch brokers without changing
34/// their headers. It works in both directions: a delivery reports the key it arrived under, and a
35/// header on an outgoing message orders that message where nothing named the key natively
36/// ([`PubSubPublishOptions`](crate::PubSubPublishOptions)).
37pub const PARTITION_KEY_HEADER: &str = "partition-key";
38
39/// Header exposing the delivery attempt count on received messages, present when the
40/// subscription has a dead-letter policy.
41///
42/// Pub/Sub counts attempts on a best-effort basis, so the number may differ from the true count
43/// by a few.
44pub const DELIVERY_ATTEMPT_HEADER: &str = "pubsub-delivery-attempt";
45
46/// A message delivered by a [`PubSubSubscriber`](crate::PubSubSubscriber).
47///
48/// `ack` and `nack(requeue = true)` are native. `nack(requeue = false)` acknowledges: Pub/Sub
49/// has no "drop without redelivery" beyond acknowledgement - dead-lettering is the
50/// subscription's dead-letter policy, driven by repeated nacks and expired deadlines, not a
51/// per-message verb. The exception is the last delivery that policy allows, where a rejection is
52/// exactly how the message reaches the dead-letter topic; a delivery there is rejected rather
53/// than acknowledged. The policy is the one the subscription carries when it opens, declared at
54/// the mount site or set by an operator. On an exactly-once subscription the confirmed forms are
55/// used, so `Ok` from `ack` means the broker accepted it.
56pub struct PubSubMessage {
57    payload: Bytes,
58    headers: HeaderMap,
59    handler: Settle,
60    limits: DeliveryLimits,
61}
62
63/// What settles a delivery: the client's handler, which carries the lease, or, under the `testing`
64/// feature, the in-process transport's settlement.
65///
66/// Without the feature there is one variant, so the type is the client's handler itself and every
67/// `match` on it is irrefutable: a production delivery carries no second settlement path.
68enum Settle {
69    /// The client's handler, and where the runtime the broker connected on is found.
70    Pull(Handler, RuntimeSlot),
71    #[cfg(feature = "testing")]
72    InProcess(Settlement),
73}
74
75// The zero-cost promise of the in-process mode: a delivery built without it settles through the
76// client's own handle and the slot beside it, and nothing more.
77#[cfg(not(feature = "testing"))]
78const _: () = assert!(size_of::<Settle>() == size_of::<(Handler, RuntimeSlot)>());
79
80impl Settle {
81    /// The delivery attempt the subscription reports, present only under a dead-letter policy.
82    fn delivery_attempt(&self) -> Option<i32> {
83        match self {
84            Self::Pull(handler, _) => handler.delivery_attempt(),
85            #[cfg(feature = "testing")]
86            Self::InProcess(settlement) => settlement.delivery_attempt(),
87        }
88    }
89}
90
91impl std::fmt::Debug for PubSubMessage {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.debug_struct("PubSubMessage")
94            .field("payload_len", &self.payload.len())
95            .finish_non_exhaustive()
96    }
97}
98
99impl PubSubMessage {
100    pub(crate) fn new(
101        message: GcpMessage,
102        handler: Handler,
103        limits: DeliveryLimits,
104        runtime: RuntimeSlot,
105    ) -> Self {
106        Self::delivered(message, Settle::Pull(handler, runtime), limits)
107    }
108
109    /// A delivery of the in-process transport, read off the message it holds with the same
110    /// mapping a delivery off the service goes through.
111    #[cfg(feature = "testing")]
112    pub(crate) fn in_process(
113        message: GcpMessage,
114        settlement: Settlement,
115        limits: DeliveryLimits,
116    ) -> Self {
117        Self::delivered(message, Settle::InProcess(settlement), limits)
118    }
119
120    fn delivered(message: GcpMessage, handler: Settle, limits: DeliveryLimits) -> Self {
121        let mut headers = headers_of(message.attributes, message.ordering_key, 1);
122        if let Some(attempt) = handler.delivery_attempt() {
123            headers.insert(
124                Str::from_static(DELIVERY_ATTEMPT_HEADER),
125                attempt.to_string(),
126            );
127        }
128        Self {
129            payload: message.data,
130            headers,
131            handler,
132            limits,
133        }
134    }
135
136    /// What the in-process transport's publish log records for `message` published to `topic`:
137    /// its data, and the headers a delivery of it reports.
138    #[cfg(feature = "testing")]
139    pub(crate) fn published(topic: &str, message: GcpMessage) -> RawMessage {
140        let headers = headers_of(message.attributes, message.ordering_key, 0);
141        RawMessage::new(topic, message.data).with_headers(headers)
142    }
143
144    /// Whether this is the last delivery the subscription's dead-letter policy allows.
145    ///
146    /// A rejection here is what moves the message to the dead-letter topic, so it is the one
147    /// place a settlement meaning "do not redeliver" has to reach the service rather than
148    /// acknowledge.
149    fn at_delivery_cap(&self) -> bool {
150        match (
151            self.limits.max_delivery_attempts,
152            self.handler.delivery_attempt(),
153        ) {
154            (Some(cap), Some(attempt)) => attempt >= cap,
155            _ => false,
156        }
157    }
158
159    /// Rejects the delivery, in the form the subscription's delivery guarantee asks for.
160    async fn reject(self) -> Result<(), AckError> {
161        let handler = match self.handler {
162            Settle::Pull(handler, _) => handler,
163            #[cfg(feature = "testing")]
164            Settle::InProcess(settlement) => {
165                settlement.reject();
166                return Ok(());
167            }
168        };
169        match handler {
170            Handler::ExactlyOnce(handler) => handler
171                .confirmed_nack()
172                .await
173                .map_err(|e| AckError::Broker(Box::new(e))),
174            handler => {
175                handler.nack();
176                Ok(())
177            }
178        }
179    }
180}
181
182impl Partitioned for PubSubMessage {
183    fn partition_key(&self) -> Option<&[u8]> {
184        self.headers.get(PARTITION_KEY_HEADER)
185    }
186}
187
188impl IncomingMessage for PubSubMessage {
189    fn payload(&self) -> &[u8] {
190        &self.payload
191    }
192
193    fn headers(&self) -> &HeaderMap {
194        &self.headers
195    }
196
197    /// The delivery attempt the service reports, counting this delivery.
198    ///
199    /// Pub/Sub sends it only where the subscription has a dead-letter policy, so a subscription
200    /// without one reports no count. It is the only count a delivery of this crate carries:
201    /// nothing here publishes a retry copy, so the framework's own header never appears beside
202    /// it, and the cap the count would be read against is the subscription's to apply.
203    fn redelivery_count(&self) -> Option<u64> {
204        self.handler
205            .delivery_attempt()
206            .and_then(|attempt| u64::try_from(attempt).ok())
207    }
208
209    async fn ack(self) -> Result<(), AckError> {
210        let handler = match self.handler {
211            Settle::Pull(handler, _) => handler,
212            #[cfg(feature = "testing")]
213            Settle::InProcess(settlement) => {
214                settlement.ack();
215                return Ok(());
216            }
217        };
218        match handler {
219            // Only the confirmed form guarantees no redelivery on an exactly-once
220            // subscription; the plain form is fire-and-forget.
221            Handler::ExactlyOnce(handler) => handler
222                .confirmed_ack()
223                .await
224                .map_err(|e| AckError::Broker(Box::new(e))),
225            handler => {
226                handler.ack();
227                Ok(())
228            }
229        }
230    }
231
232    /// Pub/Sub has no delayed nack of its own, so this crate holds the delivery instead. The
233    /// client keeps extending the ack deadline of a delivery nothing has settled, which is what
234    /// makes holding one a real delay rather than a lost message.
235    fn supports_nack_after(&self) -> bool {
236        true
237    }
238
239    /// Holds the delivery for `delay`, then rejects it so the subscription redelivers.
240    ///
241    /// The delivery stays leased while it is held: the client extends its ack deadline in the
242    /// background for as long as nothing has settled it, up to
243    /// [`GooglePubSub::max_lease`](crate::GooglePubSub::max_lease). The wait runs on a task of its
244    /// own, so the subscription keeps dispatching; the delivery counts against the subscription's
245    /// outstanding messages until it comes back, which is what it does against the service too.
246    ///
247    /// If the process dies while a delivery is held, the lease stops being extended and the
248    /// subscription redelivers on its own once it expires. The delay is lost there, not the
249    /// message.
250    ///
251    /// # Errors
252    ///
253    /// Returns [`AckError::Broker`] carrying [`PubSubError::DelayBeyondLease`] when `delay`
254    /// outlives the subscription's maximum lease, because the delivery would come back before it
255    /// elapsed. The check is at the call because a handler names the delay while it runs.
256    fn nack_after(self, delay: Duration) -> impl Future<Output = Result<(), AckError>> + Send {
257        if delay > self.limits.max_lease {
258            return ready(Err(AckError::Broker(box_err(
259                PubSubError::DelayBeyondLease {
260                    requested: delay,
261                    lease: self.limits.max_lease,
262                },
263            ))));
264        }
265        // The in-process transport holds the delivery on its own clock, which is the harness's
266        // under a test, so the delay passes when the test advances it.
267        #[cfg(feature = "testing")]
268        let (this, slot) = match self {
269            Self {
270                handler: Settle::InProcess(settlement),
271                ..
272            } => {
273                settlement.reject_after(delay);
274                return ready(Ok(()));
275            }
276            this @ Self {
277                handler: Settle::Pull(_, slot),
278                ..
279            } => (this, slot),
280        };
281        #[cfg(not(feature = "testing"))]
282        let (this, slot) = {
283            let Settle::Pull(_, slot) = self.handler;
284            (self, slot)
285        };
286        // The handle travels into the task, so the client goes on extending the lease for the
287        // whole wait. The task runs on the runtime the broker connected on, not the settling
288        // caller's, which may stop before the delay is out. A task that never gets to finish
289        // drops the handle, which rejects the delivery at once rather than stranding it, and so
290        // does a delivery whose connection is already gone.
291        let Some(runtime) = slot.runtime() else {
292            drop(this);
293            return ready(Ok(()));
294        };
295        runtime.spawn(async move {
296            sleep(delay).await;
297            let _ = this.reject().await;
298        });
299        ready(Ok(()))
300    }
301
302    async fn nack(self, requeue: bool) -> Result<(), AckError> {
303        // At the cap the rejection is what moves the message: the service publishes it to the
304        // dead-letter topic instead of redelivering it, so acknowledging here would drop a
305        // message the registration asked to keep.
306        if requeue || self.at_delivery_cap() {
307            return self.reject().await;
308        }
309        // Dropping without redelivery IS an acknowledge in Pub/Sub; the dead-letter policy on
310        // the subscription owns poison-message routing.
311        self.ack().await
312    }
313
314    fn partition_key(&self) -> Option<&[u8]> {
315        Partitioned::partition_key(self)
316    }
317}
318
319/// The headers a delivery of a message with `attributes` and `ordering_key` reports, with room for
320/// `spare` more.
321fn headers_of(
322    attributes: HashMap<String, String>,
323    ordering_key: String,
324    spare: usize,
325) -> HeaderMap {
326    let mut headers = HeaderMap::with_capacity(attributes.len() + 1 + spare);
327    // The attributes are moved, not copied: a header key and a header value are both shared
328    // buffers, and a `String` becomes one without touching its bytes.
329    for (name, value) in attributes {
330        headers.insert(name, value);
331    }
332    if !ordering_key.is_empty() {
333        headers.insert(Str::from_static(PARTITION_KEY_HEADER), ordering_key);
334    }
335    headers
336}
337
338/// The text of the header `name`, which a Pub/Sub attribute or ordering key carries.
339///
340/// Both are strings on the wire, so a value that is not UTF-8 has no faithful form there: it is
341/// refused with the reason, rather than rewritten into a value the subscriber was never sent.
342///
343/// # Errors
344///
345/// Returns the reason when `value` is not UTF-8.
346pub(crate) fn header_text<'a>(name: &str, value: &'a [u8]) -> Result<&'a str, String> {
347    str::from_utf8(value).map_err(|_| {
348        format!(
349            "header {name:?} is not UTF-8, and a Pub/Sub attribute or ordering key carries text \
350             only"
351        )
352    })
353}
354
355/// Builds the Pub/Sub message for an outgoing publish under `ordering_key`, the key the publisher
356/// resolved for it.
357///
358/// The `partition-key` header never travels as an attribute: it is the portable spelling of the
359/// ordering key, the publisher has already read it, and a delivery reports the key back under that
360/// same name.
361///
362/// # Errors
363///
364/// Returns the reason when a header value is not UTF-8 (see [`header_text`]).
365pub(crate) fn to_gcp_message(
366    payload: BytesMut,
367    headers: &HeaderMap,
368    ordering_key: Option<&str>,
369) -> Result<GcpMessage, String> {
370    let mut attributes: Vec<(String, String)> = Vec::with_capacity(headers.len());
371    for (name, value) in headers.iter() {
372        if name == PARTITION_KEY_HEADER {
373            continue;
374        }
375        attributes.push((name.to_owned(), header_text(name, value)?.to_owned()));
376    }
377
378    let mut message = GcpMessage::new().set_data(payload);
379    if !attributes.is_empty() {
380        message = message.set_attributes(attributes);
381    }
382    if let Some(key) = ordering_key {
383        message = message.set_ordering_key(key);
384    }
385    Ok(message)
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn the_resolved_key_becomes_the_messages_ordering_key() {
394        let mut headers = HeaderMap::new();
395        headers.insert(PARTITION_KEY_HEADER, "user-42");
396        headers.insert("x-tenant", "acme");
397        let message = to_gcp_message(BytesMut::from(&b"{}"[..]), &headers, Some("user-42"))
398            .expect("text headers are attributes");
399        assert_eq!(message.ordering_key, "user-42");
400        assert_eq!(
401            message.attributes.get("x-tenant").map(String::as_str),
402            Some("acme")
403        );
404        // The key is a field of the message, so it must not be duplicated as an attribute.
405        assert!(!message.attributes.contains_key(PARTITION_KEY_HEADER));
406    }
407
408    // A copy and a hand-over carry the same bytes, so only the address tells them apart.
409    #[test]
410    fn the_messages_data_is_the_buffer_the_framework_wrote() {
411        let payload = BytesMut::from(&br#"{"id":1}"#[..]);
412        let written_at = payload.as_ptr();
413
414        let message =
415            to_gcp_message(payload, &HeaderMap::new(), None).expect("no headers to refuse");
416
417        assert_eq!(
418            message.data.as_ptr(),
419            written_at,
420            "Pub/Sub keeps the payload, so the buffer travels into the message rather than being \
421             copied into a second one",
422        );
423    }
424
425    #[test]
426    fn plain_messages_carry_no_ordering_key() {
427        let message = to_gcp_message(BytesMut::from(&b"{}"[..]), &HeaderMap::new(), None)
428            .expect("no headers to refuse");
429        assert_eq!(message.ordering_key, "");
430    }
431
432    /// An attribute is text, so a binary header value is refused, never rewritten.
433    #[test]
434    fn a_header_that_is_not_text_is_refused() {
435        let mut headers = HeaderMap::new();
436        headers.insert("x-binary", Bytes::from_static(&[0x00, 0x80, 0xff]));
437
438        let refused = to_gcp_message(BytesMut::from(&b"{}"[..]), &headers, None)
439            .expect_err("a non-UTF-8 value has no attribute form");
440        assert!(refused.contains("x-binary"), "{refused}");
441    }
442}