Skip to main content

risingwave_connector/source/google_pubsub/
mod.rs

1// Copyright 2022 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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;
50// Pub/Sub messages are acknowledged only after a checkpoint. The upstream client default of 50
51// can therefore stall each reader between checkpoints and severely limit throughput.
52const DEFAULT_MAX_OUTSTANDING_MESSAGES: i64 = 1024;
53const DEFAULT_MAX_OUTSTANDING_BYTES: i64 = 1_000_000_000;
54
55/// # Implementation Notes
56/// Pub/Sub does not rely on persisted state (`SplitImpl`) to start from a position.
57/// It rely on Pub/Sub to load-balance messages between all Readers.
58/// We `ack` received messages after checkpoint (see `WaitCheckpointWorker`) to achieve at-least-once delivery.
59#[serde_as]
60#[derive(Clone, Debug, Deserialize, WithOptions)]
61pub struct PubsubProperties {
62    /// The Google Pub/Sub project ID. If omitted, the connector uses the project ID from
63    /// the credentials or Application Default Credentials.
64    #[serde(rename = "pubsub.project_id")]
65    pub project_id: Option<String>,
66
67    /// Pub/Sub subscription to consume messages from.
68    ///
69    /// Note that we rely on Pub/Sub to load-balance messages between all Readers pulling from
70    /// the same subscription. So one `subscription` (i.e., one `Source`) can only used for one MV
71    /// (shared between the actors of its fragment).
72    /// Otherwise, different MVs on the same Source will both receive part of the messages.
73    /// TODO: check and enforce this on Meta.
74    #[serde(rename = "pubsub.subscription")]
75    pub subscription: String,
76
77    /// use the connector with a pubsub emulator
78    /// <https://cloud.google.com/pubsub/docs/emulator>
79    #[serde(rename = "pubsub.emulator_host")]
80    pub emulator_host: Option<String>,
81
82    /// `credentials` is a JSON string containing the service account credentials, optionally
83    /// base64-encoded. If omitted, the connector uses Google Application Default Credentials
84    /// (ADC) when allowed by the deployment environment.
85    /// See the [service-account credentials guide](https://developers.google.com/workspace/guides/create-credentials#create_credentials_for_a_service_account).
86    /// The service account must have the `pubsub.subscriber` [role](https://cloud.google.com/pubsub/docs/access-control#roles).
87    #[serde(rename = "pubsub.credentials")]
88    pub credentials: Option<String>,
89
90    /// `start_offset` is a numeric timestamp, ideally the publish timestamp of a message
91    /// in the subscription. If present, the connector will attempt to seek the subscription
92    /// to the timestamp and start consuming from there. Note that the seek operation is
93    /// subject to limitations around the message retention policy of the subscription. See
94    /// [Seeking to a timestamp](https://cloud.google.com/pubsub/docs/replay-overview#seeking_to_a_timestamp) for
95    /// more details.
96    #[serde(rename = "pubsub.start_offset.nanos")]
97    pub start_offset: Option<String>,
98
99    /// `start_snapshot` is a named pub/sub snapshot. If present, the connector will first seek
100    /// to the snapshot before starting consumption. Snapshots are the preferred seeking mechanism
101    /// in pub/sub because they guarantee retention of:
102    /// - All unacknowledged messages at the time of their creation.
103    /// - All messages created after their creation.
104    /// Besides retention guarantees, snapshots are also more precise than timestamp-based seeks.
105    /// See [Seeking to a snapshot](https://cloud.google.com/pubsub/docs/replay-overview#seeking_to_a_timestamp) for
106    /// more details.
107    #[serde(rename = "pubsub.start_snapshot")]
108    pub start_snapshot: Option<String>,
109
110    /// Deprecated: ignored since adaptive split mode was introduced.
111    /// Split count now adapts automatically to the number of actors.
112    /// Kept for backward compatibility with existing DDL.
113    #[serde_as(as = "Option<DisplayFromStr>")]
114    #[serde(rename = "pubsub.parallelism")]
115    pub parallelism: Option<u32>,
116
117    /// The ack deadline in seconds for the streaming pull subscriber.
118    /// This is the maximum time the server will wait for an ack before redelivering the message.
119    /// Must be between 10 and 600 seconds. Defaults to 60.
120    #[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    /// The maximum number of unacknowledged messages delivered to each streaming pull reader.
126    /// Pub/Sub pauses delivery to a reader when this limit is reached. Must be greater than 0.
127    /// Defaults to 1024.
128    #[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    /// The maximum total size of unacknowledged messages delivered to each streaming pull reader.
134    /// Pub/Sub pauses delivery to a reader when this limit is reached. Must be greater than 0.
135    /// Defaults to 1 GB.
136    #[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    // Before 3.1, the environment-based authentication path also accepted base64-encoded JSON.
167    // Preserve that behavior without modifying process-wide environment variables.
168    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}