risingwave_connector/sink/
google_pubsub.rs1use std::collections::BTreeMap;
16
17use anyhow::anyhow;
18use google_cloud_gax::conn::Environment;
19use google_cloud_gax::grpc::Status;
20use google_cloud_googleapis::pubsub::v1::PubsubMessage;
21use google_cloud_pubsub::apiv1;
22use google_cloud_pubsub::client::google_cloud_auth::credentials::CredentialsFile;
23use google_cloud_pubsub::client::google_cloud_auth::project;
24use google_cloud_pubsub::client::google_cloud_auth::token::DefaultTokenSourceProvider;
25use google_cloud_pubsub::client::{Client, ClientConfig};
26use google_cloud_pubsub::publisher::Publisher;
27use risingwave_common::array::StreamChunk;
28use risingwave_common::catalog::Schema;
29use risingwave_common::util::env_var::env_var_is_true;
30use serde::Deserialize;
31use serde_with::serde_as;
32use with_options::WithOptions;
33
34use super::catalog::SinkFormatDesc;
35use super::formatter::SinkFormatterImpl;
36use super::log_store::DeliveryFutureManagerAddFuture;
37use super::writer::{
38 AsyncTruncateLogSinkerOf, AsyncTruncateSinkWriter, AsyncTruncateSinkWriterExt, FormattedSink,
39};
40use super::{Result, Sink, SinkError, SinkParam, SinkWriterParam};
41use crate::connector_common::{DISABLE_DEFAULT_CREDENTIAL, resolve_pubsub_project_id};
42use crate::dispatch_sink_formatter_str_key_impl;
43use crate::enforce_secret::EnforceSecret;
44
45pub const PUBSUB_SINK: &str = "google_pubsub";
46const PUBSUB_SEND_FUTURE_BUFFER_MAX_SIZE: usize = 65536;
47
48mod delivery_future {
49 use anyhow::Context;
50 use futures::future::try_join_all;
51 use futures::{FutureExt, TryFuture, TryFutureExt};
52 use google_cloud_pubsub::publisher::Awaiter;
53
54 use crate::sink::SinkError;
55
56 pub type GooglePubSubSinkDeliveryFuture =
57 impl TryFuture<Ok = (), Error = SinkError> + Unpin + 'static;
58
59 #[define_opaque(GooglePubSubSinkDeliveryFuture)]
60 pub(super) fn may_delivery_future(awaiter: Vec<Awaiter>) -> GooglePubSubSinkDeliveryFuture {
61 try_join_all(awaiter.into_iter().map(|awaiter| {
62 awaiter.get().map(|result| {
63 result
64 .context("Google Pub/Sub sink error")
65 .map_err(SinkError::GooglePubSub)
66 .map(|_| ())
67 })
68 }))
69 .map_ok(|_: Vec<()>| ())
70 .boxed()
71 }
72}
73
74use delivery_future::*;
75
76#[serde_as]
77#[derive(Clone, Debug, Deserialize, WithOptions)]
78pub struct GooglePubSubConfig {
79 #[serde(rename = "pubsub.project_id")]
82 pub project_id: Option<String>,
83
84 #[serde(rename = "pubsub.topic")]
86 pub topic: String,
87
88 #[serde(rename = "pubsub.endpoint")]
90 pub endpoint: String,
91
92 #[serde(rename = "pubsub.emulator_host")]
95 pub emulator_host: Option<String>,
96
97 #[serde(rename = "pubsub.credentials")]
104 pub credentials: Option<String>,
105
106 #[serde(flatten)]
107 pub unknown_fields: std::collections::HashMap<String, String>,
108}
109
110crate::impl_sink_unknown_fields!(GooglePubSubConfig);
111
112impl EnforceSecret for GooglePubSubConfig {
113 const ENFORCE_SECRET_PROPERTIES: phf::Set<&'static str> = phf::phf_set! {
114 "pubsub.credentials",
115 };
116}
117
118impl GooglePubSubConfig {
119 fn from_btreemap(values: BTreeMap<String, String>) -> Result<Self> {
120 serde_json::from_value::<GooglePubSubConfig>(serde_json::to_value(values).unwrap())
121 .map_err(|e| SinkError::Config(anyhow!(e)))
122 }
123}
124
125#[derive(Clone, Debug)]
126pub struct GooglePubSubSink {
127 pub config: GooglePubSubConfig,
128 is_append_only: bool,
129
130 schema: Schema,
131 pk_indices: Vec<usize>,
132 format_desc: SinkFormatDesc,
133 db_name: String,
134 sink_from_name: String,
135}
136
137impl EnforceSecret for GooglePubSubSink {
138 fn enforce_secret<'a>(
139 prop_iter: impl Iterator<Item = &'a str>,
140 ) -> crate::error::ConnectorResult<()> {
141 for prop in prop_iter {
142 GooglePubSubConfig::enforce_one(prop)?;
143 }
144 Ok(())
145 }
146}
147impl Sink for GooglePubSubSink {
148 type LogSinker = AsyncTruncateLogSinkerOf<GooglePubSubSinkWriter>;
149
150 const SINK_NAME: &'static str = PUBSUB_SINK;
151
152 crate::impl_validate_sink_unknown_fields!();
153
154 async fn validate(&self) -> Result<()> {
155 if !self.is_append_only {
156 return Err(SinkError::GooglePubSub(anyhow!(
157 "Google Pub/Sub sink only support append-only mode"
158 )));
159 }
160
161 let conf = &self.config;
162 if matches!((&conf.emulator_host, &conf.credentials), (None, None))
163 && env_var_is_true(DISABLE_DEFAULT_CREDENTIAL)
164 {
165 return Err(SinkError::GooglePubSub(anyhow!(
166 "Google Application Default Credentials are disabled; configure `pubsub.credentials` or `pubsub.emulator_host`"
167 )));
168 }
169
170 Ok(())
171 }
172
173 async fn new_log_sinker(&self, _writer_param: SinkWriterParam) -> Result<Self::LogSinker> {
174 Ok(GooglePubSubSinkWriter::new(
175 self.config.clone(),
176 self.schema.clone(),
177 self.pk_indices.clone(),
178 &self.format_desc,
179 self.db_name.clone(),
180 self.sink_from_name.clone(),
181 )
182 .await?
183 .into_log_sinker(PUBSUB_SEND_FUTURE_BUFFER_MAX_SIZE))
184 }
185}
186
187impl TryFrom<SinkParam> for GooglePubSubSink {
188 type Error = SinkError;
189
190 fn try_from(param: SinkParam) -> std::result::Result<Self, Self::Error> {
191 let schema = param.schema();
192 let pk_indices = param.downstream_pk_or_empty();
193 let config = GooglePubSubConfig::from_btreemap(param.properties)?;
194 let format_desc = param
195 .format_desc
196 .ok_or_else(|| SinkError::Config(anyhow!("missing FORMAT ... ENCODE ...")))?;
197 Ok(Self {
198 config,
199 is_append_only: param.sink_type.is_append_only(),
200 schema,
201 pk_indices,
202 format_desc,
203 db_name: param.db_name,
204 sink_from_name: param.sink_from_name,
205 })
206 }
207}
208
209struct GooglePubSubPayloadWriter<'w> {
210 publisher: &'w mut Publisher,
211 message_vec: Vec<PubsubMessage>,
212 add_future: DeliveryFutureManagerAddFuture<'w, GooglePubSubSinkDeliveryFuture>,
213}
214
215impl GooglePubSubSinkWriter {
216 pub async fn new(
217 config: GooglePubSubConfig,
218 schema: Schema,
219 pk_indices: Vec<usize>,
220 format_desc: &SinkFormatDesc,
221 db_name: String,
222 sink_from_name: String,
223 ) -> Result<Self> {
224 let (environment, detected_project_id) = if let Some(ref cred) = config.credentials {
225 let mut auth_config = project::Config::default();
226 auth_config = auth_config.with_audience(apiv1::conn_pool::AUDIENCE);
227 auth_config = auth_config.with_scopes(&apiv1::conn_pool::SCOPES);
228 let cred_file = CredentialsFile::new_from_str(cred).await.map_err(|e| {
229 SinkError::GooglePubSub(
230 anyhow!(e).context("Failed to create Google Cloud Pub/Sub credentials file"),
231 )
232 })?;
233 let provider =
234 DefaultTokenSourceProvider::new_with_credentials(auth_config, Box::new(cred_file))
235 .await
236 .map_err(|e| {
237 SinkError::GooglePubSub(
238 anyhow!(e).context(
239 "Failed to create Google Cloud Pub/Sub token source provider",
240 ),
241 )
242 })?;
243 let project_id = provider.project_id.clone();
244 (Environment::GoogleCloud(Box::new(provider)), project_id)
245 } else if let Some(emu_host) = config.emulator_host {
246 (Environment::Emulator(emu_host), None)
247 } else {
248 if env_var_is_true(DISABLE_DEFAULT_CREDENTIAL) {
249 return Err(SinkError::GooglePubSub(anyhow!(
250 "Google Application Default Credentials are disabled; configure `pubsub.credentials` or `pubsub.emulator_host`"
251 )));
252 }
253
254 let auth_config = project::Config::default()
255 .with_audience(apiv1::conn_pool::AUDIENCE)
256 .with_scopes(&apiv1::conn_pool::SCOPES);
257 let provider = DefaultTokenSourceProvider::new(auth_config)
258 .await
259 .map_err(|e| {
260 SinkError::GooglePubSub(anyhow!(e).context(
261 "failed to initialize Google Cloud Pub/Sub ADC; provide `pubsub.credentials`, configure ADC, or use `pubsub.emulator_host`",
262 ))
263 })?;
264 let project_id = provider.project_id.clone();
265 (Environment::GoogleCloud(Box::new(provider)), project_id)
266 };
267
268 let project_id = resolve_pubsub_project_id(
269 config.project_id.as_deref(),
270 detected_project_id.as_deref(),
271 matches!(&environment, Environment::Emulator(_)),
272 )
273 .ok_or_else(|| {
274 SinkError::Config(anyhow!(
275 "Google Cloud Pub/Sub project ID is unavailable; configure `pubsub.project_id` or provide credentials/ADC with a project ID"
276 ))
277 })?;
278 let client_config = ClientConfig {
279 endpoint: config.endpoint,
280 project_id: Some(project_id),
281 environment,
282 ..Default::default()
283 };
284 let client = Client::new(client_config)
285 .await
286 .map_err(|e| SinkError::GooglePubSub(anyhow!(e)))?;
287
288 let topic = async {
289 let topic = client.topic(&config.topic);
290 if !topic.exists(None).await? {
291 topic.create(None, None).await?;
292 }
293 Ok(topic)
294 }
295 .await
296 .map_err(|e: Status| SinkError::GooglePubSub(anyhow!(e)))?;
297
298 let formatter = SinkFormatterImpl::new(
299 format_desc,
300 schema,
301 pk_indices,
302 db_name,
303 sink_from_name,
304 topic.fully_qualified_name(),
305 )
306 .await?;
307
308 let publisher = topic.new_publisher(None);
309
310 Ok(Self {
311 formatter,
312 publisher,
313 })
314 }
315}
316
317pub struct GooglePubSubSinkWriter {
318 formatter: SinkFormatterImpl,
319 publisher: Publisher,
320}
321
322impl AsyncTruncateSinkWriter for GooglePubSubSinkWriter {
323 type DeliveryFuture = GooglePubSubSinkDeliveryFuture;
324
325 async fn write_chunk<'a>(
326 &'a mut self,
327 chunk: StreamChunk,
328 add_future: DeliveryFutureManagerAddFuture<'a, Self::DeliveryFuture>,
329 ) -> Result<()> {
330 let mut payload_writer = GooglePubSubPayloadWriter {
331 publisher: &mut self.publisher,
332 message_vec: Vec::with_capacity(chunk.cardinality()),
333 add_future,
334 };
335 dispatch_sink_formatter_str_key_impl!(&self.formatter, formatter, {
336 payload_writer.write_chunk(chunk, formatter).await
337 })?;
338 payload_writer.finish().await
339 }
340}
341
342impl GooglePubSubPayloadWriter<'_> {
343 pub async fn finish(&mut self) -> Result<()> {
344 let message_vec = std::mem::take(&mut self.message_vec);
345 let awaiters = self.publisher.publish_bulk(message_vec).await;
346 self.add_future
347 .add_future_may_await(may_delivery_future(awaiters))
348 .await?;
349 Ok(())
350 }
351}
352
353impl FormattedSink for GooglePubSubPayloadWriter<'_> {
354 type K = String;
355 type V = Vec<u8>;
356
357 async fn write_one(&mut self, k: Option<Self::K>, v: Option<Self::V>) -> Result<()> {
358 let ordering_key = k.unwrap_or_default();
359 match v {
360 Some(data) => {
361 let msg = PubsubMessage {
362 data,
363 ordering_key,
364 ..Default::default()
365 };
366 self.message_vec.push(msg);
367 Ok(())
368 }
369 None => Err(SinkError::GooglePubSub(anyhow!(
370 "Google Pub/Sub sink error: missing value to publish"
371 ))),
372 }
373 }
374}