risingwave_connector/source/google_pubsub/
mod.rs1use std::collections::HashMap;
16
17use anyhow::Context;
18use google_cloud_gax::conn::Environment;
19use google_cloud_pubsub::apiv1;
20use google_cloud_pubsub::client::google_cloud_auth::credentials::CredentialsFile;
21use google_cloud_pubsub::client::google_cloud_auth::project;
22use google_cloud_pubsub::client::google_cloud_auth::token::DefaultTokenSourceProvider;
23use google_cloud_pubsub::client::{Client, ClientConfig};
24use google_cloud_pubsub::subscriber::SubscriberConfig;
25use google_cloud_pubsub::subscription::Subscription;
26use risingwave_common::bail;
27use risingwave_common::util::env_var::env_var_is_true;
28use serde::Deserialize;
29
30pub mod enumerator;
31pub mod source;
32pub mod split;
33
34pub use enumerator::*;
35use phf::{Set, phf_set};
36use serde_with::{DisplayFromStr, serde_as};
37pub use source::*;
38pub use split::*;
39use with_options::WithOptions;
40
41use crate::connector_common::{DISABLE_DEFAULT_CREDENTIAL, resolve_pubsub_project_id};
42use crate::enforce_secret::EnforceSecret;
43use crate::error::ConnectorResult;
44use crate::source::SourceProperties;
45
46pub const GOOGLE_PUBSUB_CONNECTOR: &str = "google_pubsub";
47
48const DEFAULT_ACK_DEADLINE_SECONDS: i32 = 60;
49const DEFAULT_MAX_OUTSTANDING_MESSAGES: i64 = 1024;
52const DEFAULT_MAX_OUTSTANDING_BYTES: i64 = 1_000_000_000;
53
54#[serde_as]
59#[derive(Clone, Debug, Deserialize, WithOptions)]
60pub struct PubsubProperties {
61 #[serde(rename = "pubsub.project_id")]
64 pub project_id: Option<String>,
65
66 #[serde(rename = "pubsub.subscription")]
74 pub subscription: String,
75
76 #[serde(rename = "pubsub.emulator_host")]
79 pub emulator_host: Option<String>,
80
81 #[serde(rename = "pubsub.credentials")]
87 pub credentials: Option<String>,
88
89 #[serde(rename = "pubsub.start_offset.nanos")]
96 pub start_offset: Option<String>,
97
98 #[serde(rename = "pubsub.start_snapshot")]
107 pub start_snapshot: Option<String>,
108
109 #[serde_as(as = "Option<DisplayFromStr>")]
113 #[serde(rename = "pubsub.parallelism")]
114 pub parallelism: Option<u32>,
115
116 #[serde_as(as = "Option<DisplayFromStr>")]
120 #[serde(rename = "pubsub.ack_deadline_seconds")]
121 #[with_option(allow_alter_on_fly)]
122 pub ack_deadline_seconds: Option<i32>,
123
124 #[serde_as(as = "Option<DisplayFromStr>")]
128 #[serde(rename = "pubsub.max_outstanding_messages")]
129 #[with_option(allow_alter_on_fly)]
130 pub max_outstanding_messages: Option<i64>,
131
132 #[serde_as(as = "Option<DisplayFromStr>")]
136 #[serde(rename = "pubsub.max_outstanding_bytes")]
137 #[with_option(allow_alter_on_fly)]
138 pub max_outstanding_bytes: Option<i64>,
139
140 #[serde(flatten)]
141 pub unknown_fields: HashMap<String, String>,
142}
143
144impl EnforceSecret for PubsubProperties {
145 const ENFORCE_SECRET_PROPERTIES: Set<&'static str> = phf_set! {
146 "pubsub.credentials",
147 };
148}
149
150impl SourceProperties for PubsubProperties {
151 type Split = PubsubSplit;
152 type SplitEnumerator = PubsubSplitEnumerator;
153 type SplitReader = PubsubSplitReader;
154
155 const SOURCE_NAME: &'static str = GOOGLE_PUBSUB_CONNECTOR;
156}
157
158impl crate::source::UnknownFields for PubsubProperties {
159 fn unknown_fields(&self) -> HashMap<String, String> {
160 self.unknown_fields.clone()
161 }
162}
163
164impl PubsubProperties {
165 pub(crate) fn subscriber_config(&self) -> ConnectorResult<SubscriberConfig> {
166 let stream_ack_deadline_seconds = self
167 .ack_deadline_seconds
168 .unwrap_or(DEFAULT_ACK_DEADLINE_SECONDS);
169 if !(10..=600).contains(&stream_ack_deadline_seconds) {
170 bail!("pubsub.ack_deadline_seconds must be between 10 and 600");
171 }
172
173 let max_outstanding_messages = self
174 .max_outstanding_messages
175 .unwrap_or(DEFAULT_MAX_OUTSTANDING_MESSAGES);
176 if max_outstanding_messages <= 0 {
177 bail!("pubsub.max_outstanding_messages must be greater than 0");
178 }
179
180 let max_outstanding_bytes = self
181 .max_outstanding_bytes
182 .unwrap_or(DEFAULT_MAX_OUTSTANDING_BYTES);
183 if max_outstanding_bytes <= 0 {
184 bail!("pubsub.max_outstanding_bytes must be greater than 0");
185 }
186
187 Ok(SubscriberConfig {
188 stream_ack_deadline_seconds,
189 max_outstanding_messages,
190 max_outstanding_bytes,
191 ..Default::default()
192 })
193 }
194
195 async fn client_environment(&self) -> ConnectorResult<(Environment, Option<String>)> {
196 if let Some(emulator_host) = &self.emulator_host {
197 return Ok((Environment::Emulator(emulator_host.clone()), None));
198 }
199
200 let auth_config = project::Config::default()
201 .with_audience(apiv1::conn_pool::AUDIENCE)
202 .with_scopes(&apiv1::conn_pool::SCOPES);
203 let (environment, detected_project_id) = if let Some(credentials) = &self.credentials {
204 let credentials = CredentialsFile::new_from_str(credentials)
205 .await
206 .context("failed to parse Google Cloud Pub/Sub credentials")?;
207 let provider = DefaultTokenSourceProvider::new_with_credentials(
208 auth_config,
209 Box::new(credentials),
210 )
211 .await
212 .context("failed to initialize Google Cloud Pub/Sub token source")?;
213 let project_id = provider.project_id.clone();
214 (Environment::GoogleCloud(Box::new(provider)), project_id)
215 } else {
216 if env_var_is_true(DISABLE_DEFAULT_CREDENTIAL) {
217 bail!(
218 "Google Application Default Credentials are disabled; configure `pubsub.credentials` or `pubsub.emulator_host`"
219 );
220 }
221
222 let provider = DefaultTokenSourceProvider::new(auth_config)
223 .await
224 .context(
225 "failed to initialize Google Cloud Pub/Sub ADC; provide `pubsub.credentials`, configure ADC, or use `pubsub.emulator_host`",
226 )?;
227 let project_id = provider.project_id.clone();
228 (Environment::GoogleCloud(Box::new(provider)), project_id)
229 };
230
231 Ok((environment, detected_project_id))
232 }
233
234 pub(crate) async fn subscription_client(&self) -> ConnectorResult<Subscription> {
235 let (environment, detected_project_id) = self.client_environment().await?;
236
237 let project_id = resolve_pubsub_project_id(
238 self.project_id.as_deref(),
239 detected_project_id.as_deref(),
240 matches!(&environment, Environment::Emulator(_)),
241 )
242 .context(
243 "Google Cloud Pub/Sub project ID is unavailable; configure `pubsub.project_id` or provide credentials/ADC with a project ID",
244 )?;
245 let config = ClientConfig {
246 environment,
247 project_id: Some(project_id),
248 ..Default::default()
249 };
250 let client = Client::new(config)
251 .await
252 .context("error initializing pubsub client")?;
253
254 Ok(client.subscription(&self.subscription))
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use serde_json::json;
261
262 use super::*;
263
264 fn parse_pubsub_properties(extra: serde_json::Value) -> PubsubProperties {
265 let mut value = json!({
266 "pubsub.subscription": "projects/test/subscriptions/test",
267 "pubsub.emulator_host": "localhost:8900",
268 });
269 value
270 .as_object_mut()
271 .unwrap()
272 .extend(extra.as_object().unwrap().clone());
273 serde_json::from_value(value).unwrap()
274 }
275
276 #[test]
277 fn test_subscriber_config_defaults() {
278 let config = parse_pubsub_properties(json!({}))
279 .subscriber_config()
280 .unwrap();
281
282 assert_eq!(config.stream_ack_deadline_seconds, 60);
283 assert_eq!(config.max_outstanding_messages, 1024);
284 assert_eq!(config.max_outstanding_bytes, 1_000_000_000);
285 }
286
287 #[test]
288 fn test_subscriber_config_overrides() {
289 let config = parse_pubsub_properties(json!({
290 "pubsub.ack_deadline_seconds": "120",
291 "pubsub.max_outstanding_messages": "2048",
292 "pubsub.max_outstanding_bytes": "1048576",
293 }))
294 .subscriber_config()
295 .unwrap();
296
297 assert_eq!(config.stream_ack_deadline_seconds, 120);
298 assert_eq!(config.max_outstanding_messages, 2048);
299 assert_eq!(config.max_outstanding_bytes, 1_048_576);
300 }
301
302 #[test]
303 fn test_subscriber_config_validation() {
304 let invalid_values = [
305 (
306 json!({"pubsub.ack_deadline_seconds": "9"}),
307 "pubsub.ack_deadline_seconds must be between 10 and 600",
308 ),
309 (
310 json!({"pubsub.max_outstanding_messages": "0"}),
311 "pubsub.max_outstanding_messages must be greater than 0",
312 ),
313 (
314 json!({"pubsub.max_outstanding_bytes": "0"}),
315 "pubsub.max_outstanding_bytes must be greater than 0",
316 ),
317 ];
318
319 for (value, expected_error) in invalid_values {
320 let error = parse_pubsub_properties(value)
321 .subscriber_config()
322 .unwrap_err();
323 assert!(error.to_string().contains(expected_error));
324 }
325 }
326
327 #[tokio::test]
328 async fn test_emulator_takes_precedence_over_credentials() {
329 let properties = parse_pubsub_properties(json!({
330 "pubsub.credentials": "invalid credentials",
331 }));
332
333 let (environment, detected_project_id) = properties.client_environment().await.unwrap();
334 let Environment::Emulator(emulator_host) = environment else {
335 panic!("expected Pub/Sub emulator environment");
336 };
337 assert_eq!(emulator_host, "localhost:8900");
338 assert_eq!(detected_project_id, None);
339 }
340}