cuetip 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 80caddd838028973a0877c6a4b303d0b36b6ad6f5c98564ed865a60f41ce348a
4
+ data.tar.gz: 8b5a04b3537ff9a6bad21225ea11becb3ce5d2b2dd1e8c4f30cf8649a55f90d9
5
+ SHA512:
6
+ metadata.gz: d4b32a2ec0ad43135fd41e9a81cccf217a6415058f43244972835d6f902b5b6ac54cedd547dad7643a0a7ca7852d7a469465b263d2f392d6ba53f191f824accc
7
+ data.tar.gz: e02bbcd49dac8eada655e0b86d5ce31d8e1f9bf6186e0e0bd14ff79cf2927102a4a3457b6e31fe624a0e486f9b18ca1041244efa57de618ec8e0e1ad16c80dda
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'cuetip'
5
+ require 'cuetip/config'
6
+ require 'cuetip/version'
7
+ require 'cuetip/worker_group'
8
+ require 'optparse'
9
+
10
+ $stdout.sync = true
11
+ $stderr.sync = true
12
+
13
+ options = {}
14
+ OptionParser.new do |opts|
15
+ opts.version = Cuetip::VERSION
16
+ opts.banner = 'Usage: cuetip [options]'
17
+
18
+ opts.on('-c', '--config PATH', 'The path to your cuetip config file') do |config|
19
+ options[:config] = config
20
+ end
21
+
22
+ opts.on('-h', '--help', 'Prints this help') do
23
+ puts opts
24
+ exit
25
+ end
26
+
27
+ opts.on('-w NUMBER', 'The number of workers to run') do |i|
28
+ options[:quantity] = i.to_i
29
+ end
30
+
31
+ opts.on('-q', '--queues QUEUE1,QUEUE2', 'Queues that you wish to work on') do |queues|
32
+ options[:queues] = []
33
+ queues.split(/,/).uniq.each do |queue|
34
+ options[:queues] << queue
35
+ end
36
+ end
37
+ end.parse!
38
+
39
+ if options[:config]
40
+ if File.file?(options[:config])
41
+ file = File.expand_path(options[:config])
42
+ require file
43
+ else
44
+ puts "Cuetip config file not found at #{options[:config]}"
45
+ exit 1
46
+ end
47
+ end
48
+
49
+ worker = Cuetip::WorkerGroup.new(options[:quantity].to_i == 0 ? Cuetip.config.worker_threads : options[:quantity].to_i, options[:queues])
50
+ worker.start
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuetip'
4
+ require 'cuetip/config'
5
+ require 'cuetip/job'
6
+ require 'cuetip/version'
7
+
8
+ module Cuetip
9
+ def self.config
10
+ @config ||= Config.new
11
+ end
12
+
13
+ def self.logger
14
+ config.logger
15
+ end
16
+
17
+ def self.configure(&block)
18
+ block.call(config)
19
+ end
20
+ end
21
+
22
+ require 'cuetip/engine' if defined?(Rails)
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/core_ext/numeric/bytes'
5
+ require 'active_support/core_ext/numeric/time'
6
+
7
+ module Cuetip
8
+ class Config
9
+ # The length of time between polling
10
+ def polling_interval
11
+ @polling_interval || 5
12
+ end
13
+ attr_writer :polling_interval
14
+
15
+ # The number of worker threads to run
16
+ def worker_threads
17
+ @worker_threads || 1
18
+ end
19
+ attr_writer :worker_threads
20
+
21
+ # Return the logger
22
+ def logger
23
+ @logger ||= Logger.new(STDOUT)
24
+ end
25
+ attr_writer :logger
26
+
27
+ # Define a job event callback
28
+ def on(event, &block)
29
+ callbacks[event.to_sym] ||= []
30
+ callbacks[event.to_sym] << block
31
+ end
32
+
33
+ # Return all callbacks
34
+ def callbacks
35
+ @callbacks ||= Hash.new
36
+ end
37
+
38
+ # Emit some callbacks
39
+ def emit(event, *args)
40
+ return unless callbacks[event.to_sym]
41
+
42
+ callbacks[event.to_sym].each do |callback|
43
+ callback.call(*args)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cuetip
4
+ class Engine < Rails::Engine #:nodoc:
5
+ engine_name 'cuetip'
6
+ end
7
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuetip/models/job'
4
+
5
+ module Cuetip
6
+ class Job
7
+ class << self
8
+ # The queue that this job should be executed on
9
+ def queue_name
10
+ @queue_name || 'default'
11
+ end
12
+ attr_writer :queue_name
13
+
14
+ # The maximum length of time (in seconds) that a job can run for
15
+ def maximum_execution_time
16
+ @maximum_execution_time || 12.hours
17
+ end
18
+ attr_writer :maximum_execution_time
19
+
20
+ # The maximum length of time (in seconds) between the job being created and it being run
21
+ def ttl
22
+ @ttl || 6.hours
23
+ end
24
+ attr_writer :ttl
25
+
26
+ # The maximum number of times this job can be run
27
+ def retry_count
28
+ @retry_count || 0
29
+ end
30
+ attr_writer :retry_count
31
+
32
+ # The maximum length of time (in seconds) between each execution of this job
33
+ def retry_interval
34
+ @retry_interval || 1.minute
35
+ end
36
+ attr_writer :retry_interval
37
+
38
+ # The length of time (in seconds) from when this job is queued to when it should be executed
39
+ def delay_execution
40
+ @delay_execution || 0
41
+ end
42
+ attr_writer :delay_execution
43
+
44
+ # Queue this job
45
+ #
46
+ # @param params [Hash]
47
+ # @return [Cuetip::Models::Job]
48
+ def queue(params = {}, &block)
49
+ # Create our new job
50
+ job = Models::Job.new(class_name: name, params: params)
51
+ # Copy over any class leve lconfig
52
+ job.queue_name = queue_name
53
+ job.maximum_execution_time = maximum_execution_time
54
+ job.ttl = ttl
55
+ job.retry_count = retry_count
56
+ job.retry_interval = retry_interval
57
+ job.delay_execution = delay_execution
58
+ # Call the block
59
+ block.call(job) if block_given?
60
+ # Create the job
61
+ job.save!
62
+ # Return the job
63
+ job
64
+ end
65
+ end
66
+
67
+ # Initialize this job instance by providing a queued job instance
68
+ #
69
+ # @param queued_job [Cuetip::Models::Job]
70
+ def initialize(job)
71
+ @job = job
72
+ end
73
+
74
+ # Perform a job
75
+ #
76
+ # @return [void]
77
+ def perform; end
78
+
79
+ private
80
+
81
+ # Return all parameters for the job
82
+ #
83
+ # @return [Hashie::Mash]
84
+ def params
85
+ @job.params
86
+ end
87
+
88
+ # Return the queued job object
89
+ #
90
+ # @return [Cuetip::Models::Job]
91
+ attr_reader :job
92
+
93
+ # Return a quick access for the job
94
+ #
95
+ # @return [Logger]
96
+ def logger
97
+ Cuetip.logger
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+ require 'cuetip/models/queued_job'
5
+ require 'cuetip/serialized_hashie'
6
+
7
+ module Cuetip
8
+ module Models
9
+ class Job < ActiveRecord::Base
10
+ self.table_name = 'cuetip_jobs'
11
+
12
+ STATUSES = %w[Pending Running Complete Aborted Expired].freeze
13
+
14
+ has_one :queued_job, class_name: 'Cuetip::Models::QueuedJob'
15
+ belongs_to :associated_object, polymorphic: true, optional: true
16
+
17
+ serialize :params, Cuetip::SerializedHashie
18
+
19
+ before_validation(on: :create) do
20
+ self.status = 'Pending'
21
+ end
22
+
23
+ after_create do
24
+ # After creation, automatically add this job into the job queue for execution
25
+ create_queued_job!(run_after: run_after || delay_execution&.seconds&.from_now, queue_name: queue_name)
26
+ end
27
+
28
+ # Is this job in the queue
29
+ def queued?
30
+ queued_job.present?
31
+ end
32
+
33
+ # Has this job expired?
34
+ def expired?
35
+ ttl? ? expires_at <= Time.now : false
36
+ end
37
+
38
+ # The time that this job expired
39
+ def expires_at
40
+ ttl? ? created_at + ttl : nil
41
+ end
42
+
43
+ # Should this job be requeued on a failure right now?
44
+ def requeue_on_failure?
45
+ retry_count && retry_interval ? executions <= retry_count : false
46
+ end
47
+
48
+ # Remove this job from the queue
49
+ def remove_from_queue
50
+ queued_job&.destroy
51
+ self.queued_job = nil
52
+ log 'Removed from queue'
53
+ end
54
+
55
+ # Log some text about this job
56
+ #
57
+ # @param text [String]
58
+ def log(text)
59
+ Cuetip.logger.info "[#{id}] #{text}"
60
+ end
61
+
62
+ # Execute the job
63
+ #
64
+ # @return [Boolean] whether the job executed successfully or not
65
+ def execute(&block)
66
+ log "Beginning execution of job #{id} with #{class_name}"
67
+ # Initialize a new instance of the job we wish to execute
68
+ job_klass = class_name.constantize.new(self)
69
+
70
+ # If the job has expired, we should not be executing this so we'll just
71
+ # remove it from the queue and mark it as expired.
72
+ if expired?
73
+ log 'Job has expired'
74
+ self.status = 'Expired'
75
+ remove_from_queue
76
+ Cuetip.config.emit(:expired, self, job_klass)
77
+ return false
78
+ end
79
+
80
+ Cuetip.config.emit(:before_perform, self, job_klass)
81
+
82
+ # If we have a block, call this so we can manipulate our actual job class
83
+ # before execution if needed (mostly for testing)
84
+ block.call(job_klass) if block_given?
85
+
86
+ # Mark the job as runnign
87
+ update!(status: 'Running', started_at: Time.now, executions: executions + 1)
88
+
89
+ begin
90
+ # Perform the job within a timeout
91
+ Timeout.timeout(maximum_execution_time || 1.year) do
92
+ job_klass.perform
93
+ end
94
+ # Mark the job as complete and remove it from the queue
95
+ self.status = 'Complete'
96
+ log 'Job completed successfully'
97
+ remove_from_queue
98
+
99
+ Cuetip.config.emit(:completed, self, job_klass)
100
+
101
+ true
102
+ rescue Exception, Timeout::TimeoutError => e
103
+ log "Job failed with #{e.class} (#{e.message})"
104
+
105
+ # If there's an error, mark the job as failed and copy exception
106
+ # data into the job
107
+ self.status = 'Failed'
108
+ self.exception_class = e.class.name
109
+ self.exception_message = e.message
110
+ self.exception_backtrace = e.backtrace.join("\n")
111
+
112
+ # Handle requeing the job if needed.
113
+ if requeue_on_failure?
114
+ # Requeue this job for execution again after the retry interval.
115
+ new_job = queued_job.requeue(run_after: Time.now + retry_interval.to_i)
116
+ log "Requeing job to run after #{new_job.run_after.to_s(:long)}"
117
+ self.status = 'Pending'
118
+ else
119
+ # We're done with this job. We can't do any more retries.
120
+ remove_from_queue
121
+ end
122
+
123
+ Cuetip.config.emit(:exception, e, self, job_klass)
124
+
125
+ false
126
+ end
127
+ ensure
128
+ self.finished_at = Time.now
129
+ save!
130
+ Cuetip.config.emit(:finished, self, job_klass)
131
+ log 'Finished processing'
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'active_record'
5
+ require 'cuetip/models/job'
6
+
7
+ module Cuetip
8
+ module Models
9
+ class QueuedJob < ActiveRecord::Base
10
+ PROCESS_IDENTIFIER = Socket.gethostname + ":#{Process.pid}"
11
+ self.table_name = 'cuetip_job_queue'
12
+
13
+ scope :pending, -> { where(locked_at: nil).where('run_after is null or run_after < ?', Time.now) }
14
+ scope :from_queues, -> (queues) { where(queue_name: queues) }
15
+
16
+ belongs_to :job, class_name: 'Cuetip::Models::Job'
17
+
18
+ # Unlock the job and allow it to be re-run elsewhere.
19
+ def requeue(attributes = {})
20
+ self.attributes = attributes
21
+ self.locked_by = nil
22
+ self.locked_at = nil
23
+ save!
24
+ self
25
+ end
26
+
27
+ # Generate a random lock ID to use in the locking process
28
+ def self.generate_lock_id
29
+ PROCESS_IDENTIFIER + ':' + rand(1_000_000_000).to_s.rjust(9, '0')
30
+ end
31
+
32
+ # Simultaneously find an outstanding job and lock it
33
+ def self.find_and_lock(queued_job_id = nil)
34
+ lock_id = generate_lock_id
35
+ scope = if queued_job_id
36
+ where(id: queued_job_id)
37
+ else
38
+ self
39
+ end
40
+ count = scope.pending.limit(1).update_all(locked_by: lock_id, locked_at: Time.now)
41
+ QueuedJob.find_by_locked_by(lock_id) if count > 0
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'hashie/mash'
4
+
5
+ module Cuetip
6
+ class SerializedHashie < Hashie::Mash
7
+ def self.dump(obj)
8
+ obj.reject! { |_k, v| v.blank? }
9
+ obj.each do |key, value|
10
+ obj[key] = value.reject(&:blank?) if value.is_a?(Array)
11
+ end
12
+ ActiveSupport::JSON.encode(obj.to_h)
13
+ end
14
+
15
+ def self.load(raw_hash)
16
+ new(JSON.parse(raw_hash || '{}'))
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cuetip
4
+ VERSION = '1.2.0'
5
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuetip/models/queued_job'
4
+
5
+ module Cuetip
6
+ class Worker
7
+ attr_reader :status
8
+
9
+ include ActiveSupport::Callbacks
10
+
11
+ define_callbacks :execute, :poll
12
+
13
+ def initialize(group, id, queues)
14
+ @group = group
15
+ @id = id
16
+ @queues = queues
17
+ end
18
+
19
+ def request_exit!
20
+ @exit_requested = true
21
+ interrupt_sleep
22
+ end
23
+
24
+ def run
25
+ set_status('idle')
26
+ loop do
27
+ unless run_once
28
+ interruptible_sleep(Cuetip.config.polling_interval + rand)
29
+ end
30
+
31
+ break if @exit_requested
32
+ end
33
+ end
34
+
35
+ def run_once
36
+ set_status('polling')
37
+ run_callbacks :poll do
38
+ queued_job = silence do
39
+ if @queues.any?
40
+ scope = Cuetip::Models::QueuedJob.from_queues(@queues)
41
+ else
42
+ scope = Cuetip::Models::QueuedJob
43
+ end
44
+ scope.find_and_lock
45
+ end
46
+
47
+ if queued_job
48
+ set_status("executing #{queued_job.job.id}")
49
+ run_callbacks :execute do
50
+ queued_job.job.execute
51
+ end
52
+ set_status('idle')
53
+ true
54
+ else
55
+ set_status('idle')
56
+ false
57
+ end
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def set_status(status)
64
+ @status = status
65
+ @group&.set_process_name
66
+ end
67
+
68
+ def interruptible_sleep(seconds)
69
+ sleep_check, @sleep_interrupt = IO.pipe
70
+ IO.select([sleep_check], nil, nil, seconds)
71
+ sleep_check.close
72
+ @sleep_interrupt.close
73
+ end
74
+
75
+ def interrupt_sleep
76
+ @sleep_interrupt&.close
77
+ end
78
+
79
+ def silence(&block)
80
+ if ActiveRecord::Base.logger
81
+ ActiveRecord::Base.logger.silence(&block)
82
+ else
83
+ block.call
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cuetip/worker'
4
+
5
+ module Cuetip
6
+ class WorkerGroup
7
+ include ActiveSupport::Callbacks
8
+
9
+ define_callbacks :run_worker
10
+
11
+ attr_reader :quantity
12
+ attr_reader :workers
13
+ attr_reader :threads
14
+
15
+ def initialize(quantity, queues)
16
+ @quantity = quantity
17
+ @queues = queues || []
18
+ @workers = {}
19
+ @threads = {}
20
+ end
21
+
22
+ def start
23
+ Cuetip.logger.info "Starting #{@quantity} Cuetip workers"
24
+ if @queues.any?
25
+ @queues.each { |q| Cuetip.logger.info "-> Joined queue: #{q.to_s}" }
26
+ end
27
+
28
+ exit_trap = proc do
29
+ @workers.each { |_, worker| worker.request_exit! }
30
+ puts 'Exiting...'
31
+ end
32
+
33
+ trap('INT', &exit_trap)
34
+ trap('TERM', &exit_trap)
35
+
36
+ @quantity.times do |i|
37
+ @workers[i] = Worker.new(self, i, @queues)
38
+ Cuetip.logger.info "-> Starting worker #{i}"
39
+ @threads[i] = Thread.new(@workers[i]) do |worker|
40
+ run_callbacks :run_worker do
41
+ worker.run
42
+ end
43
+ end
44
+ @threads[i].abort_on_exception = true
45
+ end
46
+ @threads.values.each(&:join)
47
+ end
48
+
49
+ def set_process_name
50
+ thread_names = @workers.values.map(&:status)
51
+ Process.setproctitle("Cuetip: #{thread_names.inspect}")
52
+ end
53
+ end
54
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cuetip
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Adam Cooke
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-04-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: hashie
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ description: An ActiveRecord job queueing system
42
+ email:
43
+ - me@adamcooke.io
44
+ executables:
45
+ - cuetip
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - bin/cuetip
50
+ - lib/cuetip.rb
51
+ - lib/cuetip/config.rb
52
+ - lib/cuetip/engine.rb
53
+ - lib/cuetip/job.rb
54
+ - lib/cuetip/models/job.rb
55
+ - lib/cuetip/models/queued_job.rb
56
+ - lib/cuetip/serialized_hashie.rb
57
+ - lib/cuetip/version.rb
58
+ - lib/cuetip/worker.rb
59
+ - lib/cuetip/worker_group.rb
60
+ homepage: https://github.com/adamcooke/cuetip
61
+ licenses:
62
+ - MIT
63
+ metadata: {}
64
+ post_install_message:
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 3.0.3
80
+ signing_key:
81
+ specification_version: 4
82
+ summary: An ActiveRecord job queueing system
83
+ test_files: []