Conversation
Extract the SetErrorHandler lambdas on KafkaMessageConsumer and KafkaMessageProducer into public HandleError(Error) methods, wired via .SetErrorHandler((_, error) => HandleError(error)). Behaviour is unchanged; this is a structural change (Tidy First) that exposes the error-handling logic as a seam so it can be unit tested without a live broker or InternalsVisibleTo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause: KafkaMessageConsumer._hasFatalError and KafkaMessageProducer._hasFatalProducerError were assigned error.IsFatal on every librdkafka error callback rather than latched. librdkafka emits errors in bursts, so a non-fatal error arriving after a fatal one cleared the flag before Receive()/Send() observed it, suppressing the ChannelFailureException and leaving the consumer/producer running against a connection librdkafka had declared unrecoverable. Fix: latch the flag (only ever set it true on a fatal error, never clear it) and drive the log branch off error.IsFatal instead of the latch, so a non-fatal error that follows a fatal one is still logged as non-fatal. Also fixes the same non-latched pattern in the producer, surfaced during confirmation. Adds regression tests (consumer + producer) asserting Receive()/Send() still throw after a fatal-then-non-fatal sequence, plus TestCorrelator guard tests asserting the post-fatal non-fatal error still logs as non-fatal. Fixes #4227 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Review — PR #4229: latch Kafka fatal-error flag Thorough, well-documented fix. I verified the core claims against the source and the diagnosis holds up. Summary: approve with a couple of considerations, none blocking. What is correct
Considerations (non-blocking)
Verdict Correct fix, good tests, honest write-up (including flagging the pre-existing Reviewed by Claude — automated PR review. |
Symptom
The Kafka consumer's
_hasFatalErrorflag (and the producer's_hasFatalProducerError) is unconditionally overwritten on every librdkafka error-callback invocation. librdkafka emits errors in bursts, so a fatal error can be silently cleared by a subsequent non-fatal one before the consume/publish path observes it. When that happens the intendedChannelFailureExceptionis never thrown and the consumer/producer keeps running against a connection librdkafka has declared unrecoverable.Confirmed root cause
_hasFatalError = error.IsFatal;assigns rather than latches. Sequence:true.Local_TimedOuton an idle socket) → flagfalse.Receive()/Send()runs, seesfalse, does not throw → the fatal condition is masked.The flag is meant to be a one-way "the client is dead" latch, so it must never be reset to
falseby a later non-fatal error.Evidence
Proven by code-trace and reproduced at the unit level (see tests): invoking the extracted error handler with a fatal error followed by a non-fatal one and asserting
Receive()/Send()still throwChannelFailureException. The defect is a single-threaded logic error — it reproduces without any threading.Fix
trueon a fatal error; never clear it.error.IsFatal, not_hasFatalError. Without this, once latched, every subsequent non-fatal error would be logged as fatal and the non-fatal log branch would become dead code (a defect the naive one-line fix would introduce).Scope
_hasFatalError) and the producer (_hasFatalProducerError) — the same non-latched pattern, surfaced during confirmation.public HandleError(Error)methods as a separate, behaviour-preservingrefactor:commit (InternalsVisibleTois banned in this repo, so the seam is a public method). The behavioural change is isolated in thefix:commit.volatile. The confirmed diagnosis established this is a single-threaded logic bug, not a memory-visibility one;volatilewould be optional hardening beyond the proven cause.Tests
Receive()/Send()to throwChannelFailureException.Serilog.Sinks.TestCorrelator): a non-fatal error after a fatal one is still logged atWarning, locking in the log/latch decoupling.Verification
KafkaMessageAssertion.cs:50(host BST vs UTC, off by exactly one hour) — unrelated to this change and a candidate for a separate issue.Fixes #4227