faktory_worker_ruby 0.5.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,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