kick_ahead 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 59e2d5091b3c8f9f670398b61d29f1622316acb8
4
+ data.tar.gz: c55ea15fbd789c84a2a4e92a01e7619f4359c69c
5
+ SHA512:
6
+ metadata.gz: 8da428f05bc52542d04eff590ac7189318c3788544945fdcce98cd66c3a45a8425dd2889816eb3b846ec0572c39020a6318c6682eb9debccfbd046e339a237f7
7
+ data.tar.gz: 4399934d5ea49829ba721e45acbf5be03f023e98ba4929c8b94d54794871f69ad6119d111f8eef306c4b9663b68787b65bd163f74029ffa438eee3c316685dec
data/.gitignore ADDED
@@ -0,0 +1,8 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
data/.travis.yml ADDED
@@ -0,0 +1,8 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.1.10
5
+ - 2.2.8
6
+ - 2.3.4
7
+ - 2.4.2
8
+ before_install: gem install bundler -v 1.16.0
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4
+
5
+ # Specify your gem's dependencies in kick_ahead.gemspec
6
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,24 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ kick_ahead (0.1.0)
5
+
6
+ GEM
7
+ remote: https://rubygems.org/
8
+ specs:
9
+ minitest (5.10.3)
10
+ rake (10.5.0)
11
+ timecop (0.9.1)
12
+
13
+ PLATFORMS
14
+ ruby
15
+
16
+ DEPENDENCIES
17
+ bundler (~> 1.16)
18
+ kick_ahead!
19
+ minitest (~> 5.0)
20
+ rake (~> 10.0)
21
+ timecop
22
+
23
+ BUNDLED WITH
24
+ 1.16.0
data/README.md ADDED
@@ -0,0 +1,257 @@
1
+ # KickAhead
2
+
3
+ Allows you to push code to be executed in the future. The code is organized in Jobs with the same API
4
+ sidekiq has:
5
+
6
+ ```ruby
7
+ class MyJob < KickAhead::Job
8
+ def perform(some, args)
9
+ # Your code
10
+ end
11
+ end
12
+ ```
13
+
14
+ ```ruby
15
+ MyJob.run_in 3600, "some", "args"
16
+ MyJob.run_at Time.now + 2 * 3600, "some", "args"
17
+ ```
18
+
19
+ This library is minimalist and the user must provide a persistent storage system as well as a polling
20
+ mechanism, that will be used to control the timings.
21
+
22
+
23
+ ## Installation
24
+
25
+ Add this line to your application's Gemfile:
26
+
27
+ ```ruby
28
+ gem 'kick_ahead'
29
+ ```
30
+
31
+ And then execute:
32
+
33
+ $ bundle
34
+
35
+ Or install it yourself as:
36
+
37
+ $ gem install kick_ahead
38
+
39
+ ## Usage
40
+
41
+
42
+ First, you need to provide a polling mechanism that allows you to call `KickAhead.tick` at regular intervals.
43
+ This interval must be also configured at `KickAhead.tick_interval`. You application can call `tick` more
44
+ frequently than what you establish here, but never less frequently. If your polling source is not accurate
45
+ but have a predictable behavior / margin of error, configure the `tick_interval` to be your maximum possible
46
+ frequency even if your real polling source frequency is usually higher.
47
+
48
+ If you fail to call `tick` frequently enough, you may have dropped jobs (see below).
49
+
50
+ Also importantly, your call to `tick` must be atomic in your application. Only one `tick` must be in execution
51
+ at any given time. You must use some sort of locking mechanism to ensure this, like ruby's `Mutex#synchronize`
52
+ if you only have one process that may tick in your application, or some other application-wide lock mechanism
53
+ otherwise (i.e. using redis or postgres advisory locks).
54
+
55
+ You'll also need to provide a Repository object, to offer a persistent storage (see below in Configuration).
56
+
57
+ The basic idea is that you write code in Jobs, and then "push" those jobs to be executed at some point in the
58
+ future. Examples:
59
+
60
+ ```ruby
61
+ MyJob.run_in 3600, "some", "args"
62
+ MyJob.run_at Time.now + 2 * 3600, "some_args"
63
+ ```
64
+
65
+ This lib guarantees that your job will be executed between your given time and your given time + your configured
66
+ tick_interval at most. If you want more precision, you'll need to decrease your tick_interval.
67
+
68
+ If, for some reason, your polling fail and the `tick` method is not called for a long time, any job that
69
+ was configured to run during that time will not run as expected. On the next tick, we'll detect those
70
+ stale jobs and then the following may occur:
71
+
72
+ If the job is configured with a `tolerance` value, and we're still inside the tolerance period, the job will
73
+ still run normally.
74
+
75
+ Tolerance can be configured from the job class and can be different per job:
76
+
77
+ ```ruby
78
+ class MyJob < KickAhead::Job
79
+ self.tolerance = 30.minutes
80
+
81
+ def perform(some, args)
82
+ # Your code
83
+ end
84
+ end
85
+ ```
86
+
87
+ Or externally as well:
88
+
89
+ `MyJob.tolerance = 2.hours`
90
+
91
+ If tolerance is not satisfied, then the behavior depends on the `out_of_time_strategy` configured. This
92
+ can be configured on a per job basis and by default is "raise_exception". The options are:
93
+
94
+ - `raise_exception`: An exception will be raised. The job is kept in the repository, waiting for an external
95
+ action to correct the situation (remove the job, change it's schedule time, etc.). The exception gives
96
+ information about the job class and arguments. No further jobs will be executed until this is corrected.
97
+
98
+ - `ignore`: The out of time jobs will be simply deleted with no execution.
99
+
100
+ - `hook`: In this case, the method `out_of_time_hook` will be called on the job instance in the same way
101
+ the `perform` method would, giving you the change to do specific logics. The first argument, however, will be the
102
+ original scheduling time, so you can perform comparisons with this information.
103
+
104
+ Configure it with:
105
+
106
+ ```ruby
107
+ class MyJob < KickAhead::Job
108
+ # self.out_of_time_strategy = :raise_exception
109
+ # self.out_of_time_strategy = :ignore
110
+ self.out_of_time_strategy = :hook
111
+
112
+ def perform(some, args)
113
+ # Your code
114
+ end
115
+
116
+ def out_of_time_hook(scheduled_at, some, args)
117
+ # Your code when out of time
118
+ end
119
+ end
120
+ ```
121
+
122
+ In case an exception occurs inside your Job, nothing extraordinary will happen. Kick Ahead will not capture
123
+ the exception, any other possible jobs will not be processed.
124
+
125
+ Your jobs are expected to be quick. If a heavy work has to be done, delegate it to the background.
126
+
127
+
128
+ ## Configuration
129
+
130
+ ### Polling interval
131
+
132
+ `KickAhead.tick_interval = 3600`
133
+
134
+ This value must be set to the expected polling interval, in seconds. In the example, the polling frequency
135
+ is 1 hour. Your code is then expected to call:
136
+
137
+ `KickAhead.tick`
138
+
139
+ every hour.
140
+
141
+
142
+ ### Repository
143
+
144
+ You're also expected to provide a repository to implement persistence over the data used to store jobs.
145
+
146
+ A repository is any object that responds to the following methods:
147
+
148
+ - `create(klass, schedule_at, *args)`: Used to create a new job. `klass` is a string representing the
149
+ job class, `schedule_at` is a datetime representing the moment in time when this is expected to be executed,
150
+ and `*args` is an expandable list of arguments (you can use json columns in postgres to store those easily).
151
+
152
+ This method is expected to return an identifier as a string, whatever you want that to be, so that it can be
153
+ used in the future to reference this job in the persistent repository.
154
+
155
+ - `each_job_in_the_past`: Returns a collection of job objects (hashes). In no particular order, but always
156
+ jobs that are in the past respect current time. Kick Ahead will then either execute or discard them depending
157
+ on the configurations.
158
+
159
+ A job is a hash with the following properties, example:
160
+
161
+ ```ruby
162
+ {
163
+ id: "11",
164
+ job_class: "MyJob",
165
+ job_args: [1, "foo"],
166
+ scheduled_at: Time.new(2017, 1, 1, 1, 1, 1)
167
+ }
168
+ ```
169
+
170
+ - id: The identifier you gave to the job
171
+ - job_class: same first argument of the `create` call.
172
+ - job_args: same third argument of the `create` call.
173
+ - scheduled_at: same second argument of the `create` call.
174
+
175
+ - `delete(id)`: Used to remove a job from the persistent storage. The given "id" is the identifier of the
176
+ job as returned by the `create` or `each_job_in_the_past` methods.
177
+
178
+
179
+ ### Current time
180
+
181
+ Since this library is heavily based on time, it cannot make any assumption about how are you managing
182
+ the time in your application. Ruby's default behavior is to return the system time, but for a distributed
183
+ application that may run in different machines this is usually not desirable, and instead you should instead use
184
+ some other way to get the current time (i.e. rails `Time.current`).
185
+
186
+ Since this is a choice of the host app, you must also configure how KickAhead should get the current time by
187
+ providing a lambda to return it.
188
+
189
+ ```ruby
190
+ KickAhead.current_time = -> { Time.current }
191
+ ```
192
+
193
+ This way you can control what is considered to be the current time, and then make sure that this value is consistent
194
+ with the `each_job_in_the_past` method in the repository, so time comparisons work as expected.
195
+
196
+
197
+ ### Repository example with ActiveRecord
198
+
199
+ ```ruby
200
+ # create_table "kick_ahead_jobs", force: :cascade do |t|
201
+ # t.string "job_class", null: false
202
+ # t.jsonb "job_args", null: false
203
+ # t.datetime "scheduled_at", null: false
204
+ # t.index ["scheduled_at"], name: "index_kick_ahead_jobs_on_scheduled_at"
205
+ # end
206
+ class KickAheadJob < ActiveRecord::Base
207
+ end
208
+
209
+ module RepositoryExample
210
+ extend self
211
+
212
+ def each_job_in_the_past
213
+ KickAheadJob.where('scheduled_at <= ?', Time.current).find_each do |job|
214
+ yield(as_hash(job))
215
+ end
216
+ end
217
+
218
+ def create(klass, schedule_at, *args)
219
+ job = KickAheadJob.create! job_class: klass, job_args: args, scheduled_at: schedule_at
220
+ job.id
221
+ end
222
+
223
+ def delete(id)
224
+ KickAheadJob.find(id).delete
225
+ end
226
+
227
+ private
228
+
229
+ def as_hash(job)
230
+ {
231
+ id: job.id,
232
+ job_class: job.job_class,
233
+ job_args: job.job_args,
234
+ scheduled_at: job.scheduled_at
235
+ }
236
+ end
237
+ end
238
+
239
+ ```
240
+
241
+ ## FAQS
242
+
243
+ 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
244
+ don't need to be specific (ej: fail if the time doesn't fit).
245
+
246
+ A: You can set the tolerance value to an incredible high value (ie: 300 years).
247
+
248
+
249
+ ## Development
250
+
251
+ 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.
252
+
253
+ 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](https://rubygems.org).
254
+
255
+ ## Contributing
256
+
257
+ Bug reports and pull requests are welcome on GitHub at https://github.com/rogercampos/kick_ahead.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList["test/**/*_test.rb"]
8
+ end
9
+
10
+ task :default => :test
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "kick_ahead"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,35 @@
1
+ lib = File.expand_path("../lib", __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require "kick_ahead/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "kick_ahead"
7
+ spec.version = KickAhead::VERSION
8
+ spec.authors = ["Roger Campos"]
9
+ spec.email = ["roger@rogercampos.com"]
10
+
11
+ spec.summary = %q{Push code to execute in the future, without dependencies}
12
+ spec.description = %q{Push code to execute in the future, without dependencies}
13
+ spec.homepage = "https://github.com/rogercampos/kick_ahead"
14
+
15
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
16
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
17
+ if spec.respond_to?(:metadata)
18
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
19
+ else
20
+ raise "RubyGems 2.0 or newer is required to protect against " \
21
+ "public gem pushes."
22
+ end
23
+
24
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
25
+ f.match(%r{^(test|spec|features)/})
26
+ end
27
+ spec.bindir = "exe"
28
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
29
+ spec.require_paths = ["lib"]
30
+
31
+ spec.add_development_dependency "bundler", "~> 1.16"
32
+ spec.add_development_dependency "rake", "~> 10.0"
33
+ spec.add_development_dependency "minitest", "~> 5.0"
34
+ spec.add_development_dependency "timecop"
35
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KickAhead
4
+ class Job
5
+ # Simplified version extracted from ActiveSupport
6
+ def self.class_attribute(name)
7
+ define_singleton_method(name) { nil }
8
+
9
+ ivar = "@#{name}"
10
+
11
+ define_singleton_method("#{name}=") do |val|
12
+ singleton_class.class_eval do
13
+ define_method(name) { val }
14
+ end
15
+
16
+ if singleton_class?
17
+ class_eval do
18
+ define_method(name) do
19
+ if instance_variable_defined? ivar
20
+ instance_variable_get ivar
21
+ else
22
+ singleton_class.send name
23
+ end
24
+ end
25
+ end
26
+ end
27
+ val
28
+ end
29
+ end
30
+
31
+ class_attribute :tolerance
32
+ class_attribute :out_of_time_strategy
33
+
34
+ self.tolerance = 0
35
+ self.out_of_time_strategy = :raise_exception
36
+
37
+ def self.run_in(delta, *args)
38
+ KickAhead.repository.create(name, KickAhead.current_time.call + delta, *args)
39
+ end
40
+
41
+ def self.run_at(time, *args)
42
+ KickAhead.repository.create(name, time, *args)
43
+ end
44
+
45
+ def perform(*)
46
+ raise NotImplementedError
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,3 @@
1
+ module KickAhead
2
+ VERSION = "0.1.0"
3
+ end
data/lib/kick_ahead.rb ADDED
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kick_ahead/version"
4
+ require "kick_ahead/job"
5
+
6
+ module KickAhead
7
+ OutOfInterval = Class.new(StandardError)
8
+ NoTickIntervalConfigured = Class.new(RuntimeError)
9
+
10
+ RAISE_EXCEPTION_STRATEGY = :raise_exception
11
+ IGNORE_STRATEGY = :ignore
12
+ HOOK_STRATEGY = :hook
13
+
14
+ ALL_STRATEGIES = [RAISE_EXCEPTION_STRATEGY, IGNORE_STRATEGY, HOOK_STRATEGY].freeze
15
+
16
+ class << self
17
+ attr_accessor :tick_interval
18
+ attr_accessor :repository
19
+ attr_accessor :current_time
20
+
21
+ def tick
22
+ if tick_interval.nil?
23
+ raise NoTickIntervalConfigured, 'No tick_interval configured! Please set `KickAhead.tick_interval`'
24
+ end
25
+
26
+ if current_time.nil?
27
+ raise 'You must configure a way for me to know the current time!'
28
+ end
29
+
30
+ KickAhead.repository.each_job_in_the_past do |job|
31
+ if job[:scheduled_at] < KickAhead.current_time.call - tick_interval - constantize(job[:job_class]).tolerance
32
+ out_of_time_job(job)
33
+ else
34
+ run_job(job)
35
+ end
36
+ end
37
+ end
38
+
39
+ def run_job(job)
40
+ constantize(job[:job_class]).new.perform(*job[:job_args])
41
+ KickAhead.repository.delete(job[:id])
42
+ end
43
+
44
+ def out_of_time_job(job)
45
+ case constantize(job[:job_class]).out_of_time_strategy.to_sym
46
+ when RAISE_EXCEPTION_STRATEGY
47
+ raise OutOfInterval, "The job of class #{job[:job_class]} with args #{job[:job_args].inspect} "\
48
+ 'was not possible to run because of an out of interval tick '\
49
+ "(we didn't receive a tick in time to run it) and it's maximum tolerance "\
50
+ 'threshold is also overdue.'
51
+
52
+ when IGNORE_STRATEGY
53
+ KickAhead.repository.delete(job[:id])
54
+
55
+ when HOOK_STRATEGY
56
+ constantize(job[:job_class]).new.out_of_time_hook(job[:scheduled_at], *job[:job_args])
57
+ KickAhead.repository.delete(job[:id])
58
+
59
+ else
60
+ raise 'Invalid strategy'
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ # Extracted from activesupport/lib/active_support/inflector/methods.rb, line 258
67
+ def constantize(camel_cased_word)
68
+ names = camel_cased_word.split("::".freeze)
69
+
70
+ # Trigger a built-in NameError exception including the ill-formed constant in the message.
71
+ Object.const_get(camel_cased_word) if names.empty?
72
+
73
+ # Remove the first blank element in case of '::ClassName' notation.
74
+ names.shift if names.size > 1 && names.first.empty?
75
+
76
+ names.inject(Object) do |constant, name|
77
+ if constant == Object
78
+ constant.const_get(name)
79
+ else
80
+ candidate = constant.const_get(name)
81
+ next candidate if constant.const_defined?(name, false)
82
+ next candidate unless Object.const_defined?(name)
83
+
84
+ # Go down the ancestors to check if it is owned directly. The check
85
+ # stops when we reach Object or the end of ancestors tree.
86
+ constant = constant.ancestors.inject(constant) do |const, ancestor|
87
+ break const if ancestor == Object
88
+ break ancestor if ancestor.const_defined?(name, false)
89
+ const
90
+ end
91
+
92
+ # owner is in Object, so raise
93
+ constant.const_get(name, false)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
metadata ADDED
@@ -0,0 +1,112 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kick_ahead
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Roger Campos
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2017-11-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.16'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.16'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '5.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '5.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: timecop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ description: Push code to execute in the future, without dependencies
70
+ email:
71
+ - roger@rogercampos.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - ".gitignore"
77
+ - ".travis.yml"
78
+ - Gemfile
79
+ - Gemfile.lock
80
+ - README.md
81
+ - Rakefile
82
+ - bin/console
83
+ - bin/setup
84
+ - kick_ahead.gemspec
85
+ - lib/kick_ahead.rb
86
+ - lib/kick_ahead/job.rb
87
+ - lib/kick_ahead/version.rb
88
+ homepage: https://github.com/rogercampos/kick_ahead
89
+ licenses: []
90
+ metadata:
91
+ allowed_push_host: https://rubygems.org
92
+ post_install_message:
93
+ rdoc_options: []
94
+ require_paths:
95
+ - lib
96
+ required_ruby_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ required_rubygems_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '0'
106
+ requirements: []
107
+ rubyforge_project:
108
+ rubygems_version: 2.5.2
109
+ signing_key:
110
+ specification_version: 4
111
+ summary: Push code to execute in the future, without dependencies
112
+ test_files: []