fix(lifecycle): run deferred side effects directly after commit by monotykamary · Pull Request #5 · inloopstudio/fosm-rails · GitHub
Skip to content

fix(lifecycle): run deferred side effects directly after commit#5

Merged
monotykamary merged 3 commits into
mainfrom
fix/deferred-side-effects-after-commit-ordering
May 8, 2026
Merged

fix(lifecycle): run deferred side effects directly after commit#5
monotykamary merged 3 commits into
mainfrom
fix/deferred-side-effects-after-commit-ordering

Conversation

@monotykamary

Copy link
Copy Markdown
Collaborator

Bug

defer: true on side_effect declarations was completely non-functional. Deferred side effects never executed.

Root Cause

Inside fire!, the after_commit :_fosm_run_deferred_side_effects callback was registered on the model class after locked_record.update! had already been called. Since update! is the operation that triggers the after_commit callback 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.

Additionally, the after_commit approach had two secondary problems:

  1. Class pollution — every fire! call registered a one-shot callback on the model class, then cleaned it up with skip_callback. This is fragile and races with concurrent transactions on the same class.
  2. Instance variable on wrong object — deferred effects were stored on locked_record, but _fosm_run_deferred_side_effects is called on self, making the instance variables inaccessible.

Fix

Replace the dynamic after_commit approach with direct execution after the transaction block:

  • Store deferred effects in instance variables on self during the transaction
  • Call _fosm_run_deferred_side_effects immediately after ActiveRecord::Base.transaction closes (i.e. after commit)
  • Remove the after_commit registration and skip_callback cleanup entirely

This 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 on self; call _fosm_run_deferred_side_effects directly after transaction; remove after_commit/skip_callback
  • test/models/race_condition_test.rb — Update comments to reflect new approach

Test Results

68 runs, 179 assertions, 0 failures, 0 errors, 4 skips

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.

…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.
@monotykamary monotykamary merged commit f1b4697 into main May 8, 2026
1 of 2 checks passed
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