1use 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
22pub struct MssqlSink {
24 config: MssqlSinkConfig,
25 pool: MssqlPool,
26 table_quoted: String,
27 columns_cache: Mutex<Option<Vec<String>>>,
29}
30
31impl MssqlSink {
32 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(()); };
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 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 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 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 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 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 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 async fn ensure_commit_table(
271 &self,
272 conn: &mut MssqlPooledConnection<'_>,
273 ) -> Result<(), FaucetError> {
274 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 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 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 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 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 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 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 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 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 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 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
482async 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
493fn 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
508fn 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
534fn 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
548fn 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
559fn 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
577fn 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
586fn 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 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 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 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 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 return Err(e);
707 }
708 Err(_) if !self.config.isolate_row_failures => {
709 return Err(FaucetError::Sink(
711 "MSSQL batch insert failed and isolate_row_failures is disabled".into(),
712 ));
713 }
714 Err(_) => {
715 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 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); }
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 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 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 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 *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 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 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 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 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 #[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 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 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 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 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}