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!(
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 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 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 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 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 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 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 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 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 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 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
487async 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
498fn 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
513fn 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
539fn 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
553fn 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
564fn 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
582fn 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
591fn 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 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 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 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 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 return Err(e);
712 }
713 Err(_) if !self.config.isolate_row_failures => {
714 return Err(FaucetError::Sink(
716 "MSSQL batch insert failed and isolate_row_failures is disabled".into(),
717 ));
718 }
719 Err(_) => {
720 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 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); }
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 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 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 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 *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 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 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 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 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 #[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 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 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 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 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}