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}