Skip to content

fix(dispatcher): preserve retry failure guarantees - #96

Merged
yordis merged 3 commits into
mainfrom
yordis/fix-dispatcher-retry-guarantees
Apr 29, 2026
Merged

yordis merged 3 commits into
mainfrom
yordis/fix-dispatcher-retry-guarantees

Conversation

@yordis

@yordis yordis commented Apr 29, 2026

Copy link
Copy Markdown
Member
  • preserve tuple-based dispatch failures when retry attempts are exhausted so middleware and telemetry continue to observe failures consistently
  • lock in aggregate-stopped and remote-node-down recovery before changing the dispatcher failure boundary
  • keep retry and timeout documentation aligned with the guarantees the dispatcher actually provides

Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
@cursor

cursor Bot commented Apr 29, 2026 •

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches core command dispatch retry/error plumbing; mistakes could change failure semantics, break middleware callbacks, or mask dispatch errors under load or in distributed deployments.

Overview
Command dispatch now preserves tuple-based failures when retry attempts are exhausted, routing these through a shared respond_with_failure/… path so middleware after_failure hooks and [:commanded, :application, :dispatch, :stop] telemetry consistently observe an {:error, reason}.

Docs were updated to clarify that dispatch timeouts return error tuples (may be :aggregate_execution_timeout or :aggregate_execution_failed), that timeouts are not retried, and to document the retry_attempts budget and which failures are retried (wrong expected version, aggregate stopping after location, remote aggregate node down). New tests cover retry exhaustion telemetry/middleware behavior and a distributed scenario where dispatch retries after the aggregate’s remote node halts.

Reviewed by Cursor Bugbot for commit 3af552a. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai

coderabbitai Bot commented Apr 29, 2026 •

Copy link
Copy Markdown

Warning

Rate limit exceeded

@yordis has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 16 minutes and 59 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f697c823-9a9e-4f0b-9e5d-4f46f99536e6

📥 Commits

Reviewing files that changed from the base of the PR and between 3af552a and 106ffa0.

📒 Files selected for processing (7)
  • guides/explanations/commands.md
  • test/aggregates/support/aggregate_telemetry_support.ex
  • test/application/telemetry_test.exs
  • test/middleware/middleware_test.exs
  • test/opentelemetry/aggregate_test.exs
  • test/opentelemetry/dispatcher_retry_test.exs
  • test/support/retry_stop_once_aggregate.ex

Walkthrough

This PR introduces comprehensive documentation and testing for the retry_attempts dispatch option, which enables automatic retries on optimistic concurrency conflicts, aggregate shutdown, and remote node unavailability. The dispatcher error handling is refactored to centralize failure response building, and distributed test infrastructure is added to validate retry behavior in remote failure scenarios.

Changes

Cohort / File(s) Summary
Documentation Updates
guides/explanations/commands.md, lib/application.ex, lib/commanded/aggregates/aggregate.ex, lib/commanded/aggregates/execution_context.ex
Expanded documentation for timeout behavior, retry mechanism, and retry_attempts option (default value 10). Clarifies excluded scenarios (timeouts) and newly retryable failure modes (aggregate shutdown, remote node unavailability).
Dispatcher Refactoring
lib/commanded/commands/dispatcher.ex
Extracted failure response building into private helpers respond_with_failure/3 and respond_with_failure/4 to centralize error tuple construction and after_failure/2 invocation, improving code organization without altering control flow.
Distributed Test Support
test/commands/support/remote_node_down_aggregate.ex, test/commands/support/remote_node_down_command.ex, test/commands/support/remote_node_down_handler.ex, test/commands/support/remote_node_down_router.ex, test/support/distributed_test_helper.ex
New test modules and helper for distributed command dispatch scenarios: aggregate, command struct, handler implementation, router configuration, and distributed app startup utility.
Retry Exhaustion Tests
test/application/telemetry_test.exs, test/middleware/middleware_test.exs
Added concurrent dispatch tests exercising retry exhaustion (:too_many_attempts errors) with telemetry event validation and middleware failure callback verification.
Distributed Dispatch Regression Test
test/commands/distributed_dispatch_test.exs
New test validating command retry behavior when aggregate hosting node goes down, including remote aggregate setup, node monitoring, and assertion of retry execution on alternative nodes.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 When nodes fall and aggregates sleep,
Second chances the dispatcher shall keep,
Ten attempts by default to succeed,
Retrying on conflicts—exactly what we need!
Exhaustion and telemetry combined,
A robust retry dance, so well-designed! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(dispatcher): preserve retry failure guarantees' directly relates to the main changes: refactoring dispatcher failure handling to preserve tuple-based dispatch failures and ensuring middleware/telemetry observe consistent failure responses.
Description check ✅ Passed The description explains three key objectives that are all present in the changeset: preserving tuple-based dispatch failures, documenting aggregate-stopped/remote-node-down recovery, and aligning retry/timeout documentation.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch yordis/fix-dispatcher-retry-guarantees

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
Review rate limit: 0/1 reviews remaining, refill in 16 minutes and 59 seconds.

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

@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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/application/telemetry_test.exs (1)

108-175: ⚠️ Potential issue | 🟠 Major

Telemetry retry-exhaustion test is timing-sensitive and can fail intermittently.

This test combines a scheduler-dependent assertion (:too_many_attempts must appear) with a tight receive timeout at Line 165 (1_000), which increases CI flake risk.

⏱️ Minimal hardening for receive budget
-      assert_receive event, 1_000
+      assert_receive event, 5_000
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/application/telemetry_test.exs` around lines 108 - 175, The test's
receive timeout in collect_dispatch_events (currently assert_receive event,
1_000) is too tight and causes intermittent CI flakes; increase the timeout
(e.g., to 5_000 or make it a module attribute like `@receive_timeout`) and use
that constant in collect_dispatch_events so the loop has more budget to collect
[:commanded, :application, :dispatch, :stop] events; update any test helpers
(attach_telemetry / collect_dispatch_events) to reference the new timeout
constant.
🧹 Nitpick comments (2)
test/commands/support/remote_node_down_command.ex (1)

1-3: Consider validating the command fields up front.

aggregate_uuid drives routing, and sleep_in_ms is consumed unconditionally by the handler. Leaving them as nil defaults makes malformed commands fail later in the test flow instead of being rejected earlier.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/commands/support/remote_node_down_command.ex` around lines 1 - 3, The
RemoteNodeDownCommand struct allows nil for critical fields; enforce upfront
validation by either adding `@enforce_keys` [:aggregate_uuid, :sleep_in_ms] to the
Commanded.Commands.RemoteNodeDownCommand module or by providing a constructor
(e.g., new/1) that checks and raises on missing/invalid aggregate_uuid or
sleep_in_ms; ensure aggregate_uuid is present and sleep_in_ms is a non-negative
integer so malformed commands are rejected at creation rather than later in the
handler.
test/commands/distributed_dispatch_test.exs (1)

59-79: Ensure node monitor cleanup runs even on assertion failures.

monitor_node(remote_node, false) at Line 78 is skipped if earlier asserts fail. Wrap monitored logic in try/after to avoid leaked monitor state/messages.

♻️ Suggested refactor
-    :erlang.monitor_node(remote_node, true)
-
-    dispatch_task =
-      Task.async(fn ->
-        RemoteNodeDownRouter.dispatch(command,
-          application: DistributedApp,
-          retry_attempts: 1,
-          timeout: 5_000
-        )
-      end)
-
-    assert_receive {:remote_command_started, ^aggregate_uuid, ^remote_node}, 5_000
-
-    :rpc.cast(remote_node, :erlang, :halt, [])
-
-    assert_receive {:nodedown, ^remote_node}, 5_000
-    assert_receive {:remote_command_started, ^aggregate_uuid, ^current_node}, 5_000
-    assert :ok = Task.await(dispatch_task, 6_000)
-
-    :erlang.monitor_node(remote_node, false)
+    :erlang.monitor_node(remote_node, true)
+
+    try do
+      dispatch_task =
+        Task.async(fn ->
+          RemoteNodeDownRouter.dispatch(command,
+            application: DistributedApp,
+            retry_attempts: 1,
+            timeout: 5_000
+          )
+        end)
+
+      assert_receive {:remote_command_started, ^aggregate_uuid, ^remote_node}, 5_000
+
+      :rpc.cast(remote_node, :erlang, :halt, [])
+
+      assert_receive {:nodedown, ^remote_node}, 5_000
+      assert_receive {:remote_command_started, ^aggregate_uuid, ^current_node}, 5_000
+      assert :ok = Task.await(dispatch_task, 6_000)
+    after
+      :erlang.monitor_node(remote_node, false)
+    end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/commands/distributed_dispatch_test.exs` around lines 59 - 79, The test
sets up an Erlang node monitor with :erlang.monitor_node(remote_node, true) but
calls :erlang.monitor_node(remote_node, false) only at the end, which will be
skipped on assertion failures; wrap the monitored section (from the
monitor_node(true) call through Task.await) in a try ... after block so that
:erlang.monitor_node(remote_node, false) always runs; locate the block around
:erlang.monitor_node/2, Task.async (the dispatch_task using
RemoteNodeDownRouter.dispatch), assert_receive checks, :rpc.cast(remote_node,
:erlang, :halt, []), and Task.await and put the cleanup call in the after clause
to ensure monitors/messages are cleaned up even on test failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@guides/explanations/commands.md`:
- Line 194: Rewrite the fragmented timeout description into a single clear
sentence: combine the two fragments so it reads something like "A command
handler has a default timeout of 5 seconds, the same as GenServer.call/3, and
must handle the command within this period; otherwise dispatch fails with an
error tuple." Update the sentence that mentions GenServer.call/3 and the
5-second default so it's one fluid line mentioning the timeout, the
GenServer.call/3 comparison, and the failure behavior.

In `@test/middleware/middleware_test.exs`:
- Around line 184-207: Test is flaky because it assumes at least one dispatch
will hit retry exhaustion; make it deterministic by polling
dispatch_concurrently until the expected {:ok, {:error, :too_many_attempts}}
appears (or timeout/fail the test after a short deadline). Update the test
"should execute middleware failure callback when dispatcher retries are
exhausted" to loop calling dispatch_concurrently (or wrap it) and check for
Enum.any?(results, &match?({:ok, {:error, :too_many_attempts}}, &1)) with a
small sleep between attempts and a total timeout (e.g., 1-2s), then assert the
final condition; keep the broader assertion that all results are either :ok or
{:error, :too_many_attempts} using the existing Enum.all? check. Ensure you
reference dispatch_concurrently and RetryExhaustionRouter.dispatch in the
updated test.

---

Outside diff comments:
In `@test/application/telemetry_test.exs`:
- Around line 108-175: The test's receive timeout in collect_dispatch_events
(currently assert_receive event, 1_000) is too tight and causes intermittent CI
flakes; increase the timeout (e.g., to 5_000 or make it a module attribute like
`@receive_timeout`) and use that constant in collect_dispatch_events so the loop
has more budget to collect [:commanded, :application, :dispatch, :stop] events;
update any test helpers (attach_telemetry / collect_dispatch_events) to
reference the new timeout constant.

---

Nitpick comments:
In `@test/commands/distributed_dispatch_test.exs`:
- Around line 59-79: The test sets up an Erlang node monitor with
:erlang.monitor_node(remote_node, true) but calls
:erlang.monitor_node(remote_node, false) only at the end, which will be skipped
on assertion failures; wrap the monitored section (from the monitor_node(true)
call through Task.await) in a try ... after block so that
:erlang.monitor_node(remote_node, false) always runs; locate the block around
:erlang.monitor_node/2, Task.async (the dispatch_task using
RemoteNodeDownRouter.dispatch), assert_receive checks, :rpc.cast(remote_node,
:erlang, :halt, []), and Task.await and put the cleanup call in the after clause
to ensure monitors/messages are cleaned up even on test failures.

In `@test/commands/support/remote_node_down_command.ex`:
- Around line 1-3: The RemoteNodeDownCommand struct allows nil for critical
fields; enforce upfront validation by either adding `@enforce_keys`
[:aggregate_uuid, :sleep_in_ms] to the Commanded.Commands.RemoteNodeDownCommand
module or by providing a constructor (e.g., new/1) that checks and raises on
missing/invalid aggregate_uuid or sleep_in_ms; ensure aggregate_uuid is present
and sleep_in_ms is a non-negative integer so malformed commands are rejected at
creation rather than later in the handler.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 42955f18-d038-4cc8-8a51-19703f3078f9

📥 Commits

Reviewing files that changed from the base of the PR and between 8198884 and 3af552a.

📒 Files selected for processing (13)
  • guides/explanations/commands.md
  • lib/application.ex
  • lib/commanded/aggregates/aggregate.ex
  • lib/commanded/aggregates/execution_context.ex
  • lib/commanded/commands/dispatcher.ex
  • test/application/telemetry_test.exs
  • test/commands/distributed_dispatch_test.exs
  • test/commands/support/remote_node_down_aggregate.ex
  • test/commands/support/remote_node_down_command.ex
  • test/commands/support/remote_node_down_handler.ex
  • test/commands/support/remote_node_down_router.ex
  • test/middleware/middleware_test.exs
  • test/support/distributed_test_helper.ex

Comment thread guides/explanations/commands.md Outdated
Comment thread test/middleware/middleware_test.exs Outdated
yordis added 2 commits April 28, 2026 21:42
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
@yordis
yordis merged commit 7a07792 into main Apr 29, 2026
3 checks passed
@yordis
yordis deleted the yordis/fix-dispatcher-retry-guarantees branch April 29, 2026 02:03
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.

1 participant