{{ message }}
fix(lifecycle): run deferred side effects directly after commit#5
Merged
monotykamary merged 3 commits intoMay 8, 2026
Merged
Conversation
…ad of via after_commit callback The after_commit :_fosm_run_deferred_side_effects callback was registered on the model class AFTER locked_record.update! had already executed. Since update! is what triggers the after_commit chain, the dynamically-registered callback could never fire for the current transaction — it would only fire on some future unrelated update to any record of that class. This made defer: true on side_effects completely non-functional. Replace the dynamic after_commit approach with direct execution: store deferred effects in instance variables on self during the transaction, then call _fosm_run_deferred_side_effects immediately after the transaction block closes (i.e. after commit). This preserves the original intent — effects run post-commit, outside the transaction — without the callback registration ordering problem and without polluting the model class with a one-shot after_commit that needed skip_callback cleanup.
11 tests covering the core deferred side effect behavior: - Deferred side effects fire after transaction commits - Deferred side effect failure does not roll back committed state - Instance variables are cleaned up after execution - No stale deferred effects leak between separate fire! calls - No after_commit callbacks are left on the model class - Immediate side effects roll back on failure; deferred do not - triggered_by context is correctly set for nested transitions - No-op deferred side effects (next/early return) do not fail
CI runs with frozen mode, so the lockfile must reflect the current gemspec version. The previous lockfile had 0.2.0 while the gemspec declares 0.2.5.
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.

Bug
defer: trueonside_effectdeclarations was completely non-functional. Deferred side effects never executed.Root Cause
Inside
fire!, theafter_commit :_fosm_run_deferred_side_effectscallback was registered on the model class afterlocked_record.update!had already been called. Sinceupdate!is the operation that triggers theafter_commitcallback chain, the dynamically-registered callback could never fire for the current transaction — it would only fire on some future unrelatedupdateto any record of that class.Additionally, the
after_commitapproach had two secondary problems:fire!call registered a one-shot callback on the model class, then cleaned it up withskip_callback. This is fragile and races with concurrent transactions on the same class.locked_record, but_fosm_run_deferred_side_effectsis called onself, making the instance variables inaccessible.Fix
Replace the dynamic
after_commitapproach with direct execution after the transaction block:selfduring the transaction_fosm_run_deferred_side_effectsimmediately afterActiveRecord::Base.transactioncloses (i.e. after commit)after_commitregistration andskip_callbackcleanup entirelyThis preserves the original intent — effects run post-commit, outside the transaction, so failures don't roll back the state change — without the callback ordering problem or class pollution.
Changes
lib/fosm/lifecycle.rb— Store deferred effects onself; call_fosm_run_deferred_side_effectsdirectly after transaction; removeafter_commit/skip_callbacktest/models/race_condition_test.rb— Update comments to reflect new approachTest Results
All existing tests pass, including the two cross-machine causal chain tests (
smoke_test.rb,litmus_test.rb) that exercise deferred side effects end-to-end.