periodically 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,9 @@
1
+ root = true
2
+
3
+ [*]
4
+ end_of_line = lf
5
+ insert_final_newline = true
6
+
7
+ [*.rb,*.gemspec]
8
+ indent_style = space
9
+ indent_size = 2
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gem 'activerecord'
4
+ gem 'redis-namespace'
5
+ gem 'rufus-scheduler'
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.
@@ -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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file handles running Periodically in another process
4
+
5
+ module Periodically
6
+ class CLI
7
+ def run
8
+ logger.info "Running in #{RUBY_DESCRIPTION}"
9
+
10
+ Periodically.start
11
+ end
12
+ end
13
+ 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: []