Skip to content

feat: add batch support to Ecto projections - #30

Merged
yordis merged 1 commit into
mainfrom
yordis/batch-support-ecto
Nov 19, 2025
Merged

yordis merged 1 commit into
mainfrom
yordis/batch-support-ecto

Conversation

@yordis

@yordis yordis commented Nov 18, 2025 •

Copy link
Copy Markdown
Member

Note

Introduce batch event projection via project_batch/1 with watermark idempotency, after_update_batch/2, compile-time validations, updated docs, and comprehensive tests.

  • Core (lib/commanded/projections/ecto.ex)
    • Batch processing: Add project_batch/1, handle_batch/1, and update_projection_batch/2 with lock-then-watermark idempotency and single-transaction execution.
    • Helpers/flow: New internals to lock/filter unseen events, update ProjectionVersion, and merge user Ecto.Multi ops; default after_update_batch/2 provided.
    • Validations: Enforce mutual exclusivity of :batch_size and :concurrency; disallow dynamic :schema_prefix when batching.
    • Callbacks: Add after_update_batch/2 (post-commit side-effects) and document transaction semantics; keep existing after_update/3 behavior.
    • Schema prefix: Adjust generated schema_prefix/1 to return configured string or 1-arity function result; maintain 2-arity behavior.
  • Docs (guides/howtos/building-read-models-with-ecto.md)
    • Add subscription options (start_from, subscribe_to, concurrency) and top-level equivalents; note concurrency vs batch mutual exclusion.
    • Document project_batch/1 usage and after_update_batch/2 with post-commit semantics; clarify after_update/3 semantics.
    • Update config example to config :commanded, Commanded.Projections.Ecto, schema_prefix: ....
  • Tooling
    • Formatter: add project_batch: 1 to locals_without_parens.
  • Tests
    • Add integration and unit tests for batching: idempotency/watermark, mixed events, partial/duplicate batches, error propagation, and callback behavior.
    • Add tests validating schema_prefix behavior for 1-arity/static/dynamic configs.

Written by Cursor Bugbot for commit cebac78. This will update automatically on new commits. Configure here.

@coderabbitai

coderabbitai Bot commented Nov 18, 2025 •

Copy link
Copy Markdown

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Adds batch-processing support to Ecto projections: a project_batch/1 macro, transactional update_projection_batch/2 paths with watermark-based idempotency, batching validations (including schema_prefix compatibility), new after_update_batch/2 callback and schema_prefix arities, docs updates, and new tests for batch and schema_prefix behaviors.

Changes

Cohort / File(s) Summary
Formatter configuration
".formatter.exs"
Adds project_batch: 1 to locals_without_parens and adjusts trailing comma formatting.
Core batch processing implementation
lib/commanded/projections/ecto.ex
Introduces batch support: project_batch/1 macro, update_projection_batch/2 implementations (empty, valid, invalid cases), validations (validate_mutual_exclusivity/1, validate_batch_schema_prefix_compatibility/2), watermark/idempotency and locking/filtering helpers, batch-related using-time codegen (__define_update_projection_batch__/2), expanded schema_prefix dispatch (1- and 2-arity), new after_update_batch/2 callback and defoverridable updates, and compile-time checks prohibiting dynamic schema_prefix with batch_size.
Documentation
guides/howtos/building-read-models-with-ecto.md
Adds subscription and batching docs (subscription_opts, start_from, subscribe_to, concurrency), documents project_batch usage and examples, details transaction semantics for after_update/after_update_batch, and expands schema_prefix configuration and migration guidance.
Integration and unit tests
test/projections/commanded_batch_integration_test.exs, test/projections/ecto_projection_batch_test.exs, test/projections/projection_version_schema_prefix_test.exs
Adds integration and unit tests for batch processing: batch idempotency/watermark behavior, multiple batch scenarios (partial/seen/error), after_update_batch callback behaviors (success, error, raise), comprehensive schema_prefix/1 tests (including duplicated describe block), and projector modules exercising the new APIs.

Sequence Diagram(s)

sequenceDiagram
    participant Caller as Caller
    participant Projector as Projector (project_batch / update_projection_batch)
    participant Multi as Ecto.Multi
    participant Repo as Repo (DB)
    participant Callback as after_update_batch (post-commit)

    Caller->>Projector: update_projection_batch(events, multi_fn)
    Projector->>Projector: validate batch format & options
    Projector->>Projector: filter unseen events (watermark)
    Projector->>Multi: build changes via multi_fn
    Projector->>Multi: include watermark update
    Multi->>Repo: run transaction
    Repo-->>Multi: success / error
    Multi-->>Projector: transaction result
    alt success
        Projector->>Callback: invoke after_update_batch(events, changes) (post-commit)
        Callback-->>Projector: :ok | {:error, reason}
        Projector-->>Caller: :ok
    else error
        Projector-->>Caller: {:error, reason}
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Pay special attention to:
    • lib/commanded/projections/ecto.ex macro generation and expanded using-time codegen.
    • Transaction composition with Ecto.Multi, watermark/idempotency ordering, and locking behavior.
    • Schema_prefix arity dispatch and compatibility checks with batching.
    • New and duplicated tests in test/projections/* for correctness and flakiness.

Possibly related PRs

  • feat: add Commanded.Projections.Ecto #27 — modifies the same lib/commanded/projections/ecto.ex area and is directly related to initial projector implementation changes that this batch feature extends.

Poem

🐇 I chew through batches, carrots in a row,
Watermarks set where past events go.
Transactions tuck seeds safe in the ground,
After-update hops once the commit is sound. 🎩✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.65% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: add batch support to Ecto projections' clearly and concisely summarizes the main feature addition in the changeset.
Description check ✅ Passed The pull request description is comprehensive and directly related to the changeset, detailing batch processing implementation, validations, documentation updates, and tests.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch yordis/batch-support-ecto

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 and usage tips.

Comment thread lib/commanded/projections/ecto.ex

@cursor cursor 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.

Bug: Dynamic Schema Prefix Callback Silent Failure

When schema_prefix is configured as a 1-arity or 2-arity function, the generated schema_prefix/1 callback always returns nil instead of calling the configured function. This breaks the documented callback pattern where users can define schema_prefix/1 to dynamically determine schema prefixes. When a function-based prefix is used, schema_prefix(event) should invoke the configured function, not return nil.

lib/commanded/projections/ecto.ex#L494-L502

https://github.com/straw-hat-team/commanded/blob/91ccb24f6c516036b36dfaf975d2e9144271979c/lib/commanded/projections/ecto.ex#L494-L502

Fix in Cursor Fix in Web


Comment thread lib/commanded/projections/ecto.ex Outdated
@yordis
yordis force-pushed the yordis/batch-support-ecto branch from 91ccb24 to a538e90 Compare November 18, 2025 07:51

@cursor cursor 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.

Bug: Configured function returns `nil` unexpectedly.

When schema_prefix is configured as a static binary (string), the generated 1-arity schema_prefix/1 function incorrectly returns nil instead of the configured prefix string. The 2-arity version correctly returns the string. This inconsistency violates the callback contract where both versions should return the same value. While the current code only calls the 2-arity version, this creates a subtle bug that could cause incorrect behavior if the 1-arity version is ever used directly.

lib/commanded/projections/ecto.ex#L495-L499

https://github.com/straw-hat-team/commanded/blob/a538e90e002b0865ab221ed1304527844966d7ac/lib/commanded/projections/ecto.ex#L495-L499

Fix in Cursor Fix in Web


@yordis
yordis force-pushed the yordis/batch-support-ecto branch from a538e90 to 60a2e1f Compare November 18, 2025 08:41
Comment thread lib/commanded/projections/ecto.ex
@yordis
yordis force-pushed the yordis/batch-support-ecto branch from 60a2e1f to 84d49b4 Compare November 18, 2025 22:01
@yordis
yordis marked this pull request as ready for review November 18, 2025 22:02

@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

♻️ Duplicate comments (1)
lib/commanded/projections/ecto.ex (1)

204-267: Optional invariant check that all events share the same handler_name

The implementation intentionally derives projection_name from the first event’s metadata and uses it for the entire batch, with comments explaining that all events in a batch come from the same handler process and are enriched with the same :handler_name.

Even if that invariant holds within the Commanded dispatcher, it might be useful to defensively assert it for callers who invoke handle_batch/1 directly (as in tests), e.g. by checking that all events in events share the same handler_name as first_event_metadata.

This isn’t strictly required—misconfigured metadata already constitutes misuse—but it would turn a subtle idempotency bug into a clear error if someone accidentally mixes handlers in a batch.

🧹 Nitpick comments (7)
guides/howtos/building-read-models-with-ecto.md (2)

84-137: Clarify :subscription_opts vs top‑level :concurrency handling

The description of :concurrency and its mutual exclusivity with :batch_size is good, but note that the implementation in Commanded.Projections.Ecto.validate_mutual_exclusivity/1 only checks the top‑level :concurrency option, not subscription_opts[:concurrency]. If concurrency is set only inside :subscription_opts together with :batch_size, the current validation won’t catch it.

Consider either:

  • Documenting that :concurrency should be set at the top level when used, or
  • Extending the implementation to also look inside :subscription_opts for :concurrency so the docs and behavior stay aligned.

176-179: Apply hyphenation fix: “built‑in”

The sentence “there is currently no built in way” should use the hyphenated form “built‑in” (“no built‑in way”) per standard English usage and the static analysis hint.

lib/commanded/projections/ecto.ex (1)

90-121: Batch + dynamic schema_prefix guard is correct but only covers option‑based prefixes

validate_batch_schema_prefix_compatibility/2 correctly prevents batch_size from being combined with a functional :schema_prefix option or app config, enforcing “static string” prefixes for batch projectors in that configuration style.

Note that this guard does not (and cannot easily) detect dynamic schema prefixes implemented via the schema_prefix/1 or schema_prefix/2 callbacks. That’s fine so long as we treat callbacks as an escape hatch and keep the docs clear that batch support only guarantees correctness for static prefix options.

No code change strictly required, but a short note in the guides that dynamic schema_prefix callbacks are not supported for batch projectors would reduce confusion.

test/projections/commanded_batch_integration_test.exs (1)

12-115: Integration tests nicely exercise handle_batch/1 and batch watermarking

This module does a good job of validating the new batch path end‑to‑end:

  • IntegrationBatchProjector uses project_batch fn events, multi -> ... end with batch_size: 5 and Ecto.Multi.insert_all/4, matching the intended API.
  • Tests assert that handle_batch/1 is exported, that projections are inserted as expected, and that ProjectionVersion.last_seen_event_number tracks the highest event number seen.
  • The idempotency test (handle_batch called twice with the same events) aligns with the :already_seen_event branch in update_projection_batch/2 and verifies no duplicate projections are written.

One optional enhancement would be a test where the second batch partially overlaps the first (some events already seen, some new) to exercise the “filter unseen events within a batch” behavior, but the current coverage is already strong.

test/projections/ecto_projection_batch_test.exs (3)

34-37: Consider asserting the Sandbox checkout result in setup

setup/1 correctly starts TestApplication and checks out the Repo connection. To fail fast if the Repo or Sandbox is misconfigured, you could pattern-match on the return value:

   setup do
     start_supervised!(TestApplication)
-    Sandbox.checkout(Repo)
+    :ok = Sandbox.checkout(Repo)
   end

39-112: Good coverage of handle_batch/1; consider avoiding DB order assumptions in assertions

These four tests do a nice job exercising batch handling for multiple events, mixed event types, partial replays, and fully replayed batches, and they validate the projection watermark via assert_seen_event/2.

One potential robustness issue: assert_projections/2 (in test/support/projection_assertions.ex) compares the list from Repo.all(schema) |> pluck(:name) directly to expected. Without an explicit ORDER BY, DBs are free to return rows in any order, which could make these expectations brittle if row-return order ever changes.

If ordering isn’t semantically important, consider updating assert_projections/2 to be order-independent (or to add an explicit ordering), for example:

def assert_projections(schema, expected) do
  actual =
    schema
    |> Repo.all()
    |> pluck(:name)
    |> Enum.sort()

  assert actual == Enum.sort(expected)
end

This would make these new batch tests (and any existing users of assert_projections/2) resilient to DB ordering differences.


162-201: Callback-with-changes test is valuable but tightly coupled to internal keys

Using BatchProjectorCallbackWithChanges to assert that after_update_batch/2 sees the changes map is great, but asserting specific keys like :lock_and_filter, :track_projection_version, and :prepare_user_multi tightly couples this test to internal step naming inside update_projection_batch/2.

If you mainly care that the callback receives the final projection result plus some internal metadata, you could relax the assertions slightly, e.g. by asserting that:

  • changes is a map,
  • it has a :projection entry, and
  • optionally that it’s non-empty,

while avoiding dependence on the exact internal step names. That would keep this test useful without constraining future refactors of the batch pipeline.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0cfdccd and 84d49b4.

📒 Files selected for processing (6)
  • .formatter.exs (1 hunks)
  • guides/howtos/building-read-models-with-ecto.md (4 hunks)
  • lib/commanded/projections/ecto.ex (8 hunks)
  • test/projections/commanded_batch_integration_test.exs (1 hunks)
  • test/projections/ecto_projection_batch_test.exs (1 hunks)
  • test/projections/projection_version_schema_prefix_test.exs (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
test/projections/commanded_batch_integration_test.exs (2)
lib/commanded/projections/ecto.ex (1)
  • handle_batch (665-667)
test/support/projection_assertions.ex (1)
  • assert_projections (7-11)
lib/commanded/projections/ecto.ex (3)
test/projections/projection_version_schema_prefix_test.exs (1)
  • schema_prefix (341-344)
test/projections/ecto_projection_batch_test.exs (4)
  • after_update_batch (139-145)
  • after_update_batch (178-184)
  • after_update_batch (219-221)
  • after_update_batch (250-252)
test/projections/after_update_callback_test.exs (1)
  • after_update (19-25)
test/projections/ecto_projection_batch_test.exs (2)
lib/commanded/projections/ecto.ex (1)
  • handle_batch (665-667)
test/support/projection_assertions.ex (1)
  • assert_projections (7-11)
🪛 LanguageTool
guides/howtos/building-read-models-with-ecto.md

[grammar] ~178-~178: Use a hyphen to join words.
Context: .... Note that there is currently no built in way to target a single type of event ...

(QB_NEW_EN_HYPHEN)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Quality Assurance (1.18.x, 27)
🔇 Additional comments (15)
guides/howtos/building-read-models-with-ecto.md (1)

304-347: after_update/3 and after_update_batch/2 transaction semantics docs match implementation

The new “⚠️ Transaction Semantics” sections correctly describe that both after_update/3 and after_update_batch/2 run after the DB transaction commits and cannot roll back persisted changes. This matches the implementations in update_projection/3 and update_projection_batch/2, where the callbacks are invoked only after transaction/1 succeeds.

No changes needed here; the guidance about restricting these callbacks to side‑effects is accurate and helpful.

lib/commanded/projections/ecto.ex (6)

199-355: Batch watermarking, locking, and idempotency logic look sound

The new update_projection_batch/2 implementation has several strong properties:

  • Empty batches short‑circuit to :ok, keeping them trivially idempotent.
  • Batches are validated to be {event, metadata} tuples, and metadata must include both :handler_name and :event_number. Malformed batches are rejected with a clear {:invalid_batch_structure, ...} error.
  • :lock_and_filter uses SELECT ... FOR UPDATE on ProjectionVersion keyed by projection_name, ensuring a single process owns the watermark during a transaction.
  • current_last_seen is fetched inside that lock; unseen events are filtered with metadata.event_number > current_last_seen, and an :already_seen_event outcome is treated as a no‑op.
  • :track_projection_version advances the watermark to the highest unseen event via an upsert with a defensive WHERE pv.last_seen_event_number < ^last_event_number clause.
  • The user’s multi_fn is required to return an Ecto.Multi; anything else yields {:invalid_multi_return, other}.
  • All of the above—including watermark updates—run in a single Repo.transaction/2. If the user’s projection fails, the entire transaction (watermark + projections) rolls back, keeping state consistent.
  • Idempotency behavior for replayed batches is explicitly handled in the {:error, :lock_and_filter, :already_seen_event, _} branch by returning :ok.

This matches the expectations from the new tests in test/projections/ecto_projection_batch_test.exs and commanded_batch_integration_test.exs. No changes needed here.


311-347: after_update_batch/2 invocation and error handling semantics

The with branch for batches:

  • Builds and runs the full Ecto.Multi transaction, including watermark advancement and user projections.
  • Only after transaction(multi) returns {:ok, changes} does it call after_update_batch(unseen_events, changes).

Because after_update_batch/2 is defined in the quoted __using__/1 block with a default :ok implementation and marked defoverridable, user projectors can override it. Any {:error, reason} or raised exception propagates out as the return value or error, while the persisted data remains committed—matching the documented transaction semantics.

This callback wiring is correct and consistent with the tests for after_update_batch/2 behavior.


356-375: Default callbacks + @optional_callbacks are wired correctly

Providing default implementations:

def after_update(_event, _metadata, _changes), do: :ok
def after_update_batch(_events, _changes), do: :ok

combined with:

defoverridable after_update: 3, after_update_batch: 2, schema_prefix: 1, schema_prefix: 2
@optional_callbacks [...]

ensures:

  • All user projectors have callable after_update/3 and after_update_batch/2 out of the box.
  • Users can override these or schema_prefix arities as needed without compiler warnings.
  • update_projection/3 and update_projection_batch/2 can safely invoke the callbacks without extra function_exported? checks (though you still keep one for after_update/3).

This is a clean, backwards‑compatible way to expose the new batch callback and schema_prefix variants.


427-477: after_update_batch/2 callback docs align with implementation

The new @doc and @callback after_update_batch/2:

  • Clearly state that the callback runs after the transaction commits.
  • Explain that errors or exceptions will not roll back data but will propagate to the handler (potential retries / duplicate side‑effects).
  • Mirror the semantics you’ve implemented in update_projection_batch/2.
  • Provide a realistic example with project_batch usage.

This keeps the public contract precise and matches how the code actually behaves.


499-527: schema_prefix/1 and /2 generation now correctly matches option types

The updated __include_schema_prefix__/1 correctly handles all supported cases:

  • nil → both arities return nil, with 2‑arity delegating to 1‑arity.
  • Binary prefix → both arities return the static string.
  • 1‑arity function → both arities delegate to the configured function with just the event.
  • 2‑arity function → 1‑arity returns nil, 2‑arity delegates to the configured function.

This behavior is thoroughly exercised by the new tests in test/projections/projection_version_schema_prefix_test.exs (including the 1‑arity describe block), and fixes the earlier bug where the 1‑arity variant always returned nil regardless of configuration.

Looks solid.


621-669: project_batch/1 macro and handle_batch/1 wiring look correct

The project_batch/1 macro:

  • Enforces a single handle_batch/1 definition per projector using Module.defines?/2, with a helpful error message and example.
  • Defines:
def handle_batch(all_events) do
  update_projection_batch(all_events, unquote(lambda))
end

which matches the tests in commanded_batch_integration_test.exs and ecto_projection_batch_test.exs.

  • Expects the user lambda to be fn events, multi -> ... end, matching both the moduledoc examples and the batch implementation’s multi_fn.(unseen_events, Ecto.Multi.new()).

Apart from the minor doc issues in the guide file, the macro API & wiring are consistent.

.formatter.exs (1)

3-11: Formatter locals updated consistently with new macro

Adding project_batch: 1 to locals_without_parens is appropriate and keeps formatter behavior consistent with existing project macros and the intended project_batch fn ... -> ... end style. The router: 1 entry is also fine.

No changes needed.

test/projections/projection_version_schema_prefix_test.exs (1)

222-333: Great coverage of schema_prefix/1 behavior across configurations

The new "schema_prefix/1 callback (1-arity)" tests comprehensively validate:

  • Static prefix via option uses same value for both arities.
  • 1‑arity functional prefix is invoked for both arities.
  • 2‑arity functional prefix returns nil for 1‑arity and uses metadata for 2‑arity.
  • Default (no prefix) returns nil for both.
  • Per‑event 1‑arity prefix works across multiple events.
  • App‑config schema_prefix is respected by both arities.

These tests tightly couple to the behavior generated by __include_schema_prefix__/1 and should prevent regressions around the callback contract.

test/projections/ecto_projection_batch_test.exs (6)

1-10: Module header and aliases are clear and idiomatic

Top-level test module, imports, and aliases keep the tests focused and readable; no issues here.


11-32: BatchProjector’s batch handler matches the intended contract

The project_batch function cleanly maps the three event types into projection maps or an error sentinel and leaves idempotency to the projection layer, which aligns with the tests below. No problems spotted.


114-121: Error-path behaviour on failing batch is well covered

The ErrorEvent test correctly asserts that handle_batch/1 returns {:error, :failure} and that no projections are inserted, which matches the intended “no partial writes on error” semantics.


123-160: after_update_batch/2 happy-path callback is exercised appropriately

BatchProjectorAfterUpdateCallback and its test validate that after_update_batch/2 is invoked once per batch with the full event list and the changes map, and that it can safely perform side effects (sending a message) after the DB work completes. The pattern of pulling pid from the first event keeps the test simple and focused on the callback contract.


203-232: Callback error semantics (“no rollback”) are tested clearly

BatchProjectorCallbackReturnsError and its test accurately capture the contract that an {:error, reason} from after_update_batch/2 should be returned from handle_batch/1 without rolling back the already-committed projections. The expectations on both the return value and the persisted projection look correct.


234-263: Callback raise semantics (“no rollback”) are likewise well covered

The BatchProjectorCallbackRaises case properly verifies that an exception in after_update_batch/2 propagates (via assert_raise/3) while still leaving the projection committed in the database. This cleanly complements the error-return test above and matches the documented “post-commit callback” behaviour.

Comment thread guides/howtos/building-read-models-with-ecto.md
Comment thread lib/commanded/projections/ecto.ex Outdated
@yordis
yordis force-pushed the yordis/batch-support-ecto branch from 84d49b4 to 403a964 Compare November 19, 2025 00:58

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

♻️ Duplicate comments (2)
guides/howtos/building-read-models-with-ecto.md (1)

174-201: Fix project_batch macro syntax in example.

As previously noted, the example contains a syntax error. Line 190 shows:

project_batch fn events, multi ->

but is incorrectly written as:

project_batch events, fn events, multi ->

The project_batch macro is arity-1 and takes only the lambda function directly, not a separate events parameter.

lib/commanded/projections/ecto.ex (1)

71-88: Validation gap: :concurrency in :subscription_opts not checked.

As previously noted, validate_mutual_exclusivity/1 only checks top-level :concurrency but misses :concurrency nested in :subscription_opts. This allows invalid configurations like:

use Commanded.Projections.Ecto,
  batch_size: 50,
  subscription_opts: [concurrency: 4]

to pass validation despite being mutually exclusive.

🧹 Nitpick comments (1)
guides/howtos/building-read-models-with-ecto.md (1)

178-178: Minor: Hyphenate compound adjective.

Consider changing "built in" to "built-in" when used as a compound adjective.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 84d49b4 and 403a964.

📒 Files selected for processing (6)
  • .formatter.exs (1 hunks)
  • guides/howtos/building-read-models-with-ecto.md (4 hunks)
  • lib/commanded/projections/ecto.ex (8 hunks)
  • test/projections/commanded_batch_integration_test.exs (1 hunks)
  • test/projections/ecto_projection_batch_test.exs (1 hunks)
  • test/projections/projection_version_schema_prefix_test.exs (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • .formatter.exs
  • test/projections/commanded_batch_integration_test.exs
🧰 Additional context used
🧬 Code graph analysis (2)
lib/commanded/projections/ecto.ex (3)
test/projections/projection_version_schema_prefix_test.exs (1)
  • schema_prefix (341-344)
test/projections/ecto_projection_batch_test.exs (4)
  • after_update_batch (139-145)
  • after_update_batch (178-184)
  • after_update_batch (219-221)
  • after_update_batch (250-252)
test/projections/after_update_callback_test.exs (1)
  • after_update (19-25)
test/projections/ecto_projection_batch_test.exs (2)
lib/commanded/projections/ecto.ex (1)
  • handle_batch (665-667)
test/support/projection_assertions.ex (1)
  • assert_projections (7-11)
🪛 LanguageTool
guides/howtos/building-read-models-with-ecto.md

[grammar] ~178-~178: Use a hyphen to join words.
Context: .... Note that there is currently no built in way to target a single type of event ...

(QB_NEW_EN_HYPHEN)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Quality Assurance (1.18.x, 27)
🔇 Additional comments (9)
guides/howtos/building-read-models-with-ecto.md (2)

84-138: LGTM! Clear subscription options documentation.

The subscription options section is well-structured, provides both nested and top-level formats, and clearly documents the mutual exclusivity constraint between :batch_size and :concurrency.


304-346: LGTM! Excellent transaction semantics documentation.

The warnings about post-commit callback behavior are clear, emphatic, and provide sound guidance. The formatting makes the critical nature of these constraints very visible to developers.

lib/commanded/projections/ecto.ex (5)

102-122: LGTM! Proper compile-time validation.

The validation calls in __using__ properly enforce constraints at compile-time with clear error messages via CompileError. This provides good developer feedback.


199-354: LGTM! Solid batch processing implementation.

The update_projection_batch/2 implementation correctly:

  • Uses FOR UPDATE locking to prevent concurrent updates
  • Filters unseen events inside the transaction for idempotency
  • Advances the watermark atomically
  • Orchestrates user projections within the same transaction
  • Handles empty batches, partial batches, and invalid formats
  • Passes both events and multi to the user's projection function (line 320)

The architectural guarantee that all events in a batch share the same handler_name is well-documented in lines 207-210.


356-357: LGTM! Proper callback defaults and overridability.

The default no-op implementations and defoverridable declaration correctly support optional callback overrides for both single-event and batch processing.

Also applies to: 363-363


369-477: LGTM! Complete callback documentation.

The @optional_callbacks declaration and after_update_batch/2 documentation are comprehensive. The transaction semantics warnings appropriately emphasize post-commit behavior and its implications.


621-669: LGTM! Well-implemented project_batch macro.

The macro correctly:

  • Takes a single lambda argument (the projection function)
  • Prevents duplicate project_batch definitions with a clear error message
  • Generates handle_batch/1 that delegates to update_projection_batch/2
  • Documents the expected signature clearly

The user's lambda function receives both events and multi arguments when invoked at line 320 in update_projection_batch/2.

test/projections/projection_version_schema_prefix_test.exs (1)

222-333: LGTM! Comprehensive 1-arity callback tests.

The new test suite thoroughly covers schema_prefix/1 behavior across:

  • Static string prefixes
  • Dynamic 1-arity and 2-arity functions
  • Interaction between arities (2-arity returns nil when called as 1-arity)
  • No-prefix scenarios
  • Per-event dynamic prefixes
  • Application config-driven prefixes

The tests verify consistency between 1-arity and 2-arity call paths, ensuring correct implementation.

test/projections/ecto_projection_batch_test.exs (1)

1-263: LGTM! Excellent batch projection test coverage.

This test suite comprehensively validates batch processing behavior:

  • Multiple events and mixed event types
  • Idempotency for duplicate batches
  • Partial batch filtering (only unseen events projected)
  • Entire batch deduplication
  • Error propagation without partial commits
  • after_update_batch/2 callback invocation with correct arguments
  • Post-commit semantics (errors/exceptions in callbacks don't rollback the transaction)

The tests align perfectly with the implementation in lib/commanded/projections/ecto.ex and verify all critical batch processing guarantees.

@yordis
yordis force-pushed the yordis/batch-support-ecto branch from 403a964 to 3e14b01 Compare November 19, 2025 04:52
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
@yordis
yordis force-pushed the yordis/batch-support-ecto branch from 3e14b01 to cebac78 Compare November 19, 2025 04:55

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

♻️ Duplicate comments (1)
lib/commanded/projections/ecto.ex (1)

71-88: Extend :batch_size vs :concurrency validation to :subscription_opts

validate_mutual_exclusivity/1 only checks top‑level opts[:batch_size] and opts[:concurrency]. However, the guides now encourage configuring :concurrency under :subscription_opts, and @handler_opts passes :subscription_opts through unchanged. This allows a configuration like:

use Commanded.Projections.Ecto,
  batch_size: 50,
  subscription_opts: [concurrency: 4]

to compile, despite the documented “mutually exclusive” rule. That’s a real config mismatch and may surprise users.

I’d fold subscription_opts[:concurrency] into the validation, e.g.:

-    def validate_mutual_exclusivity(opts) do
-      batch_size = Keyword.get(opts, :batch_size)
-      concurrency = Keyword.get(opts, :concurrency)
+    def validate_mutual_exclusivity(opts) do
+      batch_size = Keyword.get(opts, :batch_size)
+
+      subscription_concurrency =
+        opts
+        |> Keyword.get(:subscription_opts, [])
+        |> Keyword.get(:concurrency)
+
+      concurrency = Keyword.get(opts, :concurrency) || subscription_concurrency

The rest of the case then correctly rejects any combination where both batch_size and some concurrency (top‑level or nested) are set.

🧹 Nitpick comments (5)
lib/commanded/projections/ecto.ex (2)

161-339: Batch pipeline, idempotency, and error paths look solid; consider extra handler_name validation

The batch flow (update_projection_batch → build_batch_projection_multi/3 → lock_and_filter_batch_events/4 → track_batch_projection_version/5 → execute_batch_projection/2) is coherent:

  • Empty batches short‑circuit to :ok.
  • Input validation distinguishes structural errors ({:invalid_batch_format, ...} / {:invalid_batch_structure, ...}) from the “already seen” case.
  • FOR UPDATE locking plus conditional upsert of ProjectionVersion gives a sane watermark/idempotency story, and returning {:error, :already_seen_event} maps cleanly to :ok in execute_batch_projection/2.
  • The project_batch lambda is now called as multi_fn.(unseen_events, Ecto.Multi.new()), matching the documented fn events, multi -> ... end signature.

Given the comment that all events in a batch are architecturally from the same handler, you rely on that invariant when you take projection_name from the first event’s metadata. If you want extra defence against misuse (e.g. someone calling handle_batch/1 manually in downstream code), you could cheaply assert that all metadata share the same :handler_name in lock_and_filter_batch_events/4 and error out otherwise. That would turn a subtle idempotency footgun into an immediate, explicit failure without impacting the happy path.


343-347: Default callbacks + function_exported? check: behavior is correct but can be simplified

You now provide default no‑op implementations for after_update/3 and after_update_batch/2 via __define_helper_functions__/0 and mark both as defoverridable. That matches the new docs (“callbacks are optional; default to :ok”) and keeps after_update_batch/2 safe to call unconditionally in the batch pipeline.

Given that the defaults are always defined in the using module, the function_exported?(__MODULE__, :after_update, 3) check inside update_projection/3 is now redundant: it will always be true. Not a correctness issue, but you could simplify by removing the guard and calling after_update/3 directly for a slightly cleaner implementation.

Also applies to: 400-401

guides/howtos/building-read-models-with-ecto.md (1)

174-201: project_batch docs and example now match the macro; minor wording nit

The project_batch section correctly describes:

  • enabling batching via batch_size,
  • the project_batch/1 macro signature, and
  • the handler function taking events and multi, with a working example.

One small editorial tweak you might consider: change “no built in way” to “no built‑in way” (add the hyphen) for grammatical consistency.

test/projections/commanded_batch_integration_test.exs (1)

36-116: Integration tests cover batch processing, watermarking, and idempotency end‑to‑end

The three tests:

  • verify handle_batch/1 is actually defined,
  • assert a single batch both writes projections and advances last_seen_event_number,
  • and confirm multiple batches plus repeated calls behave idempotently,

which together validate the main observable behaviors of the new batch path against a real repo. One optional improvement would be to make assert_projections/2 order‑insensitive (e.g. by sorting names) to avoid relying on database row order, but that’s not a blocker here.

test/projections/ecto_projection_batch_test.exs (1)

203-263: Callback error/exception semantics for after_update_batch/2 match the documented contract

The BatchProjectorCallbackReturnsError and BatchProjectorCallbackRaises modules, with their respective tests, confirm that:

  • returning {:error, :callback_failed} from after_update_batch/2 causes handle_batch/1 to return the error while leaving committed projections intact, and
  • raising an exception in after_update_batch/2 propagates the exception but likewise does not roll back the batch transaction.

That behavior is precisely what the new “⚠️ Transaction Semantics” docs describe and validates the post‑commit nature of these callbacks.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 403a964 and cebac78.

📒 Files selected for processing (6)
  • .formatter.exs (1 hunks)
  • guides/howtos/building-read-models-with-ecto.md (4 hunks)
  • lib/commanded/projections/ecto.ex (8 hunks)
  • test/projections/commanded_batch_integration_test.exs (1 hunks)
  • test/projections/ecto_projection_batch_test.exs (1 hunks)
  • test/projections/projection_version_schema_prefix_test.exs (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/projections/projection_version_schema_prefix_test.exs
🧰 Additional context used
🧬 Code graph analysis (3)
test/projections/ecto_projection_batch_test.exs (2)
lib/commanded/projections/ecto.ex (1)
  • handle_batch (702-704)
test/support/projection_assertions.ex (1)
  • assert_projections (7-11)
test/projections/commanded_batch_integration_test.exs (2)
lib/commanded/projections/ecto.ex (1)
  • handle_batch (702-704)
test/support/projection_assertions.ex (1)
  • assert_projections (7-11)
lib/commanded/projections/ecto.ex (3)
test/projections/projection_version_schema_prefix_test.exs (1)
  • schema_prefix (341-344)
test/projections/ecto_projection_batch_test.exs (4)
  • after_update_batch (139-145)
  • after_update_batch (178-184)
  • after_update_batch (219-221)
  • after_update_batch (250-252)
test/projections/after_update_callback_test.exs (1)
  • after_update (19-25)
🪛 LanguageTool
guides/howtos/building-read-models-with-ecto.md

[grammar] ~178-~178: Use a hyphen to join words.
Context: .... Note that there is currently no built in way to target a single type of event ...

(QB_NEW_EN_HYPHEN)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Quality Assurance (1.18.x, 27)
🔇 Additional comments (9)
lib/commanded/projections/ecto.ex (2)

536-552: Schema prefix handling now correctly exposes 1‑arity variants

The updated __include_schema_prefix__/1 correctly makes schema_prefix/1 return:

  • nil when no prefix is configured,
  • the configured string when :schema_prefix is a binary, and
  • the result of the configured 1‑arity function when applicable.

That fixes the earlier issue where schema_prefix/1 always returned nil regardless of configuration and aligns with the documented callback contract. The special handling of a 2‑arity function (1‑arity returning nil, 2‑arity delegating to the user function) is reasonable given that callers needing metadata can use schema_prefix/2.


658-706: project_batch/1 macro and handler wiring align with docs and tests

The project_batch/1 macro now:

  • Enforces a single handle_batch/1 per projector via Module.defines?/2.
  • Defines handle_batch/1 that simply delegates to update_projection_batch/2.
  • Matches the documented and tested handler shape project_batch fn events, multi -> ... end, since execute_batch_projection/2 calls the lambda as multi_fn.(unseen_events, Ecto.Multi.new()).

That resolves the previous lambda‑arity mismatch and gives a clear, single entrypoint for batched processing.

guides/howtos/building-read-models-with-ecto.md (3)

84-137: Subscription options docs are clear and match the intended API

The new :subscription_opts section does a good job explaining start_from, subscribe_to, and concurrency, plus the precedence rules when mixing nested and top‑level options. This lines up with how the handler options are passed through from __using__/1. Once validate_mutual_exclusivity/1 is updated to also look at subscription_opts[:concurrency], the documentation and implementation will be fully aligned.


304-347: Transaction semantics for after_update/3 and after_update_batch/2 are well documented

The new “⚠️ Transaction Semantics” sections make it explicit that both callbacks run after commit, cannot influence rollback, and are meant for side effects only, with clear notes about duplicate side effects on retries. This matches the implementation in update_projection/3 and execute_batch_projection/2, where callbacks are invoked after transaction/1 completes, and where errors are propagated without undoing DB changes.


356-369: Schema prefix config path in docs matches __using__/1

The updated example using:

config :commanded, Commanded.Projections.Ecto,
  schema_prefix: "example_schema_prefix"

is consistent with the new schema_prefix lookup in __using__/1 (pulling from Application.get_env(:commanded, Commanded.Projections.Ecto, []) |> Keyword.get(:schema_prefix)). This keeps the guide in sync with the actual configuration path.

.formatter.exs (1)

3-11: Formatter update correctly supports project_batch without parens

Adding project_batch: 1 to locals_without_parens keeps formatting consistent with existing project macros and the examples in the docs/tests. The change is scoped to formatting only and is safe.

test/projections/commanded_batch_integration_test.exs (1)

12-27: IntegrationBatchProjector setup is minimal and representative

The nested IntegrationBatchProjector uses batch_size: 5 and a project_batch handler that maps AnEvent names into plain projection maps and persists via Ecto.Multi.insert_all/4. This is a clean, focused projector for integration testing and exercises the batch pipeline without extra complexity.

test/projections/ecto_projection_batch_test.exs (2)

11-32: Core batch projector tests thoroughly exercise success, idempotency, and failure

The BatchProjector module plus the first group of tests validate:

  • normal multi‑event batches across one or multiple event types,
  • ignoring fully or partially already‑seen batches while still advancing the watermark,
  • and propagating user‑level failures from Ecto.Multi.error/3 as {:error, :failure} without writing projections.

These scenarios map directly onto the batch pipeline’s intended behavior and give strong coverage of idempotency and error handling.

Also applies to: 39-122


123-201: after_update_batch/2 callback behavior and changes contents are well covered

The BatchProjectorAfterUpdateCallback and BatchProjectorCallbackWithChanges modules plus their tests verify that:

  • after_update_batch/2 is invoked once per batch with the full events list,
  • the callback receives the committed changes map from the Ecto.Multi transaction, including internal steps like :lock_and_filter, :track_projection_version, :prepare_user_multi, and the user step (:projection),
  • and that callers can safely use this for out‑of‑transaction notifications.

This directly matches how execute_batch_projection/2 calls after_update_batch/2 post‑commit.

@yordis
yordis merged commit 0fb29d6 into main Nov 19, 2025
6 of 7 checks passed
@yordis
yordis deleted the yordis/batch-support-ecto branch November 19, 2025 05:07
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