Skip to main content

faucet_source_pubsub/
stream.rs

1//! The Pub/Sub `Source` implementation: streaming pull, per-message record
2//! assembly, cumulative informational bookmark, ack **at durable page
3//! boundaries**, and idle / max-messages termination.
4//!
5//! **SDK-touching module.** All `gcloud-pubsub` calls live here (client
6//! construction is in `faucet-common-pubsub`), so a real-compile fixup for a
7//! differing SDK version is localised to `pull_messages`, `ack_messages`,
8//! `subscribe`, and `check`.
9
10use crate::config::PubsubSourceConfig;
11use crate::convert::{message_to_record, timestamp_millis};
12use crate::state::{PubsubBookmark, state_key};
13use faucet_core::{FaucetError, Stream, StreamPage};
14use gcloud_pubsub::client::Client;
15use gcloud_pubsub::subscriber::ReceivedMessage;
16use gcloud_pubsub::subscription::Subscription;
17use serde_json::Value;
18use std::pin::Pin;
19use std::sync::Mutex;
20use std::time::{Duration, Instant};
21
22/// Google Cloud Pub/Sub source. See the crate README for semantics.
23pub struct PubsubSource {
24    config: PubsubSourceConfig,
25    client: Client,
26    /// Bookmark applied by the pipeline before streaming — informational only
27    /// (Pub/Sub redelivers unacked messages; there is no client-side seek).
28    start_bookmark: Mutex<Option<PubsubBookmark>>,
29}
30
31impl PubsubSource {
32    /// Create a new Pub/Sub source. Validates the config and builds the client.
33    pub async fn new(config: PubsubSourceConfig) -> Result<Self, FaucetError> {
34        config.validate()?;
35        let client = faucet_common_pubsub::build_client(&config.connection).await?;
36        Ok(Self {
37            config,
38            client,
39            start_bookmark: Mutex::new(None),
40        })
41    }
42
43    fn subscription(&self) -> Subscription {
44        self.client.subscription(&self.config.subscription)
45    }
46}
47
48/// Pull up to `max` messages. Thin SDK shim.
49async fn pull_messages(
50    subscription: &Subscription,
51    max: usize,
52) -> Result<Vec<ReceivedMessage>, FaucetError> {
53    subscription
54        .pull(max as i32, None)
55        .await
56        .map_err(|e| FaucetError::Source(format!("pubsub: pull failed: {e}")))
57}
58
59/// Ack a batch of messages (best-effort — a failed ack means redelivery, i.e.
60/// at-least-once, never data loss). Thin SDK shim.
61async fn ack_messages(messages: &[ReceivedMessage]) {
62    for m in messages {
63        if let Err(e) = m.ack().await {
64            tracing::warn!(error = %e, "pubsub: ack failed; message will be redelivered");
65        }
66    }
67}
68
69impl PubsubSource {
70    /// Epoch-millis publish time of a received message, if the server set it.
71    fn publish_millis(m: &ReceivedMessage) -> Option<i64> {
72        m.message
73            .publish_time
74            .as_ref()
75            .map(|t| timestamp_millis(t.seconds, t.nanos))
76    }
77}
78
79#[faucet_core::async_trait]
80impl faucet_core::Source for PubsubSource {
81    async fn fetch_with_context(
82        &self,
83        context: &std::collections::HashMap<String, Value>,
84    ) -> Result<Vec<Value>, FaucetError> {
85        use futures::StreamExt;
86        let mut pages = self.stream_pages(context, self.config.batch_size);
87        let mut all = Vec::new();
88        while let Some(page) = pages.next().await {
89            all.extend(page?.records);
90        }
91        Ok(all)
92    }
93
94    fn stream_pages<'a>(
95        &'a self,
96        _context: &'a std::collections::HashMap<String, Value>,
97        _batch_size: usize,
98    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
99        let chunk = if self.config.batch_size == 0 {
100            usize::MAX
101        } else {
102            self.config.batch_size
103        };
104        let idle = self.config.idle_termination_secs.map(Duration::from_secs);
105        let max_messages = self.config.max_messages;
106        let per_pull = self.config.max_messages_per_pull;
107        let format = self.config.value_format;
108        let attributes_key = self.config.attributes_key.clone();
109
110        Box::pin(async_stream::try_stream! {
111            let subscription = self.subscription();
112
113            let mut cumulative = self
114                .start_bookmark
115                .lock()
116                .expect("bookmark mutex poisoned")
117                .clone()
118                .unwrap_or_default();
119
120            // Messages of pages yielded since the last ack. Acked at the top of
121            // the next iteration — by then the pipeline has written each page to
122            // the sink and persisted its bookmark, so acking is safe (a crash
123            // before the ack redelivers, i.e. at-least-once).
124            let mut pending: Vec<ReceivedMessage> = Vec::new();
125            let mut buffer: Vec<Value> = Vec::new();
126            let mut page_msgs: Vec<ReceivedMessage> = Vec::new();
127            let mut total = 0usize;
128            let mut last_activity = Instant::now();
129
130            'consume: loop {
131                if !pending.is_empty() {
132                    ack_messages(&pending).await;
133                    pending.clear();
134                }
135
136                let pull = pull_messages(&subscription, per_pull);
137                let messages = match idle {
138                    Some(window) => match tokio::time::timeout(window, pull).await {
139                        Ok(res) => res?,
140                        Err(_) => {
141                            tracing::info!(
142                                subscription = %self.config.subscription,
143                                idle_secs = window.as_secs(),
144                                "pubsub: idle termination reached"
145                            );
146                            break 'consume;
147                        }
148                    },
149                    None => pull.await?,
150                };
151
152                if messages.is_empty() {
153                    if let Some(window) = idle
154                        && last_activity.elapsed() >= window
155                    {
156                        tracing::info!(
157                            subscription = %self.config.subscription,
158                            idle_secs = window.as_secs(),
159                            "pubsub: idle termination reached"
160                        );
161                        break 'consume;
162                    }
163                    // No idle window (max-messages only) and no messages: yield
164                    // to the scheduler briefly to avoid a hot spin.
165                    tokio::time::sleep(Duration::from_millis(200)).await;
166                    continue;
167                }
168                last_activity = Instant::now();
169
170                for m in messages {
171                    let record = message_to_record(
172                        &m.message.data,
173                        &m.message.attributes,
174                        &m.message.message_id,
175                        &m.message.ordering_key,
176                        Self::publish_millis(&m),
177                        format,
178                        &attributes_key,
179                    )?;
180                    cumulative.advance(&m.message.message_id);
181                    buffer.push(record);
182                    page_msgs.push(m);
183                    total += 1;
184
185                    if buffer.len() >= chunk {
186                        let records = std::mem::take(&mut buffer);
187                        yield StreamPage {
188                            records,
189                            bookmark: Some(cumulative.to_value()),
190                        };
191                        pending.append(&mut page_msgs);
192                    }
193
194                    if let Some(max) = max_messages
195                        && total >= max
196                    {
197                        tracing::info!(
198                            subscription = %self.config.subscription,
199                            max,
200                            "pubsub: max_messages reached"
201                        );
202                        break 'consume;
203                    }
204                }
205            }
206
207            // Flush any buffered-but-un-yielded records as a final page.
208            if !buffer.is_empty() {
209                let records = std::mem::take(&mut buffer);
210                let final_msgs = std::mem::take(&mut page_msgs);
211                yield StreamPage {
212                    records,
213                    bookmark: Some(cumulative.to_value()),
214                };
215                pending.extend(final_msgs);
216            }
217
218            // Every remaining page has now resumed past its `yield`, so it is
219            // durable — ack it before returning.
220            if !pending.is_empty() {
221                ack_messages(&pending).await;
222            }
223
224            tracing::info!(
225                subscription = %self.config.subscription,
226                records = total,
227                "pubsub source stream complete"
228            );
229        })
230    }
231
232    fn config_schema(&self) -> Value {
233        serde_json::to_value(faucet_core::schema_for!(PubsubSourceConfig))
234            .expect("schema serialization")
235    }
236
237    fn state_key(&self) -> Option<String> {
238        Some(state_key(&self.config.subscription))
239    }
240
241    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
242        *self.start_bookmark.lock().expect("bookmark mutex poisoned") =
243            Some(PubsubBookmark::from_value(&bookmark));
244        Ok(())
245    }
246
247    fn connector_name(&self) -> &'static str {
248        "pubsub"
249    }
250
251    fn dataset_uri(&self) -> String {
252        format!(
253            "pubsub://{}/subscriptions/{}",
254            self.config
255                .connection
256                .project_id
257                .as_deref()
258                .unwrap_or("default"),
259            self.config.subscription
260        )
261    }
262
263    /// Side-effect-free probe: confirm the subscription exists (no messages
264    /// consumed). The default first-page probe could block for the full idle
265    /// window on a quiet subscription.
266    async fn check(
267        &self,
268        ctx: &faucet_core::CheckContext,
269    ) -> Result<faucet_core::CheckReport, FaucetError> {
270        use faucet_core::{CheckReport, Probe};
271        let start = std::time::Instant::now();
272        let subscription = self.subscription();
273        let fut = subscription.exists(None);
274        let probe = match tokio::time::timeout(ctx.timeout, fut).await {
275            Err(_) => Probe::fail("subscription_exists", start.elapsed(), "timed out"),
276            Ok(Ok(true)) => Probe::pass("subscription_exists", start.elapsed()),
277            Ok(Ok(false)) => Probe::fail(
278                "subscription_exists",
279                start.elapsed(),
280                format!("subscription '{}' does not exist", self.config.subscription),
281            ),
282            Ok(Err(e)) => Probe::fail("subscription_exists", start.elapsed(), e.to_string()),
283        };
284        Ok(CheckReport::single(probe))
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    // Constructing a live client needs the emulator or real GCP, so the
293    // network-bound trait methods are exercised by `tests/integration.rs`
294    // (emulator-gated). Here we cover the offline, pure-ish surface.
295
296    #[test]
297    fn state_key_and_dataset_uri_helpers() {
298        // dataset_uri/state_key are built from config strings only.
299        assert_eq!(state_key("orders-sub"), "pubsub:orders-sub");
300    }
301
302    #[test]
303    fn config_schema_exposes_fields() {
304        let schema = serde_json::to_value(faucet_core::schema_for!(PubsubSourceConfig)).unwrap();
305        assert!(schema["properties"]["subscription"].is_object());
306        assert!(schema["properties"]["value_format"].is_object());
307    }
308
309    #[tokio::test]
310    async fn new_rejects_config_without_termination() {
311        // Validation runs before any client build, so this fails offline with a
312        // config error (never a network error).
313        // `PubsubSource` holds a non-`Debug` SDK subscription, so `unwrap_err`
314        // (which needs the `Ok` type to be `Debug`) is unavailable — match.
315        let err = match PubsubSource::new(PubsubSourceConfig::new("orders-sub")).await {
316            Ok(_) => panic!("expected a termination-config error"),
317            Err(e) => e,
318        };
319        assert!(err.to_string().contains("idle_termination_secs"), "{err}");
320    }
321}