Skip to content

MSSQL Source Connector - #3851

Draft
lsabi wants to merge 30 commits into
apache:masterfrom
lsabi:lsabi/mssql-source
Draft

MSSQL Source Connector#3851
lsabi wants to merge 30 commits into
apache:masterfrom
lsabi:lsabi/mssql-source

Conversation

@lsabi

@lsabi lsabi commented Aug 9, 2026

Copy link
Copy Markdown

Which issue does this PR address?

Partially Closes # #3573
Relates to #

Rationale

This is just the implementation of the source connector for the cdc. I'm also working on the table polling, but it'll require more time.

I don't have a database instance to test it, but tests are passing. If someone, who has a test MSSQL instance can try it out, it would be great.

What changed?

Added the

Local Execution

  • Unit Tests pass. Not tested with a real database instance, as I don't have an instance with dummy data to ruin (only production)
  • Pre-commit hooks not ran

AI Usage

None

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

Thanks for the PR. It is labeled S-waiting-on-review and queued for review.

Slash commands (own line, regular comment) move it around the queue:

  • /ready - back to S-waiting-on-review after addressing feedback
  • /author - flip to S-waiting-on-author while you finish changes
  • /request-review @user-or-team - request a reviewer

See CONTRIBUTING.md for details.

@github-actions github-actions Bot added the S-waiting-on-review PR is waiting on a reviewer label Aug 9, 2026
@lukaszzborek

lukaszzborek commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

@lsabi please make this pr as draft or close it and add test, then reopen
there is docker image microsoft/mssql-server which you can use

@lukaszzborek lukaszzborek added S-waiting-on-author PR is waiting on author response and removed S-waiting-on-review PR is waiting on a reviewer labels Aug 9, 2026
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.36%. Comparing base (e71e3c6) to head (a33163b).
⚠️ Report is 3 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff              @@
##             master    #3851      +/-   ##
============================================
- Coverage     83.67%   80.36%   -3.31%     
+ Complexity     1358     1340      -18     
============================================
  Files          1212     1218       +6     
  Lines        165138   164790     -348     
  Branches     132612   132723     +111     
============================================
- Hits         138181   132439    -5742     
- Misses        23308    28803    +5495     
+ Partials       3649     3548     -101     
Components Coverage Δ
Rust Core 83.81% <ø> (-0.63%) ⬇️
Java SDK 66.54% <ø> (-0.13%) ⬇️
C# SDK 40.03% <ø> (-36.06%) ⬇️
Python SDK 90.00% <ø> (ø)
PHP SDK 84.48% <ø> (ø)
Node SDK 89.09% <ø> (-6.76%) ⬇️
Go SDK 54.54% <ø> (-13.79%) ⬇️
see 284 files with indirect coverage changes
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@lsabi
lsabi marked this pull request as draft August 10, 2026 12:06
@github-actions github-actions Bot removed the S-waiting-on-author PR is waiting on author response label Aug 10, 2026
@lsabi

lsabi commented Aug 12, 2026

Copy link
Copy Markdown
Author

@lukaszzborek I'm afraid but I can't test it on my locale. I've spent a day understanding the docs, as it's not super clear how one should do in order to build their own connector.

I've then let my laptop run for almost a day and it did not finish compiling, just excessive heating.

I managed to compile mssql_source in a couple of hours, but not the runtime nor even the whole project. If you want, you can test it, but I've discovered that iggy and runtime are separate and require two different processes in order to run, which is what I did not want (I though the dynamic runtime could start up libraries on the fly and was part of the "core").

@lukaszzborek

Copy link
Copy Markdown
Contributor

@lsabi, quite intresting. Did you join to our discord? there are many people which can help. You can find discord invite in readme. Also you can merge current master to yours, because you have old version of server. And please write on discord what machine do you have (where you compile code)

@hubcio hubcio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

took this for a spin against a real sql server 2022 container with cdc enabled. open() connects fine, but as shipped poll() never returns a single message - it took five local fixes to get the first record through, and the payloads were still mangled. detailed comments inline.

FROM [{db}].cdc.[{table}] t
WHERE {query_filter}
GROUP BY [__$start_lsn], [__$command_id], [__$seqval], [__$end_lsn], [__$update_mask]
ORDER BY [__$start_lsn] ASC, [__$command_id] ASC;";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

leftover ;"; inside the sql string - every poll fails with error 105 "unclosed quotation mark after the character string". ran the connector against sql server 2022 with cdc enabled: as shipped it never produces a single message.

.flatten()
.collect::<Vec<_>>();

let tables: Vec<String> = self.config.capture_table_columns.values().cloned().collect();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this iterates the map's values (the column lists) as table names, so the query becomes FROM [testdb].cdc.[id,name,active] and fails with error 208 "invalid object name".

// For each monitored table, get the capture columns
let table_columns = self.query(Query::new(
format!(r#"
SELECT OBJECT_NAME(ct.object_id, DB_ID('{database}')) as table_name, c.object_id, ct.capture_instance, ct.start_lsn, c.columns FROM [{database}].[cdc].[change_tables] as ct JOIN (SELECT object_id, STRING_AGG(column_name, ',') as columns FROM [{database}].[cdc].[captured_columns] GROUP BY object_id) as c ON c.object_id = ct.object_id;"#

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OBJECT_NAME(ct.object_id, DB_ID(...)) names the change table (dbo_users_CT), not the source table - in cdc.change_tables, object_id is the change table's id and source_object_id is the source table's. so capture_table_columns ends up keyed by change-table names and a user-provided entry never matches the lookup on line 411. use ct.source_object_id.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since MSSQL has the source table and the change table with different names (by default _ct get's appended), either the user inserts the change table name or, there's need to make an internal conversion (while keeping a mapping of the names). Which one is the preferred one?

let changes_query = format!(
r#"SELECT
TODATETIMEOFFSET(
[#db].sys.fn_cdc_map_lsn_to_time([__$start_lsn]),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#db is only substituted in the max-lsn query; here it reaches the server literally and fails with error 4121. the surrounding query already interpolates {db} via format!.

None => return Err(Error::InvalidRecord)
};

let operation_type = match r.get::<i64, _>("[__$operation]").unwrap_or(0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this panics instead of erroring: the result column is __$operation (no brackets) and it's an int, so both the name and the i64 type are wrong. tiberius get is try_get(..).unwrap(), and try_get errors on an unknown column name as well as on a failed conversion - so unwrap_or(0) is unreachable. the panic lands inside the task the sdk spawns for poll(), killing the polling task silently: the connector still reports as running and never produces again. use try_get with i32 and return an error.

self.id
);
}
"polling" => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"polling" is accepted here but poll() returns Err(InvalidConfig) forever since poll_tables is commented out - reject the mode in open() until it exists.

format!("[__$start_lsn] <= {n_lsn}")
};

let capture_ops = self

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

capture_ops is built but never used - the capture_operations filter is silently ignored and every operation type gets streamed.

}


/*

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

over half the file is commented-out postgres source code (poll_tables, sqlx type mapping, nearly all tests) - please delete it, git keeps history. the crate also fails cargo fmt --check (tabs) and cargo clippy -- -D warnings (21 errors), both are ci gates.


[dependencies]
async-trait = { workspace = true }
base64 = { workspace = true }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

base64 is only referenced inside commented-out code - dead dependency, drop it. machete won't catch this one: its heuristic matches the crate name inside comments, so the dead code keeps it looking used.

Comment thread Cargo.toml
"core/connectors/sources/elasticsearch_source",
"core/connectors/sources/influxdb_source",
"core/connectors/sources/postgres_source",
"core/connectors/sources/mssql_source",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cargo.lock isn't part of the PR - building adds ~238 lines (tiberius, async-native-tls, connection-string, ...). commit the lock update.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants