faktory_worker_ruby 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+ require 'connection_pool'
3
+
4
+ module Faktory
5
+ class Connection
6
+ class << self
7
+ def create(options={})
8
+ size = Faktory.worker? ? (Faktory.options[:concurrency] + 2) : 5
9
+ ConnectionPool.new(:timeout => options[:pool_timeout] || 1, :size => size) do
10
+ Faktory::Client.new
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+ require 'faktory'
3
+
4
+ module Faktory
5
+ module ExceptionHandler
6
+
7
+ class Logger
8
+ def call(ex, ctxHash)
9
+ Faktory.logger.warn(Faktory.dump_json(ctxHash)) if !ctxHash.empty?
10
+ Faktory.logger.warn "#{ex.class.name}: #{ex.message}"
11
+ Faktory.logger.warn ex.backtrace.join("\n") unless ex.backtrace.nil?
12
+ end
13
+
14
+ # Set up default handler which just logs the error
15
+ Faktory.error_handlers << Faktory::ExceptionHandler::Logger.new
16
+ end
17
+
18
+ def handle_exception(ex, ctxHash={})
19
+ Faktory.error_handlers.each do |handler|
20
+ begin
21
+ handler.call(ex, ctxHash)
22
+ rescue => ex
23
+ Faktory.logger.error "!!! ERROR HANDLER THREW AN ERROR !!!"
24
+ Faktory.logger.error ex
25
+ Faktory.logger.error ex.backtrace.join("\n") unless ex.backtrace.nil?
26
+ end
27
+ end
28
+ end
29
+
30
+ end
31
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Faktory
4
+ UnitOfWork = Struct.new(:job) do
5
+ def acknowledge
6
+ Faktory.server {|c| c.ack(jid) }
7
+ end
8
+
9
+ def fail(ex)
10
+ Faktory.server {|c| c.fail(jid, ex) }
11
+ end
12
+
13
+ def jid
14
+ job['jid']
15
+ end
16
+ end
17
+
18
+ class Fetcher
19
+ def initialize(options)
20
+ @strictly_ordered_queues = !!options[:strict]
21
+ @queues = options[:queues]
22
+ @queues = @queues.uniq if @strictly_ordered_queues
23
+ end
24
+
25
+ def retrieve_work
26
+ work = Faktory.server { |conn| conn.fetch(*queues_cmd) }
27
+ UnitOfWork.new(work) if work
28
+ end
29
+
30
+ # Creating the pop command takes into account any
31
+ # configured queue weights. By default pop returns
32
+ # data from the first queue that has pending elements. We
33
+ # recreate the queue command each time we invoke pop
34
+ # to honor weights and avoid queue starvation.
35
+ def queues_cmd
36
+ if @strictly_ordered_queues
37
+ @queues
38
+ else
39
+ @queues.shuffle.uniq
40
+ end
41
+ end
42
+
43
+ end
44
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+ module Faktory
3
+
4
+ ##
5
+ # Include this module in your Job class and you can easily create
6
+ # asynchronous jobs:
7
+ #
8
+ # class HardJob
9
+ # include Faktory::Job
10
+ #
11
+ # def perform(*args)
12
+ # # do some work
13
+ # end
14
+ # end
15
+ #
16
+ # Then in your Rails app, you can do this:
17
+ #
18
+ # HardJob.perform_async(1, 2, 3)
19
+ #
20
+ # Note that perform_async is a class method, perform is an instance method.
21
+ module Job
22
+ attr_accessor :jid
23
+
24
+ def self.included(base)
25
+ raise ArgumentError, "You cannot include Faktory::Job in an ActiveJob: #{base.name}" if base.ancestors.any? {|c| c.name == 'ActiveJob::Base' }
26
+
27
+ base.extend(ClassMethods)
28
+ base.faktory_class_attribute :faktory_options_hash
29
+ end
30
+
31
+ def logger
32
+ Faktory.logger
33
+ end
34
+
35
+ # This helper class encapsulates the set options for `set`, e.g.
36
+ #
37
+ # SomeJob.set(queue: 'foo').perform_async(....)
38
+ #
39
+ class Setter
40
+ def initialize(opts)
41
+ @opts = opts
42
+ end
43
+
44
+ def perform_async(*args)
45
+ @opts['jobtype'.freeze].client_push(@opts.merge!('args'.freeze => args))
46
+ end
47
+
48
+ # +interval+ must be a timestamp, numeric or something that acts
49
+ # numeric (like an activesupport time interval).
50
+ def perform_in(interval, *args)
51
+ int = interval.to_f
52
+ now = Time.now.to_f
53
+ ts = (int < 1_000_000_000 ? now + int : int)
54
+ at = Time.at(ts).utc.to_datetime.rfc3339(9)
55
+
56
+ @opts.merge! 'args'.freeze => args, 'at'.freeze => at
57
+ # Optimization to enqueue something now that is scheduled to go out now or in the past
58
+ @opts.delete('at'.freeze) if ts <= now
59
+ @opts['jobtype'.freeze].client_push(@opts)
60
+ end
61
+ alias_method :perform_at, :perform_in
62
+ end
63
+
64
+ module ClassMethods
65
+
66
+ def set(options)
67
+ Setter.new(options.merge!('jobtype'.freeze => self))
68
+ end
69
+
70
+ def perform_async(*args)
71
+ client_push('jobtype'.freeze => self, 'args'.freeze => args)
72
+ end
73
+
74
+ # +interval+ must be a timestamp, numeric or something that acts
75
+ # numeric (like an activesupport time interval).
76
+ def perform_in(interval, *args)
77
+ int = interval.to_f
78
+ now = Time.now.to_f
79
+ ts = (int < 1_000_000_000 ? now + int : int)
80
+ item = { 'jobtype'.freeze => self, 'args'.freeze => args }
81
+
82
+ item['at'] = Time.at(ts).utc.to_datetime.rfc3339(9) if ts > now
83
+ client_push(item)
84
+ end
85
+ alias_method :perform_at, :perform_in
86
+
87
+ ##
88
+ # Allows customization of Faktory features for this type of Job.
89
+ # Legal options:
90
+ #
91
+ # queue - use a named queue for this Job, default 'default'
92
+ # retry - enable automatic retry for this Job, *Integer* count, default 25
93
+ # backtrace - whether to save the error backtrace in the job payload to display in web UI,
94
+ # an integer number of lines to save, default *0*
95
+ #
96
+ def faktory_options(opts={})
97
+ # stringify
98
+ self.faktory_options_hash = get_faktory_options.merge(Hash[opts.map{|k, v| [k.to_s, v]}])
99
+ end
100
+
101
+ def get_faktory_options # :nodoc:
102
+ self.faktory_options_hash ||= Faktory.default_job_options
103
+ end
104
+
105
+ def client_push(item) # :nodoc:
106
+ pool = Thread.current[:faktory_via_pool] || get_faktory_options['pool'.freeze] || Faktory.server_pool
107
+ item = get_faktory_options.merge(item)
108
+ # stringify
109
+ item.keys.each do |key|
110
+ item[key.to_s] = item.delete(key)
111
+ end
112
+ item["jid"] ||= SecureRandom.hex(12)
113
+ item["queue"] ||= "default"
114
+
115
+ Faktory.client_middleware.invoke(item, pool) do
116
+ pool.with do |c|
117
+ c.push(item)
118
+ end
119
+ end
120
+ end
121
+
122
+ def faktory_class_attribute(*attrs)
123
+ instance_reader = true
124
+ instance_writer = true
125
+
126
+ attrs.each do |name|
127
+ singleton_class.instance_eval do
128
+ undef_method(name) if method_defined?(name) || private_method_defined?(name)
129
+ end
130
+ define_singleton_method(name) { nil }
131
+
132
+ ivar = "@#{name}"
133
+
134
+ singleton_class.instance_eval do
135
+ m = "#{name}="
136
+ undef_method(m) if method_defined?(m) || private_method_defined?(m)
137
+ end
138
+ define_singleton_method("#{name}=") do |val|
139
+ singleton_class.class_eval do
140
+ undef_method(name) if method_defined?(name) || private_method_defined?(name)
141
+ define_method(name) { val }
142
+ end
143
+
144
+ if singleton_class?
145
+ class_eval do
146
+ undef_method(name) if method_defined?(name) || private_method_defined?(name)
147
+ define_method(name) do
148
+ if instance_variable_defined? ivar
149
+ instance_variable_get ivar
150
+ else
151
+ singleton_class.send name
152
+ end
153
+ end
154
+ end
155
+ end
156
+ val
157
+ end
158
+
159
+ if instance_reader
160
+ undef_method(name) if method_defined?(name) || private_method_defined?(name)
161
+ define_method(name) do
162
+ if instance_variable_defined?(ivar)
163
+ instance_variable_get ivar
164
+ else
165
+ self.class.public_send name
166
+ end
167
+ end
168
+ end
169
+
170
+ if instance_writer
171
+ m = "#{name}="
172
+ undef_method(m) if method_defined?(m) || private_method_defined?(m)
173
+ attr_writer name
174
+ end
175
+ end
176
+ end
177
+
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,24 @@
1
+ module Faktory
2
+ class JobLogger
3
+
4
+ def call(item)
5
+ start = Time.now
6
+ logger.info("start".freeze)
7
+ yield
8
+ logger.info("done: #{elapsed(start)} sec")
9
+ rescue Exception
10
+ logger.info("fail: #{elapsed(start)} sec")
11
+ raise
12
+ end
13
+
14
+ private
15
+
16
+ def elapsed(start)
17
+ (Time.now - start).round(3)
18
+ end
19
+
20
+ def logger
21
+ Faktory.logger
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,66 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+ require 'faktory/manager'
4
+
5
+ module Faktory
6
+ class Launcher
7
+ include Util
8
+
9
+ attr_accessor :manager
10
+
11
+ def initialize(options)
12
+ @manager = Faktory::Manager.new(options)
13
+ @done = false
14
+ @options = options
15
+ end
16
+
17
+ def run
18
+ @thread = safe_thread("heartbeat", &method(:heartbeat))
19
+ @manager.start
20
+ end
21
+
22
+ # Stops this instance from processing any more jobs,
23
+ def quiet
24
+ @done = true
25
+ @manager.quiet
26
+ end
27
+
28
+ # Shuts down the process. This method does not
29
+ # return until all work is complete and cleaned up.
30
+ # It can take up to the timeout to complete.
31
+ def stop
32
+ deadline = Time.now + @options[:timeout]
33
+
34
+ @done = true
35
+ @manager.quiet
36
+ @manager.stop(deadline)
37
+ end
38
+
39
+ def stopping?
40
+ @done
41
+ end
42
+
43
+ PROCTITLES = []
44
+
45
+ private unless $TESTING
46
+
47
+ def heartbeat
48
+ title = ['faktory-worker', Faktory::VERSION, @options[:tag]].compact.join(" ")
49
+ PROCTITLES << proc { title }
50
+ PROCTITLES << proc { "[#{Processor.busy_count} of #{@options[:concurrency]} busy]" }
51
+ PROCTITLES << proc { "stopping" if stopping? }
52
+
53
+ loop do
54
+ $0 = PROCTITLES.map {|p| p.call }.join(" ")
55
+
56
+ begin
57
+ Faktory.server {|c| c.beat }
58
+ rescue => ex
59
+ # best effort, try again in a few secs
60
+ end
61
+ sleep 10
62
+ end
63
+ end
64
+
65
+ end
66
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+ require 'time'
3
+ require 'logger'
4
+ require 'fcntl'
5
+
6
+ module Faktory
7
+ module Logging
8
+
9
+ class Pretty < Logger::Formatter
10
+ SPACE = " "
11
+
12
+ # Provide a call() method that returns the formatted message.
13
+ def call(severity, time, program_name, message)
14
+ "#{time.utc.iso8601(3)} #{::Process.pid} TID-#{Thread.current.object_id.to_s(36)}#{context} #{severity}: #{message}\n"
15
+ end
16
+
17
+ def context
18
+ c = Thread.current[:faktory_context]
19
+ " #{c.join(SPACE)}" if c && c.any?
20
+ end
21
+ end
22
+
23
+ class WithoutTimestamp < Pretty
24
+ def call(severity, time, program_name, message)
25
+ "#{::Process.pid} TID-#{Thread.current.object_id.to_s(36)}#{context} #{severity}: #{message}\n"
26
+ end
27
+ end
28
+
29
+ def self.job_hash_context(job_hash)
30
+ # If we're using a wrapper class, like ActiveJob, use the "wrapped"
31
+ # attribute to expose the underlying thing.
32
+ klass = job_hash['wrapped'.freeze] || job_hash["jobtype".freeze]
33
+ "#{klass} JID-#{job_hash['jid'.freeze]}"
34
+ end
35
+
36
+ def self.with_job_hash_context(job_hash, &block)
37
+ with_context(job_hash_context(job_hash), &block)
38
+ end
39
+
40
+ def self.with_context(msg)
41
+ Thread.current[:faktory_context] ||= []
42
+ Thread.current[:faktory_context] << msg
43
+ yield
44
+ ensure
45
+ Thread.current[:faktory_context].pop
46
+ end
47
+
48
+ def self.initialize_logger(log_target = STDOUT)
49
+ oldlogger = defined?(@logger) ? @logger : nil
50
+ @logger = Logger.new(log_target)
51
+ @logger.level = Logger::INFO
52
+ # We assume that any TTY is logging directly to a terminal and needs timestamps.
53
+ # We assume that any non-TTY is logging to Upstart/Systemd/syslog/Heroku/etc with a decent
54
+ # logging subsystem that provides a timestamp for each entry.
55
+ @logger.formatter = log_target.tty? ? Pretty.new : WithoutTimestamp.new
56
+ oldlogger.close if oldlogger && !$TESTING # don't want to close testing's STDOUT logging
57
+ @logger
58
+ end
59
+
60
+ def self.logger
61
+ defined?(@logger) ? @logger : initialize_logger
62
+ end
63
+
64
+ def self.logger=(log)
65
+ @logger = (log ? log : Logger.new(File::NULL))
66
+ end
67
+
68
+ def logger
69
+ Faktory::Logging.logger
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,129 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+ require 'faktory/util'
4
+ require 'faktory/processor'
5
+ require 'faktory/fetch'
6
+ require 'thread'
7
+ require 'set'
8
+
9
+ module Faktory
10
+
11
+ ##
12
+ # The Manager is the central coordination point in Faktory, controlling
13
+ # the lifecycle of the Processors.
14
+ #
15
+ # Tasks:
16
+ #
17
+ # 1. start: Spin up Processors.
18
+ # 3. processor_died: Handle job failure, throw away Processor, create new one.
19
+ # 4. quiet: shutdown idle Processors.
20
+ # 5. stop: hard stop the Processors by deadline.
21
+ #
22
+ # Note that only the last task requires its own Thread since it has to monitor
23
+ # the shutdown process. The other tasks are performed by other threads.
24
+ #
25
+ class Manager
26
+ include Util
27
+
28
+ attr_reader :threads
29
+ attr_reader :options
30
+
31
+ def initialize(options={})
32
+ logger.debug { options.inspect }
33
+ @options = options
34
+ @count = options[:concurrency] || 25
35
+ raise ArgumentError, "Concurrency of #{@count} is not supported" if @count < 1
36
+
37
+ @done = false
38
+ @threads = Set.new
39
+ @count.times do
40
+ @threads << Processor.new(self)
41
+ end
42
+ @plock = Mutex.new
43
+ end
44
+
45
+ def start
46
+ @threads.each do |x|
47
+ x.start
48
+ end
49
+ end
50
+
51
+ def quiet
52
+ return if @done
53
+ @done = true
54
+
55
+ logger.info { "Terminating quiet threads" }
56
+ @threads.each { |x| x.terminate }
57
+ fire_event(:quiet, true)
58
+ end
59
+
60
+ # hack for quicker development / testing environment
61
+ PAUSE_TIME = STDOUT.tty? ? 0.1 : 0.5
62
+
63
+ def stop(deadline)
64
+ quiet
65
+ fire_event(:shutdown, true)
66
+
67
+ # some of the shutdown events can be async,
68
+ # we don't have any way to know when they're done but
69
+ # give them a little time to take effect
70
+ sleep PAUSE_TIME
71
+ return if @threads.empty?
72
+
73
+ logger.info { "Pausing to allow threads to finish..." }
74
+ remaining = deadline - Time.now
75
+ while remaining > PAUSE_TIME
76
+ return if @threads.empty?
77
+ sleep PAUSE_TIME
78
+ remaining = deadline - Time.now
79
+ end
80
+ return if @threads.empty?
81
+
82
+ hard_shutdown
83
+ end
84
+
85
+ def processor_stopped(processor)
86
+ @plock.synchronize do
87
+ @threads.delete(processor)
88
+ end
89
+ end
90
+
91
+ def processor_died(processor, reason)
92
+ @plock.synchronize do
93
+ @threads.delete(processor)
94
+ unless @done
95
+ p = Processor.new(self)
96
+ @threads << p
97
+ p.start
98
+ end
99
+ end
100
+ end
101
+
102
+ def stopped?
103
+ @done
104
+ end
105
+
106
+ private
107
+
108
+ def hard_shutdown
109
+ # We've reached the timeout and we still have busy threads.
110
+ # They must die but their jobs shall live on.
111
+ cleanup = nil
112
+ @plock.synchronize do
113
+ cleanup = @threads.dup
114
+ end
115
+
116
+ if cleanup.size > 0
117
+ jobs = cleanup.map {|p| p.job }.compact
118
+
119
+ logger.warn { "Terminating #{cleanup.size} busy worker threads" }
120
+ logger.warn { "Work still in progress #{jobs.inspect}" }
121
+ end
122
+
123
+ cleanup.each do |processor|
124
+ processor.kill
125
+ end
126
+ end
127
+
128
+ end
129
+ end