gouda 0.1.12 → 0.1.13
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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +1 -2
- data/.standard.yml +1 -0
- data/CHANGELOG.md +34 -28
- data/gouda.gemspec +2 -2
- data/lib/gouda/adapter.rb +1 -1
- data/lib/gouda/scheduler.rb +15 -2
- data/lib/gouda/version.rb +1 -1
- data/lib/gouda/workload.rb +34 -6
- data/lib/gouda.rb +6 -8
- data/test/gouda/scheduler_test.rb +39 -0
- data/test/gouda/test_helper.rb +3 -14
- metadata +5 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: eb8694c3440600f405fc75ab09fa91b735d9ddaf2268ce3da37197f2cc21cd37
|
4
|
+
data.tar.gz: adb08446c066d226e45bddfb17274cd13279661eba771dcb3acf14f72b8f0702
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b9c17fa5785b888213ad8ad83190c2728de7569bd9be81e4e220d43f5ad74a2e109680da713a0e90780b2a2244bf410fcc9d175cd01b5335d5881abebc9cac6f
|
7
|
+
data.tar.gz: 683e667a73971a47043374e01ed2fb8e06f157b98e010a3cdf7ca4f153c7383ca37535f3533a034dee08dbc2779b2def8b4828aa1f76c3708838fd0811e2c991
|
data/.github/workflows/ci.yml
CHANGED
data/.standard.yml
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
ruby_version: 3.1
|
data/CHANGELOG.md
CHANGED
@@ -1,58 +1,64 @@
|
|
1
1
|
## [Unreleased]
|
2
2
|
|
3
|
-
## [0.1.0] - 2024-06-10
|
4
3
|
|
5
|
-
-
|
4
|
+
## [0.1.13] - 2024-09-03
|
6
5
|
|
7
|
-
|
6
|
+
- Ensure we won't execute workloads which were scheduled but are no longer present in the cron table entries.
|
8
7
|
|
9
|
-
|
8
|
+
## [0.1.12] - 2024-07-03
|
10
9
|
|
11
|
-
|
10
|
+
- When doing polling, suppress DEBUG-level messages. This will stop Gouda spamming the logs with SQL in dev/test environments.
|
12
11
|
|
13
|
-
|
12
|
+
## [0.1.11] - 2024-07-03
|
14
13
|
|
15
|
-
|
14
|
+
- Fix: make sure the Gouda logger config does not get used during Rails initialization
|
16
15
|
|
17
|
-
|
16
|
+
## [0.1.10] - 2024-07-03
|
18
17
|
|
19
|
-
|
18
|
+
- Fix: remove logger overrides that Gouda should install, as this causes problems for Rails apps hosting Gouda
|
20
19
|
|
21
|
-
|
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
|
20
|
+
## [0.1.9] - 2024-06-26
|
25
21
|
|
26
|
-
|
22
|
+
- Fix: cleanup_preserved_jobs_before in Gouda::Workload.prune now points to Gouda.config
|
27
23
|
|
28
|
-
-
|
29
|
-
|
24
|
+
## [0.1.8] - 2024-06-21
|
25
|
+
|
26
|
+
- Move some missed instrumentations to Gouda.instrument
|
27
|
+
|
28
|
+
## [0.1.7] - 2024-06-21
|
29
|
+
|
30
|
+
- Separate all instrumentation to use ActiveSupport::Notification
|
30
31
|
|
31
32
|
## [0.1.6] - 2024-06-18
|
32
33
|
|
33
34
|
- Fix: don't upsert workloads twice when starting Gouda.
|
34
35
|
- Add back in Appsignal calls
|
35
36
|
|
36
|
-
## [0.1.
|
37
|
+
## [0.1.5] - 2024-06-18
|
37
38
|
|
38
|
-
-
|
39
|
+
- Update documentation
|
40
|
+
- Don't pass on scheduler keys to retries
|
39
41
|
|
40
|
-
## [0.1.
|
42
|
+
## [0.1.4] - 2024-06-14
|
41
43
|
|
42
|
-
-
|
44
|
+
- Rescue NoDatabaseError at scheduler update.
|
45
|
+
- Include tests in gem, for sake of easier debugging.
|
46
|
+
- Reduce logging in local test runs.
|
47
|
+
- Bump local ruby version to 3.3.3
|
43
48
|
|
44
|
-
## [0.1.
|
49
|
+
## [0.1.3] - 2024-06-11
|
45
50
|
|
46
|
-
-
|
51
|
+
- Allow the Rails app to boot even if there is no database yet
|
47
52
|
|
48
|
-
## [0.1.
|
53
|
+
## [0.1.2] - 2024-06-11
|
49
54
|
|
50
|
-
-
|
55
|
+
- Updated readme and method renaming in Scheduler
|
51
56
|
|
52
|
-
## [0.1.
|
57
|
+
## [0.1.1] - 2024-06-10
|
53
58
|
|
54
|
-
- Fix
|
59
|
+
- Fix support for older ruby versions until 2.7
|
55
60
|
|
56
|
-
## [0.1.
|
61
|
+
## [0.1.0] - 2024-06-10
|
62
|
+
|
63
|
+
- Initial release
|
57
64
|
|
58
|
-
- When doing polling, suppress DEBUG-level messages. This will stop Gouda spamming the logs with SQL in dev/test environments.
|
data/gouda.gemspec
CHANGED
@@ -9,10 +9,10 @@ Gem::Specification.new do |spec|
|
|
9
9
|
spec.email = ["sebastian@cheddar.me", "me@julik.nl"]
|
10
10
|
spec.homepage = "https://github.com/cheddar-me/gouda"
|
11
11
|
spec.license = "MIT"
|
12
|
-
spec.required_ruby_version = Gem::Requirement.new(">=
|
12
|
+
spec.required_ruby_version = Gem::Requirement.new(">= 3.1.0")
|
13
13
|
spec.require_paths = ["lib"]
|
14
14
|
|
15
|
-
spec.metadata["homepage_uri"] =
|
15
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
16
16
|
spec.metadata["source_code_uri"] = spec.homepage
|
17
17
|
spec.metadata["changelog_uri"] = "https://github.com/cheddar-me/gouda/CHANGELOG.md"
|
18
18
|
|
data/lib/gouda/adapter.rb
CHANGED
@@ -57,7 +57,7 @@ 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
|
+
scheduler_key = (active_job.try(:executions) == 0) ? active_job.scheduler_key : nil # only enforce scheduler key on first workload
|
61
61
|
{
|
62
62
|
active_job_id: active_job.job_id, # Multiple jobs can have the same ID due to retries, job-iteration etc.
|
63
63
|
scheduled_at: active_job.scheduled_at || t_now,
|
data/lib/gouda/scheduler.rb
CHANGED
@@ -87,7 +87,7 @@ module Gouda::Scheduler
|
|
87
87
|
# @return Array[Entry]
|
88
88
|
def self.build_scheduler_entries_list!(cron_table_hash = nil)
|
89
89
|
Gouda.logger.info "Updating scheduled workload entries..."
|
90
|
-
if cron_table_hash.
|
90
|
+
if cron_table_hash.nil? # An empty hash indicates that an empty crontab will be loaded
|
91
91
|
config_from_rails = Rails.application.config.try(:gouda)
|
92
92
|
|
93
93
|
cron_table_hash = if config_from_rails.present?
|
@@ -106,6 +106,9 @@ module Gouda::Scheduler
|
|
106
106
|
params_with_defaults = defaults.merge(cron_entry_params)
|
107
107
|
Entry.new(name: name, **params_with_defaults)
|
108
108
|
end
|
109
|
+
@known_scheduler_keys = Set.new(@cron_table.map(&:scheduler_key))
|
110
|
+
|
111
|
+
@cron_table
|
109
112
|
end
|
110
113
|
|
111
114
|
# Once a workload has finished (doesn't matter whether it raised an exception
|
@@ -132,6 +135,14 @@ module Gouda::Scheduler
|
|
132
135
|
@cron_table || []
|
133
136
|
end
|
134
137
|
|
138
|
+
# Returns the set of known scheduler keys that may be present in the workloads table and are defined
|
139
|
+
# by the current entries.
|
140
|
+
#
|
141
|
+
# @return Set[String]
|
142
|
+
def self.known_scheduler_keys
|
143
|
+
@known_scheduler_keys || Set.new
|
144
|
+
end
|
145
|
+
|
135
146
|
# Will upsert (`INSERT ... ON CONFLICT UPDATE`) workloads for all entries which are in the scheduler entries
|
136
147
|
# table (the table needs to be read or hydrated first using `build_scheduler_entries_list!`). This is done
|
137
148
|
# in a transaction. Any workloads which have been previously inserted from the scheduled entries, but no
|
@@ -143,9 +154,11 @@ module Gouda::Scheduler
|
|
143
154
|
def self.upsert_workloads_from_entries_list!
|
144
155
|
table_entries = @cron_table || []
|
145
156
|
|
146
|
-
# Remove any cron keyed workloads which no longer match config-wise
|
157
|
+
# Remove any cron keyed workloads which no longer match config-wise.
|
158
|
+
# We do this to keep things clean (but it is not enough, an extra guard is needed in Workload checkout)
|
147
159
|
known_keys = table_entries.map(&:scheduler_key).uniq
|
148
160
|
Gouda::Workload.transaction do
|
161
|
+
# We do this to keep things a bit clean
|
149
162
|
Gouda::Workload.where.not(scheduler_key: known_keys).delete_all
|
150
163
|
|
151
164
|
# Insert the next iteration for every "next" entry in the crontab.
|
data/lib/gouda/version.rb
CHANGED
data/lib/gouda/workload.rb
CHANGED
@@ -95,14 +95,14 @@ class Gouda::Workload < ActiveRecord::Base
|
|
95
95
|
AND NOT EXISTS (
|
96
96
|
SELECT NULL
|
97
97
|
FROM #{quoted_table_name} AS concurrent
|
98
|
-
WHERE concurrent.state =
|
98
|
+
WHERE concurrent.state = 'executing'
|
99
99
|
AND concurrent.execution_concurrency_key = workloads.execution_concurrency_key
|
100
100
|
)
|
101
101
|
AND workloads.scheduled_at <= clock_timestamp()
|
102
102
|
SQL
|
103
103
|
# Enter a txn just to mark this job as being executed "by us". This allows us to avoid any
|
104
104
|
# locks during execution itself, including advisory locks
|
105
|
-
|
105
|
+
workloads = Gouda::Workload
|
106
106
|
.select("workloads.*")
|
107
107
|
.from("#{quoted_table_name} AS workloads")
|
108
108
|
.where(where_query)
|
@@ -111,13 +111,41 @@ class Gouda::Workload < ActiveRecord::Base
|
|
111
111
|
.limit(1)
|
112
112
|
|
113
113
|
_first_available_workload = ActiveSupport::Notifications.instrument(:checkout_and_lock_one, {queue_constraint: queue_constraint.to_sql}) do |payload|
|
114
|
-
payload[:condition_sql] =
|
114
|
+
payload[:condition_sql] = workloads.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
|
117
117
|
transaction do
|
118
|
-
|
119
|
-
|
120
|
-
|
118
|
+
workload = Gouda.suppressing_sql_logs { workloads.first } # Silence SQL output as this gets called very frequently
|
119
|
+
return nil unless workload
|
120
|
+
|
121
|
+
if workload.scheduler_key && !Gouda::Scheduler.known_scheduler_keys.include?(workload.scheduler_key)
|
122
|
+
# Check whether this workload was enqueued with a scheduler key, but no longer is in the cron table.
|
123
|
+
# If that is the case (we are trying to execute a workload which has a scheduler key, but the scheduler
|
124
|
+
# does not know about that key) it means that the workload has been removed from the cron table and must not run.
|
125
|
+
# Moreover: running it can be dangerous because it was likely removed from the table for a reason.
|
126
|
+
# Should that be the case, mark the job "finished" and return `nil` to get to the next poll. If the deployed worker still has
|
127
|
+
# the workload in its scheduler table, but a new deploy removed it - this is a race condition, but we are willing to accept it.
|
128
|
+
# Note that we are already "just not enqueueing" that job when the cron table gets loaded - this already happens.
|
129
|
+
#
|
130
|
+
# Removing jobs from the queue forcibly when we load the cron table is nice, but not enough, because our system can be in a state
|
131
|
+
# of partial deployment:
|
132
|
+
#
|
133
|
+
# [ release 1 does have some_job_hourly crontab entry ]
|
134
|
+
# [ release 2 no longer does ]
|
135
|
+
# ^ --- race conditions possible here --^
|
136
|
+
#
|
137
|
+
# So even if we remove the crontabled workloads during app boot, it does not give us a guarantee that release 1 won't reinsert them.
|
138
|
+
# This is why this safeguard is needed.
|
139
|
+
error = {class_name: "WorkloadSkippedError", message: "Skipped as scheduler_key was no longer in the cron table"}
|
140
|
+
workload.update!(state: "finished", error:)
|
141
|
+
# And return nil. This will cause a brief "sleep" in the polling routine since the caller may think there are no more workloads
|
142
|
+
# in the queue, but only for a brief moment.
|
143
|
+
nil
|
144
|
+
else
|
145
|
+
# Once we have verified this job is OK to execute
|
146
|
+
workload.update!(state: "executing", executing_on: executing_on, last_execution_heartbeat_at: Time.now.utc, execution_started_at: Time.now.utc)
|
147
|
+
workload
|
148
|
+
end
|
121
149
|
rescue ActiveRecord::RecordNotUnique
|
122
150
|
# It can happen that due to a race the `execution_concurrency_key NOT IN` does not capture
|
123
151
|
# a job which _just_ entered the "executing" state, apparently after we do our SELECT. This will happen regardless
|
data/lib/gouda.rb
CHANGED
@@ -64,10 +64,8 @@ module Gouda
|
|
64
64
|
def self.logger
|
65
65
|
# By default, return a logger that sends data nowhere. The `Rails.logger` method
|
66
66
|
# only becomes available later in the Rails lifecycle.
|
67
|
-
@fallback_gouda_logger ||=
|
68
|
-
|
69
|
-
logger.level = Logger::WARN
|
70
|
-
end
|
67
|
+
@fallback_gouda_logger ||= ActiveSupport::Logger.new($stdout).tap do |logger|
|
68
|
+
logger.level = Logger::WARN
|
71
69
|
end
|
72
70
|
|
73
71
|
# We want the Rails-configured loggers to take precedence over ours, since Gouda
|
@@ -81,22 +79,22 @@ module Gouda
|
|
81
79
|
Rails.try(:logger) || ActiveJob::Base.try(:logger) || @fallback_gouda_logger
|
82
80
|
end
|
83
81
|
|
84
|
-
def self.suppressing_sql_logs(&
|
82
|
+
def self.suppressing_sql_logs(&)
|
85
83
|
# This is used for frequently-called methods that poll the DB. If logging is done at a low level (DEBUG)
|
86
84
|
# those methods print a lot of SQL into the logs, on every poll. While that is useful if
|
87
85
|
# you collect SQL queries from the logs, in most cases - especially if this is used
|
88
86
|
# in a side-thread inside Puma - the output might be quite annoying. So silence the
|
89
87
|
# logger when we poll, but just to INFO. Omitting DEBUG-level messages gets rid of the SQL.
|
90
88
|
if Gouda::Workload.logger
|
91
|
-
Gouda::Workload.logger.silence(Logger::INFO, &
|
89
|
+
Gouda::Workload.logger.silence(Logger::INFO, &)
|
92
90
|
else
|
93
91
|
# In tests (and at earlier stages of the Rails boot cycle) the global ActiveRecord logger may be nil
|
94
92
|
yield
|
95
93
|
end
|
96
94
|
end
|
97
95
|
|
98
|
-
def self.instrument(channel, options, &
|
99
|
-
ActiveSupport::Notifications.instrument("#{channel}.gouda", options, &
|
96
|
+
def self.instrument(channel, options, &)
|
97
|
+
ActiveSupport::Notifications.instrument("#{channel}.gouda", options, &)
|
100
98
|
end
|
101
99
|
|
102
100
|
def self.create_tables(active_record_schema)
|
@@ -142,6 +142,45 @@ class GoudaSchedulerTest < ActiveSupport::TestCase
|
|
142
142
|
assert_equal [nil, nil], Gouda::Workload.first.serialized_params["arguments"]
|
143
143
|
end
|
144
144
|
|
145
|
+
test "ensures a job that was scheduled but no longer present in the cron table gets force-finished without executing" do
|
146
|
+
tab = {
|
147
|
+
first_hourly: {
|
148
|
+
cron: "@hourly",
|
149
|
+
class: "GoudaSchedulerTest::TestJob",
|
150
|
+
args: [nil, nil]
|
151
|
+
}
|
152
|
+
}
|
153
|
+
|
154
|
+
assert_nothing_raised do
|
155
|
+
Gouda::Scheduler.build_scheduler_entries_list!(tab)
|
156
|
+
end
|
157
|
+
|
158
|
+
Gouda::Workload.delete_all
|
159
|
+
assert_changes_by(-> { Gouda::Workload.count }, exactly: 1) do
|
160
|
+
Gouda::Scheduler.upsert_workloads_from_entries_list!
|
161
|
+
end
|
162
|
+
|
163
|
+
# Update all workloads so that it is already time for it to be executed (as we use clock_timestamp()
|
164
|
+
# time travel is not possible in those tests)
|
165
|
+
Gouda::Workload.update_all(scheduled_at: Time.now - 2.minutes)
|
166
|
+
|
167
|
+
workload = Gouda::Workload.checkout_and_lock_one(executing_on: "test")
|
168
|
+
assert workload # Now this workload does get selected for execution
|
169
|
+
workload.update(state: "enqueued") # Return it to the queue
|
170
|
+
|
171
|
+
# Erase the crontab.
|
172
|
+
# No need to enqueue next jobs in this test as there would not be jobs enqueued anyway
|
173
|
+
assert_nothing_raised do
|
174
|
+
Gouda::Scheduler.build_scheduler_entries_list!({})
|
175
|
+
end
|
176
|
+
|
177
|
+
assert_nil Gouda::Workload.checkout_and_lock_one(executing_on: "test"), "The workload should not be picked for execution now"
|
178
|
+
just_finished_workload = Gouda::Workload.where(state: "finished").first!
|
179
|
+
assert_equal "finished", just_finished_workload.state
|
180
|
+
assert just_finished_workload.error
|
181
|
+
assert_match(/scheduler/, just_finished_workload.error.fetch("message"))
|
182
|
+
end
|
183
|
+
|
145
184
|
test "is able to accept a crontab" do
|
146
185
|
tab = {
|
147
186
|
first_hourly: {
|
data/test/gouda/test_helper.rb
CHANGED
@@ -56,27 +56,16 @@ class ActiveSupport::TestCase
|
|
56
56
|
ActiveRecord::Base.connection.execute("TRUNCATE TABLE gouda_job_fuses")
|
57
57
|
end
|
58
58
|
|
59
|
-
def test_create_tables
|
60
|
-
ActiveRecord::Base.transaction do
|
61
|
-
ActiveRecord::Base.connection.execute("DROP TABLE gouda_workloads")
|
62
|
-
ActiveRecord::Base.connection.execute("DROP TABLE gouda_job_fuses")
|
63
|
-
# The adapter has to be in a variable as the schema definition is scoped to the migrator, not self
|
64
|
-
ActiveRecord::Schema.define(version: 1) do |via_definer|
|
65
|
-
Gouda.create_tables(via_definer)
|
66
|
-
end
|
67
|
-
end
|
68
|
-
end
|
69
|
-
|
70
59
|
def subscribed_notification_for(notification)
|
71
60
|
payload = nil
|
72
|
-
subscription = ActiveSupport::Notifications.subscribe notification do |name, start, finish, id,
|
73
|
-
payload =
|
61
|
+
subscription = ActiveSupport::Notifications.subscribe notification do |name, start, finish, id, local_payload|
|
62
|
+
payload = local_payload
|
74
63
|
end
|
75
64
|
|
76
65
|
yield
|
77
66
|
|
78
67
|
ActiveSupport::Notifications.unsubscribe(subscription)
|
79
68
|
|
80
|
-
|
69
|
+
payload
|
81
70
|
end
|
82
71
|
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
|
+
version: 0.1.13
|
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-
|
12
|
+
date: 2024-09-04 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: activerecord
|
@@ -135,6 +135,7 @@ files:
|
|
135
135
|
- ".gitignore"
|
136
136
|
- ".rubocop.yml"
|
137
137
|
- ".ruby-version"
|
138
|
+
- ".standard.yml"
|
138
139
|
- CHANGELOG.md
|
139
140
|
- Gemfile
|
140
141
|
- LICENSE.txt
|
@@ -170,8 +171,8 @@ homepage: https://github.com/cheddar-me/gouda
|
|
170
171
|
licenses:
|
171
172
|
- MIT
|
172
173
|
metadata:
|
173
|
-
source_code_uri: https://github.com/cheddar-me/gouda
|
174
174
|
homepage_uri: https://github.com/cheddar-me/gouda
|
175
|
+
source_code_uri: https://github.com/cheddar-me/gouda
|
175
176
|
changelog_uri: https://github.com/cheddar-me/gouda/CHANGELOG.md
|
176
177
|
post_install_message:
|
177
178
|
rdoc_options: []
|
@@ -181,7 +182,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
181
182
|
requirements:
|
182
183
|
- - ">="
|
183
184
|
- !ruby/object:Gem::Version
|
184
|
-
version:
|
185
|
+
version: 3.1.0
|
185
186
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
186
187
|
requirements:
|
187
188
|
- - ">="
|