geneva_drive 0.8.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/MANUAL.md +215 -4
- data/README.md +14 -1
- data/Rakefile +14 -0
- data/bin/matrix +1 -1
- data/lib/generators/geneva_drive/install/templates/create_workflows_migration.rb +1 -1
- data/lib/generators/geneva_drive/install/templates/initializer.rb.tt +33 -2
- data/lib/geneva_drive/dispatchers/active_job_dispatcher.rb +33 -0
- data/lib/geneva_drive/dispatchers/sidekiq_dispatcher.rb +112 -0
- data/lib/geneva_drive/engine.rb +13 -0
- data/lib/geneva_drive/executor.rb +5 -3
- data/lib/geneva_drive/job_options.rb +7 -3
- data/lib/geneva_drive/jobs/perform_step_job.rb +2 -35
- data/lib/geneva_drive/jobs/sidekiq_housekeeping_job.rb +32 -0
- data/lib/geneva_drive/jobs/sidekiq_perform_step_job.rb +35 -0
- data/lib/geneva_drive/jobs/step_execution_lookup.rb +42 -0
- data/lib/geneva_drive/migration_helpers.rb +33 -3
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow.rb +30 -37
- data/lib/geneva_drive.rb +63 -0
- data/test/dummy/config/initializers/geneva_drive.rb +35 -3
- data/test/dummy/db/mysql/schema-8.1.rb +95 -0
- data/test/dummy/db/postgres/schema-8.1.rb +99 -0
- data/test/dummy/db/sqlite/schema-7.2.rb +96 -0
- data/test/dummy/db/sqlite/schema-8.0.rb +96 -0
- data/test/dummy/db/sqlite/schema-8.1.rb +96 -0
- data/test/dummy/db/sqlite/test.sqlite3 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-0 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-1 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-2 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-3 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-4 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-5 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-6 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-7 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-8 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3-9 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_0 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_1 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_2 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_3 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_4 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_5 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_6 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_7 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_8 +0 -0
- data/test/dummy/db/sqlite/test.sqlite3_9 +0 -0
- data/test/dummy/db/sqlite3/schema-7.2.rb +96 -0
- data/test/dummy/db/sqlite3/schema-8.1.rb +96 -0
- data/test/dummy/db/sqlite3/test.sqlite3 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_0 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_1 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_2 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_3 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_4 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_5 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_6 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_7 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_8 +0 -0
- data/test/dummy/db/sqlite3/test.sqlite3_9 +0 -0
- data/test/generators/install_generator_test.rb +56 -0
- data/test/geneva_drive_test.rb +10 -0
- data/test/migration_helpers_test.rb +62 -0
- data/test/sidekiq/sidekiq_dispatcher_test.rb +322 -0
- data/test/sidekiq/sidekiq_redis_test.rb +115 -0
- data/test/workflow/pause_resume_test.rb +15 -0
- metadata +49 -2
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
data/test/geneva_drive_test.rb
CHANGED
|
@@ -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
|
|
@@ -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.
|