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