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