Skip to content

feat(connectors): add generic JDBC source connector#3588

Open
shbhmrzd wants to merge 1 commit into
apache:masterfrom
shbhmrzd:jdbc_source
Open

feat(connectors): add generic JDBC source connector#3588
shbhmrzd wants to merge 1 commit into
apache:masterfrom
shbhmrzd:jdbc_source

Conversation

@shbhmrzd

@shbhmrzd shbhmrzd commented Jul 1, 2026

Copy link
Copy Markdown

Which issue does this PR address?

Relates to #2500

(This is the first of two PRs splitting #2500 for easier review: the JDBC source connector here, and the JDBC sink connector as a follow-up PR once this lands. This PR also carries the shared connector test harness and a small server-side fix that both connectors' integration tests rely on.)

Rationale

Iggy only shipped PostgreSQL-specific connectors, so every other database needed a purpose-built connector. A generic JDBC source lets Iggy read from any JDBC-compliant database (PostgreSQL, MySQL, Oracle, SQL Server, H2, and others) through its existing JDBC driver, instead of writing and maintaining a new connector per database.

What changed?

Adds a JDBC source connector that talks to any JDBC database through an embedded JVM over JNI, using the standard java.sql API and the database's own driver JAR. It polls a configured SQL query and produces each row as a JSON message, in bulk mode (re-reads the query result each poll, capped at batch_size) or incremental mode (tracks a {last_offset} placeholder and persists the offset across restarts). Column values are mapped to JSON with type fidelity in mind (decimals as strings to avoid f64 precision loss, binary as base64), and query failures are classified into transient vs permanent error variants by SQLState, which sets the returned error variant and the log message. It does not currently change the connector's retry or backoff behavior.

How to review

Area Where to look What to check
Source logic core/connectors/sources/jdbc_source/src/lib.rs open() (JVM init, driver load, connection) then poll() then build_query (offset substitution via quote_sql_literal) then read_rows /
extract_column_value (JDBC to JSON) then build_message (offset state persistence)
JVM and JNI lifetime get_or_create_jvm, get_connection, read_rows one JVM per process (shared static), per-row push_local_frame / pop_local_frame so large result sets do not overflow the JNI local
reference table, isValid reconnect, pooled-connection return
Type fidelity extract_column_value NUMERIC / DECIMAL as string, binary as base64, BIGINT caveat documented in the README
Error handling is_transient_sql_state, classify_query_failure, take_pending_sql_exception SQLState classes 08 / 40 / 53 / 57 / 58 are transient; pending Java exceptions are cleared
Secrets config Debug impl, the secret serde modules, sanitize_jdbc_url jdbc_url and password are SecretString, never logged or serialized
Shared test harness core/integration/tests/connectors/mod.rs setup_runtime and the ConnectorsRuntime / ConnectorsIggyClient helpers used by the JDBC (and future connector) tests
Server change core/server/src/tcp/tcp_listener.rs (3 lines) The connectors integration harness reserves a fixed TCP port (PortReserver) and waits for the server to write current_config.toml to learn
the bound address. The server previously wrote that file only when the port was dynamic (port == 0), so a fixed-port server hung. The fix always notifies the config writer once the listener binds. The
SO_REUSEPORT cross-shard broadcast stays gated on port == 0, so multi-shard behaviour is unchanged. Worth a check for cluster mode.

Config reference and per-database examples: core/connectors/sources/jdbc_source/README.md, its config.toml, and core/connectors/runtime/example_config/connectors/jdbc_*.toml.

Local Execution

  • Passed. Ran the CI-equivalent gates locally: cargo fmt --all -- --check, cargo clippy --all-targets --all-features -- -D warnings, cargo check --all --all-features, cargo sort --check, cargo machete,
    license headers (hawkeye check), and markdownlint.
  • Tests: 33 unit tests, plus 5 Dockerized Postgres integration tests (basic, multi-row, metadata, incremental offset advance, and a large-result-set test that exercises the JNI local-frame handling). Integration
    test time is about 20 to 25 seconds; unit tests are effectively instant.
  • Validated live end to end: started iggy-server, created a stream and topic, and ran the JDBC source runtime against a Postgres container seeded with 150 rows. The source read all 150 rows in a single poll,
    advanced its incremental offset, and produced exactly 150 messages to the topic with no errors.
  • Pre-commit hooks: prek was not run (not installed locally); the equivalent checks above were run manually.

Limitations

  • JNI permits one JVM per process, so a JDBC source and a JDBC sink cannot run in the same connectors-runtime process. Run them in separate runtime processes. Documented in the README.
  • JDBC calls are synchronous (blocking) on the runtime worker thread.
  • The connector requires a JVM and a JDBC driver JAR at runtime (unlike the pure-Rust connectors); it is added to the release plugin list with that caveat.

AI Usage

  1. Tools: Claude Code (Claude).
  2. Scope: used substantially for drafting the connector implementation, tests, docs, and example configs, and for an iterative review and hardening pass.
  3. Verification: unit tests, Dockerized Postgres integration tests (including incremental-offset and large-result-set cases), and a manual end-to-end run producing 150 messages from Postgres into iggy.
  4. Can explain every line: yes.

@github-actions

github-actions Bot commented Jul 1, 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.

@codecov

codecov Bot commented Jul 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 74.11%. Comparing base (d6059a1) to head (9e0c750).
⚠️ Report is 49 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff              @@
##             master    #3588      +/-   ##
============================================
- Coverage     74.13%   74.11%   -0.02%     
  Complexity      937      937              
============================================
  Files          1258     1258              
  Lines        131452   131450       -2     
  Branches     107320   107363      +43     
============================================
- Hits          97455    97429      -26     
+ Misses        30909    30892      -17     
- Partials       3088     3129      +41     
Components Coverage Δ
Rust Core 74.79% <ø> (+<0.01%) ⬆️
Java SDK 62.44% <ø> (ø)
C# SDK 71.40% <ø> (-0.71%) ⬇️
Python SDK 88.88% <ø> (ø)
PHP SDK 84.29% <ø> (ø)
Node SDK 91.35% <ø> (+0.22%) ⬆️
Go SDK 40.14% <ø> (ø)
Files with missing lines Coverage Δ
core/server/src/tcp/tcp_listener.rs 75.00% <ø> (+0.49%) ⬆️

... and 19 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.

@shbhmrzd
shbhmrzd force-pushed the jdbc_source branch 2 times, most recently from 80435e8 to 9e0c750 Compare July 1, 2026 10:22
query = query.replace("{last_offset}", &quote_sql_literal(offset));
} else if let Some(ref initial) = self.config.initial_offset {
query = query.replace("{last_offset}", &quote_sql_literal(initial));
} else {

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.

{tracking_column} never substituted in build_query; only {last_offset} replaced. README/examples use WHERE {tracking_column} > {last_offset} → invalid SQL at runtime. Fix: substitute from config.tracking_column or fix docs.

Comment thread core/connectors/sources/jdbc_source/src/lib.rs
Comment thread core/connectors/sources/jdbc_source/src/lib.rs
}

/// Create HikariCP connection pool
fn create_connection_pool_internal(&self, env: &mut JNIEnv) -> Result<GlobalRef, Error> {

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.

Two places - 472-485,1255 enable_connection_pool loads HikariCP via JNI but JVM classpath = single driver_jar_path only → pool mode fails unless HikariCP bundled. Fix: add HAR to classpath, or disable pool flag until wired.

let state = self.state.lock().expect("state mutex poisoned");
self.build_query(&state)
};
info!("Executing query: {}", query);

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.

info!("Executing query: {}", query) logs substituted offset/filters. Fix: debug! or redact.

@ryerraguntla

Copy link
Copy Markdown
Contributor

/author

@github-actions github-actions Bot added S-waiting-on-author PR is waiting on author response and removed S-waiting-on-review PR is waiting on a reviewer labels Jul 1, 2026

@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.

this is described as the first of two PRs splitting #2500, but #3576 - the combined source + sink version - is still open and also closes #2500. is #3576 superseded by this split? if so, closing it would avoid review effort landing on the wrong PR.

@shbhmrzd

shbhmrzd commented Jul 5, 2026

Copy link
Copy Markdown
Author

this is described as the first of two PRs splitting #2500, but #3576 - the combined source + sink version - is still open and also closes #2500. is #3576 superseded by this split? if so, closing it would avoid review effort landing on the wrong PR.

Sure, closed the parent PR with a note mentioning two different PR approach for source and sink.

@shbhmrzd
shbhmrzd force-pushed the jdbc_source branch 3 times, most recently from 9966922 to 6d7c88b Compare July 18, 2026 20:16
@shbhmrzd

Copy link
Copy Markdown
Author

/ready

@github-actions github-actions Bot added S-waiting-on-review PR is waiting on a reviewer and removed S-waiting-on-author PR is waiting on author response labels Jul 18, 2026
}

/// Best-effort `Connection.isValid(timeout)` check. Returns false on any
/// JNI error so the caller re-establishes the connection.

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.

lib.rs:473-478, 551-560,634-988 — JNI exceptions left uncleared on ~45 of 46 fallible call_method/get_string sites (only classify_query_failure clears, 2 sites). Sharpest instance: statement.close() at 557 fires while executeQuery's SQLException is still pending, cleared one line too late. JNI spec forbids further JNI calls while an exception is pending — can abort the whole embedded JVM, i.e. the entire connectors-runtime process, not just this connector. Fix: route every fallible JNI call through an exception-clearing helper.

///
/// The mutex is held only briefly: once to read the current offset for
/// query building, and once after the JNI work to write the updated state.
fn execute_query(&self, env: &mut JNIEnv) -> Result<Vec<ProducedMessage>, Error> {

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.

lib.rs:484-513 vs runtime/src/source.rs:456-517 — state.last_offset advances in-memory as soon as rows are fetched, before delivery is confirmed. Disk-state persist is correctly gated on send
success, but the in-memory offset never rolls back on a transient send failure — that batch is silently and permanently skipped, and the next successful poll's further-advanced offset overwrites disk state,
erasing any replay chance. Breaks the connector's own at-least-once promise on ordinary non-crash failures. Fix: derive next offset from last durably-persisted state, not from a struct field advanced ahead of
confirmed delivery.

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.

This is the same model the existing offset-tracking sources use (postgres_source advances state.tracking_offsets in poll() before delivery too), so the durable guarantee today is at-least-once across restarts but unable to support transient in-process send failure without a restart.

IMO the clean fix can't be done at the connector layer, because the runtime owns the state file and hands the connector its state only once at open(), with no per-poll delivery ack to roll the in-memory offset back. I documented the exact guarantee in the README and would like to track the stronger guarantee as a runtime-level follow-up (roll back / re-feed the offset on send failure), which benefits every offset-tracking source uniformly.
Happy to open that issue. Please let me know your thoughts.

Ok(s) => s,
Err(_) => return Err(classify_query_failure(env, "prepare statement")),
};

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.

lib.rs:539-549,743-745 — bulk mode has no pagination beyond setMaxRows; tables larger than batch_size never fully sync, silently. Fix: log a loud warning when a poll returns exactly batch_size rows so truncation is diagnosable; full cross-DB OFFSET pagination is a larger follow-up, scope separately. Also fix the Mode::Bulk doc comment and README, both currently claim "full table scan" unconditionally.

info!("JDBC source connector [{}] opened successfully", self.id);
Ok(())
}

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.

lib.rs:1028-1055 — blocking JNI/JDBC I/O runs directly on the shared async runtime; chains with the mutex-poisoning finding since the operator restart path also runs on that same runtime with no timeout of its own. Fix: wrap the fetch body of poll() in tokio::task::block_in_place (works today, no SDK change needed since the runtime is already multi-thread); apply the same wrapping to close().


/// Return the larger of two offset values. Both are compared numerically when
/// they parse as numbers (covers integer and decimal tracking columns), and
/// lexicographically otherwise (covers ISO-8601 timestamps and text keys).

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.

lib.rs:1171-1178 — larger_offset compares via f64, loses precision past 2^53, contradicts the file's own NUMERIC/DECIMAL-as-string rationale. Fix: try i128 parse/compare first (exact for the full BIGINT range), fall back to f64 only for genuinely fractional values, lexical for non-numeric.


/// Quote a value as a SQL string literal, escaping embedded single quotes by
/// doubling them. Used to substitute the incremental `{last_offset}` value,
/// which originates from a (DB-controlled) tracking-column value.

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.

lib.rs:1148-1150 — quote_sql_literal doesn't escape backslash, MySQL default sql_mode breakout possible (downgraded from critical, requires attacker already has DB write access). Fix: escape backslash
before doubling quotes; real long-term fix is binding the offset as a PreparedStatement parameter instead of string substitution — scope that as a follow-up refactor.

}
};

let columns = self.read_column_metadata(env, &result_set)?;

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.

lib.rs:562-569 — statement.close() skipped on read-error path, leaks PreparedStatement/cursor. Fix: split the try-body into its own function and close unconditionally in the caller after it returns, on both Ok and Err.

"Creating direct JDBC connection to: {}",
sanitize_jdbc_url(self.config.jdbc_url.expose_secret())
);
let conn = self.create_direct_connection_internal(&mut env)?;

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.

lib.rs:322,441,452,458,461,489,500,1046,1075,1120 — mutex .expect()-on-poison cluster, no self-heal, contradicts repo's no-unwrap/expect rule. Fix: replace .expect() with a helper that maps PoisonError
to a returned Error::Connection/Error::InitError instead of panicking, so a poisoned lock surfaces as a logged failure rather than a permanent panic loop.


#[async_trait]
impl Source for JdbcSource {
async fn open(&mut self) -> Result<(), Error> {

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.

lib.rs:1009-1026 — open() never dry-runs build_query(), bad config only surfaces after the first poll_interval sleep. Fix: call build_query() once inside open() before returning Ok.


// Without an offset yet, drop the incremental predicate but keep the rest
// of the query (e.g. an ORDER BY) intact.
if offset.is_none() {

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.

lib.rs:755 — WHERE-auto-remove matches only an exact literal substring, brittle to casing/whitespace. Fix: replace the literal match with a case/whitespace-tolerant regex.

.map_err(|e| Error::InitError(format!("Failed to create JDBC URL string: {}", e)))?;

// If username/password are provided separately, use 3-arg getConnection
let connection = if let (Some(username), Some(password)) =

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.

lib.rs:385-386 — half-set username/password silently falls through to embedded-URL auth. Fix: validate in open() that username and password are both set or both unset, error otherwise.

use std::time::Duration;
use tracing::{debug, info};
use uuid::Uuid;

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.

local secret serde modules bypass the repo's shared iggy_common::serde_secret helper, crate doesn't even depend on iggy_common. Fix: add the iggy_common dependency and use
serialize_secret/serialize_optional_secret (verify exact names against core/common/src/utils/serde_secret.rs), drop the local serialize halves.

.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
}

/// Classify a JDBC `SQLState` class (first 2 chars) as transient vs permanent.

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.

1194-1220 vs sdk/src/source.rs:182-198 — SQLState transient/permanent classification has zero observable runtime effect. Fix: needs SDK-side change to branch on Error variant and add backoff in the poll loop; if out of scope for this PR, remove the "runtime backs off appropriately" claim from the PR description/comments until it's true.


/// Acquire the direct connection, transparently re-establishing it if it has
/// dropped since the last poll.
fn get_connection<'local>(&self, env: &mut JNIEnv<'local>) -> Result<JObject<'local>, Error> {

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.

get_connection TOCTOU shape, not reachable today but relies on undocumented single-caller discipline. Fix: collapse the check/close/create/store sequence into one lock scope so
correctness doesn't depend on external call guarantees.

}

/// Create a direct JDBC connection via DriverManager
fn create_direct_connection_internal(&self, env: &mut JNIEnv) -> Result<GlobalRef, Error> {

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.

[nit] lib.rs:328-435 — create_direct_connection_internal missing local-frame management used elsewhere in the file. Fix: wrap in push_local_frame/pop_local_frame like read_column_metadata/read_rows do.

result_set: &JObject,
columns: &[(String, i32)],
) -> Result<(Vec<ProducedMessage>, u64, Option<String>), Error> {
let mut messages = Vec::new();

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.

[nit] lib.rs:630 — Vec::new() despite known upper bound. Fix: Vec::with_capacity(self.config.batch_size as usize).

}

async fn poll(&self) -> Result<ProducedMessages, Error> {
// Sleep for poll interval

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.

[nit] lib.rs:1029-1030 — sleep-before-query causes cadence drift and delays the first poll a full interval. Fix: measure elapsed work time and sleep only the remainder of poll_interval.

.map_err(|e| Error::Serialization(format!("Failed to serialize row data: {}", e)))?
};

Ok(ProducedMessage {

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.

[nit] lib.rs:727-734 — Utc::now() called twice per message. Fix: call once, reuse for both timestamp fields.

let col_idx = (idx + 1) as i32;
let value = self.extract_column_value(env, result_set, col_idx, col_type)?;

let final_col_name = if self.config.snake_case_columns {

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.

[nit] lib.rs:689-703 — snake_case_columns collisions silently overwrite, no detection. Fix: warn when a computed snake_case name collides with an existing key in the row map.

pub driver_class: String,

/// Path to JDBC driver JAR file
pub driver_jar_path: String,

@ryerraguntla ryerraguntla Jul 19, 2026

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.

[nit] lib.rs:91 — driver_jar_path not existence-checked before JVM start, failure surfaces as an opaque wrapped exception. Fix: check the path exists in initialize_jvm/open() and return a direct, actionable error first. then
add -Djava.class.path= — in the README.

let username_jstring = env.new_string(username).map_err(|e| {
Error::InitError(format!("Failed to create username string: {}", e))
})?;
let password_jstring = env.new_string(password.expose_secret()).map_err(|e| {

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.

[nit] lib.rs:392 — password reaches the JVM heap as an unzeroed java.lang.String, defeats SecretString zeroing. Fix: none available (inherent to the JDBC API surface) — document as an accepted risk in the
README.

# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

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.

[nit] README/config.toml — no credential-precedence note for username/password vs URL-embedded credentials. Fix: add a short "Credential precedence" paragraph to the README.

// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

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.

[nit] test_with_postgres.rs naming — diverges from sibling postgres_source.rs convention. Fix: rename tests to match the sibling's shorter predicate-style naming.

@ryerraguntla

Copy link
Copy Markdown
Contributor

Good progress. There are similar pattern of issues are repeated. Think about using common methods where ever possible.
It is normal to have these kind issues in a generic connector . Very good progress. Thank you for taking up this.

@ryerraguntla

Copy link
Copy Markdown
Contributor

/author

@github-actions github-actions Bot added S-waiting-on-author PR is waiting on author response and removed S-waiting-on-review PR is waiting on a reviewer labels Jul 19, 2026
Adds a JDBC source connector that polls a configured SQL query against any
JDBC-compliant database (PostgreSQL, MySQL, Oracle, SQL Server, H2) through an
embedded JVM and produces each row as a JSON message. Supports bulk and
incremental (offset-tracked) modes with persisted offset state.

Also adds the shared connector integration-test harness and a small TCP
listener change so that harness can read the server's written runtime config
when bound to a fixed port. The matching JDBC sink connector follows in a
separate PR.
@shbhmrzd

Copy link
Copy Markdown
Author

Thanks for the thorough review. I have pushed a revision that addresses the feedback.

@shbhmrzd

Copy link
Copy Markdown
Author

/ready

@github-actions github-actions Bot added S-waiting-on-review PR is waiting on a reviewer and removed S-waiting-on-author PR is waiting on author response labels Jul 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

S-waiting-on-review PR is waiting on a reviewer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants