faktory_worker_ruby 0.8.0 → 1.0.3

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.
@@ -7,7 +7,7 @@ module Faktory
7
7
  def create(options={})
8
8
  size = Faktory.worker? ? (Faktory.options[:concurrency] + 2) : 5
9
9
  ConnectionPool.new(:timeout => options[:pool_timeout] || 1, :size => size) do
10
- Faktory::Client.new
10
+ Faktory::Client.new(**options)
11
11
  end
12
12
  end
13
13
  end
data/lib/faktory/io.rb ADDED
@@ -0,0 +1,51 @@
1
+ require "io/wait"
2
+
3
+ # this is the necessary magic to get a line-oriented protocol to
4
+ # respect a read timeout. unfortunately Ruby sockets do not provide any
5
+ # timeout support directly, delegating that to the IO reactor.
6
+ module Faktory
7
+ class TimeoutError < Timeout::Error; end
8
+
9
+ module ReadTimeout
10
+ CRLF = "\r\n"
11
+ BUFSIZE = 16_384
12
+
13
+ # Ruby's TCP sockets do not implement timeouts.
14
+ # We have to implement them ourselves by using
15
+ # nonblocking IO and IO.select.
16
+ def initialize(**opts)
17
+ @buf = "".dup
18
+ @timeout = opts[:timeout] || 5
19
+ end
20
+
21
+ def gets
22
+ while (crlf = @buf.index(CRLF)).nil?
23
+ @buf << read_timeout(BUFSIZE)
24
+ end
25
+
26
+ @buf.slice!(0, crlf + 2)
27
+ end
28
+
29
+ def read(nbytes)
30
+ result = @buf.slice!(0, nbytes)
31
+ result << read_timeout(nbytes - result.bytesize) while result.bytesize < nbytes
32
+ result
33
+ end
34
+
35
+ private
36
+ def read_timeout(nbytes)
37
+ loop do
38
+ result = @sock.read_nonblock(nbytes, exception: false)
39
+ if result == :wait_readable
40
+ raise Faktory::TimeoutError unless @sock.wait_readable(@timeout)
41
+ elsif result == :wait_writable
42
+ raise Faktory::TimeoutError unless @sock.wait_writeable(@timeout)
43
+ elsif result == nil
44
+ raise Errno::ECONNRESET
45
+ else
46
+ return result
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
data/lib/faktory/job.rb CHANGED
@@ -1,4 +1,6 @@
1
1
  # frozen_string_literal: true
2
+ require 'faktory/tracking'
3
+
2
4
  module Faktory
3
5
 
4
6
  ##
@@ -20,6 +22,9 @@ module Faktory
20
22
  # Note that perform_async is a class method, perform is an instance method.
21
23
  module Job
22
24
  attr_accessor :jid
25
+ attr_accessor :bid
26
+
27
+ include Faktory::Trackable
23
28
 
24
29
  def self.included(base)
25
30
  raise ArgumentError, "You cannot include Faktory::Job in an ActiveJob: #{base.name}" if base.ancestors.any? {|c| c.name == 'ActiveJob::Base' }
@@ -28,6 +33,16 @@ module Faktory
28
33
  base.faktory_class_attribute :faktory_options_hash
29
34
  end
30
35
 
36
+ def self.set(options)
37
+ Setter.new(options)
38
+ end
39
+
40
+ def batch
41
+ if bid
42
+ @batch ||= Faktory::Batch.new(bid)
43
+ end
44
+ end
45
+
31
46
  def logger
32
47
  Faktory.logger
33
48
  end
@@ -42,7 +57,7 @@ module Faktory
42
57
  end
43
58
 
44
59
  def perform_async(*args)
45
- @opts['jobtype'.freeze].client_push(@opts.merge!('args'.freeze => args))
60
+ client_push(@opts.merge('args'.freeze => args))
46
61
  end
47
62
 
48
63
  # +interval+ must be a timestamp, numeric or something that acts
@@ -53,34 +68,53 @@ module Faktory
53
68
  ts = (int < 1_000_000_000 ? now + int : int)
54
69
  at = Time.at(ts).utc.to_datetime.rfc3339(9)
55
70
 
56
- @opts.merge! 'args'.freeze => args, 'at'.freeze => at
71
+ item = @opts.merge('args'.freeze => args, 'at'.freeze => at)
72
+
57
73
  # 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)
74
+ item.delete('at'.freeze) if ts <= now
75
+
76
+ client_push(item)
60
77
  end
61
78
  alias_method :perform_at, :perform_in
79
+
80
+ def client_push(item) # :nodoc:
81
+ # stringify
82
+ item.keys.each do |key|
83
+ item[key.to_s] = item.delete(key)
84
+ end
85
+ item["jid"] ||= SecureRandom.hex(12)
86
+ item["queue"] ||= "default"
87
+
88
+ pool = Thread.current[:faktory_via_pool] || item["pool"] || Faktory.server_pool
89
+ item.delete("pool")
90
+
91
+ # the payload hash is shallow copied by `merge` calls BUT we don't deep clone
92
+ # the 'custom' child hash which can be problematic if we mutate it within middleware.
93
+ # Proactively dup it first.
94
+ item["custom"] = item["custom"].dup if item["custom"]
95
+
96
+ Faktory.client_middleware.invoke(item, pool) do
97
+ pool.with do |c|
98
+ c.push(item)
99
+ end
100
+ end
101
+ end
62
102
  end
63
103
 
64
104
  module ClassMethods
65
105
 
66
106
  def set(options)
67
- Setter.new(options.merge!('jobtype'.freeze => self))
107
+ Setter.new(options.merge('jobtype'.freeze => self))
68
108
  end
69
109
 
70
110
  def perform_async(*args)
71
- client_push('jobtype'.freeze => self, 'args'.freeze => args)
111
+ set(get_faktory_options).perform_async(*args)
72
112
  end
73
113
 
74
114
  # +interval+ must be a timestamp, numeric or something that acts
75
115
  # numeric (like an activesupport time interval).
76
116
  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)
117
+ set(get_faktory_options).perform_in(interval, *args)
84
118
  end
85
119
  alias_method :perform_at, :perform_in
86
120
 
@@ -102,23 +136,6 @@ module Faktory
102
136
  self.faktory_options_hash ||= Faktory.default_job_options
103
137
  end
104
138
 
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
139
  def faktory_class_attribute(*attrs)
123
140
  instance_reader = true
124
141
  instance_writer = true
@@ -11,7 +11,7 @@ module Faktory
11
11
  def initialize(options)
12
12
  merged_options = Faktory.options.merge(options)
13
13
  @manager = Faktory::Manager.new(merged_options)
14
- @done = false
14
+ @current_state = nil
15
15
  @options = merged_options
16
16
  end
17
17
 
@@ -22,7 +22,7 @@ module Faktory
22
22
 
23
23
  # Stops this instance from processing any more jobs,
24
24
  def quiet
25
- @done = true
25
+ @current_state = 'quiet'
26
26
  @manager.quiet
27
27
  end
28
28
 
@@ -32,13 +32,17 @@ module Faktory
32
32
  def stop
33
33
  deadline = Time.now + @options[:timeout]
34
34
 
35
- @done = true
35
+ @current_state = 'terminate'
36
36
  @manager.quiet
37
37
  @manager.stop(deadline)
38
38
  end
39
39
 
40
40
  def stopping?
41
- @done
41
+ @current_state == 'terminate'
42
+ end
43
+
44
+ def quiet?
45
+ @current_state == 'quiet'
42
46
  end
43
47
 
44
48
  PROCTITLES = []
@@ -50,12 +54,13 @@ module Faktory
50
54
  PROCTITLES << proc { title }
51
55
  PROCTITLES << proc { "[#{Processor.busy_count} of #{@options[:concurrency]} busy]" }
52
56
  PROCTITLES << proc { "stopping" if stopping? }
57
+ PROCTITLES << proc { "quiet" if quiet? }
53
58
 
54
59
  loop do
55
60
  $0 = PROCTITLES.map {|p| p.call }.join(" ")
56
61
 
57
62
  begin
58
- result = Faktory.server {|c| c.beat }
63
+ result = Faktory.server {|c| c.beat(@current_state) }
59
64
  case result
60
65
  when "OK"
61
66
  # all good
@@ -29,7 +29,7 @@ module Faktory
29
29
  def self.job_hash_context(job_hash)
30
30
  # If we're using a wrapper class, like ActiveJob, use the "wrapped"
31
31
  # attribute to expose the underlying thing.
32
- klass = job_hash['custom']['wrapped'] || job_hash["jobtype"]
32
+ klass = job_hash.dig('custom', 'wrapped') || job_hash["jobtype"]
33
33
  "#{klass} JID-#{job_hash['jid']}"
34
34
  end
35
35
 
@@ -123,6 +123,9 @@ module Faktory
123
123
  cleanup.each do |processor|
124
124
  processor.kill
125
125
  end
126
+ cleanup.each do |processor|
127
+ processor.thread.join(1)
128
+ end
126
129
  end
127
130
 
128
131
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # Simple middleware to save the current batch and restore it when the job executes.
4
+ #
5
+ module Faktory::Middleware::Batch
6
+ class Client
7
+ def call(payload, pool)
8
+ b = Thread.current[:faktory_batch]
9
+ if b
10
+ payload["custom"] ||= {}
11
+ payload["custom"]["bid"] = b.bid
12
+ end
13
+ yield
14
+ end
15
+ end
16
+
17
+ class Worker
18
+ def call(jobinst, payload)
19
+ jobinst.bid = payload.dig("custom", "bid")
20
+ yield
21
+ end
22
+ end
23
+ end
24
+
25
+ Faktory.configure_client do |config|
26
+ config.client_middleware do |chain|
27
+ chain.add Faktory::Middleware::Batch::Client
28
+ end
29
+ end
30
+
31
+ Faktory.configure_worker do |config|
32
+ config.client_middleware do |chain|
33
+ chain.add Faktory::Middleware::Batch::Client
34
+ end
35
+ config.worker_middleware do |chain|
36
+ chain.add Faktory::Middleware::Batch::Worker
37
+ end
38
+ end
@@ -0,0 +1,91 @@
1
+ require 'faktory/client'
2
+
3
+ ##
4
+ #
5
+ # Faktory's MUTATE API allows you to scan the sorted sets
6
+ # within Redis (retries, scheduled, dead) and take action
7
+ # (delete, enqueue, kill) on entries.
8
+ #
9
+ # require 'faktory/mutate'
10
+ # cl = Faktory::Client.new
11
+ # cl.discard(Faktory::RETRIES) do |filter|
12
+ # filter.with_type("QuickBooksSyncJob")
13
+ # filter.matching("*uid:12345*"))
14
+ # end
15
+ module Faktory
16
+
17
+ # Valid targets
18
+ RETRIES = "retries"
19
+ SCHEDULED = "scheduled"
20
+ DEAD = "dead"
21
+
22
+ module Mutator
23
+ class Filter
24
+ attr_accessor :hash
25
+
26
+ def initialize
27
+ @hash = {}
28
+ end
29
+
30
+ # This must be the exact type of the job, no pattern matching
31
+ def with_type(jobtype)
32
+ @hash[:jobtype] = jobtype
33
+ end
34
+
35
+ # This is a regexp that will be passed as is to Redis's SCAN.
36
+ # Notably you should surround it with * to ensure it matches
37
+ # substrings within the job payload.
38
+ # See https://redis.io/commands/scan for details.
39
+ def matching(regexp)
40
+ @hash[:regexp] = regexp
41
+ end
42
+
43
+ # One or more JIDs to target:
44
+ # filter.jids << 'abcdefgh1234'
45
+ # filter.jids = ['abcdefgh1234', '1234567890']
46
+ def jids
47
+ @hash[:jids] ||= []
48
+ end
49
+ def jids=(ary)
50
+ @hash[:jids] = Array(ary)
51
+ end
52
+ end
53
+
54
+ def discard(target, &block)
55
+ filter = Filter.new
56
+ block.call(filter) if block
57
+ mutate('discard', target, filter)
58
+ end
59
+
60
+ def kill(target, &block)
61
+ filter = Filter.new
62
+ block.call(filter) if block
63
+ mutate('kill', target, filter)
64
+ end
65
+
66
+ def requeue(target, &block)
67
+ filter = Filter.new
68
+ block.call(filter) if block
69
+ mutate('requeue', target, filter)
70
+ end
71
+
72
+ def clear(target)
73
+ mutate('discard', target, nil)
74
+ end
75
+
76
+ private
77
+
78
+ def mutate(cmd, target, filter)
79
+ payload = {:cmd => cmd,:target => target}
80
+ payload[:filter] = filter.hash if filter && !filter.hash.empty?
81
+
82
+ transaction do
83
+ command("MUTATE", JSON.dump(payload))
84
+ ok
85
+ end
86
+ end
87
+
88
+ end
89
+ end
90
+
91
+ Faktory::Client.send(:include, Faktory::Mutator)
@@ -88,6 +88,7 @@ module Faktory
88
88
  @@busy_count = @@busy_count + 1
89
89
  end
90
90
  begin
91
+ @job = work.job
91
92
  process(work)
92
93
  ensure
93
94
  @@busy_lock.synchronize do
@@ -148,10 +149,11 @@ module Faktory
148
149
  end
149
150
  end
150
151
  work.acknowledge
151
- rescue Faktory::Shutdown
152
- # Had to force kill this job because it didn't finish
153
- # within the timeout. Don't acknowledge the work since
154
- # we didn't properly finish it.
152
+ rescue Faktory::Shutdown => shut
153
+ # Had to force kill this job because it didn't finish within
154
+ # the timeout. Fail it so we can release any locks server-side
155
+ # and immediately restart it.
156
+ work.fail(shut)
155
157
  rescue Exception => ex
156
158
  handle_exception(ex, { :context => "Job raised exception", :job => work.job })
157
159
  work.fail(ex)
data/lib/faktory/rails.rb CHANGED
@@ -23,9 +23,19 @@ module Faktory
23
23
  if ::Rails::VERSION::MAJOR < 5
24
24
  raise "Your current version of Rails, #{::Rails::VERSION::STRING}, is not supported"
25
25
  end
26
-
26
+
27
27
  Faktory.options[:reloader] = Faktory::Rails::Reloader.new
28
28
  end
29
+
30
+ begin
31
+ # https://github.com/rails/rails/pull/41248
32
+ if defined?(::Mail::SMTP)
33
+ ::Mail::SMTP::DEFAULTS[:read_timeout] ||= 5
34
+ ::Mail::SMTP::DEFAULTS[:open_timeout] ||= 5
35
+ end
36
+ rescue => ex
37
+ # ignore
38
+ end
29
39
  end
30
40
 
31
41
  class Reloader
@@ -94,9 +94,9 @@ module Faktory
94
94
  end
95
95
  end
96
96
 
97
- def open
97
+ def open(*args)
98
98
  unless Faktory::Testing.enabled?
99
- real_open
99
+ real_open(*args)
100
100
  end
101
101
  end
102
102
  end