faktory_worker_ruby 0.8.1 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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