leah blogs

March 2005

15mar2005 · Restartable Exceptions

One thing I always liked about Common Lisp is its mighty condition system. Conditions provide what Exceptions do in Ruby, and more.

Usually, when an Exception is raised in Ruby, it may be rescued by providing an error handler (possibly even a generic one that displays an error page in a web application, for example), or ignored, which will end the program. One thing you cannot do in Ruby is to use Exceptions to emit warnings, as there is no way to resume the erroneous code when you are already in the handler.

Not till now, at least. :-) I quickly coded an Common Lispish system to restart exceptions. It’s best shown by developing a simple application, so I’ll port the piece of code that explains Conditions in Practical Common Lisp.

This is an interactive Ruby description. Fire up your irb and paste the very last piece of code. Now, please copy and paste the code as you are reading to try the examples live. Happy hacking.

First, here is the code without any error handling:

class LogAnalyzer
  def initialize(logs)
    @logs = logs
  end

  def analyze
    @logs.each { |log|
      analyze_log log
    }
  end

  def analyze_log(log)
    parse_log(log).each { |line|
      p line
    }
  end

  def parse_log(log, &block)
    entries = []
    log.split("\n").each { |line|
      entries << parse_log_entry(line)
    }
    entries
  end

  def parse_log_entry(line)
    line[/^\d+ (\w+)/, 1]
  end
end

Let’s call it with three example logs:

def test
  LogAnalyzer.new([<<LOGA, <<LOGB, <<LOGC]).analyze
1 foo
2 bar
3 quux
LOGA
"?$%
&)%$
-,$"&
4 good_but_to_late
LOGB
1 foo
bar
3 quux
LOGC
end

test

You now should see this output:

"foo"
"bar"
"quux"
nil
nil
nil
"good_but_to_late"
"foo"
nil
"quux"

All those nils are mistakes in our log files! Eww. We should raise an error if there is an erroneous line in our logs, and then ignore it so our program will not totally stop:

class LogAnalyzer
  class MalformedLogEntryError < StandardError; end

  def parse_log(log, &block)
      entries = []
          log.split("\n").each { |line|
      begin
        entries << parse_log_entry(line)
      rescue MalformedLogEntryError
        # Skip
      end
    }
    entries
  end

  def parse_log_entry(line)
    line[/^\d+ (\w+)/, 1] or raise MalformedLogEntryError
  end
end

This will result in (rerun test):

"foo"
"bar"
"quux"
"good_but_to_late"
"foo"
"quux"

Now, this looks much better, but we obviously should tell the user we had to skip some entries. Now, this is where the trouble starts. Usually you would not want to put that notification code into parse_log, because parse_log is really our backend… it should go to analyze. Here, restartable expections become a need:

class LogAnalyzer
  def analyze
    @logs.each { |log|
      begin
        analyze_log log
      rescue MalformedLogEntryError => e
        warn "Skipped invalid line: #{e.data}"
        e.restart :skip_line
      end
    }
  end

  def parse_log(log, &block)
    entries = []
    log.split("\n").each { |line|
      begin
        entries << parse_log_entry(line)
      rescue MalformedLogEntryError => e
        e.data = line
        e.restart_case(:skip_line) {
          # Skip
        }
        e.handle_restarts
      end
    }
    entries
  end
end

Now we get this output:

"foo"
"bar"
"quux"
Skipped invalid line: "?$%
Skipped invalid line: &)%$
Skipped invalid line: -,$"&
"good_but_to_late"
Skipped invalid line: bar
"foo"
"quux"

Ignoring invalid entries is obviously not the only way to handle invalid logfiles (one could say it’s a rather bad way). We may want to replace single lines with something different. Let’s implement this approach, called use_value just like in Common Lisp:

class LogAnalyzer
  def analyze
    @logs.each { |log|
      begin
        analyze_log log
      rescue MalformedLogEntryError => e
        e.restart :use_value, "xyzzy"
      end
    }
  end

  def parse_log(log, &block)
    entries = []
    log.split("\n").each { |line|
      begin
        entries << parse_log_entry(line)
      rescue MalformedLogEntryError => e
        e.data = line
        e.restart_case(:skip_line) {
      # Skip
        }
        e.restart_case(:use_value) { |replacement|
          entries << replacement
        }
        e.handle_restarts
      end
    }
    entries
  end
end

Works beautifully, now all invalid lines get xyzzy:

"foo"
"bar"
"quux"
"xyzzy"
"xyzzy"
"xyzzy"
"good_but_to_late"
"foo"
"xyzzy"
"quux"

To make you see what complex situations you can handle with this simple code, have a look at this example. We silently ignore wrong entries, but when the log has more than 2 errors, we skip the whole thing. Also, lines with bar in it for some reason or another unfortunately got broken, so let’s restore them on the fly:

class LogAnalyzer
  def analyze
    @logs.each { |log|
      invalid_entries = 0

      begin
        analyze_log log
      rescue MalformedLogEntryError => e
        if invalid_entries >= 2
          next
        elsif e.data =~ /bar/
          e.restart :use_value, "BAR"
        else
          invalid_entries += 1
          e.restart :skip_line
        end
      end
    }
  end

  def parse_log(log, &block)
    entries = []
    log.split("\n").each { |line|
      begin
        entries << parse_log_entry(line)
      rescue MalformedLogEntryError => e
        e.data = line
        e.restart_case(:skip_line) {
          # Skip
        }
        e.restart_case(:use_value) { |replacement|
          entries << replacement
        }
        e.handle_restarts
      end
    }
    entries
  end
end

Now, look how the very wrong log totally got skipped:

"foo"
"bar"
"quux"
"foo"
"BAR"
"quux"

Finally, here are the extensions to Exception I did to make this possible (I think I should make that a library):

class Exception
  attr_accessor :data

  def restart_case(strategy, &block)
    @restarts ||= {}
    @restarts[strategy] = block
    self
  end

  def handle_restarts
    callcc { |@continuation| raise self }
  end

  def restart(strategy, *params)
    @continuation.call(@restarts[strategy].call(*params))
  end
end

NP: Bob Dylan—Sara

Copyright © 2004–2022