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