gouda 0.1.4 → 0.1.7

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: cbebec9ae881152e66d8a8e763d74e2314c6694cc46a1b547323bb4fe01fd505
4
- data.tar.gz: f9836ba9594cf2473485efbe6be8dd6b001e0e514315cb84170a62d525adbaf3
3
+ metadata.gz: ec67da4c5d0dc236c02b65c770e87fe4a0a967eef8df50f0cb4ae452077f3bdb
4
+ data.tar.gz: 6089a03c99a944fcfedbe623810c72b513c52e2fe9afd698904ccab1f7cc3aa3
5
5
  SHA512:
6
- metadata.gz: 2bf05ef7dcfe8cc682f54da18f8ad8a415f5db3f103928188ad04ab714f4a2799b4998b25c5d53432776d4f913e22fe0a761bd1fe11ea2eb17763f89201cb809
7
- data.tar.gz: 636a2aa356b4fed48dece844dec2f26d46a430d6e4a0d956c56d5c5121d30f8d3fb8f87174b82e4d7ab6a58f3e0bcd99d3e07c18c631390cc6233b8cac2d48ba
6
+ metadata.gz: bb445a3c45a28d47f29124775f8f2f8c2cc9be8c6fb1069c8c3f1d00eca8269ca53d897c2cb069962bec90ee6b8b9916af133558b0692a3a64d46e3666fbb6f4
7
+ data.tar.gz: 3e82a95cf5f6107a8f9f7874a93d4bbbc58ac9a6badb2f684edca870cf6fbd3512be37299b192dcbb7a68a4be9fb638ea89af88625ae70459f1334a03b541407
data/CHANGELOG.md CHANGED
@@ -23,3 +23,16 @@
23
23
  - Reduce logging in local test runs.
24
24
  - Bump local ruby version to 3.3.3
25
25
 
26
+ ## [0.1.5] - 2023-06-18
27
+
28
+ - Update documentation
29
+ - Don't pass on scheduler keys to retries
30
+
31
+ ## [0.1.6] - 2023-06-18
32
+
33
+ - Fix: don't upsert workloads twice when starting Gouda.
34
+ - Add back in Appsignal calls
35
+
36
+ ## [0.1.7] - 2023-06-21
37
+
38
+ - Separate all instrumentation to use ActiveSupport::Notification
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,13 +7,13 @@ 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
@@ -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),
@@ -82,7 +83,7 @@ class Gouda::Adapter
82
83
  # Use batches of 500 so that we do not exceed the maximum statement size or do not create a transaction for the
83
84
  # insert which times out
84
85
  inserted_ids_and_positions = bulk_insert_attributes.each_slice(500).flat_map do |chunk|
85
- ActiveSupport::Notifications.instrument("insert_all.gouda", {n_rows: chunk.size}) do |payload|
86
+ Gouda.instrument(:insert_all, n_rows: chunk.size) do |payload|
86
87
  rows = Gouda::Workload.insert_all(chunk, returning: [:id, :position_in_bulk])
87
88
  payload[:inserted_jobs] = rows.length
88
89
  payload[:rejected_jobs] = chunk.size - rows.length
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.4"
4
+ VERSION = "0.1.7"
5
5
  end
data/lib/gouda/worker.rb CHANGED
@@ -163,7 +163,8 @@ module Gouda
163
163
  # Find jobs which just hung and clean them up (mark them as "finished" and enqueue replacement workloads if possible)
164
164
  Gouda::Workload.reap_zombie_workloads
165
165
  rescue => e
166
- # Appsignal.add_exception(e)
166
+ Gouda.instrument(:exception, exception: e)
167
+
167
168
  warn "Uncaught exception during housekeeping (#{e.class} - #{e}"
168
169
  end
169
170
 
@@ -63,7 +63,7 @@ class Gouda::Workload < ActiveRecord::Base
63
63
  workload.with_lock("FOR UPDATE SKIP LOCKED") do
64
64
  Gouda.logger.info { "Reviving (re-enqueueing) Gouda workload #{workload.id} after interruption" }
65
65
 
66
- # Appsignal.increment_counter("gouda_workloads_revived", 1, job_class: workload.active_job_class_name)
66
+ Gouda.instrument(:workloads_revived_counter, size: 1, job_class: workload.active_job_class_name)
67
67
 
68
68
  interrupted_at = workload.last_execution_heartbeat_at
69
69
  workload.update!(state: "finished", interrupted_at: interrupted_at, last_execution_heartbeat_at: Time.now.utc, execution_finished_at: Time.now.utc)
@@ -110,7 +110,7 @@ class Gouda::Workload < ActiveRecord::Base
110
110
  .lock("FOR UPDATE SKIP LOCKED")
111
111
  .limit(1)
112
112
 
113
- _first_available_workload = ActiveSupport::Notifications.instrument("checkout_and_lock_one.gouda", {queue_constraint: queue_constraint.to_sql}) do |payload|
113
+ _first_available_workload = Gouda.instrument(:checkout_and_lock_one, queue_constraint: queue_constraint.to_sql) do |payload|
114
114
  payload[:condition_sql] = jobs.to_sql
115
115
  payload[:retried_checkouts_due_to_concurrent_exec] = 0
116
116
  uncached do # Necessary because we SELECT with a clock_timestamp() which otherwise gets cached by ActiveRecord query cache
data/lib/gouda.rb CHANGED
@@ -46,8 +46,6 @@ module Gouda
46
46
  end
47
47
 
48
48
  def self.start
49
- Gouda::Scheduler.upsert_workloads_from_entries_list!
50
-
51
49
  queue_constraint = if ENV["GOUDA_QUEUES"]
52
50
  Gouda.parse_queue_constraint(ENV["GOUDA_QUEUES"])
53
51
  else
@@ -72,6 +70,11 @@ module Gouda
72
70
  Gouda.config.logger
73
71
  end
74
72
 
73
+ def self.instrument(channel, **options, &block)
74
+ ActiveSupport::Notifications.instrument("#{channel}.gouda", **options, &block)
75
+ end
76
+
77
+
75
78
  def self.create_tables(active_record_schema)
76
79
  active_record_schema.create_enum :gouda_workload_state, %w[enqueued executing finished]
77
80
  active_record_schema.create_table :gouda_workloads, id: :uuid do |t|
@@ -645,6 +645,15 @@ class GoudaTest < ActiveSupport::TestCase
645
645
  assert_equal ["did-run"], Thread.current[:gouda_test_side_effects]
646
646
  end
647
647
 
648
+ test "instrumentation" do
649
+ payload = subscribed_notification_for("workloads_revived_counter.gouda") do
650
+ Gouda.instrument(:workloads_revived_counter, size: 1, job_class: "test_class")
651
+ end
652
+
653
+ assert_equal "test_class", payload[:job_class]
654
+ assert_equal 1, payload[:size]
655
+ end
656
+
648
657
  def sample_description(sample)
649
658
  values = sample.map(&:to_f).sort
650
659
 
@@ -24,8 +24,6 @@ class GoudaSchedulerTest < ActiveSupport::TestCase
24
24
  class MegaError < StandardError
25
25
  end
26
26
 
27
- gouda_control_concurrency_with(enqueue_limit: 1, key: -> { self.class.to_s })
28
-
29
27
  retry_on StandardError, wait: :polynomially_longer, attempts: 5
30
28
  retry_on Gouda::InterruptError, wait: 0, attempts: 5
31
29
  retry_on MegaError, attempts: 3, wait: 0
@@ -55,6 +53,29 @@ class GoudaSchedulerTest < ActiveSupport::TestCase
55
53
  assert Gouda::Workload.count > 3
56
54
  end
57
55
 
56
+ test "retries do not have a scheduler_key" do
57
+ tab = {
58
+ second_minutely: {
59
+ cron: "*/1 * * * * *", # every second
60
+ class: "GoudaSchedulerTest::FailingJob"
61
+ }
62
+ }
63
+
64
+ assert_nothing_raised do
65
+ Gouda::Scheduler.build_scheduler_entries_list!(tab)
66
+ Gouda::Scheduler.upsert_workloads_from_entries_list!
67
+ end
68
+
69
+ assert_equal 1, Gouda::Workload.enqueued.count
70
+ assert_equal "second_minutely_*/1 * * * * *_GoudaSchedulerTest::FailingJob", Gouda::Workload.enqueued.first.scheduler_key
71
+ sleep(2)
72
+ Gouda::Workload.checkout_and_perform_one(executing_on: "Unit test")
73
+
74
+ assert_equal 1, Gouda::Workload.retried.reload.count
75
+ assert_nil Gouda::Workload.retried.first.scheduler_key
76
+ assert_equal "enqueued", Gouda::Workload.retried.first.state
77
+ end
78
+
58
79
  test "re-inserts the next subsequent job after executing the queued one" do
59
80
  tab = {
60
81
  second_minutely: {
@@ -67,4 +67,17 @@ class ActiveSupport::TestCase
67
67
  end
68
68
  end
69
69
  end
70
+
71
+ def subscribed_notification_for(notification)
72
+ payload = nil
73
+ subscription = ActiveSupport::Notifications.subscribe notification do |name, start, finish, id, _payload|
74
+ payload = _payload
75
+ end
76
+
77
+ yield
78
+
79
+ ActiveSupport::Notifications.unsubscribe(subscription)
80
+
81
+ return payload
82
+ end
70
83
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gouda
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian van Hesteren
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2024-06-14 00:00:00.000000000 Z
12
+ date: 2024-06-21 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activerecord
@@ -166,12 +166,12 @@ files:
166
166
  - test/gouda/workload_test.rb
167
167
  - test/support/assert_helper.rb
168
168
  - tmp/.keep
169
- homepage: https://rubygems.org/gems/gouda
169
+ homepage: https://github.com/cheddar-me/gouda
170
170
  licenses:
171
171
  - MIT
172
172
  metadata:
173
- homepage_uri: https://rubygems.org/gems/gouda
174
173
  source_code_uri: https://github.com/cheddar-me/gouda
174
+ homepage_uri: https://github.com/cheddar-me/gouda
175
175
  changelog_uri: https://github.com/cheddar-me/gouda/CHANGELOG.md
176
176
  post_install_message:
177
177
  rdoc_options: []
@@ -188,7 +188,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
188
188
  - !ruby/object:Gem::Version
189
189
  version: '0'
190
190
  requirements: []
191
- rubygems_version: 3.5.11
191
+ rubygems_version: 3.5.13
192
192
  signing_key:
193
193
  specification_version: 4
194
194
  summary: Job Scheduler