Skip to content

feat(connectors): add source batch acknowledgments - #3855

Open
rohankumardubey wants to merge 4 commits into
apache:masterfrom
rohankumardubey:fix/connectors-source-ack
Open

feat(connectors): add source batch acknowledgments#3855
rohankumardubey wants to merge 4 commits into
apache:masterfrom
rohankumardubey:fix/connectors-source-ack

Conversation

@rohankumardubey

Copy link
Copy Markdown
Contributor

Which issue does this PR address?

Relates to #3635

Rationale

Establishes the generic source acknowledgment contract before migrating PostgreSQL source behavior and adding the kill-server regression test in a follow-up PR.

What changed?

Source plugins could poll another batch without knowing whether the previous batch was delivered, allowing cursors and persisted state to advance after failed sends.

The source FFI now carries batch IDs and supports ACK/NACK results. The SDK permits one in-flight batch, while the runtime ACKs only after both the Iggy send and state persistence succeed. Send failures, timeouts, and state-save failures produce a NACK.

Local Execution

  • Passed cargo fmt --all
  • Passed clippy with warnings denied for the connector SDK, runtime, and all source plugins
  • Passed all connector SDK, runtime, and source-plugin tests
  • Built the runtime and all source dynamic libraries successfully
  • Confirmed the new iggy_source_batch_result symbol is exported
  • cargo-sort, markdownlint, and taplo were not available locally
  • Pre-commit hooks did not run because no hook was installed

@github-actions

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 10, 2026
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.90909% with 35 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.07%. Comparing base (f4a9aac) to head (f5d5303).

Files with missing lines Patch % Lines
core/connectors/sdk/src/source.rs 88.88% 27 Missing and 4 partials ⚠️
core/connectors/runtime/src/source.rs 95.83% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             master    #3855       +/-   ##
=============================================
- Coverage     82.72%   67.07%   -15.66%     
  Complexity     1296     1296               
=============================================
  Files          1199     1199               
  Lines        160319   136350    -23969     
  Branches     129924   106059    -23865     
=============================================
- Hits         132625    91451    -41174     
- Misses        24188    41303    +17115     
- Partials       3506     3596       +90     
Components Coverage Δ
Rust Core 64.36% <90.90%> (-18.87%) ⬇️
Java SDK 66.15% <ø> (ø)
C# SDK 55.89% <ø> (-19.78%) ⬇️
Python SDK 89.98% <ø> (ø)
PHP SDK 84.26% <ø> (ø)
Node SDK 96.25% <ø> (ø)
Go SDK 68.60% <ø> (ø)
Files with missing lines Coverage Δ
core/connectors/runtime/src/main.rs 85.81% <100.00%> (+0.10%) ⬆️
core/connectors/runtime/src/manager/source.rs 93.50% <100.00%> (+0.03%) ⬆️
core/connectors/sdk/src/lib.rs 57.14% <100.00%> (+0.96%) ⬆️
core/connectors/runtime/src/source.rs 77.67% <95.83%> (+8.34%) ⬆️
core/connectors/sdk/src/source.rs 86.39% <88.88%> (+4.08%) ⬆️

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

@rohankumardubey

Copy link
Copy Markdown
Contributor Author

/ready

@rohankumardubey

Copy link
Copy Markdown
Contributor Author

@hubcio This PR is ready for review.

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

i actually had comments ready yesterday, just as pending, forgot to post them. here a few things that don't sit on changed lines:

  • no test exercises the failure path - nothing forces a send or state-save failure and checks that the nack arrives and the state file stays put. and no integration test ever waits for a second batch, so a handshake that wedges after batch 1 still passes the suite.
  • manager/source.rs:150 comment "close FFI (stops callbacks)" is stale now - close doesn't stop the runtime->plugin result callback, it guarantees it fails.
  • the connector authoring docs and templates don't mention on_batch_result, so the next source written from them lands with the no-op default too.

}

if state_saved {
batch_result = SourceBatchResult::Ack;

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.

ack fires even when messages were dropped before the send - decode failures above and transform/encode/build failures inside process_messages shrink the batch, but nothing compares sent_count to count. extreme case: every message drops, send(vec![]) returns ok without touching the server, still ack. under this contract ack tells the plugin to commit destructive work, so dropped messages are gone at the source.

process_messages already counts its errors - return that and nack when decode_errors + error_count > 0 (keep filtered_count out, those drops are intentional). one catch: a plain nack on a message that can never decode means infinite redelivery, so this also needs a drop-after-n-attempts or dlq policy.

// Total histogram + emit (below) run regardless of send outcome.
let mut state_save_us: Option<u64> = None;
let mut batch_result = SourceBatchResult::Nack;
if let Err(error) = send_result {

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.

the error here already says which chunks committed (ProducerSendFailed { committed, failed, .. }) but it gets formatted into a string and the whole batch is nacked - redelivery then duplicates the committed prefix. resending just failed before deciding ack/nack would avoid that; at minimum log committed.len().

}

let result_code = batch_result_callback(plugin_id, batch_id, batch_result as u8);
if result_code != 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.

every stop with a batch in flight ends up here: iggy_source_close removes the INSTANCES entry before cleanup_sender runs, so the callback returns -1 and this logs an error, bumps the counter and sets connector error for a benign shutdown race. handle_produced_messages below treats the mirror case as trace-only for exactly this reason. side effect: the loop exit then sees status Error, so update_status(Stopped) skips the sources_running decrement and the gauge leaks on every restart.

);
}

let mut state_saved = 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.

state is saved (two fsyncs) every batch even when nothing moved. letting a source return state: None when nothing changed would skip it - the runtime already acks on None - though postgres would first have to stop re-stamping last_poll_time on every poll.


#[doc(hidden)]
pub fn complete_batch(&self, batch_id: u64, result: u8) -> i32 {
let Ok(result) = SourceBatchResult::try_from(result) 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.

an unrecognized code returns -1 without completing the pending batch - the plugin then waits on result_receiver forever and the runtime waits on recv_async, a permanent stall on both sides. safer to treat unknown as nack and complete the batch. unreachable today, but it arms the moment SourceBatchResult grows a third variant, so worth fixing before that ever happens.

return -1;
}

let Some(current) = pending.take() 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.

this else-arm is unreachable (the guard above already proved Some) and returns -1 with no log. Option::take_if collapses the check and the take - just keep the expected id in the error message, a mismatch is the only visible sign of counter desync.

});
}

if callback(plugin_id, batch_id, messages.as_ptr(), messages.len()) != 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.

messages stays alive across the whole result wait, but the runtime copies the bytes out before the callback returns - dropping it right after the callback frees the duplicate buffer for the whole send window.

/// A [`source::SourceBatchResult::Nack`] means the staged changes must be discarded so the
/// batch can be polled again. The SDK allows only one batch to be in flight at a time and
/// stops polling if this method returns an error.
async fn on_batch_result(&self, _result: source::SourceBatchResult) -> 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.

nothing in-tree implements this - every shipped source keeps the no-op default, and postgres still deletes/marks rows and advances its cursor inside poll(), so a nack rolls back nothing. worse, the loss becomes durable: after a failed send the next poll is empty (rows already consumed) but still carries the advanced cursor, send(vec![]) short-circuits ok, and the state file gets saved. the postgres migration needs to land with this, or the readme claims scoped down until an adopter exists.

4. The runtime reports `SourceBatchResult::Ack` to the plugin. A send or state-save failure reports `SourceBatchResult::Nack` instead.
5. `Source::on_batch_result()` commits or discards the plugin's staged work before the next poll starts.

An empty batch follows the same handshake. This prevents a successful no-op send from persisting state left over from an earlier failed delivery. Producer errors, including request timeouts, report a NACK. A successful send from the legacy Iggy server is still an ACK even though that server returns an empty confirmation list.

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 sentence only holds if the plugin rolled back on the earlier nack - no shipped plugin does, so today the empty poll is exactly what persists the stale cursor (see postgres). worth rewording until an adopter exists.


The crash behavior is intentionally at-least-once:

| Crash point | Recovery behavior |

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.

the table misses a server-side row: a batch is confirmed once committed in server memory, not fsynced, so ack -> destructive cleanup -> server crash before fsync loses the data on both ends. worth a row here plus a note on Ack that it means committed-in-memory (durability rides on the server's enforce_fsync, which ships off).

@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 Aug 12, 2026
@hubcio

hubcio commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

ignore failing java examples, we'll check it

@rohankumardubey

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review. I agree with the issues you identified and will address the ABI mismatch, shutdown and timeout handling, callback error propagation, NACK backoff, docs/templates, and missing regression tests.

For processing failures, I propose NACK with capped backoff, eventually stopping the connector without ACKing instead of losing data. For partial producer failures, I’ll report committed/failed counts and retry only the failed tail before NACKing.

As agreed in #3635, I’ll keep PostgreSQL migration in the follow-up PR, use a small generic source to test this contract, and narrow the README claims until PostgreSQL is migrated. Does this look good?

@hubcio

hubcio commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

@rohankumardubey looks good.

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

Labels

S-waiting-on-author PR is waiting on author response

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants