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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -1
  3. data/MANUAL.md +260 -1
  4. data/README.md +2 -0
  5. data/Rakefile +14 -1
  6. data/bin/matrix +76 -0
  7. data/bin/md2html +114 -0
  8. data/lib/generators/geneva_drive/install/install_generator.rb +5 -0
  9. data/lib/generators/geneva_drive/install/templates/add_resumable_step_support.rb +5 -9
  10. data/lib/generators/geneva_drive/install/templates/add_signals_support.rb +89 -0
  11. data/lib/geneva_drive/executor.rb +131 -10
  12. data/lib/geneva_drive/flow_control.rb +43 -3
  13. data/lib/geneva_drive/jobs/housekeeping_job.rb +83 -3
  14. data/lib/geneva_drive/json_column.rb +72 -0
  15. data/lib/geneva_drive/migration_helpers.rb +27 -0
  16. data/lib/geneva_drive/removed_step_definition.rb +34 -0
  17. data/lib/geneva_drive/signal.rb +245 -0
  18. data/lib/geneva_drive/signal_matcher.rb +60 -0
  19. data/lib/geneva_drive/step_collection.rb +31 -7
  20. data/lib/geneva_drive/step_definition.rb +91 -1
  21. data/lib/geneva_drive/step_execution.rb +39 -2
  22. data/lib/geneva_drive/test_helpers.rb +89 -2
  23. data/lib/geneva_drive/version.rb +1 -1
  24. data/lib/geneva_drive/workflow.rb +497 -24
  25. data/lib/geneva_drive.rb +24 -0
  26. data/test/dsl/signal_step_definition_test.rb +132 -0
  27. data/test/dummy/config/database.yml +40 -5
  28. data/test/dummy/config/initializers/geneva_drive.rb +46 -0
  29. data/test/dummy_install/db/migrate/20260217112949_create_geneva_drive_workflows.rb +76 -0
  30. data/test/dummy_install/db/migrate/20260217112950_create_geneva_drive_step_executions.rb +99 -0
  31. data/test/dummy_install/db/migrate/20260217112951_add_finished_at_to_geneva_drive_step_executions.rb +27 -0
  32. data/test/dummy_install/db/migrate/20260217112952_add_error_class_name_to_geneva_drive_step_executions.rb +9 -0
  33. data/test/dummy_install/db/migrate/20260217112953_add_resumable_step_support_to_geneva_drive_step_executions.rb +128 -0
  34. data/test/dummy_install/db/migrate/20260217112954_add_chained_step_executions_to_geneva_drive_step_executions.rb +136 -0
  35. data/test/dummy_install/db/schema.rb +75 -0
  36. data/test/dummy_install/log/test.log +1852 -0
  37. data/test/jobs/housekeeping_signals_test.rb +198 -0
  38. data/test/migration_helpers_test.rb +1 -1
  39. data/test/test_helper.rb +53 -13
  40. data/test/workflow/removed_step_test.rb +294 -0
  41. data/test/workflow/resume_at_test.rb +170 -0
  42. data/test/workflow/signal_consumption_test.rb +397 -0
  43. data/test/workflow/signal_delivery_test.rb +255 -0
  44. data/test/workflow/signal_durability_test.rb +164 -0
  45. data/test/workflow/signal_rendezvous_test.rb +581 -0
  46. data/test/workflow/signals_without_migration_test.rb +97 -0
  47. 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
- # PostgreSQL configuration for GenevaDrive test suite
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: postgresql
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: geneva_drive_development
44
+ database: storage/development.sqlite3
10
45
 
11
46
  test:
12
47
  <<: *default
13
- database: geneva_drive_test
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
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # license: LGPLv3.0
4
+
5
+ class AddErrorClassNameToGenevaDriveStepExecutions < ActiveRecord::Migration[7.2]
6
+ def change
7
+ add_column :geneva_drive_step_executions, :error_class_name, :string, if_not_exists: true
8
+ end
9
+ 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