faucet_source_pubsub/
stream.rs1use 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
22pub struct PubsubSource {
24 config: PubsubSourceConfig,
25 client: Client,
26 start_bookmark: Mutex<Option<PubsubBookmark>>,
29}
30
31impl PubsubSource {
32 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
48async 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
59async 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 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 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 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 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 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 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 #[test]
297 fn state_key_and_dataset_uri_helpers() {
298 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 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}