geneva_drive 0.6.0 → 0.8.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 +10 -1
- data/MANUAL.md +260 -1
- data/README.md +2 -0
- data/Rakefile +14 -1
- data/bin/matrix +76 -0
- data/bin/md2html +114 -0
- data/lib/generators/geneva_drive/install/install_generator.rb +5 -0
- data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +5 -9
- data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +89 -0
- data/lib/geneva_drive/executor.rb +131 -10
- data/lib/geneva_drive/flow_control.rb +43 -3
- data/lib/geneva_drive/jobs/housekeeping_job.rb +83 -3
- data/lib/geneva_drive/json_column.rb +72 -0
- data/lib/geneva_drive/migration_helpers.rb +27 -0
- data/lib/geneva_drive/removed_step_definition.rb +34 -0
- data/lib/geneva_drive/signal.rb +245 -0
- data/lib/geneva_drive/signal_matcher.rb +60 -0
- data/lib/geneva_drive/step_collection.rb +31 -7
- data/lib/geneva_drive/step_definition.rb +91 -1
- data/lib/geneva_drive/step_execution.rb +39 -2
- data/lib/geneva_drive/test_helpers.rb +89 -2
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow.rb +497 -24
- data/lib/geneva_drive.rb +24 -0
- data/test/dsl/signal_step_definition_test.rb +132 -0
- data/test/dummy/config/database.yml +40 -5
- data/test/dummy/config/initializers/geneva_drive.rb +46 -0
- data/test/dummy_install/db/migrate/20260217112949_create_geneva_drive_workflows.rb +76 -0
- data/test/dummy_install/db/migrate/20260217112950_create_geneva_drive_step_executions.rb +99 -0
- data/test/dummy_install/db/migrate/20260217112951_add_finished_at_to_geneva_drive_step_executions.rb +27 -0
- data/test/dummy_install/db/migrate/20260217112952_add_error_class_name_to_geneva_drive_step_executions.rb +9 -0
- data/test/dummy_install/db/migrate/20260217112953_add_resumable_step_support_to_geneva_drive_step_executions.rb +128 -0
- data/test/dummy_install/db/migrate/20260217112954_add_chained_step_executions_to_geneva_drive_step_executions.rb +136 -0
- data/test/dummy_install/db/schema.rb +75 -0
- data/test/dummy_install/log/test.log +1852 -0
- data/test/jobs/housekeeping_signals_test.rb +198 -0
- data/test/migration_helpers_test.rb +1 -1
- data/test/test_helper.rb +53 -13
- data/test/workflow/removed_step_test.rb +294 -0
- data/test/workflow/resume_at_test.rb +170 -0
- data/test/workflow/signal_consumption_test.rb +397 -0
- data/test/workflow/signal_delivery_test.rb +255 -0
- data/test/workflow/signal_durability_test.rb +164 -0
- data/test/workflow/signal_rendezvous_test.rb +581 -0
- data/test/workflow/signals_without_migration_test.rb +97 -0
- metadata +26 -2
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
|
|
5
|
+
# wait: and wait_for: read alike and take disjoint types, so the step
|
|
6
|
+
# definition enforces the disjointness in both directions at class load.
|
|
7
|
+
class SignalStepDefinitionTest < ActiveSupport::TestCase
|
|
8
|
+
class NameSetMatcher
|
|
9
|
+
def initialize(*names)
|
|
10
|
+
@names = names.map(&:to_s)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def matches?(signal)
|
|
14
|
+
@names.include?(signal.name)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def build_step(**options)
|
|
19
|
+
GenevaDrive::StepDefinition.new(name: "capture", callable: proc {}, **options)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
test "wait_for: with a symbol builds a name matcher" do
|
|
23
|
+
step_def = build_step(wait_for: :payment_confirmed)
|
|
24
|
+
|
|
25
|
+
assert step_def.waits_for_signal?
|
|
26
|
+
assert_equal "payment_confirmed", step_def.signal_matcher.name
|
|
27
|
+
assert_nil step_def.signal_matcher.condition
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
test "wait_for: with a string builds a name matcher" do
|
|
31
|
+
step_def = build_step(wait_for: "payment_confirmed")
|
|
32
|
+
|
|
33
|
+
assert step_def.waits_for_signal?
|
|
34
|
+
assert_equal "payment_confirmed", step_def.signal_matcher.name
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
test "wait_for: with a SignalMatcher keeps it as-is" do
|
|
38
|
+
matcher = GenevaDrive::SignalMatcher.new(:payment_confirmed) { |payload| payload[:order_id] == 1 }
|
|
39
|
+
step_def = build_step(wait_for: matcher)
|
|
40
|
+
|
|
41
|
+
assert_same matcher, step_def.signal_matcher
|
|
42
|
+
assert_equal "payment_confirmed", step_def.signal_matcher.name
|
|
43
|
+
assert step_def.signal_matcher.condition
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
test "a SignalMatcher needs a name" do
|
|
47
|
+
assert_raises(ArgumentError) { GenevaDrive::SignalMatcher.new(nil) }
|
|
48
|
+
assert_raises(ArgumentError) { GenevaDrive::SignalMatcher.new("") }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
test "wait_for: with a custom matcher object delegates the whole predicate" do
|
|
52
|
+
matcher = NameSetMatcher.new(:a, :b)
|
|
53
|
+
step_def = build_step(wait_for: matcher)
|
|
54
|
+
|
|
55
|
+
assert step_def.waits_for_signal?
|
|
56
|
+
assert_same matcher, step_def.signal_matcher
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
test "steps without wait_for: do not wait for a signal" do
|
|
60
|
+
assert_not build_step.waits_for_signal?
|
|
61
|
+
assert_nil build_step.signal_matcher
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
test "wait_for: with a duration points at wait:" do
|
|
65
|
+
error = assert_raises(GenevaDrive::StepConfigurationError) { build_step(wait_for: 2.days) }
|
|
66
|
+
|
|
67
|
+
assert_match(/wait_for: 2 days/, error.message)
|
|
68
|
+
assert_match(/to delay the step, use wait:/, error.message)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
test "wait_for: with a number points at wait:" do
|
|
72
|
+
error = assert_raises(GenevaDrive::StepConfigurationError) { build_step(wait_for: 30) }
|
|
73
|
+
|
|
74
|
+
assert_match(/to delay the step, use wait:/, error.message)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
test "wait_for: with a time points at wait:" do
|
|
78
|
+
error = assert_raises(GenevaDrive::StepConfigurationError) { build_step(wait_for: Time.current) }
|
|
79
|
+
|
|
80
|
+
assert_match(/to delay the step, use wait:/, error.message)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
test "wait: with a symbol points at wait_for:" do
|
|
84
|
+
error = assert_raises(GenevaDrive::StepConfigurationError) { build_step(wait: :payment_confirmed) }
|
|
85
|
+
|
|
86
|
+
assert_match(/wait: :payment_confirmed/, error.message)
|
|
87
|
+
assert_match(/to wait for a signal, use wait_for:/, error.message)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
test "wait: with a string points at wait_for: instead of silently meaning zero seconds" do
|
|
91
|
+
error = assert_raises(GenevaDrive::StepConfigurationError) { build_step(wait: "payment_confirmed") }
|
|
92
|
+
|
|
93
|
+
assert_match(/to wait for a signal, use wait_for:/, error.message)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
test "wait: with a duration-shaped string is rejected too" do
|
|
97
|
+
error = assert_raises(GenevaDrive::StepConfigurationError) { build_step(wait: "7200") }
|
|
98
|
+
|
|
99
|
+
assert_match(/to wait for a signal, use wait_for:/, error.message)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
test "wait: with a matcher object points at wait_for:" do
|
|
103
|
+
error = assert_raises(GenevaDrive::StepConfigurationError) { build_step(wait: NameSetMatcher.new(:a)) }
|
|
104
|
+
|
|
105
|
+
assert_match(/to wait for a signal, use wait_for:/, error.message)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
test "wait: still accepts durations and numbers" do
|
|
109
|
+
assert_equal 2.days, build_step(wait: 2.days).wait
|
|
110
|
+
assert_equal 30, build_step(wait: 30).wait
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
test "wait_for: with an unusable value is rejected" do
|
|
114
|
+
error = assert_raises(GenevaDrive::StepConfigurationError) { build_step(wait_for: Object.new) }
|
|
115
|
+
|
|
116
|
+
assert_match(/must be a Symbol, String, GenevaDrive::SignalMatcher, or an object responding to #matches\?/, error.message)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
test "wait: and wait_for: compose" do
|
|
120
|
+
step_def = build_step(wait: 2.days, wait_for: :payment_confirmed)
|
|
121
|
+
|
|
122
|
+
assert_equal 2.days, step_def.wait
|
|
123
|
+
assert step_def.waits_for_signal?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
test "resumable steps accept wait_for:" do
|
|
127
|
+
step_def = GenevaDrive::ResumableStepDefinition.new(name: "iterate", wait_for: :go) { |iter| }
|
|
128
|
+
|
|
129
|
+
assert step_def.resumable?
|
|
130
|
+
assert step_def.waits_for_signal?
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -1,13 +1,48 @@
|
|
|
1
|
-
|
|
1
|
+
<%
|
|
2
|
+
# Everything the database tasks write under db/ is adapter-specific, the
|
|
3
|
+
# schema dump above all: parallelize() builds every worker database out of
|
|
4
|
+
# that dump, and a PostgreSQL one carries jsonb columns SQLite cannot load.
|
|
5
|
+
# CI points DATABASE_URL at a different engine per leg, so each engine gets
|
|
6
|
+
# its own corner of db/ and the legs stop overwriting each other's artifacts.
|
|
7
|
+
#
|
|
8
|
+
# This has to be resolved here rather than in an initializer - db:prepare and
|
|
9
|
+
# the other database tasks hang off :load_config, which never runs those.
|
|
10
|
+
url = ENV["DATABASE_URL"]
|
|
11
|
+
adapter = if url
|
|
12
|
+
ActiveRecord::DatabaseConfigurations::ConnectionUrlResolver.new(url).to_hash[:adapter]
|
|
13
|
+
else
|
|
14
|
+
"sqlite3"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# MariaDB and MySQL are both the mysql2 adapter but do not produce the same
|
|
18
|
+
# dump - MariaDB has no JSON type - so the engine, when the runner names one,
|
|
19
|
+
# is what the artifacts get filed under.
|
|
20
|
+
engine = ENV.fetch("GENEVA_DRIVE_ENGINE", adapter)
|
|
21
|
+
artifacts = "db/#{engine}"
|
|
22
|
+
|
|
23
|
+
# The dump is keyed by Rails minor on top of that: it is written as
|
|
24
|
+
# `ActiveRecord::Schema[8.1].define`, and 7.2 refuses to load a version it
|
|
25
|
+
# has never heard of. The databases themselves are not version-specific -
|
|
26
|
+
# the migrations produce the same DDL - so only the dump gets the suffix.
|
|
27
|
+
schema_dump = "#{engine}/schema-#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}.rb"
|
|
28
|
+
|
|
29
|
+
# Rails only creates db/ itself, not the subdirectory the dump lives in.
|
|
30
|
+
require "fileutils"
|
|
31
|
+
FileUtils.mkdir_p(Rails.root.join(artifacts))
|
|
32
|
+
%>
|
|
33
|
+
# The test suite runs against SQLite by default so that a fresh clone can run
|
|
34
|
+
# `rake` with no database server. CI - and `bin/matrix` locally - override this
|
|
35
|
+
# with DATABASE_URL to also exercise PostgreSQL and MySQL.
|
|
2
36
|
default: &default
|
|
3
|
-
adapter:
|
|
4
|
-
encoding: unicode
|
|
37
|
+
adapter: sqlite3
|
|
5
38
|
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
|
|
39
|
+
timeout: 5000
|
|
40
|
+
schema_dump: <%= schema_dump %>
|
|
6
41
|
|
|
7
42
|
development:
|
|
8
43
|
<<: *default
|
|
9
|
-
database:
|
|
44
|
+
database: storage/development.sqlite3
|
|
10
45
|
|
|
11
46
|
test:
|
|
12
47
|
<<: *default
|
|
13
|
-
database:
|
|
48
|
+
database: <%= artifacts %>/test.sqlite3
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# license: LGPLv3.0
|
|
4
|
+
|
|
5
|
+
# How long to keep completed/canceled workflows before automatic deletion.
|
|
6
|
+
# Set to nil to disable automatic cleanup.
|
|
7
|
+
# When enabled, finished and canceled workflows older than this threshold
|
|
8
|
+
# will be deleted along with their step execution records.
|
|
9
|
+
#
|
|
10
|
+
# GenevaDrive.delete_completed_workflows_after = 30.days
|
|
11
|
+
|
|
12
|
+
# How long a step execution can be in "executing" state before being
|
|
13
|
+
# considered stuck. This typically happens when a worker process crashes
|
|
14
|
+
# mid-execution. The HousekeepingJob will recover these based on the
|
|
15
|
+
# stuck_recovery_action setting.
|
|
16
|
+
#
|
|
17
|
+
# GenevaDrive.stuck_executing_threshold = 1.hour
|
|
18
|
+
|
|
19
|
+
# How long a step execution can be past its scheduled_for time while
|
|
20
|
+
# still in "scheduled" state before being considered stuck. This can
|
|
21
|
+
# happen if jobs fail to enqueue or are lost by the queue backend.
|
|
22
|
+
#
|
|
23
|
+
# GenevaDrive.stuck_scheduled_threshold = 15.minutes
|
|
24
|
+
|
|
25
|
+
# Action to take when recovering stuck step executions.
|
|
26
|
+
# - :reattempt - Mark as recovered and schedule a retry (default)
|
|
27
|
+
# - :cancel - Mark as recovered and cancel the workflow
|
|
28
|
+
#
|
|
29
|
+
# GenevaDrive.stuck_recovery_action = :reattempt
|
|
30
|
+
|
|
31
|
+
# Maximum number of workflows/step executions to process in a single
|
|
32
|
+
# housekeeping run. Prevents runaway processing if there's a large backlog.
|
|
33
|
+
#
|
|
34
|
+
# GenevaDrive.housekeeping_batch_size = 1000
|
|
35
|
+
|
|
36
|
+
# Whether to defer job enqueueing until after the database transaction commits.
|
|
37
|
+
# In production this ensures step execution records are visible to job workers
|
|
38
|
+
# before the job runs. In test environments (the default when Rails.env.test?)
|
|
39
|
+
# this is set to false so that jobs are enqueued immediately — transactional
|
|
40
|
+
# tests (especially with SQLite) never commit the outermost transaction, which
|
|
41
|
+
# can cause after_commit callbacks to misbehave or not fire at all.
|
|
42
|
+
#
|
|
43
|
+
# Set to true in tests if you need strict transaction semantics for a specific
|
|
44
|
+
# test suite, or if you are not using transactional tests.
|
|
45
|
+
#
|
|
46
|
+
# GenevaDrive.enqueue_after_commit = !Rails.env.test?
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# license: LGPLv3.0
|
|
4
|
+
|
|
5
|
+
class CreateGenevaDriveWorkflows < ActiveRecord::Migration[7.2]
|
|
6
|
+
include GenevaDrive::MigrationHelpers
|
|
7
|
+
|
|
8
|
+
def change
|
|
9
|
+
create_table :geneva_drive_workflows, **geneva_drive_table_options do |t|
|
|
10
|
+
# Core identification (STI)
|
|
11
|
+
t.string :type, null: false, index: true
|
|
12
|
+
|
|
13
|
+
# Polymorphic association to the hero of the workflow
|
|
14
|
+
t.string :hero_type, null: false
|
|
15
|
+
t.column :hero_id, geneva_drive_key_type, null: false
|
|
16
|
+
|
|
17
|
+
# State machine
|
|
18
|
+
t.string :state, null: false, default: "ready", index: true
|
|
19
|
+
|
|
20
|
+
# Current position in workflow
|
|
21
|
+
t.string :current_step_name
|
|
22
|
+
t.string :next_step_name
|
|
23
|
+
|
|
24
|
+
# Multiple workflows of same type for same hero
|
|
25
|
+
t.boolean :allow_multiple, default: false, null: false
|
|
26
|
+
|
|
27
|
+
# Timestamps
|
|
28
|
+
t.datetime :started_at
|
|
29
|
+
t.datetime :transitioned_at
|
|
30
|
+
|
|
31
|
+
t.timestamps
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Polymorphic index
|
|
35
|
+
add_index :geneva_drive_workflows, [:hero_type, :hero_id]
|
|
36
|
+
|
|
37
|
+
reversible do |direction|
|
|
38
|
+
direction.up { create_db_specific_indices }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def create_db_specific_indices
|
|
43
|
+
# Database-specific uniqueness constraint for ongoing workflows
|
|
44
|
+
# Ensures only one ongoing workflow per (type, hero) unless allow_multiple is true
|
|
45
|
+
adapter = connection.adapter_name.downcase
|
|
46
|
+
if adapter.include?("postgresql")
|
|
47
|
+
execute <<-SQL
|
|
48
|
+
CREATE UNIQUE INDEX index_geneva_drive_workflows_unique_ongoing
|
|
49
|
+
ON geneva_drive_workflows (type, hero_type, hero_id)
|
|
50
|
+
WHERE state NOT IN ('finished', 'canceled') AND allow_multiple = false;
|
|
51
|
+
SQL
|
|
52
|
+
elsif adapter.include?("mysql")
|
|
53
|
+
execute <<-SQL
|
|
54
|
+
ALTER TABLE geneva_drive_workflows
|
|
55
|
+
ADD COLUMN ongoing_unique_key VARCHAR(767)
|
|
56
|
+
AS (
|
|
57
|
+
CASE
|
|
58
|
+
WHEN state NOT IN ('finished', 'canceled') AND allow_multiple = 0
|
|
59
|
+
THEN CONCAT(type, '-', hero_type, '-', hero_id)
|
|
60
|
+
ELSE NULL
|
|
61
|
+
END
|
|
62
|
+
) STORED;
|
|
63
|
+
SQL
|
|
64
|
+
execute <<-SQL
|
|
65
|
+
CREATE UNIQUE INDEX index_geneva_drive_workflows_unique_ongoing
|
|
66
|
+
ON geneva_drive_workflows (ongoing_unique_key);
|
|
67
|
+
SQL
|
|
68
|
+
elsif adapter.include?("sqlite")
|
|
69
|
+
execute <<-SQL
|
|
70
|
+
CREATE UNIQUE INDEX index_geneva_drive_workflows_unique_ongoing
|
|
71
|
+
ON geneva_drive_workflows (type, hero_type, hero_id)
|
|
72
|
+
WHERE state NOT IN ('finished', 'canceled') AND allow_multiple = 0;
|
|
73
|
+
SQL
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# license: LGPLv3.0
|
|
4
|
+
|
|
5
|
+
class CreateGenevaDriveStepExecutions < ActiveRecord::Migration[7.2]
|
|
6
|
+
include GenevaDrive::MigrationHelpers
|
|
7
|
+
|
|
8
|
+
def change
|
|
9
|
+
key_type = geneva_drive_key_type
|
|
10
|
+
|
|
11
|
+
create_table :geneva_drive_step_executions, **geneva_drive_table_options do |t|
|
|
12
|
+
# Link to workflow (cascade delete when workflow is deleted)
|
|
13
|
+
t.references :workflow,
|
|
14
|
+
null: false,
|
|
15
|
+
foreign_key: {to_table: :geneva_drive_workflows, on_delete: :cascade},
|
|
16
|
+
index: true,
|
|
17
|
+
type: key_type
|
|
18
|
+
|
|
19
|
+
# Which step this execution represents
|
|
20
|
+
t.string :step_name, null: false
|
|
21
|
+
|
|
22
|
+
# Execution state machine
|
|
23
|
+
t.string :state, null: false, default: "scheduled", index: true
|
|
24
|
+
|
|
25
|
+
# Outcome for audit purposes
|
|
26
|
+
t.string :outcome
|
|
27
|
+
|
|
28
|
+
# Scheduling
|
|
29
|
+
t.datetime :scheduled_for, null: false, index: true
|
|
30
|
+
|
|
31
|
+
# Execution tracking (individual timestamps for audit trail)
|
|
32
|
+
t.datetime :started_at
|
|
33
|
+
t.datetime :completed_at
|
|
34
|
+
t.datetime :failed_at
|
|
35
|
+
t.datetime :canceled_at
|
|
36
|
+
t.datetime :skipped_at
|
|
37
|
+
|
|
38
|
+
# Error tracking
|
|
39
|
+
t.string :error_class_name
|
|
40
|
+
t.text :error_message
|
|
41
|
+
t.text :error_backtrace
|
|
42
|
+
|
|
43
|
+
# Job tracking (for debugging)
|
|
44
|
+
t.string :job_id
|
|
45
|
+
|
|
46
|
+
t.timestamps
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Index for finding scheduled executions
|
|
50
|
+
add_index :geneva_drive_step_executions,
|
|
51
|
+
[:state, :scheduled_for],
|
|
52
|
+
name: "index_geneva_drive_step_executions_scheduled"
|
|
53
|
+
|
|
54
|
+
# Index for workflow execution history
|
|
55
|
+
add_index :geneva_drive_step_executions, [:workflow_id, :created_at]
|
|
56
|
+
|
|
57
|
+
# Index for common query patterns
|
|
58
|
+
add_index :geneva_drive_step_executions, [:workflow_id, :state]
|
|
59
|
+
|
|
60
|
+
reversible do |direction|
|
|
61
|
+
direction.up { create_db_specific_indices }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def create_db_specific_indices
|
|
66
|
+
# Database-specific uniqueness constraint for active step executions
|
|
67
|
+
# Ensures only one active (scheduled/in_progress) step per workflow
|
|
68
|
+
adapter = connection.adapter_name.downcase
|
|
69
|
+
if adapter.include?("postgresql")
|
|
70
|
+
execute <<-SQL
|
|
71
|
+
CREATE UNIQUE INDEX index_geneva_drive_step_executions_one_active
|
|
72
|
+
ON geneva_drive_step_executions (workflow_id)
|
|
73
|
+
WHERE state IN ('scheduled', 'in_progress');
|
|
74
|
+
SQL
|
|
75
|
+
elsif adapter.include?("mysql")
|
|
76
|
+
execute <<-SQL
|
|
77
|
+
ALTER TABLE geneva_drive_step_executions
|
|
78
|
+
ADD COLUMN active_unique_key VARCHAR(767)
|
|
79
|
+
AS (
|
|
80
|
+
CASE
|
|
81
|
+
WHEN state IN ('scheduled', 'in_progress')
|
|
82
|
+
THEN CAST(workflow_id AS CHAR)
|
|
83
|
+
ELSE NULL
|
|
84
|
+
END
|
|
85
|
+
) STORED;
|
|
86
|
+
SQL
|
|
87
|
+
execute <<-SQL
|
|
88
|
+
CREATE UNIQUE INDEX index_geneva_drive_step_executions_one_active
|
|
89
|
+
ON geneva_drive_step_executions (active_unique_key);
|
|
90
|
+
SQL
|
|
91
|
+
elsif adapter.include?("sqlite")
|
|
92
|
+
execute <<-SQL
|
|
93
|
+
CREATE UNIQUE INDEX index_geneva_drive_step_executions_one_active
|
|
94
|
+
ON geneva_drive_step_executions (workflow_id)
|
|
95
|
+
WHERE state IN ('scheduled', 'in_progress');
|
|
96
|
+
SQL
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
data/test/dummy_install/db/migrate/20260217112951_add_finished_at_to_geneva_drive_step_executions.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# license: LGPLv3.0
|
|
4
|
+
|
|
5
|
+
class AddFinishedAtToGenevaDriveStepExecutions < ActiveRecord::Migration[7.2]
|
|
6
|
+
def change
|
|
7
|
+
add_column :geneva_drive_step_executions, :finished_at, :datetime
|
|
8
|
+
|
|
9
|
+
add_index :geneva_drive_step_executions, :finished_at
|
|
10
|
+
|
|
11
|
+
reversible do |direction|
|
|
12
|
+
direction.up do
|
|
13
|
+
# Backfill finished_at from existing timestamp columns.
|
|
14
|
+
# Uses COALESCE to pick the first non-null value.
|
|
15
|
+
execute <<-SQL.squish
|
|
16
|
+
UPDATE geneva_drive_step_executions
|
|
17
|
+
SET finished_at = COALESCE(completed_at, failed_at, canceled_at, skipped_at)
|
|
18
|
+
WHERE finished_at IS NULL
|
|
19
|
+
AND (completed_at IS NOT NULL
|
|
20
|
+
OR failed_at IS NOT NULL
|
|
21
|
+
OR canceled_at IS NOT NULL
|
|
22
|
+
OR skipped_at IS NOT NULL)
|
|
23
|
+
SQL
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# license: LGPLv3.0
|
|
4
|
+
|
|
5
|
+
class AddResumableStepSupportToGenevaDriveStepExecutions < ActiveRecord::Migration[7.2]
|
|
6
|
+
def change
|
|
7
|
+
# Use database-native JSON type:
|
|
8
|
+
# - PostgreSQL: jsonb (indexed, efficient, supports containment queries)
|
|
9
|
+
# - MySQL 5.7+: json (native validation and storage)
|
|
10
|
+
# - SQLite: json (Rails handles as TEXT with serialization)
|
|
11
|
+
adapter = connection.adapter_name.downcase
|
|
12
|
+
if adapter.include?("postgresql")
|
|
13
|
+
add_column :geneva_drive_step_executions, :cursor, :jsonb
|
|
14
|
+
else
|
|
15
|
+
add_column :geneva_drive_step_executions, :cursor, :json
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
add_column :geneva_drive_step_executions, :completed_iterations, :integer, default: 0, null: false
|
|
19
|
+
|
|
20
|
+
reversible do |direction|
|
|
21
|
+
direction.up { update_unique_index_for_suspended_state }
|
|
22
|
+
direction.down { restore_original_unique_index }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def update_unique_index_for_suspended_state
|
|
29
|
+
# Update unique constraint to include 'suspended' as an active state.
|
|
30
|
+
# The constraint ensures only one active step execution per workflow.
|
|
31
|
+
adapter = connection.adapter_name.downcase
|
|
32
|
+
|
|
33
|
+
if adapter.include?("postgresql")
|
|
34
|
+
execute <<-SQL
|
|
35
|
+
DROP INDEX IF EXISTS index_geneva_drive_step_executions_one_active;
|
|
36
|
+
SQL
|
|
37
|
+
execute <<-SQL
|
|
38
|
+
CREATE UNIQUE INDEX index_geneva_drive_step_executions_one_active
|
|
39
|
+
ON geneva_drive_step_executions (workflow_id)
|
|
40
|
+
WHERE state IN ('scheduled', 'in_progress', 'suspended');
|
|
41
|
+
SQL
|
|
42
|
+
elsif adapter.include?("mysql")
|
|
43
|
+
# Drop the old generated column and index
|
|
44
|
+
execute <<-SQL
|
|
45
|
+
DROP INDEX index_geneva_drive_step_executions_one_active
|
|
46
|
+
ON geneva_drive_step_executions;
|
|
47
|
+
SQL
|
|
48
|
+
execute <<-SQL
|
|
49
|
+
ALTER TABLE geneva_drive_step_executions
|
|
50
|
+
DROP COLUMN active_unique_key;
|
|
51
|
+
SQL
|
|
52
|
+
# Create new generated column including suspended state
|
|
53
|
+
execute <<-SQL
|
|
54
|
+
ALTER TABLE geneva_drive_step_executions
|
|
55
|
+
ADD COLUMN active_unique_key VARCHAR(767)
|
|
56
|
+
AS (
|
|
57
|
+
CASE
|
|
58
|
+
WHEN state IN ('scheduled', 'in_progress', 'suspended')
|
|
59
|
+
THEN CAST(workflow_id AS CHAR)
|
|
60
|
+
ELSE NULL
|
|
61
|
+
END
|
|
62
|
+
) STORED;
|
|
63
|
+
SQL
|
|
64
|
+
execute <<-SQL
|
|
65
|
+
CREATE UNIQUE INDEX index_geneva_drive_step_executions_one_active
|
|
66
|
+
ON geneva_drive_step_executions (active_unique_key);
|
|
67
|
+
SQL
|
|
68
|
+
elsif adapter.include?("sqlite")
|
|
69
|
+
execute <<-SQL
|
|
70
|
+
DROP INDEX IF EXISTS index_geneva_drive_step_executions_one_active;
|
|
71
|
+
SQL
|
|
72
|
+
execute <<-SQL
|
|
73
|
+
CREATE UNIQUE INDEX index_geneva_drive_step_executions_one_active
|
|
74
|
+
ON geneva_drive_step_executions (workflow_id)
|
|
75
|
+
WHERE state IN ('scheduled', 'in_progress', 'suspended');
|
|
76
|
+
SQL
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def restore_original_unique_index
|
|
81
|
+
# Restore original unique constraint without 'suspended' state
|
|
82
|
+
adapter = connection.adapter_name.downcase
|
|
83
|
+
|
|
84
|
+
if adapter.include?("postgresql")
|
|
85
|
+
execute <<-SQL
|
|
86
|
+
DROP INDEX IF EXISTS index_geneva_drive_step_executions_one_active;
|
|
87
|
+
SQL
|
|
88
|
+
execute <<-SQL
|
|
89
|
+
CREATE UNIQUE INDEX index_geneva_drive_step_executions_one_active
|
|
90
|
+
ON geneva_drive_step_executions (workflow_id)
|
|
91
|
+
WHERE state IN ('scheduled', 'in_progress');
|
|
92
|
+
SQL
|
|
93
|
+
elsif adapter.include?("mysql")
|
|
94
|
+
execute <<-SQL
|
|
95
|
+
DROP INDEX index_geneva_drive_step_executions_one_active
|
|
96
|
+
ON geneva_drive_step_executions;
|
|
97
|
+
SQL
|
|
98
|
+
execute <<-SQL
|
|
99
|
+
ALTER TABLE geneva_drive_step_executions
|
|
100
|
+
DROP COLUMN active_unique_key;
|
|
101
|
+
SQL
|
|
102
|
+
execute <<-SQL
|
|
103
|
+
ALTER TABLE geneva_drive_step_executions
|
|
104
|
+
ADD COLUMN active_unique_key VARCHAR(767)
|
|
105
|
+
AS (
|
|
106
|
+
CASE
|
|
107
|
+
WHEN state IN ('scheduled', 'in_progress')
|
|
108
|
+
THEN CAST(workflow_id AS CHAR)
|
|
109
|
+
ELSE NULL
|
|
110
|
+
END
|
|
111
|
+
) STORED;
|
|
112
|
+
SQL
|
|
113
|
+
execute <<-SQL
|
|
114
|
+
CREATE UNIQUE INDEX index_geneva_drive_step_executions_one_active
|
|
115
|
+
ON geneva_drive_step_executions (active_unique_key);
|
|
116
|
+
SQL
|
|
117
|
+
elsif adapter.include?("sqlite")
|
|
118
|
+
execute <<-SQL
|
|
119
|
+
DROP INDEX IF EXISTS index_geneva_drive_step_executions_one_active;
|
|
120
|
+
SQL
|
|
121
|
+
execute <<-SQL
|
|
122
|
+
CREATE UNIQUE INDEX index_geneva_drive_step_executions_one_active
|
|
123
|
+
ON geneva_drive_step_executions (workflow_id)
|
|
124
|
+
WHERE state IN ('scheduled', 'in_progress');
|
|
125
|
+
SQL
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|