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 pub(crate) async fn subscription_client(&self) -> ConnectorResult<Subscription> {
196 let auth_config = project::Config::default()
197 .with_audience(apiv1::conn_pool::AUDIENCE)
198 .with_scopes(&apiv1::conn_pool::SCOPES);
199 let (environment, detected_project_id) = if let Some(credentials) = &self.credentials {
200 let credentials = CredentialsFile::new_from_str(credentials)
201 .await
202 .context("failed to parse Google Cloud Pub/Sub credentials")?;
203 let provider = DefaultTokenSourceProvider::new_with_credentials(
204 auth_config,
205 Box::new(credentials),
206 )
207 .await
208 .context("failed to initialize Google Cloud Pub/Sub token source")?;
209 let project_id = provider.project_id.clone();
210 (Environment::GoogleCloud(Box::new(provider)), project_id)
211 } else if let Some(emulator_host) = &self.emulator_host {
212 (Environment::Emulator(emulator_host.clone()), None)
213 } else {
214 if env_var_is_true(DISABLE_DEFAULT_CREDENTIAL) {
215 bail!(
216 "Google Application Default Credentials are disabled; configure `pubsub.credentials` or `pubsub.emulator_host`"
217 );
218 }
219
220 let provider = DefaultTokenSourceProvider::new(auth_config)
221 .await
222 .context(
223 "failed to initialize Google Cloud Pub/Sub ADC; provide `pubsub.credentials`, configure ADC, or use `pubsub.emulator_host`",
224 )?;
225 let project_id = provider.project_id.clone();
226 (Environment::GoogleCloud(Box::new(provider)), project_id)
227 };
228
229 let project_id = resolve_pubsub_project_id(
230 self.project_id.as_deref(),
231 detected_project_id.as_deref(),
232 matches!(&environment, Environment::Emulator(_)),
233 )
234 .context(
235 "Google Cloud Pub/Sub project ID is unavailable; configure `pubsub.project_id` or provide credentials/ADC with a project ID",
236 )?;
237 let config = ClientConfig {
238 environment,
239 project_id: Some(project_id),
240 ..Default::default()
241 };
242 let client = Client::new(config)
243 .await
244 .context("error initializing pubsub client")?;
245
246 Ok(client.subscription(&self.subscription))
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use serde_json::json;
253
254 use super::*;
255
256 fn parse_pubsub_properties(extra: serde_json::Value) -> PubsubProperties {
257 let mut value = json!({
258 "pubsub.subscription": "projects/test/subscriptions/test",
259 "pubsub.emulator_host": "localhost:8900",
260 });
261 value
262 .as_object_mut()
263 .unwrap()
264 .extend(extra.as_object().unwrap().clone());
265 serde_json::from_value(value).unwrap()
266 }
267
268 #[test]
269 fn test_subscriber_config_defaults() {
270 let config = parse_pubsub_properties(json!({}))
271 .subscriber_config()
272 .unwrap();
273
274 assert_eq!(config.stream_ack_deadline_seconds, 60);
275 assert_eq!(config.max_outstanding_messages, 1024);
276 assert_eq!(config.max_outstanding_bytes, 1_000_000_000);
277 }
278
279 #[test]
280 fn test_subscriber_config_overrides() {
281 let config = parse_pubsub_properties(json!({
282 "pubsub.ack_deadline_seconds": "120",
283 "pubsub.max_outstanding_messages": "2048",
284 "pubsub.max_outstanding_bytes": "1048576",
285 }))
286 .subscriber_config()
287 .unwrap();
288
289 assert_eq!(config.stream_ack_deadline_seconds, 120);
290 assert_eq!(config.max_outstanding_messages, 2048);
291 assert_eq!(config.max_outstanding_bytes, 1_048_576);
292 }
293
294 #[test]
295 fn test_subscriber_config_validation() {
296 let invalid_values = [
297 (
298 json!({"pubsub.ack_deadline_seconds": "9"}),
299 "pubsub.ack_deadline_seconds must be between 10 and 600",
300 ),
301 (
302 json!({"pubsub.max_outstanding_messages": "0"}),
303 "pubsub.max_outstanding_messages must be greater than 0",
304 ),
305 (
306 json!({"pubsub.max_outstanding_bytes": "0"}),
307 "pubsub.max_outstanding_bytes must be greater than 0",
308 ),
309 ];
310
311 for (value, expected_error) in invalid_values {
312 let error = parse_pubsub_properties(value)
313 .subscriber_config()
314 .unwrap_err();
315 assert!(error.to_string().contains(expected_error));
316 }
317 }
318}