Automatic Rails Model Notification Concerns

This ActiveRecord Concern is a module I am particularly proud of. It was developed for an application that had the need for an advanced notification system on a whole slew of database changes. Rather than just wire up a basic notification job to each controller action that triggered each model change, I elected to write a model concern that automatically triggered the notification system on different ActiveRecord changes.

The concerns worked amazingly well, and assisted in not only keeping our controllers very light, but also meant that database changes could not escape notifying users of the change.


# This concerns allows you to directly hookup ActiveRecord model changes
# directly into a system-wide notification system using ActiveSupport
# Callbacks. Jobs can be created to reflect the exact work you want done
# when a specific event occurs in the lifecycle of the model you want
# to be notified on.
#
# ==== Example
#   class MyModel < ApplicationRecord
#     include Notifyable
#
#     notify :on_create, :handler_job
#
module Notifyable
  # Make this module a concerns and include the ActiveSupport callbacks module
  extend ActiveSupport::Concern
  include ActiveSupport::Callbacks

  included do
    # Add a has_many model association for the notifications events.
    has_many :notification_events, as: :item

    # This code opens up the parent class and generates several methods
    # directly into it providing the core foundation of the
    # Notifyable concerns. It declares the available callbacks
    # and runs the associated callback when the custom defined
    # calback is triggered.
    %w(on_initialize on_update on_save on_create on_commit on_destroy on_find).each {|name|
      module_eval <<-RUBY, __FILE__, __LINE__ + 1
        define_callbacks :#{name}
        #{name.gsub('on', 'after')} :notify_#{name}
        
        def notify_#{name.to_s}
          run_callbacks :#{name} do
            @invocation = :#{name}
          end
        end
      RUBY
    }

    # Opens up the calling class so methods can be redefined on
    # the current object. We need to add the +notify+ method so
    # that we can define what callbacks should be watched to
    # trigger notifications.
    #--
    # FIXME: Class variables for handlers are bugged if
    # FIXME: different models use different handlers. I
    # FIXME: would love to refactor this so that you can 
    # FIXME: provide a &block instead of just a handler
    # FIXME: name/symbol.
    #++
    # Callbacks are always appended *after* the source event
    # declared; so that +:on_save+ will actually declare itself
    # as a +:after_save+ on the parent ActiveRecord class.
    class << self
      def notify(name, *handlers)
        @@handlers ||= HashWithIndifferentAccess.new
        @@handlers.store(name, handlers)
        set_callback(name, :after, :handler_callback)
      end
    end

  end


  protected

  # Execute the callback for each handler invocation. It
  # is expected that there will be a corresponding ActiveJob
  # to handle the notification within a +Notification+
  # namespace with the same name as the invocation class,
  # followed by the handler name.
  #
  # Example:
  #   +Notification::MyClassHandlerJob+
  def handler_callback
    @@handlers[@invocation].each do |handler|
      eval "Notification::#{self.class.name}#{handler.to_s.classify}.perform_later(self, '#{@invocation}')"
    end
  end

end

As directed in the comments of the concerns, the only thing needed to make this work is a method call in your model telling the module when the handler should be notified of the change, and what trigger should it attach. For example, lets assume you have a TodoItem model:

class TodoItem < ApplicationRecord
# Include the Notifyable concerns
include Notifyable

# Instruct Notifyable on which callback and handler should be used.
notify :on_create, :handler_job
end

Lastly, as you can see from the concerns, you now need to create an ActiveJob class called: Notification::TodoItemHandlerJob which will be enqueued whenever a ‘TodoItem’ database record is created. This job can do whatever you need to do in order to notify the relevant stakeholders of a new TodoItem record.

Whats more, this will be done asynchronously to the main thread of your application which should make your application more performant.

There are a few improvements I would eventually like to make to this:

  1. I’d like to package it as a gem and monkey patch it to the abstract ApplicationRecord class so that the concerns is automatically included on all models and that the include statement is not required.
  2. I’d like to be able to pass a &block to #notify instead of the handler job symbol because then you could eliminate that disgusting eval in the protected #handler_callback
  3. There is a bug in this concerns regarding the class variables the concerns. Because the class variables are stored at the class level, and not the ‘consuming’ class using the concerns, the handlers were being overwritten between models (which means that each model most use the same handler symbol). Fortunately, in the project that uses this, the conformity of all the notification handler jobs using the suffix ‘HandlerJob’ was deemed preferred anyway so it was not seen as a big problem. Alas, I’d very much like to fix it one day.

A better way to benchmark

Today I found some magic.

include ActiveSupport::Benchmarkable

An ActiveSupport module that provides an AMAZINGLY elegant alternative to the classic Ruby Benchmark class; which I had always considered to be a bit ‘clunky’.

ActiveSupport::Benchmarkable provides you a simple method ‘benchmark’ which you can pass a few options and block through and output the benchmark result to the log. Wrap this block around expensive operations or possible bottlenecks to get a time reading for the operation.

For example, let’s say you thought your data transformattion method was taking too long; you could wrap it in a benchmark block.

<% benchmark 'v' do %>
  <%= expensive_record_transforming %>
<% end %>

That would write something like “Processing some data (143.6ms)” to the log, which you can now use to monitor or compare speed over time (make sure its not getting worse as data size increases) or compared to alternate methods for optimisation.

The ActiveSupport::Benchmarkable docs also indicate that, you may give an optional logger level (:debug, :info, :warn, :error) as the :level option. The default logger level value is :info.

<% benchmark 'Low-level files', level: :debug do %>
  <%= lowlevel_files_operation %>
<% end %>

Finally, you can pass true as the third argument to silence all log activity (other than the timing information) from inside the block. This is great for boiling down a noisy block to just a single statement that produces one log line:

<% benchmark 'Process data files', level: :info, silence: true do %>
  <%= expensive_and_chatty_files_operation %>
<% end %>

while these samples are indicative of what they would look like in a .html.erb file (view) – it also works in any other place you use the mix in.

I have personally used these to great success in controllers and models to help diagnose specific controller/model operations suspected to be slow.

This ActiveSupport module is an immensely powerful tool to have in a developers arsenal. I highly recommend it’s use.