faktory_worker_ruby 0.8.1 → 1.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 7877d9a875449524da22d1e8efd6f29a23381fe0
4
- data.tar.gz: 665d0631430befd3f019588366502558f7e6f9c8
2
+ SHA256:
3
+ metadata.gz: 52c0a1a508d1d2451f4c350b71f0d4091e7c134b8c4b57b802023e0f1527bd1d
4
+ data.tar.gz: 98fedca04b49b7ab68d8ce398099cfeb92b74d4bd4d3b8951c4f2a15d8d27d10
5
5
  SHA512:
6
- metadata.gz: 3b6c62d76a317adecdf96e161a42ad817a5cc4b3e23c0bab49b6217e7240a7aef2348b0ff2117b156d10eaed6f832196e38e53e75fabae154263069a2e7ee29f
7
- data.tar.gz: b0d68a791832e2e916578757b93675c408461e93022483da0cd24f1dcce3d4fe5b825505f1d6c1985a0b6880958db2ede88ccf689a9e031de98aae2813327820
6
+ metadata.gz: e3a1766171e19dd6750a558dd93eb4734529412674aac18f18b792c716643d5d09e668f0e5ddaf5b291a0c861729a9a29a7918db6b56ac314bbd6d761ab31b6f
7
+ data.tar.gz: 5c1f3c6d16d42ecb2e90e82ff926d147526ce483cc5b4ac178459935e4b22c6bf4e9816038f82fa317871c53df54ee631a817ebbfc47c42f85a519e12cccee55
data/Changes.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changes
2
2
 
3
+ ## 1.0.0
4
+
5
+ - Ruby 2.5+ is now required
6
+ - Support for Faktory Enterprise, job batches and job tracking
7
+ - Support for the MUTATE command.
8
+ - Notify Faktory when a worker process is going quiet so that the UI shows this
9
+ - Refactor Faktory::Client error handling for faktory#208
10
+
3
11
  ## 0.8.1
4
12
 
5
13
  - Fix breakage with non-ActiveJobs [#29]
data/Gemfile CHANGED
@@ -1,3 +1,5 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
+ gem 'simplecov', require: false, group: :test
4
+
3
5
  gemspec
@@ -1,30 +1,37 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- faktory_worker_ruby (0.8.1)
5
- connection_pool (~> 2.2, >= 2.2.1)
4
+ faktory_worker_ruby (1.0.0)
5
+ connection_pool (~> 2.2, >= 2.2.2)
6
6
 
7
7
  GEM
8
8
  remote: https://rubygems.org/
9
9
  specs:
10
- activejob (5.2.1)
11
- activesupport (= 5.2.1)
10
+ activejob (5.2.2)
11
+ activesupport (= 5.2.2)
12
12
  globalid (>= 0.3.6)
13
- activesupport (5.2.1)
13
+ activesupport (5.2.2)
14
14
  concurrent-ruby (~> 1.0, >= 1.0.2)
15
15
  i18n (>= 0.7, < 2)
16
16
  minitest (~> 5.1)
17
17
  tzinfo (~> 1.1)
18
- concurrent-ruby (1.0.5)
18
+ concurrent-ruby (1.1.4)
19
19
  connection_pool (2.2.2)
20
- globalid (0.4.1)
20
+ docile (1.3.1)
21
+ globalid (0.4.2)
21
22
  activesupport (>= 4.2.0)
22
- i18n (1.1.0)
23
+ i18n (1.5.3)
23
24
  concurrent-ruby (~> 1.0)
25
+ json (2.1.0)
24
26
  minitest (5.11.3)
25
27
  minitest-hooks (1.5.0)
26
28
  minitest (> 5.3)
27
29
  rake (12.3.1)
30
+ simplecov (0.16.1)
31
+ docile (~> 1.1)
32
+ json (>= 1.8, < 3)
33
+ simplecov-html (~> 0.10.0)
34
+ simplecov-html (0.10.2)
28
35
  thread_safe (0.3.6)
29
36
  tzinfo (1.2.5)
30
37
  thread_safe (~> 0.1)
@@ -38,6 +45,4 @@ DEPENDENCIES
38
45
  minitest (~> 5)
39
46
  minitest-hooks
40
47
  rake (~> 12)
41
-
42
- BUNDLED WITH
43
- 1.17.1
48
+ simplecov
@@ -14,9 +14,9 @@ Gem::Specification.new do |gem|
14
14
  gem.files = `git ls-files | grep -Ev '^(test|myapp|examples)'`.split("\n")
15
15
  gem.test_files = []
16
16
  gem.version = Faktory::VERSION
17
- gem.required_ruby_version = ">= 2.3.0"
17
+ gem.required_ruby_version = ">= 2.5.0"
18
18
 
19
- gem.add_dependency 'connection_pool', '~> 2.2', ">= 2.2.1"
19
+ gem.add_dependency 'connection_pool', '~> 2.2', ">= 2.2.2"
20
20
  gem.add_development_dependency 'activejob', '>= 5.1.5'
21
21
  gem.add_development_dependency 'minitest', '~> 5'
22
22
  gem.add_development_dependency 'minitest-hooks'
@@ -52,6 +52,7 @@ module Faktory
52
52
  # config.worker_middleware do |chain|
53
53
  # chain.add MyServerHook
54
54
  # end
55
+ # config.default_job_options = { retry: 3 }
55
56
  # end
56
57
  def self.configure_worker
57
58
  yield self if worker?
@@ -61,7 +62,7 @@ module Faktory
61
62
  # Configuration for Faktory client, use like:
62
63
  #
63
64
  # Faktory.configure_client do |config|
64
- # config.faktory = { :size => 1, :url => 'myhost:7419' }
65
+ # config.default_job_options = { retry: 3 }
65
66
  # end
66
67
  def self.configure_client
67
68
  yield self unless worker?
@@ -164,3 +165,4 @@ module Faktory
164
165
  end
165
166
 
166
167
  require 'faktory/rails' if defined?(::Rails::Engine)
168
+ require 'faktory/batch'
@@ -0,0 +1,178 @@
1
+ require "faktory/middleware/batch"
2
+
3
+ module Faktory
4
+ ##
5
+ # A Batch is a set of jobs which can be tracked as a group, with
6
+ # callbacks that can fire after all the jobs are attempted or successful.
7
+ # Every batch must define at least one callback.
8
+ #
9
+ # * The "complete" callback is fired when all jobs in the batch have been attempted.
10
+ # Some might have failed.
11
+ # * The "success" callback is fired when all jobs in the batch have succeeded. This
12
+ # might never be fired if a job continues to error until it runs out of retries.
13
+ #
14
+ # **Please note that batches are only available in Faktory Enterprise.** This is
15
+ # the client-side code required to implement batches, it won't work without
16
+ # the server-side component.
17
+ #
18
+ # Simple example:
19
+ #
20
+ # b = Faktory::Batch.new
21
+ # b.description = "Process all documents for user 12345"
22
+ # # a callback can be defined as just a Ruby job class
23
+ # b.success = "MySuccessCallbackJob"
24
+ # # or the full job hash...
25
+ # b.complete = { jobtype: "MyCompleteCallbackJob", args: [12345], queue: "critical" }
26
+ # b.jobs do
27
+ # SomeJob.perform_async(xyz)
28
+ # AnotherJob.perform_async(user_id)
29
+ # end
30
+ #
31
+ # At the end of the `jobs` call, the batch is persisted to the Faktory server. It must
32
+ # not be modified further with one exception: jobs within the batch can "reopen" the batch
33
+ # in order to dynamically add more jobs or child batches.
34
+ #
35
+ # Any job within a batch may "reopen" its own batch to dynamically add more jobs.
36
+ # A job can get access to its batch by using the `bid` or `batch` accessor on
37
+ # `Faktory::Job`. You can use the `bid` accessor to test if the job is part of a batch.
38
+ #
39
+ # Reopen example:
40
+ #
41
+ # class MyJob
42
+ # include Faktory::Job
43
+ #
44
+ # def perform
45
+ # batch.jobs do
46
+ # SomeOtherJob.perform_async
47
+ # end if bid
48
+ # end
49
+ #
50
+ # Batches may be nested without limit by setting `parent_bid` when creating a
51
+ # batch. Generally you create child batches if you wish that subset of jobs to have
52
+ # their own callback for your application logic purposes. Otherwise you can reopen the
53
+ # current batch and add more jobs.
54
+ #
55
+ # Batch parent/child relationship is never implicit: you must manually set
56
+ # `parent_bid` if you wish to define a child batch.
57
+ #
58
+ # Nested example:
59
+ #
60
+ # class MyJob
61
+ # include Faktory::Job
62
+ #
63
+ # def perform
64
+ # child = Faktory::Batch.new
65
+ #
66
+ # # MyJob is executing as part of a previously defined batch.
67
+ # # Add a new child batch to this batch.
68
+ # child.parent_bid = bid
69
+ # child.success = ...
70
+ # child.jobs do |cb|
71
+ # SomeJob.perform_async
72
+ #
73
+ # gchild = Faktory::Batch.new
74
+ # gchild.parent_bid = cb.bid
75
+ # gchild.success = ...
76
+ # gchild.jobs do |gcb|
77
+ # ChildJob.perform_async
78
+ # end
79
+ # end
80
+ # end
81
+ # end
82
+ #
83
+ # Callbacks are guaranteed to be called hierarchically: child's success callback
84
+ # will not be called until gchild's success callback has executed successfully.
85
+ #
86
+ class Batch
87
+ attr_reader :bid
88
+ attr_accessor :description, :parent_bid
89
+
90
+ def initialize(bid=nil)
91
+ @bid = bid
92
+ end
93
+
94
+ def success=(val)
95
+ raise "Batch cannot be modified once created" if bid
96
+ @success = to_callback(val)
97
+ end
98
+
99
+ def complete=(val)
100
+ raise "Batch cannot be modified once created" if bid
101
+ @success = to_callback(val)
102
+ end
103
+
104
+ def jobs(&block)
105
+ Faktory.server do |client|
106
+ if @bid.nil?
107
+ @bid = client.create_batch(self, &block)
108
+ else
109
+ client.reopen_batch(self, &block)
110
+ end
111
+ end
112
+ end
113
+
114
+ def to_h
115
+ raise ArgumentError, "Callback required" unless defined?(@success) || defined?(@complete)
116
+
117
+ hash = {}
118
+ hash["parent_bid"] = parent_bid if parent_bid
119
+ hash["description"] = description if description
120
+ hash["success"] = @success if defined?(@success)
121
+ hash["complete"] = @complete if defined?(@complete)
122
+ hash
123
+ end
124
+
125
+ private
126
+
127
+ def to_callback(val)
128
+ case val
129
+ when String
130
+ basic_job.merge({ "jobtype" => val })
131
+ when Class
132
+ basic_job.merge({ "jobtype" => val })
133
+ when Hash
134
+ basic_job.merge(val)
135
+ else
136
+ raise ArgumentError, "Unknown callback #{val}"
137
+ end
138
+ end
139
+
140
+ def basic_job
141
+ {
142
+ "jid" => SecureRandom.hex(12),
143
+ "args" => [],
144
+ "queue" => "default",
145
+ }
146
+ end
147
+ end
148
+
149
+ class BatchStatus
150
+ def initialize(bid)
151
+ @bid = bid
152
+ end
153
+
154
+ def hash
155
+ @hash ||= Faktory.server{|c| c.batch_status(@bid) }
156
+ end
157
+
158
+ def created_at
159
+ hash["created_at"]
160
+ end
161
+
162
+ def description
163
+ hash["description"]
164
+ end
165
+
166
+ def parent_bid
167
+ hash["parent_bid"]
168
+ end
169
+
170
+ def total
171
+ hash["total"]
172
+ end
173
+
174
+ def pending
175
+ hash["pending"]
176
+ end
177
+ end
178
+ end
@@ -8,6 +8,11 @@ require 'optparse'
8
8
  require 'erb'
9
9
  require 'fileutils'
10
10
 
11
+ module Faktory
12
+ class CLI
13
+ end
14
+ end
15
+
11
16
  require 'faktory'
12
17
  require 'faktory/util'
13
18
 
@@ -5,12 +5,20 @@ require 'digest'
5
5
  require 'securerandom'
6
6
 
7
7
  module Faktory
8
- class CommandError < StandardError;end
9
- class ParseError < StandardError;end
10
-
8
+ class BaseError < StandardError; end
9
+ class CommandError < BaseError; end
10
+ class ParseError < BaseError; end
11
+
12
+ # Faktory::Client provides a low-level connection to a Faktory server
13
+ # and APIs which map to Faktory commands.
14
+ #
15
+ # Most APIs will return `true` if the operation succeeded or raise a
16
+ # Faktory::BaseError if there was an unexpected error.
11
17
  class Client
12
18
  @@random_process_wid = ""
13
19
 
20
+ DEFAULT_TIMEOUT = 5.0
21
+
14
22
  HASHER = proc do |iter, pwd, salt|
15
23
  sha = Digest::SHA256.new
16
24
  hashing = pwd + salt
@@ -36,10 +44,12 @@ module Faktory
36
44
  # MY_FAKTORY_URL=tcp://:somepass@my-server.example.com:7419
37
45
  #
38
46
  # Note above, the URL can contain the password for secure installations.
39
- def initialize(url: uri_from_env || 'tcp://localhost:7419', debug: false)
47
+ def initialize(url: uri_from_env || 'tcp://localhost:7419', debug: false, timeout: DEFAULT_TIMEOUT)
40
48
  @debug = debug
41
49
  @location = URI(url)
42
- open
50
+ @timeout = timeout
51
+
52
+ open(@timeout)
43
53
  end
44
54
 
45
55
  def close
@@ -53,23 +63,95 @@ module Faktory
53
63
  def flush
54
64
  transaction do
55
65
  command "FLUSH"
56
- ok!
66
+ ok
67
+ end
68
+ end
69
+
70
+ def create_batch(batch, &block)
71
+ bid = transaction do
72
+ command "BATCH NEW", Faktory.dump_json(batch.to_h)
73
+ result!
74
+ end
75
+ batch.instance_variable_set(:@bid, bid)
76
+
77
+ old = Thread.current["faktory_batch"]
78
+ Thread.current["faktory_batch"] = batch
79
+ begin
80
+ # any jobs pushed in this block will implicitly have
81
+ # their `bid` attribute set so they are associated
82
+ # with the current batch.
83
+ yield batch
84
+ ensure
85
+ Thread.current[:faktory_batch] = old
86
+ end
87
+ transaction do
88
+ command "BATCH COMMIT", bid
89
+ ok
57
90
  end
91
+ bid
58
92
  end
59
93
 
94
+ def batch_status(bid)
95
+ transaction do
96
+ command "BATCH STATUS", bid
97
+ Faktory.load_json result!
98
+ end
99
+ end
100
+
101
+ def reopen_batch(b)
102
+ transaction do
103
+ command "BATCH OPEN", b.bid
104
+ ok
105
+ end
106
+ old = Thread.current[:faktory_batch]
107
+ Thread.current[:faktory_batch] = b
108
+ begin
109
+ # any jobs pushed in this block will implicitly have
110
+ # their `bid` attribute set so they are associated
111
+ # with the current batch.
112
+ yield b
113
+ ensure
114
+ Thread.current[:faktory_batch] = old
115
+ end
116
+ transaction do
117
+ command "BATCH COMMIT", b.bid
118
+ ok
119
+ end
120
+ end
121
+
122
+ def get_track(jid)
123
+ transaction do
124
+ command "TRACK GET", jid
125
+ hashstr = result!
126
+ JSON.parse(hashstr)
127
+ end
128
+ end
129
+
130
+ # hash must include a 'jid' element
131
+ def set_track(hash)
132
+ transaction do
133
+ command("TRACK SET", Faktory.dump_json(hash))
134
+ ok
135
+ end
136
+ end
137
+
138
+ # Push a hash corresponding to a job payload to Faktory.
139
+ # Hash must contain "jid", "jobtype" and "args" elements at minimum.
140
+ # Returned value will either be the JID String if successful OR
141
+ # a symbol corresponding to an error.
60
142
  def push(job)
61
143
  transaction do
62
- command "PUSH", JSON.generate(job)
63
- ok!
64
- job["jid"]
144
+ command "PUSH", Faktory.dump_json(job)
145
+ ok(job["jid"])
65
146
  end
66
147
  end
67
148
 
149
+ # Returns either a job hash or falsy.
68
150
  def fetch(*queues)
69
151
  job = nil
70
152
  transaction do
71
153
  command("FETCH", *queues)
72
- job = result
154
+ job = result!
73
155
  end
74
156
  JSON.parse(job) if job
75
157
  end
@@ -77,34 +159,42 @@ module Faktory
77
159
  def ack(jid)
78
160
  transaction do
79
161
  command("ACK", %Q[{"jid":"#{jid}"}])
80
- ok!
162
+ ok
81
163
  end
82
164
  end
83
165
 
84
166
  def fail(jid, ex)
85
167
  transaction do
86
- command("FAIL", JSON.dump({ message: ex.message[0...1000],
168
+ command("FAIL", Faktory.dump_json({ message: ex.message[0...1000],
87
169
  errtype: ex.class.name,
88
170
  jid: jid,
89
171
  backtrace: ex.backtrace}))
90
- ok!
172
+ ok
91
173
  end
92
174
  end
93
175
 
94
176
  # Sends a heartbeat to the server, in order to prove this
95
177
  # worker process is still alive.
96
178
  #
179
+ # You can pass in the current_state of the process, for example during shutdown
180
+ # quiet and/or terminate can be supplied.
181
+ #
97
182
  # Return a string signal to process, legal values are "quiet" or "terminate".
98
183
  # The quiet signal is informative: the server won't allow this process to FETCH
99
184
  # any more jobs anyways.
100
- def beat
185
+ def beat(current_state = nil)
101
186
  transaction do
102
- command("BEAT", %Q[{"wid":"#{@@random_process_wid}"}])
103
- str = result
187
+ if current_state.nil?
188
+ command("BEAT", %Q[{"wid":"#{@@random_process_wid}"}])
189
+ else
190
+ command("BEAT", %Q[{"wid":"#{@@random_process_wid}", "current_state":"#{current_state}"}])
191
+ end
192
+
193
+ str = result!
104
194
  if str == "OK"
105
195
  str
106
196
  else
107
- hash = JSON.parse(str)
197
+ hash = Faktory.load_json(str)
108
198
  hash["state"]
109
199
  end
110
200
  end
@@ -113,8 +203,8 @@ module Faktory
113
203
  def info
114
204
  transaction do
115
205
  command("INFO")
116
- str = result
117
- JSON.parse(str) if str
206
+ str = result!
207
+ Faktory.load_json(str) if str
118
208
  end
119
209
  end
120
210
 
@@ -129,9 +219,17 @@ module Faktory
129
219
  @location.scheme =~ /tls/
130
220
  end
131
221
 
132
- def open
222
+ def open(timeout = DEFAULT_TIMEOUT)
223
+ # this is the read/write timeout, not open.
224
+ secs = Integer(timeout)
225
+ usecs = Integer((timeout - secs) * 1_000_000)
226
+ optval = [secs, usecs].pack("l_2")
133
227
  if tls?
134
228
  sock = TCPSocket.new(@location.hostname, @location.port)
229
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
230
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, optval)
231
+ sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDTIMEO, optval)
232
+
135
233
  ctx = OpenSSL::SSL::SSLContext.new
136
234
  ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
137
235
  ctx.ssl_version = :TLSv1_2
@@ -143,6 +241,8 @@ module Faktory
143
241
  else
144
242
  @sock = TCPSocket.new(@location.hostname, @location.port)
145
243
  @sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
244
+ @sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, optval)
245
+ @sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDTIMEO, optval)
146
246
  end
147
247
 
148
248
  payload = {
@@ -176,11 +276,10 @@ module Faktory
176
276
  end
177
277
  end
178
278
 
179
- command("HELLO", JSON.dump(payload))
180
- ok!
279
+ command("HELLO", Faktory.dump_json(payload))
280
+ ok
181
281
  end
182
282
 
183
-
184
283
  def command(*args)
185
284
  cmd = args.join(" ")
186
285
  @sock.puts(cmd)
@@ -189,12 +288,19 @@ module Faktory
189
288
 
190
289
  def transaction
191
290
  retryable = true
291
+
292
+ # When using Faktory::Testing, you can get a client which does not actually
293
+ # have an underlying socket. Now if you disable testing and try to use that
294
+ # client, it will crash without a socket. This open() handles that case to
295
+ # transparently open a socket.
296
+ open(@timeout) if !@sock
297
+
192
298
  begin
193
299
  yield
194
300
  rescue Errno::EPIPE, Errno::ECONNRESET
195
301
  if retryable
196
302
  retryable = false
197
- open
303
+ open(@timeout)
198
304
  retry
199
305
  else
200
306
  raise
@@ -218,7 +324,16 @@ module Faktory
218
324
  line = @sock.gets # read extra linefeeds
219
325
  data
220
326
  elsif chr == '-'
221
- raise CommandError, line[1..-1]
327
+ # Server can respond with:
328
+ #
329
+ # -ERR Something unexpected
330
+ # We raise a CommandError
331
+ #
332
+ # -NOTUNIQUE Job not unique
333
+ # We return ["NOTUNIQUE", "Job not unique"]
334
+ err = line[1..-1].split(" ", 2)
335
+ raise CommandError, err[1] if err[0] == "ERR"
336
+ err
222
337
  else
223
338
  # this is bad, indicates we need to reset the socket
224
339
  # and start fresh
@@ -226,10 +341,17 @@ module Faktory
226
341
  end
227
342
  end
228
343
 
229
- def ok!
344
+ def ok(retval=true)
230
345
  resp = result
231
- raise CommandError, resp if resp != "OK"
232
- true
346
+ return retval if resp == "OK"
347
+ return resp[0].to_sym
348
+ end
349
+
350
+ def result!
351
+ resp = result
352
+ return nil if resp == nil
353
+ raise CommandError, resp[0] if !resp.is_a?(String)
354
+ resp
233
355
  end
234
356
 
235
357
  # FAKTORY_PROVIDER=MY_FAKTORY_URL
@@ -253,4 +375,3 @@ module Faktory
253
375
 
254
376
  end
255
377
  end
256
-
@@ -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
@@ -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,12 +68,32 @@ 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
+ Faktory.client_middleware.invoke(item, pool) do
92
+ pool.with do |c|
93
+ c.push(item)
94
+ end
95
+ end
96
+ end
62
97
  end
63
98
 
64
99
  module ClassMethods
@@ -68,19 +103,13 @@ module Faktory
68
103
  end
69
104
 
70
105
  def perform_async(*args)
71
- client_push('jobtype'.freeze => self, 'args'.freeze => args)
106
+ set(get_faktory_options).perform_async(*args)
72
107
  end
73
108
 
74
109
  # +interval+ must be a timestamp, numeric or something that acts
75
110
  # numeric (like an activesupport time interval).
76
111
  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)
112
+ set(get_faktory_options).perform_in(interval, *args)
84
113
  end
85
114
  alias_method :perform_at, :perform_in
86
115
 
@@ -102,23 +131,6 @@ module Faktory
102
131
  self.faktory_options_hash ||= Faktory.default_job_options
103
132
  end
104
133
 
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
134
  def faktory_class_attribute(*attrs)
123
135
  instance_reader = true
124
136
  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
@@ -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,85 @@
1
+ require 'faktory/client'
2
+
3
+ # require 'faktory/mutate'
4
+ # cl = Faktory::Client.new
5
+ # cl.discard(Faktory::RETRIES) do |filter|
6
+ # filter.with_type("QuickBooksSyncJob")
7
+ # filter.matching("*uid:12345*"))
8
+ # end
9
+ module Faktory
10
+
11
+ # Valid targets
12
+ RETRIES = "retries"
13
+ SCHEDULED = "scheduled"
14
+ DEAD = "dead"
15
+
16
+ module Mutator
17
+ class Filter
18
+ attr_accessor :hash
19
+
20
+ def initialize
21
+ @hash = {}
22
+ end
23
+
24
+ # This must be the exact type of the job, no pattern matching
25
+ def with_type(jobtype)
26
+ @hash[:jobtype] = jobtype
27
+ end
28
+
29
+ # This is a regexp that will be passed as is to Redis's SCAN.
30
+ # Notably you should surround it with * to ensure it matches
31
+ # substrings within the job payload.
32
+ # See https://redis.io/commands/scan for details.
33
+ def matching(regexp)
34
+ @hash[:regexp] = regexp
35
+ end
36
+
37
+ # One or more JIDs to target:
38
+ # filter.jids << 'abcdefgh1234'
39
+ # filter.jids = ['abcdefgh1234', '1234567890']
40
+ def jids
41
+ @hash[:jids] ||= []
42
+ end
43
+ def jids=(ary)
44
+ @hash[:jids] = Array(ary)
45
+ end
46
+ end
47
+
48
+ def discard(target, &block)
49
+ filter = Filter.new
50
+ block.call(filter) if block
51
+ mutate('discard', target, filter)
52
+ end
53
+
54
+ def kill(target, &block)
55
+ filter = Filter.new
56
+ block.call(filter) if block
57
+ mutate('kill', target, filter)
58
+ end
59
+
60
+ def requeue(target, &block)
61
+ filter = Filter.new
62
+ block.call(filter) if block
63
+ mutate('requeue', target, filter)
64
+ end
65
+
66
+ def clear(target)
67
+ mutate('discard', target, nil)
68
+ end
69
+
70
+ private
71
+
72
+ def mutate(cmd, target, filter)
73
+ payload = {:cmd => cmd,:target => target}
74
+ payload[:filter] = filter.hash if filter && !filter.hash.empty?
75
+
76
+ transaction do
77
+ command("MUTATE", JSON.dump(payload))
78
+ ok
79
+ end
80
+ end
81
+
82
+ end
83
+ end
84
+
85
+ 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)
@@ -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
@@ -0,0 +1,41 @@
1
+ module Faktory
2
+ module Trackable
3
+
4
+ ##
5
+ # Tracking allows a long-running Faktory job to report its progress:
6
+ #
7
+ # def perform(...)
8
+ # track_progress(10, "Calculating values")
9
+ # # do some work
10
+ #
11
+ # track_progress(20, "Sending emails")
12
+ # # do some more work
13
+ #
14
+ # track_progress(20, "Sending emails", reserve_until: 10.minutes.from_now)
15
+ # # do some more work
16
+ # end
17
+ #
18
+ # Note:
19
+ # 1. jobs should be small and fine-grained (and so fast) if possible.
20
+ # 2. tracking is useful for long-running jobs, tracking a fast job will only add overhead
21
+ # 3. tracking only works with a single job, use Batches to monitor a group of jobs
22
+ # 4. reserve_until allows a job to dynamically extend its reservation so it is not garbage collected by Faktory while running
23
+ # 5. you can only reserve up to 24 hours.
24
+ #
25
+ def track_progress(percent, desc=nil, reserve_until:nil)
26
+ hash = { 'jid' => jid, 'percent' => percent.to_i, 'desc' => desc }
27
+ hash["reserve_until"] = convert(reserve_until) if reserve_until
28
+ Faktory.server {|c| c.set_track(hash) }
29
+ end
30
+
31
+ private
32
+
33
+ def convert(ts)
34
+ raise ArgumentError, "Timestamp in the past: #{ts}" if Time.now > ts
35
+ raise ArgumentError, "Timestamp too far in the future: #{ts}" if (Time.now + 86400) < ts
36
+
37
+ tsf = ts.to_f
38
+ Time.at(tsf).utc.iso8601
39
+ end
40
+ end
41
+ end
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Faktory
3
- VERSION = "0.8.1"
3
+ VERSION = "1.0.0"
4
4
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: faktory_worker_ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Perham
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-12-21 00:00:00.000000000 Z
11
+ date: 2020-02-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: connection_pool
@@ -19,7 +19,7 @@ dependencies:
19
19
  version: '2.2'
20
20
  - - ">="
21
21
  - !ruby/object:Gem::Version
22
- version: 2.2.1
22
+ version: 2.2.2
23
23
  type: :runtime
24
24
  prerelease: false
25
25
  version_requirements: !ruby/object:Gem::Requirement
@@ -29,7 +29,7 @@ dependencies:
29
29
  version: '2.2'
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: 2.2.1
32
+ version: 2.2.2
33
33
  - !ruby/object:Gem::Dependency
34
34
  name: activejob
35
35
  requirement: !ruby/object:Gem::Requirement
@@ -106,6 +106,7 @@ files:
106
106
  - faktory_worker_ruby.gemspec
107
107
  - lib/active_job/queue_adapters/faktory_adapter.rb
108
108
  - lib/faktory.rb
109
+ - lib/faktory/batch.rb
109
110
  - lib/faktory/cli.rb
110
111
  - lib/faktory/client.rb
111
112
  - lib/faktory/connection.rb
@@ -116,11 +117,14 @@ files:
116
117
  - lib/faktory/launcher.rb
117
118
  - lib/faktory/logging.rb
118
119
  - lib/faktory/manager.rb
120
+ - lib/faktory/middleware/batch.rb
119
121
  - lib/faktory/middleware/chain.rb
120
122
  - lib/faktory/middleware/i18n.rb
123
+ - lib/faktory/mutate.rb
121
124
  - lib/faktory/processor.rb
122
125
  - lib/faktory/rails.rb
123
126
  - lib/faktory/testing.rb
127
+ - lib/faktory/tracking.rb
124
128
  - lib/faktory/util.rb
125
129
  - lib/faktory/version.rb
126
130
  - lib/faktory_worker_ruby.rb
@@ -136,15 +140,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
136
140
  requirements:
137
141
  - - ">="
138
142
  - !ruby/object:Gem::Version
139
- version: 2.3.0
143
+ version: 2.5.0
140
144
  required_rubygems_version: !ruby/object:Gem::Requirement
141
145
  requirements:
142
146
  - - ">="
143
147
  - !ruby/object:Gem::Version
144
148
  version: '0'
145
149
  requirements: []
146
- rubyforge_project:
147
- rubygems_version: 2.6.13
150
+ rubygems_version: 3.0.3
148
151
  signing_key:
149
152
  specification_version: 4
150
153
  summary: Ruby worker for Faktory