In Progress
Unit 1, Lesson 21
In Progress

Self Save Part 3

Video transcript & code

Moving on to another scenario…

Here's yet another version of the MonkeyPurchase class.

In this one, we've learned from mistakes of the past. There's an after_save hook here as well, but instead of putting work directly inside the hook, the hook just adds a job to a background job-queuing class.

As part of the enqueuing procedure, we pass the record ID of the current purchase object, so that the job can look it up.

require "active_record"
require "net/http"

class JobOTron
  def self.enqueue_job(*args, &job)
    (@jobs ||= []) << [job, *args]
  end

  def self.run_jobs
    (@jobs ||= []).each do |job|
      job.first.call(*job.drop(1))
    end
  end
end

class MonkeyPurchase < ActiveRecord::Base
  establish_connection :adapter  => "sqlite3",
                       :database => ENV.fetch("DB") { ":memory:" }

  connection.create_table( :monkey_purchases ) do |t|
    t.integer    :quantity
    t.string     :customer
    t.string     :postal_code
    t.string     :state
    t.timestamps null: false
  end

  after_save do
    if state == "awaiting_waiver"
      JobOTron.enqueue_job(id) do |id|
        purchase = MonkeyPurchase.find(id)
        purchase.email_waiver_to_customer
      end
    end
  end

  def save!
    if ENV["SELFSAVE"] == "YES"
      super
    else
      # NOOP
    end
  end

  def submitted
    self.state = "awaiting_waiver"
    save!
  end

  def waived
    self.state = "awaiting_approval"
    save!
  end

  def email_waiver_to_customer
    puts "Emailing monkey waiver to #{customer}"
  end
end

Here's some code for submitting a purchase.

It starts a transaction.

Then it makes a new MonkeyPurchase and marks it submitted.

Later on in the method, something bad happens, and an exception is raised.

But that's OK, because it's captured and reported before the end.

Let's go ahead and try this out.

After running the purchase submission, we'll tell the job class to run all queued jobs.

The result of this code is an exception.

Not the exception we intentionally raised to simulate a failure. That one was successfully caught.

No, what we see is an ActiveRecord::RecordNotFound error stemming from the deferred task.

require "./monkey_purchase4"

ENV["SELFSAVE"] = "YES"

def submit_purchase
  MonkeyPurchase.transaction do
    mp = MonkeyPurchase.new(customer: "Man in the Yellow Hat")
    mp.submitted

    # ...

    raise "Whoopsie"
  end
rescue
  puts "Something went wrong. Try again later!"
end


submit_purchase
JobOTron.run_jobs

# >> Something went wrong. Try again later!

# ~> ActiveRecord::RecordNotFound
# ~> Couldn't find MonkeyPurchase with 'id'=1
# ~>
# ~> /home/avdi/.gem/ruby/2.3.0/gems/activerecord-4.2.6/lib/active_record/core.rb:155:in `find'
# ~> /home/avdi/Dropbox/rubytapas/402-self-save/monkey_purchase4.rb:38:in `block (2 levels) in <class:MonkeyPurchase>'
# ~> /home/avdi/Dropbox/rubytapas/402-self-save/monkey_purchase4.rb:18:in `block in run_jobs'
# ~> /home/avdi/Dropbox/rubytapas/402-self-save/monkey_purchase4.rb:17:in `each'
# ~> /home/avdi/Dropbox/rubytapas/402-self-save/monkey_purchase4.rb:17:in `run_jobs'
# ~> xmptmp-in3707Yem.rb:20:in `<main>'

So what happened? Well, remember how the operations in the submit_purchase method were all enclosed in a transaction?

Well, the MonkeyPurchase#submitted method implicitly saved the object. And a job was en queued to do some deferred work—complete with the row ID of the MonkeyPurchase record it should work on.

But it was saved in the context of that database transaction.

Later, the exception that was raised caused the transaction to be aborted. The save was rolled back. But no one told the delayed job about this. And it blew up.

A failed job isn't the end of the world. But sooner or later we're going to have to trace down the source of the job failures. Good luck debugging that convoluted chain of events!

Problems aren't only caused by introducing transactions. Removing a transaction can be just as problematic.

Here's a new version of MonkeyPurchase.

It has a new association: it can own a single Waiver object.

This is an electronically signed waiver indemnifying the company from any and all damage and/or poo-flinging incidents caused by the delivered monkeys.

After the order is marked as submitted, the MonkeyPurchase object saves itself, just as in all the preceding examples.

But this time, it also creates the associated waiver before returning.

Or, well, it tries to, anyway. Sadly, there is a bug in creating the Waiver object.

require "active_record"

class ActiveRecord::Base
  establish_connection :adapter  => "sqlite3",
                       :database => ENV.fetch("DB") { ":memory:" }

  connection.create_table( :monkey_purchases ) do |t|
    t.integer    :quantity
    t.string     :customer
    t.string     :postal_code
    t.string     :state
    t.timestamps null: false
  end

  connection.create_table( :waivers ) do |t|
    t.integer :monkey_purchase_id
    t.boolean :signed
  end
end

class MonkeyPurchase < ActiveRecord::Base

  has_one :waiver

  def save!
    if ENV["SELFSAVE"] == "YES"
      super
    else
      # NOOP
    end
  end

  def submitted
    self.state = "awaiting_waiver"
    save!
    create_waiver
  end

  def waived
    self.state = "awaiting_approval"
    save!
  end
end

class Waiver < ActiveRecord::Base
  before_create do
    raise "Behold, a bug!"
  end

  belongs_to :monkey_purchase
end

Here's the purchase submission code which triggers the methods we just looked at.

Once again, we see a transaction surrounding it.

Once again, a purchase is instantiated and submitted.

Once again, errors are caught and reported.

Let's run this.

We can see the captured exception, but not much else.

Let's look at the ending object counts in the MonkeyPurchase and Waiver database tables, and run it again.

Both are zero. This is good. As a result of the error, the transaction has prevented the MonkeyPurchase from being saved in an inconsistent state.

require "./monkey_purchase6"

ENV["SELFSAVE"] = "YES"

def submit_purchase
  MonkeyPurchase.transaction do
    mp = MonkeyPurchase.new(customer: "Man in the Yellow Hat")
    mp.submitted
  end
rescue => e
  puts e
end


submit_purchase
MonkeyPurchase.count            # => 0
Waiver.count                    # => 0

# >> Behold, a bug!

But then, later on, we run the exact same code outside the context of a transaction.

Maybe because we run it in different situation, like a delayed job. Or maybe, much later, we go through this code and say to ourselves "wait a sec, what's this transaction doing here? There's obviously no saving going on, let alone multiple separate saves that need to be grouped into a transaction"." And then we delete the transaction.

However it comes to pass, we run this innocent-seeming code without a transaction.

And this time, the object counts don't come out right.

There's now a MonkeyPurchase that's in a state which implies it has an attached waiver—but the waiver is missing.

Of course, we didn't actually check the counts. Oh no. We just blithely allowed other code to keep running with the expectation that all submitted MonkeyPurchase objects have an associated Waiver.

Like this code here, which examines the creation dates of waivers.

And which now blows up.

What happened this time was that our object that quietly saves itself developed a hidden and completely implicit dependency on running in the context of a transaction. Without the benefit of a transaction, it is no longer exception-safe.

require "./monkey_purchase6"

ENV["SELFSAVE"] = "YES"

def submit_purchase
  mp = MonkeyPurchase.new(customer: "Man in the Yellow Hat")
  mp.submitted
rescue => e
  puts e
end


submit_purchase
MonkeyPurchase.count            # => 1
Waiver.count                    # => 0

MonkeyPurchase.where(state: "awaiting_waiver").each do |mp|
  puts mp.waiver.created_at
end
# =>

# >> Behold, a bug!

# ~> NoMethodError
# ~> undefined method `created_at' for nil:NilClass
# ~>
# ~> xmptmp-in3707m3b.rb:18:in `block in <main>'
# ~> /home/avdi/.gem/ruby/2.3.0/gems/activerecord-4.2.6/lib/active_record/relation/delegation.rb:46:in `each'
# ~> /home/avdi/.gem/ruby/2.3.0/gems/activerecord-4.2.6/lib/active_record/relation/delegation.rb:46:in `each'
# ~> xmptmp-in3707m3b.rb:17:in `<main>'

Are we done yet? Not quite!

Here's one last version of our models.

This time, rather than saving an associated waiver, the submission code just builds one but doesn't save it.

This should then be saved automatically when the MonkeyPurchase is saved.

require "active_record"

class ActiveRecord::Base
  establish_connection :adapter  => "sqlite3",
                       :database => ENV.fetch("DB") { ":memory:" }

  connection.create_table( :monkey_purchases ) do |t|
    t.integer    :quantity
    t.string     :customer
    t.string     :postal_code
    t.string     :state
    t.timestamps null: false
  end

  connection.create_table( :waivers ) do |t|
    t.integer :monkey_purchase_id
    t.boolean :signed
  end
end

class MonkeyPurchase < ActiveRecord::Base

  has_one :waiver

  def save!
    if ENV["SELFSAVE"] == "YES"
      super
    else
      # NOOP
    end
  end

  def submitted
    build_waiver
    self.state = "awaiting_waiver"
    save!
  end

  def waived
    self.state = "awaiting_approval"
    save!
  end
end

class Waiver < ActiveRecord::Base
  belongs_to :monkey_purchase
end

Here's some very simple code using our models.

We make a purchase.

We mark it submitted.

We save it.—Then we set its customer, a bit belatedly, and tell the object that is waiver has been signed.

Sometime later, in a bit of code far, far away, we look up the waiver by the id of the purchase.

If we compare the customer on our original purchase object and on the one associated with our found waiver, they are the same. All is well.

require "./monkey_purchase5"

ENV["SELFSAVE"] = "YES"

mp = MonkeyPurchase.new
mp.submitted
mp.save

mp.customer = "Avdi Grimm"
mp.waived
# ...

w = Waiver.find_by_monkey_purchase_id(mp)
mp.customer                     # => "Avdi Grimm"
w.monkey_purchase.customer      # => "Avdi Grimm"

But then one day we decide that all these self-saves really are a bad idea. So we eliminate them.

Suddenly, while our initial purchase object shows the customer name, the one found by way of the waiver shows no customer name at all!

require "./monkey_purchase5"

mp = MonkeyPurchase.new
mp.submitted
mp.save

mp.customer = "Avdi Grimm"
mp.waived
# ...

w = Waiver.find_by_monkey_purchase_id(mp)
mp.customer                     # => "Avdi Grimm"
w.monkey_purchase.customer      # => nil

Now, removing the self-saves has broken code at a distance.

What's going on here? It's not at all clear, but the problem is that before, we were unknowingly relying on the implicit #save in the #waived method.

Without it, our changes are left unsaved, and any new objects representing the same purchase will be inconsistent with the modified version. Way back in episode #177 we talked about the problem of "aliasing" in ActiveRecord models. Here, it has turned up and bitten us again.

All this goes to show that not only does self-saving cause problems in its own right; it can also lead to code that "works by accident", which then breaks when the self-saves are changed.

It's been a long and arduous trip. We've been over many examples of problems associated with objects saving themselves. And yet, believe it or not, I left out a few of the examples that I came across in my research.

I felt that it was important that we get a good survey of the different kinds of issues that can crop up. If I had only pointed out one or two issues, it might have been easy to say that those were just rare cases of "doing it wrong". Hopefully by now I've succeeded in making a case that there are so many non-obvious things that can go wrong, it's just not worth it to have an object save itself to the database.

As usual when a technique seems to go wrong repeatedly in different and surprising ways, there is a deeper design lesson here. A domain model's business responsibilities, and its ability to persist itself to a database, are fundamentally different and orthogonal roles. The one should not be tied to the other. Business actions should not be making decisions about persistence; and persistence actions should not be triggering business rules.

My recommendation is that you shy away from having objects save themselves. An object can still be saved in a controller action, or as a side effect of some other object being saved. But business logic should stay free of saves—and the same goes for record creation and deletion, as well.

Following this advice can sometimes lead to some head-scratching problems, especially when servicing a user requests requires changes in a bunch of different domain models. We'll look at strategies for systematically tackling those cases in a future episode, when we talk about Aggregate Objects.

But I think this is enough food for thought for now. Happy hacking!

Responses