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.
@@ -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