Kick Ahead
Kick Ahead allows you to push code to be executed in the future. The code is organized in Jobs with a simple API:
class MyJob < KickAhead::Job
def perform(some, args)
# Your code
end
end
MyJob.run_in 1.hour, "some", "args"
MyJob.run_at 2.hours.from_now, "some_args"
This library is minimalist and has no gem dependencies. However, in order to accomplish its task it requires the host app to provide certain features which this lib depends upon. By expressing dependencies in this way instead of by "hardcoded" gem dependencies, the user is free to reuse their existing solutions to common problems.
Satisfying requirements
We'll see first what requirements must be satisfied by the app.
A Regular clock
The most important thing is to have a regular clock available, which allows you to call code at regular intervals.
Possible solutions to this problem are "plugins" for background systems (like sidekiq-cron for sidekiq), the traditional cron system in a server, or maybe having an independent process control times with a loop manually or with rufus-scheduler.
Once you have a regular clock, you must use it to call KickAhead.tick
. Also, the interval between your clock ticks
must be specified in KickAhead.tick_interval
in seconds, for example:
KickAhead.tick_interval = 3600 # every hour
Additionally, you must make sure that no two KickAhead.tick
can be running at the same time across you entire
app (maybe across different servers).
A Persistence repository
Next you'll have to provide a persistence layer to store jobs, as a Repository object. A repository is any object that responds to the following methods and signatures:
-
create(klass, schedule_at, *args)
: Used to create a new job.klass
is a string representing the job class,schedule_at
is a datetime representing the moment in time when this is expected to be executed, and*args
is the list of arguments.
This method is expected to return an identifier as a string, whatever you want that to be, so that it can be used in the future to reference this job in the persistence repository.
-
each_job_in_the_past
: Returns a collection of job objects. In no particular order, but always jobs that are in the past respect current time.
A job object is a hash with the following properties, example:
{
id: "11",
job_class: "MyJob",
scheduled_at: Time.new(2017, 1, 1, 1, 1, 1),
job_args: [1, "foo"]
}
- id: The identifier you gave to the job
- job_class: same first argument of the
create
call. - scheduled_at: same second argument of the
create
call. - job_args: same third argument of the
create
call.
-
delete(id)
: Used to remove a job from the persistent storage. The given "id" is the identifier of the job as returned by thecreate
oreach_job_in_the_past
methods.
If you want to use an Active Record model for persistence, you can get an already made repository with this:
repository = KickAhead.create_active_record_repository_for(KickAheadJob)
The table structure must be as follows, taken from a rails schema (note you can name the table and the model whatever you like):
create_table "kick_ahead_jobs", force: :cascade do |t|
t.string "job_class", null: false
t.jsonb "job_args", null: false
t.datetime "scheduled_at", null: false
t.index ["scheduled_at"], name: "index_kick_ahead_jobs_on_scheduled_at"
end
Current time
Finally, since this library is heavily based on time, it cannot make any assumption about how are you managing
time in your application. Ruby's default behavior is to return the system time, but for a distributed
application that may run in different machines this is usually not desirable, and instead you might use
some other way to get the current time (like rails Time.current
).
Since this is a choice of the host app, you must also configure how KickAhead should get the current time by providing a lambda to return it.
KickAhead.current_time = -> { Time.current }
This way you can control what is considered to be the current time, and then make sure that this value is consistent
with the each_job_in_the_past
method in the repository, so time comparisons work as expected.
Usage
The basic idea is that you write code in Jobs, and then "push" those jobs to be executed at some point in the future. Examples:
class MyJob < KickAhead::Job
def perform(some, args)
# Your code
end
end
MyJob.run_in 1.hour, "some", "args"
MyJob.run_at 2.hours.from_now, "some_args"
This lib guarantees that your job will be executed between your given time and your given time + your configured tick_interval at most. If you want more precision, you'll need to reduce your tick_interval.
If, for some reason, your clock fails and the tick
method is not called for a long time, any job that
was configured to run during that time will not run as expected. On the next tick, we'll detect those
stale jobs and then the following logic will apply:
If the job is configured with a tolerance
value, and we're still inside the tolerance period, the job will
still run normally.
Tolerance can be configured from the job class and can be different per job:
class MyJob < KickAhead::Job
self.tolerance = 30.minutes
def perform(some, args)
# Your code
end
end
Or externally as well:
MyJob.tolerance = 2.hours
If tolerance is not satisfied, then the behavior depends on the out_of_time_strategy
configured. This
can be configured on a per job basis and by default is raise_exception
. The options are:
-
raise_exception
: An exception will be raised. The job is kept in the repository, waiting for an external action to correct the situation (remove the job, run it, or fix the situation in some other way). The exception gives information about the job class and arguments. No further jobs will be executed until this is corrected. -
ignore
: The out of time jobs will be simply deleted with no execution. -
hook
: In this case, the methodout_of_time_hook
will be called on the job instance in the same way theperform
method would, giving you the chance to do specific logics. The first argument, however, will be the original scheduling time, so you can perform comparisons with this information.
Configure it with:
class MyJob < KickAhead::Job
self.out_of_time_strategy = :raise_exception
# self.out_of_time_strategy = :ignore
# self.out_of_time_strategy = :hook
def perform(some, args)
# Your code
end
def out_of_time_hook(scheduled_at, some, args)
# Your code
end
end
In case an exception occurs inside your Job, nothing extraordinary will happen. Kick Ahead will not capture the exception, any other possible job will not be executed.
FAQS
- Q: What If I don't care about jobs executing out of time? I want the job to execute after time X, but after that, I don't need to be specific.
A: You can set the tolerance value to an very high value (ie: 300 years).
Development
After checking out the repo, run bin/setup
to install dependencies. Then, run rake test
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and tags, and push the .gem
file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/rogercampos/kick_ahead.