Skip to content

Produce with acks=0 no longer gets an unsolicited response; failure closes the connection - #845

Merged
solace-aross merged 4 commits into
mainfrom
fix/sol-155328-acks-zero-no-response
Oct 9, 2026
Merged

solace-aross merged 4 commits into
mainfrom
fix/sol-155328-acks-zero-no-response

Conversation

@solace-aross

@solace-aross solace-aross commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

Bug

A Kafka producer configured with acks=0 never 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 (for acks=0 requests only):

  • On success: marks a new SuppressResponseExtension, a self-resetting AtomicBool-backed marker, so TcpBytesService::req skips writing the already-assembled response. A plain bool can't be inserted once and reused because rama's Extensions is shared (not forked) across every request on a connection and has no way to remove an entry once inserted.
  • On any partition error anywhere in the assembled response, matching real Kafka's own handleProduceRequest (which closes the connection whenever unauthorizedTopicResponses/nonExistingTopicResponses/invalidRequestResponses/per-partition errors merge into a non-empty error set): returns a new Error::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.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. A pre-existing deviation was also checked in this review: TcpBytesService::process's error! on a connection failure was deliberately left alone rather than downgraded to debug!, because TcpListenerService (the proxy's own accept loop) only logs connection failures at debug! 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=0 call site in the repo. nisshi-generator and nisshi-perf both built a default (acks=0) ProduceRequest and awaited a response that would now never arrive from a nisshi broker, so both now request Ack::Leader explicitly.

One more call site needed the same fix: nisshi-proxy/src/produce.rs's produce_request(), used by the batch-forwarding path (tansu.batch=true), also built a default ProduceRequest with no acks(...). send_pending_batch awaits the origin's response to fan out base offsets to the waiting downstream clients, so forwarding with acks=0 against an origin that correctly suppresses its response (this PR's fix) would hang that await forever. Fixed by requesting Ack::Leader explicitly, 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's BytesConnectionService (reached via ConnectionManager), which does write_all then an unconditional read_exact for every forwarded request. Forwarding a pass-through acks=0 Produce to an origin that correctly never responds (a nisshi origin after this fix) will hang on that read_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 BytesConnectionService itself, 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 an acks=0 Produce 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 via ListOffsets(Latest) the record was still stored.
    • failure_closes_connection: a record-less batch, rejected by ProduceService::partition's pre-storage rejection() validation, asserts the connection closes.
    • storage_failure_closes_connection (new in this round): an idempotent producer id never registered via InitProducerId, exercising a genuine storage.produce()-layer failure (UnknownProducerId) rather than pre-storage validation, across all 4 engines.
  • Bumped 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.
  • nisshi-proxy's batch tests updated to expect acks=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 unmodified main build) -- 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:

cargo nextest run -p nisshi-broker --all-features -E 'test(/^produce_acks_zero::/)'  # 12 passed
cargo nextest run -p nisshi-broker --all-features -E 'test(/^produce::/)'            # 28 passed
cargo nextest run -p nisshi-proxy --all-features                                      # 10 passed
just fmt / just clippy / just build-all                                               # clean

CHANGELOG

The broker no longer writes a Produce response for an acks=0 request. A storage or validation failure on an acks=0 Produce now closes the connection (matching Apache Kafka) instead of silently dropping the batch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D2qdgPhMyLGZN5dLR8CVHs

@sgamelin sgamelin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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): BytesConnectionService writes 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 calling nisshi_client::Client::call with a default ProduceRequest, which is acks=0.
  • Batch path: BatchProduceService returns 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=true producer 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?

Comment thread nisshi-service/src/stream.rs Outdated
Comment thread nisshi-proxy/src/produce.rs Outdated
Comment thread nisshi-broker/src/broker.rs
Comment thread nisshi-broker/tests/it/produce_acks_zero.rs
Comment thread nisshi-broker/tests/it/produce_acks_zero.rs
@solace-aross

Copy link
Copy Markdown
Collaborator Author

On the proxy points from the summary, in 5beae8b:

  • The batch path forwards the strictest acks among batched requests, at least 1.
  • CHANGELOG ### Changed notes the upgrade order: upgrade proxies before brokers, since an older proxy forwards batches with acks=0 and would stall against an upgraded broker.
  • The pass-through stall is not fixed here; it needs a no-response path through nisshi_client and the proxy (a Frame::expects_response predicate, as Kafka's Sender does with expectResponse = acks != 0). The CHANGELOG lists it as a known limitation, and it needs a follow-up issue before this lands.

solace-aross and others added 4 commits October 8, 2026 04:08
…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>
@solace-aross
solace-aross force-pushed the fix/sol-155328-acks-zero-no-response branch from 5beae8b to c4c9175 Compare October 8, 2026 08:20
@solace-aross

Copy link
Copy Markdown
Collaborator Author

Rebased on main. broker.rs has both the pre-authentication arm from main and the acks=0 arm. No code change otherwise.

@sgamelin sgamelin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 produce helper in produce_invalid_record.rs sends a default ProduceRequest, which is acks=0, and asserts INVALID_RECORD in the response. With this PR, ProduceService returns Err(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 five produce.rs tests. #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?

@solace-aross
solace-aross added this pull request to the merge queue Oct 8, 2026
@solace-aross
solace-aross removed this pull request from the merge queue due to a manual request Oct 8, 2026
@solace-aross
solace-aross added this pull request to the merge queue Oct 8, 2026
@solace-aross
solace-aross removed this pull request from the merge queue due to a manual request Oct 8, 2026
@solace-aross
solace-aross added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 8543d06 Oct 9, 2026
25 checks passed
@solace-aross
solace-aross deleted the fix/sol-155328-acks-zero-no-response branch October 9, 2026 00:35
solace-wkourlas added a commit that referenced this pull request Oct 9, 2026
#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>
solace-wkourlas added a commit that referenced this pull request Oct 9, 2026
#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>
IgorOffline pushed a commit to IgorOffline/tansu-io-main that referenced this pull request Oct 10, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants