Repository navigation
Conversation
…MaxPendingMessages reserveSemaphore() acquires one permit per chunk up front from the publish semaphore, whose capacity is MaxPendingMessages. A chunked message split into more chunks than MaxPendingMessages can never acquire all of its permits: the producer blocks in Acquire(ctx) holding every permit it will ever get, while those permits can only be released after the chunks are dispatched, which never happens. Send() then hangs until the caller's context expires. Reserve a single permit for the first chunk and let each subsequent chunk acquire its own permit just before it is dispatched (the Java client's per-chunk strategy, as discussed in apache#1462). Permits temporarily exceed MaxPendingMessages and are released as earlier chunks are acknowledged. If a per-chunk acquire fails, the parent send request releases only the memory of the chunks that were never dispatched and hands the chunk-0 permit over to its own send request, keeping the semaphore and memory accounting exact. TestMemLimitRejectProducerMessagesWithChunking now waits for the asynchronous release of the dispatched chunk's memory, mirroring the retryAssert window already used in the same test. Fixes apache#1448
Sending a chunked message whose total number of chunks exceeds MaxPendingMessages used to block Send() until the send context expired (apache#1448). The test bounds the send with a 30s context and asserts that it completes, and skips automatically when no Pulsar broker is reachable; setting PULSAR_TEST_BROKER forces it to run against a remote broker.
|
Thanks for working on this fix! The deadlock issue is real and important. I have some concerns about the current per-chunk semaphore acquisition approach that I'd like to discuss. Partial chunk delivery riskThe current implementation acquires semaphore permits one-by-one inside the Non-blocking mode ( Blocking mode ( In both cases, the broker has already persisted chunks 0–4, but chunks 5–10 will never arrive. On the consumer side, the Suggested alternative: throttle semantics (allow one request to exceed the limit)As discussed in #1462 (review), // From memory_limit_controller.go — TryReserveMemory
if m.IsMemoryLimited() && current > m.limit {
return false // only reject when already over the limit
}
// otherwise allow, even if current + size > limitThe key idea: as long as the current usage hasn't exceeded the limit yet, allow the entire request (all chunks) to go through in one atomic operation, even if it temporarily pushes usage above the limit. Subsequent requests are blocked/rejected until usage drops back below the limit. Concretely, this would mean adding batch acquire support to the func (s *semaphore) TryAcquireN(n int32) bool {
for {
current := atomic.LoadInt32(&s.permits)
if current > s.maxPermits {
return false // already over limit, reject
}
// Allow this batch to exceed maxPermits (same as MemoryLimitController)
if atomic.CompareAndSwapInt32(&s.permits, current, current+n) {
return true
}
}
} |
|
Thanks for the detailed review — the partial-delivery argument convinced us, and we're not going to defend the per-chunk acquisition loop. We implemented the batch acquire you proposed and measured it against the regression test. Short version: on its own it does not close #1448, so we'd like to check the direction with you before pushing another iteration. What we measuredImplementation as described in your sketch:
The same test against So the batch acquire regresses this regression test at its own configured value, and only starts passing once One plausible reading of the boundary: at What we do know about the remaining hangAt timeout (goroutine dump plus producer warnings,
That is consistent with every chunk being dispatched and acked while the chunk group's callback still never fires, but we haven't isolated it yet. Two corrections to the sketch
QuestionIs there prior art we should be following for throttling a chunk group as a unit — in particular, whether the bound should be the chunk count at all, or whether the chunks should be admitted against the connection's pending-message budget rather than the producer's publish semaphore? We'll keep the test at |
A message split into more chunks than MaxPendingMessages could never be sent, and a chunk group could also hang when its receipt arrived before the chunk had been added to the pending queue. - semaphore: add AcquireN/TryAcquireN. A request is admitted while the permits held are below the limit, even if it overshoots it, like the memory limit controller. Release no longer blocks when nobody waits; waiters are woken in FIFO order. - producer: reserve one permit per chunk in a single call and record the count in reservedSemaphore so a message that fails before it is split returns every permit. Each dispatched chunk still releases its own. - producer: put the pending item in the queue before writing it to the connection, both under the pending queue lock, so a fast receipt can no longer be dropped as "ack for timed out msg". - tests: cover the new semaphore behavior and chunked messages with more chunks than MaxPendingMessages, in blocking and non-blocking mode. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
I pushed the redesign (0ace76c). It follows your throttle suggestion, and it also closes the hang I described in my last comment. Batch acquire. The remaining hang was not the semaphore. It was an ordering problem in That also answers my question about the connection's pending budget: with the ordering fixed, the producer semaphore alone is enough, so I did not add a second limit. Tests.
The The tests that still fail locally (TLS/token setup, the admin port, broker max message size, DLQ, send timeout) fail the same way on a clean master checkout in my environment, so they are not caused by this change. I updated the PR description to match the new approach. If you see a case where a chunk group can still be dispatched partially, I would like to hear about it. |
|
Hi @geniusjoe, gentle ping whenever you have a moment — commit 0ace76c switches the multi-chunk path to batch-acquire permits upfront as discussed, and workflows are currently awaiting approval to run. Thanks! |
|
Hi @n24q02m, thanks a lot for this work — especially for reworking the per-chunk approach into a batch acquire after the earlier review, and for digging into the timeout itself rather than only the symptom. I should own that the I'd also like to keep the Go client's underlying behavior aligned with the Java client as much as possible. Java keeps a hard, fair So I'd like to step back and rethink the approach before asking you to iterate further — if I can find a viable direction that keeps Thanks again for the detailed analysis. |
Motivation
When chunking is enabled,
reserveSemaphore()acquires one permit per chunk up front frompublishSemaphore, whose capacity isMaxPendingMessages(default 1000).If
totalChunks > MaxPendingMessages, the producer can never acquire all the permits it needs. It blocks inAcquire(ctx)holding every permit it will ever get, while those permits can only be released after the chunks are dispatched, which never happens becauseinternalSend()is never reached.Send()only returns when the caller's context expires.Reproduction: with
MaxPendingMessages: 2,EnableChunking: true,ChunkMaxMessageSize: 1000and a 10 KiB payload (11 chunks),Send()blocks until the context deadline.Approach
This follows the throttle semantics suggested in the review (and in #1462):
MaxPendingMessageslimits how much is admitted, it is not a hard cap, exactly likeMemoryLimitController. A message is never split between "sent" and "not sent".SemaphoregetsAcquireN/TryAcquireN. A request is admitted while the permits already held are below the limit (>=is rejected, matchingTryAcquire), even if the request itself overshoots the limit. Later requests wait until usage drops below the limit again.Releaseno longer blocks when nobody is waiting. The old unbuffered-channel implementation could block a releasing goroutine once permits were above the limit. Waiters are now kept in a FIFO list guarded by a mutex, and a cancelled waiter neither consumes nor leaks a permit.reserveSemaphore()takes alltotalChunkspermits in one call before the message enters the event loop, and records the number inreservedSemaphore. If the message fails before it is split (memory limit, context, closing producer),done()returns all of them. Once split, each chunk releases its own permit when it is acknowledged or fails.writeData()now puts the pending item in the pending queue before writing it to the connection, both under the pending queue lock. With more chunks in flight than the queue can hold, the broker could acknowledge a chunk before it had been queued; the receipt was then dropped (Got ack ... for timed out msg) and the callback of the chunk group never fired. This was the remaining hang after the semaphore change on its own.Test evidence
TryAcquireNboundary,Releasenot blocking without waiters, FIFO wake-up, context expiry while waiting, and cancel racing with release. They pass with-race -count=20.TestSendChunkedMessageWithMoreChunksThanMaxPendingMessages(11 chunks,MaxPendingMessages: 2, bounded 30s context) completes instead of blocking until the deadline.TestChunkBlockIfQueueFull(MaxPendingMessages: 1) now expects the send to complete. It was the test that exposed the receipt-before-enqueue problem: it failed intermittently before thewriteData()change and ran 800 iterations under-racewithout a failure after it.TestChunksEnqueueFailedchanged: a single message larger than the limit is now admitted, so the test fills the limit with one pendingSendAsyncand expectsErrSendQueueIsFullfor the next message.TestMemLimitRejectProducerMessagesWithChunkingno longer needs theretryAssertadded earlier in this PR. No chunk is dispatched when the memory reservation fails, so the memory is released synchronously.go build ./...andgo vetare clean. The remaining failures in the broker-backed suite (TestChunkSize,TestChunkDLQWithNack,TestSendTimeout,TestTxn_AckChunkMessage, plus the TLS and token tests) fail identically on pristine master in the same environment, which I checked by running the same suite against a master worktree.