In Progress
Unit 1, Lesson 1
In Progress

Advanced Audited Predicate

Video transcript & code

In episode #279, we rewrote a complex predicate method so that it could take an optional "auditor" argument. The auditor is able to surface details about the decisions that went into the predicate's conclusion, in a user-visible and understandable way.

require "active_support"
require "active_support/core_ext"

class Reminder
  attr_reader :reminder_at, :completed, :last_reminded_at

  def initialize(completed: false, reminder_at:, last_reminded_at: nil)
    @completed = completed
    @reminder_at = reminder_at
    @last_reminded_at = last_reminded_at
  end

  alias completed? completed

  def alert_due?(audit=->(*, &block){block.call})
    return false if     audit.("task completed") { completed? }
    return false unless audit.("past due") { current_time_is_past_due_time? }
    return false if     audit.("sent yesterday") { sent_yesterday? }
    return false unless audit.("hour late enough") { hour_is_past? }
    return false unless audit.("minute late enough") { minute_is_past? }
    true
  end

  def current_time_is_past_due_time?
    reminder_at <= Time.current
  end

  def sent_yesterday?
    last_reminded_at != nil && last_reminded_at > 23.hours.ago
  end

  def hour_is_past?
    reminder_at.hour <= Time.current.hour
  end

  def minute_is_past?
    reminder_at.min <= Time.current.min
  end
end

As a a demonstration, here's the simplest example we used in that episode. We have a little lambda that simply takes the description and the result of each criterion in the predicate, and prints them to standard out.

require "./reminder"

r = Reminder.new(completed: false,
                 reminder_at: Time.new(2014, 1, 15, 22, 0))

auditor = ->(description, &test) {
  result = test.call
  puts "#{description}: #{result}"
  result
}

r.alert_due?(auditor)           # => false

# >> task completed: false
# >> past due: true
# >> sent yesterday: false
# >> hour late enough: false

This is just one example. Since auditors can be any callable object, the sky is the limit when it comes to what we do with the information they collect. It would be easy, for instance, to write an auditor that collected information to display as a validation error on a form.

Today I want to discuss some advanced considerations for this "audited predicate" pattern.

First off, what happens when we want to audit more than one method deep?

Here's an example. Let's say we decide to extract the last two tests in our predicate to a method called late_enough_to_alert?.

class Reminder
  # ...
  def late_enough_to_alert?(audit=->(*, &block){block.call})
    return false unless audit.("hour late enough") { hour_is_past? }
    return false unless audit.("minute late enough") { minute_is_past? }
    true
  end
  # ...
end

How should we alter the main composite predicate method? The most straightforward answer is that we add a call to the late_enough_to_alert? predicate, and pass in our auditor.

require "./reminder"
require "./late_enough_to_alert"

class Reminder
  # ...
  def alert_due?(audit=->(*, &block){block.call})
    return false if     audit.("task completed") { completed? }
    return false unless audit.("past due") { current_time_is_past_due_time? }
    return false if     audit.("sent yesterday") { sent_yesterday? }
    return false unless late_enough_to_alert?(audit)
    true
  end
  # ...
end

When we run our example code again, the output is identical to what we saw before.

require "./reminder2"

r = Reminder.new(completed: false,
                 reminder_at: Time.new(2014, 1, 15, 22, 0))

auditor = ->(description, &test) {
  result = test.call
  puts "#{description}: #{result}"
  result
}

r.alert_due?(auditor)           # => false

# >> task completed: false
# >> past due: true
# >> sent yesterday: false
# >> hour late enough: false

Before we move forward, let's eliminate a little bit of duplication. Both of these methods use the same inline lambda for their default auditor. Repeating this definition over and over could get annoying fast. Let's introduce an Auditing module, and give it a singleton method which will return this default auditor.

module Auditing
  def self.default_auditor
    ->(*, &block){block.call}
  end
end

Then we replace both instances of the inline lambda definition with calls to this new global method.

require "./reminder2"
require "./auditing"

class Reminder
  # ...
  def late_enough_to_alert?(audit=Auditing.default_auditor)
    return false unless audit.("hour late enough") { hour_is_past? }
    return false unless audit.("minute late enough") { minute_is_past? }
    true
  end

  def alert_due?(audit=Auditing.default_auditor)
    return false if     audit.("task completed") { completed? }
    return false unless audit.("past due") { current_time_is_past_due_time? }
    return false if     audit.("sent yesterday") { sent_yesterday? }
    return false unless late_enough_to_alert?(audit)
    true
  end
  # ...
end

This takes care of our little duplication problem.

Now let's introduce a new complication. In another part of the program, we have a helper method that takes a list of reminders and checks to see if any of them are due for alerts. This method was written before we added auditing to reminders, and it doesn't know anything about it.

def any_due?(reminders)
  reminders.any?(&:alert_due?)
end

Here's a quick demo of how it works. We instantiate two different reminders, and then pass them in an array to the helper method. It returns true if any of the reminders is due for an alert.

require "./reminder3"
require "./any_due"

r1 = Reminder.new(completed: false,
                  reminder_at: Time.new(2014, 1, 15, 22, 0))

r2 = Reminder.new(completed: false,
                  reminder_at: Time.new(2014, 1, 15, 1, 0))

any_due?([r1, r2])              # => true

If we want to audit the decisions made within this method, we have a problem. It doesn't know anything about auditors, and can't pass them through.

Now, obviously one solution would be to modify the any_due? method. But in the real world that might not be feasible. This example only has as single, simple method in between us and direct interaction with Reminder objects. But in a realistic scenario, there might be a half a dozen or more levels of call between here and the methods we want to audit. And some of those levels might be in third-party gem code that we can't change.

In effect, we need to find a way to effectively "tunnel" information down deeper into the call stack, without modifying the intervening methods. This might ring a bell: we talked about a technique for doing exactly that in episode #161.

Let's reconsider our whole approach to dealing with multiple levels of method that need to be audited. First, we're going to modify the alert_due? method. No longer will we pass the auditor down into the next layer of predicates. Instead, the call the the method we extracted will look exactly like the calls to all the other lower-level predicates.

require "./reminder3"

class Reminder
  # ...
  def alert_due?(audit=Auditing.default_auditor)
    return false if     audit.("task completed") { completed? }
    return false unless audit.("past due") { current_time_is_past_due_time? }
    return false if     audit.("sent yesterday") { sent_yesterday? }
    return false unless audit.("late enough to alert") { late_enough_to_alert? }
    true
  end
  # ...
end

Now we turn our attention to the Auditing module. We start by adding a new method called thread_local_state. This method stores a hash in a thread-local variable, or returns a pre-existing hash if it has already been initialized.

Next we update the definition of .default_auditor. We make it return either the value of a :current_auditor key inside the thread local storage, or, if none exists, the default do-nothing lambda we used before.

Now we define another new method. This one is called with_auditor. It takes an auditor object as an argument. Inside, it first saves off any existing current auditor, if any. Then it updates the current auditor using the passed-in object. Then it yields to caller code. After caller code is done, it cleans up by resetting the current auditor to the old value, which might have been nil.

module Auditing
  def self.default_auditor
    thread_local_state[:current_auditor] || ->(*, &block){block.call}
  end

  def self.with_auditor(auditor)
    old_auditor = thread_local_state[:current_auditor]
    thread_local_state[:current_auditor] = auditor
    yield
  ensure
    thread_local_state[:current_auditor] = old_auditor
  end

  def self.thread_local_state
    Thread.current[:auditing] ||= {}
  end
end

Let's see how we can put this to use. We build an auditor. Then we construct a with_auditor block around our use of the any_due? predicate. When we execute the code, we can now see our auditing output for each reminder that was processed. We've successfully passed an auditor down the call chain without having to update any intervening code.

require "./reminder4"
require "./any_due"
require "./auditing2.rb"

r1 = Reminder.new(completed: false,
                  reminder_at: Time.new(2014, 1, 15, 22, 0))

r2 = Reminder.new(completed: false,
                  reminder_at: Time.new(2014, 1, 15, 1, 0))

auditor = ->(description, &test) {
  result = test.call
  puts "#{description}: #{result}"
  result
}

Auditing.with_auditor(auditor) do
  any_due?([r1, r2])              # => true
end

# >> task completed: false
# >> past due: true
# >> sent yesterday: false
# >> hour late enough: false
# >> late enough to alert: false
# >> task completed: false
# >> past due: true
# >> sent yesterday: false
# >> hour late enough: true
# >> minute late enough: true
# >> late enough to alert: true

Let's look at one more variation on this theme before we stop.

This time, we're going to add a new singleton-level reader and writer for a thread-local value called level. We also add a reader for the current auditor.

Then we add an instance method to the Auditing module. We call it, simply, #audit. We have it take a description and a block.

Inside, it first increments the current level. Then it delegates auditing of the passed block to the current auditor. Afterwards, it decrements the current level.

module Auditing
  def self.default_auditor
    thread_local_state[:current_auditor] || ->(*, &block){block.call}
  end

  def self.with_auditor(auditor)
    old_auditor = thread_local_state[:current_auditor]
    thread_local_state[:current_auditor] = auditor
    yield
  ensure
    thread_local_state[:current_auditor] = old_auditor
  end

  def self.level
    thread_local_state[:level] ||= 0
  end

  def self.level=(new_level)
    thread_local_state[:level] = new_level
  end

  def self.current_auditor
    thread_local_state[:current_auditor]
  end

  def self.thread_local_state
    Thread.current[:auditing] ||= {}
  end

  def audit(description, &block)
    Auditing.level += 1
    Auditing.current_auditor.call(description, &block)
  ensure
    Auditing.level -= 1
  end
end

Back in our reminder class, we change things up. For the first time, we include the Auditing module into the class. Then for each method that uses auditing, we remove the arguments and change the audited lines from using object call notation to use ordinary method calls to the #audit method we defined moments ago.

require "./reminder4"
require "./auditing3"

class Reminder
  include Auditing

  # ...

  def late_enough_to_alert?
    return false unless audit("hour late enough") { hour_is_past? }
    return false unless audit("minute late enough") { minute_is_past? }
    true
  end

  def alert_due?
    return false if     audit("task completed") { completed? }
    return false unless audit("past due") { current_time_is_past_due_time? }
    return false if     audit("sent yesterday") { sent_yesterday? }
    return false unless audit("late enough") { late_enough_to_alert? }
    true
  end
  # ...
end

Now we update our standard output auditor a little bit. We add a prefix of star characters before each output line, with the number of stars based on the current auditing level.

When we run this code, we can see not only the decisions that went into it, but we can see which ones were at the top level, and which ones were subsidiary parts of higher-level predicates.

require "./reminder5"
require "./any_due"

r1 = Reminder.new(completed: false,
                  reminder_at: Time.new(2014, 1, 15, 22, 0))

r2 = Reminder.new(completed: false,
                  reminder_at: Time.new(2014, 1, 15, 1, 0))

auditor = ->(description, &test) {
  result = test.call
  stars = "*" * Auditing.level
  puts "#{stars} #{description}: #{result}"
  result
}

Auditing.with_auditor(auditor) do
  any_due?([r1, r2])              # => true
end

# >> * task completed: false
# >> * past due: true
# >> * sent yesterday: false
# >> ** hour late enough: false
# >> * late enough: false
# >> * task completed: false
# >> * past due: true
# >> * sent yesterday: false
# >> ** hour late enough: true
# >> ** minute late enough: true
# >> * late enough: true

There are other variations on this theme we could explore, but I think that's enough for today. Happy hacking!

Responses