Skip to main content

faucet_sink_mssql/
sink.rs

1//! The MSSQL [`Sink`] implementation — connection pool, transaction-wrapped
2//! multi-row `INSERT` with 2100-parameter auto-splitting, and row-isolation
3//! partial-failure handling for DLQ routing.
4
5use std::sync::Mutex;
6use std::time::Duration;
7
8use async_trait::async_trait;
9use faucet_core::check::{CheckContext, CheckReport, Probe};
10use faucet_core::{FaucetError, RowOutcome, Sink};
11use serde_json::Value;
12use tiberius::ToSql;
13
14use faucet_common_mssql::{MssqlPool, MssqlPooledConnection, build_pool, quote_ident_mssql};
15
16use crate::config::{MssqlColumnMapping, MssqlSinkConfig};
17use crate::encode::{
18    BoundParam, auto_row_params, build_insert_sql, build_merge, build_merge_delete,
19    max_rows_per_insert, resolve_insert_columns,
20};
21
22/// Microsoft SQL Server sink.
23pub struct MssqlSink {
24    config: MssqlSinkConfig,
25    pool: MssqlPool,
26    table_quoted: String,
27    /// Cached writable (non-IDENTITY) columns for `auto_columns` mode.
28    columns_cache: Mutex<Option<Vec<String>>>,
29}
30
31impl MssqlSink {
32    /// Connect, validate, build the pool, and (in `json_column` + `create_table`
33    /// mode) create the table if it doesn't exist.
34    pub async fn new(config: MssqlSinkConfig) -> Result<Self, FaucetError> {
35        config.validate()?;
36        config.write.validate()?;
37        if !matches!(config.write.write_mode, faucet_core::WriteMode::Append)
38            && !matches!(
39                config.column_mapping,
40                MssqlColumnMapping::AutoColumns { .. }
41            )
42        {
43            return Err(FaucetError::Config(
44                "mssql sink: write_mode upsert/delete requires column_mapping: auto_columns \
45                 (key columns must be real columns, not inside a JSON column)"
46                    .into(),
47            ));
48        }
49        let table_quoted = quote_table(&config.table)?;
50        let pool = build_pool(&config.connection, config.max_connections).await?;
51
52        let sink = Self {
53            config,
54            pool,
55            table_quoted,
56            columns_cache: Mutex::new(None),
57        };
58        sink.maybe_create_table().await?;
59        Ok(sink)
60    }
61
62    fn timeout(&self) -> Option<Duration> {
63        match self.config.statement_timeout_secs {
64            0 => None,
65            secs => Some(Duration::from_secs(secs)),
66        }
67    }
68
69    async fn maybe_create_table(&self) -> Result<(), FaucetError> {
70        if !self.config.create_table {
71            return Ok(());
72        }
73        let MssqlColumnMapping::JsonColumn { column } = &self.config.column_mapping else {
74            return Ok(()); // validated: create_table only with json_column
75        };
76        let col = quote_ident_mssql(column)?;
77        let sql = format!(
78            "IF OBJECT_ID(N'{}', N'U') IS NULL \
79             CREATE TABLE {} (id BIGINT IDENTITY(1,1) PRIMARY KEY, {} NVARCHAR(MAX))",
80            self.config.table.replace('\'', "''"),
81            self.table_quoted,
82            col
83        );
84        let mut conn = self.checkout().await?;
85        conn.simple_query(sql.as_str())
86            .await
87            .map_err(|e| FaucetError::Sink(format!("MSSQL create_table failed: {e}")))?
88            .into_results()
89            .await
90            .map_err(|e| FaucetError::Sink(format!("MSSQL create_table failed: {e}")))?;
91        Ok(())
92    }
93
94    async fn checkout(&self) -> Result<MssqlPooledConnection<'_>, FaucetError> {
95        self.pool
96            .get()
97            .await
98            .map_err(|e| FaucetError::Sink(format!("MSSQL pool checkout failed: {e}")))
99    }
100
101    /// Writable (non-IDENTITY) table columns, discovered once and cached.
102    async fn insertable_columns(&self) -> Result<Vec<String>, FaucetError> {
103        if let Some(cols) = self.columns_cache.lock().expect("columns mutex").clone() {
104            return Ok(cols);
105        }
106        let cols = self.discover_columns().await?;
107        *self.columns_cache.lock().expect("columns mutex") = Some(cols.clone());
108        Ok(cols)
109    }
110
111    async fn discover_columns(&self) -> Result<Vec<String>, FaucetError> {
112        let mut conn = self.checkout().await?;
113        let table: &str = &self.config.table;
114        let rows = conn
115            .query(
116                "SELECT c.name AS name FROM sys.columns c \
117                 WHERE c.object_id = OBJECT_ID(@P1) AND c.is_identity = 0 \
118                 ORDER BY c.column_id",
119                &[&table],
120            )
121            .await
122            .map_err(|e| FaucetError::Sink(format!("MSSQL column discovery failed: {e}")))?
123            .into_first_result()
124            .await
125            .map_err(|e| FaucetError::Sink(format!("MSSQL column discovery failed: {e}")))?;
126
127        let mut cols = Vec::with_capacity(rows.len());
128        for row in &rows {
129            if let Some(name) = row.get::<&str, _>("name") {
130                cols.push(name.to_string());
131            }
132        }
133        if cols.is_empty() {
134            return Err(FaucetError::Sink(format!(
135                "MSSQL table '{}' has no writable columns or does not exist",
136                self.config.table
137            )));
138        }
139        Ok(cols)
140    }
141
142    /// Discover each column's name, system type name, and nullability for the
143    /// target relation. Returns `(name, type_name, is_nullable)` in column order,
144    /// or an empty vec when the table does not exist / has no columns.
145    async fn discover_column_types(&self) -> Result<Vec<(String, String, bool)>, FaucetError> {
146        let mut conn = self.checkout().await?;
147        let table: &str = &self.config.table;
148        let rows = conn
149            .query(
150                "SELECT c.name AS name, ty.name AS type_name, c.is_nullable AS is_nullable \
151                 FROM sys.columns c \
152                 JOIN sys.types ty ON ty.user_type_id = c.user_type_id \
153                 WHERE c.object_id = OBJECT_ID(@P1) \
154                 ORDER BY c.column_id",
155                &[&table],
156            )
157            .await
158            .map_err(|e| FaucetError::Sink(format!("MSSQL schema query failed: {e}")))?
159            .into_first_result()
160            .await
161            .map_err(|e| FaucetError::Sink(format!("MSSQL schema query failed: {e}")))?;
162
163        let mut cols = Vec::with_capacity(rows.len());
164        for row in &rows {
165            let name = row.get::<&str, _>("name");
166            let type_name = row.get::<&str, _>("type_name");
167            // `is_nullable` is a SQL Server `bit` — tiberius decodes it as a bool.
168            let is_nullable = row.get::<bool, _>("is_nullable").unwrap_or(true);
169            if let (Some(name), Some(type_name)) = (name, type_name) {
170                cols.push((name.to_string(), type_name.to_string(), is_nullable));
171            }
172        }
173        Ok(cols)
174    }
175
176    /// Resolve the column list + per-row owned params for one chunk.
177    /// Returns `None` when there is nothing to insert (e.g. auto_columns with no
178    /// matching keys).
179    async fn prepare_chunk(
180        &self,
181        chunk: &[Value],
182    ) -> Result<Option<(Vec<String>, Vec<Vec<BoundParam>>)>, FaucetError> {
183        match &self.config.column_mapping {
184            MssqlColumnMapping::JsonColumn { column } => {
185                let cols = vec![column.clone()];
186                let rows: Vec<Vec<BoundParam>> = chunk
187                    .iter()
188                    .map(|r| {
189                        serde_json::to_string(r)
190                            .map(|s| vec![BoundParam::Str(s)])
191                            .map_err(|e| {
192                                FaucetError::Sink(format!(
193                                    "MSSQL json_column: failed to serialize record to JSON: {e}"
194                                ))
195                            })
196                    })
197                    .collect::<Result<_, _>>()?;
198                Ok(Some((cols, rows)))
199            }
200            MssqlColumnMapping::AutoColumns { on_unknown_field } => {
201                let insertable = self.insertable_columns().await?;
202                let cols = resolve_insert_columns(&insertable, chunk, *on_unknown_field)?;
203                if cols.is_empty() {
204                    return Ok(None);
205                }
206                let rows: Vec<Vec<BoundParam>> =
207                    chunk.iter().map(|r| auto_row_params(r, &cols)).collect();
208                Ok(Some((cols, rows)))
209            }
210        }
211    }
212
213    /// Insert rows within an **already-open** transaction — caller owns
214    /// `BEGIN TRAN`/`COMMIT TRAN`/`ROLLBACK TRAN`.  Splits into ≤2100-param
215    /// sub-INSERTs but does NOT issue any transaction-control statements.
216    ///
217    /// Used by `write_batch_idempotent` so the data INSERTs and the commit-token
218    /// MERGE share one externally-managed transaction.
219    ///
220    /// Returns `Err((error, timed_out))`. When `timed_out` is `true` the `exec`
221    /// future was dropped mid-TDS, leaving the connection desynced — the caller
222    /// must NOT issue ROLLBACK on it (mirrors `insert_chunk`).
223    async fn insert_rows_no_txn(
224        &self,
225        conn: &mut MssqlPooledConnection<'_>,
226        cols: &[String],
227        rows: &[Vec<BoundParam>],
228    ) -> Result<usize, (FaucetError, bool)> {
229        if rows.is_empty() {
230            return Ok(0);
231        }
232        let cols_quoted: Vec<String> = cols
233            .iter()
234            .map(|c| quote_ident_mssql(c))
235            .collect::<Result<_, _>>()
236            .map_err(|e| (e, false))?;
237        let per_insert = max_rows_per_insert(cols_quoted.len());
238        for sub in rows.chunks(per_insert) {
239            let sql = build_insert_sql(&self.table_quoted, &cols_quoted, sub.len());
240            let owned: Vec<&BoundParam> = sub.iter().flatten().collect();
241            let refs: Vec<&dyn ToSql> = owned.iter().map(|p| p.as_tosql()).collect();
242            let exec = async {
243                conn.execute(sql.as_str(), &refs)
244                    .await
245                    .map(|_| ())
246                    .map_err(|e| FaucetError::Sink(format!("MSSQL insert failed: {e}")))
247            };
248            // On timeout the `exec` future is dropped mid-TDS, desyncing the
249            // connection — the caller must NOT issue ROLLBACK on it (mirrors
250            // `insert_chunk`).
251            let (result, timed_out) = match self.timeout() {
252                Some(t) => match tokio::time::timeout(t, exec).await {
253                    Ok(inner) => (inner, false),
254                    Err(_) => (
255                        Err(FaucetError::Sink("MSSQL insert timed out".into())),
256                        true,
257                    ),
258                },
259                None => (exec.await, false),
260            };
261            if let Err(e) = result {
262                return Err((e, timed_out));
263            }
264        }
265        Ok(rows.len())
266    }
267
268    /// Ensure the per-sink commit-token watermark table exists.
269    /// Uses `IF OBJECT_ID … IS NULL CREATE TABLE` so it is idempotent.
270    async fn ensure_commit_table(
271        &self,
272        conn: &mut MssqlPooledConnection<'_>,
273    ) -> Result<(), FaucetError> {
274        // NVARCHAR(450) is the maximum SQL Server index key byte budget (900 B /
275        // 2 bytes-per-char = 450 chars) — fine for the PRIMARY KEY `scope`. The
276        // `token` column is `NVARCHAR(MAX)` because a `#291` commit token embeds
277        // the page's resume bookmark (`{20-digit seq}#{bookmark-json}`) and
278        // exceeds the old `NVARCHAR(32)`, which raised "String or binary data
279        // would be truncated" and broke exactly-once delivery (audit #321 C4).
280        // The table / column names are the fixed constants — no user-controlled
281        // input in this string.
282        let sql = format!(
283            "IF OBJECT_ID(N'{tbl}', N'U') IS NULL \
284             CREATE TABLE [{tbl}] ([scope] NVARCHAR(450) PRIMARY KEY, \
285             [token] NVARCHAR(MAX) NOT NULL, \
286             [updated_at] DATETIME2 DEFAULT SYSUTCDATETIME())",
287            tbl = faucet_core::idempotency::COMMIT_TOKEN_TABLE,
288        );
289        control(conn, &sql).await
290    }
291
292    /// Insert one chunk, splitting into ≤2100-parameter sub-INSERTs wrapped in a
293    /// single transaction (when `transaction_per_batch`). Returns rows inserted.
294    async fn insert_chunk(
295        &self,
296        conn: &mut MssqlPooledConnection<'_>,
297        cols: &[String],
298        rows: &[Vec<BoundParam>],
299    ) -> Result<usize, FaucetError> {
300        if rows.is_empty() {
301            return Ok(0);
302        }
303        let cols_quoted: Vec<String> = cols
304            .iter()
305            .map(|c| quote_ident_mssql(c))
306            .collect::<Result<_, _>>()?;
307        let per_insert = max_rows_per_insert(cols_quoted.len());
308
309        // Wrap the chunk in a transaction when configured, OR whenever it spans
310        // more than one ≤2100-param sub-INSERT. Under autocommit a multi-sub
311        // chunk commits each sub-INSERT independently, so a later failure leaves
312        // earlier sub-INSERTs committed — and both the batch-level transient
313        // retry (`write_batch`) and the per-row isolation (`write_batch_partial`)
314        // re-run the whole chunk, duplicating those committed rows (audit #146
315        // H6). Forcing a transaction makes the chunk atomic so re-running is safe.
316        let txn = self.config.transaction_per_batch || rows.len() > per_insert;
317        if txn {
318            control(conn, "BEGIN TRAN").await?;
319        }
320
321        for sub in rows.chunks(per_insert) {
322            let sql = build_insert_sql(&self.table_quoted, &cols_quoted, sub.len());
323            let owned: Vec<&BoundParam> = sub.iter().flatten().collect();
324            let refs: Vec<&dyn ToSql> = owned.iter().map(|p| p.as_tosql()).collect();
325
326            let exec = async {
327                conn.execute(sql.as_str(), &refs)
328                    .await
329                    .map(|_| ())
330                    .map_err(|e| FaucetError::Sink(format!("MSSQL insert failed: {e}")))
331            };
332            // Track whether the failure was a *timeout* specifically. On timeout
333            // the `exec` future is dropped mid-TDS, leaving an unread response on
334            // the wire — the connection is desynced and must NOT be reused (the
335            // pool helper's contract is "drop it"). Issuing ROLLBACK on it would
336            // run on a corrupt stream. A *normal* error leaves the connection in
337            // sync, so ROLLBACK is safe and releases the transaction promptly.
338            let (result, timed_out) = match self.timeout() {
339                Some(t) => match tokio::time::timeout(t, exec).await {
340                    Ok(inner) => (inner, false),
341                    Err(_) => (
342                        Err(FaucetError::Sink("MSSQL insert timed out".into())),
343                        true,
344                    ),
345                },
346                None => (exec.await, false),
347            };
348            if let Err(e) = result {
349                if txn && !timed_out {
350                    let _ = control(conn, "ROLLBACK TRAN").await;
351                }
352                return Err(e);
353            }
354        }
355
356        if txn {
357            control(conn, "COMMIT TRAN").await?;
358        }
359        Ok(rows.len())
360    }
361
362    /// Run a single `MERGE`-upsert / `MERGE`-delete statement on an
363    /// already-open connection, honouring the per-statement timeout. Returns
364    /// `Err((error, timed_out))`; on timeout the connection is desynced and the
365    /// caller must NOT issue ROLLBACK on it (mirrors `insert_rows_no_txn`).
366    async fn exec_merge(
367        &self,
368        conn: &mut MssqlPooledConnection<'_>,
369        sql: &str,
370        refs: &[&dyn ToSql],
371    ) -> Result<(), (FaucetError, bool)> {
372        let exec = async {
373            conn.execute(sql, refs)
374                .await
375                .map(|_| ())
376                .map_err(|e| FaucetError::Sink(format!("MSSQL merge failed: {e}")))
377        };
378        match self.timeout() {
379            Some(t) => match tokio::time::timeout(t, exec).await {
380                Ok(Ok(())) => Ok(()),
381                Ok(Err(e)) => Err((e, false)),
382                Err(_) => Err((FaucetError::Sink("MSSQL merge timed out".into()), true)),
383            },
384            None => exec.await.map_err(|e| (e, false)),
385        }
386    }
387
388    /// Upsert `upserts` into the table via `MERGE`, on an already-open
389    /// connection (the caller owns the transaction). Resolves the column set
390    /// via `resolve_insert_columns`, chunks by `max_rows_per_insert`, and binds
391    /// each row's params row-major exactly as `build_merge` numbers them.
392    async fn upsert_rows_no_txn(
393        &self,
394        conn: &mut MssqlPooledConnection<'_>,
395        upserts: &[Value],
396    ) -> Result<usize, (FaucetError, bool)> {
397        if upserts.is_empty() {
398            return Ok(0);
399        }
400        let MssqlColumnMapping::AutoColumns { on_unknown_field } = &self.config.column_mapping
401        else {
402            // Validated in `new()` — upsert requires auto_columns.
403            return Err((
404                FaucetError::Sink("MSSQL upsert requires column_mapping: auto_columns".into()),
405                false,
406            ));
407        };
408        let insertable = self.insertable_columns().await.map_err(|e| (e, false))?;
409        let cols = resolve_insert_columns(&insertable, upserts, *on_unknown_field)
410            .map_err(|e| (e, false))?;
411        if cols.is_empty() {
412            return Ok(0);
413        }
414        let per_insert = max_rows_per_insert(cols.len());
415        for sub in upserts.chunks(per_insert) {
416            let sql = build_merge(&self.table_quoted, &self.config.write.key, &cols, sub.len())
417                .map_err(|e| (e, false))?;
418            // Bind every row's params concatenated row-major — matches the @PN
419            // numbering build_merge emits.
420            let owned: Vec<BoundParam> =
421                sub.iter().flat_map(|r| auto_row_params(r, &cols)).collect();
422            let refs: Vec<&dyn ToSql> = owned.iter().map(|p| p.as_tosql()).collect();
423            self.exec_merge(conn, &sql, &refs).await?;
424        }
425        Ok(upserts.len())
426    }
427
428    /// Delete the `deletes` key tuples via `MERGE … WHEN MATCHED THEN DELETE`,
429    /// on an already-open connection. Chunks by `max_rows_per_insert(key.len())`
430    /// and binds each key tuple's values in `key` order, row-major.
431    async fn delete_keys_no_txn(
432        &self,
433        conn: &mut MssqlPooledConnection<'_>,
434        deletes: &[faucet_core::KeyTuple],
435    ) -> Result<usize, (FaucetError, bool)> {
436        if deletes.is_empty() {
437            return Ok(0);
438        }
439        let key = &self.config.write.key;
440        let per = max_rows_per_insert(key.len());
441        for chunk in deletes.chunks(per) {
442            let sql =
443                build_merge_delete(&self.table_quoted, key, chunk.len()).map_err(|e| (e, false))?;
444            // Bind each tuple's values in key order, row-major.
445            let owned: Vec<BoundParam> = chunk
446                .iter()
447                .flat_map(|kt| kt.0.iter().map(|(_, v)| BoundParam::from_value(v)))
448                .collect();
449            let refs: Vec<&dyn ToSql> = owned.iter().map(|p| p.as_tosql()).collect();
450            self.exec_merge(conn, &sql, &refs).await?;
451        }
452        Ok(deletes.len())
453    }
454
455    /// Apply a planned upsert/delete batch atomically: upserts then deletes,
456    /// wrapped in a single `BEGIN TRAN` / `COMMIT TRAN` so they commit together
457    /// (last-write-wins dedup already collapsed conflicting ops in the planner).
458    async fn apply_plan(&self, plan: &faucet_core::WritePlan) -> Result<usize, FaucetError> {
459        let mut conn = self.checkout().await?;
460        control(&mut conn, "BEGIN TRAN").await?;
461
462        let mut affected = 0usize;
463        match self.upsert_rows_no_txn(&mut conn, &plan.upserts).await {
464            Ok(n) => affected += n,
465            Err((e, timed_out)) => {
466                if !timed_out {
467                    let _ = control(&mut conn, "ROLLBACK TRAN").await;
468                }
469                return Err(e);
470            }
471        }
472        match self.delete_keys_no_txn(&mut conn, &plan.deletes).await {
473            Ok(n) => affected += n,
474            Err((e, timed_out)) => {
475                if !timed_out {
476                    let _ = control(&mut conn, "ROLLBACK TRAN").await;
477                }
478                return Err(e);
479            }
480        }
481
482        control(&mut conn, "COMMIT TRAN").await?;
483        Ok(affected)
484    }
485}
486
487/// Run a transaction-control statement and drain its (empty) result.
488async fn control(conn: &mut MssqlPooledConnection<'_>, stmt: &str) -> Result<(), FaucetError> {
489    conn.simple_query(stmt)
490        .await
491        .map_err(|e| FaucetError::Sink(format!("MSSQL {stmt} failed: {e}")))?
492        .into_results()
493        .await
494        .map_err(|e| FaucetError::Sink(format!("MSSQL {stmt} failed: {e}")))?;
495    Ok(())
496}
497
498/// Map a [`SqlBaseType`](faucet_core::SqlBaseType) to the MSSQL type keyword used when adding/widening a
499/// column during schema evolution (issue #194). Integers widen to `BIGINT` and
500/// floats to `FLOAT` so a later, wider value never overflows a narrower column;
501/// text/json land in `NVARCHAR(MAX)`.
502fn mssql_keyword(t: faucet_core::SqlBaseType) -> &'static str {
503    use faucet_core::SqlBaseType::*;
504    match t {
505        Integer => "BIGINT",
506        Double => "FLOAT",
507        Boolean => "BIT",
508        Text => "NVARCHAR(MAX)",
509        Json => "NVARCHAR(MAX)",
510    }
511}
512
513/// Build an idempotent `ADD COLUMN`. T-SQL has no `ADD COLUMN IF NOT EXISTS`, so
514/// guard with `IF NOT EXISTS (SELECT 1 FROM sys.columns …)`.
515///
516/// `table_quoted` is the already-bracket-quoted relation (`[dbo].[events]`).
517/// `table_literal` is the bare (un-quoted) table name used inside the
518/// `OBJECT_ID(N'…')` lookup — the caller passes `self.config.table` so it
519/// resolves the same relation the DDL targets. Both `table_literal` and `col`
520/// are single-quote-escaped for their `N'…'` string literals; `col` is also
521/// bracket-quoted for the `ADD` clause via [`quote_ident_mssql`].
522fn build_add_column_sql(
523    table_quoted: &str,
524    table_literal: &str,
525    col: &str,
526    t: faucet_core::SqlBaseType,
527) -> Result<String, FaucetError> {
528    let qcol = quote_ident_mssql(col)?;
529    Ok(format!(
530        "IF NOT EXISTS (SELECT 1 FROM sys.columns \
531         WHERE object_id = OBJECT_ID(N'{}') AND name = N'{}') \
532         ALTER TABLE {table_quoted} ADD {qcol} {}",
533        table_literal.replace('\'', "''"),
534        col.replace('\'', "''"),
535        mssql_keyword(t),
536    ))
537}
538
539/// `ALTER TABLE <ref> ALTER COLUMN <col> <kw>` — widen an existing column's
540/// type. Naturally idempotent (re-running the same type change is a no-op).
541fn build_alter_type_sql(
542    table_quoted: &str,
543    col: &str,
544    t: faucet_core::SqlBaseType,
545) -> Result<String, FaucetError> {
546    let qcol = quote_ident_mssql(col)?;
547    Ok(format!(
548        "ALTER TABLE {table_quoted} ALTER COLUMN {qcol} {}",
549        mssql_keyword(t),
550    ))
551}
552
553/// `ALTER TABLE <ref> ALTER COLUMN <col> <kw> NULL` — relax a NOT NULL
554/// constraint. MSSQL requires re-stating the column's current type when toggling
555/// nullability, so `kw` must be the column's existing type keyword. Naturally
556/// idempotent.
557fn build_alter_null_sql(table_quoted: &str, col: &str, kw: &str) -> Result<String, FaucetError> {
558    let qcol = quote_ident_mssql(col)?;
559    Ok(format!(
560        "ALTER TABLE {table_quoted} ALTER COLUMN {qcol} {kw} NULL",
561    ))
562}
563
564/// Map an MSSQL system type name (`sys.types.name`, e.g. `bigint`, `float`,
565/// `bit`, `nvarchar`) back to a JSON-Schema type fragment so
566/// [`MssqlSink::current_schema`] round-trips with [`faucet_core::diff_schema`].
567/// `nullable` reflects `sys.columns.is_nullable`.
568fn mssql_type_to_json_schema(type_name: &str, nullable: bool) -> Value {
569    let base = match type_name.to_ascii_lowercase().as_str() {
570        "bigint" | "int" | "smallint" | "tinyint" => "integer",
571        "float" | "real" | "decimal" | "numeric" | "money" | "smallmoney" => "number",
572        "bit" => "boolean",
573        _ => "string",
574    };
575    if nullable {
576        serde_json::json!({ "type": [base, "null"] })
577    } else {
578        serde_json::json!({ "type": base })
579    }
580}
581
582/// Quote a (possibly schema-qualified) table name: `dbo.events` → `[dbo].[events]`.
583fn quote_table(table: &str) -> Result<String, FaucetError> {
584    let parts: Vec<String> = table
585        .split('.')
586        .map(quote_ident_mssql)
587        .collect::<Result<_, _>>()?;
588    Ok(parts.join("."))
589}
590
591/// Heuristic for transient errors that warrant a batch-level retry / outer-Err
592/// propagation rather than per-row DLQ isolation.
593fn is_transient_error(msg: &str) -> bool {
594    let m = msg.to_ascii_lowercase();
595    m.contains("deadlock")
596        || m.contains("timed out")
597        || m.contains("timeout")
598        || m.contains("connection")
599        || m.contains("transport")
600        || m.contains("link failure")
601        || m.contains("1205")
602}
603
604const TRANSIENT_RETRIES: usize = 3;
605
606#[async_trait]
607impl Sink for MssqlSink {
608    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
609        if records.is_empty() {
610            return Ok(0);
611        }
612
613        // Non-append modes: plan the writes and apply upserts + deletes
614        // atomically. (NOTE: write_batch_partial upsert routing is handled in
615        // the DLQ task; write_batch_idempotent in the exactly-once task.)
616        if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
617            let plan = faucet_core::plan_writes(records, &self.config.write);
618            if let Some((idx, msg)) = plan.failed.first() {
619                return Err(FaucetError::Sink(format!(
620                    "mssql {}: row {idx}: {msg}",
621                    self.config.write.write_mode.as_str()
622                )));
623            }
624            let total = self.apply_plan(&plan).await?;
625            tracing::info!(
626                table = %self.config.table,
627                mode = self.config.write.write_mode.as_str(),
628                rows = total,
629                "MSSQL write complete"
630            );
631            return Ok(total);
632        }
633
634        let chunks: Vec<&[Value]> = if self.config.batch_size == 0 {
635            vec![records]
636        } else {
637            records.chunks(self.config.batch_size).collect()
638        };
639
640        let mut total = 0usize;
641        for chunk in chunks {
642            let Some((cols, rows)) = self.prepare_chunk(chunk).await? else {
643                continue;
644            };
645            // Bounded retry on transient (deadlock / lock-timeout) errors.
646            let mut attempt = 0;
647            loop {
648                let mut conn = self.checkout().await?;
649                match self.insert_chunk(&mut conn, &cols, &rows).await {
650                    Ok(n) => {
651                        total += n;
652                        break;
653                    }
654                    Err(e) if is_transient_error(&e.to_string()) && attempt < TRANSIENT_RETRIES => {
655                        attempt += 1;
656                        let backoff = Duration::from_millis(50 * (1 << attempt));
657                        tracing::warn!(attempt, error = %e, "MSSQL transient error; retrying batch");
658                        tokio::time::sleep(backoff).await;
659                    }
660                    Err(e) => return Err(e),
661                }
662            }
663        }
664        tracing::info!(table = %self.config.table, rows = total, "MSSQL write complete");
665        Ok(total)
666    }
667
668    async fn write_batch_partial(&self, records: &[Value]) -> Result<Vec<RowOutcome>, FaucetError> {
669        if records.is_empty() {
670            return Ok(Vec::new());
671        }
672
673        // Upsert/delete: apply the good rows (upserts + deletes) and route only
674        // the rows whose key could not be extracted (missing / null key) to the
675        // DLQ per-row. The append path below keeps its row-isolation behaviour.
676        if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
677            let plan = faucet_core::plan_writes(records, &self.config.write);
678            self.apply_plan(&plan).await?;
679
680            let mut outcomes: Vec<RowOutcome> = records.iter().map(|_| Ok(())).collect();
681            for (idx, msg) in &plan.failed {
682                outcomes[*idx] = Err(FaucetError::Sink(format!(
683                    "mssql {}: {msg}",
684                    self.config.write.write_mode.as_str()
685                )));
686            }
687            return Ok(outcomes);
688        }
689
690        let chunks: Vec<&[Value]> = if self.config.batch_size == 0 {
691            vec![records]
692        } else {
693            records.chunks(self.config.batch_size).collect()
694        };
695
696        let mut outcomes: Vec<RowOutcome> = Vec::with_capacity(records.len());
697        for chunk in chunks {
698            let Some((cols, rows)) = self.prepare_chunk(chunk).await? else {
699                // Nothing to insert for this chunk (no matching columns): the
700                // rows were effectively dropped per on_unknown_field; report Ok.
701                outcomes.extend(chunk.iter().map(|_| Ok(())));
702                continue;
703            };
704
705            let mut conn = self.checkout().await?;
706            match self.insert_chunk(&mut conn, &cols, &rows).await {
707                Ok(_) => outcomes.extend(chunk.iter().map(|_| Ok(()))),
708                Err(e) if is_transient_error(&e.to_string()) => {
709                    // Infra/transient — not row-specific. Propagate so the
710                    // pipeline's on_batch_error policy decides.
711                    return Err(e);
712                }
713                Err(_) if !self.config.isolate_row_failures => {
714                    // One bad row fails the whole batch (caller's choice).
715                    return Err(FaucetError::Sink(
716                        "MSSQL batch insert failed and isolate_row_failures is disabled".into(),
717                    ));
718                }
719                Err(_) => {
720                    // Row-isolate: retry each row alone to find the offender.
721                    for (i, row) in rows.iter().enumerate() {
722                        let single = std::slice::from_ref(row);
723                        let single_cols = cols.clone();
724                        match self.insert_chunk(&mut conn, &single_cols, single).await {
725                            Ok(_) => outcomes.push(Ok(())),
726                            Err(e) if is_transient_error(&e.to_string()) => return Err(e),
727                            Err(e) => {
728                                tracing::warn!(row = i, error = %e, "MSSQL row rejected; routing to DLQ");
729                                outcomes.push(Err(e));
730                            }
731                        }
732                    }
733                }
734            }
735        }
736        Ok(outcomes)
737    }
738
739    async fn flush(&self) -> Result<(), FaucetError> {
740        Ok(())
741    }
742
743    fn supports_idempotent_writes(&self) -> bool {
744        true
745    }
746
747    fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
748        &[
749            faucet_core::WriteMode::Append,
750            faucet_core::WriteMode::Upsert,
751            faucet_core::WriteMode::Delete,
752        ]
753    }
754
755    fn dedups_by_key(&self) -> bool {
756        self.config.write.dedups_by_key()
757    }
758
759    fn supports_schema_evolution(&self) -> bool {
760        true
761    }
762
763    /// Read the live destination schema from `sys.columns` as an
764    /// `infer_schema`-shaped object (`{"type":"object","properties":{…}}`), or
765    /// `None` when the target table does not exist yet (issue #194).
766    async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
767        let cols = self.discover_column_types().await?;
768        if cols.is_empty() {
769            return Ok(None); // table does not exist yet
770        }
771        let mut props = serde_json::Map::new();
772        for (name, type_name, nullable) in cols {
773            props.insert(name, mssql_type_to_json_schema(&type_name, nullable));
774        }
775        Ok(Some(
776            serde_json::json!({ "type": "object", "properties": props }),
777        ))
778    }
779
780    /// Apply an additive schema evolution (new columns, lossless widenings,
781    /// nullability relaxations) to the destination table. Idempotent — the
782    /// `ADD` is guarded with `IF NOT EXISTS (SELECT 1 FROM sys.columns …)`, and
783    /// re-running the same `ALTER COLUMN` type / `… NULL` is a no-op (issue #194).
784    async fn evolve_schema(
785        &self,
786        evolution: &faucet_core::SchemaEvolution,
787    ) -> Result<(), FaucetError> {
788        let mut conn = self.checkout().await?;
789
790        for c in &evolution.additions {
791            let t =
792                faucet_core::json_schema_base_type(&c.to).unwrap_or(faucet_core::SqlBaseType::Text);
793            let sql = build_add_column_sql(&self.table_quoted, &self.config.table, &c.name, t)?;
794            control(&mut conn, &sql).await.map_err(|e| {
795                FaucetError::Sink(format!("MSSQL ADD COLUMN {} failed: {e}", c.name))
796            })?;
797        }
798        for c in &evolution.widenings {
799            let t =
800                faucet_core::json_schema_base_type(&c.to).unwrap_or(faucet_core::SqlBaseType::Text);
801            let sql = build_alter_type_sql(&self.table_quoted, &c.name, t)?;
802            control(&mut conn, &sql).await.map_err(|e| {
803                FaucetError::Sink(format!("MSSQL ALTER COLUMN {} failed: {e}", c.name))
804            })?;
805        }
806        if !evolution.relax_nullability.is_empty() {
807            // Re-emitting the column as NULL requires its CURRENT type keyword —
808            // derive it from the live schema.
809            let current: std::collections::HashMap<String, &'static str> = self
810                .discover_column_types()
811                .await?
812                .into_iter()
813                .map(|(name, type_name, _)| {
814                    let base = faucet_core::json_schema_base_type(&mssql_type_to_json_schema(
815                        &type_name, false,
816                    ))
817                    .unwrap_or(faucet_core::SqlBaseType::Text);
818                    (name, mssql_keyword(base))
819                })
820                .collect();
821            for col in &evolution.relax_nullability {
822                let Some(kw) = current.get(col) else {
823                    // Column not found in the live schema — nothing to relax.
824                    continue;
825                };
826                let sql = build_alter_null_sql(&self.table_quoted, col, kw)?;
827                control(&mut conn, &sql).await.map_err(|e| {
828                    FaucetError::Sink(format!("MSSQL relax NULL {col} failed: {e}"))
829                })?;
830            }
831        }
832
833        // Columns changed — drop the cached AutoColumns set so the next write
834        // re-discovers them (a newly-added column must be picked up).
835        *self.columns_cache.lock().expect("columns mutex") = None;
836        Ok(())
837    }
838
839    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
840        let mut conn = self.checkout().await?;
841        self.ensure_commit_table(&mut conn).await?;
842        let scope_owned = scope.to_string();
843        let rows = conn
844            .query(
845                &format!(
846                    "SELECT [token] FROM [{}] WHERE [scope] = @P1",
847                    faucet_core::idempotency::COMMIT_TOKEN_TABLE
848                ),
849                &[&scope_owned],
850            )
851            .await
852            .map_err(|e| FaucetError::Sink(format!("MSSQL token read failed: {e}")))?
853            .into_first_result()
854            .await
855            .map_err(|e| FaucetError::Sink(format!("MSSQL token read failed: {e}")))?;
856        Ok(rows
857            .first()
858            .and_then(|r| r.get::<&str, _>("token"))
859            .map(str::to_string))
860    }
861
862    async fn write_batch_idempotent(
863        &self,
864        records: &[Value],
865        scope: &str,
866        token: &str,
867    ) -> Result<usize, FaucetError> {
868        // For upsert/delete modes, plan the page before opening the transaction
869        // so a key-extraction failure aborts without leaving an open tx.
870        let plan = if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
871            None
872        } else {
873            let plan = faucet_core::plan_writes(records, &self.config.write);
874            if let Some((idx, msg)) = plan.failed.first() {
875                return Err(FaucetError::Sink(format!(
876                    "mssql {}: row {idx}: {msg}",
877                    self.config.write.write_mode.as_str()
878                )));
879            }
880            Some(plan)
881        };
882
883        let mut conn = self.checkout().await?;
884        self.ensure_commit_table(&mut conn).await?;
885        control(&mut conn, "BEGIN TRAN").await?;
886
887        // Data write and the commit-token MERGE share ONE transaction so the
888        // page is committed atomically with its watermark. For upsert/delete the
889        // planned upserts/deletes run via the same no-txn MERGE helpers used by
890        // the append path's INSERTs, inside this same BEGIN TRAN.
891        let written = match &plan {
892            Some(plan) => {
893                let mut affected = 0usize;
894                match self.upsert_rows_no_txn(&mut conn, &plan.upserts).await {
895                    Ok(n) => affected += n,
896                    Err((e, timed_out)) => {
897                        if !timed_out {
898                            let _ = control(&mut conn, "ROLLBACK TRAN").await;
899                        }
900                        return Err(e);
901                    }
902                }
903                match self.delete_keys_no_txn(&mut conn, &plan.deletes).await {
904                    Ok(n) => affected += n,
905                    Err((e, timed_out)) => {
906                        if !timed_out {
907                            let _ = control(&mut conn, "ROLLBACK TRAN").await;
908                        }
909                        return Err(e);
910                    }
911                }
912                affected
913            }
914            None => match self.prepare_chunk(records).await {
915                Ok(Some((cols, rows))) => {
916                    match self.insert_rows_no_txn(&mut conn, &cols, &rows).await {
917                        Ok(n) => n,
918                        Err((e, timed_out)) => {
919                            // Desynced connection on timeout — ROLLBACK would run on a
920                            // corrupt stream (mirrors insert_chunk). Drop the conn instead.
921                            if !timed_out {
922                                let _ = control(&mut conn, "ROLLBACK TRAN").await;
923                            }
924                            return Err(e);
925                        }
926                    }
927                }
928                Ok(None) => 0,
929                Err(e) => {
930                    let _ = control(&mut conn, "ROLLBACK TRAN").await;
931                    return Err(e);
932                }
933            },
934        };
935
936        // UPSERT the commit token atomically with the data rows.
937        let merge = format!(
938            "MERGE [{tbl}] AS t \
939             USING (SELECT @P1 AS [scope], @P2 AS [token]) AS s \
940             ON t.[scope] = s.[scope] \
941             WHEN MATCHED THEN UPDATE SET t.[token] = s.[token], t.[updated_at] = SYSUTCDATETIME() \
942             WHEN NOT MATCHED THEN INSERT ([scope], [token]) VALUES (s.[scope], s.[token]);",
943            tbl = faucet_core::idempotency::COMMIT_TOKEN_TABLE,
944        );
945        let (scope_owned, token_owned) = (scope.to_string(), token.to_string());
946        let refs: Vec<&dyn ToSql> = vec![&scope_owned, &token_owned];
947        if let Err(e) = conn.execute(merge.as_str(), &refs).await {
948            let _ = control(&mut conn, "ROLLBACK TRAN").await;
949            return Err(FaucetError::Sink(format!("MSSQL token merge failed: {e}")));
950        }
951
952        control(&mut conn, "COMMIT TRAN").await?;
953        Ok(written)
954    }
955
956    fn config_schema(&self) -> Value {
957        serde_json::to_value(faucet_core::schema_for!(MssqlSinkConfig))
958            .expect("schema serialization")
959    }
960
961    fn connector_name(&self) -> &'static str {
962        "mssql"
963    }
964
965    fn dataset_uri(&self) -> String {
966        let conn = self
967            .config
968            .connection
969            .connection_url
970            .as_deref()
971            .or(self.config.connection.connection_string.as_deref())
972            .unwrap_or("");
973        format!(
974            "{}?table={}",
975            faucet_core::redact_uri_credentials(conn),
976            self.config.table
977        )
978    }
979
980    async fn check(&self, ctx: &CheckContext) -> Result<CheckReport, FaucetError> {
981        let started = std::time::Instant::now();
982        let probe = match tokio::time::timeout(ctx.timeout, self.pool.get()).await {
983            Ok(Ok(_conn)) => Probe::pass("connect", started.elapsed()),
984            Ok(Err(e)) => Probe::fail_hint(
985                "connect",
986                started.elapsed(),
987                e.to_string(),
988                "check connection_url / credentials / TLS / that the server is reachable",
989            ),
990            Err(_) => Probe::fail_hint(
991                "connect",
992                started.elapsed(),
993                "timed out",
994                "check connection_url / credentials / TLS / that the server is reachable",
995            ),
996        };
997        Ok(CheckReport::single(probe))
998    }
999}
1000
1001#[cfg(test)]
1002mod tests {
1003    use super::*;
1004
1005    // dataset_uri test is skipped: MssqlSink::new() requires a live pool
1006    // (connects to SQL Server in new()), and no offline constructor exists.
1007
1008    #[test]
1009    fn quote_table_handles_schema_qualified() {
1010        assert_eq!(quote_table("dbo.events").unwrap(), "[dbo].[events]");
1011        assert_eq!(quote_table("events").unwrap(), "[events]");
1012        assert_eq!(
1013            quote_table("my.sales.events").unwrap(),
1014            "[my].[sales].[events]"
1015        );
1016    }
1017
1018    #[test]
1019    fn idempotency_constant_names() {
1020        // The commit table and column constants used in ensure_commit_table,
1021        // last_committed_token, and write_batch_idempotent must match the
1022        // canonical values from faucet_core::idempotency.
1023        assert_eq!(
1024            faucet_core::idempotency::COMMIT_TOKEN_TABLE,
1025            "_faucet_commit_token",
1026            "COMMIT_TOKEN_TABLE name changed — update DDL and queries"
1027        );
1028        assert_eq!(
1029            faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL,
1030            "scope",
1031            "COMMIT_TOKEN_SCOPE_COL name changed — update DDL and queries"
1032        );
1033        assert_eq!(
1034            faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL,
1035            "token",
1036            "COMMIT_TOKEN_TOKEN_COL name changed — update DDL and queries"
1037        );
1038    }
1039
1040    #[test]
1041    fn mssql_add_column_ddl() {
1042        let sql = build_add_column_sql(
1043            "[dbo].[events]",
1044            "dbo.events",
1045            "email",
1046            faucet_core::SqlBaseType::Text,
1047        )
1048        .unwrap();
1049        assert!(sql.starts_with("IF NOT EXISTS"), "{sql}");
1050        assert!(
1051            sql.contains("ALTER TABLE [dbo].[events] ADD [email] NVARCHAR(MAX)"),
1052            "{sql}"
1053        );
1054        // The OBJECT_ID guard targets the bare table literal.
1055        assert!(sql.contains("OBJECT_ID(N'dbo.events')"), "{sql}");
1056        assert!(sql.contains("name = N'email'"), "{sql}");
1057    }
1058
1059    #[test]
1060    fn mssql_add_column_keyword_per_base_type() {
1061        use faucet_core::SqlBaseType::*;
1062        for (t, kw) in [
1063            (Integer, "BIGINT"),
1064            (Double, "FLOAT"),
1065            (Boolean, "BIT"),
1066            (Text, "NVARCHAR(MAX)"),
1067            (Json, "NVARCHAR(MAX)"),
1068        ] {
1069            let sql = build_add_column_sql("[t]", "t", "c", t).unwrap();
1070            assert!(sql.ends_with(&format!("ADD [c] {kw}")), "{t:?}: {sql}");
1071        }
1072    }
1073
1074    #[test]
1075    fn mssql_add_column_escapes_literals() {
1076        // Single quotes in the table/column literal are doubled for N'…';
1077        // brackets in the identifier are doubled by quote_ident_mssql.
1078        let sql = build_add_column_sql("[d].[t]", "d.o'x", "c'l", faucet_core::SqlBaseType::Text)
1079            .unwrap();
1080        assert!(sql.contains("OBJECT_ID(N'd.o''x')"), "{sql}");
1081        assert!(sql.contains("name = N'c''l'"), "{sql}");
1082        assert!(sql.contains("ADD [c'l] NVARCHAR(MAX)"), "{sql}");
1083    }
1084
1085    #[test]
1086    fn mssql_widen_column_ddl() {
1087        let sql =
1088            build_alter_type_sql("[dbo].[t]", "score", faucet_core::SqlBaseType::Double).unwrap();
1089        assert_eq!(sql, "ALTER TABLE [dbo].[t] ALTER COLUMN [score] FLOAT");
1090    }
1091
1092    #[test]
1093    fn mssql_relax_null_ddl_re_emits_current_type() {
1094        let sql = build_alter_null_sql("[t]", "created_at", "DATETIME2").unwrap();
1095        assert_eq!(
1096            sql,
1097            "ALTER TABLE [t] ALTER COLUMN [created_at] DATETIME2 NULL"
1098        );
1099    }
1100
1101    #[test]
1102    fn mssql_type_round_trips_to_json_schema() {
1103        use serde_json::json;
1104        assert_eq!(
1105            mssql_type_to_json_schema("bigint", false),
1106            json!({"type":"integer"})
1107        );
1108        assert_eq!(
1109            mssql_type_to_json_schema("int", false),
1110            json!({"type":"integer"})
1111        );
1112        assert_eq!(
1113            mssql_type_to_json_schema("float", false),
1114            json!({"type":"number"})
1115        );
1116        assert_eq!(
1117            mssql_type_to_json_schema("decimal", false),
1118            json!({"type":"number"})
1119        );
1120        assert_eq!(
1121            mssql_type_to_json_schema("bit", false),
1122            json!({"type":"boolean"})
1123        );
1124        assert_eq!(
1125            mssql_type_to_json_schema("nvarchar", false),
1126            json!({"type":"string"})
1127        );
1128        // Case-insensitive; nullable widens to a type array.
1129        assert_eq!(
1130            mssql_type_to_json_schema("NVARCHAR", true),
1131            json!({"type":["string","null"]})
1132        );
1133    }
1134
1135    #[test]
1136    fn transient_classifier() {
1137        assert!(is_transient_error(
1138            "Transaction (Process ID 55) was deadlocked"
1139        ));
1140        assert!(is_transient_error(
1141            "Lock request time out period exceeded (1205)"
1142        ));
1143        assert!(is_transient_error("connection reset by peer"));
1144        assert!(!is_transient_error("Violation of PRIMARY KEY constraint"));
1145        assert!(!is_transient_error(
1146            "Conversion failed when converting date"
1147        ));
1148    }
1149}