cuetip 1.2.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.
@@ -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: []