Skip to content

fix(publisher): improve PublishDelayed to correctly handle message delays and add tests#15

Merged
KARTIKrocks merged 1 commit into
mainfrom
fix/PublishDelayed-not-delaying-delivery
Jul 2, 2026
Merged

fix(publisher): improve PublishDelayed to correctly handle message delays and add tests#15
KARTIKrocks merged 1 commit into
mainfrom
fix/PublishDelayed-not-delaying-delivery

Conversation

@KARTIKrocks

@KARTIKrocks KARTIKrocks commented Jul 2, 2026

Copy link
Copy Markdown
Owner

Summary

Added

  • DelayLadder() returns the fixed set of delays supported by PublishDelayed
    (1s, 5s, 10s, 30s, 1m, 5m, 15m, 30m, 1h). A requested delay is rounded up
    to the nearest rung so a message is never delivered early, keeping the number
    of holding queues on the broker bounded.
  • ErrDelayTooLong sentinel error, returned by PublishDelayed when the
    requested delay exceeds the largest ladder rung.

Fixes #

  • PublishDelayed now actually delays delivery. Previously it set a
    per-message TTL and published straight to the destination, so the message was
    either consumed immediately (no delay) or, if unconsumed, expired and was
    silently dropped — the documented "TTL and dead letter exchange" mechanism was
    half-implemented (the DLX was missing). It now publishes into a dedicated
    holding queue whose queue-level TTL equals the delay and whose dead-letter
    exchange/routing key point at the real destination; the message is
    dead-lettered onward when its TTL expires. Using a queue-level TTL (one holding
    queue per delay rung) avoids the head-of-line blocking that per-message TTL
    suffers. Idle holding queues are auto-deleted by the broker (x-expires).
    Verified against RabbitMQ 3.13 and 4.3; requires no broker plugin.
  • PublishDelayed no longer mutates the caller's *Message (it previously
    rewrote the message's Expiration).

Changes

  • PublishDelayed delay semantics. A requested delay is now rounded up to
    the nearest DelayLadder() rung (minimum 1s); a delay <= 0 publishes
    immediately, and a delay above the largest rung returns ErrDelayTooLong.
    Delivery timing is best-effort (at or shortly after the target, never before),
    suitable for retry backoff rather than precise scheduling.

Motivation

Checklist

  • fmt, vet, lint, test, build passes (make all)
  • New code has tests where appropriate
  • Breaking changes are documented

Summary by CodeRabbit

  • New Features
    • Delayed messages now use supported delay intervals with stable holding queues, improving timing reliability.
    • Added a way to view the available delay intervals.
    • Added a clear error when a requested delay is longer than supported.
  • Bug Fixes
    • Delayed delivery now behaves more predictably: short or zero delays publish immediately, and longer delays round up to the nearest supported interval.
    • Message delivery no longer depends on changing the original message expiration.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

PublishDelayed is reworked to delay delivery through deterministic, TTL-configured holding queues with dead-letter routing to the real destination, rather than mutating message expiration. Adds DelayLadder(), ErrDelayTooLong, delay-snapping logic, holding-queue naming/declaration, unit tests, updated integration test, and changelog entry.

Changes

Delay Ladder and Holding-Queue Publishing

Layer / File(s) Summary
Sentinel error
rabbitmq.go
Adds exported ErrDelayTooLong sentinel error for delays exceeding the maximum supported rung.
Delay ladder and snapping
publisher.go
Adds hash/hex imports, fixed delayLadder, exported DelayLadder() (returns a defensive copy), and snapDelay to round delays up to the nearest rung.
Holding queue naming and declaration
publisher.go
Adds delayQueueName (SHA-256-based deterministic naming) and declareDelayQueue (idempotent queue declaration with TTL, DLX/routing key, and x-expires).
PublishDelayed rework
publisher.go
Replaces TTL-on-message publish with snap/validate, ensure holding queue, and publish-to-holding-queue logic relying on dead-lettering.
Tests and changelog
publisher_test.go, integration_test.go, CHANGELOG.md
Adds unit tests for snapDelay, delayQueueName, and DelayLadder; updates integration test to assert delivery timing instead of Expiration field; documents the 0.4.0 behavior changes.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
    participant Caller
    participant Publisher as Publisher (PublishDelayed)
    participant HoldingQueue as Holding Queue (TTL/DLX)
    participant Destination as Real Destination Queue

    Caller->>Publisher: PublishDelayed(msg, delay)
    Publisher->>Publisher: snapDelay(delay) -> rung or ErrDelayTooLong
    Publisher->>Publisher: delayQueueName(exchange, routingKey, rung)
    Publisher->>HoldingQueue: declareDelayQueue(TTL, DLX, x-expires)
    Publisher->>HoldingQueue: Publish message (default exchange)
    Note over HoldingQueue: Message expires after TTL
    HoldingQueue-->>Destination: Dead-lettered to real destination
    Destination-->>Caller: Message delivered after ~rung duration
Loading

Estimated code review effort: 3 (Moderate) | ~25 minutes

Poem

A rabbit hops not straight, but waits in a queue so neat,
TTL ticks, DLX kicks, delivering right on beat. 🐇
No more mutating the message's face,
Just holding queues, snapped to their place.
Ladder rungs climb, delays now sublime! ⏱️

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is specific and accurately summarizes the main change to PublishDelayed, plus the added tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description follows the required template and covers summary, motivation, changes, and checklist, with only an incomplete issue reference.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/PublishDelayed-not-delaying-delivery

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 24.39024% with 31 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
publisher.go 24.39% 31 Missing ⚠️

📢 Thoughts on this report? Let us know!

@KARTIKrocks
KARTIKrocks merged commit 01a326b into main Jul 2, 2026
10 of 11 checks passed
@KARTIKrocks
KARTIKrocks deleted the fix/PublishDelayed-not-delaying-delivery branch July 2, 2026 05:27

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@integration_test.go`:
- Around line 1339-1342: The timing assertion in the integration test is too
strict and can flake under CI jitter; adjust the check around the elapsed time
calculation so it uses a wider lower-bound margin or adds an upper-bound
assertion as well. Update the test logic near the elapsed/Since(start)
validation to keep catching near-instant delivery regressions without relying on
a 900ms floor that is too close to the intended ~1s delay.

In `@publisher_test.go`:
- Around line 9-34: The table-driven checks in TestSnapDelay_Ladder should be
converted to named t.Run subtests so each delay case is isolated and easier to
triage. Update the loop in TestSnapDelay_Ladder to wrap each test case in t.Run
using a descriptive case name, and keep the existing snapDelay assertions inside
the subtest so failures are reported per input and can later support
t.Parallel() if needed.
- Around line 36-47: The delayQueueName test is missing coverage for exchange
collisions, so a regression that ignores or merges the exchange input could slip
through. Update TestDelayQueueName_Deterministic to add a case that keeps the
routing key and delay the same but uses two different exchanges, and assert the
returned names differ. Use delayQueueName in publisher_test.go as the anchor for
the new check.

In `@publisher.go`:
- Around line 328-335: Add a brief godoc caveat to delayQueueName explaining
that the exchange/routingKey hash is intentionally truncated to 64 bits and that
a rare collision would cause QueueDeclare to fail with PRECONDITION_FAILED
rather than misroute messages; keep the implementation unchanged and make the
accepted residual collision risk explicit near delayQueueName and
PublishDelayed.
- Around line 363-374: PublishDelayed is doing a synchronous QueueDeclare on
every call via declareDelayQueue, which adds an extra broker round-trip for hot
retry/backoff paths. Update PublishDelayed and declareDelayQueue to cache queue
declarations by queueName with a lastDeclaredAt timestamp, skip redeclaring when
the queue was declared recently, and only refresh periodically often enough to
preserve the x-expires reset behavior while reducing repeated RPCs.
- Around line 382-416: Document the new AMQP permission requirement introduced
by PublishDelayed: because declareDelayQueue now calls QueueDeclare for the
rabbitwrap.delay.* queues, deployments need configure/declare access for those
queue names. Update the CHANGELOG or migration notes to call out this upgrade
impact and reference PublishDelayed and declareDelayQueue so operators know
restrictive allow-lists must be widened.
- Around line 355-380: PublishDelayed is only partially honoring ctx because
declareDelayQueue still calls QueueDeclare without any cancellation path, so a
slow broker can block past the caller’s deadline before PublishToExchange runs.
Update PublishDelayed and/or declareDelayQueue to make the queue-declaration
step cancellable using ctx (or stop treating it as fully context-aware), and if
full cancellation isn’t possible with amqp091-go, add godoc on PublishDelayed
explaining the partial context behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e719d8e4-aef9-4914-8569-d0d058ebe742

📥 Commits

Reviewing files that changed from the base of the PR and between c4575ea and beee0e8.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • integration_test.go
  • publisher.go
  • publisher_test.go
  • rabbitmq.go

Comment thread integration_test.go
Comment on lines +1339 to +1342
elapsed := time.Since(start)
if elapsed < 900*time.Millisecond {
t.Errorf("message delivered after %s, expected it to be delayed ~1s", elapsed)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Tight lower-bound threshold risks flakiness.

The 900ms floor against a targeted ~1s delay leaves only a 100ms margin for scheduling jitter, broker/network latency, and CI load — a common source of flaky integration timing assertions. Consider widening the margin (e.g. 700–800ms) or asserting against an upper bound too, so a regression that skips rounding entirely (delivering near-instantly) is still reliably caught without tightening the lower bound so much that healthy runs occasionally fail.

As per path instructions, integration tests should be "free of races, sleeps, and flaky timing assumptions."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@integration_test.go` around lines 1339 - 1342, The timing assertion in the
integration test is too strict and can flake under CI jitter; adjust the check
around the elapsed time calculation so it uses a wider lower-bound margin or
adds an upper-bound assertion as well. Update the test logic near the
elapsed/Since(start) validation to keep catching near-instant delivery
regressions without relying on a 900ms floor that is too close to the intended
~1s delay.

Source: Path instructions

Comment thread publisher_test.go
Comment on lines +9 to +34
func TestSnapDelay_Ladder(t *testing.T) {
tests := []struct {
delay time.Duration
want time.Duration
}{
{50 * time.Millisecond, 1 * time.Second},
{1 * time.Second, 1 * time.Second},
{2 * time.Second, 5 * time.Second},
{45 * time.Second, 1 * time.Minute},
{1 * time.Hour, 1 * time.Hour},
}
for _, tt := range tests {
got, err := snapDelay(tt.delay)
if err != nil {
t.Errorf("snapDelay(%s) unexpected error: %v", tt.delay, err)
continue
}
if got != tt.want {
t.Errorf("snapDelay(%s) = %s, want %s", tt.delay, got, tt.want)
}
}

if _, err := snapDelay(2 * time.Hour); !errors.Is(err, ErrDelayTooLong) {
t.Errorf("snapDelay(2h) error = %v, want ErrDelayTooLong", err)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Table-driven test doesn't use t.Run subtests.

Each case is exercised inline without t.Run(...), so a single failing case fails the whole TestSnapDelay_Ladder without isolating which input failed by name (the error message includes the input, mitigating this somewhat). Not blocking, but subtests would improve failure triage and enable t.Parallel() per case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@publisher_test.go` around lines 9 - 34, The table-driven checks in
TestSnapDelay_Ladder should be converted to named t.Run subtests so each delay
case is isolated and easier to triage. Update the loop in TestSnapDelay_Ladder
to wrap each test case in t.Run using a descriptive case name, and keep the
existing snapDelay assertions inside the subtest so failures are reported per
input and can later support t.Parallel() if needed.

Comment thread publisher_test.go
Comment on lines +36 to +47
func TestDelayQueueName_Deterministic(t *testing.T) {
if a, b := delayQueueName("ex", "key", time.Second), delayQueueName("ex", "key", time.Second); a != b {
t.Errorf("delayQueueName not deterministic: %q != %q", a, b)
}
// Different destinations and delays must not collide.
if delayQueueName("ex", "key", time.Second) == delayQueueName("ex", "key2", time.Second) {
t.Error("delayQueueName collided on different routing key")
}
if delayQueueName("ex", "key", time.Second) == delayQueueName("ex", "key", 5*time.Second) {
t.Error("delayQueueName collided on different delay")
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing exchange-collision coverage.

delayQueueName hashes exchange + routingKey, but the test only varies routing key and delay — it never checks that different exchanges (same routing key) produce distinct names. Given the naming scheme derives its uniqueness solely from this hash, a regression collapsing the two inputs (e.g. dropping the separator or the exchange) wouldn't be caught.

Suggested addition
 	if delayQueueName("ex", "key", time.Second) == delayQueueName("ex", "key", 5*time.Second) {
 		t.Error("delayQueueName collided on different delay")
 	}
+	if delayQueueName("ex", "key", time.Second) == delayQueueName("ex2", "key", time.Second) {
+		t.Error("delayQueueName collided on different exchange")
+	}
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func TestDelayQueueName_Deterministic(t *testing.T) {
if a, b := delayQueueName("ex", "key", time.Second), delayQueueName("ex", "key", time.Second); a != b {
t.Errorf("delayQueueName not deterministic: %q != %q", a, b)
}
// Different destinations and delays must not collide.
if delayQueueName("ex", "key", time.Second) == delayQueueName("ex", "key2", time.Second) {
t.Error("delayQueueName collided on different routing key")
}
if delayQueueName("ex", "key", time.Second) == delayQueueName("ex", "key", 5*time.Second) {
t.Error("delayQueueName collided on different delay")
}
}
func TestDelayQueueName_Deterministic(t *testing.T) {
if a, b := delayQueueName("ex", "key", time.Second), delayQueueName("ex", "key", time.Second); a != b {
t.Errorf("delayQueueName not deterministic: %q != %q", a, b)
}
// Different destinations and delays must not collide.
if delayQueueName("ex", "key", time.Second) == delayQueueName("ex", "key2", time.Second) {
t.Error("delayQueueName collided on different routing key")
}
if delayQueueName("ex", "key", time.Second) == delayQueueName("ex", "key", 5*time.Second) {
t.Error("delayQueueName collided on different delay")
}
if delayQueueName("ex", "key", time.Second) == delayQueueName("ex2", "key", time.Second) {
t.Error("delayQueueName collided on different exchange")
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@publisher_test.go` around lines 36 - 47, The delayQueueName test is missing
coverage for exchange collisions, so a regression that ignores or merges the
exchange input could slip through. Update TestDelayQueueName_Deterministic to
add a case that keeps the routing key and delay the same but uses two different
exchanges, and assert the returned names differ. Use delayQueueName in
publisher_test.go as the anchor for the new check.

Comment thread publisher.go
Comment on lines +328 to +335
// delayQueueName builds the deterministic name of the holding queue for a given
// destination and delay. The destination is hashed so the name stays within
// length limits and is safe regardless of the characters in the exchange or
// routing key, while remaining stable across calls (so the queue is reused).
func delayQueueName(exchange, routingKey string, delay time.Duration) string {
h := sha256.Sum256([]byte(exchange + "\x00" + routingKey))
return fmt.Sprintf("rabbitwrap.delay.%d.%s", delay.Milliseconds(), hex.EncodeToString(h[:8]))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Truncated hash leaves a small residual collision surface.

Truncating SHA-256 to 8 bytes (64 bits) for (exchange, routingKey) naming is fine for practically all deployments, but if two distinct destinations ever collide, the resulting QueueDeclare for the second destination will fail with a broker PRECONDITION_FAILED (mismatched x-dead-letter-routing-key/x-dead-letter-exchange), silently breaking PublishDelayed for that destination rather than misdelivering. Worth a one-line godoc caveat, or just noting this residual risk is accepted.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@publisher.go` around lines 328 - 335, Add a brief godoc caveat to
delayQueueName explaining that the exchange/routingKey hash is intentionally
truncated to 64 bits and that a rare collision would cause QueueDeclare to fail
with PRECONDITION_FAILED rather than misroute messages; keep the implementation
unchanged and make the accepted residual collision risk explicit near
delayQueueName and PublishDelayed.

Comment thread publisher.go
Comment on lines 355 to +380
func (p *Publisher) PublishDelayed(ctx context.Context, msg *Message, delay time.Duration) error {
if msg == nil {
return ErrNilMessage
}
msg.WithTTL(delay)
return p.Publish(ctx, msg)
if delay <= 0 {
return p.Publish(ctx, msg)
}

snapped, err := snapDelay(delay)
if err != nil {
return err
}

exchange := p.config.Exchange
routingKey := p.config.RoutingKey
queueName := delayQueueName(exchange, routingKey, snapped)

if err := p.declareDelayQueue(queueName, exchange, routingKey, snapped); err != nil {
return err
}

// Publish into the holding queue via the default exchange, which routes by
// queue name. On TTL expiry the broker dead-letters the message to the real
// destination configured on the queue.
return p.PublishToExchange(ctx, "", queueName, msg)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does amqp091-go's Channel.QueueDeclare support context cancellation or a context-aware variant?

💡 Result:

No, the amqp091-go library's Channel.QueueDeclare method does not support context cancellation, nor is there a context-aware variant (such as QueueDeclareWithContext) [1][2]. The library maintains a synchronous RPC-like design for its channel operations [3]. While there have been ongoing discussions and proposals within the project to refactor the internal state machine to support context-based timeouts and cancellation for all RPC operations, these changes have not been implemented for QueueDeclare [4]. Attempts to add context support to various methods in the library have faced significant architectural challenges [5][6][7]. Specifically, because the AMQP protocol requires synchronous, serialized RPCs on a channel, cancelling a blocked operation like QueueDeclare would leave the channel in an indeterminate state that cannot be safely reused, typically requiring the entire channel to be closed [4]. Consequently, the current API remains synchronous, and developers are advised that even for methods labeled *WithContext (like PublishWithContext), the context is often not propagated to the underlying network call in a way that truly cancels the server-side operation [6][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the relevant symbols and surrounding docs.
rg -n "func \\(p \\*Publisher\\) PublishDelayed|declareDelayQueue|QueueDeclare|PublishToExchange|PublishWithContext|Publish\\(" publisher.go
printf '\n--- publisher.go excerpt ---\n'
sed -n '1,260p' publisher.go
printf '\n--- publisher.go excerpt 2 ---\n'
sed -n '260,460p' publisher.go

Repository: KARTIKrocks/rabbitwrap

Length of output: 15903


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check how other public methods describe ctx behavior and whether the delay path is documented.
rg -n "ctx|context" publisher.go README.md *.md

Repository: KARTIKrocks/rabbitwrap

Length of output: 4101


PublishDelayed is only partially context-aware.

ctx reaches the final publish, but declareDelayQueue still blocks on QueueDeclare, which has no context-aware variant in amqp091-go. A slow broker can therefore ignore the caller’s deadline/cancel before the delayed publish even starts. Either make that step cancellable or document the partial context behavior in the godoc.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@publisher.go` around lines 355 - 380, PublishDelayed is only partially
honoring ctx because declareDelayQueue still calls QueueDeclare without any
cancellation path, so a slow broker can block past the caller’s deadline before
PublishToExchange runs. Update PublishDelayed and/or declareDelayQueue to make
the queue-declaration step cancellable using ctx (or stop treating it as fully
context-aware), and if full cancellation isn’t possible with amqp091-go, add
godoc on PublishDelayed explaining the partial context behavior.

Comment thread publisher.go
Comment on lines +363 to +374
snapped, err := snapDelay(delay)
if err != nil {
return err
}

exchange := p.config.Exchange
routingKey := p.config.RoutingKey
queueName := delayQueueName(exchange, routingKey, snapped)

if err := p.declareDelayQueue(queueName, exchange, routingKey, snapped); err != nil {
return err
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Synchronous QueueDeclare on every delayed publish.

Every call to PublishDelayed now does a broker round-trip via declareDelayQueue before publishing, whereas the previous TTL-on-message approach needed only the publish itself. This is explained as intentional (redeclaring resets x-expires), but for a hot retry/backoff path this doubles RPCs per delayed message.

Consider caching (queueName -> lastDeclaredAt) and skipping redeclare if it happened recently (e.g., within some fraction of x-expires), still redeclaring periodically to keep the idle-expiry lease alive. This preserves the reset semantics while cutting broker round-trips for bursty callers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@publisher.go` around lines 363 - 374, PublishDelayed is doing a synchronous
QueueDeclare on every call via declareDelayQueue, which adds an extra broker
round-trip for hot retry/backoff paths. Update PublishDelayed and
declareDelayQueue to cache queue declarations by queueName with a lastDeclaredAt
timestamp, skip redeclaring when the queue was declared recently, and only
refresh periodically often enough to preserve the x-expires reset behavior while
reducing repeated RPCs.

Comment thread publisher.go
Comment on lines +382 to +416
// declareDelayQueue idempotently declares the holding queue for PublishDelayed.
// Re-declaring on every publish also resets the queue's idle-expiry timer, so an
// actively used delay queue stays alive while unused ones are reaped.
func (p *Publisher) declareDelayQueue(name, dlExchange, dlRoutingKey string, delay time.Duration) error {
p.mu.RLock()
if p.closed {
p.mu.RUnlock()
return ErrShuttingDown
}
ch := p.channel
p.mu.RUnlock()

if ch == nil {
return ErrChannelClosed
}

// x-expires must comfortably exceed the message TTL, otherwise the broker
// could delete the queue (and any in-flight message) before it is
// dead-lettered.
expires := delay * 2
if minExpires := delay + 30*time.Second; expires < minExpires {
expires = minExpires
}

args := amqp.Table{
"x-message-ttl": delay.Milliseconds(),
"x-dead-letter-exchange": dlExchange,
"x-dead-letter-routing-key": dlRoutingKey,
"x-expires": expires.Milliseconds(),
}

if _, err := ch.ch.QueueDeclare(name, true /*durable*/, false /*autoDelete*/, false /*exclusive*/, false /*noWait*/, args); err != nil {
return fmt.Errorf("%w: declare delay queue: %v", ErrPublishFailed, err)
}
return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

New broker permission requirement on upgrade.

Unlike the previous TTL-on-message implementation, PublishDelayed now requires the connecting user to have configure/declare permission on queue names matching rabbitwrap.delay.*. Existing deployments with restrictive AMQP permission grants (queue name allow-lists) will start failing PublishDelayed calls after upgrading until permissions are widened. Worth calling out explicitly in the CHANGELOG/migration notes if not already covered there.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@publisher.go` around lines 382 - 416, Document the new AMQP permission
requirement introduced by PublishDelayed: because declareDelayQueue now calls
QueueDeclare for the rabbitwrap.delay.* queues, deployments need
configure/declare access for those queue names. Update the CHANGELOG or
migration notes to call out this upgrade impact and reference PublishDelayed and
declareDelayQueue so operators know restrictive allow-lists must be widened.

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