asyncron 0.1
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 +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
|