Repository navigation
Produce with acks=0 no longer gets an unsolicited response; failure closes the connection - #845
Conversation
sgamelin
left a comment
There was a problem hiding this comment.
The broker change looks right to me. I checked it against Kafka 3.9.1: KafkaApis.handleProduceRequest sends nothing on success and closes the connection when any partition in the merged map has an error, and ProduceRequest.getErrorResponse returns null for acks=0, so request-level failures close the connection too. Nisshi's failures before the handler runs (frame decode, unauthenticated, unknown API key) already return Err and close, so those match as well.
I'm requesting changes for the proxy, because merging this as it stands breaks acks=0 producers that go through it:
- Pass-through (topics without
tansu.batch):BytesConnectionServicewrites the request and then waits for a response, with no timeout. A nisshi origin no longer sends one, so the proxied request stalls until the origin's connection idle timeout (600 s by default) closes the upstream connection. The same applies to anyone callingnisshi_client::Client::callwith a defaultProduceRequest, which is acks=0. - Batch path:
BatchProduceServicereturns the origin's response to the downstream client whatever acks that client asked for, so an acks=0 client behind the proxy still gets the unsolicited response this PR removes from the broker. - Upgrade order: a proxy built before this PR forwards batches with acks=0 and waits for the reply, so it stalls every
tansu.batch=trueproducer once its origin is upgraded. Proxies have to be upgraded before brokers.
Kafka's own client decides this per request: the producer's Sender passes expectResponse = acks != 0. A predicate in nisshi-sans-io (say Frame::expects_response) that BytesConnectionService checks before reading would fix the pass-through and the published client together. For the batch path, the downstream suppression needs per-connection state that the proxy doesn't have yet (see the inline comment on stream.rs). Checking acks in BytesFrameService, which every Produce on both the broker and the proxy passes through, would keep the rule in one place instead of in each handler.
If you'd rather land the broker change first, could you open an issue for the proxy, link it here, and add the pass-through limitation and the upgrade order to the CHANGELOG entry?
e179565 to
5beae8b
Compare
|
On the proxy points from the summary, in 5beae8b:
|
…n failure A real Kafka producer configured with acks=0 never reads a response, so the broker writing one anyway serves no purpose on success, and silently dropping a failed batch on the floor serves the client even worse than disconnecting: the real Java producer already treats a dropped connection as a retriable NetworkException. ProduceService now (for acks=0 requests): - on any partition error anywhere in the assembled response (matching Kafka's own handleProduceRequest, which closes the connection whenever unauthorizedTopicResponses/nonExistingTopicResponses/invalidRequestResponses/ per-partition errors are merged into a non-empty error set) returns a new Error::AcksZeroProduceFailed, reusing the existing Err-closes-connection path instead of embedding the error in a response nobody reads. - on success, marks a new SuppressResponseExtension (a self-resetting AtomicBool-backed marker, since rama's Extensions has no way to remove an entry once inserted and the connection's Extensions store is shared, not forked, across every request on it) so TcpBytesService::req skips writing the already-assembled response. broker.rs logs the new failure variant at info (matching real Kafka's own "Closing connection due to error during produce request..." level) instead of falling through to the generic error! path. Audited every existing acks=0 call site: nisshi-generator and nisshi-perf both built a default (acks=0) ProduceRequest and awaited a response that would now never arrive, so both now request Ack::Leader explicitly, matching the existing pattern in nisshi-client's example and nisshi-cat. Bumped the 5 produce.rs tests that relied on an acks=0 response embedding an error code (their actual subject is idempotent-producer/batch-rejection error codes, not acks semantics) to a non-zero acks so they keep testing that. Added nisshi-broker/tests/it/produce_acks_zero.rs: real-socket tests across all 4 storage engines, built on tls.rs's spawn_broker/round_trip precedent. The success case sends the acks=0 Produce then a Metadata request and asserts the first (and only) frame read back carries the Metadata correlation id, not the Produce one -- a positive proof nothing was interposed, not just a read timeout -- then confirms via ListOffsets(Latest) that the record was still stored. The failure case (a record-less batch, rejected before storage is touched, so it fails identically on every backend regardless of a backend's own auto-topic-creation-on-produce behavior) asserts the connection closes. Verified against a real client: librdkafka's compat suite's 0008_reqacks (acks=-1/0/1) passes, along with the rest of the allowlist bar one pre-existing, unrelated failure (0060_op_prio, a legacy-consumer polling test with no acks/produce involvement, reproduced against this same build). Follow-up filed as SOL-155348: nisshi-proxy's own forwarding path has an unrelated pre-existing hang forwarding an acks=0 Produce to a real upstream Kafka, since it doesn't go through ProduceService. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2qdgPhMyLGZN5dLR8CVHs Signed-off-by: Andrea Ross <168456375+solace-aross@users.noreply.github.com>
…re test, fix changelog wording Round 2 review follow-up on SOL-155328. nisshi-proxy/src/produce.rs's produce_request() (used by the batch-forwarding path, tansu.batch=true) built the outgoing ProduceRequest with no .acks(...), so it always forwarded with the wire default of acks=0 regardless of what the original client asked for. send_pending_batch awaits the origin's response to fan out base offsets to the waiting downstream clients, so against a nisshi origin that now correctly suppresses its acks=0 response (this ticket's fix), that await would hang forever. Request Ack::Leader explicitly, the same pattern already used in nisshi-generator/nisshi-perf -- the batch path needs a real response regardless of what kind of origin is on the other end. Corrected SOL-155348 (the proxy acks=0 follow-up filed alongside the original fix): the component is nisshi-client's BytesConnectionService (reached via ConnectionManager), not nisshi-service's BytesTcpService, and this fix is what exposes the proxy's pass-through hang against a nisshi origin, not a pre-existing unrelated issue. The pass-through case is still correctly deferred to that ticket; the batch-forwarding case is fixed here. Added produce_acks_zero.rs::storage_failure_closes_connection across all 4 storage engines: an acks=0 Produce using an idempotent producer id never registered via InitProducerId, exercising the genuine storage/protocol-layer UnknownProducerId failure (ProduceService::partition's storage.produce() call) rather than the pre-storage rejection() validation path the existing failure_closes_connection tests cover. Fixed the CHANGELOG entry, which had the bug backwards: nobody was "waiting" for a response: the broker wrote one the client never reads, which corrupts correlation-id matching on the client's next request. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2qdgPhMyLGZN5dLR8CVHs Signed-off-by: Andrea Ross <168456375+solace-aross@users.noreply.github.com>
Reword a repeated test comment to state the current behavior as a
requirement rather than an observation ("now closes"), and to use a
complete sentence instead of a "Non-zero:" fragment label.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Andrea Ross <168456375+solace-aross@users.noreply.github.com>
- TcpBytesService forks each connection's extensions and inserts the suppression marker before the first request; handlers only set it. - The proxy forwards a batch with the strictest acks among its requests, at least acks=1. - Count failed acks=0 Produce requests by error code. - Test three acks=0 Produce requests, then acks=1 and Metadata, on one connection. - CHANGELOG: proxy upgrade order and the unbatched pass-through limitation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011P97dHLhTRdJ35fMJFpYPg Signed-off-by: Andrea Ross <168456375+solace-aross@users.noreply.github.com>
5beae8b to
c4c9175
Compare
|
Rebased on main. |
sgamelin
left a comment
There was a problem hiding this comment.
Thanks, this covers everything from my last review. Forking the extensions once per connection in TcpBytesService::serve fixes the cross-connection leak. acks_zero_then_acks_one_on_one_connection covers the reused marker. The batch path keeps the strictest acks, and #890 tracks the proxy pass-through and the response the proxy still returns downstream. Approving.
Two things before it merges, neither blocking:
- #853's new tests will fail once this lands, and git won't show a conflict. The
producehelper inproduce_invalid_record.rssends a defaultProduceRequest, which is acks=0, and assertsINVALID_RECORDin the response. With this PR,ProduceServicereturnsErr(AcksZeroProduceFailed)for that request, so every test in that file fails at the?. Whichever PR lands second needs.acks(-1)in that helper, as you did for the fiveproduce.rstests. #847's new tests already use acks=-1, so they're fine. - The description is out of date. It still says the marker is shared across a connection's requests, not forked per connection, and that the batch path forwards
Ack::Leader, not the strictest acks. The deferred section doesn't link #890. Could you update those?
#845 stopped the broker answering a produce request sent with acks=0, so the test runs again, except on in-memory storage, where it reads the latest offset like the other produce tests waiting for #798. #849 stops the broker advertising ListOffsets versions it doesn't support, which the max-timestamp lookup is waiting for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: William Kourlas <156007774+solace-wkourlas@users.noreply.github.com>
#845 stopped the broker answering a produce request sent with acks=0, so the test runs again, except on in-memory storage, where it reads the latest offset like the other produce tests waiting for #798. #849 stops the broker advertising ListOffsets versions it doesn't support, which the max-timestamp lookup is waiting for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: William Kourlas <156007774+solace-wkourlas@users.noreply.github.com>
…uce, consume, offsets and restarts (nisshi-io#902) Stacked on nisshi-io#862: review that one first. This PR adds the next set of smoke tests on top of its harness. ## What this adds Tests that run the Kafka CLI tools the way a user does, on every storage engine: - `broker`: ApiVersions names node 111, and the cluster id is the one the broker started with. - `topics`: describe, auto-create on produce, delete and re-create. - `configs`: describe, add and delete topic configs, and broker defaults. - `produce`: every `acks` setting, compression codecs, tombstones, `max.message.bytes`, idempotent and concurrent producers, `LogAppendTime`. - `consume`: reading from an offset, a group resuming where it stopped, a pattern subscription that picks up a topic created later. - `offsets`: lookups by time. - `restart`: topics, records, committed offsets, topic configs and SCRAM users survive a SIGTERM restart on PostgreSQL and SQLite, and an in-memory broker starts again empty. - `storage_url`: an unparsable or invalid storage URL stops the broker with an error that names it. The harness now has one module per Kafka tool. Each command whose output a test reads has its own `Output<Marker>` type, so a reader can only be called on its own command's output. Brokers can restart on the same storage, and tests can start isolated brokers. The CI change gives a smoke leg that fails before `just smoke` runs (setup, toolchain) a FAIL row, so it still shows in the report. `run.sh` also fails the leg if no nextest status line parses, instead of showing a green leg with no tests. `.claude/rules/smoke-tests.md` sets the rules for writing a smoke test. ## Ignored tests A test that fails because of a broker bug is ignored with the bug in its reason, so CI stays green and the fix enables it. Every reason names the issue or PR that fixes it, e.g. nisshi-io#798 for the five produce tests that read the latest offset on in-memory storage, and nisshi-io#849 for the `max-timestamp` lookup. ## Testing - `just clippy`-equivalent for `nisshi-smoke-test` (all features, and each engine feature alone), `cargo fmt`, the crate's unit tests, rustdoc with warnings denied, ShellCheck on `run.sh` and actionlint on `ci.yml` all pass. - `just smoke postgres`, `sqlite` and `memory` on this tree, rebased on main: every test that isn't ignored passes, and the shared broker passes its checks. Local runs also run the ignored tests: postgres 55 of 64 pass, sqlite 58 of 67, memory 45 of 58, and every failure is an ignored test. - The `maintenance_interval`, timestamp-lookup and `acks=0` tests were ignored for bugs that nisshi-io#850, nisshi-io#838 and nisshi-io#845 fixed, and are now enabled. `acks=0` stays ignored on in-memory storage for nisshi-io#798, like the other produce tests. - `s3` is unchanged: it still runs only `topic_lifecycle`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: William Kourlas <156007774+solace-wkourlas@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Bug
A Kafka producer configured with
acks=0never reads a response. Nisshi's broker wrote one anyway for every Produce request. On success this served no purpose; on failure, the broker silently dropped the batch instead of signaling anything. Worse, the unsolicited success response sits on the connection and corrupts correlation-id matching on the client's next request, since a real acks=0 client never consumes it.Fix
ProduceService(foracks=0requests only):SuppressResponseExtension, a self-resettingAtomicBool-backed marker, soTcpBytesService::reqskips writing the already-assembled response. A plain bool can't be inserted once and reused because rama'sExtensionsis shared (not forked) across every request on a connection and has no way to remove an entry once inserted.handleProduceRequest(which closes the connection wheneverunauthorizedTopicResponses/nonExistingTopicResponses/invalidRequestResponses/per-partition errors merge into a non-empty error set): returns a newError::AcksZeroProduceFailed, reusing the existing error-closes-connection path instead of embedding the error in a response nobody reads.Both decisions (broad scope matching every error path, not just storage; a dedicated error variant) were checked against real Kafka source and independently mutation-tested: disabling the response suppression makes all 4 success tests fail with a stray Produce response's correlation id showing up where it shouldn't, and narrowing the error scope back to storage-only makes all 4 failure tests fail with a timeout since the connection incorrectly stays open. Both were restored to green afterward.
broker.rslogs the new failure variant atinfo, matching real Kafka's own "Closing connection due to error during produce request..." level, instead of falling through to the genericerror!path. A pre-existing deviation was also checked in this review:TcpBytesService::process'serror!on a connection failure was deliberately left alone rather than downgraded todebug!, becauseTcpListenerService(the proxy's own accept loop) only logs connection failures atdebug!already -- downgrading the broker side too would remove the proxy operator's only error-level signal for this class of failure.Proxy batch-forwarding fix (included here)
Audited every existing
acks=0call site in the repo.nisshi-generatorandnisshi-perfboth built a default (acks=0)ProduceRequestand awaited a response that would now never arrive from a nisshi broker, so both now requestAck::Leaderexplicitly.One more call site needed the same fix:
nisshi-proxy/src/produce.rs'sproduce_request(), used by the batch-forwarding path (tansu.batch=true), also built a defaultProduceRequestwith noacks(...).send_pending_batchawaits the origin's response to fan out base offsets to the waiting downstream clients, so forwarding withacks=0against an origin that correctly suppresses its response (this PR's fix) would hang that await forever. Fixed by requestingAck::Leaderexplicitly, same pattern as generator/perf -- the batch path needs the origin's real response regardless of what kind of origin is on the other end.Deferred: proxy pass-through case
The proxy's plain pass-through forwarding (not the batching path above) goes through
nisshi-client'sBytesConnectionService(reached viaConnectionManager), which doeswrite_allthen an unconditionalread_exactfor every forwarded request. Forwarding a pass-throughacks=0Produce to an origin that correctly never responds (a nisshi origin after this fix) will hang on thatread_exact.This is a known, deliberately-deferred consequence of this fix for the pass-through path specifically, not something newly discovered and unrelated. Fixing it properly means adding acks-awareness to
BytesConnectionServiceitself, a separate, larger piece of work, tracked as a separate follow-up (description corrected to name the right component and framing).Tests
nisshi-broker/tests/it/produce_acks_zero.rs: real-socket tests across all 4 storage engines (in-memory, libSQL, Postgres, SlateDB).success_is_not_written: sends anacks=0Produce then a Metadata request, asserts the first (and only) frame read back carries the Metadata correlation id, not the Produce one -- positive proof nothing was interposed -- then confirms viaListOffsets(Latest)the record was still stored.failure_closes_connection: a record-less batch, rejected byProduceService::partition's pre-storagerejection()validation, asserts the connection closes.storage_failure_closes_connection(new in this round): an idempotent producer id never registered viaInitProducerId, exercising a genuinestorage.produce()-layer failure (UnknownProducerId) rather than pre-storage validation, across all 4 engines.produce.rstests that relied on anacks=0response embedding an error code (their actual subject is idempotent-producer/batch-rejection error codes, not acks semantics) to a non-zero acks so they keep testing that.nisshi-proxy's batch tests updated to expectacks=1(Ack::Leader) on the forwarded request.Verified against a real client: librdkafka's compat suite's
0008_reqacks(acks=-1/0/1) passes, along with the rest of the allowlist bar one pre-existing failure,0060_op_prio(reproduced identically against an unmodifiedmainbuild) -- a legacy-consumer test that does call produce, but fails during its later consume phase, not in the produce path itself.Local run this round:
CHANGELOG
🤖 Generated with Claude Code
https://claude.ai/code/session_01D2qdgPhMyLGZN5dLR8CVHs