gouda 0.1.3 → 0.1.5

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
2
  SHA256:
3
- metadata.gz: a8d5d39b730d429fe834fc88ecfe67e3a1bad397022715f6f52a1aa5e050df8b
4
- data.tar.gz: b6cb85e539a387f75e3e84ffe756d54a991755dbd3c4335d67b476d70770eb97
3
+ metadata.gz: 0be474cea3bc87b9c0a41c9c654a31e4dc865e46876249a1db615bf56e494a40
4
+ data.tar.gz: '048574e736404c6c76f437fdfded93d7e810a23c83bb4f28ede8dffe9761a215'
5
5
  SHA512:
6
- metadata.gz: 919a70fddf01f87880289e977b853aad5b01470e4f9b46ef1e8c5867ee50c4751f1ccd6ddc05146c40af83f8c82106d980feb93e022c4871114d7bb01361cee5
7
- data.tar.gz: 466fb4a016ff74b481b936445ea66ed85981520147831b4cb11ec08446b7e7f7962974041f153448248558190ff0b0afb427b37e21af8b915337885ceb37f250
6
+ metadata.gz: 00e687d2fc384484cf2c07bf6c661e8d5ff1287a96f4e4f0abd98df2933538d7d81946ee51fe8e7abd6f2e5b285b6762aba231d359926ba5ee09d07a8b7e526c
7
+ data.tar.gz: cbd09ad83be4b9e6b9b35e20b2c80b7c8a5b8de1e20f8adfa6d0701d1aa31be7e5ea8edd2e64fab8f67e69cdc9757d1bcf79908fac9a81e9eccf9497f0a1c073
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.3.1
1
+ 3.3.3
data/CHANGELOG.md CHANGED
@@ -15,3 +15,15 @@
15
15
  ## [0.1.3] - 2023-06-11
16
16
 
17
17
  - Allow the Rails app to boot even if there is no database yet
18
+
19
+ ## [0.1.4] - 2023-06-14
20
+
21
+ - Rescue NoDatabaseError at scheduler update.
22
+ - Include tests in gem, for sake of easier debugging.
23
+ - Reduce logging in local test runs.
24
+ - Bump local ruby version to 3.3.3
25
+
26
+ ## [0.1.5] - 2023-06-18
27
+
28
+ - Update documentation
29
+ - Don't pass on scheduler keys to retries
data/README.md CHANGED
@@ -1,7 +1,10 @@
1
1
  Gouda is an ActiveJob adapter used at Cheddar. It requires PostgreSQL and a recent version of Rails.
2
2
 
3
- ⚠️ At the moment Gouda is only used internally at Cheddar. We do not provide support for it, nor do we accept
4
- issues or feature requests. This is likely to change in the future.
3
+ > [!CAUTION]
4
+ > At the moment Gouda is only used internally at Cheddar. Any support to external parties is on best-effort
5
+ > basis. While we are happy to see issues and pull requests, we can't guarantee that those will be addressed
6
+ > quickly. The library does receive rapid updates which may break your application if you come to depend on
7
+ > the library. That is to be expected.
5
8
 
6
9
  ## Installation
7
10
 
@@ -11,10 +14,10 @@ $ bundle install
11
14
  $ bin/rails g gouda:install
12
15
  ```
13
16
 
14
- Gouda is build as a lightweight alternative to [good_job](https://github.com/bensheldon/good_job) and has been created before [solid_queue.](https://github.com/rails/solid_queue/)
15
- It is _smaller_ than solid_queue though.
16
-
17
- It was designed to enable job processing using `SELECT ... FOR UPDATE SKIP LOCKED` on Postgres so that we could use pg_bouncer in our system setup.
17
+ Gouda is a lightweight alternative to [good_job](https://github.com/bensheldon/good_job) and [solid_queue.](https://github.com/rails/solid_queue/) - while
18
+ more similar to the latter. It has been created prior to solid_queue and is smaller. It was designed to enable job processing using `SELECT ... FOR UPDATE SKIP LOCKED`
19
+ on Postgres so that we could use pg_bouncer in our system setup. We have also observed that `SKIP LOCKED` causes less load on our database than advisory locking,
20
+ especially as queue depths would grow.
18
21
 
19
22
 
20
23
  ## Key concepts in Gouda: Workload
data/gouda.gemspec CHANGED
@@ -7,17 +7,17 @@ Gem::Specification.new do |spec|
7
7
  spec.description = "Job Scheduler for Rails and PostgreSQL"
8
8
  spec.authors = ["Sebastian van Hesteren", "Julik Tarkhanov"]
9
9
  spec.email = ["sebastian@cheddar.me", "me@julik.nl"]
10
- spec.homepage = "https://rubygems.org/gems/gouda"
10
+ spec.homepage = "https://github.com/cheddar-me/gouda"
11
11
  spec.license = "MIT"
12
12
  spec.required_ruby_version = Gem::Requirement.new(">= 2.7.0")
13
13
  spec.require_paths = ["lib"]
14
14
 
15
- spec.metadata["homepage_uri"] = spec.homepage
16
- spec.metadata["source_code_uri"] = "https://github.com/cheddar-me/gouda"
15
+ spec.metadata["homepage_uri"] =
16
+ spec.metadata["source_code_uri"] = spec.homepage
17
17
  spec.metadata["changelog_uri"] = "https://github.com/cheddar-me/gouda/CHANGELOG.md"
18
18
 
19
19
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
20
- `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
20
+ `git ls-files -z`.split("\x0")
21
21
  end
22
22
 
23
23
  spec.add_dependency "activerecord", "~> 7"
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module ActiveJob # :nodoc:
4
- module QueueAdapters # :nodoc:
5
- class GoudaAdapter < Gouda::Adapter
6
- end
7
- end
3
+ # The sole purpose of this class is so that you can do
4
+ # `config.active_job.queue_adapter = :gouda` in your Rails
5
+ # config, as Rails insists on resolving the adapter module
6
+ # name from the symbol automatically. If Rails ever allows
7
+ # us to "register" an adapter to a symbol this module can
8
+ # be removed later.
9
+ class ActiveJob::QueueAdapters::GoudaAdapter < Gouda::Adapter
8
10
  end
data/lib/gouda/adapter.rb CHANGED
@@ -57,10 +57,11 @@ class Gouda::Adapter
57
57
  # We can't tell Postgres to ignore conflicts on _both_ the scheduler key and the enqueue concurrency key but not on
58
58
  # the ID - it is either "all indexes" or "just one", but never "this index and that index". MERGE https://www.postgresql.org/docs/current/sql-merge.html
59
59
  # is in theory capable of solving this but let's not complicate things all to hastily, the hour is getting late
60
+ scheduler_key = active_job.try(:executions) == 0 ? active_job.scheduler_key : nil # only enforce scheduler key on first workload
60
61
  {
61
62
  active_job_id: active_job.job_id, # Multiple jobs can have the same ID due to retries, job-iteration etc.
62
63
  scheduled_at: active_job.scheduled_at || t_now,
63
- scheduler_key: active_job.scheduler_key, # So that the scheduler_key gets retained between retries
64
+ scheduler_key: scheduler_key,
64
65
  priority: active_job.priority,
65
66
  execution_concurrency_key: extract_execution_concurrency_key(active_job),
66
67
  enqueue_concurrency_key: extract_enqueue_concurrency_key(active_job),
data/lib/gouda/bulk.rb CHANGED
@@ -1,6 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gouda
4
+ # Inside this call, all `perform_later` calls on ActiveJob subclasses
5
+ # (including mailers) will be buffered. The call is reentrant, so you
6
+ # can have multiple `in_bulk` calls with arbitrary nesting. At the end
7
+ # of the block, the buffered jobs will be enqueued using their respective
8
+ # adapters. If an adapter supports `enqueue_all` (Sidekiq does in recent
9
+ # releases of Rails, for example), this functionality will be used. This
10
+ # method is especially useful when doing things such as mass-emails, or
11
+ # maintenance tasks where a large swath of jobs gets enqueued at once.
12
+ #
13
+ # @example
14
+ # Gouda.in_bulk do
15
+ # User.recently_joined.find_each do |recently_joined_user|
16
+ # GreetingJob.perform_later(recently_joined_user)
17
+ # end
18
+ # end
19
+ # @return [Object] the return value of the block
4
20
  def self.in_bulk(&blk)
5
21
  if Thread.current[:gouda_bulk_buffer].nil?
6
22
  Thread.current[:gouda_bulk_buffer] = []
@@ -1,44 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gouda
4
+ # A queue constraint supports just one method, `to_sql`, which returns
5
+ # a condition based on the `queue_name` value of the `gouda_workloads`
6
+ # table. The minimal constraint is just a no-op - it allows execution
7
+ # of jobs from all queues in the system.
4
8
  module AnyQueue
5
9
  def self.to_sql
6
10
  "1=1"
7
11
  end
8
12
  end
9
13
 
14
+ # Allows execution of jobs only from specified queues
15
+ # For example, if you have a queue named "gpu", and you run
16
+ # jobs requiring a GPU on this queue, on your worker script
17
+ # running on GPU-equipped machines you could use
18
+ # `OnlyQueuesConstraint.new([:gpu])`
10
19
  class OnlyQueuesConstraint < Struct.new(:queue_names)
11
20
  def to_sql
12
21
  placeholders = (["?"] * queue_names.length).join(",")
13
- ActiveRecord::Base.sanitize_sql_array([<<~SQL, *queue_names])
22
+ Gouda::Workload.sanitize_sql_array([<<~SQL, *queue_names])
14
23
  queue_name IN (#{placeholders})
15
24
  SQL
16
25
  end
17
26
  end
18
27
 
28
+ # Allows execution of jobs from queues except the given ones
29
+ # For example, if you have a queue named "emails" which is time-critical,
30
+ # on all other machines your worker script can specify
31
+ # `ExceptQueueConstraint.new([:emails])`
19
32
  class ExceptQueueConstraint < Struct.new(:queue_names)
20
33
  def to_sql
21
34
  placeholders = (["?"] * queue_names.length).join(",")
22
- ActiveRecord::Base.sanitize_sql_array([<<~SQL, *queue_names])
35
+ Gouda::Workload.sanitize_sql_array([<<~SQL, *queue_names])
23
36
  queue_name NOT IN (#{placeholders})
24
37
  SQL
25
38
  end
26
39
  end
27
40
 
28
- def self.parse_queue_constraint(constraint_str_from_envvar)
29
- parsed = queue_parser(constraint_str_from_envvar)
30
- if parsed[:include]
31
- OnlyQueuesConstraint.new(parsed[:include])
32
- elsif parsed[:exclude]
33
- ExceptQueueConstraint.new(parsed[:exclude])
34
- else
35
- AnyQueue
36
- end
37
- end
38
-
39
- # Parse a string representing a group of queues into a more readable data
40
- # structure.
41
- # @param string [String] Queue string
41
+ # Parse a string representing a group of queues into a queue constraint
42
+ # Note that this works similar to good_job. For example, the
43
+ # constraints do not necessarily compose all that well.
44
+ #
45
+ # @param queue_constraint_str[String] Queue string
42
46
  # @return [Hash]
43
47
  # How to match a given queue. It can have the following keys and values:
44
48
  # - +{ all: true }+ indicates that all queues match.
@@ -49,8 +53,8 @@ module Gouda
49
53
  # @example
50
54
  # Gouda::QueueConstraints.queue_parser('-queue1,queue2')
51
55
  # => { exclude: [ 'queue1', 'queue2' ] }
52
- def self.queue_parser(string)
53
- string = string.presence || "*"
56
+ def self.parse_queue_constraint(queue_constraint_str)
57
+ string = queue_constraint_str.presence || "*"
54
58
 
55
59
  case string.first
56
60
  when "-"
@@ -63,11 +67,11 @@ module Gouda
63
67
  queues = string.split(",").map(&:strip)
64
68
 
65
69
  if queues.include?("*")
66
- {all: true}
70
+ AnyQueue
67
71
  elsif exclude_queues
68
- {exclude: queues}
72
+ ExceptQueueConstraint.new([queues])
69
73
  else
70
- {include: queues}
74
+ OnlyQueuesConstraint.new([queues])
71
75
  end
72
76
  end
73
77
  end
data/lib/gouda/railtie.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gouda
4
+ UNINITIALISED_DATABASE_EXCEPTIONS = [ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished]
5
+
4
6
  class Railtie < Rails::Railtie
5
7
  rake_tasks do
6
8
  task preload: :setup do
@@ -54,7 +56,7 @@ module Gouda
54
56
  Gouda::Scheduler.build_scheduler_entries_list!
55
57
  begin
56
58
  Gouda::Scheduler.upsert_workloads_from_entries_list!
57
- rescue ActiveRecord::NoDatabaseError
59
+ rescue *Gouda::UNINITIALISED_DATABASE_EXCEPTIONS
58
60
  # Do nothing. On a freshly checked-out Rails app, running even unrelated Rails tasks
59
61
  # (such as asset compilation) - or, more importantly, initial db:create -
60
62
  # will cause a NoDatabaseError, as this is a chicken-and-egg problem. That error
@@ -11,6 +11,9 @@ module Gouda::Scheduler
11
11
  [name, interval_seconds, cron, job_class].compact.join("_")
12
12
  end
13
13
 
14
+ # Tells when this particular task should next run
15
+ #
16
+ # @return [Time]
14
17
  def next_at
15
18
  if interval_seconds
16
19
  first_existing = Gouda::Workload.where(scheduler_key: scheduler_key).where("scheduled_at > NOW()").order("scheduled_at DESC").pluck(:scheduled_at).first
@@ -22,6 +25,9 @@ module Gouda::Scheduler
22
25
  end
23
26
  end
24
27
 
28
+ # Builds the ActiveJob which can be enqueued for this entry
29
+ #
30
+ # @return [ActiveJob::Base]
25
31
  def build_active_job
26
32
  next_at = self.next_at
27
33
  return unless next_at
data/lib/gouda/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gouda
4
- VERSION = "0.1.3"
4
+ VERSION = "0.1.5"
5
5
  end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gouda/test_helper"
4
+
5
+ class GoudaConcurrencyExtensionTest < ActiveSupport::TestCase
6
+ include AssertHelper
7
+ class TestJobWithoutConcurrency < ActiveJob::Base
8
+ self.queue_adapter = Gouda::Adapter.new
9
+ end
10
+
11
+ class TestJobWithPerformConcurrency < ActiveJob::Base
12
+ self.queue_adapter = Gouda::Adapter.new
13
+ include Gouda::ActiveJobExtensions::Concurrency
14
+ gouda_control_concurrency_with(perform_limit: 1)
15
+
16
+ def perform(*args)
17
+ end
18
+ end
19
+
20
+ setup do
21
+ @adapter ||= Gouda::Adapter.new
22
+ Gouda::Railtie.initializers.each(&:run)
23
+ end
24
+
25
+ test "gouda_control_concurrency_with with just perform_limit sets a perform concurrency key and no enqueue concurrency key" do
26
+ job = TestJobWithPerformConcurrency.new
27
+ assert_nil job.enqueue_concurrency_key
28
+ assert job.execution_concurrency_key
29
+ end
30
+
31
+ test "gouda_control_concurrency_with with just perform_limit makes the perform concurrency key dependent on job params" do
32
+ job1 = TestJobWithPerformConcurrency.new(1, 2, :something)
33
+ assert job1.execution_concurrency_key
34
+
35
+ job2 = TestJobWithPerformConcurrency.new(1, 2, :something)
36
+ assert_equal job2.execution_concurrency_key, job1.execution_concurrency_key
37
+
38
+ job3 = TestJobWithPerformConcurrency.new(1, 2, :something_else)
39
+ refute_equal job3.execution_concurrency_key, job1.execution_concurrency_key
40
+ end
41
+
42
+ class TestJobWithCommonConcurrency < ActiveJob::Base
43
+ self.queue_adapter = Gouda::Adapter.new
44
+ include Gouda::ActiveJobExtensions::Concurrency
45
+ gouda_control_concurrency_with(total_limit: 1)
46
+
47
+ def perform(*args)
48
+ end
49
+ end
50
+
51
+ test "gouda_control_concurrency_with with total_limit sets a perform concurrency key and an enqueue concurrency key" do
52
+ job = TestJobWithCommonConcurrency.new
53
+ assert job.enqueue_concurrency_key
54
+ assert job.execution_concurrency_key
55
+ end
56
+
57
+ test "gouda_control_concurrency_with with total_limit makes the perform concurrency key dependent on job params" do
58
+ job1 = TestJobWithCommonConcurrency.new(1, 2, :something)
59
+ assert job1.execution_concurrency_key
60
+
61
+ job2 = TestJobWithCommonConcurrency.new(1, 2, :something)
62
+ assert_equal job2.execution_concurrency_key, job1.execution_concurrency_key
63
+
64
+ job3 = TestJobWithCommonConcurrency.new(1, 2, :something_else)
65
+ refute_equal job3.execution_concurrency_key, job1.execution_concurrency_key
66
+ end
67
+
68
+ test "gouda_control_concurrency_with with total_limit makes the enqueue concurrency key dependent on job params" do
69
+ job1 = TestJobWithCommonConcurrency.new(1, 2, :something)
70
+ assert job1.enqueue_concurrency_key
71
+
72
+ job2 = TestJobWithCommonConcurrency.new(1, 2, :something)
73
+ assert_equal job2.enqueue_concurrency_key, job1.enqueue_concurrency_key
74
+
75
+ job3 = TestJobWithCommonConcurrency.new(1, 2, :something_else)
76
+ refute_equal job3.enqueue_concurrency_key, job1.enqueue_concurrency_key
77
+ end
78
+
79
+ class TestJobWithEnqueueConcurrency < ActiveJob::Base
80
+ self.queue_adapter = Gouda::Adapter.new
81
+ include Gouda::ActiveJobExtensions::Concurrency
82
+ gouda_control_concurrency_with(enqueue_limit: 1)
83
+
84
+ def perform(*args)
85
+ end
86
+ end
87
+
88
+ test "gouda_control_concurrency_with with enqueue_limit sets a perform concurrency key and an enqueue concurrency key" do
89
+ job = TestJobWithEnqueueConcurrency.new
90
+ assert job.enqueue_concurrency_key
91
+ assert_nil job.execution_concurrency_key
92
+ end
93
+
94
+ test "gouda_control_concurrency_with with enqueue_limit makes the enqueue concurrency key dependent on job params" do
95
+ job1 = TestJobWithEnqueueConcurrency.new(1, 2, :something)
96
+ assert job1.enqueue_concurrency_key
97
+
98
+ job2 = TestJobWithEnqueueConcurrency.new(1, 2, :something)
99
+ assert_equal job2.enqueue_concurrency_key, job1.enqueue_concurrency_key
100
+
101
+ job3 = TestJobWithEnqueueConcurrency.new(1, 2, :something_else)
102
+ refute_equal job3.enqueue_concurrency_key, job1.enqueue_concurrency_key
103
+ end
104
+
105
+ class TestJobWithCustomKey < ActiveJob::Base
106
+ self.queue_adapter = Gouda::Adapter.new
107
+ include Gouda::ActiveJobExtensions::Concurrency
108
+ gouda_control_concurrency_with total_limit: 1, key: "42"
109
+ end
110
+
111
+ test "can use an arbitrary string as the custom key" do
112
+ job = TestJobWithCustomKey.new
113
+ assert_equal "42", job.enqueue_concurrency_key
114
+ assert_equal "42", job.execution_concurrency_key
115
+ end
116
+
117
+ class TestJobWithCustomKeyProc < ActiveJob::Base
118
+ self.queue_adapter = Gouda::Adapter.new
119
+ include Gouda::ActiveJobExtensions::Concurrency
120
+ gouda_control_concurrency_with total_limit: 1, key: -> { @ivar }
121
+
122
+ def initialize(...)
123
+ super
124
+ @ivar = "123"
125
+ end
126
+ end
127
+
128
+ test "can use a proc that gets instance_exec'd as the custom key" do
129
+ job = TestJobWithCustomKeyProc.new
130
+ assert_equal "123", job.enqueue_concurrency_key
131
+ assert_equal "123", job.execution_concurrency_key
132
+ end
133
+
134
+ class TestJobWithWithUnconfiguredConcurrency < ActiveJob::Base
135
+ self.queue_adapter = Gouda::Adapter.new
136
+ include Gouda::ActiveJobExtensions::Concurrency
137
+ end
138
+
139
+ test "validates arguments" do
140
+ assert_raises ArgumentError do
141
+ TestJobWithWithUnconfiguredConcurrency.gouda_control_concurrency_with
142
+ end
143
+
144
+ assert_raises ArgumentError do
145
+ TestJobWithWithUnconfiguredConcurrency.gouda_control_concurrency_with total_limit: 2
146
+ end
147
+
148
+ assert_raises ArgumentError do
149
+ TestJobWithWithUnconfiguredConcurrency.gouda_control_concurrency_with perform_limit: 2
150
+ end
151
+
152
+ assert_raises ArgumentError do
153
+ TestJobWithWithUnconfiguredConcurrency.gouda_control_concurrency_with enqueue_limit: 2
154
+ end
155
+
156
+ assert_raises ArgumentError do
157
+ TestJobWithWithUnconfiguredConcurrency.gouda_control_concurrency_with total_limit: 2, bollocks: 4
158
+ end
159
+ end
160
+ end