Skip to main content

faucet_sink_spanner/
sink.rs

1//! Cloud Spanner sink implementation.
2//!
3//! Records are written as batched **mutations** (`Insert` for append,
4//! `InsertOrUpdate` for upsert, `Delete` for delete mode), one atomic
5//! `Commit` per chunk. Values are encoded against the destination column
6//! types read once from `INFORMATION_SCHEMA` (see
7//! [`faucet_common_spanner::encode`]). Exactly-once delivery commits the
8//! page's mutations and a `faucet_commit_token` watermark row in a single
9//! read-write transaction.
10
11use 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
29/// Conservative bound on mutated cells per commit. Spanner rejects commits
30/// above ~80,000 mutated cells (rows × columns, **plus** secondary-index
31/// amplification the client cannot see), so the sink chunks well below it.
32const CELL_BUDGET: usize = 60_000;
33
34/// Live table metadata read from `INFORMATION_SCHEMA` (cached per sink).
35#[derive(Debug, Clone)]
36struct TableMeta {
37    /// `(name, type, nullable)` in declared column order.
38    columns: Vec<(String, SpannerType, bool)>,
39    /// PRIMARY KEY column names, in key order.
40    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/// One planned mutation plus the number of cells it mutates (drives the
57/// per-commit cell budget).
58#[derive(Debug)]
59struct Planned {
60    mutation: Mutation,
61    cells: usize,
62}
63
64/// Which write mutation to build for a data row.
65#[derive(Clone, Copy, PartialEq)]
66enum WriteOp {
67    /// Append mode — duplicate PKs fail the commit.
68    Insert,
69    /// Upsert mode — Spanner's native keyed upsert.
70    InsertOrUpdate,
71}
72
73/// Build the mutation for one record: columns are the intersection of the
74/// record's top-level fields with the table's columns, encoded per column
75/// type. Fields with no matching column are dropped with a one-shot warning
76/// (`warned` dedups across the sink's lifetime). A record with *no* matching
77/// column, a non-object record, or an encode failure is an `Err` naming the
78/// column.
79fn 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
123/// Build the delete mutation for one planned key tuple. Spanner delete keys
124/// must be supplied in **PRIMARY KEY column order**, which may differ from
125/// the configured `key` order — values are re-ordered here.
126fn 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
151/// Split planned mutations into commit-sized chunks: at most `batch_size`
152/// rows (0 = unbounded) and at most [`CELL_BUDGET`] cells per chunk. A single
153/// row above the budget still ships alone — Spanner, not the sink, is the
154/// authority on rejecting it.
155fn 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
178/// Upsert/delete requires the configured `key` to be exactly the table's
179/// PRIMARY KEY columns (Spanner mutations always key on the PK). Order is
180/// irrelevant — set equality.
181fn 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
193/// Map a [`faucet_core::SqlBaseType`] to the Spanner DDL keyword used when
194/// adding a column during schema evolution. Integers land as `INT64` and
195/// floats as `FLOAT64` — Spanner's widest numeric types.
196fn 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
207/// Render a [`SpannerType`] back to its DDL form, used when re-emitting a
208/// column to relax `NOT NULL`. `Other` (STRUCT/PROTO) cannot be re-emitted.
209fn 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
228/// `ALTER TABLE <t> ADD COLUMN IF NOT EXISTS <c> <kw>` — idempotent addition.
229/// New columns are always nullable (Spanner requires a default for NOT NULL
230/// additions, and drift additions are nullable by construction).
231fn 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
240/// `ALTER TABLE <t> ALTER COLUMN <c> <TYPE>` — re-emitting the column at its
241/// current type *without* `NOT NULL` relaxes the null constraint (Spanner has
242/// no `DROP NOT NULL`). Idempotent.
243fn 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
252/// Commit-token watermark table name. Spanner (GoogleSQL) identifiers must
253/// start with a letter, so the canonical `_faucet_commit_token` used by the
254/// other SQL sinks (`faucet_core::idempotency::COMMIT_TOKEN_TABLE`) is not a
255/// valid Spanner table name — this sink drops the leading underscore.
256pub(crate) const SPANNER_COMMIT_TOKEN_TABLE: &str = "faucet_commit_token";
257
258/// DDL for the commit-token watermark table (exactly-once). `updated_at` is a
259/// commit-timestamp column so replays are debuggable without a clock source.
260fn 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
270/// Render cached metadata as the `infer_schema`-shaped object
271/// (`{"type":"object","properties":{…}}`) the drift pass diffs against.
272fn 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
284/// A sink that writes JSON records to a Cloud Spanner table.
285pub struct SpannerSink {
286    config: SpannerSinkConfig,
287    client: Client,
288    /// Cached table metadata; `None` until first use, cleared by
289    /// [`evolve_schema`](faucet_core::Sink::evolve_schema).
290    meta: tokio::sync::RwLock<Option<Arc<TableMeta>>>,
291    /// One-shot commit-token-table creation guard.
292    token_table_ready: tokio::sync::OnceCell<()>,
293    /// Record fields already warned-about as having no matching column.
294    warned_fields: std::sync::Mutex<HashSet<String>>,
295}
296
297impl SpannerSink {
298    /// Create a new Spanner sink. Validates the config and builds the client
299    /// (session pool). Table metadata is read lazily on first write.
300    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    /// Read `(columns, pk)` for the target table from `INFORMATION_SCHEMA`.
313    /// Returns `Ok(None)` when the table does not exist.
314    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    /// Cached metadata, fetched on first use. Errors when the table is absent
386    /// — every caller here is a write path that requires it.
387    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        // Another writer may have raced us here.
393        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    /// Plan the whole page into mutations (mode-aware). Returns the planned
409    /// mutations and the written-row count, or the first failure.
410    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    /// Run DDL statements through the admin API, bounded by
458    /// `ddl_timeout_secs`.
459    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    /// Ensure the commit-token watermark table exists (once per sink).
485    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    /// Read the live destination schema from `INFORMATION_SCHEMA` as an
521    /// `infer_schema`-shaped object, or `None` when the table does not exist
522    /// yet (issue #194). Always fetches fresh — the drift pass caches on its
523    /// side and refreshes after `evolve`.
524    async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
525        Ok(self.fetch_meta().await?.map(|m| current_schema_json(&m)))
526    }
527
528    /// Apply additive schema evolution: new columns via `ADD COLUMN IF NOT
529    /// EXISTS`; nullability relaxations by re-emitting the column at its
530    /// current type without `NOT NULL`. Spanner cannot change a column's base
531    /// type (e.g. INT64→FLOAT64), so a base-type widening is a typed error —
532    /// set `allow_type_widening: false` so the drift policy classifies it
533    /// `incompatible` and routes it via `on_incompatible` instead.
534    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        // Widenings: Spanner can only "widen" nullability. A widening whose
548        // base type actually changes is unsupported.
549        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            // The table changed shape — drop the cached metadata.
594            *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    /// Preflight probes (`faucet doctor`): `auth` runs `SELECT 1`; `schema`
610    /// verifies the target table exists and — for upsert/delete — that the
611    /// configured `key` equals the table's PRIMARY KEY. Non-mutating.
612    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    /// Write records as batched mutations, one atomic commit per chunk
681    /// (`batch_size` rows and ≤60,000 cells per commit). Append mode uses
682    /// `Insert` (a duplicate PK fails the commit); upsert/delete are planned
683    /// via [`faucet_core::plan_writes`].
684    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    /// Write a batch and report per-row outcomes.
705    ///
706    /// Append mode delegates to `write_batch` (all-or-nothing per chunk). In
707    /// upsert/delete mode the good rows are applied and rows whose key could
708    /// not be extracted are reported as per-row `Err` so the pipeline routes
709    /// them to the DLQ.
710    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        // Every commit is already durable; nothing is buffered client-side.
761        Ok(())
762    }
763
764    fn supports_idempotent_writes(&self) -> bool {
765        true
766    }
767
768    /// Read the last committed watermark token for `scope` from the
769    /// `faucet_commit_token` table. `Ok(None)` when the table (or row) does
770    /// not exist yet.
771    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            // First exactly-once run: the watermark table hasn't been created
792            // yet — that's "no token", not an error.
793            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    /// Commit the page's mutations **and** the `(scope, token)` watermark in
805    /// one read-write transaction — on crash either both land or neither
806    /// does. The whole page is a single commit here (no re-chunking), so it
807    /// is bounded by Spanner's ~80,000-cell commit limit: size the source's
808    /// `batch_size` down for very wide tables.
809    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        // Plan before the transaction so planning failures abort cleanly.
818        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        // The token may carry a `#<bookmark>` suffix — stored verbatim,
823        // never parsed.
824        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        // The closure retries on ABORTED and must not keep state between
836        // attempts — it clones the pre-built mutation set per attempt.
837        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        // Unknown field warned exactly once.
903        assert!(warned.contains("unknown"));
904        // A second record with the same unknown field doesn't re-insert.
905        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        // WriteSpec key order differs from PK order.
972        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        // PK order is (tenant, id) — first value must be the tenant string.
984        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    /// Offline plan_page coverage through a sink built without a server —
1122    /// exercised via the pure helpers instead (plan_page requires a client).
1123    #[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        // INT64 keys travel string-encoded, losslessly.
1135        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        // Sanity: plan_writes + our builders compose (dedup by key, delete marker).
1146        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); // last-write-wins dedup
1162        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}