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