chore-core 1.8.2 → 3.2.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.
Files changed (51) hide show
  1. checksums.yaml +5 -5
  2. data/README.md +6 -0
  3. data/chore-core.gemspec +1 -0
  4. data/lib/chore.rb +11 -5
  5. data/lib/chore/cli.rb +21 -2
  6. data/lib/chore/consumer.rb +15 -5
  7. data/lib/chore/fetcher.rb +12 -7
  8. data/lib/chore/hooks.rb +2 -1
  9. data/lib/chore/job.rb +17 -0
  10. data/lib/chore/manager.rb +18 -2
  11. data/lib/chore/queues/filesystem/consumer.rb +116 -59
  12. data/lib/chore/queues/filesystem/filesystem_queue.rb +19 -0
  13. data/lib/chore/queues/filesystem/publisher.rb +12 -18
  14. data/lib/chore/queues/sqs/consumer.rb +6 -21
  15. data/lib/chore/strategies/consumer/batcher.rb +8 -9
  16. data/lib/chore/strategies/consumer/threaded_consumer_strategy.rb +3 -1
  17. data/lib/chore/strategies/consumer/throttled_consumer_strategy.rb +121 -0
  18. data/lib/chore/strategies/worker/forked_worker_strategy.rb +5 -6
  19. data/lib/chore/strategies/worker/helpers/ipc.rb +88 -0
  20. data/lib/chore/strategies/worker/helpers/preforked_worker.rb +163 -0
  21. data/lib/chore/strategies/worker/helpers/work_distributor.rb +65 -0
  22. data/lib/chore/strategies/worker/helpers/worker_info.rb +13 -0
  23. data/lib/chore/strategies/worker/helpers/worker_killer.rb +40 -0
  24. data/lib/chore/strategies/worker/helpers/worker_manager.rb +183 -0
  25. data/lib/chore/strategies/worker/preforked_worker_strategy.rb +150 -0
  26. data/lib/chore/strategies/worker/single_worker_strategy.rb +35 -13
  27. data/lib/chore/unit_of_work.rb +8 -0
  28. data/lib/chore/util.rb +5 -1
  29. data/lib/chore/version.rb +3 -3
  30. data/lib/chore/worker.rb +29 -0
  31. data/spec/chore/cli_spec.rb +2 -2
  32. data/spec/chore/consumer_spec.rb +0 -4
  33. data/spec/chore/duplicate_detector_spec.rb +17 -5
  34. data/spec/chore/fetcher_spec.rb +0 -11
  35. data/spec/chore/manager_spec.rb +7 -0
  36. data/spec/chore/queues/filesystem/filesystem_consumer_spec.rb +71 -11
  37. data/spec/chore/queues/sqs/consumer_spec.rb +1 -3
  38. data/spec/chore/strategies/consumer/batcher_spec.rb +50 -0
  39. data/spec/chore/strategies/consumer/threaded_consumer_strategy_spec.rb +1 -0
  40. data/spec/chore/strategies/consumer/throttled_consumer_strategy_spec.rb +165 -0
  41. data/spec/chore/strategies/worker/forked_worker_strategy_spec.rb +16 -1
  42. data/spec/chore/strategies/worker/helpers/ipc_spec.rb +127 -0
  43. data/spec/chore/strategies/worker/helpers/preforked_worker_spec.rb +236 -0
  44. data/spec/chore/strategies/worker/helpers/work_distributor_spec.rb +131 -0
  45. data/spec/chore/strategies/worker/helpers/worker_info_spec.rb +14 -0
  46. data/spec/chore/strategies/worker/helpers/worker_killer_spec.rb +97 -0
  47. data/spec/chore/strategies/worker/helpers/worker_manager_spec.rb +304 -0
  48. data/spec/chore/strategies/worker/preforked_worker_strategy_spec.rb +183 -0
  49. data/spec/chore/strategies/worker/single_worker_strategy_spec.rb +25 -0
  50. data/spec/chore/worker_spec.rb +69 -1
  51. metadata +33 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: f1a706f2f8f8eefe36f16cfaea8636f6fe13df8d
4
- data.tar.gz: d4c0b0f97c85916e509ec609bd9a9e38cac6aac0
2
+ SHA256:
3
+ metadata.gz: 5aeebbb3efb30c9047c67996272864003542d9bd91d9887d3e149b6840f0573a
4
+ data.tar.gz: dd210d2eda88ddfab6e97646cb69ecccfc01a57a4198a8c2fc50c30d260dd32f
5
5
  SHA512:
6
- metadata.gz: c92dcbc42098e17b5e1d3ac05860ca1b74022725a2839964f6480bc8c3f080555e950757997ad9d3d4cdbd4b122e02e467614bb481a4108dfe65c5a74b797c6c
7
- data.tar.gz: 95baf93fe723268b499447c24464443e3e7e19ed62d5718394c7dae66bcafb44a33f47133b3973827efd5856acc4fee2aab795adc33515c18068807f9e5039aa
6
+ metadata.gz: 0df90580322b71a356a1b7bed8d1480c0794e6fcd12995bc82edfb3a878f40981fb87fefdcb2ad97a9841cabad2a151268a0e098d08f697cd56b727fcb973948
7
+ data.tar.gz: 24ef7ff568162c5f173ef599d687cd768c9b8a85948d5c80df10855fe30eac9df118e6a15b741e348e064e51ab693b395d81bf4e3689ca9519d9c15b69d05521
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Chore: Job processing... for the future!
2
2
 
3
+ [![Build Status](https://travis-ci.org/Tapjoy/chore.svg?branch=master)](https://travis-ci.org/Tapjoy/chore)
4
+
3
5
  ## About
4
6
 
5
7
  Chore is a pluggable, multi-backend job processor. It was built from the ground up to be extremely flexible. We hope that you
@@ -33,6 +35,7 @@ Other options include:
33
35
  --threads-per-queue 4 # number of threads per queue for consuming from a given queue.
34
36
  --dedupe-servers # if using SQS or similiar queue with at-least once delivery and your memcache is running on something other than localhost
35
37
  --batch-size 50 # how many messages are batched together before handing them to a worker
38
+ --batch-timeout 20 # maximum number of seconds to wait until handing a message over to a worker
36
39
  --queue_prefix prefixy # A prefix to prepend to queue names, mainly for development and qa testing purposes
37
40
  --max-attempts 100 # The maximum number of times a job can be attempted
38
41
  --dupe-on-cache-failure # Determines the deduping behavior when a cache connection error occurs. When set to `false`, the message is assumed not to be a duplicate. Defaults to `false`.
@@ -92,6 +95,7 @@ Chore.configure do |c|
92
95
  c.max_attempts = 100
93
96
  ...
94
97
  c.batch_size = 50
98
+ c.batch_timeout = 20
95
99
  end
96
100
  ```
97
101
 
@@ -189,11 +193,13 @@ A number of hooks, both global and per-job, exist in Chore for your convenience.
189
193
 
190
194
  Global Hooks:
191
195
 
196
+ * before_start
192
197
  * before_first_fork
193
198
  * before_fork
194
199
  * after_fork
195
200
  * around_fork
196
201
  * within_fork
202
+ * before_shutdown
197
203
 
198
204
  ("within_fork" behaves similarly to around_fork, except that it is called after the worker process has been forked. In contrast, around_fork is called by the parent process.)
199
205
 
@@ -39,6 +39,7 @@ Gem::Specification.new do |s|
39
39
  s.add_runtime_dependency(%q<json>, [">= 0"])
40
40
  s.add_runtime_dependency(%q<aws-sdk-v1>, ["~> 1.56", ">= 1.56.0"])
41
41
  s.add_runtime_dependency(%q<thread>, ["~> 0.1.3"])
42
+ s.add_runtime_dependency('get_process_mem', ["~> 0.2.0"])
42
43
  s.add_development_dependency(%q<rspec>, ["~> 3.3.0"])
43
44
  s.add_development_dependency(%q<rdoc>, ["~> 3.12"])
44
45
  s.add_development_dependency(%q<bundler>, [">= 0"])
@@ -34,13 +34,18 @@ module Chore #:nodoc:
34
34
  :fetcher => Fetcher,
35
35
  :consumer_strategy => Strategy::ThreadedConsumerStrategy,
36
36
  :batch_size => 50,
37
+ :batch_timeout => 20,
37
38
  :log_level => Logger::WARN,
38
39
  :log_path => STDOUT,
39
40
  :default_queue_timeout => (12 * 60 * 60), # 12 hours
40
41
  :shutdown_timeout => (2 * 60),
41
42
  :max_attempts => 1.0 / 0.0, # Infinity
42
43
  :dupe_on_cache_failure => false,
43
- :payload_handler => Chore::Job
44
+ :queue_polling_size => 10,
45
+ :payload_handler => Chore::Job,
46
+ :master_procline => "chore-master-#{Chore::VERSION}",
47
+ :worker_procline => "chore-worker-#{Chore::VERSION}",
48
+ :consumer_sleep_interval => 1
44
49
  }
45
50
 
46
51
  class << self
@@ -110,9 +115,9 @@ module Chore #:nodoc:
110
115
  # add_hook(:before_fork) {|worker| puts 1 }
111
116
  # add_hook(:before_fork) {|worker| puts 2 }
112
117
  # add_hook(:before_fork) {|worker| puts 3 }
113
- #
118
+ #
114
119
  # run_hooks_for(:before_fork, worker)
115
- #
120
+ #
116
121
  # # ...will produce the following output
117
122
  # => 1
118
123
  # => 2
@@ -129,9 +134,9 @@ module Chore #:nodoc:
129
134
  # add_hook(:around_fork) {|worker, &block| puts 'before 1'; block.call; puts 'after 1'}
130
135
  # add_hook(:around_fork) {|worker, &block| puts 'before 2'; block.call; puts 'after 2'}
131
136
  # add_hook(:around_fork) {|worker, &block| puts 'before 3'; block.call; puts 'after 3'}
132
- #
137
+ #
133
138
  # run_hooks_for(:around_fork, worker) { puts 'block' }
134
- #
139
+ #
135
140
  # # ...will produce the following output
136
141
  # => before 1
137
142
  # => before 2
@@ -186,6 +191,7 @@ module Chore #:nodoc:
186
191
  # Chore.configure do |c|
187
192
  # c.consumer = Chore::Queues::SQS::Consumer
188
193
  # c.batch_size = 50
194
+ # c.batch_timeout = 20
189
195
  # end
190
196
  def self.configure(opts={})
191
197
  @config = (@config ? @config.merge_hash(opts) : Chore::Configuration.new(DEFAULT_OPTIONS.merge(opts)))
@@ -89,6 +89,7 @@ module Chore #:nodoc:
89
89
  detect_queues
90
90
  Chore.configure(options)
91
91
  Chore.configuring = false
92
+ validate_strategy!
92
93
  end
93
94
 
94
95
 
@@ -134,7 +135,7 @@ module Chore #:nodoc:
134
135
  options[:consumer_strategy] = constantize(arg)
135
136
  end
136
137
 
137
- register_option 'consumer_sleep_interval', '--consumer-sleep-interval INTERVAL', Float, 'Length of time in seconds to sleep when the consumer does not find any messages. Defaults vary depending on consumer implementation'
138
+ register_option 'consumer_sleep_interval', '--consumer-sleep-interval INTERVAL', Float, 'Length of time in seconds to sleep when the consumer does not find any messages (default: 1)'
138
139
 
139
140
  register_option 'payload_handler', '--payload_handler CLASS_NAME', 'Name of a class to use as the payload handler (default: Chore::Job)' do |arg|
140
141
  options[:payload_handler] = constantize(arg)
@@ -144,6 +145,7 @@ module Chore #:nodoc:
144
145
 
145
146
  register_option 'dupe_on_cache_failure', '--dupe-on-cache-failure BOOLEAN', 'Determines the deduping behavior when a cache connection error occurs. When set to false, the message is assumed not to be a duplicate. (default: false)'
146
147
 
148
+ register_option 'queue_polling_size', '--queue_polling_size NUM', Integer, 'Amount of messages to grab on each request (default: 10)'
147
149
  end
148
150
 
149
151
  def parse_opts(argv, ignore_errors = false) #:nodoc:
@@ -254,7 +256,6 @@ module Chore #:nodoc:
254
256
  end
255
257
 
256
258
  def validate! #:nodoc:
257
-
258
259
  missing_option!("--require [PATH|DIR]") unless options[:require]
259
260
 
260
261
  if !File.exist?(options[:require]) ||
@@ -266,7 +267,25 @@ module Chore #:nodoc:
266
267
  puts @parser
267
268
  exit(1)
268
269
  end
270
+ end
269
271
 
272
+ def validate_strategy!
273
+ consumer_strategy = Chore.config.consumer_strategy.to_s
274
+ worker_strategy = Chore.config.worker_strategy.to_s
275
+
276
+ throttled_consumer = 'Chore::Strategy::ThrottledConsumerStrategy'
277
+ preforked_worker = 'Chore::Strategy::PreForkedWorkerStrategy'
278
+
279
+ if consumer_strategy == throttled_consumer || worker_strategy == preforked_worker
280
+ unless consumer_strategy == throttled_consumer && worker_strategy == preforked_worker
281
+ puts "=================================================================="
282
+ puts " PreForkedWorkerStrategy may only be paired with "
283
+ puts " ThrottledConsumerStrategy or vice versa "
284
+ puts " Please check your configurations "
285
+ puts "=================================================================="
286
+ exit(1)
287
+ end
288
+ end
270
289
  end
271
290
  end
272
291
  end
@@ -21,11 +21,6 @@ module Chore
21
21
  def self.reset_connection!
22
22
  end
23
23
 
24
- # Cleans up any resources that were left behind from prior instances of the
25
- # chore process. By default, this is a no-op.
26
- def self.cleanup(queue)
27
- end
28
-
29
24
  # Consume takes a block with an arity of two. The two params are
30
25
  # |message_id,message_body| where message_id is any object that the
31
26
  # consumer will need to be able to act on a message later (reject, complete, etc)
@@ -53,5 +48,20 @@ module Chore
53
48
  def running?
54
49
  @running
55
50
  end
51
+
52
+ # returns up to n work
53
+ def provide_work(n)
54
+ raise NotImplementedError
55
+ end
56
+
57
+ # now, given an arbitrary key and klass, have we seen the key already?
58
+ def duplicate_message?(dedupe_key, klass, queue_timeout)
59
+ dupe_detector.found_duplicate?(:id=>dedupe_key, :queue=>klass.to_s, :visibility_timeout=>queue_timeout)
60
+ end
61
+
62
+ def dupe_detector
63
+ @dupes ||= DuplicateDetector.new({:servers => Chore.config.dedupe_servers,
64
+ :dupe_on_cache_failure => false})
65
+ end
56
66
  end
57
67
  end
@@ -11,21 +11,16 @@ module Chore
11
11
  # Starts the fetcher with the configured Consumer Strategy. This will begin consuming messages from your queue
12
12
  def start
13
13
  Chore.logger.info "Fetcher starting up"
14
-
15
- # Clean up configured queues in case there are any resources left behind
16
- Chore.config.queues.each do |queue|
17
- Chore.config.consumer.cleanup(queue)
18
- end
19
-
20
14
  @strategy.fetch
21
15
  end
22
16
 
23
17
  # Stops the fetcher, preventing any further messages from being pulled from the queue
24
18
  def stop!
25
19
  unless @stopping
26
- Chore.logger.info "Fetcher shutting down"
20
+ Chore.logger.info "Fetcher shutting down started"
27
21
  @stopping = true
28
22
  @strategy.stop!
23
+ Chore.logger.info "Fetcher shutting down completed"
29
24
  end
30
25
  end
31
26
 
@@ -33,5 +28,15 @@ module Chore
33
28
  def stopping?
34
29
  @stopping
35
30
  end
31
+
32
+ # returns upto n work units
33
+ def provide_work(n)
34
+ @strategy.provide_work(n)
35
+ end
36
+
37
+ # gives work back to the consumer in case it couldn't be assigned
38
+ def return_work(work_units)
39
+ @strategy.return_work(work_units)
40
+ end
36
41
  end
37
42
  end
@@ -15,7 +15,8 @@ module Chore
15
15
 
16
16
  private
17
17
  def hooks_for(event)
18
- candidate_methods.grep(/^#{event}/).sort
18
+ @_chore_hooks ||= {}
19
+ @_chore_hooks[event] ||= candidate_methods.grep(/^#{event}/).sort
19
20
  end
20
21
 
21
22
  # NOTE: Any hook methods defined after this is first referenced (i.e.,
@@ -60,6 +60,12 @@ module Chore
60
60
  raise ArgumentError, "#{self.to_s}: backoff must accept a single argument"
61
61
  end
62
62
  end
63
+
64
+ if @chore_options.key?(:dedupe_lambda)
65
+ if !@chore_options[:dedupe_lambda].is_a?(Proc)
66
+ raise ArgumentError, "#{self.to_s}: dedupe_lambda must be a lambda or Proc"
67
+ end
68
+ end
63
69
  end
64
70
 
65
71
  # This is a method so it can be overriden to create additional required
@@ -108,6 +114,17 @@ module Chore
108
114
  def has_backoff?
109
115
  self.options.key?(:backoff)
110
116
  end
117
+
118
+ def has_dedupe_lambda?
119
+ self.options.key?(:dedupe_lambda)
120
+ end
121
+
122
+ def dedupe_key(*args)
123
+ return unless has_dedupe_lambda?
124
+
125
+ # run the proc to get the key
126
+ self.options[:dedupe_lambda].call(*args).to_s
127
+ end
111
128
  end #ClassMethods
112
129
 
113
130
  # This is handy to override in an included job to be able to do job setup that requires
@@ -5,19 +5,21 @@ require 'chore/fetcher'
5
5
  module Chore
6
6
  # Manages the interactions between fetching messages (Consumer Strategy), and working over them (Worker Strategy)
7
7
  class Manager
8
+ include Util
8
9
 
9
10
  def initialize()
10
11
  Chore.logger.info "Booting Chore #{Chore::VERSION}"
11
12
  Chore.logger.debug { Chore.config.inspect }
13
+ procline("#{Chore.config.master_procline}:Started:#{Time.now}")
12
14
  @started_at = nil
13
15
  @worker_strategy = Chore.config.worker_strategy.new(self)
14
16
  @fetcher = Chore.config.fetcher.new(self)
15
- @processed = 0
16
17
  @stopping = false
17
18
  end
18
19
 
19
20
  # Start the Manager. This calls both the #start method of the configured Worker Strategy, as well as Fetcher#start.
20
21
  def start
22
+ Chore.run_hooks_for(:before_start)
21
23
  @started_at = Time.now
22
24
  @worker_strategy.start
23
25
  @fetcher.start
@@ -26,11 +28,12 @@ module Chore
26
28
  # Shut down the Manager, the Worker Strategy, and the Fetcher. This calls the +:before_shutdown+ hook.
27
29
  def shutdown!
28
30
  unless @stopping
29
- Chore.logger.info "Manager shutting down"
31
+ Chore.logger.info "Manager shutting down started"
30
32
  @stopping = true
31
33
  Chore.run_hooks_for(:before_shutdown)
32
34
  @fetcher.stop!
33
35
  @worker_strategy.stop!
36
+ Chore.logger.info "Manager shutting down completed"
34
37
  end
35
38
  end
36
39
 
@@ -41,7 +44,20 @@ module Chore
41
44
  # than they can be consumed.
42
45
  def assign(work)
43
46
  Chore.logger.debug { "Manager#assign: No. of UnitsOfWork: #{work.length})" }
47
+ work.each do | item |
48
+ Chore.run_hooks_for(:before_send_to_worker, item)
49
+ end
44
50
  @worker_strategy.assign(work) unless @stopping
45
51
  end
52
+
53
+ # returns up to n from the throttled consumer queue
54
+ def fetch_work(n)
55
+ @fetcher.provide_work(n)
56
+ end
57
+
58
+ # gives work back to the fetcher in case it couldn't be assigned
59
+ def return_work(work_units)
60
+ @fetcher.return_work(work_units)
61
+ end
46
62
  end
47
63
  end
@@ -21,55 +21,105 @@ module Chore
21
21
 
22
22
  Chore::CLI.register_option 'fs_queue_root', '--fs-queue-root DIRECTORY', 'Root directory for fs based queue'
23
23
 
24
- FILE_QUEUE_MUTEXES = {}
25
-
26
24
  class << self
27
- # Cleans up the in-progress files by making them new again. This should only
28
- # happen once per process.
29
- def cleanup(queue)
30
- new_dir = self.new_dir(queue)
31
- in_progress_dir = self.in_progress_dir(queue)
32
-
33
- job_files(in_progress_dir).each do |file|
34
- make_new_again(file, new_dir, in_progress_dir)
25
+ # Cleans up expired in-progress files by making them new again.
26
+ def cleanup(expiration_time, new_dir, in_progress_dir)
27
+ each_file(in_progress_dir) do |job_file|
28
+ id, previous_attempts, timestamp = file_info(job_file)
29
+ next if timestamp > expiration_time
30
+
31
+ begin
32
+ make_new_again(job_file, new_dir, in_progress_dir)
33
+ rescue Errno::ENOENT
34
+ # File no longer exists; skip since it's been recovered by another
35
+ # consumer
36
+ rescue ArgumentError
37
+ # Move operation was attempted at same time as another consumer;
38
+ # skip since the other process succeeded where this one didn't
39
+ end
35
40
  end
36
41
  end
37
42
 
38
- def make_in_progress(job, new_dir, in_progress_dir)
39
- move_job(File.join(new_dir, job), File.join(in_progress_dir, job))
43
+ # Moves job file to inprogress directory and returns the full path
44
+ # if the job was successfully locked by this consumer
45
+ def make_in_progress(job, new_dir, in_progress_dir, queue_timeout)
46
+ basename, previous_attempts, * = file_info(job)
47
+
48
+ from = File.join(new_dir, job)
49
+ # Add a timestamp to mark when the job was started
50
+ to = File.join(in_progress_dir, "#{basename}.#{previous_attempts}.#{Time.now.to_i}.job")
51
+
52
+ # If the file is non-zero, this means it was successfully written to
53
+ # by a publisher and we can attempt to move it to "in progress".
54
+ #
55
+ # There is a small window of time where the file can be zero, but
56
+ # the publisher hasn't finished writing to the file yet.
57
+ if !File.zero?(from)
58
+ File.open(from, "r") do |f|
59
+ # If the lock can't be obtained, that means it's been locked
60
+ # by another consumer or the publisher of the file) -- don't
61
+ # block and skip it
62
+ if f.flock(File::LOCK_EX | File::LOCK_NB)
63
+ FileUtils.mv(from, to)
64
+ to
65
+ end
66
+ end
67
+ elsif (Time.now - File.ctime(from)) >= queue_timeout
68
+ # The file is empty (zero bytes) and enough time has passed since
69
+ # the file was written that we can safely assume it will never
70
+ # get written to be the publisher.
71
+ #
72
+ # The scenario where this happens is when the publisher created
73
+ # the file, but the process was killed before it had a chance to
74
+ # actually write the data.
75
+ File.delete(from)
76
+ nil
77
+ end
78
+ rescue Errno::ENOENT
79
+ # File no longer exists; skip it since it's been picked up by
80
+ # another consumer
40
81
  end
41
82
 
83
+ # Moves job file to new directory and returns the full path
42
84
  def make_new_again(job, new_dir, in_progress_dir)
43
85
  basename, previous_attempts = file_info(job)
44
- move_job(File.join(in_progress_dir, job), File.join(new_dir, "#{basename}.#{previous_attempts + 1}.job"))
45
- end
46
86
 
47
- # Moves job file to inprogress directory and returns the full path
48
- def move_job(from, to)
49
- f = File.open(from, "r")
50
- # wait on the lock a publisher in another process might have.
51
- # Once we get the lock the file is ours to move to mark it in progress
52
- f.flock(File::LOCK_EX)
53
- begin
54
- FileUtils.mv(f.path, to)
55
- ensure
56
- f.flock(File::LOCK_UN) # yes we can unlock it after its been moved, I checked
57
- end
87
+ from = File.join(in_progress_dir, job)
88
+ to = File.join(new_dir, "#{basename}.#{previous_attempts + 1}.job")
89
+ FileUtils.mv(from, to)
90
+
58
91
  to
59
92
  end
60
93
 
61
- def job_files(dir)
62
- Dir.entries(dir).select{|e| ! e.start_with?(".")}
94
+ def each_file(path, limit = nil)
95
+ count = 0
96
+
97
+ Dir.foreach(path) do |file|
98
+ next if file.start_with?('.')
99
+
100
+ yield file
101
+
102
+ count += 1
103
+ break if limit && count >= limit
104
+ end
63
105
  end
64
106
 
65
107
  # Grabs the unique identifier for the job filename and the number of times
66
108
  # it's been attempted (also based on the filename)
67
109
  def file_info(job_file)
68
- id, previous_attempts = File.basename(job_file, '.job').split('.')
69
- [id, previous_attempts.to_i]
110
+ id, previous_attempts, timestamp, * = job_file.split('.')
111
+ [id, previous_attempts.to_i, timestamp.to_i]
70
112
  end
71
113
  end
72
114
 
115
+ # The minimum number of seconds to allow to pass between checks for expired
116
+ # jobs on the filesystem.
117
+ #
118
+ # Since queue times are measured on the order of seconds, 1 second is the
119
+ # smallest duration. It also prevents us from burning a lot of CPU looking
120
+ # at expired jobs when the consumer sleep interval is less than 1 second.
121
+ EXPIRATION_CHECK_INTERVAL = 1
122
+
73
123
  # The amount of time units of work can run before the queue considers
74
124
  # them timed out. For filesystem queues, this is the global default.
75
125
  attr_reader :queue_timeout
@@ -77,38 +127,46 @@ module Chore
77
127
  def initialize(queue_name, opts={})
78
128
  super(queue_name, opts)
79
129
 
80
- # Even though putting these Mutexes in this hash is, by itself, not particularly threadsafe
81
- # as long as some Mutex ends up in the queue after all consumers are created we're good
82
- # as they are pulled from the queue and synchronized for file operations below
83
- FILE_QUEUE_MUTEXES[@queue_name] ||= Mutex.new
84
-
85
130
  @in_progress_dir = self.class.in_progress_dir(queue_name)
86
131
  @new_dir = self.class.new_dir(queue_name)
87
- @queue_timeout = Chore.config.default_queue_timeout
132
+ @queue_timeout = self.class.queue_timeout(queue_name)
88
133
  end
89
134
 
90
- def consume(&handler)
135
+ def consume
91
136
  Chore.logger.info "Starting consuming file system queue #{@queue_name} in #{self.class.queue_dir(queue_name)}"
92
137
  while running?
93
138
  begin
94
- #TODO move expired job files to new directory?
95
- handle_jobs(&handler)
139
+ # Move expired job files to new directory (so long as enough time has
140
+ # passed since we last did this check)
141
+ if !@last_cleaned_at || (Time.now - @last_cleaned_at).to_i >= EXPIRATION_CHECK_INTERVAL
142
+ self.class.cleanup(Time.now.to_i - @queue_timeout, @new_dir, @in_progress_dir)
143
+ @last_cleaned_at = Time.now
144
+ end
145
+
146
+ found_files = false
147
+ handle_jobs do |*args|
148
+ found_files = true
149
+ yield(*args)
150
+ end
96
151
  rescue => e
97
152
  Chore.logger.error { "#{self.class}#consume: #{e} #{e.backtrace * "\n"}" }
98
153
  ensure
99
- sleep 5
154
+ sleep(Chore.config.consumer_sleep_interval) unless found_files
100
155
  end
101
156
  end
102
157
  end
103
158
 
159
+ # Rejects the given message from the filesystem by +id+. Currently a noop
104
160
  def reject(id)
105
- Chore.logger.debug "Rejecting: #{id}"
106
- make_new_again(id)
161
+
107
162
  end
108
163
 
109
164
  def complete(id)
110
165
  Chore.logger.debug "Completing (deleting): #{id}"
111
- FileUtils.rm(File.join(@in_progress_dir, id))
166
+ File.delete(File.join(@in_progress_dir, id))
167
+ rescue Errno::ENOENT
168
+ # The job took too long to complete, was deemed expired, and moved
169
+ # back into "new". Ignore.
112
170
  end
113
171
 
114
172
  private
@@ -116,27 +174,26 @@ module Chore
116
174
  # finds all new job files, moves them to in progress and starts the job
117
175
  # Returns a list of the job files processed
118
176
  def handle_jobs(&block)
119
- # all consumers on a single queue share a lock on handling files.
120
- # Each consumer comes along, processes all present files and release the lock.
121
- # This isn't particularly useful but is here to allow the configuration of
122
- # ThreadedConsumerStrategy with mutiple threads on a queue safely although you
123
- # probably wouldn't want to do that.
124
- FILE_QUEUE_MUTEXES[@queue_name].synchronize do
125
- self.class.job_files(@new_dir).each do |job_file|
126
- Chore.logger.debug "Found a new job #{job_file}"
127
-
128
- job_json = File.read(make_in_progress(job_file))
129
- basename, previous_attempts = self.class.file_info(job_file)
130
-
131
- # job_file is just the name which is the job id
132
- block.call(job_file, queue_name, queue_timeout, job_json, previous_attempts)
133
- Chore.run_hooks_for(:on_fetch, job_file, job_json)
134
- end
177
+ self.class.each_file(@new_dir, Chore.config.queue_polling_size) do |job_file|
178
+ Chore.logger.debug "Found a new job #{job_file}"
179
+
180
+ in_progress_path = make_in_progress(job_file)
181
+ next unless in_progress_path
182
+
183
+ # The job filename may have changed, so update it to reflect the in progress path
184
+ job_file = File.basename(in_progress_path)
185
+
186
+ job_json = File.read(in_progress_path)
187
+ basename, previous_attempts, * = self.class.file_info(job_file)
188
+
189
+ # job_file is just the name which is the job id
190
+ block.call(job_file, queue_name, queue_timeout, job_json, previous_attempts)
191
+ Chore.run_hooks_for(:on_fetch, job_file, job_json)
135
192
  end
136
193
  end
137
194
 
138
195
  def make_in_progress(job)
139
- self.class.make_in_progress(job, @new_dir, @in_progress_dir)
196
+ self.class.make_in_progress(job, @new_dir, @in_progress_dir, @queue_timeout)
140
197
  end
141
198
 
142
199
  def make_new_again(job)