4

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
lcguida
  • 3,787
  • 2
  • 33
  • 56
Maxim Fedotov
  • 1,349
  • 1
  • 18
  • 38
  • 1
    Can you elaborate on what changes might happen to what objects and how they are connected to the state machine? An example might help to come up with a good solution. I am not sure if I agree on the `before_save`, but I agree that some kind of observer pattern is better than a cron like background job, because the observer can provide a *real time* experience, whereas a cron job will always be behind. – spickermann Feb 18 '17 at 18:02
  • Can you please explain a bit more about what exactly you want to do here. So that I can give you an useful answer. – Pradeep Agrawal Feb 19 '17 at 06:17
  • I am just looking for a general opinion on a best practice. I've added an example of a state machine @PradeepAgrawal – Maxim Fedotov Feb 20 '17 at 15:04
  • There are no dependencies, only thing would change is a state of the Model @spickermann – Maxim Fedotov Feb 20 '17 at 15:04
  • If you do not want to call for example `start_onboarding` manually, how would you automatically decide or notice that the onboading process started or that is was completed? – spickermann Feb 20 '17 at 15:09
  • Well that's what I am asking? For example if `start_onboarding` is triggered by visiting some route, sure, we can call the event. But say it depends on a DB column, then we are back to my original question -> Have a background job checking for column changes or a callback `after_commit` or something. That applies to any event – Maxim Fedotov Feb 20 '17 at 16:30
  • If it depends on a different attribute, the question is, would you change that attribute only when the event is triggered, or in other cases as well? In the first case, you may decide that you want the event to be the one driving both the state column change and the other attribute. You could use an after callback for that: https://github.com/aasm/aasm#callbacks. In the second case, an `after_commit` with a guard would probably be better. – Teoulas Feb 21 '17 at 14:11

1 Answers1

3

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
itsnikolay
  • 17,415
  • 4
  • 65
  • 64
  • 2
    Great, that's more along the lines what I am looking for. Could you go into more detail on what kind of performance degrade you saw with callbacks and live polling? Also is it just that or you think there are more downsides to using callbacks? My concern with callbacks in general, is lack of control of the execution sequence. – Maxim Fedotov Feb 21 '17 at 16:17