geneva_drive 0.7.0 → 0.9.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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/MANUAL.md +299 -5
  4. data/README.md +14 -1
  5. data/Rakefile +28 -1
  6. data/bin/matrix +76 -0
  7. data/bin/md2html +114 -0
  8. data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +5 -9
  9. data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +5 -10
  10. data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +1 -1
  11. data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +33 -2
  12. data/lib/geneva_drive/dispatchers/active_job_dispatcher.rb +33 -0
  13. data/lib/geneva_drive/dispatchers/sidekiq_dispatcher.rb +112 -0
  14. data/lib/geneva_drive/engine.rb +13 -0
  15. data/lib/geneva_drive/executor.rb +23 -3
  16. data/lib/geneva_drive/job_options.rb +7 -3
  17. data/lib/geneva_drive/jobs/perform_step_job.rb +2 -35
  18. data/lib/geneva_drive/jobs/sidekiq_housekeeping_job.rb +32 -0
  19. data/lib/geneva_drive/jobs/sidekiq_perform_step_job.rb +35 -0
  20. data/lib/geneva_drive/jobs/step_execution_lookup.rb +42 -0
  21. data/lib/geneva_drive/json_column.rb +72 -0
  22. data/lib/geneva_drive/migration_helpers.rb +60 -3
  23. data/lib/geneva_drive/removed_step_definition.rb +34 -0
  24. data/lib/geneva_drive/signal.rb +4 -2
  25. data/lib/geneva_drive/step_collection.rb +31 -7
  26. data/lib/geneva_drive/step_definition.rb +9 -0
  27. data/lib/geneva_drive/step_execution.rb +3 -2
  28. data/lib/geneva_drive/version.rb +1 -1
  29. data/lib/geneva_drive/workflow.rb +200 -39
  30. data/lib/geneva_drive.rb +65 -0
  31. data/test/dummy/config/database.yml +40 -5
  32. data/test/dummy/config/initializers/geneva_drive.rb +78 -0
  33. data/test/dummy/db/mysql/schema-8.1.rb +95 -0
  34. data/test/dummy/db/postgres/schema-8.1.rb +99 -0
  35. data/test/dummy/db/sqlite/schema-7.2.rb +96 -0
  36. data/test/dummy/db/sqlite/schema-8.0.rb +96 -0
  37. data/test/dummy/db/sqlite/schema-8.1.rb +96 -0
  38. data/test/dummy/db/sqlite/test.sqlite3 +0 -0
  39. data/test/dummy/db/sqlite/test.sqlite3-0 +0 -0
  40. data/test/dummy/db/sqlite/test.sqlite3-1 +0 -0
  41. data/test/dummy/db/sqlite/test.sqlite3-2 +0 -0
  42. data/test/dummy/db/sqlite/test.sqlite3-3 +0 -0
  43. data/test/dummy/db/sqlite/test.sqlite3-4 +0 -0
  44. data/test/dummy/db/sqlite/test.sqlite3-5 +0 -0
  45. data/test/dummy/db/sqlite/test.sqlite3-6 +0 -0
  46. data/test/dummy/db/sqlite/test.sqlite3-7 +0 -0
  47. data/test/dummy/db/sqlite/test.sqlite3-8 +0 -0
  48. data/test/dummy/db/sqlite/test.sqlite3-9 +0 -0
  49. data/test/dummy/db/sqlite/test.sqlite3_0 +0 -0
  50. data/test/dummy/db/sqlite/test.sqlite3_1 +0 -0
  51. data/test/dummy/db/sqlite/test.sqlite3_2 +0 -0
  52. data/test/dummy/db/sqlite/test.sqlite3_3 +0 -0
  53. data/test/dummy/db/sqlite/test.sqlite3_4 +0 -0
  54. data/test/dummy/db/sqlite/test.sqlite3_5 +0 -0
  55. data/test/dummy/db/sqlite/test.sqlite3_6 +0 -0
  56. data/test/dummy/db/sqlite/test.sqlite3_7 +0 -0
  57. data/test/dummy/db/sqlite/test.sqlite3_8 +0 -0
  58. data/test/dummy/db/sqlite/test.sqlite3_9 +0 -0
  59. data/test/dummy/db/sqlite3/schema-7.2.rb +96 -0
  60. data/test/dummy/db/sqlite3/schema-8.1.rb +96 -0
  61. data/test/dummy/db/sqlite3/test.sqlite3 +0 -0
  62. data/test/dummy/db/sqlite3/test.sqlite3_0 +0 -0
  63. data/test/dummy/db/sqlite3/test.sqlite3_1 +0 -0
  64. data/test/dummy/db/sqlite3/test.sqlite3_2 +0 -0
  65. data/test/dummy/db/sqlite3/test.sqlite3_3 +0 -0
  66. data/test/dummy/db/sqlite3/test.sqlite3_4 +0 -0
  67. data/test/dummy/db/sqlite3/test.sqlite3_5 +0 -0
  68. data/test/dummy/db/sqlite3/test.sqlite3_6 +0 -0
  69. data/test/dummy/db/sqlite3/test.sqlite3_7 +0 -0
  70. data/test/dummy/db/sqlite3/test.sqlite3_8 +0 -0
  71. data/test/dummy/db/sqlite3/test.sqlite3_9 +0 -0
  72. data/test/dummy_install/db/migrate/20260217112949_create_geneva_drive_workflows.rb +76 -0
  73. data/test/dummy_install/db/migrate/20260217112950_create_geneva_drive_step_executions.rb +99 -0
  74. data/test/dummy_install/db/migrate/20260217112951_add_finished_at_to_geneva_drive_step_executions.rb +27 -0
  75. data/test/dummy_install/db/migrate/20260217112952_add_error_class_name_to_geneva_drive_step_executions.rb +9 -0
  76. data/test/dummy_install/db/migrate/20260217112953_add_resumable_step_support_to_geneva_drive_step_executions.rb +128 -0
  77. data/test/dummy_install/db/migrate/20260217112954_add_chained_step_executions_to_geneva_drive_step_executions.rb +136 -0
  78. data/test/dummy_install/db/schema.rb +75 -0
  79. data/test/dummy_install/log/test.log +1852 -0
  80. data/test/generators/install_generator_test.rb +56 -0
  81. data/test/geneva_drive_test.rb +10 -0
  82. data/test/migration_helpers_test.rb +62 -0
  83. data/test/sidekiq/sidekiq_dispatcher_test.rb +322 -0
  84. data/test/sidekiq/sidekiq_redis_test.rb +115 -0
  85. data/test/test_helper.rb +51 -13
  86. data/test/workflow/pause_resume_test.rb +15 -0
  87. data/test/workflow/removed_step_test.rb +294 -0
  88. data/test/workflow/resume_at_test.rb +170 -0
  89. metadata +62 -1
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "rails/generators/test_case"
5
+ require "tmpdir"
6
+ require "generators/geneva_drive/install/install_generator"
7
+
8
+ class InstallGeneratorTest < Rails::Generators::TestCase
9
+ tests GenevaDrive::Generators::InstallGenerator
10
+
11
+ # One directory per test: parallel workers would otherwise wipe each other's
12
+ # output in prepare_destination.
13
+ setup do
14
+ self.destination_root = Dir.mktmpdir("geneva_drive_install")
15
+ prepare_destination
16
+ end
17
+
18
+ teardown do
19
+ FileUtils.rm_rf(destination_root)
20
+ end
21
+
22
+ test "writes the Sidekiq dispatcher when Active Job is set up for Sidekiq" do
23
+ skip "Sidekiq is only in the sidekiq_* appraisals" unless defined?(::Sidekiq)
24
+
25
+ with_active_job_adapter(:sidekiq) { run_generator }
26
+
27
+ assert_file "config/initializers/geneva_drive.rb" do |content|
28
+ assert_match(/^GenevaDrive\.dispatcher = :sidekiq$/, content)
29
+ assert RubyVM::InstructionSequence.compile(content)
30
+ end
31
+ end
32
+
33
+ test "leaves the dispatcher commented out for any other adapter" do
34
+ [nil, :solid_queue, :test].each do |adapter|
35
+ prepare_destination
36
+ with_active_job_adapter(adapter) { run_generator }
37
+
38
+ assert_file "config/initializers/geneva_drive.rb" do |content|
39
+ assert_match(/^# GenevaDrive\.dispatcher = :active_job$/, content)
40
+ refute_match(/^GenevaDrive\.dispatcher/, content, "with adapter #{adapter.inspect}")
41
+ assert RubyVM::InstructionSequence.compile(content)
42
+ end
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def with_active_job_adapter(adapter)
49
+ config = Rails.application.config.active_job
50
+ previous = config.queue_adapter
51
+ config.queue_adapter = adapter
52
+ yield
53
+ ensure
54
+ config.queue_adapter = previous
55
+ end
56
+ end
@@ -4,4 +4,14 @@ class GenevaDriveTest < ActiveSupport::TestCase
4
4
  test "it has a version number" do
5
5
  assert GenevaDrive::VERSION
6
6
  end
7
+
8
+ test "initializer template only documents settings that exist" do
9
+ template = File.expand_path("../lib/generators/geneva_drive/install/templates/initializer.rb.tt", __dir__)
10
+ settings = File.read(template).scan(/GenevaDrive\.(\w+)\s*=/).flatten
11
+ refute_empty settings
12
+
13
+ settings.each do |setting|
14
+ assert GenevaDrive.respond_to?("#{setting}="), "GenevaDrive.#{setting} in the initializer template is not a setting"
15
+ end
16
+ end
7
17
  end
@@ -62,6 +62,68 @@ class MigrationHelpersTest < ActiveSupport::TestCase
62
62
  assert_equal :bigint, build_migration(columns).geneva_drive_key_type
63
63
  end
64
64
 
65
+ test "keeps :bigint for the gem tables when the app uses string ids" do
66
+ columns = [
67
+ FakeColumn.new(name: "id", sql_type: "varchar", default_function: nil),
68
+ FakeColumn.new(name: "id", sql_type: "varchar", default_function: nil)
69
+ ]
70
+ assert_equal :bigint, build_migration(columns).geneva_drive_key_type
71
+ end
72
+
73
+ # --- geneva_drive_hero_key_type ---
74
+
75
+ test "hero key type is :bigint when there are no application tables" do
76
+ assert_equal :bigint, build_migration.geneva_drive_hero_key_type
77
+ end
78
+
79
+ test "hero key type is :bigint when most tables use integer ids" do
80
+ columns = [
81
+ FakeColumn.new(name: "id", sql_type: "bigint", default_function: nil),
82
+ FakeColumn.new(name: "id", sql_type: "integer", default_function: nil),
83
+ FakeColumn.new(name: "id", sql_type: "varchar", default_function: nil)
84
+ ]
85
+ assert_equal :bigint, build_migration(columns).geneva_drive_hero_key_type
86
+ end
87
+
88
+ test "hero key type is :uuid when most tables use uuid ids" do
89
+ columns = [
90
+ FakeColumn.new(name: "id", sql_type: "uuid", default_function: "gen_random_uuid()"),
91
+ FakeColumn.new(name: "id", sql_type: "char(36)", default_function: nil),
92
+ FakeColumn.new(name: "id", sql_type: "bigint", default_function: nil)
93
+ ]
94
+ assert_equal :uuid, build_migration(columns).geneva_drive_hero_key_type
95
+ end
96
+
97
+ test "hero key type is :string for string ids of any length" do
98
+ ["varchar", "varchar(255)", "character varying", "char(26)", "text"].each do |sql_type|
99
+ columns = [FakeColumn.new(name: "id", sql_type: sql_type, default_function: nil)]
100
+ assert_equal :string, build_migration(columns).geneva_drive_hero_key_type, "for #{sql_type}"
101
+ end
102
+ end
103
+
104
+ test "hero key type is :string when uuid and string ids together outnumber integer ids" do
105
+ columns = [
106
+ FakeColumn.new(name: "id", sql_type: "uuid", default_function: nil),
107
+ FakeColumn.new(name: "id", sql_type: "varchar", default_function: nil),
108
+ FakeColumn.new(name: "id", sql_type: "bigint", default_function: nil)
109
+ ]
110
+ assert_equal :string, build_migration(columns).geneva_drive_hero_key_type
111
+ end
112
+
113
+ test "hero key type is :string for an id: :string table on SQLite" do
114
+ connection = ActiveRecord::Base.connection
115
+ skip "SQLite only" unless connection.adapter_name.downcase.include?("sqlite")
116
+
117
+ begin
118
+ connection.create_table(:string_id_heroes, id: :string)
119
+ id_column = connection.columns(:string_id_heroes).find { |c| c.name == "id" }
120
+ assert_equal "varchar", id_column.sql_type # no length, which the uuid regex used to miss
121
+ assert_equal :string, build_migration([id_column]).geneva_drive_hero_key_type
122
+ ensure
123
+ connection.drop_table(:string_id_heroes, if_exists: true)
124
+ end
125
+ end
126
+
65
127
  # --- geneva_drive_table_options ---
66
128
 
67
129
  test "returns empty hash for bigint key type" do
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "minitest/mock"
5
+
6
+ # Sidekiq is only in the sidekiq_* appraisals; run these with `rake test:sidekiq`
7
+ return unless defined?(::Sidekiq)
8
+
9
+ # Sidekiq 8 has a new testing API; 7 only has the old require, which also
10
+ # switches fake mode on globally. Fake mode is toggled per test below.
11
+ begin
12
+ require "sidekiq/test_api"
13
+ rescue LoadError
14
+ require "sidekiq/testing"
15
+ end
16
+ Sidekiq::Testing.disable!
17
+ Sidekiq.logger.level = Logger::WARN
18
+
19
+ class SidekiqDispatcherTest < ActiveSupport::TestCase
20
+ include GenevaDrive::TestHelpers
21
+ include ActiveJob::TestHelper
22
+
23
+ class TwoStepWorkflow < GenevaDrive::Workflow
24
+ step :first do
25
+ Thread.current[:sidekiq_steps] << :first
26
+ end
27
+
28
+ step :second, wait: 2.hours do
29
+ Thread.current[:sidekiq_steps] << :second
30
+ end
31
+ end
32
+
33
+ class QueuedWorkflow < GenevaDrive::Workflow
34
+ set_step_job_options queue: :workflows
35
+ step(:only, job_options: {queue: :urgent}) {}
36
+ end
37
+
38
+ class FailingWorkflow < GenevaDrive::Workflow
39
+ step :explode do
40
+ raise ArgumentError, "kaboom"
41
+ end
42
+ end
43
+
44
+ class IteratingWorkflow < GenevaDrive::Workflow
45
+ resumable_step :crunch do |iter|
46
+ iter.iterate_over((1..5).to_a) do |n|
47
+ Thread.current[:sidekiq_steps] << n
48
+ end
49
+ end
50
+ end
51
+
52
+ # Stands in for Sidekiq::TransactionAwareClient: it records that it was
53
+ # asked to push, and pushes nothing.
54
+ class RecordingClient
55
+ class << self
56
+ attr_accessor :pushes
57
+ end
58
+
59
+ def initialize(**)
60
+ end
61
+
62
+ def push(item)
63
+ self.class.pushes << item
64
+ "recorded"
65
+ end
66
+ end
67
+
68
+ # Fake mode keeps pushed jobs in memory, per job class, so none of this needs Redis.
69
+ setup do
70
+ Sidekiq::Testing.fake!
71
+ @previous_dispatcher = GenevaDrive.dispatcher
72
+ GenevaDrive.dispatcher = :sidekiq
73
+ Sidekiq::Job.clear_all
74
+ Thread.current[:sidekiq_steps] = []
75
+ @user = create_user
76
+ end
77
+
78
+ teardown do
79
+ GenevaDrive.dispatcher = @previous_dispatcher
80
+ GenevaDrive.reset_exit_request!
81
+ Sidekiq::Job.clear_all
82
+ Sidekiq::Testing.disable!
83
+ Thread.current[:sidekiq_steps] = nil
84
+ end
85
+
86
+ test "dispatcher= maps symbols to the built-in dispatchers" do
87
+ GenevaDrive.dispatcher = :sidekiq
88
+ assert_kind_of GenevaDrive::SidekiqDispatcher, GenevaDrive.dispatcher
89
+
90
+ GenevaDrive.dispatcher = :active_job
91
+ assert_kind_of GenevaDrive::ActiveJobDispatcher, GenevaDrive.dispatcher
92
+ end
93
+
94
+ test "dispatcher= rejects anything that cannot enqueue steps" do
95
+ error = assert_raises(ArgumentError) { GenevaDrive.dispatcher = :resque }
96
+ assert_match(/:active_job, :sidekiq/, error.message)
97
+
98
+ assert_raises(ArgumentError) { GenevaDrive.dispatcher = Object.new }
99
+ assert_raises(ArgumentError) { GenevaDrive.dispatcher = nil }
100
+ end
101
+
102
+ test "creating a workflow pushes a native Sidekiq job for the first step" do
103
+ workflow = TwoStepWorkflow.create!(hero: @user)
104
+ execution = workflow.step_executions.sole
105
+
106
+ jobs = GenevaDrive::SidekiqPerformStepJob.jobs
107
+ assert_equal 1, jobs.size
108
+ job = jobs.first
109
+
110
+ assert_equal "GenevaDrive::SidekiqPerformStepJob", job["class"]
111
+ assert_equal [execution.id], job["args"]
112
+ assert_equal "default", job["queue"]
113
+ assert_equal false, job["retry"]
114
+ assert_nil job["at"], "an immediate step must not wait in the schedule set"
115
+ assert_equal ["geneva_drive", TwoStepWorkflow.name], job["tags"]
116
+
117
+ assert_equal job["jid"], execution.reload.job_id
118
+ end
119
+
120
+ test "nothing goes through Active Job" do
121
+ assert_no_enqueued_jobs do
122
+ TwoStepWorkflow.create!(hero: @user)
123
+ end
124
+ end
125
+
126
+ test "a step with wait: is pushed with an at timestamp" do
127
+ workflow = TwoStepWorkflow.create!(hero: @user)
128
+ GenevaDrive::SidekiqPerformStepJob.perform_one
129
+
130
+ second = workflow.step_executions.find_by!(step_name: "second")
131
+ job = GenevaDrive::SidekiqPerformStepJob.jobs.sole
132
+
133
+ assert_equal [second.id], job["args"]
134
+ assert_in_delta second.scheduled_for.to_f, job["at"], 1.0
135
+ end
136
+
137
+ test "queue options reach the pushed job, step options winning" do
138
+ QueuedWorkflow.create!(hero: @user)
139
+ assert_equal "urgent", GenevaDrive::SidekiqPerformStepJob.jobs.sole["queue"]
140
+ end
141
+
142
+ test "draining the queue runs the workflow to the end" do
143
+ workflow = TwoStepWorkflow.create!(hero: @user)
144
+
145
+ # The fake queue ignores "at", so the delayed step runs as soon as drained
146
+ GenevaDrive::SidekiqPerformStepJob.drain
147
+
148
+ assert_equal [:first, :second], Thread.current[:sidekiq_steps]
149
+ assert workflow.reload.finished?
150
+ end
151
+
152
+ test "a step's exception reaches Sidekiq after the executor has settled the step" do
153
+ workflow = FailingWorkflow.create!(hero: @user)
154
+
155
+ error = assert_raises(ArgumentError) { GenevaDrive::SidekiqPerformStepJob.perform_one }
156
+ assert_equal "kaboom", error.message
157
+
158
+ execution = workflow.step_executions.sole.reload
159
+ assert_equal "failed", execution.state
160
+ assert_empty GenevaDrive::SidekiqPerformStepJob.jobs, "no Sidekiq retry is queued"
161
+ end
162
+
163
+ test "a job for a vanished step execution is a no-op" do
164
+ job = GenevaDrive::SidekiqPerformStepJob.new
165
+ job.stub(:sleep, ->(_delay) {}) do
166
+ assert_nothing_raised { job.perform(999_999_999) }
167
+ end
168
+ end
169
+
170
+ test "pushes skip the client_class that perform_async would use" do
171
+ # Sidekiq.transactional_push! works by installing a client_class. We push
172
+ # through Sidekiq::Client directly, so it never sees our jobs.
173
+ RecordingClient.pushes = []
174
+ options = Sidekiq.default_job_options
175
+ previous = options["client_class"]
176
+ options["client_class"] = RecordingClient
177
+
178
+ begin
179
+ TwoStepWorkflow.create!(hero: @user)
180
+ ensure
181
+ previous ? options["client_class"] = previous : options.delete("client_class")
182
+ end
183
+
184
+ assert_empty RecordingClient.pushes
185
+ assert_equal 1, GenevaDrive::SidekiqPerformStepJob.jobs.size
186
+ end
187
+
188
+ test "a custom job class can be supplied" do
189
+ custom_job = Class.new(GenevaDrive::SidekiqPerformStepJob) do
190
+ sidekiq_options queue: "workflows", tags: ["billing"]
191
+ end
192
+ GenevaDrive.dispatcher = GenevaDrive::SidekiqDispatcher.new(job_class: custom_job)
193
+
194
+ RecordingClient.pushes = []
195
+ Sidekiq::Client.stub(:new, RecordingClient.new) do
196
+ TwoStepWorkflow.create!(hero: @user)
197
+ end
198
+
199
+ item = RecordingClient.pushes.sole
200
+ assert_equal custom_job, item["class"]
201
+ assert_equal ["billing", TwoStepWorkflow.name], item["tags"]
202
+ refute item.key?("queue"), "the class's own queue applies when no queue option is set"
203
+ end
204
+
205
+ test "priority is rejected under the Sidekiq dispatcher" do
206
+ error = assert_raises(GenevaDrive::StepConfigurationError) do
207
+ Class.new(GenevaDrive::Workflow) { set_step_job_options priority: 5 }
208
+ end
209
+ assert_match(/queues with weights/, error.message)
210
+
211
+ assert_raises(GenevaDrive::StepConfigurationError) do
212
+ Class.new(GenevaDrive::Workflow) { step(:s, job_options: {priority: 1}) {} }
213
+ end
214
+ end
215
+
216
+ test "priority stays allowed under the Active Job dispatcher" do
217
+ GenevaDrive.dispatcher = :active_job
218
+ assert_nothing_raised do
219
+ Class.new(GenevaDrive::Workflow) { set_step_job_options priority: 5 }
220
+ end
221
+ end
222
+
223
+ test "housekeeping runs as a Sidekiq job" do
224
+ result = GenevaDrive::SidekiqHousekeepingJob.new.perform
225
+ assert_kind_of Hash, result
226
+ assert result.key?(:stuck_scheduled_recovered)
227
+ assert_equal false, GenevaDrive::SidekiqHousekeepingJob.get_sidekiq_options["retry"]
228
+ end
229
+
230
+ test "a shutdown request hands a resumable step off to a successor" do
231
+ workflow = IteratingWorkflow.create!(hero: @user)
232
+ first = workflow.step_executions.sole
233
+
234
+ GenevaDrive.request_exit!
235
+ GenevaDrive::SidekiqPerformStepJob.perform_one
236
+
237
+ assert_operator Thread.current[:sidekiq_steps].size, :<, 5, "the step must stop early"
238
+ assert_equal "completed", first.reload.state
239
+
240
+ successor = workflow.step_executions.find_by!(continues_from_id: first.id)
241
+ assert_equal "scheduled", successor.state
242
+ assert_equal [successor.id], GenevaDrive::SidekiqPerformStepJob.jobs.sole["args"]
243
+
244
+ GenevaDrive.reset_exit_request!
245
+ GenevaDrive::SidekiqPerformStepJob.drain
246
+ assert_equal [1, 2, 3, 4, 5], Thread.current[:sidekiq_steps], "the successor picks up from the cursor"
247
+ assert workflow.reload.finished?
248
+ end
249
+
250
+ test "Sidekiq is recommended when Active Job is pointed at Sidekiq" do
251
+ sidekiq_adapter = Class.new { def self.name = "ActiveJob::QueueAdapters::SidekiqAdapter" }
252
+ [:sidekiq, "sidekiq", sidekiq_adapter.new].each do |adapter|
253
+ assert GenevaDrive::SidekiqDispatcher.recommended?(adapter), "with adapter #{adapter.inspect}"
254
+ end
255
+ end
256
+
257
+ test "Sidekiq is not recommended for any other adapter, or none" do
258
+ # Unset is the common case in development, where the generator runs, even
259
+ # for apps that use Solid Queue in production.
260
+ [nil, :async, :inline, :test, :solid_queue, :good_job, ActiveJob::QueueAdapters::AsyncAdapter.new].each do |adapter|
261
+ refute GenevaDrive::SidekiqDispatcher.recommended?(adapter), "with adapter #{adapter.inspect}"
262
+ end
263
+ end
264
+
265
+ test "an unset dispatcher is Active Job even where Sidekiq would be recommended" do
266
+ with_active_job_adapter(:sidekiq) do
267
+ GenevaDrive.instance_variable_set(:@dispatcher, nil)
268
+ assert_kind_of GenevaDrive::ActiveJobDispatcher, GenevaDrive.dispatcher
269
+ end
270
+ end
271
+
272
+ test "the supported Sidekiq versions are 7.3 up to, not including, 9" do
273
+ supported = GenevaDrive::SidekiqDispatcher::SUPPORTED_SIDEKIQ_VERSIONS
274
+ %w[7.3.0 7.3.10 8.0.0 8.1.7].each { |v| assert supported.satisfied_by?(Gem::Version.new(v)), v }
275
+ %w[6.5.12 7.2.4 9.0.0].each { |v| refute supported.satisfied_by?(Gem::Version.new(v)), v }
276
+ assert GenevaDrive::SidekiqDispatcher.supported_sidekiq_version?, "the bundled Sidekiq must be supported"
277
+ end
278
+
279
+ test "the Sidekiq dispatcher refuses an unsupported Sidekiq" do
280
+ GenevaDrive::SidekiqDispatcher.stub(:supported_sidekiq_version?, false) do
281
+ error = assert_raises(ArgumentError) { GenevaDrive::SidekiqDispatcher.new }
282
+ assert_match(Sidekiq::VERSION, error.message)
283
+ assert_match(/>= 7\.3/, error.message)
284
+ end
285
+ end
286
+
287
+ test "Sidekiq is not recommended when the loaded version is unsupported" do
288
+ GenevaDrive::SidekiqDispatcher.stub(:supported_sidekiq_version?, false) do
289
+ refute GenevaDrive::SidekiqDispatcher.recommended?(:sidekiq)
290
+ end
291
+ end
292
+
293
+ # Runs the initializer block against a stand-in config; a real `sidekiq`
294
+ # boot is not part of the suite.
295
+ test "the engine wires Sidekiq's quiet event to the shutdown request" do
296
+ initializer = GenevaDrive::Engine.instance.initializers.find { |i| i.name == "geneva_drive.sidekiq_lifecycle" }
297
+ assert initializer, "expected the lifecycle initializer to exist"
298
+
299
+ events = {}
300
+ fake_config = Object.new
301
+ fake_config.define_singleton_method(:on) { |event, &block| events[event] = block }
302
+
303
+ Sidekiq.stub(:configure_server, ->(&blk) { blk.call(fake_config) }) do
304
+ initializer.run(Rails.application)
305
+ end
306
+
307
+ refute GenevaDrive.exit_requested?
308
+ events.fetch(:quiet).call
309
+ assert GenevaDrive.exit_requested?
310
+ end
311
+
312
+ private
313
+
314
+ def with_active_job_adapter(adapter)
315
+ config = Rails.application.config.active_job
316
+ previous = config.queue_adapter
317
+ config.queue_adapter = adapter
318
+ yield
319
+ ensure
320
+ config.queue_adapter = previous
321
+ end
322
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ # Sidekiq is only in the sidekiq_* appraisals; run these with `rake test:sidekiq`
6
+ return unless defined?(::Sidekiq)
7
+
8
+ begin
9
+ require "sidekiq/test_api"
10
+ rescue LoadError
11
+ require "sidekiq/testing"
12
+ end
13
+ require "sidekiq/api"
14
+
15
+ # The Sidekiq dispatcher against a real Redis, end to end: real commits,
16
+ # GenevaDrive's own after-commit deferral switched on, and Sidekiq's
17
+ # transactional push installed - the combination the dispatcher exists to
18
+ # get right. Runs only where GENEVA_DRIVE_SIDEKIQ_REDIS_URL is set, which the
19
+ # Sidekiq CI workflow does; the main CI has no Redis and skips these.
20
+ class SidekiqRedisTest < ActiveSupport::TestCase
21
+ REDIS_URL = ENV["GENEVA_DRIVE_SIDEKIQ_REDIS_URL"]
22
+
23
+ # after_all_transactions_commit never fires inside a test transaction
24
+ self.use_transactional_tests = false
25
+
26
+ class CheckoutWorkflow < GenevaDrive::Workflow
27
+ step :reserve_stock do
28
+ Thread.current[:sidekiq_redis_steps] << :reserve_stock
29
+ end
30
+
31
+ step :send_reminder, wait: 2.hours do
32
+ Thread.current[:sidekiq_redis_steps] << :send_reminder
33
+ end
34
+
35
+ # A queue of its own per test, so nothing else in this Redis is touched
36
+ private def merged_step_job_options(step_definition)
37
+ super.merge(queue: Thread.current[:sidekiq_redis_test_queue])
38
+ end
39
+ end
40
+
41
+ setup do
42
+ skip "Set GENEVA_DRIVE_SIDEKIQ_REDIS_URL to run these" unless REDIS_URL
43
+
44
+ Sidekiq.configure_client { |config| config.redis = {url: REDIS_URL} }
45
+ Sidekiq::Testing.disable!
46
+
47
+ @queue = Thread.current[:sidekiq_redis_test_queue] = "geneva_drive_test_#{SecureRandom.hex(6)}"
48
+ Thread.current[:sidekiq_redis_steps] = []
49
+ @previous_dispatcher = GenevaDrive.dispatcher
50
+ @previous_deferral = GenevaDrive.enqueue_after_commit
51
+ GenevaDrive.dispatcher = :sidekiq
52
+ GenevaDrive.enqueue_after_commit = true
53
+ @user = create_user
54
+ end
55
+
56
+ teardown do
57
+ next unless REDIS_URL
58
+
59
+ jids = GenevaDrive::StepExecution.where(workflow: @workflow).pluck(:job_id).compact if @workflow
60
+ Array(jids).each { |jid| Sidekiq::ScheduledSet.new.scan(jid).each(&:delete) }
61
+ Sidekiq::Queue.new(@queue).clear
62
+
63
+ @workflow&.step_executions&.delete_all
64
+ @workflow&.delete
65
+ @user&.delete
66
+
67
+ Sidekiq.default_job_options.delete("client_class")
68
+ GenevaDrive.dispatcher = @previous_dispatcher
69
+ GenevaDrive.enqueue_after_commit = @previous_deferral
70
+ Thread.current[:sidekiq_redis_test_queue] = nil
71
+ Thread.current[:sidekiq_redis_steps] = nil
72
+ end
73
+
74
+ test "a workflow created in a transaction lands in Redis once, after the commit" do
75
+ Sidekiq.transactional_push!
76
+
77
+ ActiveRecord::Base.transaction do
78
+ @workflow = CheckoutWorkflow.create!(hero: @user)
79
+ assert_equal 0, Sidekiq::Queue.new(@queue).size, "nothing may be pushed before the commit"
80
+ end
81
+
82
+ jobs = Sidekiq::Queue.new(@queue).to_a
83
+ assert_equal 1, jobs.size
84
+
85
+ execution = @workflow.step_executions.sole
86
+ assert_equal "GenevaDrive::SidekiqPerformStepJob", jobs.first.klass
87
+ assert_equal [execution.id], jobs.first.args
88
+ assert_equal jobs.first.jid, execution.reload.job_id
89
+ end
90
+
91
+ test "a step job taken off the queue runs the step and schedules the next one" do
92
+ @workflow = CheckoutWorkflow.create!(hero: @user)
93
+
94
+ perform(Sidekiq::Queue.new(@queue).first)
95
+ assert_equal [:reserve_stock], Thread.current[:sidekiq_redis_steps]
96
+
97
+ reminder = @workflow.step_executions.find_by!(step_name: "send_reminder")
98
+ scheduled = Sidekiq::ScheduledSet.new.scan(reminder.job_id).first
99
+ assert scheduled, "the delayed step must wait in the Scheduled set"
100
+ assert_in_delta reminder.scheduled_for.to_f, scheduled.score, 1.0
101
+
102
+ perform(scheduled)
103
+ assert_equal [:reserve_stock, :send_reminder], Thread.current[:sidekiq_redis_steps]
104
+ assert @workflow.reload.finished?
105
+ end
106
+
107
+ private
108
+
109
+ # What a Sidekiq processor does with a job, minus the fetching: delete it
110
+ # and run it with the arguments that went through Redis as JSON.
111
+ def perform(record)
112
+ record.delete
113
+ record.klass.constantize.new.perform(*record.args)
114
+ end
115
+ end
data/test/test_helper.rb CHANGED
@@ -7,29 +7,52 @@ require_relative "../test/dummy/config/environment"
7
7
  ActiveRecord::Migrator.migrations_paths = [File.expand_path("../test/dummy/db/migrate", __dir__)]
8
8
  ActiveRecord::Migrator.migrations_paths << File.expand_path("../db/migrate", __dir__)
9
9
 
10
- # Ensure GenevaDrive migrations exist and database is prepared.
11
- # The generator is the single source of truth for migrations.
10
+ # Bring the dummy app's database in line with the migrations the install
11
+ # generator produces, recreating it from scratch whenever it has drifted.
12
+ #
13
+ # Rebuilding rather than migrating forward is deliberate. The generated
14
+ # migrations are gitignored and get fresh timestamps every time they are
15
+ # regenerated, so a database left behind by an earlier generation records
16
+ # versions that no longer exist on disk: every migration reads as pending, and
17
+ # replaying them onto the existing tables just fails on "table already exists".
12
18
  unless defined?(GENEVA_DRIVE_TEST_DB_PREPARED)
13
19
  GENEVA_DRIVE_TEST_DB_PREPARED = true
14
20
 
15
21
  dummy_root = File.expand_path("../test/dummy", __dir__)
16
- migrations = Dir.glob("#{dummy_root}/db/migrate/*geneva_drive*.rb")
17
- tables_exist = begin
18
- ActiveRecord::Base.connection.table_exists?("geneva_drive_workflows")
19
- rescue
20
- false
21
- end
22
+ db_config = ActiveRecord::Base.connection_db_config
23
+ # Adapter-specific, and deliberately so - see the comment in the dummy app's
24
+ # config/database.yml. Ask Rails for the path rather than rebuilding it here.
25
+ schema_file = ActiveRecord::Tasks::DatabaseTasks.schema_dump_path(db_config)
22
26
 
23
- if migrations.empty? && !tables_exist
27
+ # The generator is the single source of truth for migrations.
28
+ if Dir.glob("#{dummy_root}/db/migrate/*geneva_drive*.rb").empty?
24
29
  puts "Generating GenevaDrive migrations..."
25
30
  Dir.chdir(dummy_root) do
26
31
  system("bin/rails", "generate", "geneva_drive:install", "--skip") || abort("Failed to generate migrations")
27
- system("bin/rails", "db:migrate") || abort("Failed to run migrations")
28
32
  end
29
- elsif migrations.any? && !tables_exist
30
- puts "Running pending migrations..."
33
+ end
34
+
35
+ on_disk = Dir.glob("#{dummy_root}/db/migrate/*.rb").map { |path| File.basename(path)[/\A\d+/] }
36
+ applied = begin
37
+ ActiveRecord::Base.connection.select_values("SELECT version FROM schema_migrations").map(&:to_s)
38
+ rescue
39
+ [] # No database, or no schema_migrations in it yet
40
+ end
41
+
42
+ if (on_disk - applied).any? || !File.exist?(schema_file)
43
+ puts "Recreating the #{db_config.adapter} test database..."
44
+ # db:migrate seeds an empty database from the schema dump before applying
45
+ # anything, so the outdated dump has to go first - otherwise it recreates
46
+ # exactly the tables the migrations are about to create.
47
+ File.delete(schema_file) if File.exist?(schema_file)
48
+ # bin/rails cannot drop a database this process still holds a connection to.
49
+ ActiveRecord::Base.connection_handler.clear_all_connections!
31
50
  Dir.chdir(dummy_root) do
32
- system("bin/rails", "db:prepare") || abort("Failed to prepare database")
51
+ # One bin/rails per task, deliberately. Asking a single process to drop,
52
+ # recreate and migrate carries its column cache across the drop, and the
53
+ # schema it then dumps silently loses column defaults.
54
+ system("bin/rails", "db:drop") || abort("Failed to drop the test database")
55
+ system("bin/rails", "db:prepare") || abort("Failed to recreate the test database")
33
56
  end
34
57
  end
35
58
  end
@@ -44,6 +67,21 @@ if ActiveSupport::TestCase.respond_to?(:fixture_paths=)
44
67
  ActiveSupport::TestCase.fixtures :all
45
68
  end
46
69
 
70
+ # Rails 8.1 clears Active Record's connections before forking the parallel test
71
+ # workers. 7.2 and 8.0 fork with them still open, and a libpq connection
72
+ # inherited across the fork takes the child down with a segfault in
73
+ # PG::Connection#connect_start as it opens its own - a race, so it only bites
74
+ # some of the time, which makes it maddening rather than merely broken. Where
75
+ # upstream has no pre-fork hook to register on, wrap the fork point itself.
76
+ unless ActiveSupport::Testing::Parallelization.respond_to?(:before_fork_hook)
77
+ ActiveSupport::Testing::Parallelization.prepend(Module.new do
78
+ def start
79
+ ActiveRecord::Base.connection_handler.clear_all_connections!
80
+ super
81
+ end
82
+ end)
83
+ end
84
+
47
85
  # Test helper methods
48
86
  class ActiveSupport::TestCase
49
87
  # Run tests in parallel with specified workers
@@ -284,6 +284,21 @@ class PauseResumeTest < ActiveSupport::TestCase
284
284
  end
285
285
  end
286
286
 
287
+ test "resume! records the re-enqueued job's id on the execution" do
288
+ workflow = WaitingWorkflow.create!(hero: @user)
289
+ perform_next_step(workflow)
290
+ execution = workflow.step_executions.find_by!(step_name: "step_two", state: "scheduled")
291
+ original_job_id = execution.job_id
292
+ assert original_job_id
293
+
294
+ workflow.pause!
295
+ workflow.resume!
296
+
297
+ assert_equal execution.id, workflow.current_execution.id
298
+ refute_equal original_job_id, execution.reload.job_id,
299
+ "the row must point at the job resume! pushed, not the one that may already have run"
300
+ end
301
+
287
302
  # Scenario: Resume with SCHEDULED execution now OVERDUE
288
303
  #
289
304
  # Workflow was paused, time passed, the scheduled time went by.