Skip to main content

risingwave_connector/sink/
firestore.rs

1// Copyright 2026 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::{BTreeMap, HashMap};
16use std::time::Duration;
17
18use anyhow::anyhow;
19use base64::Engine;
20use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
21use firestore::{FirestoreDb, FirestoreDbOptions};
22use futures::StreamExt;
23use gcloud_sdk::google::firestore::admin::v1::GetDatabaseRequest;
24use gcloud_sdk::google::firestore::admin::v1::database::DatabaseEdition;
25use gcloud_sdk::google::firestore::admin::v1::firestore_admin_client::FirestoreAdminClient;
26use gcloud_sdk::google::firestore::v1::value::ValueType;
27use gcloud_sdk::google::firestore::v1::{
28    ArrayValue, BatchWriteRequest, Document, MapValue, Value, Write, write,
29};
30use prost::Message as _;
31use risingwave_common::array::{Op, RowRef, StreamChunk};
32use risingwave_common::catalog::Schema;
33use risingwave_common::row::Row as _;
34use risingwave_common::types::{DataType, ScalarRefImpl, ToText};
35use risingwave_common::util::iter_util::ZipEqDebug;
36use serde::Deserialize;
37use serde_json::Value as JsonValue;
38use serde_with::{DisplayFromStr, serde_as};
39use thiserror_ext::AsReport;
40use with_options::WithOptions;
41
42use super::log_store::DeliveryFutureManagerAddFuture;
43use super::writer::{
44    AsyncTruncateLogSinkerOf, AsyncTruncateSinkWriter, AsyncTruncateSinkWriterExt,
45};
46use super::{Result, Sink, SinkError, SinkParam, SinkWriterParam};
47use crate::enforce_secret::EnforceSecret;
48
49pub const FIRESTORE_SINK: &str = "firestore";
50
51const DEFAULT_MAX_BATCH_SIZE: usize = 500; // Firestore batch limit
52const MAX_DOCUMENT_SIZE: usize = 1_048_576;
53// Leave room for the database name and protobuf framing below the 10 MiB RPC limit.
54const MAX_BATCH_WRITE_BYTES: usize = 9 * 1024 * 1024;
55const DATABASE_METADATA_TIMEOUT: Duration = Duration::from_secs(10);
56
57#[derive(Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
58#[serde(rename_all = "lowercase")]
59pub enum FirestoreDecimalMode {
60    #[default]
61    Decimal128,
62    Double,
63}
64
65impl crate::with_options::WithOptions for FirestoreDecimalMode {}
66
67#[serde_as]
68#[derive(Deserialize, Debug, Clone, WithOptions)]
69pub struct FirestoreConfig {
70    /// Google Cloud project ID
71    #[serde(rename = "firestore.project_id")]
72    pub project_id: String,
73
74    /// Collection path. A parent document segment may reference a column, e.g. `users/{user_id}/orders`.
75    #[serde(rename = "firestore.collection")]
76    pub collection: String,
77
78    /// Optional: Firestore database ID (for multi-database projects)
79    #[serde(rename = "firestore.database_id")]
80    pub database_id: Option<String>,
81
82    /// A JSON string containing the service account credentials for authorization
83    /// see the [service-account](https://developers.google.com/workspace/guides/create-credentials#create_credentials_for_a_service_account) credentials guide.
84    #[serde(rename = "firestore.credentials")]
85    pub credentials: Option<String>,
86
87    /// Column whose value is used as the Firestore document ID. Values must be unique and valid
88    /// Firestore document IDs. Duplicate values in the same resolved collection intentionally
89    /// map multiple rows to the same Firestore document.
90    #[serde(rename = "firestore.document_id_column")]
91    pub document_id_column: Option<String>,
92
93    /// Skip invalid rows and document-level `INVALID_ARGUMENT/OUT_OF_RANGE` rejections (default: false).
94    /// Skipped mutations are not delivered; authentication and transient failures still fail the chunk.
95    #[serde(rename = "firestore.skip_invalid_rows", default)]
96    #[serde_as(as = "DisplayFromStr")]
97    pub skip_invalid_rows: bool,
98
99    /// DECIMAL encoding: `decimal128` (default, Enterprise only) or `double` (may lose precision).
100    /// Database metadata is checked for Decimal128; unavailable metadata never selects double.
101    #[serde(rename = "firestore.decimal_mode", default)]
102    pub decimal_mode: FirestoreDecimalMode,
103
104    /// Maximum batch size for batch writes (default: 500)
105    #[serde(
106        rename = "firestore.max_batch_size",
107        default = "default_firestore_max_batch_size"
108    )]
109    #[serde_as(as = "DisplayFromStr")]
110    pub max_batch_size: usize,
111
112    /// Maximum concurrent batch write requests within one chunk (default: 256).
113    /// Chunks are written sequentially to preserve per-document mutation order.
114    #[serde(
115        rename = "firestore.max_future_send_nums",
116        default = "default_firestore_max_future_send_nums"
117    )]
118    #[serde_as(as = "DisplayFromStr")]
119    pub max_future_send_nums: usize,
120}
121
122fn default_firestore_max_batch_size() -> usize {
123    500
124}
125
126fn default_firestore_max_future_send_nums() -> usize {
127    256
128}
129
130impl EnforceSecret for FirestoreConfig {
131    const ENFORCE_SECRET_PROPERTIES: phf::Set<&'static str> = phf::phf_set! {
132        "firestore.credentials",
133    };
134}
135
136impl FirestoreConfig {
137    pub async fn build_client(&self) -> Result<FirestoreDb> {
138        self.build_client_with_emulator(std::env::var("FIRESTORE_EMULATOR_HOST").ok())
139            .await
140    }
141
142    async fn build_client_with_emulator(
143        &self,
144        emulator_host: Option<String>,
145    ) -> Result<FirestoreDb> {
146        let mut options = if let Some(ref database_id) = self.database_id {
147            FirestoreDbOptions::new(self.project_id.clone()).with_database_id(database_id.clone())
148        } else {
149            FirestoreDbOptions::new(self.project_id.clone())
150        };
151
152        if let Some(ref host) = emulator_host {
153            options.firebase_api_url = Some(format!("http://{host}"));
154        }
155        let (scopes, token_source) = self.token_source(emulator_host.is_some());
156        FirestoreDb::with_options_token_source(options, scopes, token_source)
157            .await
158            .map_err(|e| {
159                SinkError::Firestore(anyhow!(e).context("failed to create Firestore client"))
160            })
161    }
162
163    fn token_source(&self, emulator: bool) -> (Vec<String>, gcloud_sdk::TokenSourceType) {
164        use gcloud_sdk::{ExternalJwtFunctionSource, GCP_DEFAULT_SCOPES, Token, TokenSourceType};
165        if emulator {
166            // Neither the data client nor the metadata client should fetch ADC or
167            // forward configured cloud credentials to an emulator.
168            let source = ExternalJwtFunctionSource::new(|| async {
169                Ok(Token::new(
170                    "Bearer".to_owned(),
171                    "owner".to_owned().into(),
172                    chrono::Utc::now() + chrono::Duration::hours(1),
173                ))
174            });
175            (vec![], TokenSourceType::ExternalSource(Box::new(source)))
176        } else if let Some(ref cred) = self.credentials {
177            (
178                GCP_DEFAULT_SCOPES.clone(),
179                TokenSourceType::Json(cred.clone()),
180            )
181        } else {
182            (GCP_DEFAULT_SCOPES.clone(), TokenSourceType::Default)
183        }
184    }
185
186    async fn validate_decimal_mode(&self, schema: &Schema, client: &FirestoreDb) -> Result<()> {
187        if self.decimal_mode == FirestoreDecimalMode::Double
188            || !schema
189                .fields()
190                .iter()
191                .any(|field| contains_decimal(&field.data_type))
192        {
193            return Ok(());
194        }
195        let edition =
196            tokio::time::timeout(DATABASE_METADATA_TIMEOUT, self.database_edition(client))
197                .await
198                .map_err(|_| {
199                    SinkError::Firestore(anyhow!("Firestore database metadata request timed out"))
200                })??;
201        match edition.and_then(|edition| DatabaseEdition::try_from(edition).ok()) {
202            Some(DatabaseEdition::Standard) => Err(SinkError::Config(anyhow!(
203                "firestore.decimal_mode='decimal128' requires Firestore Enterprise edition; \
204                 this database is Standard. Set firestore.decimal_mode='double' to opt into \
205                 floating-point conversion (which may lose precision), or cast DECIMAL to VARCHAR"
206            ))),
207            Some(DatabaseEdition::Enterprise) => Ok(()),
208            _ => {
209                tracing::warn!(database = %client.get_database_path(), "Firestore database edition is unknown; keeping firestore.decimal_mode='decimal128', without falling back to double");
210                Ok(())
211            }
212        }
213    }
214
215    async fn database_edition(&self, client: &FirestoreDb) -> Result<Option<i32>> {
216        let options = client.get_options();
217        let (scopes, token_source) = self.token_source(options.firebase_api_url.is_some());
218        let admin = gcloud_sdk::GoogleApi::from_function_with_token_source(
219            FirestoreAdminClient::new,
220            options
221                .firebase_api_url
222                .as_deref()
223                .unwrap_or("https://firestore.googleapis.com"),
224            Some(client.get_database_path().to_owned()),
225            scopes,
226            token_source,
227        )
228        .await
229        .map_err(|error| {
230            SinkError::Firestore(
231                anyhow!(error).context("failed to create Firestore metadata client"),
232            )
233        })?;
234        let mut request = tonic::Request::new(GetDatabaseRequest {
235            name: client.get_database_path().to_owned(),
236        });
237        request.set_timeout(DATABASE_METADATA_TIMEOUT);
238        match admin.get().get_database(request).await {
239            Ok(response) => Ok(Some(response.into_inner().database_edition)),
240            Err(error)
241                if matches!(
242                    error.code(),
243                    tonic::Code::PermissionDenied | tonic::Code::Unimplemented
244                ) =>
245            {
246                tracing::warn!(database = %client.get_database_path(), code = ?error.code(), "Cannot read Firestore database edition; metadata requires datastore.databases.getMetadata and may be unsupported by the emulator");
247                Ok(None)
248            }
249            Err(error) => Err(SinkError::Firestore(
250                anyhow!(error).context("failed to read Firestore database edition"),
251            )),
252        }
253    }
254
255    fn validate_schema(&self, schema: &Schema, pk_indices: &[usize]) -> Result<Option<usize>> {
256        parse_collection_path(&self.collection, schema)?;
257        for (name, value) in [
258            ("project_id", Some(&self.project_id)),
259            ("database_id", self.database_id.as_ref()),
260        ] {
261            if value.is_some_and(|value| value.is_empty() || value.contains('/')) {
262                return Err(SinkError::Config(anyhow!(
263                    "firestore.{} must be a nonempty resource ID without '/'",
264                    name
265                )));
266            }
267        }
268        if !(1..=DEFAULT_MAX_BATCH_SIZE).contains(&self.max_batch_size) {
269            return Err(SinkError::Config(anyhow!(
270                "firestore.max_batch_size must be between 1 and {}",
271                DEFAULT_MAX_BATCH_SIZE
272            )));
273        }
274        if self.max_future_send_nums == 0 {
275            return Err(SinkError::Config(anyhow!(
276                "firestore.max_future_send_nums must be greater than 0"
277            )));
278        }
279        for field in schema.fields() {
280            validate_column_type(&field.data_type, &field.name)?;
281        }
282        if let Some(name) = &self.document_id_column {
283            return schema
284                .fields()
285                .iter()
286                .position(|f| &f.name == name)
287                .map(Some)
288                .ok_or_else(|| {
289                    SinkError::Config(anyhow!("document_id_column '{}' not found in schema", name))
290                });
291        }
292        if pk_indices.is_empty() {
293            return Err(SinkError::Config(anyhow!(
294                "Either document_id_column or primary key must be specified"
295            )));
296        }
297        Ok(None)
298    }
299
300    fn from_btreemap(values: BTreeMap<String, String>) -> Result<Self> {
301        serde_json::from_value::<FirestoreConfig>(serde_json::to_value(values).unwrap())
302            .map_err(|e| SinkError::Config(anyhow!(e)))
303    }
304}
305
306#[derive(Clone, Debug)]
307pub struct FirestoreSink {
308    pub config: FirestoreConfig,
309    schema: Schema,
310    pk_indices: Vec<usize>,
311}
312
313impl EnforceSecret for FirestoreSink {
314    fn enforce_secret<'a>(
315        prop_iter: impl Iterator<Item = &'a str>,
316    ) -> crate::error::ConnectorResult<()> {
317        for prop in prop_iter {
318            FirestoreConfig::enforce_one(prop)?;
319        }
320        Ok(())
321    }
322}
323
324impl Sink for FirestoreSink {
325    type LogSinker = AsyncTruncateLogSinkerOf<FirestoreSinkWriter>;
326
327    const SINK_NAME: &'static str = FIRESTORE_SINK;
328
329    async fn validate(&self) -> Result<()> {
330        risingwave_common::license::Feature::FirestoreSink
331            .check_available()
332            .map_err(|e| anyhow!(e))?;
333        self.config
334            .validate_schema(&self.schema, &self.pk_indices)?;
335        let client = self.config.build_client().await?;
336        self.config
337            .validate_decimal_mode(&self.schema, &client)
338            .await?;
339
340        Ok(())
341    }
342
343    async fn new_log_sinker(&self, _writer_param: SinkWriterParam) -> Result<Self::LogSinker> {
344        Ok(FirestoreSinkWriter::new(
345            self.config.clone(),
346            self.schema.clone(),
347            self.pk_indices.clone(),
348        )
349        .await?
350        // Writes finish inline; request concurrency is bounded inside each chunk.
351        .into_log_sinker(1))
352    }
353}
354
355impl TryFrom<SinkParam> for FirestoreSink {
356    type Error = SinkError;
357
358    fn try_from(param: SinkParam) -> std::result::Result<Self, Self::Error> {
359        let schema = param.schema();
360        let pk_indices = param.downstream_pk_or_empty();
361        let config = FirestoreConfig::from_btreemap(param.properties)?;
362
363        Ok(Self {
364            config,
365            schema,
366            pk_indices,
367        })
368    }
369}
370
371pub struct FirestoreSinkWriter {
372    client: FirestoreDb,
373    config: FirestoreConfig,
374    schema: Schema,
375    pk_indices: Vec<usize>,
376    doc_id_col_idx: Option<usize>,
377    collection_path: Vec<CollectionPathSegment>,
378}
379
380impl FirestoreSinkWriter {
381    pub async fn new(
382        config: FirestoreConfig,
383        schema: Schema,
384        pk_indices: Vec<usize>,
385    ) -> Result<Self> {
386        let doc_id_col_idx = config.validate_schema(&schema, &pk_indices)?;
387        let collection_path = parse_collection_path(&config.collection, &schema)?;
388        let client = config.build_client().await?;
389        config.validate_decimal_mode(&schema, &client).await?;
390
391        Ok(Self {
392            client,
393            config,
394            schema,
395            pk_indices,
396            doc_id_col_idx,
397            collection_path,
398        })
399    }
400
401    fn get_document_id(&self, row: RowRef<'_>) -> Result<String> {
402        if let Some(idx) = self.doc_id_col_idx {
403            // Use specified document ID column
404            let datum = row.datum_at(idx);
405            match datum {
406                Some(scalar) => {
407                    let doc_id = scalar.to_text();
408                    validate_firestore_document_id(&doc_id)?;
409                    Ok(doc_id)
410                }
411                None => Err(SinkError::Firestore(anyhow!(
412                    "document_id_column value is null"
413                ))),
414            }
415        } else {
416            // Encode the PK tuple unambiguously. Joining text values is unsafe for
417            // composite keys because values may contain the separator.
418            let pk_values: Vec<JsonValue> = self
419                .pk_indices
420                .iter()
421                .map(|&idx| {
422                    let field = &self.schema.fields()[idx];
423                    row.datum_at(idx)
424                        .map(|s| JsonValue::String(s.to_text_with_type(&field.data_type)))
425                        .unwrap_or(JsonValue::Null)
426                })
427                .collect();
428            encode_pk_document_id(pk_values)
429        }
430    }
431
432    fn format_row(&self, row: RowRef<'_>) -> Result<HashMap<String, Value>> {
433        let mut map = HashMap::new();
434        for (datum, field) in row.iter().zip_eq_debug(self.schema.fields()) {
435            let value = format_datum(datum, &field.data_type, self.config.decimal_mode)?;
436            map.insert(field.name.clone(), value);
437        }
438        Ok(map)
439    }
440
441    fn get_document_path(&self, row: RowRef<'_>) -> Result<String> {
442        let mut segments = Vec::with_capacity(self.collection_path.len() + 1);
443        for segment in &self.collection_path {
444            segments.push(match segment {
445                CollectionPathSegment::Literal(value) => value.clone(),
446                CollectionPathSegment::Column(index) => {
447                    let value = row
448                        .datum_at(*index)
449                        .ok_or_else(|| {
450                            SinkError::Firestore(anyhow!("Firestore parent document ID is null"))
451                        })?
452                        .to_text();
453                    validate_firestore_document_id(&value)?;
454                    value
455                }
456            });
457        }
458        segments.push(self.get_document_id(row)?);
459        let path = segments.join("/");
460        if self.client.get_documents_path().len() + 1 + path.len() > 6 * 1024 {
461            return Err(SinkError::Firestore(anyhow!(
462                "Firestore document name exceeds 6 KiB"
463            )));
464        }
465        Ok(path)
466    }
467
468    fn write_chunk_inner(&self, chunk: StreamChunk) -> Result<Vec<FirestoreOperation>> {
469        let mut operations = BTreeMap::new();
470
471        for (op, row) in chunk.rows() {
472            let operation: Result<FirestoreOperation> = (|| {
473                let path = self.get_document_path(row)?;
474                Ok(match op {
475                    Op::Insert | Op::UpdateInsert => {
476                        let data = self.format_row(row)?;
477                        validate_document_size(&path, &data)?;
478                        FirestoreOperation::Upsert { path, data }
479                    }
480                    Op::Delete | Op::UpdateDelete => FirestoreOperation::Delete { path },
481                })
482            })();
483            match operation {
484                Ok(operation) => {
485                    operations.insert(operation.document_path().to_owned(), operation);
486                }
487                Err(error) if self.config.skip_invalid_rows => {
488                    tracing::warn!(error = %error.as_report(), "skipping invalid Firestore row");
489                }
490                Err(error) => return Err(error),
491            }
492        }
493
494        Ok(operations.into_values().collect())
495    }
496
497    async fn write_operations(&self, operations: Vec<FirestoreOperation>) -> Result<()> {
498        let writes = operations
499            .into_iter()
500            .map(|op| {
501                let name = format!(
502                    "{}/{}",
503                    self.client.get_documents_path(),
504                    op.document_path()
505                );
506                let operation = match op {
507                    FirestoreOperation::Upsert { data, .. } => write::Operation::Update(Document {
508                        name,
509                        fields: data,
510                        ..Default::default()
511                    }),
512                    FirestoreOperation::Delete { .. } => write::Operation::Delete(name),
513                };
514                Write {
515                    operation: Some(operation),
516                    ..Default::default()
517                }
518            })
519            .collect::<Vec<_>>();
520        let batches = split_write_batches(writes, self.config.max_batch_size);
521        let requests = batches.into_iter().map(|writes| self.write_batch(writes));
522
523        // Each document occurs at most once per chunk. Only these independent requests may
524        // run concurrently. Drain requests even on failure before allowing rewind/replay.
525        futures::stream::iter(requests)
526            .buffer_unordered(self.config.max_future_send_nums)
527            .fold(Ok(()), |result, next| async move { result.and(next) })
528            .await
529    }
530
531    async fn write_batch(&self, writes: Vec<Write>) -> Result<()> {
532        let request = BatchWriteRequest {
533            database: self.client.get_database_path().clone(),
534            writes,
535            ..Default::default()
536        };
537        let response = self
538            .client
539            .client()
540            .get()
541            .batch_write(request.clone())
542            .await;
543        match response {
544            Ok(response) => {
545                let paths = request
546                    .writes
547                    .iter()
548                    .map(write_document_path)
549                    .collect::<Vec<_>>();
550                check_batch_statuses(
551                    &response.into_inner().status,
552                    &paths,
553                    self.config.skip_invalid_rows,
554                )
555            }
556            Err(error)
557                if self.config.skip_invalid_rows && is_invalid_row_code(error.code() as i32) =>
558            {
559                // Some servers reject the whole RPC for one invalid document. Isolate it;
560                // never discard the other writes in that batch or ignore transport failures.
561                for write in request.writes {
562                    let path = write_document_path(&write);
563                    let response = self
564                        .client
565                        .client()
566                        .get()
567                        .batch_write(BatchWriteRequest {
568                            database: request.database.clone(),
569                            writes: vec![write],
570                            ..Default::default()
571                        })
572                        .await;
573                    match response {
574                        Ok(response) => {
575                            check_batch_statuses(&response.into_inner().status, &[path], true)?
576                        }
577                        Err(error) if is_invalid_row_code(error.code() as i32) => {
578                            tracing::warn!(document = %path, error = %error.as_report(), "skipping rejected Firestore row");
579                        }
580                        Err(error) => return Err(SinkError::Firestore(anyhow!(error))),
581                    }
582                }
583                Ok(())
584            }
585            Err(error) => Err(SinkError::Firestore(
586                anyhow!(error).context("failed to execute Firestore batch write"),
587            )),
588        }
589    }
590}
591
592#[derive(Clone)]
593enum CollectionPathSegment {
594    Literal(String),
595    Column(usize),
596}
597
598fn parse_collection_path(path: &str, schema: &Schema) -> Result<Vec<CollectionPathSegment>> {
599    let segments = path.split('/').collect::<Vec<_>>();
600    if segments.len() % 2 != 1 || segments.len() > 201 {
601        return Err(SinkError::Config(anyhow!(
602            "firestore.collection must be a collection path with at most 100 subcollections"
603        )));
604    }
605    segments
606        .into_iter()
607        .enumerate()
608        .map(|(position, segment)| {
609            if let Some(column) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
610                if position % 2 == 0 {
611                    return Err(SinkError::Config(anyhow!(
612                        "collection placeholders must be parent document segments"
613                    )));
614                }
615                let index = schema
616                    .fields()
617                    .iter()
618                    .position(|f| f.name == column)
619                    .ok_or_else(|| {
620                        SinkError::Config(anyhow!(
621                            "collection path column '{}' not found in schema",
622                            column
623                        ))
624                    })?;
625                Ok(CollectionPathSegment::Column(index))
626            } else {
627                if segment.contains(['{', '}']) {
628                    return Err(SinkError::Config(anyhow!(
629                        "collection placeholders must occupy a complete path segment"
630                    )));
631                }
632                validate_firestore_document_id(segment)
633                    .map_err(|error| SinkError::Config(anyhow!(error)))?;
634                Ok(CollectionPathSegment::Literal(segment.to_owned()))
635            }
636        })
637        .collect()
638}
639
640fn is_invalid_row_code(code: i32) -> bool {
641    matches!(code, 3 | 11) // INVALID_ARGUMENT and OUT_OF_RANGE only.
642}
643
644fn write_document_path(write: &Write) -> String {
645    match write
646        .operation
647        .as_ref()
648        .expect("Firestore write has an operation")
649    {
650        write::Operation::Update(document) => document.name.clone(),
651        write::Operation::Delete(path) => path.clone(),
652        write::Operation::Transform(_) => unreachable!("sink only emits upserts and deletes"),
653    }
654}
655
656fn split_write_batches(writes: Vec<Write>, max_count: usize) -> Vec<Vec<Write>> {
657    let mut batches = Vec::new();
658    let mut batch = Vec::new();
659    let mut bytes = 0;
660    for write in writes {
661        let size = write.encoded_len() + 5;
662        if !batch.is_empty() && (batch.len() >= max_count || bytes + size > MAX_BATCH_WRITE_BYTES) {
663            batches.push(std::mem::take(&mut batch));
664            bytes = 0;
665        }
666        bytes += size;
667        batch.push(write);
668    }
669    if !batch.is_empty() {
670        batches.push(batch);
671    }
672    batches
673}
674
675// Firestore storage size differs from protobuf size. See:
676// https://firebase.google.com/docs/firestore/storage-size
677fn firestore_fields_size(fields: &HashMap<String, Value>) -> usize {
678    fields
679        .iter()
680        .map(|(name, value)| name.len() + 1 + firestore_value_size(value))
681        .sum()
682}
683
684fn firestore_value_size(value: &Value) -> usize {
685    match value.value_type.as_ref().expect("encoded Firestore value") {
686        ValueType::NullValue(_) | ValueType::BooleanValue(_) => 1,
687        ValueType::IntegerValue(_) | ValueType::DoubleValue(_) | ValueType::TimestampValue(_) => 8,
688        ValueType::StringValue(value) => value.len() + 1,
689        ValueType::BytesValue(value) => value.len(),
690        ValueType::ArrayValue(value) => value.values.iter().map(firestore_value_size).sum(),
691        ValueType::MapValue(value) => firestore_fields_size(&value.fields) + 32,
692        ValueType::GeoPointValue(_) => 16,
693        ValueType::ReferenceValue(value) => value.len() + 16,
694        ValueType::FieldReferenceValue(_)
695        | ValueType::FunctionValue(_)
696        | ValueType::PipelineValue(_) => {
697            unreachable!("query-only Firestore values are never emitted by the row encoder")
698        }
699    }
700}
701
702fn validate_document_size(path: &str, fields: &HashMap<String, Value>) -> Result<()> {
703    let name_size = path.split('/').map(|part| part.len() + 1).sum::<usize>() + 16;
704    if name_size + firestore_fields_size(fields) + 32 > MAX_DOCUMENT_SIZE {
705        return Err(SinkError::Firestore(anyhow!(
706            "Firestore document exceeds 1 MiB"
707        )));
708    }
709    Ok(())
710}
711
712fn check_batch_statuses(
713    statuses: &[gcloud_sdk::google::rpc::Status],
714    document_ids: &[String],
715    skip_invalid_rows: bool,
716) -> Result<()> {
717    if statuses.len() != document_ids.len() {
718        return Err(SinkError::Firestore(anyhow!(
719            "Firestore batch write returned {} statuses for {} writes",
720            statuses.len(),
721            document_ids.len()
722        )));
723    }
724    for (status, document_id) in statuses.iter().zip_eq_debug(document_ids) {
725        if status.code != 0 {
726            if skip_invalid_rows && is_invalid_row_code(status.code) {
727                tracing::warn!(document = %document_id, code = status.code, message = %status.message, "skipping rejected Firestore row");
728                continue;
729            }
730            return Err(SinkError::Firestore(anyhow!(
731                "Firestore batch write failed for document '{}': code={}, message={}",
732                document_id,
733                status.code,
734                status.message
735            )));
736        }
737    }
738    Ok(())
739}
740
741#[derive(Clone)]
742enum FirestoreOperation {
743    Upsert {
744        path: String,
745        data: HashMap<String, Value>,
746    },
747    Delete {
748        path: String,
749    },
750}
751
752impl FirestoreOperation {
753    fn document_path(&self) -> &str {
754        match self {
755            Self::Upsert { path, .. } | Self::Delete { path } => path,
756        }
757    }
758}
759
760// Encode Firestore values directly: JSON cannot represent non-finite floats, and the
761// SDK's serde serializer casts u64 to i64 without checking its range.
762fn format_datum(
763    datum: Option<ScalarRefImpl<'_>>,
764    data_type: &DataType,
765    decimal_mode: FirestoreDecimalMode,
766) -> Result<Value> {
767    format_datum_at_depth(datum, data_type, decimal_mode, 0)
768}
769
770/// Reject types the row encoder cannot serialize before connecting to Firestore.
771fn validate_column_type(data_type: &DataType, column_path: &str) -> Result<()> {
772    match data_type {
773        DataType::Map(_) | DataType::Vector(_) | DataType::Variant => {
774            Err(SinkError::Config(anyhow!(
775                "Firestore sink column '{}' has unsupported type {}",
776                column_path,
777                data_type
778            )))
779        }
780        DataType::List(inner) if matches!(inner.elem(), DataType::List(_)) => {
781            Err(SinkError::Config(anyhow!(
782                "Firestore sink column '{}' has unsupported type {}: arrays cannot directly contain arrays",
783                column_path,
784                data_type
785            )))
786        }
787        DataType::List(inner) => validate_column_type(inner.elem(), &format!("{column_path}[]")),
788        DataType::Struct(st) => st.iter().try_for_each(|(name, data_type)| {
789            validate_column_type(data_type, &format!("{column_path}.{name}"))
790        }),
791        _ => Ok(()),
792    }
793}
794
795fn contains_decimal(data_type: &DataType) -> bool {
796    match data_type {
797        DataType::Decimal => true,
798        DataType::List(list) => contains_decimal(list.elem()),
799        DataType::Struct(struct_type) => struct_type.types().any(contains_decimal),
800        _ => false,
801    }
802}
803
804fn check_container_depth(depth: usize) -> Result<()> {
805    if depth >= 20 {
806        return Err(SinkError::Firestore(anyhow!(
807            "Firestore maps and arrays must not exceed 20 levels"
808        )));
809    }
810    Ok(())
811}
812
813fn array_value(values: Vec<Value>) -> Result<ValueType> {
814    if values
815        .iter()
816        .any(|v| matches!(v.value_type, Some(ValueType::ArrayValue(_))))
817    {
818        return Err(SinkError::Firestore(anyhow!(
819            "Firestore arrays cannot directly contain arrays"
820        )));
821    }
822    Ok(ValueType::ArrayValue(ArrayValue { values }))
823}
824
825fn format_datum_at_depth(
826    datum: Option<ScalarRefImpl<'_>>,
827    data_type: &DataType,
828    decimal_mode: FirestoreDecimalMode,
829    depth: usize,
830) -> Result<Value> {
831    let Some(scalar) = datum else {
832        return Ok(Value {
833            value_type: Some(ValueType::NullValue(0)),
834        });
835    };
836    let value_type = match data_type {
837        DataType::Boolean => ValueType::BooleanValue(scalar.into_bool()),
838        DataType::Int16 => ValueType::IntegerValue(scalar.into_int16().into()),
839        DataType::Int32 => ValueType::IntegerValue(scalar.into_int32().into()),
840        DataType::Int64 => ValueType::IntegerValue(scalar.into_int64()),
841        DataType::Float32 => ValueType::DoubleValue(scalar.into_float32().0.into()),
842        DataType::Float64 => ValueType::DoubleValue(scalar.into_float64().0),
843        DataType::Decimal => match decimal_mode {
844            // Firestore's wire representation for Decimal128 is a reserved map,
845            // not a string field. RisingWave decimals have at most 29 significant
846            // digits, within Decimal128's 34, so no intermediate f64 is needed.
847            // https://cloud.google.com/firestore/mongodb-compatibility/docs/supported-data-types-drivers
848            FirestoreDecimalMode::Decimal128 => ValueType::MapValue(MapValue {
849                fields: HashMap::from([(
850                    "__decimal128__".to_owned(),
851                    Value {
852                        value_type: Some(ValueType::StringValue(scalar.into_decimal().to_string())),
853                    },
854                )]),
855            }),
856            FirestoreDecimalMode::Double => ValueType::DoubleValue(
857                f64::try_from(scalar.into_decimal())
858                    .map_err(|error| SinkError::Firestore(anyhow!(error)))?,
859            ),
860        },
861        DataType::Timestamp => {
862            // A timestamp without a time zone is interpreted as UTC, independently of
863            // the session/process time zone. Firestore stores microsecond precision.
864            let time = scalar.into_timestamp().0.and_utc();
865            firestore_timestamp(time.timestamp(), time.timestamp_subsec_nanos())?
866        }
867        DataType::Timestamptz => {
868            let time = scalar.into_timestamptz();
869            firestore_timestamp(time.timestamp(), time.timestamp_subsec_nanos())?
870        }
871        DataType::Int256
872        | DataType::Serial
873        | DataType::Interval
874        | DataType::Date
875        | DataType::Time => ValueType::StringValue(scalar.to_text_with_type(data_type)),
876        DataType::Varchar => ValueType::StringValue(scalar.into_utf8().to_owned()),
877        DataType::Bytea => ValueType::StringValue(STANDARD.encode(scalar.into_bytea())),
878        DataType::Jsonb => return format_jsonb(scalar.into_jsonb().into(), depth),
879        DataType::List(inner) => {
880            check_container_depth(depth)?;
881            let values = scalar
882                .into_list()
883                .iter()
884                .map(|item| format_datum_at_depth(item, inner.elem(), decimal_mode, depth + 1))
885                .collect::<Result<Vec<_>>>()?;
886            array_value(values)?
887        }
888        DataType::Struct(st) => {
889            check_container_depth(depth)?;
890            let mut fields = HashMap::new();
891            for (datum, (name, dt)) in scalar
892                .into_struct()
893                .iter_fields_ref()
894                .zip_eq_debug(st.iter())
895            {
896                fields.insert(
897                    name.to_owned(),
898                    format_datum_at_depth(datum, dt, decimal_mode, depth + 1)?,
899                );
900            }
901            ValueType::MapValue(MapValue { fields })
902        }
903        DataType::Map(_) | DataType::Vector(_) | DataType::Variant => {
904            return Err(SinkError::Firestore(anyhow!(
905                "{} is not supported by Firestore sink",
906                data_type
907            )));
908        }
909    };
910    Ok(Value {
911        value_type: Some(value_type),
912    })
913}
914
915fn firestore_timestamp(seconds: i64, nanos: u32) -> Result<ValueType> {
916    if !(-62_135_596_800..=253_402_300_799).contains(&seconds) || nanos >= 1_000_000_000 {
917        return Err(SinkError::Firestore(anyhow!(
918            "Firestore timestamp must be between years 0001 and 9999"
919        )));
920    }
921    Ok(ValueType::TimestampValue(prost_types::Timestamp {
922        seconds,
923        nanos: (nanos / 1000 * 1000) as i32,
924    }))
925}
926
927fn format_jsonb(value: jsonbb::ValueRef<'_>, depth: usize) -> Result<Value> {
928    let value_type =
929        match value {
930            jsonbb::ValueRef::Null => ValueType::NullValue(0),
931            jsonbb::ValueRef::Bool(v) => ValueType::BooleanValue(v),
932            jsonbb::ValueRef::String(v) => ValueType::StringValue(v.as_str().to_owned()),
933            jsonbb::ValueRef::Number(v) => {
934                if let Some(v) = v.as_i64() {
935                    ValueType::IntegerValue(v)
936                } else if v.as_u64().is_some() {
937                    return Err(SinkError::Firestore(anyhow!(
938                        "JSONB integer exceeds Firestore signed 64-bit range"
939                    )));
940                } else {
941                    ValueType::DoubleValue(v.as_f64().ok_or_else(|| {
942                        SinkError::Firestore(anyhow!("JSONB number out of range"))
943                    })?)
944                }
945            }
946            jsonbb::ValueRef::Array(v) => {
947                check_container_depth(depth)?;
948                array_value(
949                    v.iter()
950                        .map(|v| format_jsonb(v, depth + 1))
951                        .collect::<Result<_>>()?,
952                )?
953            }
954            jsonbb::ValueRef::Object(v) => {
955                check_container_depth(depth)?;
956                let fields = v
957                    .iter()
958                    .map(|(k, v)| Ok((k.to_owned(), format_jsonb(v, depth + 1)?)))
959                    .collect::<Result<_>>()?;
960                ValueType::MapValue(MapValue { fields })
961            }
962        };
963    Ok(Value {
964        value_type: Some(value_type),
965    })
966}
967
968fn encode_pk_document_id(pk_values: Vec<JsonValue>) -> Result<String> {
969    let doc_id =
970        URL_SAFE_NO_PAD.encode(serde_json::to_vec(&pk_values).map_err(|e| {
971            SinkError::Firestore(anyhow!(e).context("failed to encode primary key"))
972        })?);
973    validate_firestore_document_id(&doc_id)?;
974    Ok(doc_id)
975}
976
977fn validate_firestore_document_id(doc_id: &str) -> Result<()> {
978    if doc_id.len() > 1500 || (doc_id.starts_with("__") && doc_id.ends_with("__")) {
979        return Err(SinkError::Firestore(anyhow!(
980            "Firestore document ID exceeds 1500 bytes or uses a reserved name"
981        )));
982    }
983    if doc_id.is_empty() {
984        return Err(SinkError::Firestore(anyhow!(
985            "Firestore document ID must not be empty"
986        )));
987    }
988    if doc_id.contains('/') {
989        return Err(SinkError::Firestore(anyhow!(
990            "Firestore document ID must not contain '/'"
991        )));
992    }
993    if doc_id == "." || doc_id == ".." {
994        return Err(SinkError::Firestore(anyhow!(
995            "Firestore document ID must not be '.' or '..'"
996        )));
997    }
998    Ok(())
999}
1000
1001impl AsyncTruncateSinkWriter for FirestoreSinkWriter {
1002    async fn write_chunk<'a>(
1003        &'a mut self,
1004        chunk: StreamChunk,
1005        _add_future: DeliveryFutureManagerAddFuture<'a, Self::DeliveryFuture>,
1006    ) -> Result<()> {
1007        let operations = self.write_chunk_inner(chunk)?;
1008        self.write_operations(operations).await?;
1009        Ok(())
1010    }
1011}
1012
1013#[cfg(test)]
1014mod tests;