Wrapping Rake
Useful before and after hooks to rake
On the verge of a very risky deploy with paltry test coverage, I recently set out to build a number of dashboards to at least provide some signal if something had gone horribly wrong. Here I’m focusing on my efforts on our cron tasks, almost all of which simply run one rake task or another. My primary goal: for all rake tasks, report success or failure. This information must be traceable to the name of the task that failed. Other metrics such as run-time are bonuses.
Not quite: task enhancement
Rake has a halfway useful feature called enhance. This allows you to dynamically attach dependencies to pre-defined rake tasks. In other words, it lets you define a task that will run prior to any desired task. You can also provide a block to Rake::Task#enhance
that will run after successful completion of the task.
task = Rake.application.tasks.find { ... }
task.enhance [:first_run_this, :next_run_this] do
# ... run this afterwards ...
end
Immediately, there are two problem with this feature and our goal.
- The block only runs on successful completion of the task. Not very useful when we want to log failures.
- The dependency tasks have no awareness of the task being modified. Meaning we don’t have access to the name of the primary task, so we can’t report which task succeeded or failed; our report has no idea what it’s reporting on!
As far as I could discover, there is no around_filter
style wrapper to rake tasks.
Run this no matter what
To solve problem 1, we can make judicious use of Kernel#at_exit
. This method accepts a block of code that will be run when the thread exits. This guarantees that we will have a post-execution hook that will be run on success or failure.
task log_task_stats: :environment do
at_exit do
# ... we can log stats here, if we have them
end
end
We’re gonna have to hack our way in
To solve problem 2, let’s take a look at the source of Rake::Task#enhance
. We can see it appends a block to a tasks @actions
ivar. And, looking at how @actions
is used in Rake::Tasks#execute
, we’re in luck: the blocks are called with the primary task as the first argument! Even better: the primary task block itself is an action in this array!
Rake::Tasks#actions
is part of the publicly document API, but it’s a bit more than dicey to start adding blocks into this array ourselves instead of going through public methods. However, left with no other option, that’s what we’re going to rely on.
In short: action blocks know the task they are attached to, but public methods only give us a way to run them after a task. We’ll use our own method to make sure our action block runs first.
Specifically:
- We will prepend an action block that will set up our initial task data. This allows us to define a before_hook block that knows and can store the name of the primary task.
- We’ll set up an after_hook, though the normal channels, that set a
success
boolean. This datum will let our final hook know if the tasks succeeded or failed. - We’ll use an
at_exit
hook to log our final stats, taking the task name stored by our first action block and the success status either set or left unset by the after_hook or it’s absence.
First, let’s define our before_hook as a lambda:
setup = lambda do |task, *_args|
@wrapped_rake_task_data = {
task_name: task.name,
started_at: Time.zone.now,
}
end
(We use *_args
because some tasks will pass these in, so we must accept them, but we don’t care about them.)
Next, let’s set up our final stats logging as its own task. Remember, we’re using at_exit
in a dependency task to run this on success or failure.
task log_task_stats: :environment do
# Run after task exits, regardless of success or failure
at_exit do
task_data = @wrapped_rake_task_data || {}
task_name = task_data[:task_name]
started_at = task_data[:started_at]
task_data[:exited_at] = Time.zone.now
task_data[:duration] = started_at && exited_at - started_at
metrics = MetricsClient.new(...)
metrics.emit(format(task_data))
end
end
I’m leaving the metrics
implementation here as pseudocode, since it’s not the focus of this post. Also omited are bomb-proof error handlers – this is, after all, going to be run at every rake task invocation: buyer beware.
Next we get the list of tasks we want to amend – in our case all tasks, except for environment
and our own log_task_stats
task – we can’t have log_task_stats
calling itself in an infinite loop!
tasks = Rake.application.tasks
tasks -= [
Rake::Task['environment'],
Rake::Task['log_task_stats'],
]
Finally, we attack the hooks, including an inline-defined “success” action block:
tasks.each do |task|
task.actions.prepend(setup)
task.enhance([:log_task_stats]) do
# This runs only if the task successfully completes
@wrapped_rake_task_data[:finished_at] = Time.zone.now
@wrapped_rake_task_data[:success] = true
end
end
The key, and the unconventional behavior we’re invoking, is to prepend
the setup lambda to actions
.
The regular, post-run action block sets the success
boolean and the end time; this block only runs if the task is successfully completed.
In sum
Rake::Task#enhance
allows us to define tasks that will run before our primary task, but have no access to the task name that we wish to modify. It also allows us to add a block that is aware of the task name but will only run on successful task completionKernel#at_exit
lets us run hook that will run after successful or failed completion.- Hacking our own action block at the front of the
@actions
array of a given task lets us set up an action block that runs before the task and has access to the task name that we are modifying.
Combined, these three behaviors allow us to transparently add effective monitoring code to all rake tasks, including third-party and not-yet-written-by-other-teammates tasks without any need for a sweeping rake refacctor, monkey-patch of imported gems, or requiring other developers who have no eye on devops to modify their usage of rake.
And that’s a wrap.