DEV Community

Carlos Ghan
Carlos Ghan

Posted on

Decorating Rake tasks for fun and profit

Alt Text

Why?

I just thought it would be fun to try this experiment: decorate (wrap) dynamically any Rake task execution with some arbitrary code.
Also, it would be nice if I could do it even for tasks defined outside my project, for example in gems, without having to modify the source-code.

Examples:

  • Custom logging around task's execution (e.g. time spent, maybe using funny colors)
  • Run a Rails DB task in read-only mode (setting ActiveRecord connection to use a replica DB)
  • Notify in Slack the results of some tasks
  • ...etc., you name it

I'm not going to use Rake::Task.enhance which, as you may already know, allows you to execute another Rake task as a dependency before and/or after the "enhanced" task, because you lose some context, and in order to do things like referencing a variable defined at the "before"-task from the "after"-task, you may need to resort to some trickery...

How?

Recently, I've learned about Rule Tasks. As per the documentation, "rules" let you

synthesize a task by looking at a list of rules supplied in the Rakefile.

This feature can be (ab)used to accomplish my goal... The rule-block receives (as arguments) an instance of Rake::FileTask (which responds to #name) and the arguments passed to the task; we could do something along the lines of:

rule /^decorated:.*/ do |t, args|
  task_name = t.name.delete_prefix('decorated:')

  # Code to be executed BEFORE the task...

  Rake::Task[task_name]).invoke(*args)

  # Code to be executed AFTER the task...
end
~~~{% endraw %}

In the previous snipppet, invoking Rake with a task name prefixed by a "marker" string, will be processed by this rule. (I chose {% raw %}`decorated:`{% endraw %} but actually it could be anything, I just felt that using {% raw %}`<label>:`{% endraw %} looked more Rake-ish 😉)

Despite it seems "rules" where designed to be used with file-name matching patterns in mind (like you do with GNU Make), it does the trick anyway; yeah, hacky, I know.

This is an example that logs task's execution time:{% raw %}

~~~ruby
rule /^timed:.+/ do |t, args|
  task_name = t.name.delete_prefix('timed:')

  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)

  Rake::Task[task_name].invoke(*args)

  end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  total_time = end_time - start_time
  Rails.logger.info("#{task_name} executed in #{total_time} seconds")
end
~~~

Then, you can simply do, for example:
~~~shell
bundle exec rake timed:db:seed
~~~

And, yes, the meticulous reader may have noticed that it doesn't return the "total" time as when using the shell's `time` command (as in `time bundle exec rake ...`), since it doesn't take into account any startup related overhead, but the example was just for illustration purposes.

Now, this code has a problem though... if the invoked task fails (which usually means it executes {% raw %}`exit`{% endraw %} or {% raw %}`abort`{% endraw %}, and thus a {% raw %}`SystemException`{% endraw %} is raised), the next line of code after {% raw %}`Rake::Task[task_name].invoke`{% endraw %} won't be executed.

Well... not the cleanest way in the world, but we can leverage [`at_exit`](https://ruby-doc.org/core-2.7.2/Kernel.html#method-i-at_exit) to register a block so that it's always executed at program's exit no matter what:

~~~ruby
rule /^timed:.+/ do |t, args|
  task_name = t.name.delete_prefix('timed:')

  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)

  at_exit do
    # Here goes the "after-task" code
    total_time = end_time - start_time
    Rails.logger.info("#{task_name} executed in #{total_time} seconds")
  end

  Rake::Task[task_name].invoke(*args)
end
~~~

Neat 🙂

Note that you can also compose these "decorator rules": 

~~~shell
bundle exec rails safe:timed:db:drop
~~~

where `safe` could be the following rule:

~~~ruby
rule /^safe:/ do |t, args|
  task_name = t.name.delete_prefix('safe:')

  print "Do you really want to perform this action (yes/no)? "
  confirmed = gets.chomp == 'yes'

  if confirmed
    Rake::Task[task_name].invoke(*args)
  else
    puts "Phew... almost did some crazy thing"
  end
end
~~~

And that's all.

## Closing words

In the "real world", the use of this technique may be arguable and even frown-upon, because there's too much magic going on (read it "brittle non-explicit stuff"), but hey... it may help you debugging an issue (like it did for me) or with some ad-hoc code that you won't commit into that pristine application code-repository 😬

----
*Cover image: ["Max and Ruby Party"]( https://www.flickr.com/photos/13698839@N00/3221858084) by Kid's Birthday Parties is licensed with CC BY-ND 2.0. To view a copy of this license, visit https://creativecommons.org/licenses/by-nd/2.0/*
Enter fullscreen mode Exit fullscreen mode

Top comments (0)