fix(atomicity): wrap streams CRUD + pg_notify in one transaction#125
Merged
Conversation
When the `streams` feature is enabled, generated CRUD methods previously emitted 2–3 separate SQL round-trips on the pool with no coordination: 1. `update_method` race — `find_by_id` (read old) and `UPDATE` ran on the bare pool with no shared transaction. A concurrent UPDATE between the two could land, leaving the Updated event payload describing a transition that never actually happened. 2. `create_method` / `delete_method` event loss — `pg_notify` was a separate round-trip after the DML committed. A crash, connection drop, or pool error between commit and notify silently dropped the event; subscribers missed the change while the row write persisted. Both classes of bug are eliminated by wrapping the DML and the notify in one `sqlx::Transaction`. Postgres' `NOTIFY` is buffered per transaction and broadcast only on commit, so: - the new state and the notify commit atomically; - on rollback the notify is discarded automatically; - the `SELECT ... FOR UPDATE` row lock used to read the old payload now also blocks concurrent writers until the same transaction commits, so `old` and `new` are guaranteed adjacent states. Changes: - `sql/postgres/crud.rs::tx_wrapping(streams) -> (open, close, executor)` helper. Streams-on returns `let mut tx = self.begin().await?;`, `tx.commit().await?;`, and `&mut *tx`. Streams-off returns empty fragments and `self`, preserving the single-statement fast path for non-streams entities. - `create_method` / `delete_method` thread the helper through every branch (`ReturningMode::Full`, `Id`, `None`, `Custom` for create; soft and hard for delete). - `update_method` splits into two paths: streams-on takes the always- `RETURNING *` route because the Updated event payload requires the full row anyway; streams-off keeps the historical match on `ReturningMode`. - `notify.rs` updates the executor token in every `notify_*` to `&mut *tx` and replaces `fetch_old_for_update` with a direct `SELECT ... FOR UPDATE` against the transaction instead of routing through the trait method on the pool. - `README.md` codeblock for the tracing sample log line is tagged `text` so rustdoc no longer tries to compile it (a side-effect of `lib.rs` including the README via `include_str!`). Bump (changed crates only): - entity-derive-impl: 0.6.0 -> 0.6.1 - entity-derive: 0.8.1 -> 0.8.2 `entity-core` is unchanged. Closes #124
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This was referenced May 11, 2026
RAprogramm
added a commit
that referenced
this pull request
May 11, 2026
…entities (#134) `crates/entity-derive-impl/src/entity/sql/postgres/save.rs::save_method` already wrapped the INSERT in a transaction (#118 era), but for entities with both `aggregate_root` and `streams` enabled it never spliced `pg_notify`. Subscribers missed every new aggregate-root insert silently, even though the row write itself was atomic. Splice `self.notify_created()` into the existing transaction body, after the INSERT but before the commit. The notify executes against `&mut *tx`, the same handle the INSERT uses, so Postgres only broadcasts `Created` on commit and discards it on rollback — the same guarantee already in place for `create_method` (#125). When `streams` is off, `notify_created()` returns an empty TokenStream, so non-streams aggregate roots stay one round-trip with no regression. Tests (3 new lib tests in `save::tests`): - `save_emits_pg_notify_when_streams_enabled`: combined attribute set produces a save body that contains `pg_notify` and executes it on `&mut *tx`. - `save_omits_pg_notify_when_streams_disabled`: aggregate_root without streams does NOT emit `pg_notify` (perf regression guard). - `save_is_empty_for_non_aggregate_root`: untouched control case — `save_method` returns empty when aggregate_root is off. Bump: - entity-derive-impl: 0.6.3 -> 0.6.4 - entity-derive: 0.8.4 -> 0.8.5 `entity-core` is unchanged. Closes #133
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #124
Why
When the `streams` feature is on, generated CRUD methods previously did multiple round-trips on the pool with no coordination. Two real bugs:
Fix
Wrap the DML and the `pg_notify` in one `sqlx::Transaction` whenever `streams` is on:
When `streams` is off, the generated method stays a single SQL statement — no perf regression for non-streams entities.
Changes
Version bump
`entity-core` is unchanged.
Test plan
Out of scope (follow-up issues)