module StatusWorkflow

Constants

LOCK_ACQUISITION_TIMEOUT
LOCK_CHECK_RATE
LOCK_EXPIRY
VERSION

Public Class Methods

included(klass) click to toggle source
# File lib/status_workflow.rb, line 9
def self.included(klass)
  klass.extend ClassMethods
end
redis() click to toggle source
# File lib/status_workflow.rb, line 17
def self.redis
  @redis or raise("please set StatusWorkflow.redis=")
end
redis=(redis) click to toggle source
# File lib/status_workflow.rb, line 13
def self.redis=(redis)
  @redis = redis
end

Public Instance Methods

status_transition!(intermediate_to_status, final_to_status, prefix = nil) { || ... } click to toggle source
# File lib/status_workflow.rb, line 25
def status_transition!(intermediate_to_status, final_to_status, prefix = nil)
  result = nil # what the block yields, return to the user
  before_status_transition = self.class.instance_variable_get(:@before_status_transition)
  intermediate_to_status = intermediate_to_status&.to_s
  final_to_status = final_to_status&.to_s
  prefix_ = prefix ? "#{prefix}_" : nil
  status_column = "#{prefix_}status"
  status_changed_at_column = "#{status_column}_changed_at"
  error_column = "#{status_column}_error"
  lock_obtained_at = nil
  lock_key = "status_workflow/#{self.class.name}/#{id}/#{status_column}"
  # Give ourselves 8 seconds to get the lock, checking every 0.2 seconds
  Timeout.timeout(LOCK_ACQUISITION_TIMEOUT, nil, "#{lock_key} timeout waiting for lock") do
    until StatusWorkflow.redis.set(lock_key, true, nx: true, ex: LOCK_EXPIRY)
      sleep LOCK_CHECK_RATE
    end
    lock_obtained_at = Time.now
  end
  heartbeat = nil
  initial_to_status = intermediate_to_status || final_to_status
  begin
    # depend on #can_enter_X to reload
    send "#{prefix_}can_enter_#{initial_to_status}?", true
    if intermediate_to_status
      update_columns status_column => intermediate_to_status, status_changed_at_column => Time.now
    end
    # If a block was given, start a heartbeat thread
    if block_given?
      begin
        heartbeat = Thread.new do
          loop do
            StatusWorkflow.redis.expire lock_key, LOCK_EXPIRY
            lock_obtained_at = Time.now
            sleep LOCK_EXPIRY/2.0
          end
        end
        result = yield
        before_status_transition&.call
      rescue
        # If the block errors, set status to error and record the backtrace
        error = (["#{$!.class} #{$!.message}"] + $!.backtrace).join("\n")
        before_status_transition&.call
        status = read_attribute status_column
        update_columns status_column => "#{status}_error", status_changed_at_column => Time.now, error_column => error
        raise
      end
    end
    # Success!
    if intermediate_to_status
      send "#{prefix_}can_enter_#{final_to_status}?", true
    end
    update_columns status_column => final_to_status, status_changed_at_column => Time.now
  ensure
    raise TooSlow, "#{lock_key} lost lock" if Time.now - lock_obtained_at > LOCK_EXPIRY
    StatusWorkflow.redis.del lock_key
    heartbeat.kill if heartbeat
  end
  result
end