periodically 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.editorconfig +9 -0
- data/Gemfile +5 -0
- data/README.md +125 -0
- data/lib/periodically.rb +52 -0
- data/lib/periodically/cli.rb +13 -0
- data/lib/periodically/condition.rb +28 -0
- data/lib/periodically/job.rb +68 -0
- data/lib/periodically/model.rb +17 -0
- data/lib/periodically/redis.rb +101 -0
- data/periodically.gemspec +13 -0
- metadata +94 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 39405716f55a3c85a4674d38d0440ba293d9bea7e44a260e062b2e631dc230b8
|
4
|
+
data.tar.gz: f51dbc7e4d9a033778107700e6c82c8743374e9705e8526f84e7900ac56e6cdc
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: f65b70c130c7dffd6cb01da0c02992a4ff4bec0bf763a46ed996057a047709871aabd07f0efb5fa79708a40e5c2d4c33da263c4fdcb0726644af8ad3494517c0
|
7
|
+
data.tar.gz: fe4a38609d1b958f748cbeaae53f571ce0d005318599d49d96a6ae0d788db1bde0c96a05b32fb9908c33864b7529aa4930d09d2806d843b1948cd6cad81026ee
|
data/.editorconfig
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,125 @@
|
|
1
|
+
# periodically
|
2
|
+
|
3
|
+
Redis-backed Ruby library for periodically running tasks, whose execution depends can depend on a custom lambda block (e.g. weekly syncs).
|
4
|
+
The job execution won't be guaranteed to happen exactly as the condition is fulfilled, hence periodically is best for
|
5
|
+
nonfrequent and noncritical jobs.
|
6
|
+
|
7
|
+
Example usecases:
|
8
|
+
|
9
|
+
- Sync a Rails model's data once per week
|
10
|
+
- Achievable by e.g. a `last_synced` value in the database
|
11
|
+
- Launch a non-important sync operation depending on a specific condition (e.g. NULL value in some database column)
|
12
|
+
- Achievable by checking the column against NULL inside the `on` condition
|
13
|
+
|
14
|
+
### Example usage with Rails
|
15
|
+
|
16
|
+
```rb
|
17
|
+
# config/initializers/periodically.rb
|
18
|
+
require "periodically"
|
19
|
+
|
20
|
+
# Note: in development mode classes are loaded lazily, so periodical jobs only start once classes have been loaded
|
21
|
+
# In production classes are loaded eagerly, so this is no problem
|
22
|
+
|
23
|
+
Periodically.start
|
24
|
+
```
|
25
|
+
|
26
|
+
```rb
|
27
|
+
# app/models/item.rb
|
28
|
+
|
29
|
+
class Item < ApplicationRecord
|
30
|
+
include Periodically::Model
|
31
|
+
|
32
|
+
periodically :refresh_price,
|
33
|
+
on: -> { Item.where("last_synced < ?", 7.days.ago) }
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def refresh_price
|
38
|
+
self.price = PriceFetcher.fetch(item_id)
|
39
|
+
save!
|
40
|
+
end
|
41
|
+
end
|
42
|
+
```
|
43
|
+
|
44
|
+
### Example usage with pure Ruby
|
45
|
+
|
46
|
+
```rb
|
47
|
+
# TODO
|
48
|
+
```
|
49
|
+
|
50
|
+
## Execution model
|
51
|
+
|
52
|
+
Periodically launches a single background thread, which executes registered queries every x seconds. If a pending query is found, the registered callback method is called in the same thread. Hence, a blocking callback method will also block execution of other pending queries.
|
53
|
+
|
54
|
+
By default everything happens in the same process as the main Rails web server.
|
55
|
+
To parallelize processing a bit, you can do `bundle exec periodically` to start a new process for jobs. (Remember to remove `Periodically.start` from initializer!)
|
56
|
+
|
57
|
+
## API
|
58
|
+
|
59
|
+
### Terminology
|
60
|
+
|
61
|
+
- **Job**: something enqueued to be called using the `periodically` method
|
62
|
+
- **Instance job**: a single Job execution concerning a specific instance of the class
|
63
|
+
|
64
|
+
### Inside a Model
|
65
|
+
|
66
|
+
#### Definitions
|
67
|
+
|
68
|
+
```rb
|
69
|
+
# Add Periodically context to this class
|
70
|
+
include Periodically::Model
|
71
|
+
|
72
|
+
# Enqueue a Periodically job
|
73
|
+
periodically :update_method, # call instance method "update_method" for found instances
|
74
|
+
on: -> { where("last_synced < ?", 7.days.ago) }, # Condition that must be true for update method to be called
|
75
|
+
min_class_interval: 5.minutes, # (Optional) The minimum interval between calls to this specific class (TODO not implemented)
|
76
|
+
max_retries: 25, # (Optional) Maximum number of retries. Periodically uses exponential backoff
|
77
|
+
instance_id: -> { cache_key_with_version }, # (Optional) Returns this instance's unique identifying key. Used for e.g. deferring jobs and marking them as erroring (TODO not implemented)
|
78
|
+
|
79
|
+
|
80
|
+
```
|
81
|
+
|
82
|
+
#### Update method return values
|
83
|
+
|
84
|
+
Job method's return value or raised exception determines further executions of that specific instance job.
|
85
|
+
|
86
|
+
```rb
|
87
|
+
# As referred to by a previous `periodically` call
|
88
|
+
def update_method
|
89
|
+
# Let's retrieve a normal value from the model instance
|
90
|
+
status = my_column_status
|
91
|
+
|
92
|
+
# No-op
|
93
|
+
# Since we don't update `last_synced`, this method will get called again without much delay!
|
94
|
+
return if status == "pending"
|
95
|
+
|
96
|
+
# Log error and defer execution
|
97
|
+
# This unique instance will be deferred for later execution (using exponential backoff) and the error logged
|
98
|
+
raise "something went wrong" if status == "error"
|
99
|
+
|
100
|
+
# Update checked delay
|
101
|
+
# Updates the property we check against, thus making this instance not pass the Periodically condition
|
102
|
+
# Note that this line is normal Rails code: Periodically conditions are database/anything-agnostic
|
103
|
+
update(last_synced: Time.now)
|
104
|
+
end
|
105
|
+
```
|
106
|
+
|
107
|
+
The job method's return value can be used to defer further execution of the same model instance, even if it still passes the condition: `return Periodically::Defer(60.minutes)`
|
108
|
+
|
109
|
+
## Dashboard
|
110
|
+
|
111
|
+
(When implemented :D) Dashboard contains recently succeeded executions, failed executions (with stacktrace) and deferred executions.
|
112
|
+
|
113
|
+
## Why not Sidekiq?
|
114
|
+
|
115
|
+
With Sidekiq you can achieve something almost similar by combining a scheduled job that enqueues further unique jobs based on the condition.
|
116
|
+
|
117
|
+
However, there are few advantages Periodically has for the specific usecase of per-instance non-critical jobs:
|
118
|
+
|
119
|
+
- **Improved backpressure handling.** Due to knowing the conditions, we are able to track the number of pending jobs at all times. This enables early warnings for the developers in case of job buildup. (TODO)
|
120
|
+
- **Better observability.** We know exactly how many items fulfill the condition and how many don't; therefore, we can visualize success rates and current status as a percentage of the total. (TODO)
|
121
|
+
- **Cleaner per-instance retrying.** If we start executing a job, but suddenly want to defer execution by some time in Sidekiq, it is definitely doable with scheduled jobs. However, this may entrap you in a "scheduled unique job" hell: if some job keeps getting mistakenly deferred, it might be hard to find out about this behavior without some complex job tracking logic. In contrast, Periodically delivers this functionality for free due to more explicit control over job scheduling and rescheduling. (TODO)
|
122
|
+
- **More clever polling.** Since we know the exact condition for new periodic jobs, we can deduce the next execution time and sleep accordingly. (TODO)
|
123
|
+
- **Easier priority escalation.** Periodically selects jobs in order of the given condition and maintains no queue of its own; therefore it is trivial to prioritize certain jobs by adding a new query condition.
|
124
|
+
|
125
|
+
Importantly, Sidekiq and Periodically aim to solve different problems. Nothing prevents one from using both at the same time.
|
data/lib/periodically.rb
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "periodically/job"
|
4
|
+
require "periodically/redis"
|
5
|
+
require "periodically/model" if defined?(::Rails::Engine)
|
6
|
+
|
7
|
+
module Periodically
|
8
|
+
@@registered = []
|
9
|
+
|
10
|
+
def self.logger
|
11
|
+
Rails.logger # TODO
|
12
|
+
end
|
13
|
+
|
14
|
+
REDIS_DEFAULTS = {
|
15
|
+
namespace: "periodically"
|
16
|
+
}
|
17
|
+
|
18
|
+
def self.execute_next
|
19
|
+
job, instance = @@registered.lazy.filter_map do |job|
|
20
|
+
instance = job.poll_next_instance
|
21
|
+
[job, instance] if instance
|
22
|
+
end.first
|
23
|
+
|
24
|
+
job.execute_instance(instance) if job && instance
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.register(klass, method, opts)
|
28
|
+
@@registered.push(Periodically::Job.new(klass, method, opts))
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.redis_pool
|
32
|
+
@redis ||= Periodically::RedisConnection.create(REDIS_DEFAULTS)
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.redis
|
36
|
+
raise ArgumentError, "requires a block" unless block_given?
|
37
|
+
redis_pool.with do |conn|
|
38
|
+
yield conn
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.start
|
43
|
+
require 'rufus-scheduler'
|
44
|
+
scheduler = Rufus::Scheduler.new
|
45
|
+
|
46
|
+
Periodically.redis { |conn| conn.ping }
|
47
|
+
|
48
|
+
scheduler.interval '10s' do
|
49
|
+
Periodically.execute_next
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Periodically
|
4
|
+
module Condition
|
5
|
+
def self.class()
|
6
|
+
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.evaluate(func)
|
10
|
+
condition = func.call()
|
11
|
+
|
12
|
+
simple = simple_evaluation(condition)
|
13
|
+
|
14
|
+
# Filter condition to skip
|
15
|
+
if evaluated.is_a?(ActiveRecord::Base)
|
16
|
+
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def self.simple_evaluation(condition)
|
23
|
+
row = evaluated.first()
|
24
|
+
row if !Periodically.redis {|conn| conn.exists("")}
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Periodically
|
4
|
+
DEFAULT_ERROR_DELAY = proc { |count| (count ** 4) + 15 + (rand(30)*(count+1)) }
|
5
|
+
|
6
|
+
class Job
|
7
|
+
def initialize(klass, method, opts)
|
8
|
+
@klass = klass
|
9
|
+
@method = method
|
10
|
+
@opts = opts
|
11
|
+
end
|
12
|
+
|
13
|
+
def poll_next_instance
|
14
|
+
ActiveRecord::Base.uncached do
|
15
|
+
where = @opts[:on].call()
|
16
|
+
where.to_a.find do |obj|
|
17
|
+
!instance_locked?(obj)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def execute_instance(instance)
|
23
|
+
return_value =
|
24
|
+
begin
|
25
|
+
instance.send(@method)
|
26
|
+
rescue => e
|
27
|
+
Periodically.logger.error("Job instance[#{instance}] execution raised an error: #{e}")
|
28
|
+
|
29
|
+
new_error_count = increase_instance_error_count(instance)
|
30
|
+
lock_instance(instance, DEFAULT_ERROR_DELAY.call(new_error_count))
|
31
|
+
return
|
32
|
+
end
|
33
|
+
|
34
|
+
clear_instance_error_count(instance)
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def instance_key(instance)
|
40
|
+
instance.cache_key_with_version
|
41
|
+
end
|
42
|
+
|
43
|
+
def instance_locked?(instance)
|
44
|
+
Periodically.redis {|conn| conn.exists("locks:#{instance_key(instance)}")}
|
45
|
+
end
|
46
|
+
|
47
|
+
def increase_instance_error_count(instance)
|
48
|
+
error_count_key = "errors:#{instance_key(instance)}"
|
49
|
+
Periodically.redis {|conn| conn.incr(error_count_key)}
|
50
|
+
end
|
51
|
+
|
52
|
+
def clear_instance_error_count(instance)
|
53
|
+
error_count_key = "errors:#{instance_key(instance)}"
|
54
|
+
Periodically.redis {|conn| conn.del(error_count_key)}
|
55
|
+
end
|
56
|
+
|
57
|
+
def lock_instance(instance, seconds)
|
58
|
+
lock_key = "locks:#{instance_key(instance)}"
|
59
|
+
|
60
|
+
Periodically.redis do |conn|
|
61
|
+
conn.multi do |multi|
|
62
|
+
multi.set(lock_key, "1")
|
63
|
+
multi.expire(lock_key, seconds)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Periodically
|
4
|
+
module Model
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
def self.included(base)
|
8
|
+
base.extend ClassMethods
|
9
|
+
end
|
10
|
+
|
11
|
+
module ClassMethods
|
12
|
+
def periodically(symbol, **opts)
|
13
|
+
Periodically.register(self, symbol, opts)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "connection_pool"
|
4
|
+
require "redis"
|
5
|
+
require "uri"
|
6
|
+
|
7
|
+
module Periodically
|
8
|
+
class RedisConnection
|
9
|
+
class << self
|
10
|
+
def create(options = {})
|
11
|
+
options.keys.each do |key|
|
12
|
+
options[key.to_sym] = options.delete(key)
|
13
|
+
end
|
14
|
+
|
15
|
+
options[:id] = "Periodically-PID-#{::Process.pid}" unless options.key?(:id)
|
16
|
+
options[:url] ||= determine_redis_provider
|
17
|
+
|
18
|
+
size = if options[:size]
|
19
|
+
options[:size]
|
20
|
+
elsif ENV["RAILS_MAX_THREADS"]
|
21
|
+
Integer(ENV["RAILS_MAX_THREADS"])
|
22
|
+
else
|
23
|
+
5
|
24
|
+
end
|
25
|
+
|
26
|
+
pool_timeout = options[:pool_timeout] || 1
|
27
|
+
|
28
|
+
ConnectionPool.new(timeout: pool_timeout, size: size) do
|
29
|
+
build_client(options)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def build_client(options)
|
36
|
+
namespace = options[:namespace]
|
37
|
+
|
38
|
+
client = Redis.new client_opts(options)
|
39
|
+
if namespace
|
40
|
+
begin
|
41
|
+
require "redis-namespace"
|
42
|
+
Redis::Namespace.new(namespace, redis: client)
|
43
|
+
rescue LoadError
|
44
|
+
Periodically.logger.error("Your Redis configuration uses the namespace '#{namespace}' but the redis-namespace gem is not included in the Gemfile." \
|
45
|
+
"Add the gem to your Gemfile to continue using a namespace. Otherwise, remove the namespace parameter.")
|
46
|
+
exit(-127)
|
47
|
+
end
|
48
|
+
else
|
49
|
+
client
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def client_opts(options)
|
54
|
+
opts = options.dup
|
55
|
+
if opts[:namespace]
|
56
|
+
opts.delete(:namespace)
|
57
|
+
end
|
58
|
+
|
59
|
+
if opts[:network_timeout]
|
60
|
+
opts[:timeout] = opts[:network_timeout]
|
61
|
+
opts.delete(:network_timeout)
|
62
|
+
end
|
63
|
+
|
64
|
+
opts[:driver] ||= Redis::Connection.drivers.last || "ruby"
|
65
|
+
|
66
|
+
# Issue #3303, redis-rb will silently retry an operation.
|
67
|
+
# This can lead to duplicate jobs if Periodically::Client's LPUSH
|
68
|
+
# is performed twice but I believe this is much, much rarer
|
69
|
+
# than the reconnect silently fixing a problem; we keep it
|
70
|
+
# on by default.
|
71
|
+
opts[:reconnect_attempts] ||= 1
|
72
|
+
|
73
|
+
opts
|
74
|
+
end
|
75
|
+
|
76
|
+
def determine_redis_provider
|
77
|
+
# If you have this in your environment:
|
78
|
+
# MY_REDIS_URL=redis://hostname.example.com:1238/4
|
79
|
+
# then set:
|
80
|
+
# REDIS_PROVIDER=MY_REDIS_URL
|
81
|
+
# and Periodically will find your custom URL variable with no custom
|
82
|
+
# initialization code at all.
|
83
|
+
#
|
84
|
+
p = ENV["REDIS_PROVIDER"]
|
85
|
+
if p && p =~ /\:/
|
86
|
+
raise <<~EOM
|
87
|
+
REDIS_PROVIDER should be set to the name of the variable which contains the Redis URL, not a URL itself.
|
88
|
+
Platforms like Heroku will sell addons that publish a *_URL variable. You need to tell Periodically with REDIS_PROVIDER, e.g.:
|
89
|
+
|
90
|
+
REDISTOGO_URL=redis://somehost.example.com:6379/4
|
91
|
+
REDIS_PROVIDER=REDISTOGO_URL
|
92
|
+
EOM
|
93
|
+
end
|
94
|
+
|
95
|
+
ENV[
|
96
|
+
p || "REDIS_URL"
|
97
|
+
]
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
Gem::Specification.new do |gem|
|
2
|
+
gem.authors = ["wyozi"]
|
3
|
+
gem.summary = "Periodic background jobs for Ruby"
|
4
|
+
|
5
|
+
gem.files = `git ls-files | grep -Ev '^(test|myapp|examples)'`.split("\n")
|
6
|
+
gem.name = "periodically"
|
7
|
+
gem.version = "0.0.1"
|
8
|
+
gem.required_ruby_version = ">= 2.5.0"
|
9
|
+
|
10
|
+
gem.add_dependency "redis", ">= 4.1.0"
|
11
|
+
gem.add_dependency "redis-namespace", ">= 1.7"
|
12
|
+
gem.add_dependency "connection_pool", ">= 2.2.2"
|
13
|
+
end
|
metadata
ADDED
@@ -0,0 +1,94 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: periodically
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- wyozi
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2020-01-06 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: redis
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 4.1.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 4.1.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: redis-namespace
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.7'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.7'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: connection_pool
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 2.2.2
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 2.2.2
|
55
|
+
description:
|
56
|
+
email:
|
57
|
+
executables: []
|
58
|
+
extensions: []
|
59
|
+
extra_rdoc_files: []
|
60
|
+
files:
|
61
|
+
- ".editorconfig"
|
62
|
+
- Gemfile
|
63
|
+
- README.md
|
64
|
+
- lib/periodically.rb
|
65
|
+
- lib/periodically/cli.rb
|
66
|
+
- lib/periodically/condition.rb
|
67
|
+
- lib/periodically/job.rb
|
68
|
+
- lib/periodically/model.rb
|
69
|
+
- lib/periodically/redis.rb
|
70
|
+
- periodically.gemspec
|
71
|
+
homepage:
|
72
|
+
licenses: []
|
73
|
+
metadata: {}
|
74
|
+
post_install_message:
|
75
|
+
rdoc_options: []
|
76
|
+
require_paths:
|
77
|
+
- lib
|
78
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: 2.5.0
|
83
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
84
|
+
requirements:
|
85
|
+
- - ">="
|
86
|
+
- !ruby/object:Gem::Version
|
87
|
+
version: '0'
|
88
|
+
requirements: []
|
89
|
+
rubyforge_project:
|
90
|
+
rubygems_version: 2.7.6
|
91
|
+
signing_key:
|
92
|
+
specification_version: 4
|
93
|
+
summary: Periodic background jobs for Ruby
|
94
|
+
test_files: []
|