Search code examples
ruby-on-railsrubystate-machineaasm

What's the best way to automatically change state in AASM


I've been using AASM to make state machines in my current project and was wondering what's the best way to automatically call events and proceed to the next state?

I am considering 2 ways of doing this:

  1. Setup a background job to periodically check if certain conditions are met, hence call the event to proceed to the next state.

  2. Have a before_save call a method that tries the next event in succession. With a guard on it, it won't succeed if conditions are met, otherwise, state changes and next time Model is updated, we check for a new event.

I was leaning towards the second option as setting up a background_job queue just to transition events seems like an overkill. I couldn't find best practices regarding this, so I would love to know the best approach and why it is so?

Example

For example we have start_onboarding and complete_onboarding events. I don't want to manually call these events, but I want to automatically transition between pending -> in_progress -> completed events.

  enum status: {
    pending: 1,
    in_progress: 2,
    completed: 3
  }

  aasm column: :status, enum: true, whiny_transitions: false do
    state :pending, initial: true
    state :in_progress
    state :completed

    event :start_onboarding do
      transitions from: :pending, to: :in_progress
    end

    event :complete_onboarding do
      transitions from: :in_progress,
                  to: :completed,
                  if: :onboarding_completed?
    end
  end

Solution

  • In the similar task:

    We got rid of:

    • Callbacks to switch states because they bring performance degrade
    • A live polling (with background jobs) because it also bring performance degrade

    We come to using:

    And the code was looking something like this:

    require 'active_record'
    require 'aasm'
    require 'sidekiq'
    
    class Task < ActiveRecord::Base
      include AASM
    
      establish_connection adapter: 'sqlite3', database: 'todo.db'
    
      connection.create_table table_name, force: true do |t|
        t.string   :name,       null: false
        t.string   :aasm_state, null: false, index: true
        t.datetime :expired_at, null: false
      end
    
      validates :name, :aasm_state, :expired_at, presence: true
    
      aasm do
        state :pending, initial: true
        state :in_progress
        state :completed
        state :expired
    
        event :run do
          transitions to: :in_progress
        end
    
        event :complete do
          transitions to: :completed
        end
    
        event :expire do
          transitions to: :expired, unless: :completed?
        end
      end
    end
    
    class Task::ExpireJob
      include Sidekiq::Worker
    
      def perform task
        task.expire!
      end
    end
    
    class Task::CreationService
      def self.create! params
        task = Task.create! params
        task.run!
        Task::ExpireJob.perform_at task.expired_at, task
        task
      end
    
      def self.complete! task
        task.complete!
        task
      end
    end
    
    task = Task::CreationService.create! \
      name:       'first',
      expired_at: DateTime.now + 30.seconds
    
    p task
    p Task::CreationService.complete! task