qpush 0.1.0

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,72 @@
1
+ module QPush
2
+ class << self
3
+ def job(options)
4
+ job = Job::Wrapper.new(options)
5
+ job.queue
6
+ end
7
+ end
8
+
9
+ module Job
10
+ class << self
11
+ def included(base)
12
+ base.extend(ClassMethods)
13
+ end
14
+ end
15
+
16
+ module ClassMethods
17
+ def queue(options)
18
+ QPush.job(options.merge(klass: name))
19
+ end
20
+ end
21
+
22
+ module Base
23
+ def to_json
24
+ { klass: @klass,
25
+ id: @id,
26
+ priority: @priority,
27
+ created_at: @created_at,
28
+ start_at: @start_at,
29
+ cron: @cron,
30
+ retry_max: @retry_max,
31
+ total_fail: @total_fail,
32
+ total_success: @total_success,
33
+ args: @args }.to_json
34
+ end
35
+
36
+ private
37
+
38
+ def defaults
39
+ { id: SecureRandom.urlsafe_base64,
40
+ args: {},
41
+ priority: 3,
42
+ created_at: Time.now.to_i,
43
+ start_at: Time.now.to_i - 1,
44
+ cron: '',
45
+ retry_max: 10,
46
+ total_fail: 0,
47
+ total_success: 0
48
+ }
49
+ end
50
+ end
51
+
52
+ class Wrapper
53
+ include QPush::Job::Base
54
+
55
+ attr_accessor :klass, :id, :priority, :created_at, :start_at,
56
+ :cron, :retry_max, :total_success, :total_fail,
57
+ :args
58
+
59
+ def initialize(options = {})
60
+ options = defaults.merge(options)
61
+ options.each { |key, value| send("#{key}=", value) }
62
+ end
63
+
64
+ def queue
65
+ QPush.redis.with do |conn|
66
+ conn.incr("#{QPush.config.stats_namespace}:queued")
67
+ conn.lpush("#{QPush.config.queue_namespace}", to_json)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,5 @@
1
+ class TestJob
2
+ def call
3
+ puts 'hello'
4
+ end
5
+ end
@@ -0,0 +1,17 @@
1
+ module QPush
2
+ class << self
3
+ attr_reader :redis_pool
4
+
5
+ def redis
6
+ @redis_pool ||= RedisPool.create
7
+ end
8
+ end
9
+
10
+ class RedisPool
11
+ def self.create
12
+ ::ConnectionPool.new(size: QPush.config.redis_pool) do
13
+ ::Redis.new(url: QPush.config.redis_url)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,18 @@
1
+ # Base
2
+ require 'qpush/base'
3
+
4
+ # Server
5
+ require 'sequel'
6
+ require 'object_validator'
7
+ require 'parse-cron'
8
+ require 'qpush/server/database'
9
+ require 'qpush/server/delay'
10
+ require 'qpush/server/errors'
11
+ require 'qpush/server/execute'
12
+ require 'qpush/server/jobs'
13
+ require 'qpush/server/launcher'
14
+ require 'qpush/server/logger'
15
+ require 'qpush/server/manager'
16
+ require 'qpush/server/perform'
17
+ require 'qpush/server/queue'
18
+ require 'qpush/server/worker'
@@ -0,0 +1,13 @@
1
+ module QPush
2
+ class << self
3
+ def db
4
+ @db ||= Database.create
5
+ end
6
+ end
7
+
8
+ class Database
9
+ def self.create
10
+ Sequel.connect(QPush.config.database_url, max_connections: QPush.config.database_pool)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,68 @@
1
+ module QPush
2
+ module Server
3
+ # The Delay worker requeues any jobs that have been delayed on our Redis
4
+ # server. Delayed jobs are pulled by a 'zrangebyscore', with the score
5
+ # representing the time the job should be performed.
6
+ #
7
+ class Delay
8
+ def initialize
9
+ @done = false
10
+ @conn = nil
11
+ end
12
+
13
+ # Starts our delay process. This will run until instructed to stop.
14
+ #
15
+ def start
16
+ until @done
17
+ QPush.redis.with do |conn|
18
+ @conn = conn
19
+ watch_delay { retrieve_delays }
20
+ end
21
+ sleep 2
22
+ end
23
+ end
24
+
25
+ # Shutsdown our dleay process.
26
+ #
27
+ def shutdown
28
+ @done = true
29
+ end
30
+
31
+ private
32
+
33
+ # Retrieves delayed jobs based on the time they should be performed.
34
+ # If any are found, begin to update them.
35
+ #
36
+ def retrieve_delays
37
+ delays = @conn.zrangebyscore(QPush.config.delay_namespace, 0, Time.now.to_i)
38
+ delays.any? ? update_delays(delays) : @conn.unwatch
39
+ end
40
+
41
+ # Removes jobs that have been retrieved and sets them up to be performed.
42
+ #
43
+ def update_delays(delays)
44
+ @conn.multi do |multi|
45
+ multi.zrem(QPush.config.delay_namespace, delays)
46
+ delays.each { |job| perform_job(job) }
47
+ end
48
+ end
49
+
50
+ # Add a delayed job to the appropriate perform list.
51
+ #
52
+ def perform_job(json)
53
+ job = Job.new(JSON.parse(json))
54
+ job.api.perform
55
+ rescue => e
56
+ raise ServerError, e.message
57
+ end
58
+
59
+ # Performs a watch on our delay list
60
+ #
61
+ def watch_delay
62
+ @conn.watch(QPush.config.delay_namespace) do
63
+ yield if block_given?
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,18 @@
1
+ module QPush
2
+ class ServerError < StandardError
3
+ def initialize(msg = nil)
4
+ @message = msg
5
+ log_error
6
+ end
7
+
8
+ def message
9
+ "The following error occured: #{@message}"
10
+ end
11
+
12
+ private
13
+
14
+ def log_error
15
+ Server.log.err("Server Error - #{@message}")
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,92 @@
1
+ module QPush
2
+ module Server
3
+ class Execute
4
+ def initialize(job)
5
+ @job = job
6
+ end
7
+
8
+ def call
9
+ measure_run_time { job_object.call }
10
+ ExecutionSuccess.call(@job)
11
+ rescue => e
12
+ ExecutionFail.call(@job, e)
13
+ end
14
+
15
+ private
16
+
17
+ def measure_run_time
18
+ start = Time.now
19
+ yield
20
+ finish = Time.now
21
+ @job.run_time = "#{((finish - start) * 1000.0).round(3)} ms"
22
+ end
23
+
24
+ def job_object
25
+ klass = Object.const_get(@job.klass)
26
+ @job.args.empty? ? klass.new : klass.new(@job.args)
27
+ end
28
+ end
29
+
30
+ class ExecutionFail
31
+ def self.call(*args)
32
+ failed = ExecutionFail.new(*args)
33
+ failed.call
34
+ end
35
+
36
+ def initialize(job, error)
37
+ @job = job
38
+ @error = error
39
+ end
40
+
41
+ def call
42
+ @job.bump_fail
43
+ @job.api.retry if @job.retry_job?
44
+ stat_increment
45
+ log_error
46
+ end
47
+
48
+ private
49
+
50
+ def stat_increment
51
+ QPush.redis.with do |c|
52
+ c.incr("#{QPush.config.stats_namespace}:dead") if @job.dead_job?
53
+ c.incr("#{QPush.config.stats_namespace}:failed")
54
+ end
55
+ end
56
+
57
+ def log_error
58
+ Server.log.err("Job FAILED | #{@job.klass} | #{@job.id} | #{@error.message}")
59
+ end
60
+ end
61
+
62
+ class ExecutionSuccess
63
+ def self.call(*args)
64
+ success = ExecutionSuccess.new(*args)
65
+ success.call
66
+ end
67
+
68
+ def initialize(job)
69
+ @job = job
70
+ end
71
+
72
+ def call
73
+ @job.bump_success
74
+ @job.api.delay if @job.delay_job?
75
+ stat_increment
76
+ log_success
77
+ end
78
+
79
+ private
80
+
81
+ def stat_increment
82
+ QPush.redis.with do |c|
83
+ c.incr("#{QPush.config.stats_namespace}:success")
84
+ end
85
+ end
86
+
87
+ def log_success
88
+ Server.log.info("Job SUCCESS | #{@job.klass} with ID: #{@job.id} | #{@job.run_time}")
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,139 @@
1
+ module QPush
2
+ module Server
3
+ module JobHelpers
4
+ def bump_success
5
+ @total_success += 1
6
+ end
7
+
8
+ def bump_fail
9
+ @total_fail += 1
10
+ end
11
+
12
+ def retry_job?
13
+ @retry_max > @total_fail
14
+ end
15
+
16
+ def perform_job?
17
+ @start_at < Time.now.to_i && @cron.empty?
18
+ end
19
+
20
+ def delay_job?
21
+ (@start_at > Time.now.to_i && @cron.empty?) || cron_job?
22
+ end
23
+
24
+ def cron_job?
25
+ @start_at < Time.now.to_i && !@cron.empty?
26
+ end
27
+
28
+ def dead_job?
29
+ @total_fail >= @retry_max
30
+ end
31
+
32
+ def cron_at
33
+ CronParser.new(@cron).next(Time.now).to_i
34
+ rescue => e
35
+ raise ServerError, e.message
36
+ end
37
+
38
+ def delay_until
39
+ @cron.empty? ? @start_at : cron_at
40
+ end
41
+
42
+ def retry_at
43
+ Time.now.to_i + ((@total_fail**4) + 15 + (rand(30) * (@total_fail + 1)))
44
+ end
45
+ end
46
+
47
+ class Job
48
+ include QPush::Job::Base
49
+ include QPush::Server::JobHelpers
50
+ include ObjectValidator::Validate
51
+
52
+ attr_accessor :klass, :id, :priority, :created_at, :start_at,
53
+ :cron, :retry_max, :total_success, :total_fail,
54
+ :run_time
55
+ attr_reader :args, :api
56
+
57
+ def initialize(options = {})
58
+ options = defaults.merge(options)
59
+ options.each { |key, value| send("#{key}=", value) }
60
+ @api = JobApi.new(self)
61
+ end
62
+
63
+ def args=(args)
64
+ @args =
65
+ if args.is_a?(String) then JSON.parse(args)
66
+ else args
67
+ end
68
+ rescue JSON::ParserError
69
+ @args = nil
70
+ end
71
+ end
72
+
73
+ class JobValidator
74
+ include ObjectValidator::Validator
75
+
76
+ validates :klass,
77
+ with: { proc: proc { |j| Object.const_defined?(j.klass) },
78
+ msg: 'has not been defined' }
79
+ validates :cron,
80
+ with: { proc: proc { |j| j.cron.empty? ? true : CronParser.new(j.cron) },
81
+ msg: 'is not a valid expression' }
82
+ validates :id, type: String
83
+ validates :args, type: Hash
84
+ validates :created_at, type: Integer
85
+ validates :start_at, type: Integer
86
+ validates :retry_max, type: Integer
87
+ validates :total_fail, type: Integer
88
+ validates :total_success, type: Integer
89
+ end
90
+
91
+ class JobApi
92
+ def initialize(job)
93
+ @job = job
94
+ @config = QPush.config
95
+ end
96
+
97
+ def delay
98
+ QPush.redis.with do |conn|
99
+ conn.incr("#{@config.stats_namespace}:delayed")
100
+ conn.zadd(@config.delay_namespace, @job.delay_until, @job.to_json)
101
+ end
102
+ end
103
+
104
+ def queue
105
+ QPush.redis.with do |conn|
106
+ conn.incr("#{@config.stats_namespace}:queued")
107
+ conn.lpush("#{@config.queue_namespace}", @job.to_json)
108
+ end
109
+ end
110
+
111
+ def execute
112
+ execute = Execute.new(@job)
113
+ execute.call
114
+ end
115
+
116
+ def perform
117
+ QPush.redis.with do |conn|
118
+ conn.incr("#{@config.stats_namespace}:performed")
119
+ conn.lpush("#{@config.perform_namespace}:#{@job.priority}", @job.to_json)
120
+ end
121
+ end
122
+
123
+ def retry
124
+ QPush.redis.with do |conn|
125
+ conn.incr("#{@config.stats_namespace}:retries")
126
+ conn.zadd(@config.delay_namespace, @job.retry_at, @job.to_json)
127
+ end
128
+ end
129
+
130
+ def setup
131
+ fail unless @job.valid?
132
+ perform if @job.perform_job?
133
+ delay if @job.delay_job?
134
+ rescue
135
+ raise ServerError, 'Invalid job: ' + @job.errors.full_messages.join(' ')
136
+ end
137
+ end
138
+ end
139
+ end