1use 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
25const IDEMPOTENT_JOB_TIMEOUT: Duration = Duration::from_secs(120);
29
30const JOB_POLL_LONG_POLL_MS: i32 = 10_000;
33
34fn is_table_not_found(err: &BQError) -> bool {
38 matches!(err, BQError::ResponseError { error } if error.error.code == 404)
39}
40
41fn 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
57fn 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
65fn 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
81pub struct BigQuerySink {
84 config: BigQuerySinkConfig,
85 client: Client,
86 schema_cache: RwLock<Option<Vec<idempotent::FieldSpec>>>,
92 #[cfg(feature = "arrow")]
96 gcs_store: tokio::sync::OnceCell<google_cloud_storage::client::Storage>,
97}
98
99impl BigQuerySink {
100 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 #[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 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 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 async fn insert_batch(&self, rows: &[Value]) -> Result<usize, FaucetError> {
195 if rows.is_empty() {
196 return Ok(0);
197 }
198
199 let response = self.insert_chunk_raw(rows, false).await?;
204
205 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 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 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 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 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 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 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 fn overwrite_temp_id(&self) -> String {
317 format!("{}__faucet_ovw", self.config.table_id)
318 }
319
320 fn overwrite_temp_ref(&self) -> String {
322 idempotent::table_ref(
323 &self.config.project_id,
324 &self.config.dataset_id,
325 &self.overwrite_temp_id(),
326 )
327 }
328
329 async fn insert_overwrite_page(&self, records: &[Value]) -> Result<usize, FaucetError> {
335 let columns = self.target_schema().await?;
336 let payload = serde_json::to_string(records).map_err(|e| {
337 FaucetError::Sink(format!("BigQuery overwrite: serialize page payload: {e}"))
338 })?;
339 let sql = idempotent::build_insert_select(
340 &columns,
341 &self.config.project_id,
342 &self.config.dataset_id,
343 &self.overwrite_temp_id(),
344 );
345 let mut req = QueryRequest::new(sql);
346 req.use_legacy_sql = false;
347 req.parameter_mode = Some("NAMED".to_string());
348 req.query_parameters = Some(vec![Self::string_param("payload", &payload)]);
349 let resp = self
350 .client
351 .job()
352 .query(&self.config.project_id, req)
353 .await
354 .map_err(|e| {
355 FaucetError::Sink(format!("BigQuery overwrite page insert failed: {e}"))
356 })?;
357 self.await_query_complete(resp).await?;
358 Ok(records.len())
359 }
360
361 async fn run_ddl(&self, sql: String) -> Result<(), FaucetError> {
365 let mut req = QueryRequest::new(sql);
366 req.use_legacy_sql = false;
367 let resp = self
368 .client
369 .job()
370 .query(&self.config.project_id, req)
371 .await
372 .map_err(|e| FaucetError::Sink(format!("BigQuery schema-evolution DDL failed: {e}")))?;
373 self.await_query_complete(resp).await
374 }
375
376 async fn ensure_commit_table(&self) -> Result<(), FaucetError> {
378 let sql =
379 idempotent::build_create_commit_table(&self.config.project_id, &self.config.dataset_id);
380 let mut req = QueryRequest::new(sql);
381 req.use_legacy_sql = false;
382 let resp = self
383 .client
384 .job()
385 .query(&self.config.project_id, req)
386 .await
387 .map_err(|e| FaucetError::Sink(format!("BigQuery commit-table create failed: {e}")))?;
388 self.await_query_complete(resp).await
389 }
390
391 async fn await_query_complete(&self, initial: QueryResponse) -> Result<(), FaucetError> {
394 self.await_query_job(initial).await.map(|_| ())
395 }
396
397 async fn await_query_job(&self, initial: QueryResponse) -> Result<Job, FaucetError> {
409 let (job_id, location) = Self::job_reference(&initial)?;
410
411 if !initial.job_complete.unwrap_or(false) {
413 let started = std::time::Instant::now();
414 loop {
415 let params = GetQueryResultsParameters {
416 location: location.clone(),
417 timeout_ms: Some(JOB_POLL_LONG_POLL_MS),
418 max_results: Some(0),
419 ..Default::default()
420 };
421 let resp = self
422 .client
423 .job()
424 .get_query_results(&self.config.project_id, &job_id, params)
425 .await
426 .map_err(|e| {
427 FaucetError::Sink(format!("BigQuery jobs.getQueryResults failed: {e}"))
428 })?;
429 if resp.job_complete.unwrap_or(false) {
430 break;
431 }
432 if started.elapsed() >= IDEMPOTENT_JOB_TIMEOUT {
433 return Err(FaucetError::Sink(format!(
434 "BigQuery job '{job_id}' did not complete within {}s",
435 IDEMPOTENT_JOB_TIMEOUT.as_secs()
436 )));
437 }
438 tokio::time::sleep(Duration::from_millis(250)).await;
442 }
443 }
444
445 let job = self
453 .client
454 .job()
455 .get_job(&self.config.project_id, &job_id, location.as_deref())
456 .await
457 .map_err(|e| FaucetError::Sink(format!("BigQuery jobs.get failed: {e}")))?;
458 let (state, error_result) = {
461 let status = job.status.as_ref().ok_or_else(|| {
462 FaucetError::Sink(format!(
463 "BigQuery job '{job_id}' returned no status; cannot confirm durable commit"
464 ))
465 })?;
466 (
467 status.state.clone(),
468 status.error_result.as_ref().map(|e| e.to_string()),
469 )
470 };
471 if let Some(err) = error_result {
472 return Err(FaucetError::Sink(format!(
473 "BigQuery query job '{job_id}' failed: {err}"
474 )));
475 }
476 match state.as_deref() {
477 Some("DONE") => Ok(job),
478 other => Err(FaucetError::Sink(format!(
479 "BigQuery job '{job_id}' is in state {other:?}, not DONE; cannot confirm durable commit"
480 ))),
481 }
482 }
483
484 fn job_reference(qr: &QueryResponse) -> Result<(String, Option<String>), FaucetError> {
486 let r = qr.job_reference.as_ref().ok_or_else(|| {
487 FaucetError::Sink("BigQuery query response missing jobReference".to_string())
488 })?;
489 let job_id = r
490 .job_id
491 .clone()
492 .ok_or_else(|| FaucetError::Sink("BigQuery jobReference missing jobId".to_string()))?;
493 Ok((job_id, r.location.clone()))
494 }
495
496 async fn run_upsert_script(
504 &self,
505 plan: &faucet_core::WritePlan,
506 token: Option<(&str, &str)>,
507 ) -> Result<usize, FaucetError> {
508 let columns = self.target_schema().await?;
509 merge::validate_keys_present(&columns, &self.config.write.key)?;
510
511 let has_upserts = !plan.upserts.is_empty();
512 let has_deletes = !plan.deletes.is_empty();
513 if !has_upserts && !has_deletes {
514 if token.is_none() {
517 return Ok(0);
518 }
519 }
520
521 let key = &self.config.write.key;
522 let (project, dataset, table) = (
523 &self.config.project_id,
524 &self.config.dataset_id,
525 &self.config.table_id,
526 );
527 let sql = match token {
528 Some(_) => merge::build_upsert_idempotent_sql(
529 &columns,
530 key,
531 has_upserts,
532 has_deletes,
533 project,
534 dataset,
535 table,
536 ),
537 None => merge::build_upsert_transaction_sql(
538 &columns,
539 key,
540 has_upserts,
541 has_deletes,
542 project,
543 dataset,
544 table,
545 ),
546 };
547
548 let mut params = Vec::new();
549 if has_upserts {
550 let payload = serde_json::to_string(&plan.upserts).map_err(|e| {
551 FaucetError::Sink(format!("bigquery upsert: serialize payload: {e}"))
552 })?;
553 params.push(Self::string_param("payload", &payload));
554 }
555 if has_deletes {
556 let deletes = deletes_to_payload(&plan.deletes);
557 params.push(Self::string_param("deletes", &deletes));
558 }
559 if let Some((scope, tok)) = token {
560 params.push(Self::string_param("scope", scope));
561 params.push(Self::string_param("token", tok));
562 }
563
564 let mut req = QueryRequest::new(sql);
565 req.use_legacy_sql = false;
566 req.parameter_mode = Some("NAMED".to_string());
567 if let Some((scope, tok)) = token {
571 req.request_id = Some(idempotent::build_request_id(scope, tok));
572 }
573 req.query_parameters = Some(params);
574
575 let resp = self
576 .client
577 .job()
578 .query(&self.config.project_id, req)
579 .await
580 .map_err(|e| FaucetError::Sink(format!("bigquery upsert write failed: {e}")))?;
581 self.await_query_complete(resp).await?;
582
583 Ok(plan.upserts.len() + plan.deletes.len())
584 }
585
586 async fn cleanup_scope_impl(
601 &self,
602 scope: &std::collections::BTreeMap<String, Value>,
603 seen: &faucet_core::SeenKeys,
604 ) -> Result<u64, FaucetError> {
605 let key = &self.config.write.key;
606 if key.is_empty() {
607 return Err(FaucetError::Sink(
608 "bigquery cleanup requires a non-empty `key`".to_string(),
609 ));
610 }
611 let columns = self.target_schema().await?;
612 let scope_cols: Vec<String> = scope.keys().cloned().collect();
613 merge::validate_cleanup_columns(&columns, &scope_cols, key)?;
614
615 let keys_payload = deletes_to_payload(seen.keys());
616 merge::check_cleanup_payload_size(keys_payload.len(), seen.len())?;
617
618 let sql = merge::build_cleanup_delete(
619 &columns,
620 &scope_cols,
621 key,
622 &self.config.project_id,
623 &self.config.dataset_id,
624 &self.config.table_id,
625 );
626 let mut req = QueryRequest::new(sql);
627 req.use_legacy_sql = false;
628 req.parameter_mode = Some("NAMED".to_string());
629 req.query_parameters = Some(vec![
630 Self::string_param("scope", &scope_to_payload(scope)),
631 Self::string_param("keys", &keys_payload),
632 ]);
633
634 let resp = self
635 .client
636 .job()
637 .query(&self.config.project_id, req)
638 .await
639 .map_err(|e| FaucetError::Sink(format!("bigquery cleanup delete failed: {e}")))?;
640 let job = self.await_query_job(resp).await?;
641 let deleted = dml_affected_rows(&job);
642
643 tracing::info!(
644 table = %format!(
645 "{}.{}.{}",
646 self.config.project_id, self.config.dataset_id, self.config.table_id
647 ),
648 deleted,
649 written_keys = seen.len(),
650 "BigQuery scoped cleanup complete"
651 );
652 Ok(deleted)
653 }
654}
655
656#[async_trait]
657impl faucet_core::Sink for BigQuerySink {
658 fn connector_name(&self) -> &'static str {
659 "bigquery"
660 }
661
662 fn supports_staged_load(&self) -> bool {
665 true
666 }
667
668 fn config_schema(&self) -> serde_json::Value {
669 serde_json::to_value(faucet_core::schema_for!(BigQuerySinkConfig))
670 .expect("schema serialization")
671 }
672
673 fn dataset_uri(&self) -> String {
674 format!(
675 "bigquery://{}.{}.{}",
676 self.config.project_id, self.config.dataset_id, self.config.table_id
677 )
678 }
679
680 async fn check(
691 &self,
692 ctx: &faucet_core::check::CheckContext,
693 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
694 use faucet_core::check::{CheckReport, Probe};
695
696 let started = std::time::Instant::now();
697 let fqn = format!(
698 "{}.{}.{}",
699 self.config.project_id, self.config.dataset_id, self.config.table_id
700 );
701
702 let result = tokio::time::timeout(
703 ctx.timeout,
704 self.client.table().get(
705 &self.config.project_id,
706 &self.config.dataset_id,
707 &self.config.table_id,
708 Some(vec!["tableReference"]),
709 ),
710 )
711 .await;
712
713 let probe = match result {
714 Ok(Ok(_table)) => Probe::pass("auth", started.elapsed()),
715 Ok(Err(e)) => Probe::fail_hint(
716 "auth",
717 started.elapsed(),
718 format!("BigQuery tables.get on {fqn} failed: {e}"),
719 "Verify the service account has roles/bigquery.dataViewer (or \
720 read access) on the dataset and that the project, dataset, and \
721 table IDs are correct.",
722 ),
723 Err(_elapsed) => Probe::fail_hint(
724 "auth",
725 started.elapsed(),
726 format!(
727 "BigQuery tables.get on {fqn} timed out after {:?}",
728 ctx.timeout
729 ),
730 "Check network reachability to bigquery.googleapis.com and that \
731 credentials can be minted within the timeout.",
732 ),
733 };
734
735 Ok(CheckReport::single(probe))
736 }
737
738 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
747 if records.is_empty() {
748 return Ok(0);
749 }
750
751 if matches!(
752 self.config.write.write_mode,
753 faucet_core::WriteMode::Upsert | faucet_core::WriteMode::Delete
754 ) {
755 let plan = faucet_core::plan_writes(records, &self.config.write);
756 if let Some((idx, msg)) = plan.failed.first() {
757 return Err(FaucetError::Sink(format!(
758 "bigquery {}: row {idx}: {msg}",
759 self.config.write.write_mode.as_str()
760 )));
761 }
762 return self.run_upsert_script(&plan, None).await;
763 }
764
765 if self.config.write.is_overwrite() {
769 return self.insert_overwrite_page(records).await;
770 }
771
772 let chunks: Vec<&[Value]> = if self.config.batch_size == 0 {
773 vec![records]
776 } else {
777 records.chunks(self.config.batch_size).collect()
778 };
779
780 let mut total = 0;
781 for chunk in chunks {
782 total += self.insert_batch(chunk).await?;
783 }
784
785 tracing::info!(
786 table = %format!(
787 "{}.{}.{}",
788 self.config.project_id, self.config.dataset_id, self.config.table_id
789 ),
790 rows = total,
791 "BigQuery write complete"
792 );
793 Ok(total)
794 }
795
796 async fn write_batch_partial(
814 &self,
815 records: &[Value],
816 ) -> Result<Vec<faucet_core::RowOutcome>, FaucetError> {
817 use std::collections::HashMap;
818
819 if records.is_empty() {
820 return Ok(Vec::new());
821 }
822
823 if self.config.write.is_overwrite() {
824 self.insert_overwrite_page(records).await?;
826 return Ok(records.iter().map(|_| Ok(())).collect());
827 }
828
829 if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
830 let plan = faucet_core::plan_writes(records, &self.config.write);
831 self.run_upsert_script(&plan, None).await?;
832 let mut outcomes: Vec<faucet_core::RowOutcome> =
833 records.iter().map(|_| Ok(())).collect();
834 for (idx, msg) in &plan.failed {
835 outcomes[*idx] = Err(FaucetError::Sink(format!(
836 "bigquery {}: {msg}",
837 self.config.write.write_mode.as_str()
838 )));
839 }
840 return Ok(outcomes);
841 }
842
843 let chunks: Vec<&[Value]> = if self.config.batch_size == 0 {
844 vec![records]
845 } else {
846 records.chunks(self.config.batch_size).collect()
847 };
848
849 let mut outcomes: Vec<faucet_core::RowOutcome> = Vec::with_capacity(records.len());
850
851 for chunk in chunks {
852 let response = self.insert_chunk_raw(chunk, true).await?;
859
860 let failed: HashMap<usize, String> = response
862 .insert_errors
863 .unwrap_or_default()
864 .into_iter()
865 .filter_map(|e| {
866 let idx = e.index? as usize;
867 let msg = e
868 .errors
869 .as_ref()
870 .and_then(|v| v.first())
871 .map(|er| er.message.clone().unwrap_or_default())
872 .unwrap_or_default();
873 Some((idx, msg))
874 })
875 .collect();
876
877 for i in 0..chunk.len() {
878 match failed.get(&i) {
879 Some(msg) => outcomes.push(Err(FaucetError::Sink(format!(
880 "BigQuery row rejected: {msg}"
881 )))),
882 None => outcomes.push(Ok(())),
883 }
884 }
885 }
886
887 Ok(outcomes)
888 }
889
890 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
891 &[
892 faucet_core::WriteMode::Append,
893 faucet_core::WriteMode::Upsert,
894 faucet_core::WriteMode::Delete,
895 faucet_core::WriteMode::Overwrite,
896 ]
897 }
898
899 fn is_overwrite(&self) -> bool {
900 self.config.write.is_overwrite()
901 }
902
903 async fn begin_overwrite(&self) -> Result<(), FaucetError> {
909 self.run_ddl(format!(
910 "CREATE OR REPLACE TABLE {} LIKE {}",
911 self.overwrite_temp_ref(),
912 self.table_ref()
913 ))
914 .await
915 }
916
917 async fn commit_overwrite(&self) -> Result<(), FaucetError> {
925 let temp = self.overwrite_temp_ref();
926 let sql = match &self.config.scope {
927 Some(scope) => {
928 let col = format!("`{}`", scope.column().replace('`', ""));
930 idempotent::build_scoped_overwrite_commit_sql(
931 &self.table_ref(),
932 &temp,
933 &scope.render_where_literal(&col),
934 )
935 }
936 None => idempotent::build_overwrite_commit_sql(&self.table_ref(), &temp),
937 };
938 self.run_ddl(sql).await?;
939 self.run_ddl(format!("DROP TABLE IF EXISTS {temp}")).await
940 }
941
942 async fn abort_overwrite(&self) -> Result<(), FaucetError> {
945 self.run_ddl(format!(
946 "DROP TABLE IF EXISTS {}",
947 self.overwrite_temp_ref()
948 ))
949 .await
950 }
951
952 fn dedups_by_key(&self) -> bool {
953 self.config.write.dedups_by_key()
954 }
955
956 fn supports_cleanup(&self) -> bool {
960 true
961 }
962
963 async fn cleanup_scope(
964 &self,
965 scope: &std::collections::BTreeMap<String, Value>,
966 seen: &faucet_core::SeenKeys,
967 ) -> Result<u64, FaucetError> {
968 self.cleanup_scope_impl(scope, seen).await
969 }
970
971 fn supports_idempotent_writes(&self) -> bool {
972 true
973 }
974
975 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
978 self.ensure_commit_table().await?;
979 let mut req = QueryRequest::new(idempotent::build_select_token(
980 &self.config.project_id,
981 &self.config.dataset_id,
982 ));
983 req.use_legacy_sql = false;
984 req.parameter_mode = Some("NAMED".to_string());
985 req.query_parameters = Some(vec![Self::string_param("scope", scope)]);
986
987 let resp = self
988 .client
989 .job()
990 .query(&self.config.project_id, req)
991 .await
992 .map_err(|e| FaucetError::Sink(format!("BigQuery token read failed: {e}")))?;
993
994 if !resp.job_complete.unwrap_or(false) {
999 return Err(FaucetError::Sink(
1000 "BigQuery watermark read did not complete synchronously".to_string(),
1001 ));
1002 }
1003 if resp.schema.is_none() {
1008 return Err(FaucetError::Sink(
1009 "BigQuery watermark read returned no schema; cannot trust the token result"
1010 .to_string(),
1011 ));
1012 }
1013
1014 let mut rs = ResultSet::new_from_query_response(resp);
1015 if rs.next_row() {
1016 rs.get_string_by_name(COMMIT_TOKEN_TOKEN_COL)
1017 .map_err(|e| FaucetError::Sink(format!("BigQuery token decode failed: {e}")))
1018 } else {
1019 Ok(None)
1020 }
1021 }
1022
1023 async fn write_batch_idempotent(
1034 &self,
1035 records: &[Value],
1036 scope: &str,
1037 token: &str,
1038 ) -> Result<usize, FaucetError> {
1039 self.ensure_commit_table().await?;
1040
1041 if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
1042 let plan = faucet_core::plan_writes(records, &self.config.write);
1043 if let Some((idx, msg)) = plan.failed.first() {
1044 return Err(FaucetError::Sink(format!(
1045 "bigquery {}: row {idx}: {msg}",
1046 self.config.write.write_mode.as_str()
1047 )));
1048 }
1049 return self.run_upsert_script(&plan, Some((scope, token))).await;
1050 }
1051
1052 let columns = self.target_schema().await?;
1053
1054 let payload = serde_json::to_string(records).map_err(|e| {
1055 FaucetError::Sink(format!(
1056 "BigQuery exactly-once: serialize page payload: {e}"
1057 ))
1058 })?;
1059
1060 let sql = idempotent::build_transaction_sql(
1061 &columns,
1062 &self.config.project_id,
1063 &self.config.dataset_id,
1064 &self.config.table_id,
1065 );
1066 let mut req = QueryRequest::new(sql);
1067 req.use_legacy_sql = false;
1068 req.parameter_mode = Some("NAMED".to_string());
1069 req.request_id = Some(idempotent::build_request_id(scope, token));
1070 req.query_parameters = Some(vec![
1071 Self::string_param("payload", &payload),
1072 Self::string_param("scope", scope),
1073 Self::string_param("token", token),
1074 ]);
1075
1076 let resp = self
1077 .client
1078 .job()
1079 .query(&self.config.project_id, req)
1080 .await
1081 .map_err(|e| FaucetError::Sink(format!("BigQuery idempotent write failed: {e}")))?;
1082 self.await_query_complete(resp).await?;
1083
1084 tracing::info!(
1085 table = %format!(
1086 "{}.{}.{}",
1087 self.config.project_id, self.config.dataset_id, self.config.table_id
1088 ),
1089 rows = records.len(),
1090 token = %token,
1091 "BigQuery exactly-once page committed"
1092 );
1093 Ok(records.len())
1094 }
1095
1096 fn supports_schema_evolution(&self) -> bool {
1101 true
1102 }
1103
1104 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
1112 match self.fetch_schema_fields().await {
1113 Ok(fields) if fields.is_empty() => Ok(None),
1114 Ok(fields) => Ok(Some(idempotent::fieldspecs_to_json_schema(&fields))),
1115 Err(e) if is_table_not_found(&e) => Ok(None),
1116 Err(e) => Err(FaucetError::Sink(format!(
1117 "BigQuery current_schema (tables.get) failed: {e}"
1118 ))),
1119 }
1120 }
1121
1122 async fn evolve_schema(
1134 &self,
1135 evolution: &faucet_core::SchemaEvolution,
1136 ) -> Result<(), FaucetError> {
1137 let table_ref = self.table_ref();
1138
1139 for c in &evolution.additions {
1140 let bq = idempotent::base_to_bq(
1141 faucet_core::json_schema_base_type(&c.to).unwrap_or(faucet_core::SqlBaseType::Text),
1142 );
1143 self.run_ddl(idempotent::build_add_column_ddl(&table_ref, &c.name, bq))
1144 .await?;
1145 }
1146 for c in &evolution.widenings {
1147 let bq = idempotent::base_to_bq(
1148 faucet_core::json_schema_base_type(&c.to).unwrap_or(faucet_core::SqlBaseType::Text),
1149 );
1150 self.run_ddl(idempotent::build_alter_type_ddl(&table_ref, &c.name, bq))
1151 .await?;
1152 }
1153 for col in &evolution.relax_nullability {
1154 self.run_ddl(idempotent::build_drop_not_null_ddl(&table_ref, col))
1155 .await?;
1156 }
1157
1158 *self.schema_cache.write().await = None;
1161 Ok(())
1162 }
1163
1164 #[cfg(feature = "arrow")]
1169 fn supports_columnar(&self) -> bool {
1170 self.config.bulk_load.is_some()
1171 && self.config.write.write_mode == faucet_core::WriteMode::Append
1172 }
1173
1174 #[cfg(feature = "arrow")]
1180 async fn write_batch_columnar(
1181 &self,
1182 batch: &arrow::array::RecordBatch,
1183 ) -> Result<usize, FaucetError> {
1184 crate::load::write_columnar(&self.client, &self.config, &self.gcs_store, batch).await
1185 }
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190 use super::{Job, deletes_to_payload, dml_affected_rows, scope_to_payload};
1191 use faucet_core::KeyTuple;
1192 use serde_json::json;
1193
1194 #[test]
1199 fn deletes_to_payload_preserves_number_type() {
1200 let p = deletes_to_payload(&[KeyTuple(vec![("id".into(), json!(2))])]);
1204 let v: serde_json::Value = serde_json::from_str(&p).expect("valid JSON");
1205 assert_eq!(v, json!([{"id": 2}]));
1206 assert!(v[0]["id"].is_number(), "id must serialize as a number: {p}");
1207 }
1208
1209 #[test]
1210 fn deletes_to_payload_composite_key_roundtrips() {
1211 let p = deletes_to_payload(&[KeyTuple(vec![
1212 ("tenant".into(), json!("acme")),
1213 ("id".into(), json!(7)),
1214 ])]);
1215 let v: serde_json::Value = serde_json::from_str(&p).expect("valid JSON");
1216 assert_eq!(v, json!([{"tenant": "acme", "id": 7}]));
1217 }
1218
1219 #[test]
1222 fn scope_to_payload_is_one_object_with_typed_values() {
1223 let scope = std::collections::BTreeMap::from([
1224 ("contact_id".to_string(), json!(42)),
1225 ("region".to_string(), json!("eu")),
1226 ]);
1227 let v: serde_json::Value = serde_json::from_str(&scope_to_payload(&scope)).expect("JSON");
1228 assert_eq!(v, json!({"contact_id": 42, "region": "eu"}));
1229 assert!(v["contact_id"].is_number());
1232 }
1233
1234 #[test]
1235 fn seen_keys_serialize_through_the_same_payload_shape() {
1236 assert_eq!(deletes_to_payload(&[]), "[]");
1239 }
1240
1241 #[test]
1242 fn dml_affected_rows_reads_the_job_statistics() {
1243 use gcp_bigquery_client::model::job_statistics::JobStatistics;
1244 use gcp_bigquery_client::model::job_statistics2::JobStatistics2;
1245
1246 let job = |n: Option<&str>| Job {
1247 statistics: Some(JobStatistics {
1248 query: Some(JobStatistics2 {
1249 num_dml_affected_rows: n.map(str::to_string),
1250 ..Default::default()
1251 }),
1252 ..Default::default()
1253 }),
1254 ..Default::default()
1255 };
1256 assert_eq!(dml_affected_rows(&job(Some("7"))), 7);
1257 assert_eq!(dml_affected_rows(&job(Some("0"))), 0);
1258 assert_eq!(dml_affected_rows(&job(None)), 0);
1261 assert_eq!(dml_affected_rows(&job(Some("not-a-number"))), 0);
1262 assert_eq!(dml_affected_rows(&Job::default()), 0);
1263 }
1264
1265 #[test]
1266 fn deletes_to_payload_multiple_rows() {
1267 let p = deletes_to_payload(&[
1268 KeyTuple(vec![("id".into(), json!(1))]),
1269 KeyTuple(vec![("id".into(), json!(2))]),
1270 ]);
1271 let v: serde_json::Value = serde_json::from_str(&p).expect("valid JSON");
1272 assert_eq!(v, json!([{"id": 1}, {"id": 2}]));
1273 }
1274}