GeezerVisonThe World Wide Web, restored to 800 pixels of glory. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Home|About|Gaming cabinet|Archives|How it works|Starter sites | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
GeezerVison is showing github.com — [connectors] Replay the transaction in the exactly-once Kafka output by bestouff · Pull Request #7682 · feldera/feldera Conversation
|
| Transaction error | Action |
|---|---|
| Retriable, including an API timeout | Retry with backoff |
| Requires abort | Abort, begin a new transaction, replay the records, commit |
| Fatal or unclassified | Report a fatal error and hold output until shutdown |
A transaction cannot simply be retried: librdkafka fails it at the first failed delivery and purges its queue on abort, and the broker aborts transactions older than transaction.max.timeout.ms. So the sink keeps its own copy. The replay spool holds 64 MiB in memory, then spills to a temporary file in the pipeline's storage directory, or the system temporary directory without one. A record that librdkafka refuses for its own sake, such as an oversized message, is reported once and not kept. The broker refuses whole batches, so a broker refusal, a revoked permission, a missing topic or an expired transaction drops nothing: after three aborts in a row the sink reports a connector error that names the cause and keeps retrying. Each transaction call waits at most 10 seconds and cancellation is checked between calls. A fenced producer is never recreated; a restart skips transactions already committed.
Hot path cost of the spool, release build against the mock broker, 50k records of 1 KiB per transaction, three rounds each:
| Variant | push_buffer loop |
Commit done after |
|---|---|---|
| Before this change | 18 to 33 ms | 0.52 to 0.64 s |
| Spool in memory | 14 to 36 ms | 0.52 to 0.54 s |
| Spool spilled to disk (1 MiB limit) | 106 to 145 ms | 0.51 to 0.55 s |
The sink reports unhealthy during recovery and exports the shared queue metrics plus kafka_output_transaction_aborts_total, kafka_output_commit_retries_total and kafka_output_replay_spool_bytes.
PRs #7432 and #7433 touch the same code and need a rebase when this lands.
Twelve mock-cluster tests and five single-broker tests through a test-only TCP proxy: abort and replay from disk with headers, null payloads and several partitions; a commit reply delayed past the API timeout; restart after a lost commit reply; fencing by a replacement producer; an outage longer than the transaction timeout. Each ends with exactly one committed copy.
Mock-cluster cases added for review findings: a record refused on its first send is not reported again by a replay; a record first refused during replay is reported once over two replays; a topic-level produce error is replayed, not dropped; an abortable error at begin leaves a fresh transaction open; a spool write failure holds the sink without a commit; a batch the broker refuses is reported once after repeated aborts and nothing is dropped. Each fails with its fix reverted.
With ghcr.io/feldera/redpanda-ci:v24: all transport::kafka:: tests pass, the fencing test passed 20 parallel runs, clippy -D warnings and cargo fmt --check pass.
The exactly-once sink no longer forces retries=5. Recoverable failures hold output during recovery, fatal ones until restart. The sink keeps up to 64 MiB of replay data in memory and uses temporary disk space above that. Position formats are unchanged.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| .unwrap() | ||
| .expect_err("a fatal error must end the batch with an error"); | ||
| assert!(error.to_string().contains("fenced"), "{error}"); | ||
| let (fatal, tag, text) = &callbacks.errors()[0]; |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
bug (high, flaky test): errors()[0] isn't always the fatal one. The test failed once in about 50 runs under parallel load (assertion failed: *fatal, line 285): a non-fatal error sometimes comes first. Look up the fatal entry instead:
| let (fatal, tag, text) = &callbacks.errors()[0]; | |
| let errors = callbacks.errors(); | |
| let (fatal, tag, text) = errors.iter().find(|(fatal, ..)| *fatal).unwrap(); |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| message.payload_view::<str>().unwrap().unwrap().to_owned(), | ||
| )); | ||
| } | ||
| received.len() >= expected |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
missing (medium): read_topic stops once it has expected messages, so it never shows that there are no extra records. A duplicate after the first N passes, and read_topic(&cluster, 6).len() == 6 (line 180) can't fail. To back the "exactly once" claim, read up to the high watermark, or poll until a quiet period, and then assert the exact count.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| let size = record.size(); | ||
| if self.file.is_none() && self.memory_bytes + size > self.memory_limit { | ||
| let mut writer = BufWriter::new( | ||
| tempfile::tempfile().context("creating the replay spool file for Kafka output")?, |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
pitfall (medium): any step over 64 MiB now spills to the system temp dir on the normal (no-failure) path. In containers that directory is often a small or read-only overlay, or a tmpfs, and a tmpfs counts against pod memory, which defeats the spill. A failed write there is now fatal and parks the pipeline. Consider spilling under the pipeline's storage directory, when one is configured. Also, there is no cap on spill size, and nothing besides the bytes gauge shows it.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
What I ran (PR head
QA test I added locally: #[test] fn qa_consecutive_outages_recover_independently() { let cluster = cluster(); let (mut endpoint, callbacks, _shutdown) = endpoint(&cluster); let mut total = 0; for txn in 0..2u64 { endpoint.batch_start(txn, OutputBatchType::Delta).unwrap(); cluster.broker_down(1).unwrap(); for i in 0..3 { endpoint.push_buffer(format!("t{txn} m{i}").as_bytes()).unwrap(); } total += 3; let before = callbacks.metric("kafka_output_delivery_errors_total"); wait(|| callbacks.metric("kafka_output_delivery_errors_total") > before, 30_000).unwrap(); let worker = thread::spawn(move || (endpoint.batch_end(), endpoint)); thread::sleep(Duration::from_millis(1500)); cluster.broker_up(1).unwrap(); let (result, e) = worker.join().unwrap(); result.unwrap(); endpoint = e; assert_eq!(callbacks.metric("kafka_output_replay_spool_bytes"), 0.0); } assert!(callbacks.metric("kafka_output_transaction_aborts_total") >= 2.0); let expected: Vec<String> = (0..2).flat_map(|t| (0..3).map(move |i| format!("t{t} m{i}"))).collect(); assert_eq!(read_topic(&cluster, total).into_iter().map(|(_, p)| p).collect::<Vec<_>>(), expected); } |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
…need them The Kafka sink page now documents delivery, retries, backpressure, health, exactly-once recovery and the connector's metrics, but the pages a reader starts from did not point there. The fault tolerance page names the Kafka output connector as the one with exactly-once delivery and links its recovery section. The metrics reference says that output connectors can export metrics of their own and links the Kafka ones. The sink page's introduction links its own delivery sections. Fourth step for #7578, stacked on #7682.
be944ac to
dd6c1f6
Compare
…need them The Kafka sink page now documents delivery, retries, backpressure, health, exactly-once recovery and the connector's metrics, but the pages a reader starts from did not point there. The fault tolerance page names the Kafka output connector as the one with exactly-once delivery and links its recovery section. The metrics reference says that output connectors can export metrics of their own and links the Kafka ones. The sink page's introduction links its own delivery sections. Fourth step for #7578, stacked on #7682. Signed-off-by: Xavier Bestel <[email protected]>
dd6c1f6 to
ea7c518
Compare
| } | ||
|
|
||
| // Keep the record before sending it, so that a replay has it. | ||
| if let Err(error) = self.spool.push(record.clone()) { |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
bug (medium): a record that the first send refuses is already in the spool. push_key returns the error, and then every later replay attempt reports it again with kafka_ft_replay. During a long outage that repeats once per abort cycle. I confirmed it with this test, which fails with 2 replay errors after one refused push and one forced abort:
#[test] fn a_record_refused_on_first_send_is_not_reported_again_on_replay() { let cluster = cluster(); let (mut endpoint, callbacks, _shutdown) = endpoint(&cluster); endpoint.batch_start(0, OutputBatchType::Delta).unwrap(); assert!(endpoint.push_buffer(&vec![b'x'; DEFAULT_MAX_MESSAGE_SIZE + 1]).is_err()); cluster.request_errors(RDKafkaApiKey::Produce, &[RDKafkaRespErr::RD_KAFKA_RESP_ERR_TOPIC_AUTHORIZATION_FAILED]); endpoint.push_buffer(b"forces an abort").unwrap(); wait(|| callbacks.metric("kafka_output_delivery_errors_total") > 0.0, 30_000).unwrap(); let (result, _) = BatchWorker::new(endpoint).finish(); result.unwrap(); let n = callbacks.errors().iter().filter(|(_, t, _)| *t == Some("kafka_ft_replay")).count(); assert_eq!(n, 0, "{:?}", callbacks.errors()); // fails: 2 }
Suggested fix: send &record first, then move it into the spool on Sent/NeedsAbort only. That also removes the record.clone(), which copies every payload a second time on the hot path. Records refused for the first time during replay still need a "reported once" marker, for example dropping them from the spool or keeping a set of refused indices.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| pub error: KafkaError, | ||
| } | ||
|
|
||
| impl std::fmt::Display for SendError { |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
pitfall (low-medium): Display already prints self.error, and source() returns it as well, so {error:#} prints the cause twice. Users see this text in the replay error: ...topic 'ft_test_topic': Message production error: MessageSizeTooLarge (...): Message production error: MessageSizeTooLarge (...): MessageSizeTooLarge (...). Either leave self.error out of Display, or return None from source().
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| let size = record.size(); | ||
| if self.file.is_none() && self.memory_bytes + size > self.memory_limit { | ||
| let mut writer = BufWriter::new( | ||
| tempfile::tempfile().context("creating the replay spool file for Kafka output")?, |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
pitfall (medium): tempfile::tempfile() writes to $TMPDIR, which is usually a small ephemeral container filesystem, and it has no size bound. One large transaction, for example the first output after a backfill or a big snapshot, now copies its whole output to /tmp. Before this change it needed no local disk at all. If that disk fills up, push_key calls fail_fatally and the pipeline stalls. Please consider spilling into the pipeline's storage directory when one is configured. Also consider exposing the threshold, or at least documenting the expected disk use per transaction.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
What I ran
Gates: tests, docs, changelog and breaking-change notes are present. No Missing tests (medium)
Flakiness (low): the mock tests depend on wall-clock timing: the 10 s API timeout, plus Overall the design looks sound. Please address the double-reporting bug before merge. |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
…need them The Kafka sink page now documents delivery, retries, backpressure, health, exactly-once recovery and the connector's metrics, but the pages a reader starts from did not point there. The fault tolerance page says that output connectors document their own support, that today only the Kafka output connector supports exactly-once output, and links its recovery section. The metrics reference says that output connectors can export metrics of their own and points at the Kafka and Delta Lake pages. The sink page's introduction links its own delivery sections. Fourth step for #7578, stacked on #7682. Signed-off-by: Xavier Bestel <[email protected]>
ea7c518 to
71463b1
Compare
| // error would incorrectly imply that records were lost. | ||
| debug!("Kafka producer reconnecting: {error}; {reason}"); | ||
| // error would incorrectly imply that records were lost. Log it, | ||
| // so the log says why the pipeline parked. |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
I don't recognize the term "parked" here. Perhaps "hung"?
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| const SPOOL_MEMORY_LIMIT: usize = 64 << 20; | ||
|
|
||
| /// Longest wait for one transaction API call. A call that times out returns a | ||
| /// retriable error and the next call resumes it, so this bounds the shutdown |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
I would spell this "retryable"
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
I take it back, apparently librdkafka uses "retriable"
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
|
My first thought in looking at this was that it's odd to record and replay transactions instead of just retrying them forever. Looking closer, I found out that this doesn't work because librdkafka puts a limit on the length of a transaction, and even if we made that very long, Kafka brokers also cap the length of a transaction (at 15 minutes by deafult). So we have to record and replay. I feel like this should be mentioned somewhere, probably both in comments and in the commit message, because it's surprising. |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
These notes are from Claude (an AI assistant), posted at the request of blp, who is also writing a separate review. Line numbers refer to The earlier bot findings (double report on replay, spill directory, 1. A record that the broker refuses at delivery time makes the sink loop forever (high)
That failure goes to Possible fix: set 2. A transaction that cannot be sent within
|
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
|
||
| /// Hands `record` to `producer`, waiting out a full queue, and sorts the | ||
| /// result for the commit loop. | ||
| fn send_record( |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
This function seems familiar. Maybe there is a very similar function in the non-ft version?
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
|
||
| impl SpooledRecord { | ||
| /// Bytes this record holds in memory, including the container overhead. | ||
| pub fn size(&self) -> usize { |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Might be able to just use the SizeOf trait that we use eleswhere, e.g. #[derive(SizeOf)] then size_of().total_bytes().
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| transaction must be aborted, the connector aborts it, begins a new one, sends | ||
| the kept records again, and commits. It repeats this, with a growing backoff | ||
| of up to 10 seconds, until the commit succeeds or the pipeline shuts down. A | ||
| commit that only times out is repeated as is, without a replay. Aborted |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
A commit that only times out is repeated as is, without a replay.
I'm not sure of the difference between a repeat and a replay.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| It reports healthy again after the next successful commit. | ||
|
|
||
| A fatal producer error, or a transaction error whose recovery action is unknown, | ||
| is reported as a fatal connector error. One example is a producer fenced by |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
I had to look up the term "fenced" in relation to Kafka (maybe I knew it once but I forgot). Perhaps defining it here would be helpful to others. For example:
Kafka fences a transactional producer when another producer starts with the same
transactional.id: the broker then refuses all further writes from the older producer, so that only one instance can commit.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
…need them The Kafka sink page now documents delivery, retries, backpressure, health, exactly-once recovery and the connector's metrics, but the pages a reader starts from did not point there. The fault tolerance page says that output connectors document their own support, that today only the Kafka output connector supports exactly-once output, and links its recovery section. The metrics reference says that output connectors can export metrics of their own and points at the Kafka and Delta Lake pages. The sink page's introduction links its own delivery sections. Fourth step for #7578, stacked on #7682. Signed-off-by: Xavier Bestel <[email protected]>
71463b1 to
4a53112
Compare
…need them The Kafka sink page now documents delivery, retries, backpressure, health, exactly-once recovery and the connector's metrics, but the pages a reader starts from did not point there. The fault tolerance page says that output connectors document their own support, that today only the Kafka output connector supports exactly-once output, and links its recovery section. The metrics reference says that output connectors can export metrics of their own and points at the Kafka and Delta Lake pages. The sink page's introduction links its own delivery sections. Fourth step for #7578, stacked on #7682. Signed-off-by: Xavier Bestel <[email protected]>
4a53112 to
38740c4
Compare
The exactly-once Kafka sink stopped after five delivery retries. A failed commit then left its producer in an error state, and later batches failed while the controller continued processing them. A transaction cannot simply be retried: librdkafka fails it at its first failed delivery and purges its queue on abort, and the broker aborts any transaction older than transaction.max.timeout.ms (15 minutes by default). So keep each transaction's encoded records in a replay spool, with a 64 MiB memory threshold and a spill file in the pipeline's storage directory. Retry retriable commits as-is; abort and replay abortable transactions. Fatal and unclassified errors report the failure and hold output until shutdown, retaining the spool. Never recreate a fenced producer. A broker can fence an expired transaction too; restart then recovers from the last committed position. A record that librdkafka refuses for its own sake is reported once and dropped, also when a replay first discovers it. The broker refuses whole batches, so a broker refusal, a revoked permission, a missing topic or an expired transaction drops nothing: after three aborts in a row the sink reports a connector error that names the cause and keeps retrying. Check cancellation between transaction calls, during backoff, and before each replayed send. Expose health and queue, delivery, transaction, and spool metrics. Mock tests cover error classification, replay refusals, broker refusals, cancellation, and fencing. Real-broker tests use a connection proxy to exercise disk-backed replay, delayed and lost commit replies, restart deduplication, ownership fencing, and transaction expiry. Consumers read through the high watermark so assertions catch trailing duplicates as well as missing records. Third step for #7578, stacked on #7653. Signed-off-by: Xavier Bestel <[email protected]>
…need them The Kafka sink page now documents delivery, retries, backpressure, health, exactly-once recovery and the connector's metrics, but the pages a reader starts from did not point there. The fault tolerance page says that output connectors document their own support, that today only the Kafka output connector supports exactly-once output, and links its recovery section. The metrics reference says that output connectors can export metrics of their own and points at the Kafka and Delta Lake pages. The sink page's introduction links its own delivery sections. Fourth step for #7578, stacked on #7682. Signed-off-by: Xavier Bestel <[email protected]>
38740c4 to
1ae5241
Compare
There was an error while loading. Please reload this page.
…need them The Kafka sink page now documents delivery, retries, backpressure, health, exactly-once recovery and the connector's metrics, but the pages a reader starts from did not point there. The fault tolerance page says that output connectors document their own support, that today only the Kafka output connector supports exactly-once output, and links its recovery section. The metrics reference says that output connectors can export metrics of their own and points at the Kafka and Delta Lake pages. The sink page's introduction links its own delivery sections. Fourth step for #7578, stacked on #7682. Signed-off-by: Xavier Bestel <[email protected]>
…need them The Kafka sink page now documents delivery, retries, backpressure, health, exactly-once recovery and the connector's metrics, but the pages a reader starts from did not point there. The fault tolerance page says that output connectors document their own support, that today only the Kafka output connector supports exactly-once output, and links its recovery section. The metrics reference says that output connectors can export metrics of their own and points at the Kafka and Delta Lake pages. The sink page's introduction links its own delivery sections. Fourth step for feldera#7578, stacked on feldera#7682. Signed-off-by: Xavier Bestel <[email protected]>
There was an error while loading. Please reload this page.
Successfully merging this pull request may close these issues.
There was an error while loading. Please reload this page.