good_job 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9604c74cf4073b178401ec0cb606e55732f087464058bebe968b666c31c242d4
4
+ data.tar.gz: f4af6118d55f231d15c3ffa2a3aac9f625911889a4f1adce5c14dcb2626af4d5
5
+ SHA512:
6
+ metadata.gz: 46e92a0efba3937d274942ea55371f65cc3aa0451bc625bff6856be09525446960d192473548820bf1c289713151ee82e5cf1bdef21252da6a0927baafe5cc9c
7
+ data.tar.gz: 9e1d108190398d5d698713e12ba981d63ee121f095185990f1f0963bee36e4f4760a22c9f64f8148624ba6be931a89943e48a601c4395ad825254d5d23c86c58
data/LICENSE.txt ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2020 Ben Sheldon
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # GoodJob
2
+
3
+ GoodJob is a multithreaded, Postgres-based ActiveJob backend for Ruby on Rails.
4
+
5
+ ## Usage
6
+
7
+ 1. Create a database migration:
8
+ ```bash
9
+ bin/rails g migration CreateGoodJobs
10
+ ```
11
+
12
+ And then add to the newly created file:
13
+
14
+ ```ruby
15
+ class CreateGoodJobs < ActiveRecord::Migration[6.0]
16
+ def change
17
+ enable_extension 'pgcrypto'
18
+
19
+ create_table :good_jobs, id: :uuid do |t|
20
+ t.timestamps
21
+
22
+ t.text :queue_name
23
+ t.integer :priority
24
+ t.jsonb :serialized_params
25
+ t.timestamp :scheduled_at
26
+ end
27
+ end
28
+ end
29
+ ```
30
+ 1. Configure the ActiveJob adapter:
31
+ ```ruby
32
+ # config/environments/production.rb
33
+ config.active_job.queue_adapter = GoodJob::Adapter.new
34
+
35
+ # config/environments/development.rb
36
+ config.active_job.queue_adapter = GoodJob::Adapter.new(inline: true)
37
+ ```
38
+
39
+ 1. In production, the scheduler is designed to run in its own process:
40
+ ```bash
41
+ $ bundle exec good_job
42
+ ```
43
+
44
+ ## Installation
45
+ Add this line to your application's Gemfile:
46
+
47
+ ```ruby
48
+ gem 'good_job', github: 'bensheldon/good_job'
49
+ ```
50
+
51
+ And then execute:
52
+ ```bash
53
+ $ bundle
54
+ ```
55
+
56
+ ## Development
57
+
58
+ To run tests:
59
+
60
+ ```bash
61
+ # Clone the repository locally
62
+ $ git clone git@github.com:bensheldon/good_job.git
63
+
64
+ # Set up the local environment
65
+ $ bin/setup_test
66
+
67
+ # Run the tests
68
+ $ bin/rspec
69
+ ```
70
+
71
+ For developing locally within another Ruby on Rails project:
72
+
73
+ ```bash
74
+ # Within Ruby on Rails directory...
75
+ $ bundle config local.good_job /path/to/local/git/repository
76
+
77
+ # Confirm that the local copy is used
78
+ $ bundle install
79
+
80
+ # => Using good_job 0.1.0 from https://github.com/bensheldon/good_job.git (at /Users/You/Projects/good_job@dc57fb0)
81
+ ```
82
+
83
+ ## Contributing
84
+ Contribution directions go here.
85
+
86
+ ## License
87
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/bin/good_job ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env ruby
2
+ require 'good_job/cli'
3
+ GoodJob::CLI.start(ARGV)
@@ -0,0 +1,32 @@
1
+ module GoodJob
2
+ class Adapter
3
+ def initialize(options = {})
4
+ @options = options
5
+ @scheduler = InlineScheduler.new if inline?
6
+ end
7
+
8
+ def inline?
9
+ @options.fetch(:inline, false)
10
+ end
11
+
12
+ def enqueue(job)
13
+ enqueue_at(job, nil)
14
+ end
15
+
16
+ def enqueue_at(job, timestamp)
17
+ params = {
18
+ queue_name: job.queue_name,
19
+ priority: job.priority,
20
+ serialized_params: job.serialize,
21
+ }
22
+ params[:scheduled_at] = Time.at(timestamp) if timestamp
23
+
24
+ good_job = GoodJob::Job.create(params)
25
+ @scheduler.enqueue(good_job) if inline?
26
+ end
27
+
28
+ def shutdown(wait: true)
29
+ @scheduler&.shutdown(wait: wait)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,19 @@
1
+ require 'thor'
2
+
3
+ module GoodJob
4
+ class CLI < Thor
5
+ RAILS_ENVIRONMENT_RB = File.expand_path("config/environment.rb")
6
+
7
+ desc :start, "Start jobs"
8
+ def start
9
+ require RAILS_ENVIRONMENT_RB
10
+
11
+ scheduler = GoodJob::Scheduler.new
12
+ Kernel.loop do
13
+ sleep 1
14
+ end
15
+ end
16
+
17
+ default_task :start
18
+ end
19
+ end
@@ -0,0 +1,10 @@
1
+ module GoodJob
2
+ class InlineScheduler
3
+ def enqueue(good_job)
4
+ JobWrapper.new(good_job).perform
5
+ end
6
+
7
+ def shutdown(wait: true)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,6 @@
1
+ module GoodJob
2
+ class Job < ActiveRecord::Base
3
+ include Lockable
4
+ self.table_name = 'good_jobs'
5
+ end
6
+ end
@@ -0,0 +1,16 @@
1
+ module GoodJob
2
+ class JobWrapper
3
+ def initialize(good_job)
4
+ @good_job = good_job
5
+ end
6
+
7
+ def perform
8
+ serialized_params = @good_job.serialized_params.merge(
9
+ "provider_job_id" => @good_job.id
10
+ )
11
+ ActiveJob::Base.execute(serialized_params)
12
+
13
+ @good_job.destroy!
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,97 @@
1
+ module GoodJob
2
+ module Lockable
3
+ extend ActiveSupport::Concern
4
+
5
+ RecordAlreadyAdvisoryLockedError = Class.new(StandardError)
6
+
7
+ included do
8
+ scope :joins_advisory_locks, (lambda do
9
+ joins(<<~SQL)
10
+ LEFT JOIN pg_locks ON pg_locks.locktype = 'advisory'
11
+ AND pg_locks.objsubid = 1
12
+ AND pg_locks.classid = ('x'||substr(md5(good_jobs.id::text), 1, 16))::bit(32)::int
13
+ AND pg_locks.objid = (('x'||substr(md5(good_jobs.id::text), 1, 16))::bit(64) << 32)::bit(32)::int
14
+ SQL
15
+ end)
16
+
17
+ scope :advisory_unlocked, -> { joins_advisory_locks.where(pg_locks: { locktype: nil }) }
18
+ scope :with_advisory_lock, (lambda do
19
+ where(<<~SQL)
20
+ pg_try_advisory_lock(('x'||substr(md5(id::text), 1, 16))::bit(64)::bigint)
21
+ SQL
22
+ end)
23
+
24
+ def self.first_advisory_locked_row(query)
25
+ find_by_sql(<<~SQL)
26
+ WITH rows AS (#{query.to_sql})
27
+ SELECT rows.id
28
+ FROM rows
29
+ WHERE pg_try_advisory_lock(('x'||substr(md5(id::text), 1, 16))::bit(64)::bigint)
30
+ SQL
31
+ end
32
+ # private_class_method :first_advisory_locked_row
33
+
34
+ # https://www.postgresql.org/docs/9.6/view-pg-locks.html
35
+ # Advisory locks can be acquired on keys consisting of either a single bigint value or two integer values.
36
+ # A bigint key is displayed with its high-order half in the classid column, its low-order half in the objid column, and objsubid equal to 1.
37
+ # The original bigint value can be reassembled with the expression (classid::bigint << 32) | objid::bigint.
38
+ # Integer keys are displayed with the first key in the classid column, the second key in the objid column, and objsubid equal to 2.
39
+ # The actual meaning of the keys is up to the user. Advisory locks are local to each database, so the database column is meaningful for an advisory lock.
40
+ def self.advisory_lock_details
41
+ connection.select("SELECT * FROM pg_locks WHERE locktype = 'advisory' AND objsubid = 1")
42
+ end
43
+
44
+ def advisory_lock
45
+ self.class.connection.execute(self.class.sanitize_sql_for_conditions(["SELECT 1 as one WHERE pg_try_advisory_lock(('x'||substr(md5(?), 1, 16))::bit(64)::bigint)", id])).ntuples > 0
46
+ end
47
+
48
+ def advisory_lock!
49
+ result = advisory_lock
50
+ result || raise(RecordAlreadyAdvisoryLockedError)
51
+ end
52
+
53
+ def with_advisory_lock
54
+ begin
55
+ advisory_lock!
56
+ yield
57
+ rescue StandardError => e
58
+ advisory_unlock unless e.is_a? RecordAlreadyAdvisoryLockedError
59
+ raise
60
+ end
61
+ end
62
+
63
+ def advisory_locked?
64
+ self.class.connection.execute(<<~SQL).ntuples > 0
65
+ SELECT 1 as one
66
+ FROM pg_locks
67
+ WHERE
68
+ locktype = 'advisory'
69
+ AND objsubid = 1
70
+ AND classid = ('x'||substr(md5('#{id}'), 1, 16))::bit(32)::int
71
+ AND objid = (('x'||substr(md5('#{id}'), 1, 16))::bit(64) << 32)::bit(32)::int
72
+ SQL
73
+ end
74
+
75
+ def owns_advisory_lock?
76
+ self.class.connection.execute(<<~SQL).ntuples > 0
77
+ SELECT 1 as one
78
+ FROM pg_locks
79
+ WHERE
80
+ locktype = 'advisory'
81
+ AND objsubid = 1
82
+ AND classid = ('x'||substr(md5('#{id}'), 1, 16))::bit(32)::int
83
+ AND objid = (('x'||substr(md5('#{id}'), 1, 16))::bit(64) << 32)::bit(32)::int
84
+ AND pid = pg_backend_pid()
85
+ SQL
86
+ end
87
+
88
+ def advisory_unlock
89
+ self.class.connection.execute("SELECT pg_advisory_unlock(('x'||substr(md5('#{id}'), 1, 16))::bit(64)::bigint)").first["pg_advisory_unlock"]
90
+ end
91
+
92
+ def advisory_unlock!
93
+ advisory_unlock while advisory_locked?
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,4 @@
1
+ module GoodJob
2
+ class Railtie < ::Rails::Railtie
3
+ end
4
+ end
@@ -0,0 +1,89 @@
1
+ require "concurrent/scheduled_task"
2
+ require "concurrent/executor/thread_pool_executor"
3
+ require "concurrent/utility/processor_counter"
4
+
5
+ module GoodJob
6
+ class Scheduler
7
+ MINIMUM_EXECUTION_INTERVAL = 0.1
8
+
9
+ DEFAULT_TIMER_OPTIONS = {
10
+ execution_interval: 1,
11
+ timeout_interval: 1,
12
+ run_now: true
13
+ }.freeze
14
+
15
+ MAX_THREADS = Concurrent.processor_count
16
+
17
+ DEFAULT_POOL_OPTIONS = {
18
+ name: 'good_job',
19
+ min_threads: 0,
20
+ max_threads: MAX_THREADS,
21
+ auto_terminate: true,
22
+ idletime: 0,
23
+ max_queue: 0,
24
+ fallback_policy: :abort # shouldn't matter -- 0 max queue
25
+ }.freeze
26
+
27
+ def initialize(query = GoodJob::Job.all, **options)
28
+ @query = query
29
+
30
+ @pool = Concurrent::ThreadPoolExecutor.new(DEFAULT_POOL_OPTIONS)
31
+ @timer = Concurrent::TimerTask.new(DEFAULT_TIMER_OPTIONS) do
32
+ idle_threads = @pool.max_length - @pool.length
33
+ puts "There are idle_threads: #{idle_threads}"
34
+ create_thread if idle_threads.positive?
35
+ true
36
+ end
37
+ @timer.execute
38
+ end
39
+
40
+ def execute
41
+ end
42
+
43
+ def shutdown(wait: true)
44
+ if @timer.running?
45
+ @timer.shutdown
46
+ @timer.wait_for_termination if wait
47
+ end
48
+
49
+ if @pool.running?
50
+ @pool.shutdown
51
+ @pool.wait_for_termination if wait
52
+ end
53
+ end
54
+
55
+ def create_thread
56
+ future = Concurrent::Future.new(args: [@query], executor: @pool) do |query|
57
+ Rails.application.executor.wrap do
58
+ thread_name = Thread.current.name || Thread.current.object_id
59
+ while job = query.with_advisory_lock.first
60
+ puts "Executing job #{job.id} in thread #{thread_name}"
61
+
62
+ JobWrapper.new(job).perform
63
+
64
+ job.advisory_unlock
65
+ end
66
+ true
67
+ end
68
+ end
69
+ future.add_observer(TaskObserver.new(self))
70
+ future.execute
71
+ end
72
+
73
+ class TaskObserver
74
+ def initialize(scheduler)
75
+ @scheduler = scheduler
76
+ end
77
+
78
+ def update(time, result, ex)
79
+ if result
80
+ puts "(#{time}) Execution successfully returned #{result}\n"
81
+ elsif ex.is_a?(Concurrent::TimeoutError)
82
+ puts "(#{time}) Execution timed out\n"
83
+ else
84
+ puts "(#{time}) Execution failed with error #{result} #{ex}\n"
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,3 @@
1
+ module GoodJob
2
+ VERSION = '0.1.0'
3
+ end
data/lib/good_job.rb ADDED
@@ -0,0 +1,12 @@
1
+ require "rails"
2
+
3
+ require 'good_job/lockable'
4
+ require 'good_job/job'
5
+ require 'good_job/inline_scheduler'
6
+ require "good_job/scheduler"
7
+ require "good_job/job_wrapper"
8
+ require 'good_job/adapter'
9
+
10
+ module GoodJob
11
+ # Your code goes here...
12
+ end
metadata ADDED
@@ -0,0 +1,155 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: good_job
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ben Sheldon
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-03-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: concurrent-ruby
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rails
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
+ - !ruby/object:Gem::Dependency
42
+ name: thor
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: database_cleaner
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: gem-release
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pg
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec-rails
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ description: GoodJob is a minimal postgres based job queue system for Rails
112
+ email:
113
+ - bensheldon@gmail.com
114
+ executables:
115
+ - good_job
116
+ extensions: []
117
+ extra_rdoc_files: []
118
+ files:
119
+ - LICENSE.txt
120
+ - README.md
121
+ - bin/good_job
122
+ - lib/good_job.rb
123
+ - lib/good_job/adapter.rb
124
+ - lib/good_job/cli.rb
125
+ - lib/good_job/inline_scheduler.rb
126
+ - lib/good_job/job.rb
127
+ - lib/good_job/job_wrapper.rb
128
+ - lib/good_job/lockable.rb
129
+ - lib/good_job/railtie.rb
130
+ - lib/good_job/scheduler.rb
131
+ - lib/good_job/version.rb
132
+ homepage: https://github.com/benheldon/good_job
133
+ licenses:
134
+ - MIT
135
+ metadata: {}
136
+ post_install_message:
137
+ rdoc_options: []
138
+ require_paths:
139
+ - lib
140
+ required_ruby_version: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: '0'
145
+ required_rubygems_version: !ruby/object:Gem::Requirement
146
+ requirements:
147
+ - - ">="
148
+ - !ruby/object:Gem::Version
149
+ version: '0'
150
+ requirements: []
151
+ rubygems_version: 3.1.2
152
+ signing_key:
153
+ specification_version: 4
154
+ summary: GoodJob is a minimal postgres based job queue system for Rails
155
+ test_files: []