asyncron 0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asyncron
4
+ VERSION = "0.1"
5
+ 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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "asyncron"
4
+ require "minitest/autorun"
5
+
6
+ Dir["test/**/*_spec.rb"].each { |f| load f }
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