use crate::config::PubsubSourceConfig;
use crate::convert::{message_to_record, timestamp_millis};
use crate::state::{PubsubBookmark, state_key};
use faucet_core::{FaucetError, Stream, StreamPage};
use gcloud_pubsub::client::Client;
use gcloud_pubsub::subscriber::ReceivedMessage;
use gcloud_pubsub::subscription::Subscription;
use serde_json::Value;
use std::pin::Pin;
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub struct PubsubSource {
config: PubsubSourceConfig,
client: Client,
start_bookmark: Mutex<Option<PubsubBookmark>>,
}
impl PubsubSource {
pub async fn new(config: PubsubSourceConfig) -> Result<Self, FaucetError> {
config.validate()?;
let client = faucet_common_pubsub::build_client(&config.connection).await?;
Ok(Self {
config,
client,
start_bookmark: Mutex::new(None),
})
}
fn subscription(&self) -> Subscription {
self.client.subscription(&self.config.subscription)
}
}
async fn pull_messages(
subscription: &Subscription,
max: usize,
) -> Result<Vec<ReceivedMessage>, FaucetError> {
subscription
.pull(max as i32, None)
.await
.map_err(|e| FaucetError::Source(format!("pubsub: pull failed: {e}")))
}
async fn ack_messages(messages: &[ReceivedMessage]) {
for m in messages {
if let Err(e) = m.ack().await {
tracing::warn!(error = %e, "pubsub: ack failed; message will be redelivered");
}
}
}
impl PubsubSource {
fn publish_millis(m: &ReceivedMessage) -> Option<i64> {
m.message
.publish_time
.as_ref()
.map(|t| timestamp_millis(t.seconds, t.nanos))
}
}
#[faucet_core::async_trait]
impl faucet_core::Source for PubsubSource {
async fn fetch_with_context(
&self,
context: &std::collections::HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
use futures::StreamExt;
let mut pages = self.stream_pages(context, self.config.batch_size);
let mut all = Vec::new();
while let Some(page) = pages.next().await {
all.extend(page?.records);
}
Ok(all)
}
fn stream_pages<'a>(
&'a self,
_context: &'a std::collections::HashMap<String, Value>,
_batch_size: usize,
) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
let chunk = if self.config.batch_size == 0 {
usize::MAX
} else {
self.config.batch_size
};
let idle = self.config.idle_termination_secs.map(Duration::from_secs);
let max_messages = self.config.max_messages;
let per_pull = self.config.max_messages_per_pull;
let format = self.config.value_format;
let attributes_key = self.config.attributes_key.clone();
Box::pin(async_stream::try_stream! {
let subscription = self.subscription();
let mut cumulative = self
.start_bookmark
.lock()
.expect("bookmark mutex poisoned")
.clone()
.unwrap_or_default();
let mut pending: Vec<ReceivedMessage> = Vec::new();
let mut buffer: Vec<Value> = Vec::new();
let mut page_msgs: Vec<ReceivedMessage> = Vec::new();
let mut total = 0usize;
let mut last_activity = Instant::now();
'consume: loop {
if !pending.is_empty() {
ack_messages(&pending).await;
pending.clear();
}
let pull = pull_messages(&subscription, per_pull);
let messages = match idle {
Some(window) => match tokio::time::timeout(window, pull).await {
Ok(res) => res?,
Err(_) => {
tracing::info!(
subscription = %self.config.subscription,
idle_secs = window.as_secs(),
"pubsub: idle termination reached"
);
break 'consume;
}
},
None => pull.await?,
};
if messages.is_empty() {
if let Some(window) = idle
&& last_activity.elapsed() >= window
{
tracing::info!(
subscription = %self.config.subscription,
idle_secs = window.as_secs(),
"pubsub: idle termination reached"
);
break 'consume;
}
tokio::time::sleep(Duration::from_millis(200)).await;
continue;
}
last_activity = Instant::now();
for m in messages {
let record = message_to_record(
&m.message.data,
&m.message.attributes,
&m.message.message_id,
&m.message.ordering_key,
Self::publish_millis(&m),
format,
&attributes_key,
)?;
cumulative.advance(&m.message.message_id);
buffer.push(record);
page_msgs.push(m);
total += 1;
if buffer.len() >= chunk {
let records = std::mem::take(&mut buffer);
yield StreamPage {
records,
bookmark: Some(cumulative.to_value()),
};
pending.append(&mut page_msgs);
}
if let Some(max) = max_messages
&& total >= max
{
tracing::info!(
subscription = %self.config.subscription,
max,
"pubsub: max_messages reached"
);
break 'consume;
}
}
}
if !buffer.is_empty() {
let records = std::mem::take(&mut buffer);
let final_msgs = std::mem::take(&mut page_msgs);
yield StreamPage {
records,
bookmark: Some(cumulative.to_value()),
};
pending.extend(final_msgs);
}
if !pending.is_empty() {
ack_messages(&pending).await;
}
tracing::info!(
subscription = %self.config.subscription,
records = total,
"pubsub source stream complete"
);
})
}
fn config_schema(&self) -> Value {
serde_json::to_value(faucet_core::schema_for!(PubsubSourceConfig))
.expect("schema serialization")
}
fn state_key(&self) -> Option<String> {
Some(state_key(&self.config.subscription))
}
async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
*self.start_bookmark.lock().expect("bookmark mutex poisoned") =
Some(PubsubBookmark::from_value(&bookmark));
Ok(())
}
fn connector_name(&self) -> &'static str {
"pubsub"
}
fn dataset_uri(&self) -> String {
format!(
"pubsub://{}/subscriptions/{}",
self.config
.connection
.project_id
.as_deref()
.unwrap_or("default"),
self.config.subscription
)
}
async fn check(
&self,
ctx: &faucet_core::CheckContext,
) -> Result<faucet_core::CheckReport, FaucetError> {
use faucet_core::{CheckReport, Probe};
let start = std::time::Instant::now();
let subscription = self.subscription();
let fut = subscription.exists(None);
let probe = match tokio::time::timeout(ctx.timeout, fut).await {
Err(_) => Probe::fail("subscription_exists", start.elapsed(), "timed out"),
Ok(Ok(true)) => Probe::pass("subscription_exists", start.elapsed()),
Ok(Ok(false)) => Probe::fail(
"subscription_exists",
start.elapsed(),
format!("subscription '{}' does not exist", self.config.subscription),
),
Ok(Err(e)) => Probe::fail("subscription_exists", start.elapsed(), e.to_string()),
};
Ok(CheckReport::single(probe))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_key_and_dataset_uri_helpers() {
assert_eq!(state_key("orders-sub"), "pubsub:orders-sub");
}
#[test]
fn config_schema_exposes_fields() {
let schema = serde_json::to_value(faucet_core::schema_for!(PubsubSourceConfig)).unwrap();
assert!(schema["properties"]["subscription"].is_object());
assert!(schema["properties"]["value_format"].is_object());
}
#[tokio::test]
async fn new_rejects_config_without_termination() {
let err = match PubsubSource::new(PubsubSourceConfig::new("orders-sub")).await {
Ok(_) => panic!("expected a termination-config error"),
Err(e) => e,
};
assert!(err.to_string().contains("idle_termination_secs"), "{err}");
}
}