Replies: 2 comments 4 replies
|
One thing that's not obvious to me is how will channel exceptions be propagated to the app, via the library-owned channels? I'd also explore whether automatic connection recovery can be fit into this design. With [Go] channels and waiting it can look meaningfully different from from other clients do but if there ever is a moment do consider this feature, that time is now. |
3 replies
|
Oof, big doc that I skimmed. The proposal feels more in-line with what the .NET client does. Seems like a good path to version 2 for this library. |
1 reply
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Since we have access to very intelligent AI models, I decided to brainstorm potential solutions to the fundamental problem of this client: deadlocks in the internal state machine.
The deadlocks are not because of poor implementation or bugs, but due to design flaws. It is relatively easy to trip up with this library and end up in a deadlock situation. Read #225 and #182. There were some other deadlocks in the past that are alleviated, but the "fix" was in documentation, not in the code.
I'm at a point where the proposal and analysis concluded, with the help of Opus 4.6, is ready to be discussed. Spoiler alert: we need breaking changes.
Tagging @lukebakken and @michaelklishin, our top contributors and maintainers. Without further due, the design document and proposal:
Design document and proposal (click me) 👈
Design Document: Channel Deadlock Fix
References: Issue #253, Issue #225
Breaking changes: Allowed and required.
Summary
The current
amqp091-gochannel implementation has a structural deadlock problem: the connection's single reader goroutine callschannel.recv()anddispatch()synchronously, and multiple code paths insidedispatch()can block indefinitely. This stalls frame processing for every channel on the connection.This document describes a four-part redesign that eliminates all identified deadlock vectors:
ch.rpcchannelcontext.Contextto all RPC methodsRoot Cause Analysis
Deadlock Scenario 1: Blocked RPC (Issues #253, #225)
ch.call()(e.g.QueueUnbind), which sends a method frame and then blocks in aselectwaiting onch.rpcorch.errors.ch.rpcchannel (unbuffered) never receives, soch.call()blocks forever.Deadlock Scenario 2: Notify channel backpressure
NotifyClose(make(chan *Error))with an unbuffered channel.c.shutdown()→ tries to send onc.closeschannels:c <- err.close(c.errors)from ever executing, so any goroutine waiting inch.call()onch.errorsalso never unblocks.Deadlock Scenario 3:
dispatch()blocks the readerThe
dispatch()method runs inline on the connection reader goroutine. Several paths can block:defaultcase sends onch.rpc(unbuffered). If the user goroutine that should be receiving onch.rpchas already given up or hasn't started listening yet, the reader goroutine blocks, stalling all channels on the connection.ch.flows,ch.cancels,ch.returnschannels — if the user doesn't read from them, the reader blocks.ch.consumers.send()holds a mutex and sends on a channel — if the consumer buffer goroutine is slow, the reader blocks.Deadlock Scenario 4:
dispatch↔sendcircular dependencydispatch()handleschannelClose. It acquiresch.m.Lock(), then callsch.send()→ch.sendClosed()→c.send()→ acquiresc.sendM.Lock()and writes to the socket.ch.m.PublishWithDeferredConfirmalso acquiresch.m.Lock(). Both paths contend onch.mand the connection write path.Architecture
Part 1: Per-channel Goroutine
Goal: Decouple the connection reader from channel frame processing.
Channelstruct changesRemove:
recv func(*Channel, frame)— function-pointer state machine fieldrpc chan message— shared RPC response channelAdd:
frames chan frame— buffered (cap 64) frame inbox; the reader sends herependingRPC chan message— per-RPC one-shot response channel (nil when idle)state func(*Channel, frame)— replacesrecvfor state trackingnewChannelchangesrecvLoopgoroutineWhen
ch.framesis closed (during shutdown), the goroutine exits cleanly.shutdownchangesclose(ch.frames)must be called early inshutdown()to stop the channel goroutine. All sends toch.closesandch.errorsbecome non-blocking (Part 4).State machine functions
transition()is removed.recvMethod,recvHeader,recvContentdirectly assignch.state:Connection.dispatchNchangesReplace inline
channel.recv(channel, f)with a non-blocking send:The reader goroutine can never block on channel processing.
Part 2: Per-RPC One-Shot Response Channel
Goal: Replace the shared
ch.rpcchannel with a per-call buffered channel, so a stale response after a timeout cannot poison the next RPC anddispatch()never blocks on RPC delivery.call()changesdispatch()default casedefault: ch.m.Lock() pending := ch.pendingRPC ch.m.Unlock() if pending != nil { select { case pending <- msg: default: } } // If pending is nil, nobody is waiting — discard the message.Why this is safe after a timeout
When the context fires:
call()hitscase <-ctx.Done().deferruns: setsch.pendingRPC = nil.call()triggerscloseChanneland returnsctx.Err().If the stale response arrives later:
dispatch()readsch.pendingRPC— it'snil.If the stale response arrives before the
deferhas run (race window):dispatch()readsch.pendingRPC— it's stillreply.dispatch()sends onreply(succeeds because buffer cap is 1).deferruns and setsch.pendingRPC = nil.reply. It is garbage collected.The buffer of 1 is what makes this safe —
dispatch()never blocks.Part 3: Context Support with Close-on-Timeout
Goal: Allow callers to set timeouts and cancellations on all RPC operations. When the context expires, the AMQP channel is closed — it is in an indeterminate state and cannot be reused.
Public method signature changes
Every public RPC method gains
ctx context.Contextas its first parameter. TheXxxWithContextduplicates are removed.Close() errorClose(ctx context.Context) errorQos(prefetchCount, prefetchSize int, global bool) errorQos(ctx context.Context, ...) errorQueueDeclare(name string, ...) (Queue, error)QueueDeclare(ctx context.Context, ...) (Queue, error)QueueBind(name, key, exchange string, ...) errorQueueBind(ctx context.Context, ...) errorQueueUnbind(name, key, exchange string, ...) errorQueueUnbind(ctx context.Context, ...) errorQueueDelete(name string, ...) (int, error)QueueDelete(ctx context.Context, ...) (int, error)QueuePurge(name string, noWait bool) (int, error)QueuePurge(ctx context.Context, ...) (int, error)Consume(queue, consumer string, ...) (<-chan Delivery, error)Consume(ctx context.Context, ...) (<-chan Delivery, error)ConsumeWithContext(ctx, queue, consumer, ...) (<-chan Delivery, error)Consume)ExchangeDeclare(name, kind string, ...) errorExchangeDeclare(ctx context.Context, ...) errorExchangeBind(destination, key, source string, ...) errorExchangeBind(ctx context.Context, ...) errorExchangeUnbind(destination, key, source string, ...) errorExchangeUnbind(ctx context.Context, ...) errorExchangeDelete(name string, ...) errorExchangeDelete(ctx context.Context, ...) errorPublish(exchange, key string, ...) errorPublish(ctx context.Context, ...) errorPublishWithContext(ctx, exchange, key string, ...) errorPublish)PublishWithDeferredConfirm(exchange, key string, ...) (*DeferredConfirmation, error)PublishWithDeferredConfirm(ctx context.Context, ...) (*DeferredConfirmation, error)PublishWithDeferredConfirmWithContext(ctx, ...) (*DeferredConfirmation, error)Get(queue string, autoAck bool) (msg Delivery, ok bool, err error)Get(ctx context.Context, ...) (Delivery, bool, error)Ack/Nack/RejectConfirm(noWait bool) errorConfirm(ctx context.Context, noWait bool) errorFlow(active bool) errorFlow(ctx context.Context, active bool) errorRecover(requeue bool) errorRecover(ctx context.Context, requeue bool) errorTx/TxCommit/TxRollbackTx(ctx)/TxCommit(ctx)/TxRollback(ctx)Cancel(consumer string, noWait bool) errorCancel(ctx context.Context, consumer string, noWait bool) errorPublishandPublishWithDeferredConfirmare fire-and-forget (no server round-trip). The context gates entry only:Connection API changes
CloseDeadlineis removed. Callers usecontext.WithDeadlineorcontext.WithTimeoutinstead.Part 4: Library-Owned Non-Blocking Notification Channels
Goal: Eliminate the class of deadlocks caused by user-provided unbuffered notification channels.
Approach:
Notify*methods no longer accept a channel. They allocate and return a buffered, receive-only channel. All internal sends to notification channels useselect/default(non-blocking).Channel
Notify*signaturesNotifyConfirmis removed. It spawned a goroutine to fan out toack/nackchannels; users should useNotifyPublish()and branch onConfirmation.Ack.Internal channel allocations:
Same pattern for all other
Notify*methods.NotifyPublishusesmake(chan Confirmation, 32).Connection
Notify*signaturesNon-blocking sends throughout
Every send to a notification channel becomes:
Affected locations:
channel.godispatch():channelFlow,basicCancel,basicReturncaseschannel.goshutdown():ch.closes,ch.errorssendsconnection.goshutdown():c.closes,c.errorssendsconnection.godispatch0():connectionBlocked,connectionUnblockedsendsconfirms.goconfirm(): listener channel sendsWhat Gets Removed
recv func(*Channel, frame)fieldstate+recvLoopgoroutinerpc chan messagefieldpendingRPCone-shot channeltransition()methodch.stateassignmentConsumeWithContextConsume(ctx, ...)PublishWithContextPublish(ctx, ...)PublishWithDeferredConfirmWithContextPublishWithDeferredConfirm(ctx, ...)CloseDeadlineon ConnectionClose(ctx)NotifyConfirmNotifyPublish+Confirmation.AckNotify*What Gets Added
frames chan frame(cap 64) on ChannelrecvLoop()goroutine per channelpendingRPC chan message(cap 1) on Channelcontext.Contexton all RPC methodsdispatchNWhat Stays the Same
recvMethod,recvHeader,recvContent) — same transitions, same frame reassemblyconsumersbuffering mechanism — already properly decoupled via goroutinesdispatchNis now non-blockingsendOpen/sendClosed/sendon Channel — unchangedDeferredConfirmationtype andWait/WaitContext— unchangedconfirmsstruct) — unchangedConnection.call()and the connection-levelc.rpcchannel — connection handshake is fine as-isDeadlock Scenario Traces (Post-Fix)
RPC during connection blocked (Issue #253)
ch.QueueUnbind(ctx, ...).call()sends the method frame, createsreply := make(chan message, 1), setsch.pendingRPC = reply.call()hitscase <-ctx.Done().call()launchesgo ch.connection.closeChannel(ch, ...)and returnsctx.Err().closeChannelcallsch.shutdown(err)thenc.releaseChannel(ch).shutdownclosesch.frames(stopping the goroutine), non-blocking sends toch.errorsand notify channels.dispatchNdoesn't find the channel inc.channelsand callsdispatchClosed.Connection.Channel()with network down (Issue #225)conn.Channel(ctx).openChannelallocates a channel, callsch.open(ctx).ch.opencallsch.call(ctx, &channelOpen{}, &channelOpenOk{}).call()returnsctx.Err(). Channel is closed.openChannelcallsc.releaseChannel(ch). Channel is cleaned up.Unbuffered
NotifyCloseduring shutdownEliminated entirely. The library owns channel allocation with buffer 1. Non-blocking sends ensure
shutdown()never blocks even if the buffer is full.dispatch()blocking the reader goroutineEliminated.
dispatch()now runs on the per-channel goroutine, not the reader. The reader only does a non-blockingchannel.frames <- f. Within the channel goroutine, all sends are non-blocking: RPC responses go to a buffered one-shot channel, notify sends useselect/default, consumer sends use the existing buffered mechanism.Implementation Plan
Step 1:
channel.go— Channel struct and core infrastructurerecv func(*Channel, frame)andrpc chan messageframes chan frame,pendingRPC chan message,state func(*Channel, frame)newChannel: initialize new fields, launchrecvLooprecvLoop()methodshutdown(): closech.frames, non-blocking notification sendsrecvMethod,recvHeader,recvContent: assignch.state, removetransition()Step 2:
connection.go— Non-blockingdispatchNchannel.recv(channel, f)withselect { case channel.frames <- f: default: go closeChannel(...) }Step 3:
channel.go—call()rewritecall(ctx context.Context, req message, res ...message) errorreply := make(chan message, 1)ch.pendingRPCunderch.mselectonctx.Done(),ch.errors,replycloseChannelStep 4:
channel.go—dispatch()default casech.pendingRPCunderch.m.Lock()Step 5:
channel.goandconnection.go— Public API context changesctx context.Contextto every public RPC methodXxxWithContextduplicatesChannel.open,Connection.Channel,Connection.Close,Connection.UpdateSecretCloseDeadlineStep 6:
channel.goandconnection.go— Notify API overhaulNotify*methods to allocate and return<-chanchannelsNotifyConfirmdispatch(),shutdown(),confirms.goStep 7: Tests
client_test.go,connection_test.go,examples_test.go,example_client_test.gofor new APIintegration_test.go_examples/directoryNotifyConfirm,XxxWithContextvariants)TestCallContextTimeout— context expiry closes the channelTestDispatchNOverflow— frame buffer overflow closes the channelTestShutdownNonBlocking— shutdown completes with no listenersStep 8: Documentation
doc.gowith new API patterns and context semanticsAll reactions