asyncron 0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/asyncron.rb +62 -0
- data/lib/asyncron/cron.rb +79 -0
- data/lib/asyncron/schedule.rb +167 -0
- data/lib/asyncron/version.rb +5 -0
- data/test/asyncron_spec.rb +78 -0
- data/test/test_runner.rb +6 -0
- metadata +79 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 24f97fc5986f15a6700155eee1635d9c7f9591ff
|
4
|
+
data.tar.gz: 3baa3caef38cf1cd215594c2b7182b1ab05420be
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 108c1f461020fb2efcf83ac2dff00173c1f1c7dd9837ff86f94fa3011515ab3b4c8a125bc160ca6188c728e7228853d04c78ba09a6e149dc695fe96d778e8e2c
|
7
|
+
data.tar.gz: 69a1560ca11a79c9ecbc850532994fd7ff5b27a160a331192faf0cc56f0f1e6a3e53f964b94a54d73c3ab843ba1d83807bc16cfa017a20315cae01ea8954b2e1
|
data/lib/asyncron.rb
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "redis"
|
4
|
+
require "json"
|
5
|
+
|
6
|
+
require "asyncron/cron"
|
7
|
+
require "asyncron/schedule"
|
8
|
+
|
9
|
+
module Asyncron
|
10
|
+
extend self
|
11
|
+
|
12
|
+
DEFAULT_OPTS = {
|
13
|
+
redis: Redis.new,
|
14
|
+
key: "sorted_set_asyncron/%{callback_str}"
|
15
|
+
}
|
16
|
+
|
17
|
+
def insert(opts = {}, callback_str, payload)
|
18
|
+
unless payload.key?(:expr)
|
19
|
+
raise RuntimeError.new("#{payload.inspect} has no :expr key")
|
20
|
+
end
|
21
|
+
time = Schedule.next(payload[:expr])
|
22
|
+
if time.nil?
|
23
|
+
raise RuntimeError.new("#{payload[:expr]} for #{callback_str} and " \
|
24
|
+
"#{payload.inspect} has no future execution time")
|
25
|
+
end
|
26
|
+
set_key = key(opts, callback_str)
|
27
|
+
return if redis(opts).zscore(set_key, payload.to_json)
|
28
|
+
return redis(opts).zadd(set_key, time.to_i, payload.to_json)
|
29
|
+
end
|
30
|
+
|
31
|
+
def remove(opts = {}, callback_str, payload)
|
32
|
+
redis(opts).zrem(key(opts, callback_str), payload.to_json)
|
33
|
+
end
|
34
|
+
|
35
|
+
def due(opts = {})
|
36
|
+
t = Time.now
|
37
|
+
redis(opts).keys(key(opts, "*")).each do |set_key|
|
38
|
+
cb = callback(set_key.split("/").last)
|
39
|
+
redis(opts).zrangebyscore(set_key, 0, t.to_i).each do |payload|
|
40
|
+
parsed_payload = JSON.parse(payload, symbolize_names: true)
|
41
|
+
cb.call(parsed_payload)
|
42
|
+
redis(opts).zrem(set_key, payload)
|
43
|
+
insert(opts, set_key.split("/").last, parsed_payload)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def redis(opts)
|
49
|
+
opts[:redis] || DEFAULT_OPTS[:redis]
|
50
|
+
end
|
51
|
+
|
52
|
+
def key(opts, callback_str)
|
53
|
+
(opts[:key] || DEFAULT_OPTS[:key]) % {callback_str: callback_str}
|
54
|
+
end
|
55
|
+
|
56
|
+
def callback(callback_str)
|
57
|
+
mod, method = callback_str.split(".")
|
58
|
+
mods = mod.split("::")
|
59
|
+
ref = mods.reduce(Object) { |acc, m| acc.const_get(m) }
|
60
|
+
return ref.method(method)
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Asyncron
|
4
|
+
module Cron
|
5
|
+
extend self
|
6
|
+
|
7
|
+
MINUTE = (0..59).to_a
|
8
|
+
HOUR = (0..23).to_a
|
9
|
+
MONTHDAY = (1..31).to_a
|
10
|
+
MONTH = (1..12).to_a
|
11
|
+
WEEKDAY = (1..7).to_a
|
12
|
+
YEAR = (1900..3000).to_a
|
13
|
+
|
14
|
+
POSITION = %w(minute hour monthday month weekday year)
|
15
|
+
|
16
|
+
def parse(expr)
|
17
|
+
expr.split(/[\s\t]+/).map.with_index { |e, i| transform(i, e) }
|
18
|
+
end
|
19
|
+
|
20
|
+
def validate(value)
|
21
|
+
value =~ /^((\d+(-\d+)?(,\d+(-\d+)?)*)(\/\d+)?|\*(\/\d+)?)$/
|
22
|
+
end
|
23
|
+
|
24
|
+
def transform(pos, value)
|
25
|
+
unless validate(value)
|
26
|
+
raise ArgumentError.new("invalid format for #{POSITION[pos]}")
|
27
|
+
end
|
28
|
+
return divide(value) do |no_divide|
|
29
|
+
range = single_num(no_divide) || extend_asterisk(pos, value)
|
30
|
+
next range if range
|
31
|
+
next filter(expand(pos), value)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def expand(pos)
|
38
|
+
const_get(POSITION[pos].upcase)
|
39
|
+
end
|
40
|
+
|
41
|
+
def single_num(value)
|
42
|
+
return if value !~ /^\d+$/
|
43
|
+
return [value.to_i]
|
44
|
+
end
|
45
|
+
|
46
|
+
def extend_asterisk(pos, value)
|
47
|
+
return if value != "*"
|
48
|
+
return expand(pos)
|
49
|
+
end
|
50
|
+
|
51
|
+
def filter(range, value)
|
52
|
+
values = value.split(",")
|
53
|
+
last_value = values.pop
|
54
|
+
values.push(last_value.match(/^([^\/]+)/).captures.first)
|
55
|
+
return range unless values.all? { |v| v =~ /^\d+(-\d+)?$/ }
|
56
|
+
values = values.reduce([]) do |acc, value|
|
57
|
+
min, _, max = value.match(/^(\d+)(-(\d+))?$/).captures
|
58
|
+
if max.nil?
|
59
|
+
acc << min.to_i
|
60
|
+
else
|
61
|
+
acc += (min.to_i..max.to_i).to_a
|
62
|
+
end
|
63
|
+
next acc
|
64
|
+
end
|
65
|
+
return range & values
|
66
|
+
end
|
67
|
+
|
68
|
+
def divide(value)
|
69
|
+
arg, _, divider = value.match(/^([^\/]+)(\/(\d+))?$/).captures
|
70
|
+
range = yield(arg)
|
71
|
+
return range if divider !~ /^\d+$/
|
72
|
+
divider = divider.to_i
|
73
|
+
return range.each.with_index.reduce([]) do |acc, (r, i)|
|
74
|
+
acc << r if i % divider == 0
|
75
|
+
next acc
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,167 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Asyncron
|
4
|
+
module Schedule
|
5
|
+
extend self
|
6
|
+
|
7
|
+
def map(time = nil)
|
8
|
+
time = Time.now + 60 if time.nil?
|
9
|
+
%w(min hour day month wday year).map { |m| time.send(m) }
|
10
|
+
end
|
11
|
+
|
12
|
+
def next(expr)
|
13
|
+
cron = Cron.parse(expr)
|
14
|
+
current = map
|
15
|
+
year(cron, current) do
|
16
|
+
month(cron, current) do
|
17
|
+
day(cron, current) do
|
18
|
+
hour(cron, current) do
|
19
|
+
min(cron, current) do
|
20
|
+
Time.new(current[5], current[3], current[2], current[1],
|
21
|
+
current[0])
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def detect_next_weekday(cron, from)
|
32
|
+
while(!cron[4].include?(from.wday))
|
33
|
+
next_day = cron[2].detect { |d| d > from.day }
|
34
|
+
if next_day
|
35
|
+
from += (next_day - from.day) * 24 * 60 * 60
|
36
|
+
next
|
37
|
+
end
|
38
|
+
next_month = cron[3].detect { |m| m > from.month }
|
39
|
+
if next_month
|
40
|
+
from = Time.new(from.year, next_month, cron[2].first,
|
41
|
+
from.hour, from.min)
|
42
|
+
next
|
43
|
+
end
|
44
|
+
next_year = cron[5].detect { |y| y > from.year }
|
45
|
+
if next_year
|
46
|
+
from = Time.new(next_year, cron[3].first, cron[2].first,
|
47
|
+
from.hour, from.min)
|
48
|
+
next
|
49
|
+
else
|
50
|
+
from = nil
|
51
|
+
break
|
52
|
+
end
|
53
|
+
end
|
54
|
+
return from
|
55
|
+
end
|
56
|
+
|
57
|
+
def year(cron, current)
|
58
|
+
if cron[5].include?(current[5])
|
59
|
+
return yield
|
60
|
+
else
|
61
|
+
next_year = cron[5].detect { |y| y > current[5] }
|
62
|
+
return if next_year.nil?
|
63
|
+
min, hour, day, month = cron[0..3].map(&:first)
|
64
|
+
|
65
|
+
return detect_next_weekday(cron,
|
66
|
+
Time.new(next_year, month, day, hour, min))
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def month(cron, current)
|
71
|
+
if cron[3].include?(current[3])
|
72
|
+
return yield
|
73
|
+
else
|
74
|
+
next_year = current[5]
|
75
|
+
next_month = cron[3].detect { |m| m > current[3] }
|
76
|
+
if next_month.nil?
|
77
|
+
next_year = cron[5].detect { |y| y > next_year }
|
78
|
+
return if next_year.nil?
|
79
|
+
next_month = cron[3].first
|
80
|
+
end
|
81
|
+
min, hour, day = cron[0..2].map(&:first)
|
82
|
+
return detect_next_weekday(cron,
|
83
|
+
Time.new(next_year, next_month, day, hour, min))
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def day(cron, current)
|
88
|
+
if cron[2].include?(current[2]) && cron[4].include?(current[4])
|
89
|
+
return yield
|
90
|
+
else
|
91
|
+
next_year = current[5]
|
92
|
+
next_month = current[3]
|
93
|
+
next_day = cron[2].detect { |d| d > current[2] }
|
94
|
+
if next_day.nil?
|
95
|
+
next_day = cron[2].first
|
96
|
+
next_month = cron[3].detect { |m| m > next_month }
|
97
|
+
if next_month.nil?
|
98
|
+
next_year = cron[5].detect { |y| y > next_year }
|
99
|
+
return if next_year.nil?
|
100
|
+
next_month = cron[3].first
|
101
|
+
end
|
102
|
+
end
|
103
|
+
min, hour = cron[0..1].map(&:first)
|
104
|
+
return detect_next_weekday(cron,
|
105
|
+
Time.new(next_year, next_month, next_day, hour, min))
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def hour(cron, current)
|
110
|
+
if cron[1].include?(current[1])
|
111
|
+
return yield
|
112
|
+
else
|
113
|
+
next_year = current[5]
|
114
|
+
next_month = current[3]
|
115
|
+
next_day = current[2]
|
116
|
+
next_hour = cron[1].detect { |h| h > current[1] }
|
117
|
+
if next_hour.nil?
|
118
|
+
next_hour = cron[1].first
|
119
|
+
next_day = cron[2].detect { |d| d > current[2] }
|
120
|
+
if next_day.nil?
|
121
|
+
next_day = cron[2].first
|
122
|
+
next_month = cron[3].detect { |m| m > next_month }
|
123
|
+
if next_month.nil?
|
124
|
+
next_year = cron[5].detect { |y| y > next_year }
|
125
|
+
return if next_year.nil?
|
126
|
+
next_month = cron[3].first
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
min = cron[0].first
|
131
|
+
return detect_next_weekday(cron,
|
132
|
+
Time.new(next_year, next_month, next_day, next_hour, min))
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def min(cron, current)
|
137
|
+
if cron[0].include?(current[0])
|
138
|
+
return yield
|
139
|
+
else
|
140
|
+
next_year = current[5]
|
141
|
+
next_month = current[3]
|
142
|
+
next_day = current[2]
|
143
|
+
next_hour = current[1]
|
144
|
+
next_min = cron[0].detect { |m| m > current[0] }
|
145
|
+
if next_min.nil?
|
146
|
+
next_min = cron[0].first
|
147
|
+
next_hour = cron[1].detect { |h| h > current[1] }
|
148
|
+
if next_hour.nil?
|
149
|
+
next_hour = cron[1].first
|
150
|
+
next_day = cron[2].detect { |d| d > current[2] }
|
151
|
+
if next_day.nil?
|
152
|
+
next_day = cron[2].first
|
153
|
+
next_month = cron[3].detect { |m| m > next_month }
|
154
|
+
if next_month.nil?
|
155
|
+
next_year = cron[5].detect { |y| y > next_year }
|
156
|
+
return if next_year.nil?
|
157
|
+
next_month = cron[3].first
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
return detect_next_weekday(cron,
|
163
|
+
Time.new(next_year, next_month, next_day, next_hour, next_min))
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Callbacker
|
4
|
+
def self.with(cb)
|
5
|
+
@cb = cb
|
6
|
+
yield
|
7
|
+
@cb = nil
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.callback(payload)
|
11
|
+
@cb.call(payload)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
describe "asyncron" do
|
16
|
+
before do
|
17
|
+
@callback = "Callbacker.callback"
|
18
|
+
@payload = {expr: "* * * * * *", moo: "bar"}
|
19
|
+
@redis = Asyncron::DEFAULT_OPTS[:redis]
|
20
|
+
@key = Asyncron.key({}, @callback)
|
21
|
+
end
|
22
|
+
|
23
|
+
after do
|
24
|
+
@redis.keys(Asyncron.key({}, "*")).each { |k| @redis.del(k) }
|
25
|
+
%w(callback payload redis key).each do |i|
|
26
|
+
remove_instance_variable("@#{i}")
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
describe "insert" do
|
31
|
+
it "inserts a new entry with payload at the next possible slot" do
|
32
|
+
assert_nil @redis.zscore(@key, @payload.to_json)
|
33
|
+
t = Time.now
|
34
|
+
t = Time.new(t.year, t.month, t.day, t.hour, t.min).to_i + 60
|
35
|
+
Asyncron.insert(@callback, @payload)
|
36
|
+
assert_equal t, @redis.zscore(@key, @payload.to_json)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
describe "due" do
|
41
|
+
it "sends the payload to the callback module" do
|
42
|
+
@redis.zadd(@key, Time.now.to_i, @payload.to_json)
|
43
|
+
ran = false
|
44
|
+
Callbacker.with(
|
45
|
+
->(payload) { ran = true; assert_equal payload, @payload }
|
46
|
+
) { Asyncron.due }
|
47
|
+
assert ran
|
48
|
+
end
|
49
|
+
|
50
|
+
it "does not send the payload for future work" do
|
51
|
+
@redis.zadd(@key, Time.now.to_i + 5, @payload.to_json)
|
52
|
+
ran = false
|
53
|
+
Callbacker.with(->(payload) { ran = true; }) { Asyncron.due }
|
54
|
+
refute ran
|
55
|
+
end
|
56
|
+
|
57
|
+
it "adds the payload for the next execution again after running" do
|
58
|
+
@redis.zadd(@key, Time.now.to_i, @payload.to_json)
|
59
|
+
ran = false
|
60
|
+
Callbacker.with(->(payload) { ran = true; }) { Asyncron.due }
|
61
|
+
assert ran
|
62
|
+
t = Time.now
|
63
|
+
t = Time.new(t.year, t.month, t.day, t.hour, t.min).to_i + 60
|
64
|
+
assert_equal t, @redis.zscore(@key, @payload.to_json)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
describe "remove" do
|
69
|
+
it "returns true when removal was successful" do
|
70
|
+
@redis.zadd(@key, Time.now.to_i + 5, @payload.to_json)
|
71
|
+
assert Asyncron.remove(@callback, @payload)
|
72
|
+
end
|
73
|
+
|
74
|
+
it "returns false when removal was unsuccessful" do
|
75
|
+
refute Asyncron.remove(@callback, @payload)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
data/test/test_runner.rb
ADDED
metadata
ADDED
@@ -0,0 +1,79 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: asyncron
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: '0.1'
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Matthias Geier
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2017-09-19 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: '3'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: minitest
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '5'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '5'
|
41
|
+
description: Takes a cron expression, a payload and a callback for later execution
|
42
|
+
email:
|
43
|
+
executables: []
|
44
|
+
extensions: []
|
45
|
+
extra_rdoc_files: []
|
46
|
+
files:
|
47
|
+
- lib/asyncron.rb
|
48
|
+
- lib/asyncron/cron.rb
|
49
|
+
- lib/asyncron/schedule.rb
|
50
|
+
- lib/asyncron/version.rb
|
51
|
+
- test/asyncron_spec.rb
|
52
|
+
- test/test_runner.rb
|
53
|
+
homepage: https://github.com/matthias-geier/asyncron
|
54
|
+
licenses:
|
55
|
+
- BSD-2-Clause
|
56
|
+
metadata: {}
|
57
|
+
post_install_message:
|
58
|
+
rdoc_options: []
|
59
|
+
require_paths:
|
60
|
+
- lib
|
61
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
62
|
+
requirements:
|
63
|
+
- - ">="
|
64
|
+
- !ruby/object:Gem::Version
|
65
|
+
version: '0'
|
66
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
67
|
+
requirements:
|
68
|
+
- - ">="
|
69
|
+
- !ruby/object:Gem::Version
|
70
|
+
version: '0'
|
71
|
+
requirements: []
|
72
|
+
rubyforge_project:
|
73
|
+
rubygems_version: 2.5.1
|
74
|
+
signing_key:
|
75
|
+
specification_version: 4
|
76
|
+
summary: Asynchronous execution of cron jobs
|
77
|
+
test_files:
|
78
|
+
- test/test_runner.rb
|
79
|
+
- test/asyncron_spec.rb
|