1use crate::config::SpannerSinkConfig;
12use async_trait::async_trait;
13use faucet_common_spanner::encode::{EncodedKind, encode_to_kind};
14use faucet_common_spanner::quote_ident_spanner;
15use faucet_common_spanner::types::{SpannerType, parse_spanner_type, spanner_type_to_json_schema};
16use faucet_core::FaucetError;
17use gcloud_googleapis::spanner::admin::database::v1::UpdateDatabaseDdlRequest;
18use gcloud_googleapis::spanner::v1::Mutation;
19use gcloud_spanner::client::Client;
20use gcloud_spanner::key::Key;
21use gcloud_spanner::mutation::{delete, insert, insert_or_update};
22use gcloud_spanner::statement::{Statement, ToKind};
23use gcloud_spanner::value::CommitTimestamp;
24use serde_json::Value;
25use std::collections::HashSet;
26use std::sync::Arc;
27use std::time::Duration;
28
29const CELL_BUDGET: usize = 60_000;
33
34#[derive(Debug, Clone)]
36struct TableMeta {
37 columns: Vec<(String, SpannerType, bool)>,
39 pk: Vec<String>,
41}
42
43impl TableMeta {
44 fn type_of(&self, col: &str) -> Option<&SpannerType> {
45 self.columns
46 .iter()
47 .find(|(name, _, _)| name == col)
48 .map(|(_, ty, _)| ty)
49 }
50
51 fn has_column(&self, col: &str) -> bool {
52 self.columns.iter().any(|(name, _, _)| name == col)
53 }
54}
55
56#[derive(Debug)]
59struct Planned {
60 mutation: Mutation,
61 cells: usize,
62}
63
64#[derive(Clone, Copy, PartialEq)]
66enum WriteOp {
67 Insert,
69 InsertOrUpdate,
71}
72
73fn build_row_mutation(
80 table: &str,
81 record: &Value,
82 meta: &TableMeta,
83 op: WriteOp,
84 warned: &mut HashSet<String>,
85) -> Result<Planned, String> {
86 let obj = record
87 .as_object()
88 .ok_or_else(|| "record is not a JSON object".to_string())?;
89
90 let mut cols: Vec<&str> = Vec::new();
91 let mut vals: Vec<EncodedKind> = Vec::new();
92 for (name, ty, _) in &meta.columns {
93 if let Some(v) = obj.get(name) {
94 let kind = encode_to_kind(v, ty).map_err(|e| format!("column `{name}`: {e}"))?;
95 cols.push(name.as_str());
96 vals.push(EncodedKind(kind));
97 }
98 }
99 for key in obj.keys() {
100 if !meta.has_column(key) && warned.insert(key.clone()) {
101 tracing::warn!(
102 field = %key,
103 table = %table,
104 "record field has no matching Spanner column; dropping it (warned once per field)"
105 );
106 }
107 }
108 if cols.is_empty() {
109 return Err("record has no fields matching table columns".into());
110 }
111
112 let refs: Vec<&dyn ToKind> = vals.iter().map(|v| v as &dyn ToKind).collect();
113 let mutation = match op {
114 WriteOp::Insert => insert(table, &cols, &refs),
115 WriteOp::InsertOrUpdate => insert_or_update(table, &cols, &refs),
116 };
117 Ok(Planned {
118 mutation,
119 cells: cols.len(),
120 })
121}
122
123fn build_delete_mutation(
127 table: &str,
128 key_tuple: &faucet_core::KeyTuple,
129 meta: &TableMeta,
130) -> Result<Planned, String> {
131 let mut vals: Vec<EncodedKind> = Vec::with_capacity(meta.pk.len());
132 for pk_col in &meta.pk {
133 let (_, v) = key_tuple
134 .0
135 .iter()
136 .find(|(col, _)| col == pk_col)
137 .ok_or_else(|| format!("delete key is missing PK column `{pk_col}`"))?;
138 let ty = meta
139 .type_of(pk_col)
140 .ok_or_else(|| format!("PK column `{pk_col}` not found in table metadata"))?;
141 let kind = encode_to_kind(v, ty).map_err(|e| format!("key column `{pk_col}`: {e}"))?;
142 vals.push(EncodedKind(kind));
143 }
144 let refs: Vec<&dyn ToKind> = vals.iter().map(|v| v as &dyn ToKind).collect();
145 Ok(Planned {
146 mutation: delete(table, Key::composite(&refs)),
147 cells: meta.pk.len().max(1),
148 })
149}
150
151fn chunk_by_cells(planned: Vec<Planned>, batch_size: usize, budget: usize) -> Vec<Vec<Mutation>> {
156 let row_cap = if batch_size == 0 {
157 usize::MAX
158 } else {
159 batch_size
160 };
161 let mut chunks: Vec<Vec<Mutation>> = Vec::new();
162 let mut current: Vec<Mutation> = Vec::new();
163 let mut cells = 0usize;
164 for p in planned {
165 if !current.is_empty() && (cells + p.cells > budget || current.len() >= row_cap) {
166 chunks.push(std::mem::take(&mut current));
167 cells = 0;
168 }
169 cells += p.cells;
170 current.push(p.mutation);
171 }
172 if !current.is_empty() {
173 chunks.push(current);
174 }
175 chunks
176}
177
178fn validate_key_matches_pk(key: &[String], pk: &[String], table: &str) -> Result<(), FaucetError> {
182 let key_set: HashSet<&str> = key.iter().map(|s| s.as_str()).collect();
183 let pk_set: HashSet<&str> = pk.iter().map(|s| s.as_str()).collect();
184 if key_set != pk_set {
185 return Err(FaucetError::Config(format!(
186 "spanner sink: write key {key:?} must equal table `{table}`'s PRIMARY KEY columns \
187 {pk:?} (Spanner mutations always key on the primary key)"
188 )));
189 }
190 Ok(())
191}
192
193fn spanner_keyword(t: faucet_core::SqlBaseType) -> &'static str {
197 use faucet_core::SqlBaseType::*;
198 match t {
199 Integer => "INT64",
200 Double => "FLOAT64",
201 Boolean => "BOOL",
202 Text => "STRING(MAX)",
203 Json => "JSON",
204 }
205}
206
207fn spanner_type_ddl(ty: &SpannerType) -> Result<String, String> {
210 Ok(match ty {
211 SpannerType::Bool => "BOOL".into(),
212 SpannerType::Int64 => "INT64".into(),
213 SpannerType::Float32 => "FLOAT32".into(),
214 SpannerType::Float64 => "FLOAT64".into(),
215 SpannerType::Timestamp => "TIMESTAMP".into(),
216 SpannerType::Date => "DATE".into(),
217 SpannerType::String => "STRING(MAX)".into(),
218 SpannerType::Bytes => "BYTES(MAX)".into(),
219 SpannerType::Numeric => "NUMERIC".into(),
220 SpannerType::Json => "JSON".into(),
221 SpannerType::Array(inner) => format!("ARRAY<{}>", spanner_type_ddl(inner)?),
222 SpannerType::Other => {
223 return Err("cannot re-emit a STRUCT/PROTO column in DDL".into());
224 }
225 })
226}
227
228fn build_add_column_sql(table: &str, col: &str, t: faucet_core::SqlBaseType) -> String {
232 format!(
233 "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} {}",
234 quote_ident_spanner(table),
235 quote_ident_spanner(col),
236 spanner_keyword(t)
237 )
238}
239
240fn build_alter_column_sql(table: &str, col: &str, type_ddl: &str) -> String {
244 format!(
245 "ALTER TABLE {} ALTER COLUMN {} {}",
246 quote_ident_spanner(table),
247 quote_ident_spanner(col),
248 type_ddl
249 )
250}
251
252pub(crate) const SPANNER_COMMIT_TOKEN_TABLE: &str = "faucet_commit_token";
257
258fn commit_token_ddl() -> String {
261 format!(
262 "CREATE TABLE IF NOT EXISTS {t} ({s} STRING(MAX) NOT NULL, {k} STRING(MAX) NOT NULL, \
263 updated_at TIMESTAMP OPTIONS (allow_commit_timestamp=true)) PRIMARY KEY ({s})",
264 t = quote_ident_spanner(SPANNER_COMMIT_TOKEN_TABLE),
265 s = quote_ident_spanner(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
266 k = quote_ident_spanner(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
267 )
268}
269
270fn current_schema_json(meta: &TableMeta) -> Value {
273 let mut props = serde_json::Map::new();
274 for (name, ty, nullable) in &meta.columns {
275 props.insert(name.clone(), spanner_type_to_json_schema(ty, *nullable));
276 }
277 serde_json::json!({ "type": "object", "properties": props })
278}
279
280fn sink_err(context: &str, e: impl std::fmt::Display) -> FaucetError {
281 FaucetError::Sink(format!("spanner {context}: {e}"))
282}
283
284pub struct SpannerSink {
286 config: SpannerSinkConfig,
287 client: Client,
288 meta: tokio::sync::RwLock<Option<Arc<TableMeta>>>,
291 token_table_ready: tokio::sync::OnceCell<()>,
293 warned_fields: std::sync::Mutex<HashSet<String>>,
295}
296
297impl SpannerSink {
298 pub async fn new(config: SpannerSinkConfig) -> Result<Self, FaucetError> {
301 config.validate()?;
302 let client = config.connection.connect().await?;
303 Ok(Self {
304 config,
305 client,
306 meta: tokio::sync::RwLock::new(None),
307 token_table_ready: tokio::sync::OnceCell::new(),
308 warned_fields: std::sync::Mutex::new(HashSet::new()),
309 })
310 }
311
312 async fn fetch_meta(&self) -> Result<Option<TableMeta>, FaucetError> {
315 let mut columns: Vec<(String, SpannerType, bool)> = Vec::new();
316 {
317 let mut tx = self
318 .client
319 .single()
320 .await
321 .map_err(|e| sink_err("metadata read", e))?;
322 let mut stmt = Statement::new(
323 "SELECT c.COLUMN_NAME AS name, c.SPANNER_TYPE AS spanner_type, \
324 c.IS_NULLABLE AS is_nullable FROM INFORMATION_SCHEMA.COLUMNS c \
325 WHERE c.TABLE_SCHEMA = '' AND c.TABLE_NAME = @table \
326 ORDER BY c.ORDINAL_POSITION",
327 );
328 stmt.add_param("table", &self.config.table_name);
329 let mut iter = tx
330 .query(stmt)
331 .await
332 .map_err(|e| sink_err("metadata query", e))?;
333 while let Some(row) = iter
334 .next()
335 .await
336 .map_err(|e| sink_err("metadata read", e))?
337 {
338 let name = row
339 .column_by_name::<String>("name")
340 .map_err(|e| sink_err("metadata decode", e))?;
341 let ty = row
342 .column_by_name::<String>("spanner_type")
343 .map_err(|e| sink_err("metadata decode", e))?;
344 let nullable = row
345 .column_by_name::<String>("is_nullable")
346 .map_err(|e| sink_err("metadata decode", e))?;
347 columns.push((name, parse_spanner_type(&ty), nullable == "YES"));
348 }
349 }
350 if columns.is_empty() {
351 return Ok(None);
352 }
353
354 let mut pk: Vec<String> = Vec::new();
355 {
356 let mut tx = self
357 .client
358 .single()
359 .await
360 .map_err(|e| sink_err("metadata read", e))?;
361 let mut stmt = Statement::new(
362 "SELECT ic.COLUMN_NAME AS name FROM INFORMATION_SCHEMA.INDEX_COLUMNS ic \
363 WHERE ic.TABLE_SCHEMA = '' AND ic.TABLE_NAME = @table \
364 AND ic.INDEX_NAME = 'PRIMARY_KEY' ORDER BY ic.ORDINAL_POSITION",
365 );
366 stmt.add_param("table", &self.config.table_name);
367 let mut iter = tx
368 .query(stmt)
369 .await
370 .map_err(|e| sink_err("primary-key query", e))?;
371 while let Some(row) = iter
372 .next()
373 .await
374 .map_err(|e| sink_err("primary-key read", e))?
375 {
376 pk.push(
377 row.column_by_name::<String>("name")
378 .map_err(|e| sink_err("primary-key decode", e))?,
379 );
380 }
381 }
382 Ok(Some(TableMeta { columns, pk }))
383 }
384
385 async fn require_meta(&self) -> Result<Arc<TableMeta>, FaucetError> {
388 if let Some(meta) = self.meta.read().await.as_ref() {
389 return Ok(Arc::clone(meta));
390 }
391 let mut slot = self.meta.write().await;
392 if let Some(meta) = slot.as_ref() {
394 return Ok(Arc::clone(meta));
395 }
396 let fetched = self.fetch_meta().await?.ok_or_else(|| {
397 FaucetError::Sink(format!(
398 "spanner table `{}` does not exist in {}",
399 self.config.table_name,
400 self.config.connection.database_path()
401 ))
402 })?;
403 let arc = Arc::new(fetched);
404 *slot = Some(Arc::clone(&arc));
405 Ok(arc)
406 }
407
408 fn plan_page(
411 &self,
412 records: &[Value],
413 meta: &TableMeta,
414 ) -> Result<(Vec<Planned>, usize), FaucetError> {
415 let table = &self.config.table_name;
416 let mut warned = self
417 .warned_fields
418 .lock()
419 .unwrap_or_else(|poisoned| poisoned.into_inner());
420
421 if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
422 let mut planned = Vec::with_capacity(records.len());
423 for (idx, record) in records.iter().enumerate() {
424 let p = build_row_mutation(table, record, meta, WriteOp::Insert, &mut warned)
425 .map_err(|msg| {
426 FaucetError::Sink(format!("spanner append: row {idx}: {msg}"))
427 })?;
428 planned.push(p);
429 }
430 let count = planned.len();
431 return Ok((planned, count));
432 }
433
434 validate_key_matches_pk(&self.config.write.key, &meta.pk, table)?;
435 let plan = faucet_core::plan_writes(records, &self.config.write);
436 if let Some((idx, msg)) = plan.failed.first() {
437 return Err(FaucetError::Sink(format!(
438 "spanner {}: row {idx}: {msg}",
439 self.config.write.write_mode.as_str()
440 )));
441 }
442 let mut planned = Vec::with_capacity(plan.upserts.len() + plan.deletes.len());
443 for record in &plan.upserts {
444 let p = build_row_mutation(table, record, meta, WriteOp::InsertOrUpdate, &mut warned)
445 .map_err(|msg| FaucetError::Sink(format!("spanner upsert: {msg}")))?;
446 planned.push(p);
447 }
448 for key_tuple in &plan.deletes {
449 let p = build_delete_mutation(table, key_tuple, meta)
450 .map_err(|msg| FaucetError::Sink(format!("spanner delete: {msg}")))?;
451 planned.push(p);
452 }
453 let count = planned.len();
454 Ok((planned, count))
455 }
456
457 async fn run_ddl(&self, statements: Vec<String>) -> Result<(), FaucetError> {
460 let admin = self.config.connection.connect_admin().await?;
461 let mut op = admin
462 .database()
463 .update_database_ddl(
464 UpdateDatabaseDdlRequest {
465 database: self.config.connection.database_path(),
466 statements,
467 ..Default::default()
468 },
469 None,
470 )
471 .await
472 .map_err(|e| sink_err("DDL submit", e))?;
473 let timeout = Duration::from_secs(self.config.ddl_timeout_secs);
474 match tokio::time::timeout(timeout, op.wait(None)).await {
475 Ok(Ok(_)) => Ok(()),
476 Ok(Err(e)) => Err(sink_err("DDL operation", e)),
477 Err(_) => Err(FaucetError::Sink(format!(
478 "spanner DDL operation did not complete within {}s (ddl_timeout_secs)",
479 self.config.ddl_timeout_secs
480 ))),
481 }
482 }
483
484 async fn ensure_token_table(&self) -> Result<(), FaucetError> {
486 self.token_table_ready
487 .get_or_try_init(|| async { self.run_ddl(vec![commit_token_ddl()]).await })
488 .await?;
489 Ok(())
490 }
491}
492
493#[async_trait]
494impl faucet_core::Sink for SpannerSink {
495 fn config_schema(&self) -> Value {
496 serde_json::to_value(faucet_core::schema_for!(SpannerSinkConfig))
497 .expect("schema serialization")
498 }
499
500 fn connector_name(&self) -> &'static str {
501 "spanner"
502 }
503
504 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
505 &[
506 faucet_core::WriteMode::Append,
507 faucet_core::WriteMode::Upsert,
508 faucet_core::WriteMode::Delete,
509 ]
510 }
511
512 fn dedups_by_key(&self) -> bool {
513 self.config.write.dedups_by_key()
514 }
515
516 fn supports_schema_evolution(&self) -> bool {
517 true
518 }
519
520 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
525 Ok(self.fetch_meta().await?.map(|m| current_schema_json(&m)))
526 }
527
528 async fn evolve_schema(
535 &self,
536 evolution: &faucet_core::SchemaEvolution,
537 ) -> Result<(), FaucetError> {
538 let table = &self.config.table_name;
539 let mut statements: Vec<String> = Vec::new();
540
541 for c in &evolution.additions {
542 let t =
543 faucet_core::json_schema_base_type(&c.to).unwrap_or(faucet_core::SqlBaseType::Text);
544 statements.push(build_add_column_sql(table, &c.name, t));
545 }
546
547 let meta = if evolution.widenings.is_empty() && evolution.relax_nullability.is_empty() {
550 None
551 } else {
552 Some(self.require_meta().await?)
553 };
554 for c in &evolution.widenings {
555 let from_base = c.from.as_ref().and_then(faucet_core::json_schema_base_type);
556 let to_base = faucet_core::json_schema_base_type(&c.to);
557 if from_base != to_base {
558 return Err(FaucetError::Sink(format!(
559 "spanner cannot widen column `{}`'s base type ({:?} -> {:?}): Spanner does \
560 not support changing a column's type; set `allow_type_widening: false` so \
561 the drift policy treats this as incompatible",
562 c.name, from_base, to_base
563 )));
564 }
565 let meta = meta.as_ref().expect("meta fetched when widenings present");
566 let ty = meta.type_of(&c.name).ok_or_else(|| {
567 FaucetError::Sink(format!(
568 "spanner widen: column `{}` not found in table `{table}`",
569 c.name
570 ))
571 })?;
572 let ddl = spanner_type_ddl(ty).map_err(|e| {
573 FaucetError::Sink(format!("spanner widen column `{}`: {e}", c.name))
574 })?;
575 statements.push(build_alter_column_sql(table, &c.name, &ddl));
576 }
577 for col in &evolution.relax_nullability {
578 let meta = meta
579 .as_ref()
580 .expect("meta fetched when relaxations present");
581 let ty = meta.type_of(col).ok_or_else(|| {
582 FaucetError::Sink(format!(
583 "spanner relax: column `{col}` not found in table `{table}`"
584 ))
585 })?;
586 let ddl = spanner_type_ddl(ty)
587 .map_err(|e| FaucetError::Sink(format!("spanner relax column `{col}`: {e}")))?;
588 statements.push(build_alter_column_sql(table, col, &ddl));
589 }
590
591 if !statements.is_empty() {
592 self.run_ddl(statements).await?;
593 *self.meta.write().await = None;
595 }
596 Ok(())
597 }
598
599 fn dataset_uri(&self) -> String {
600 format!(
601 "spanner://{}/{}/{}/{}",
602 self.config.connection.project_id,
603 self.config.connection.instance,
604 self.config.connection.database,
605 self.config.table_name
606 )
607 }
608
609 async fn check(
613 &self,
614 ctx: &faucet_core::check::CheckContext,
615 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
616 use faucet_core::check::{CheckReport, Probe};
617
618 let started = std::time::Instant::now();
619 let select_one = async {
620 let mut tx = self.client.single().await.map_err(|e| e.to_string())?;
621 let mut iter = tx
622 .query(Statement::new("SELECT 1"))
623 .await
624 .map_err(|e| e.to_string())?;
625 iter.next().await.map_err(|e| e.to_string())?;
626 Ok::<(), String>(())
627 };
628 let auth = match tokio::time::timeout(ctx.timeout, select_one).await {
629 Ok(Ok(())) => Probe::pass("auth", started.elapsed()),
630 Ok(Err(e)) => Probe::fail_hint(
631 "auth",
632 started.elapsed(),
633 e,
634 "check project/instance/database and credentials",
635 ),
636 Err(_) => Probe::fail_hint(
637 "auth",
638 started.elapsed(),
639 "timed out",
640 "check project/instance/database and credentials",
641 ),
642 };
643
644 let started = std::time::Instant::now();
645 let schema = match tokio::time::timeout(ctx.timeout, self.fetch_meta()).await {
646 Ok(Ok(Some(meta))) => {
647 if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
648 Probe::pass("schema", started.elapsed())
649 } else {
650 match validate_key_matches_pk(
651 &self.config.write.key,
652 &meta.pk,
653 &self.config.table_name,
654 ) {
655 Ok(()) => Probe::pass("schema", started.elapsed()),
656 Err(e) => Probe::fail_hint(
657 "schema",
658 started.elapsed(),
659 e.to_string(),
660 "set `key` to exactly the table's PRIMARY KEY columns",
661 ),
662 }
663 }
664 }
665 Ok(Ok(None)) => Probe::fail_hint(
666 "schema",
667 started.elapsed(),
668 format!("table `{}` does not exist", self.config.table_name),
669 "create the table (Spanner mutations require an existing table)",
670 ),
671 Ok(Err(e)) => Probe::fail("schema", started.elapsed(), e.to_string()),
672 Err(_) => Probe::fail("schema", started.elapsed(), "timed out"),
673 };
674
675 Ok(CheckReport {
676 probes: vec![auth, schema],
677 })
678 }
679
680 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
685 if records.is_empty() {
686 return Ok(0);
687 }
688 let meta = self.require_meta().await?;
689 let (planned, count) = self.plan_page(records, &meta)?;
690 for chunk in chunk_by_cells(planned, self.config.batch_size, CELL_BUDGET) {
691 self.client
692 .apply(chunk)
693 .await
694 .map_err(|e| sink_err("commit", e))?;
695 }
696 tracing::info!(
697 table = %self.config.table_name,
698 rows = count,
699 "Spanner write complete"
700 );
701 Ok(count)
702 }
703
704 async fn write_batch_partial(
711 &self,
712 records: &[Value],
713 ) -> Result<Vec<faucet_core::RowOutcome>, FaucetError> {
714 if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
715 self.write_batch(records).await?;
716 return Ok(records.iter().map(|_| Ok(())).collect());
717 }
718
719 let meta = self.require_meta().await?;
720 validate_key_matches_pk(&self.config.write.key, &meta.pk, &self.config.table_name)?;
721 let plan = faucet_core::plan_writes(records, &self.config.write);
722
723 let table = &self.config.table_name;
724 let mut planned: Vec<Planned> = Vec::with_capacity(plan.upserts.len() + plan.deletes.len());
725 {
726 let mut warned = self
727 .warned_fields
728 .lock()
729 .unwrap_or_else(|poisoned| poisoned.into_inner());
730 for record in &plan.upserts {
731 let p =
732 build_row_mutation(table, record, &meta, WriteOp::InsertOrUpdate, &mut warned)
733 .map_err(|msg| FaucetError::Sink(format!("spanner upsert: {msg}")))?;
734 planned.push(p);
735 }
736 }
737 for key_tuple in &plan.deletes {
738 let p = build_delete_mutation(table, key_tuple, &meta)
739 .map_err(|msg| FaucetError::Sink(format!("spanner delete: {msg}")))?;
740 planned.push(p);
741 }
742 for chunk in chunk_by_cells(planned, self.config.batch_size, CELL_BUDGET) {
743 self.client
744 .apply(chunk)
745 .await
746 .map_err(|e| sink_err("commit", e))?;
747 }
748
749 let mut outcomes: Vec<faucet_core::RowOutcome> = records.iter().map(|_| Ok(())).collect();
750 for (idx, msg) in &plan.failed {
751 outcomes[*idx] = Err(FaucetError::Sink(format!(
752 "spanner {}: {msg}",
753 self.config.write.write_mode.as_str()
754 )));
755 }
756 Ok(outcomes)
757 }
758
759 async fn flush(&self) -> Result<(), FaucetError> {
760 Ok(())
762 }
763
764 fn supports_idempotent_writes(&self) -> bool {
765 true
766 }
767
768 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
772 let mut tx = self
773 .client
774 .single()
775 .await
776 .map_err(|e| sink_err("token read", e))?;
777 let scope_key = scope.to_string();
778 match tx
779 .read_row(
780 SPANNER_COMMIT_TOKEN_TABLE,
781 &[faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL],
782 Key::new(&scope_key),
783 )
784 .await
785 {
786 Ok(Some(row)) => Ok(Some(
787 row.column_by_name::<String>(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL)
788 .map_err(|e| sink_err("token decode", e))?,
789 )),
790 Ok(None) => Ok(None),
791 Err(status) if status.code() == gcloud_gax::grpc::Code::NotFound => Ok(None),
794 Err(status)
795 if status.message().to_ascii_lowercase().contains("not found")
796 || status.message().contains(SPANNER_COMMIT_TOKEN_TABLE) =>
797 {
798 Ok(None)
799 }
800 Err(status) => Err(sink_err("token read", status)),
801 }
802 }
803
804 async fn write_batch_idempotent(
810 &self,
811 records: &[Value],
812 scope: &str,
813 token: &str,
814 ) -> Result<usize, FaucetError> {
815 self.ensure_token_table().await?;
816 let meta = self.require_meta().await?;
817 let (planned, count) = self.plan_page(records, &meta)?;
819
820 let mut mutations: Vec<Mutation> = planned.into_iter().map(|p| p.mutation).collect();
821 let scope_owned = scope.to_string();
822 let token_owned = token.to_string();
825 mutations.push(insert_or_update(
826 SPANNER_COMMIT_TOKEN_TABLE,
827 &[
828 faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL,
829 faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL,
830 "updated_at",
831 ],
832 &[&scope_owned, &token_owned, &CommitTimestamp::new()],
833 ));
834
835 let mutations = Arc::new(mutations);
838 self.client
839 .read_write_transaction(move |tx| {
840 let mutations = Arc::clone(&mutations);
841 Box::pin(async move {
842 tx.buffer_write(mutations.as_ref().clone());
843 Ok::<(), gcloud_spanner::client::Error>(())
844 })
845 })
846 .await
847 .map_err(|e| sink_err("idempotent commit", e))?;
848 Ok(count)
849 }
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855 use faucet_core::{WriteMode, WriteSpec};
856 use gcloud_googleapis::spanner::v1::mutation::Operation;
857 use serde_json::json;
858
859 fn meta() -> TableMeta {
860 TableMeta {
861 columns: vec![
862 ("id".into(), SpannerType::Int64, false),
863 ("name".into(), SpannerType::String, true),
864 ("score".into(), SpannerType::Float64, true),
865 ("meta".into(), SpannerType::Json, true),
866 ],
867 pk: vec!["id".into()],
868 }
869 }
870
871 fn composite_meta() -> TableMeta {
872 TableMeta {
873 columns: vec![
874 ("tenant".into(), SpannerType::String, false),
875 ("id".into(), SpannerType::Int64, false),
876 ("v".into(), SpannerType::String, true),
877 ],
878 pk: vec!["tenant".into(), "id".into()],
879 }
880 }
881
882 fn write_columns(m: &Mutation) -> Vec<String> {
883 match m.operation.as_ref().expect("operation") {
884 Operation::Insert(w) | Operation::InsertOrUpdate(w) => w.columns.clone(),
885 other => panic!("expected write mutation, got {other:?}"),
886 }
887 }
888
889 #[test]
890 fn row_mutation_intersects_columns_and_encodes() {
891 let mut warned = HashSet::new();
892 let p = build_row_mutation(
893 "t",
894 &json!({"id": 1, "name": "a", "unknown": true}),
895 &meta(),
896 WriteOp::Insert,
897 &mut warned,
898 )
899 .unwrap();
900 assert_eq!(p.cells, 2);
901 assert_eq!(write_columns(&p.mutation), vec!["id", "name"]);
902 assert!(warned.contains("unknown"));
904 let before = warned.len();
906 build_row_mutation(
907 "t",
908 &json!({"id": 2, "unknown": false}),
909 &meta(),
910 WriteOp::Insert,
911 &mut warned,
912 )
913 .unwrap();
914 assert_eq!(warned.len(), before);
915 }
916
917 #[test]
918 fn row_mutation_rejects_non_objects_and_no_matches() {
919 let mut warned = HashSet::new();
920 assert!(
921 build_row_mutation("t", &json!([1]), &meta(), WriteOp::Insert, &mut warned)
922 .unwrap_err()
923 .contains("not a JSON object")
924 );
925 assert!(
926 build_row_mutation(
927 "t",
928 &json!({"nope": 1}),
929 &meta(),
930 WriteOp::Insert,
931 &mut warned
932 )
933 .unwrap_err()
934 .contains("no fields matching")
935 );
936 }
937
938 #[test]
939 fn row_mutation_surfaces_encode_errors_with_column_name() {
940 let mut warned = HashSet::new();
941 let err = build_row_mutation(
942 "t",
943 &json!({"id": "not-an-int-at-all"}),
944 &meta(),
945 WriteOp::Insert,
946 &mut warned,
947 )
948 .unwrap_err();
949 assert!(err.contains("column `id`"), "err: {err}");
950 }
951
952 #[test]
953 fn upsert_op_builds_insert_or_update() {
954 let mut warned = HashSet::new();
955 let p = build_row_mutation(
956 "t",
957 &json!({"id": 1}),
958 &meta(),
959 WriteOp::InsertOrUpdate,
960 &mut warned,
961 )
962 .unwrap();
963 assert!(matches!(
964 p.mutation.operation,
965 Some(Operation::InsertOrUpdate(_))
966 ));
967 }
968
969 #[test]
970 fn delete_mutation_reorders_keys_into_pk_order() {
971 let kt = faucet_core::KeyTuple(vec![
973 ("id".into(), json!(7)),
974 ("tenant".into(), json!("acme")),
975 ]);
976 let p = build_delete_mutation("t", &kt, &composite_meta()).unwrap();
977 assert_eq!(p.cells, 2);
978 let Some(Operation::Delete(d)) = &p.mutation.operation else {
979 panic!("expected delete");
980 };
981 let keys = &d.key_set.as_ref().expect("key set").keys;
982 assert_eq!(keys.len(), 1);
983 let vals = &keys[0].values;
985 assert_eq!(
986 vals[0].kind,
987 Some(prost_types::value::Kind::StringValue("acme".into()))
988 );
989 assert_eq!(
990 vals[1].kind,
991 Some(prost_types::value::Kind::StringValue("7".into()))
992 );
993 }
994
995 #[test]
996 fn delete_mutation_errors_on_missing_pk_column() {
997 let kt = faucet_core::KeyTuple(vec![("id".into(), json!(7))]);
998 let err = build_delete_mutation("t", &kt, &composite_meta()).unwrap_err();
999 assert!(err.contains("missing PK column `tenant`"));
1000 }
1001
1002 fn planned(cells: usize) -> Planned {
1003 Planned {
1004 mutation: insert("t", &["a"], &[&"x".to_string()]),
1005 cells,
1006 }
1007 }
1008
1009 #[test]
1010 fn chunking_respects_cell_budget() {
1011 let chunks = chunk_by_cells(vec![planned(40), planned(40), planned(40)], 0, 100);
1012 assert_eq!(chunks.iter().map(Vec::len).collect::<Vec<_>>(), vec![2, 1]);
1013 }
1014
1015 #[test]
1016 fn chunking_respects_row_cap() {
1017 let chunks = chunk_by_cells((0..5).map(|_| planned(1)).collect(), 2, 1000);
1018 assert_eq!(
1019 chunks.iter().map(Vec::len).collect::<Vec<_>>(),
1020 vec![2, 2, 1]
1021 );
1022 }
1023
1024 #[test]
1025 fn chunking_lets_an_oversized_row_ship_alone() {
1026 let chunks = chunk_by_cells(vec![planned(500), planned(1)], 0, 100);
1027 assert_eq!(chunks.iter().map(Vec::len).collect::<Vec<_>>(), vec![1, 1]);
1028 }
1029
1030 #[test]
1031 fn chunking_zero_batch_size_is_cell_bounded_only() {
1032 let chunks = chunk_by_cells((0..100).map(|_| planned(1)).collect(), 0, 1000);
1033 assert_eq!(chunks.len(), 1);
1034 assert_eq!(chunks[0].len(), 100);
1035 }
1036
1037 #[test]
1038 fn chunking_empty_input_is_empty() {
1039 assert!(chunk_by_cells(vec![], 10, 100).is_empty());
1040 }
1041
1042 #[test]
1043 fn key_pk_validation_is_order_insensitive_set_equality() {
1044 let key = vec!["id".to_string(), "tenant".to_string()];
1045 let pk = vec!["tenant".to_string(), "id".to_string()];
1046 assert!(validate_key_matches_pk(&key, &pk, "t").is_ok());
1047 let err = validate_key_matches_pk(&["id".to_string()], &pk, "t").unwrap_err();
1048 assert!(err.to_string().contains("PRIMARY KEY"));
1049 assert!(matches!(err, FaucetError::Config(_)));
1050 }
1051
1052 #[test]
1053 fn add_column_ddl() {
1054 assert_eq!(
1055 build_add_column_sql("t", "email", faucet_core::SqlBaseType::Text),
1056 "ALTER TABLE `t` ADD COLUMN IF NOT EXISTS `email` STRING(MAX)"
1057 );
1058 assert_eq!(
1059 build_add_column_sql("t", "n", faucet_core::SqlBaseType::Integer),
1060 "ALTER TABLE `t` ADD COLUMN IF NOT EXISTS `n` INT64"
1061 );
1062 assert_eq!(
1063 build_add_column_sql("t", "j", faucet_core::SqlBaseType::Json),
1064 "ALTER TABLE `t` ADD COLUMN IF NOT EXISTS `j` JSON"
1065 );
1066 }
1067
1068 #[test]
1069 fn alter_column_ddl_reemits_type_without_not_null() {
1070 assert_eq!(
1071 build_alter_column_sql("t", "name", "STRING(MAX)"),
1072 "ALTER TABLE `t` ALTER COLUMN `name` STRING(MAX)"
1073 );
1074 }
1075
1076 #[test]
1077 fn spanner_type_ddl_round_trips() {
1078 assert_eq!(spanner_type_ddl(&SpannerType::Int64).unwrap(), "INT64");
1079 assert_eq!(
1080 spanner_type_ddl(&SpannerType::Array(Box::new(SpannerType::Float64))).unwrap(),
1081 "ARRAY<FLOAT64>"
1082 );
1083 assert_eq!(spanner_type_ddl(&SpannerType::Bytes).unwrap(), "BYTES(MAX)");
1084 assert!(spanner_type_ddl(&SpannerType::Other).is_err());
1085 }
1086
1087 #[test]
1088 fn commit_token_ddl_shape() {
1089 let ddl = commit_token_ddl();
1090 assert!(ddl.contains("CREATE TABLE IF NOT EXISTS `faucet_commit_token`"));
1091 assert!(ddl.contains("`scope` STRING(MAX) NOT NULL"));
1092 assert!(ddl.contains("`token` STRING(MAX) NOT NULL"));
1093 assert!(ddl.contains("allow_commit_timestamp=true"));
1094 assert!(ddl.ends_with("PRIMARY KEY (`scope`)"));
1095 }
1096
1097 #[test]
1098 fn current_schema_json_shape() {
1099 let schema = current_schema_json(&meta());
1100 assert_eq!(schema["type"], "object");
1101 assert_eq!(schema["properties"]["id"]["type"], "integer");
1102 assert_eq!(
1103 schema["properties"]["name"]["type"],
1104 json!(["string", "null"])
1105 );
1106 assert_eq!(
1107 schema["properties"]["score"]["type"],
1108 json!(["number", "null"])
1109 );
1110 }
1111
1112 #[test]
1113 fn table_meta_lookups() {
1114 let m = meta();
1115 assert!(m.has_column("id"));
1116 assert!(!m.has_column("missing"));
1117 assert_eq!(m.type_of("score"), Some(&SpannerType::Float64));
1118 assert_eq!(m.type_of("missing"), None);
1119 }
1120
1121 #[test]
1124 fn write_spec_key_reorder_composite_delete_encodes_types() {
1125 let kt = faucet_core::KeyTuple(vec![
1126 ("tenant".into(), json!("t1")),
1127 ("id".into(), json!(9_007_199_254_740_993_i64)),
1128 ]);
1129 let p = build_delete_mutation("t", &kt, &composite_meta()).unwrap();
1130 let Some(Operation::Delete(d)) = &p.mutation.operation else {
1131 panic!("expected delete");
1132 };
1133 let vals = &d.key_set.as_ref().unwrap().keys[0].values;
1134 assert_eq!(
1136 vals[1].kind,
1137 Some(prost_types::value::Kind::StringValue(
1138 "9007199254740993".into()
1139 ))
1140 );
1141 }
1142
1143 #[test]
1144 fn plan_writes_integration_with_write_spec() {
1145 let spec = WriteSpec {
1147 write_mode: WriteMode::Upsert,
1148 key: vec!["id".into()],
1149 delete_marker: Some(faucet_core::DeleteMarker {
1150 field: "__op".into(),
1151 values: vec!["d".into()],
1152 }),
1153 };
1154 let page = vec![
1155 json!({"id": 1, "name": "a"}),
1156 json!({"id": 1, "name": "b"}),
1157 json!({"id": 2, "__op": "d"}),
1158 json!({"name": "no-key"}),
1159 ];
1160 let plan = faucet_core::plan_writes(&page, &spec);
1161 assert_eq!(plan.upserts.len(), 1); assert_eq!(plan.deletes.len(), 1);
1163 assert_eq!(plan.failed.len(), 1);
1164 let mut warned = HashSet::new();
1165 for u in &plan.upserts {
1166 build_row_mutation("t", u, &meta(), WriteOp::InsertOrUpdate, &mut warned).unwrap();
1167 }
1168 for d in &plan.deletes {
1169 build_delete_mutation("t", d, &meta()).unwrap();
1170 }
1171 }
1172}