periodically 0.0.5 → 0.0.6
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +18 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +4 -0
- data/README.md +28 -22
- data/lib/periodically.rb +9 -4
- data/lib/periodically/debug.rb +18 -4
- data/lib/periodically/job.rb +11 -6
- data/lib/periodically/redis.rb +2 -1
- data/periodically.gemspec +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: db828c2104d540e27fda8d4896b3ea91620c6b40034ef7be09678938bd57eba3
|
4
|
+
data.tar.gz: 4dad188a4afe02239af267042e3fea818c7544889dcfa17962a83f7fa00735c9
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 30e5029889fc90ff67fd12cc9e2be6eedf438474130d03ba41474998415747230f57a7e0aea424afd5c32274c3d2b89fa1b0eb61a4f2a5ee640e17cb5b30853c
|
7
|
+
data.tar.gz: 010cbdfd28fe74a99a9d4ed7ce3ea1062f14726fd9a92239a7c8f389c7a393bcebdea5d26af83fa289d6bac7a4391506c21ec85c732d0e3ffbcd805d8e163ea7
|
@@ -0,0 +1,18 @@
|
|
1
|
+
name: CI
|
2
|
+
|
3
|
+
on: [push, pull_request]
|
4
|
+
|
5
|
+
jobs:
|
6
|
+
build:
|
7
|
+
runs-on: ubuntu-latest
|
8
|
+
steps:
|
9
|
+
- uses: actions/checkout@v1
|
10
|
+
- name: Set up Ruby 2.6
|
11
|
+
uses: actions/setup-ruby@v1
|
12
|
+
with:
|
13
|
+
ruby-version: 2.6.x
|
14
|
+
- name: Build and test with Rake
|
15
|
+
run: |
|
16
|
+
gem install bundler
|
17
|
+
bundle install --jobs 4 --retry 3
|
18
|
+
bundle exec rake test
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
@@ -14,10 +14,12 @@ GEM
|
|
14
14
|
zeitwerk (~> 2.2)
|
15
15
|
ast (2.4.0)
|
16
16
|
concurrent-ruby (1.1.5)
|
17
|
+
connection_pool (2.2.2)
|
17
18
|
i18n (1.7.0)
|
18
19
|
concurrent-ruby (~> 1.0)
|
19
20
|
jaro_winkler (1.5.4)
|
20
21
|
minitest (5.13.0)
|
22
|
+
mock_redis (0.22.0)
|
21
23
|
parallel (1.19.1)
|
22
24
|
parser (2.7.0.1)
|
23
25
|
ast (~> 2.4.0)
|
@@ -50,7 +52,9 @@ PLATFORMS
|
|
50
52
|
|
51
53
|
DEPENDENCIES
|
52
54
|
activerecord
|
55
|
+
connection_pool
|
53
56
|
minitest
|
57
|
+
mock_redis
|
54
58
|
rake
|
55
59
|
redis-namespace
|
56
60
|
standard
|
data/README.md
CHANGED
@@ -1,15 +1,19 @@
|
|
1
1
|
# periodically
|
2
2
|
|
3
|
-
Redis-backed Ruby library for
|
4
|
-
|
5
|
-
|
3
|
+
Redis-backed Ruby library for tasks that need to run once in a while. "Once in a while" is defined more accurately by a custom lambda
|
4
|
+
block by the library user.
|
5
|
+
|
6
|
+
Since task execution is done in a single threa using non-accurate and non-time-based conditions, periodically is best for
|
7
|
+
infrequent and noncritical jobs, such as weekly syncs.
|
6
8
|
|
7
9
|
Example usecases:
|
8
10
|
|
9
|
-
- Sync a Rails model's data once per week
|
10
|
-
-
|
11
|
-
-
|
12
|
-
|
11
|
+
- Sync a Rails model's data once per week from an external source. External source can be unstable, so you'd like to repeat failing jobs but some amount of marginally outdated data won't be a problem
|
12
|
+
- Great fit for periodically! Can be achieved for example by a `last_synced` value in the database and a condition based on it
|
13
|
+
- Additionally, periodically supports deferring jobs (meaning one line rate limiting!)
|
14
|
+
- Apply asynchronous corrections to data added to the database. For example automatically fetch title for user-submitted links.
|
15
|
+
- Achievable by adding a condition against the correctable column (`where(title: nil)`)
|
16
|
+
- However, a background job processor like Sidekiq would likely be more efficient
|
13
17
|
|
14
18
|
## Getting started with Rails
|
15
19
|
|
@@ -21,6 +25,8 @@ Example usecases:
|
|
21
25
|
|
22
26
|
```rb
|
23
27
|
require "periodically"
|
28
|
+
|
29
|
+
# Launches a background thread. For production usage you may want to do this in another process
|
24
30
|
Periodically.start
|
25
31
|
```
|
26
32
|
|
@@ -37,12 +43,13 @@ class Item < ApplicationRecord
|
|
37
43
|
include Periodically::Model
|
38
44
|
|
39
45
|
periodically :refresh_price,
|
40
|
-
|
46
|
+
on: -> { Item.where("last_synced < ?", 7.days.ago) }
|
41
47
|
|
42
48
|
private
|
43
49
|
|
44
50
|
def refresh_price
|
45
51
|
self.price = PriceFetcher.fetch(item_id)
|
52
|
+
self.last_synced = Time.now! # Remember to update the condition by yourself!
|
46
53
|
save!
|
47
54
|
end
|
48
55
|
end
|
@@ -74,7 +81,7 @@ include Periodically::Model
|
|
74
81
|
periodically :update_method, # call instance method "update_method" for found instances
|
75
82
|
on: -> { where("last_synced < ?", 7.days.ago) }, # Condition that must be true for update method to be called
|
76
83
|
min_class_interval: 5.minutes, # (Optional) The minimum interval between calls to this specific class (TODO not implemented)
|
77
|
-
max_retries: 25, # (Optional) Maximum number of retries. Periodically uses exponential backoff
|
84
|
+
max_retries: 25, # (Optional) Maximum number of retries. Periodically uses exponential backoff (TODO not implemented)
|
78
85
|
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)
|
79
86
|
|
80
87
|
|
@@ -126,29 +133,28 @@ Just call them by yourself in your tests.
|
|
126
133
|
|
127
134
|
**The periodically :on condition**
|
128
135
|
|
129
|
-
(
|
130
|
-
|
131
|
-
You can call `Periodically.would_execute?(MyModel, :object)` to statically check the condition.
|
136
|
+
You can call `Periodically.would_execute?(MyModel, :object)` to statically check the condition. (TODO not implemented)
|
132
137
|
|
133
138
|
## Debugging
|
134
139
|
|
135
|
-
As part of your application you might want to be able to get a quick snapshot of how
|
140
|
+
As part of your application you might want to be able to get a quick snapshot of how periodically is doing.
|
136
141
|
You can do that by calling `Periodically::Debug.total_debug_dump`, which returns a hash containing bunch of debug information.
|
137
142
|
|
138
143
|
## Dashboard
|
139
144
|
|
140
|
-
|
145
|
+
Dashboard contains recently succeeded executions, failed executions (with stacktrace) and deferred executions. (TODO not implemented)
|
141
146
|
|
142
147
|
## Why not Sidekiq?
|
143
148
|
|
144
|
-
With Sidekiq you can achieve something almost similar by combining a scheduled job that enqueues further unique jobs based on the condition.
|
149
|
+
With Sidekiq you can achieve something almost similar by combining a scheduled job that enqueues further unique jobs based on the condition. (see https://github.com/mperham/sidekiq/wiki/Ent-Periodic-Jobs#dynamic-jobs)
|
145
150
|
|
146
|
-
However, there are few advantages
|
151
|
+
However, there are few advantages periodically has for the specific usecase of per-instance non-critical jobs:
|
147
152
|
|
148
|
-
- **Improved backpressure handling.**
|
149
|
-
- **
|
150
|
-
- **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
|
151
|
-
- **More
|
152
|
-
- **
|
153
|
+
- **Improved backpressure handling.** Since we know the conditions for executing jobs, we are in better control of the job producer and able to balance between different jobs. This enables for instance early warnings for the developers in case of job buildup. (TODO not implemented)
|
154
|
+
- **Closer to the source.** Periodic callbacks are defined inside the models, so it is always easy to find which jobs are affecting which models.
|
155
|
+
- **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 the repeated erroneous 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 not implemented)
|
156
|
+
- **More flexible delaying.** In periodically, you can delay job executions per-instance, per-job or per-class.
|
157
|
+
- **More clever polling.** Since we know the exact condition for new periodic jobs, we can deduce the next execution time and sleep accordingly. (TODO not implemented)
|
158
|
+
- **Easier priority escalation.** Periodically selects jobs in order of the given condition and maintains no queue of its own; therefore prioritizing certain jobs by adding a new query condition comes by design.
|
153
159
|
|
154
|
-
Importantly, Sidekiq and
|
160
|
+
Importantly, Sidekiq and periodically aim to solve different problems. Nothing prevents one from using both at the same time.
|
data/lib/periodically.rb
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "logger"
|
4
|
+
|
3
5
|
require "periodically/job"
|
4
6
|
require "periodically/debug"
|
5
7
|
require "periodically/defer"
|
@@ -9,8 +11,11 @@ require "periodically/model"
|
|
9
11
|
module Periodically
|
10
12
|
@@registered = []
|
11
13
|
|
14
|
+
def self.logger=(new_logger)
|
15
|
+
@logger ||= new_logger
|
16
|
+
end
|
12
17
|
def self.logger
|
13
|
-
|
18
|
+
@logger ||= Logger.new(STDOUT)
|
14
19
|
end
|
15
20
|
|
16
21
|
REDIS_DEFAULTS = {
|
@@ -18,13 +23,13 @@ module Periodically
|
|
18
23
|
}
|
19
24
|
|
20
25
|
def self.execute_next
|
21
|
-
|
26
|
+
found_job, instance = @@registered.lazy.filter_map do |job|
|
22
27
|
instance = job.poll_next_instance
|
23
28
|
[job, instance] if instance
|
24
29
|
end.first
|
25
30
|
|
26
|
-
if
|
27
|
-
|
31
|
+
if found_job && instance
|
32
|
+
found_job.execute_instance(instance)
|
28
33
|
true
|
29
34
|
else
|
30
35
|
false
|
data/lib/periodically/debug.rb
CHANGED
@@ -8,17 +8,32 @@ module Periodically
|
|
8
8
|
values = conn.mget(keys)
|
9
9
|
Hash[keys.zip(values)]
|
10
10
|
end
|
11
|
-
|
11
|
+
error_messages = Periodically.redis do |conn|
|
12
|
+
keys = conn.scan_each(:match => "errormessages:*").to_a
|
13
|
+
values = conn.mget(keys)
|
14
|
+
Hash[keys.zip(values)]
|
15
|
+
end
|
16
|
+
locks = lock_ttls
|
17
|
+
{
|
18
|
+
job_count: Periodically._registered_jobs.size,
|
19
|
+
error_counts: error_counts,
|
20
|
+
error_messages: error_messages,
|
21
|
+
lock_ttls: locks
|
22
|
+
}
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.lock_ttls
|
26
|
+
Periodically.redis do |conn|
|
12
27
|
keys = conn.scan_each(:match => "locks:*").to_a
|
13
28
|
ttls = conn.eval(%{
|
14
29
|
local matcher = KEYS[1] .. ":*"
|
15
30
|
local ttls = {}
|
16
|
-
|
31
|
+
|
17
32
|
local cursor = "0"
|
18
33
|
repeat
|
19
34
|
local result = redis.call("SCAN", cursor, "MATCH", matcher)
|
20
35
|
cursor = result[1]
|
21
|
-
|
36
|
+
|
22
37
|
local keys = result[2]
|
23
38
|
for i, key in ipairs(keys) do
|
24
39
|
ttls[#ttls + 1] = redis.call('ttl', key)
|
@@ -28,7 +43,6 @@ module Periodically
|
|
28
43
|
}, :keys => ['locks'])
|
29
44
|
Hash[keys.zip(ttls)]
|
30
45
|
end
|
31
|
-
{ job_count: Periodically._registered_jobs.size, error_counts: error_counts, lock_ttls: locks }
|
32
46
|
end
|
33
47
|
end
|
34
48
|
end
|
data/lib/periodically/job.rb
CHANGED
@@ -15,11 +15,9 @@ module Periodically
|
|
15
15
|
def poll_next_instance
|
16
16
|
return if job_or_class_locked?
|
17
17
|
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
!instance_locked?(obj)
|
22
|
-
end
|
18
|
+
where = @opts[:on].call()
|
19
|
+
where.to_a.find do |obj|
|
20
|
+
!instance_locked?(obj)
|
23
21
|
end
|
24
22
|
end
|
25
23
|
|
@@ -28,9 +26,11 @@ module Periodically
|
|
28
26
|
begin
|
29
27
|
instance.send(@method)
|
30
28
|
rescue => e
|
31
|
-
|
29
|
+
stored_error = "#{e.message}\n#{e.backtrace.join("\n")}"
|
30
|
+
Periodically.logger.error("Job instance[#{instance}] execution raised an exception\n#{stored_error}")
|
32
31
|
new_error_count = increase_instance_error_count(instance)
|
33
32
|
lock_instance(instance, DEFAULT_ERROR_DELAY.call(new_error_count))
|
33
|
+
store_instance_error(instance, stored_error)
|
34
34
|
return
|
35
35
|
end
|
36
36
|
|
@@ -61,6 +61,11 @@ module Periodically
|
|
61
61
|
Periodically.redis {|conn| conn.del(error_count_key)}
|
62
62
|
end
|
63
63
|
|
64
|
+
def store_instance_error(instance, error)
|
65
|
+
error_message_key = "errormessages:#{instance_key(instance)}"
|
66
|
+
Periodically.redis {|conn| conn.set(error_message_key, error)}
|
67
|
+
end
|
68
|
+
|
64
69
|
def job_or_class_locked?
|
65
70
|
Periodically.redis do |conn|
|
66
71
|
# TODO can this be optimized with exists?(Array)
|
data/lib/periodically/redis.rb
CHANGED
@@ -35,7 +35,8 @@ module Periodically
|
|
35
35
|
def build_client(options)
|
36
36
|
namespace = options[:namespace]
|
37
37
|
|
38
|
-
client = Redis.new
|
38
|
+
client = $PERIODICALLY_MOCK_REDIS ? MockRedis.new : Redis.new(client_opts(options))
|
39
|
+
|
39
40
|
if namespace
|
40
41
|
begin
|
41
42
|
require "redis-namespace"
|
data/periodically.gemspec
CHANGED
@@ -4,7 +4,7 @@ Gem::Specification.new do |gem|
|
|
4
4
|
|
5
5
|
gem.files = `git ls-files | grep -Ev '^(test|myapp|examples)'`.split("\n")
|
6
6
|
gem.name = "periodically"
|
7
|
-
gem.version = "0.0.
|
7
|
+
gem.version = "0.0.6"
|
8
8
|
gem.required_ruby_version = ">= 2.5.0"
|
9
9
|
|
10
10
|
gem.add_dependency "redis", ">= 4.1.0"
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: periodically
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.6
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- wyozi
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-01-
|
11
|
+
date: 2020-01-10 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: redis
|
@@ -59,6 +59,7 @@ extensions: []
|
|
59
59
|
extra_rdoc_files: []
|
60
60
|
files:
|
61
61
|
- ".editorconfig"
|
62
|
+
- ".github/workflows/ci.yml"
|
62
63
|
- Gemfile
|
63
64
|
- Gemfile.lock
|
64
65
|
- README.md
|