Skip to main content

faucet_sink_bigquery/
sink.rs

1//! BigQuery streaming insert sink.
2
3use crate::config::BigQuerySinkConfig;
4use crate::idempotent;
5use crate::merge;
6use async_trait::async_trait;
7use faucet_common_bigquery::build_client;
8use faucet_core::FaucetError;
9use faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL;
10use gcp_bigquery_client::Client;
11use gcp_bigquery_client::error::BQError;
12use gcp_bigquery_client::model::get_query_results_parameters::GetQueryResultsParameters;
13use gcp_bigquery_client::model::query_parameter::QueryParameter;
14use gcp_bigquery_client::model::query_parameter_type::QueryParameterType;
15use gcp_bigquery_client::model::query_parameter_value::QueryParameterValue;
16use gcp_bigquery_client::model::query_request::QueryRequest;
17use gcp_bigquery_client::model::query_response::{QueryResponse, ResultSet};
18use gcp_bigquery_client::model::table_data_insert_all_request::TableDataInsertAllRequest;
19use gcp_bigquery_client::model::table_data_insert_all_response::TableDataInsertAllResponse;
20use serde_json::Value;
21use std::time::Duration;
22use tokio::sync::RwLock;
23
24/// Max wall-clock spent polling an idempotent-write / token-read job to
25/// completion before giving up. Exactly-once pages are small, so this is a
26/// generous safety cap, not a steady-state wait.
27const IDEMPOTENT_JOB_TIMEOUT: Duration = Duration::from_secs(120);
28
29/// Server-side long-poll window per `getQueryResults` completion check —
30/// BigQuery holds the connection open up to this long, so we don't busy-wait.
31const JOB_POLL_LONG_POLL_MS: i32 = 10_000;
32
33/// `true` when a `tables.get` error is a 404 (table does not exist) — used by
34/// `current_schema` to report a not-yet-created target as `Ok(None)` rather
35/// than a hard error.
36fn is_table_not_found(err: &BQError) -> bool {
37    matches!(err, BQError::ResponseError { error } if error.error.code == 404)
38}
39
40/// Serialize planned delete key tuples into a JSON array of `{key_col: value}`
41/// objects for the `@deletes` parameter consumed by the semi-join `DELETE`.
42fn deletes_to_payload(deletes: &[faucet_core::KeyTuple]) -> String {
43    let arr: Vec<Value> = deletes
44        .iter()
45        .map(|kt| {
46            let mut obj = serde_json::Map::new();
47            for (k, v) in &kt.0 {
48                obj.insert(k.clone(), v.clone());
49            }
50            Value::Object(obj)
51        })
52        .collect();
53    Value::Array(arr).to_string()
54}
55
56/// A sink that writes JSON records to a BigQuery table using the streaming
57/// insert API (`tabledata.insertAll`).
58pub struct BigQuerySink {
59    config: BigQuerySinkConfig,
60    client: Client,
61    /// Target table schema, fetched lazily on the first exactly-once / upsert
62    /// call and reused for every page in the run. `None` until first read, and
63    /// reset to `None` by [`evolve_schema`](faucet_core::Sink::evolve_schema) so
64    /// the next page diffs against the evolved table. Unused on the plain
65    /// streaming path.
66    schema_cache: RwLock<Option<Vec<idempotent::FieldSpec>>>,
67    /// Lazily-built GCS client for the Arrow columnar load-job staging upload
68    /// (#380). Built once from `config.bulk_load.gcs_auth` on the first
69    /// columnar write and reused for every staged file.
70    #[cfg(feature = "arrow")]
71    gcs_store: tokio::sync::OnceCell<google_cloud_storage::client::Storage>,
72}
73
74impl BigQuerySink {
75    /// Create a new BigQuery sink from the given configuration.
76    ///
77    /// This initialises the BigQuery client and authenticates with GCP.
78    /// Returns a [`FaucetError::Auth`] if authentication fails.
79    pub async fn new(config: BigQuerySinkConfig) -> Result<Self, FaucetError> {
80        faucet_core::validate_batch_size(config.batch_size)?;
81        config.write.validate()?;
82        let client = build_client(&config.auth).await?;
83        Ok(Self {
84            config,
85            client,
86            schema_cache: RwLock::new(None),
87            #[cfg(feature = "arrow")]
88            gcs_store: tokio::sync::OnceCell::new(),
89        })
90    }
91
92    /// Construct a sink from a pre-built BigQuery client.
93    ///
94    /// This is a low-level escape hatch for callers that build their own
95    /// [`gcp_bigquery_client::Client`] — for example to target the
96    /// [`bigquery-emulator`](https://github.com/goccy/bigquery-emulator) via
97    /// [`ClientBuilder::with_v2_base_url`](gcp_bigquery_client::client_builder::ClientBuilder::with_v2_base_url),
98    /// or to drive a wiremock-backed test fixture. Production code should
99    /// prefer [`BigQuerySink::new`], which handles credential loading.
100    #[doc(hidden)]
101    pub fn from_parts(config: BigQuerySinkConfig, client: Client) -> Self {
102        Self {
103            config,
104            client,
105            schema_cache: RwLock::new(None),
106            #[cfg(feature = "arrow")]
107            gcs_store: tokio::sync::OnceCell::new(),
108        }
109    }
110
111    /// Issue a single `tabledata.insertAll` call and return the raw response.
112    ///
113    /// Returns `Err` only on transport-level or HTTP-level failures. Per-row
114    /// `insertErrors` in the response body are surfaced to the caller as-is;
115    /// it is the caller's responsibility to inspect them.
116    ///
117    /// `skip_invalid_rows` maps to BigQuery's `skipInvalidRows` flag. When
118    /// `false` (the all-or-nothing [`write_batch`](Self::write_batch) path) a
119    /// single invalid row makes BigQuery commit *nothing* and return per-row
120    /// errors. When `true` (the [`write_batch_partial`] DLQ path) BigQuery
121    /// commits every valid row and reports `insertErrors` only for the rejected
122    /// ones — which is what makes the per-row `Ok`/`Err` mapping in
123    /// `write_batch_partial` truthful (without it, the "good" siblings are
124    /// reported `Ok` but were never actually committed → silent data loss).
125    ///
126    /// [`write_batch_partial`]: faucet_core::Sink::write_batch_partial
127    async fn insert_chunk_raw(
128        &self,
129        rows: &[Value],
130        skip_invalid_rows: bool,
131    ) -> Result<TableDataInsertAllResponse, FaucetError> {
132        let mut insert_request = TableDataInsertAllRequest::new();
133        if skip_invalid_rows {
134            insert_request.skip_invalid_rows();
135        }
136        for row in rows {
137            // When `insert_id_field` is configured, send that field's value as
138            // the streaming `insertId` so BigQuery can de-duplicate retries
139            // (#78/#31). A row lacking the field is inserted without one.
140            let insert_id = self.config.insert_id_field.as_ref().and_then(|field| {
141                row.get(field).map(|v| match v {
142                    Value::String(s) => s.clone(),
143                    other => other.to_string(),
144                })
145            });
146            insert_request.add_row(insert_id, row).map_err(|e| {
147                FaucetError::Sink(format!("failed to serialize row for BigQuery: {e}"))
148            })?;
149        }
150        self.client
151            .tabledata()
152            .insert_all(
153                &self.config.project_id,
154                &self.config.dataset_id,
155                &self.config.table_id,
156                insert_request,
157            )
158            .await
159            .map_err(|e| FaucetError::Sink(format!("BigQuery insertAll failed: {e}")))
160    }
161
162    /// Insert a single chunk of rows in one `tabledata.insertAll` call,
163    /// collapsing any per-row errors into a single [`FaucetError::Sink`].
164    ///
165    /// Used by [`write_batch`](Self::write_batch). Callers that need per-row
166    /// error granularity should use
167    /// [`write_batch_partial`](faucet_core::Sink::write_batch_partial) instead,
168    /// which calls [`insert_chunk_raw`](Self::insert_chunk_raw) directly.
169    async fn insert_batch(&self, rows: &[Value]) -> Result<usize, FaucetError> {
170        if rows.is_empty() {
171            return Ok(0);
172        }
173
174        // All-or-nothing path: `skipInvalidRows=false` so BigQuery commits the
175        // whole chunk or nothing. Any `insertErrors` below becomes an outer
176        // `Err`, so the pipeline aborts before the bookmark advances — no
177        // partial commit to resume past.
178        let response = self.insert_chunk_raw(rows, false).await?;
179
180        // Check for per-row errors.
181        if let Some(errors) = response.insert_errors
182            && !errors.is_empty()
183        {
184            let count = errors.len();
185            let first = &errors[0];
186            return Err(FaucetError::Sink(format!(
187                "BigQuery insertAll: {count} row(s) failed; first error on row {:?}: {:?}",
188                first.index,
189                first
190                    .errors
191                    .as_ref()
192                    .and_then(|errs| errs.first())
193                    .map(|e| &e.message),
194            )));
195        }
196
197        Ok(rows.len())
198    }
199
200    // -----------------------------------------------------------------------
201    // Exactly-once helpers
202    // -----------------------------------------------------------------------
203
204    /// Build a NAMED STRING query parameter.
205    fn string_param(name: &str, value: &str) -> QueryParameter {
206        QueryParameter {
207            name: Some(name.to_string()),
208            parameter_type: Some(QueryParameterType {
209                r#type: "STRING".to_string(),
210                array_type: None,
211                struct_types: None,
212            }),
213            parameter_value: Some(QueryParameterValue {
214                value: Some(value.to_string()),
215                array_values: None,
216                struct_values: None,
217            }),
218        }
219    }
220
221    /// Fetch the target table's schema fields directly via `tables.get`, with no
222    /// caching. Returns the raw [`idempotent::FieldSpec`]s (possibly empty for a
223    /// schemaless table); a missing table surfaces as the client's `BQError`.
224    async fn fetch_schema_fields(&self) -> Result<Vec<idempotent::FieldSpec>, BQError> {
225        let table = self
226            .client
227            .table()
228            .get(
229                &self.config.project_id,
230                &self.config.dataset_id,
231                &self.config.table_id,
232                Some(vec!["schema"]),
233            )
234            .await?;
235        // Table.schema is TableSchema (not Option); TableSchema.fields is Option<Vec<...>>.
236        Ok(table
237            .schema
238            .fields
239            .as_ref()
240            .map(|fs| {
241                fs.iter()
242                    .map(idempotent::FieldSpec::from_table_field)
243                    .collect()
244            })
245            .unwrap_or_default())
246    }
247
248    /// Fetch (once) and cache the target table's schema as
249    /// [`idempotent::FieldSpec`]s, returning an owned clone. Used by the
250    /// exactly-once / upsert write paths, which require a table with a defined
251    /// schema — a missing table or empty schema is a hard error here.
252    ///
253    /// The cache is reset by [`evolve_schema`](faucet_core::Sink::evolve_schema)
254    /// so a later page re-fetches the evolved schema.
255    async fn target_schema(&self) -> Result<Vec<idempotent::FieldSpec>, FaucetError> {
256        if let Some(fields) = self.schema_cache.read().await.as_ref() {
257            return Ok(fields.clone());
258        }
259        // Miss: fetch under the write lock so concurrent callers don't each
260        // issue a redundant tables.get. Re-check after acquiring in case a
261        // racing writer already filled it.
262        let mut guard = self.schema_cache.write().await;
263        if let Some(fields) = guard.as_ref() {
264            return Ok(fields.clone());
265        }
266        let fields = self
267            .fetch_schema_fields()
268            .await
269            .map_err(|e| FaucetError::Sink(format!("BigQuery tables.get (schema) failed: {e}")))?;
270        if fields.is_empty() {
271            return Err(FaucetError::Sink(format!(
272                "BigQuery target table {}.{}.{} has no schema fields; exactly-once \
273                 delivery requires a table with a defined schema",
274                self.config.project_id, self.config.dataset_id, self.config.table_id
275            )));
276        }
277        *guard = Some(fields.clone());
278        Ok(fields)
279    }
280
281    /// Backtick-quoted fully-qualified `` `project.dataset.table` `` reference.
282    fn table_ref(&self) -> String {
283        idempotent::table_ref(
284            &self.config.project_id,
285            &self.config.dataset_id,
286            &self.config.table_id,
287        )
288    }
289
290    /// Run one schema-evolution DDL statement through the same `jobs.query` +
291    /// authoritative job-status-verify path the data writes use, mapping any
292    /// failure to [`FaucetError::Sink`].
293    async fn run_ddl(&self, sql: String) -> Result<(), FaucetError> {
294        let mut req = QueryRequest::new(sql);
295        req.use_legacy_sql = false;
296        let resp = self
297            .client
298            .job()
299            .query(&self.config.project_id, req)
300            .await
301            .map_err(|e| FaucetError::Sink(format!("BigQuery schema-evolution DDL failed: {e}")))?;
302        self.await_query_complete(resp).await
303    }
304
305    /// Create the commit-token watermark table if it does not exist.
306    async fn ensure_commit_table(&self) -> Result<(), FaucetError> {
307        let sql =
308            idempotent::build_create_commit_table(&self.config.project_id, &self.config.dataset_id);
309        let mut req = QueryRequest::new(sql);
310        req.use_legacy_sql = false;
311        let resp = self
312            .client
313            .job()
314            .query(&self.config.project_id, req)
315            .await
316            .map_err(|e| FaucetError::Sink(format!("BigQuery commit-table create failed: {e}")))?;
317        self.await_query_complete(resp).await
318    }
319
320    /// Wait for a query/script job to finish, then authoritatively verify it
321    /// succeeded. Returns `Ok(())` only once the job reaches a terminal state
322    /// with no `errorResult`.
323    ///
324    /// Why `get_job` rather than the response `errors` field: the client maps
325    /// only non-2xx HTTP to `Err`, so a job that fails at *runtime* (a CAST
326    /// failure, a NULL into a REQUIRED column, …) comes back as `Ok` with the
327    /// failure recorded in the job body. `Job.status.error_result` is the
328    /// authoritative terminal-failure signal; the `errors` array can also carry
329    /// non-fatal warnings, so it must not be treated as failure on its own.
330    async fn await_query_complete(&self, initial: QueryResponse) -> Result<(), FaucetError> {
331        let (job_id, location) = Self::job_reference(&initial)?;
332
333        // Phase 1 — wait for completion via server-side long-poll (not a busy wait).
334        if !initial.job_complete.unwrap_or(false) {
335            let started = std::time::Instant::now();
336            loop {
337                let params = GetQueryResultsParameters {
338                    location: location.clone(),
339                    timeout_ms: Some(JOB_POLL_LONG_POLL_MS),
340                    max_results: Some(0),
341                    ..Default::default()
342                };
343                let resp = self
344                    .client
345                    .job()
346                    .get_query_results(&self.config.project_id, &job_id, params)
347                    .await
348                    .map_err(|e| {
349                        FaucetError::Sink(format!("BigQuery jobs.getQueryResults failed: {e}"))
350                    })?;
351                if resp.job_complete.unwrap_or(false) {
352                    break;
353                }
354                if started.elapsed() >= IDEMPOTENT_JOB_TIMEOUT {
355                    return Err(FaucetError::Sink(format!(
356                        "BigQuery job '{job_id}' did not complete within {}s",
357                        IDEMPOTENT_JOB_TIMEOUT.as_secs()
358                    )));
359                }
360                // The server long-poll normally blocks until completion, but if
361                // it returns early, back off so a still-running job can't turn
362                // this into a tight request-hammering loop.
363                tokio::time::sleep(Duration::from_millis(250)).await;
364            }
365        }
366
367        // Phase 2 — authoritative success check via the job's errorResult.
368        //
369        // We require an explicit terminal `DONE` state with no `errorResult`.
370        // A missing `status`, a non-`DONE` state, or a present `errorResult` all
371        // mean we cannot confirm the transaction durably committed — fail safe
372        // (returning `Ok` here would advance the bookmark over data that may
373        // never have landed, the silent-data-loss failure mode).
374        let job = self
375            .client
376            .job()
377            .get_job(&self.config.project_id, &job_id, location.as_deref())
378            .await
379            .map_err(|e| FaucetError::Sink(format!("BigQuery jobs.get failed: {e}")))?;
380        let status = job.status.ok_or_else(|| {
381            FaucetError::Sink(format!(
382                "BigQuery job '{job_id}' returned no status; cannot confirm durable commit"
383            ))
384        })?;
385        if let Some(err) = status.error_result {
386            return Err(FaucetError::Sink(format!(
387                "BigQuery query job '{job_id}' failed: {err}"
388            )));
389        }
390        match status.state.as_deref() {
391            Some("DONE") => Ok(()),
392            other => Err(FaucetError::Sink(format!(
393                "BigQuery job '{job_id}' is in state {other:?}, not DONE; cannot confirm durable commit"
394            ))),
395        }
396    }
397
398    /// Extract `(job_id, location)` from a query response's job reference.
399    fn job_reference(qr: &QueryResponse) -> Result<(String, Option<String>), FaucetError> {
400        let r = qr.job_reference.as_ref().ok_or_else(|| {
401            FaucetError::Sink("BigQuery query response missing jobReference".to_string())
402        })?;
403        let job_id = r
404            .job_id
405            .clone()
406            .ok_or_else(|| FaucetError::Sink("BigQuery jobReference missing jobId".to_string()))?;
407        Ok((job_id, r.location.clone()))
408    }
409
410    /// Run a planned upsert/delete page as one BigQuery multi-statement
411    /// transaction. When `token` is `Some((scope, tok))` the watermark `MERGE`
412    /// is appended inside the same transaction (exactly-once + upsert).
413    ///
414    /// The caller must have already validated `plan.failed` is empty (and, for
415    /// the exactly-once path, ensured the commit table exists). Returns the
416    /// number of rows applied (upserts + deletes).
417    async fn run_upsert_script(
418        &self,
419        plan: &faucet_core::WritePlan,
420        token: Option<(&str, &str)>,
421    ) -> Result<usize, FaucetError> {
422        let columns = self.target_schema().await?;
423        merge::validate_keys_present(&columns, &self.config.write.key)?;
424
425        let has_upserts = !plan.upserts.is_empty();
426        let has_deletes = !plan.deletes.is_empty();
427        if !has_upserts && !has_deletes {
428            // Nothing planned; for the exactly-once path the bookmark still
429            // needs its token, so emit the watermark MERGE alone.
430            if token.is_none() {
431                return Ok(0);
432            }
433        }
434
435        let key = &self.config.write.key;
436        let (project, dataset, table) = (
437            &self.config.project_id,
438            &self.config.dataset_id,
439            &self.config.table_id,
440        );
441        let sql = match token {
442            Some(_) => merge::build_upsert_idempotent_sql(
443                &columns,
444                key,
445                has_upserts,
446                has_deletes,
447                project,
448                dataset,
449                table,
450            ),
451            None => merge::build_upsert_transaction_sql(
452                &columns,
453                key,
454                has_upserts,
455                has_deletes,
456                project,
457                dataset,
458                table,
459            ),
460        };
461
462        let mut params = Vec::new();
463        if has_upserts {
464            let payload = serde_json::to_string(&plan.upserts).map_err(|e| {
465                FaucetError::Sink(format!("bigquery upsert: serialize payload: {e}"))
466            })?;
467            params.push(Self::string_param("payload", &payload));
468        }
469        if has_deletes {
470            let deletes = deletes_to_payload(&plan.deletes);
471            params.push(Self::string_param("deletes", &deletes));
472        }
473        if let Some((scope, tok)) = token {
474            params.push(Self::string_param("scope", scope));
475            params.push(Self::string_param("token", tok));
476        }
477
478        let mut req = QueryRequest::new(sql);
479        req.use_legacy_sql = false;
480        req.parameter_mode = Some("NAMED".to_string());
481        // MERGE-by-key is idempotent, so a retried request is harmless; for the
482        // exactly-once path a deterministic request_id additionally suppresses
483        // duplicate jobs from a retried HTTP request within BigQuery's window.
484        if let Some((scope, tok)) = token {
485            req.request_id = Some(idempotent::build_request_id(scope, tok));
486        }
487        req.query_parameters = Some(params);
488
489        let resp = self
490            .client
491            .job()
492            .query(&self.config.project_id, req)
493            .await
494            .map_err(|e| FaucetError::Sink(format!("bigquery upsert write failed: {e}")))?;
495        self.await_query_complete(resp).await?;
496
497        Ok(plan.upserts.len() + plan.deletes.len())
498    }
499}
500
501#[async_trait]
502impl faucet_core::Sink for BigQuerySink {
503    fn connector_name(&self) -> &'static str {
504        "bigquery"
505    }
506
507    fn config_schema(&self) -> serde_json::Value {
508        serde_json::to_value(faucet_core::schema_for!(BigQuerySinkConfig))
509            .expect("schema serialization")
510    }
511
512    fn dataset_uri(&self) -> String {
513        format!(
514            "bigquery://{}.{}.{}",
515            self.config.project_id, self.config.dataset_id, self.config.table_id
516        )
517    }
518
519    /// Preflight check (`faucet doctor`).
520    ///
521    /// Runs a single read-only `tables.get` against the configured
522    /// `project_id.dataset_id.table_id` using the already-authenticated
523    /// client built in [`BigQuerySink::new`]. This mints/uses the access
524    /// token and confirms the credentials can read the target table's
525    /// metadata — without inserting any rows. Auth, missing-dataset,
526    /// missing-table, and permission errors all surface as a `Fail` probe
527    /// with a remediation hint. The access token is never included in the
528    /// reason or hint.
529    async fn check(
530        &self,
531        ctx: &faucet_core::check::CheckContext,
532    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
533        use faucet_core::check::{CheckReport, Probe};
534
535        let started = std::time::Instant::now();
536        let fqn = format!(
537            "{}.{}.{}",
538            self.config.project_id, self.config.dataset_id, self.config.table_id
539        );
540
541        let result = tokio::time::timeout(
542            ctx.timeout,
543            self.client.table().get(
544                &self.config.project_id,
545                &self.config.dataset_id,
546                &self.config.table_id,
547                Some(vec!["tableReference"]),
548            ),
549        )
550        .await;
551
552        let probe = match result {
553            Ok(Ok(_table)) => Probe::pass("auth", started.elapsed()),
554            Ok(Err(e)) => Probe::fail_hint(
555                "auth",
556                started.elapsed(),
557                format!("BigQuery tables.get on {fqn} failed: {e}"),
558                "Verify the service account has roles/bigquery.dataViewer (or \
559                 read access) on the dataset and that the project, dataset, and \
560                 table IDs are correct.",
561            ),
562            Err(_elapsed) => Probe::fail_hint(
563                "auth",
564                started.elapsed(),
565                format!(
566                    "BigQuery tables.get on {fqn} timed out after {:?}",
567                    ctx.timeout
568                ),
569                "Check network reachability to bigquery.googleapis.com and that \
570                 credentials can be minted within the timeout.",
571            ),
572        };
573
574        Ok(CheckReport::single(probe))
575    }
576
577    /// Write records to BigQuery.
578    ///
579    /// When `config.batch_size > 0` and the input slice is larger than
580    /// `batch_size`, the slice is split into chunks of `batch_size` rows and
581    /// each chunk is sent as a separate `tabledata.insertAll` call. When
582    /// `config.batch_size == 0`, the entire slice is sent in a single
583    /// `insertAll` request — useful when upstream `StreamPage`s are already
584    /// sized for BigQuery's per-request limits.
585    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
586        if records.is_empty() {
587            return Ok(0);
588        }
589
590        if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
591            let plan = faucet_core::plan_writes(records, &self.config.write);
592            if let Some((idx, msg)) = plan.failed.first() {
593                return Err(FaucetError::Sink(format!(
594                    "bigquery {}: row {idx}: {msg}",
595                    self.config.write.write_mode.as_str()
596                )));
597            }
598            return self.run_upsert_script(&plan, None).await;
599        }
600
601        let chunks: Vec<&[Value]> = if self.config.batch_size == 0 {
602            // Sentinel: pass the entire upstream page through in a single
603            // insertAll call. Subject to BigQuery's ~10MB request limit.
604            vec![records]
605        } else {
606            records.chunks(self.config.batch_size).collect()
607        };
608
609        let mut total = 0;
610        for chunk in chunks {
611            total += self.insert_batch(chunk).await?;
612        }
613
614        tracing::info!(
615            table = %format!(
616                "{}.{}.{}",
617                self.config.project_id, self.config.dataset_id, self.config.table_id
618            ),
619            rows = total,
620            "BigQuery write complete"
621        );
622        Ok(total)
623    }
624
625    /// Write records to BigQuery, returning a per-row outcome vector.
626    ///
627    /// Unlike [`write_batch`](faucet_core::Sink::write_batch), which collapses all
628    /// `insertErrors` into a single `FaucetError`, this method maps each row
629    /// to `Ok(())` if BigQuery accepted it or `Err(FaucetError::Sink(...))` if
630    /// BigQuery reported a per-row error for it. This allows the pipeline's DLQ
631    /// router to quarantine only the rows that BigQuery actually rejected while
632    /// keeping already-committed siblings out of the dead-letter queue.
633    ///
634    /// Transport-level or HTTP-level failures (e.g. network errors, 4xx/5xx
635    /// responses) are still returned as an outer `Err` because no rows can be
636    /// considered committed in that case.
637    ///
638    /// Chunking follows the same `batch_size` semantics as `write_batch`:
639    /// `batch_size == 0` sends the entire slice in one call; `batch_size > 0`
640    /// splits the slice into chunks and concatenates the per-row outcomes in
641    /// input order.
642    async fn write_batch_partial(
643        &self,
644        records: &[Value],
645    ) -> Result<Vec<faucet_core::RowOutcome>, FaucetError> {
646        use std::collections::HashMap;
647
648        if records.is_empty() {
649            return Ok(Vec::new());
650        }
651
652        if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
653            let plan = faucet_core::plan_writes(records, &self.config.write);
654            self.run_upsert_script(&plan, None).await?;
655            let mut outcomes: Vec<faucet_core::RowOutcome> =
656                records.iter().map(|_| Ok(())).collect();
657            for (idx, msg) in &plan.failed {
658                outcomes[*idx] = Err(FaucetError::Sink(format!(
659                    "bigquery {}: {msg}",
660                    self.config.write.write_mode.as_str()
661                )));
662            }
663            return Ok(outcomes);
664        }
665
666        let chunks: Vec<&[Value]> = if self.config.batch_size == 0 {
667            vec![records]
668        } else {
669            records.chunks(self.config.batch_size).collect()
670        };
671
672        let mut outcomes: Vec<faucet_core::RowOutcome> = Vec::with_capacity(records.len());
673
674        for chunk in chunks {
675            // `skipInvalidRows=true`: BigQuery commits every valid row and
676            // returns `insertErrors` only for the rejected ones. This is what
677            // makes mapping the flagged indices to `Err` and the rest to
678            // `Ok(())` correct — the unflagged rows really were committed, so
679            // the DLQ router quarantines only the bad rows and the bookmark
680            // advances over genuinely-persisted data.
681            let response = self.insert_chunk_raw(chunk, true).await?;
682
683            // Build a set of failed row indices → first error message.
684            let failed: HashMap<usize, String> = response
685                .insert_errors
686                .unwrap_or_default()
687                .into_iter()
688                .filter_map(|e| {
689                    let idx = e.index? as usize;
690                    let msg = e
691                        .errors
692                        .as_ref()
693                        .and_then(|v| v.first())
694                        .map(|er| er.message.clone().unwrap_or_default())
695                        .unwrap_or_default();
696                    Some((idx, msg))
697                })
698                .collect();
699
700            for i in 0..chunk.len() {
701                match failed.get(&i) {
702                    Some(msg) => outcomes.push(Err(FaucetError::Sink(format!(
703                        "BigQuery row rejected: {msg}"
704                    )))),
705                    None => outcomes.push(Ok(())),
706                }
707            }
708        }
709
710        Ok(outcomes)
711    }
712
713    fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
714        &[
715            faucet_core::WriteMode::Append,
716            faucet_core::WriteMode::Upsert,
717            faucet_core::WriteMode::Delete,
718        ]
719    }
720
721    fn dedups_by_key(&self) -> bool {
722        self.config.write.dedups_by_key()
723    }
724
725    fn supports_idempotent_writes(&self) -> bool {
726        true
727    }
728
729    /// Read the last durably-committed token for `scope` from the watermark
730    /// table, so the pipeline can skip already-committed pages on resume.
731    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
732        self.ensure_commit_table().await?;
733        let mut req = QueryRequest::new(idempotent::build_select_token(
734            &self.config.project_id,
735            &self.config.dataset_id,
736        ));
737        req.use_legacy_sql = false;
738        req.parameter_mode = Some("NAMED".to_string());
739        req.query_parameters = Some(vec![Self::string_param("scope", scope)]);
740
741        let resp = self
742            .client
743            .job()
744            .query(&self.config.project_id, req)
745            .await
746            .map_err(|e| FaucetError::Sink(format!("BigQuery token read failed: {e}")))?;
747
748        // The watermark is a single tiny row, so `jobs.query` returns it inline.
749        // If BigQuery did not complete the read synchronously, fail safe: a
750        // wrong `None` here would re-run an already-committed page and produce
751        // duplicates, defeating exactly-once.
752        if !resp.job_complete.unwrap_or(false) {
753            return Err(FaucetError::Sink(
754                "BigQuery watermark read did not complete synchronously".to_string(),
755            ));
756        }
757        // `ResultSet` only yields rows when the response carries a schema; a
758        // completed `SELECT` always returns one. If it is somehow absent we
759        // cannot tell "no committed token" from "row present but unreadable",
760        // and a wrong `None` would replay committed pages — fail safe instead.
761        if resp.schema.is_none() {
762            return Err(FaucetError::Sink(
763                "BigQuery watermark read returned no schema; cannot trust the token result"
764                    .to_string(),
765            ));
766        }
767
768        let mut rs = ResultSet::new_from_query_response(resp);
769        if rs.next_row() {
770            rs.get_string_by_name(COMMIT_TOKEN_TOKEN_COL)
771                .map_err(|e| FaucetError::Sink(format!("BigQuery token decode failed: {e}")))
772        } else {
773            Ok(None)
774        }
775    }
776
777    /// Atomically write `records` and record `token` for `scope` in one BigQuery
778    /// multi-statement transaction: a typed `INSERT … SELECT FROM
779    /// UNNEST(JSON_QUERY_ARRAY(@payload))` plus a watermark `MERGE`. Either both
780    /// the rows and the token commit, or neither does — so a crash/resume skips
781    /// the already-committed page (zero duplicates) and a failed page replays
782    /// cleanly.
783    ///
784    /// The entire page is one atomic unit (no `batch_size` re-chunking — core
785    /// issues exactly one token per page), so the page must serialize within
786    /// BigQuery's ~10 MB `jobs.query` request limit.
787    async fn write_batch_idempotent(
788        &self,
789        records: &[Value],
790        scope: &str,
791        token: &str,
792    ) -> Result<usize, FaucetError> {
793        self.ensure_commit_table().await?;
794
795        if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
796            let plan = faucet_core::plan_writes(records, &self.config.write);
797            if let Some((idx, msg)) = plan.failed.first() {
798                return Err(FaucetError::Sink(format!(
799                    "bigquery {}: row {idx}: {msg}",
800                    self.config.write.write_mode.as_str()
801                )));
802            }
803            return self.run_upsert_script(&plan, Some((scope, token))).await;
804        }
805
806        let columns = self.target_schema().await?;
807
808        let payload = serde_json::to_string(records).map_err(|e| {
809            FaucetError::Sink(format!(
810                "BigQuery exactly-once: serialize page payload: {e}"
811            ))
812        })?;
813
814        let sql = idempotent::build_transaction_sql(
815            &columns,
816            &self.config.project_id,
817            &self.config.dataset_id,
818            &self.config.table_id,
819        );
820        let mut req = QueryRequest::new(sql);
821        req.use_legacy_sql = false;
822        req.parameter_mode = Some("NAMED".to_string());
823        req.request_id = Some(idempotent::build_request_id(scope, token));
824        req.query_parameters = Some(vec![
825            Self::string_param("payload", &payload),
826            Self::string_param("scope", scope),
827            Self::string_param("token", token),
828        ]);
829
830        let resp = self
831            .client
832            .job()
833            .query(&self.config.project_id, req)
834            .await
835            .map_err(|e| FaucetError::Sink(format!("BigQuery idempotent write failed: {e}")))?;
836        self.await_query_complete(resp).await?;
837
838        tracing::info!(
839            table = %format!(
840                "{}.{}.{}",
841                self.config.project_id, self.config.dataset_id, self.config.table_id
842            ),
843            rows = records.len(),
844            token = %token,
845            "BigQuery exactly-once page committed"
846        );
847        Ok(records.len())
848    }
849
850    // -----------------------------------------------------------------------
851    // Schema drift (issue #194)
852    // -----------------------------------------------------------------------
853
854    fn supports_schema_evolution(&self) -> bool {
855        true
856    }
857
858    /// Read the live destination schema via a schema-only `tables.get`, mapped
859    /// to an `infer_schema`-shaped object so the drift policy can diff a page
860    /// against the real table.
861    ///
862    /// Returns `Ok(None)` when the target table does not exist yet (404) or
863    /// carries no field definitions — both mean "no schema to diff against",
864    /// so the drift pass treats every page column as new.
865    async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
866        match self.fetch_schema_fields().await {
867            Ok(fields) if fields.is_empty() => Ok(None),
868            Ok(fields) => Ok(Some(idempotent::fieldspecs_to_json_schema(&fields))),
869            Err(e) if is_table_not_found(&e) => Ok(None),
870            Err(e) => Err(FaucetError::Sink(format!(
871                "BigQuery current_schema (tables.get) failed: {e}"
872            ))),
873        }
874    }
875
876    /// Apply an additive schema evolution to the target table via `ALTER TABLE`
877    /// DDL (issue #194):
878    ///
879    /// - additions → `ADD COLUMN IF NOT EXISTS <col> <type>`
880    /// - widenings → `ALTER COLUMN <col> SET DATA TYPE <type>`
881    /// - nullability relaxations → `ALTER COLUMN <col> DROP NOT NULL`
882    ///
883    /// Each statement runs as its own `jobs.query` job, verified to completion
884    /// via the authoritative job-status check. Every statement is idempotent so
885    /// concurrent runs converge. The cached schema is invalidated afterwards so
886    /// the next page re-fetches the evolved table.
887    async fn evolve_schema(
888        &self,
889        evolution: &faucet_core::SchemaEvolution,
890    ) -> Result<(), FaucetError> {
891        let table_ref = self.table_ref();
892
893        for c in &evolution.additions {
894            let bq = idempotent::base_to_bq(
895                faucet_core::json_schema_base_type(&c.to).unwrap_or(faucet_core::SqlBaseType::Text),
896            );
897            self.run_ddl(idempotent::build_add_column_ddl(&table_ref, &c.name, bq))
898                .await?;
899        }
900        for c in &evolution.widenings {
901            let bq = idempotent::base_to_bq(
902                faucet_core::json_schema_base_type(&c.to).unwrap_or(faucet_core::SqlBaseType::Text),
903            );
904            self.run_ddl(idempotent::build_alter_type_ddl(&table_ref, &c.name, bq))
905                .await?;
906        }
907        for col in &evolution.relax_nullability {
908            self.run_ddl(idempotent::build_drop_not_null_ddl(&table_ref, col))
909                .await?;
910        }
911
912        // Invalidate the cached schema so the next exactly-once / upsert page
913        // (and the next drift diff) reads the evolved table.
914        *self.schema_cache.write().await = None;
915        Ok(())
916    }
917
918    /// Columnar load-job is available only when a `bulk_load` staging config is
919    /// set **and** the write mode is `append` (#380). Load jobs are
920    /// append/truncate only; upsert/delete stay on the `Value` MERGE path, so
921    /// the pipeline never negotiates the columnar loop for them.
922    #[cfg(feature = "arrow")]
923    fn supports_columnar(&self) -> bool {
924        self.config.bulk_load.is_some()
925            && self.config.write.write_mode == faucet_core::WriteMode::Append
926    }
927
928    /// Write one Arrow `RecordBatch` by encoding it to Parquet, staging it on
929    /// GCS, and running a BigQuery `PARQUET` load job to completion. Append-only.
930    /// The body lives in `load.rs` (pure cloud I/O — a GCS-SDK staging upload +
931    /// live load job — that can't run in CI, so `codecov.yml` excludes that file
932    /// exactly as it does the GCS connectors).
933    #[cfg(feature = "arrow")]
934    async fn write_batch_columnar(
935        &self,
936        batch: &arrow::array::RecordBatch,
937    ) -> Result<usize, FaucetError> {
938        crate::load::write_columnar(&self.client, &self.config, &self.gcs_store, batch).await
939    }
940}
941
942#[cfg(test)]
943mod tests {
944    use super::deletes_to_payload;
945    use faucet_core::KeyTuple;
946    use serde_json::json;
947
948    // dataset_uri test is skipped: BigQuerySink::new() requires GCP credentials
949    // (build_client fetches auth in new()), and from_parts() requires a
950    // gcp_bigquery_client::Client which cannot be constructed without auth.
951
952    #[test]
953    fn deletes_to_payload_preserves_number_type() {
954        // The delete payload must keep an integer key as a JSON number (not the
955        // string "2"), so the matching `CAST(JSON_VALUE(d, '$.id') AS INT64)`
956        // semi-join compares like-for-like.
957        let p = deletes_to_payload(&[KeyTuple(vec![("id".into(), json!(2))])]);
958        let v: serde_json::Value = serde_json::from_str(&p).expect("valid JSON");
959        assert_eq!(v, json!([{"id": 2}]));
960        assert!(v[0]["id"].is_number(), "id must serialize as a number: {p}");
961    }
962
963    #[test]
964    fn deletes_to_payload_composite_key_roundtrips() {
965        let p = deletes_to_payload(&[KeyTuple(vec![
966            ("tenant".into(), json!("acme")),
967            ("id".into(), json!(7)),
968        ])]);
969        let v: serde_json::Value = serde_json::from_str(&p).expect("valid JSON");
970        assert_eq!(v, json!([{"tenant": "acme", "id": 7}]));
971    }
972
973    #[test]
974    fn deletes_to_payload_multiple_rows() {
975        let p = deletes_to_payload(&[
976            KeyTuple(vec![("id".into(), json!(1))]),
977            KeyTuple(vec![("id".into(), json!(2))]),
978        ]);
979        let v: serde_json::Value = serde_json::from_str(&p).expect("valid JSON");
980        assert_eq!(v, json!([{"id": 1}, {"id": 2}]));
981    }
982}