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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/MANUAL.md +299 -5
- data/README.md +14 -1
- data/Rakefile +28 -1
- data/bin/matrix +76 -0
- data/bin/md2html +114 -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 +5 -10
- 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 +23 -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/json_column.rb +72 -0
- data/lib/geneva_drive/migration_helpers.rb +60 -3
- data/lib/geneva_drive/removed_step_definition.rb +34 -0
- data/lib/geneva_drive/signal.rb +4 -2
- data/lib/geneva_drive/step_collection.rb +31 -7
- data/lib/geneva_drive/step_definition.rb +9 -0
- data/lib/geneva_drive/step_execution.rb +3 -2
- data/lib/geneva_drive/version.rb +1 -1
- data/lib/geneva_drive/workflow.rb +200 -39
- data/lib/geneva_drive.rb +65 -0
- data/test/dummy/config/database.yml +40 -5
- data/test/dummy/config/initializers/geneva_drive.rb +78 -0
- 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/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/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/test_helper.rb +51 -13
- data/test/workflow/pause_resume_test.rb +15 -0
- data/test/workflow/removed_step_test.rb +294 -0
- data/test/workflow/resume_at_test.rb +170 -0
- metadata +62 -1
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# JSON encoding for columns the database does not type as JSON itself.
|
|
4
|
+
#
|
|
5
|
+
# PostgreSQL and MySQL both hand Rails a real JSON attribute, so a Hash
|
|
6
|
+
# assigned to one is encoded by the adapter and comes back decoded. MariaDB
|
|
7
|
+
# has no JSON type - see GenevaDrive::MigrationHelpers#geneva_drive_json_column
|
|
8
|
+
# for why - so the column is plain LONGTEXT, Rails types it as text, and a Hash
|
|
9
|
+
# written to it would be stored as `#inspect` output. Encoding it here keeps
|
|
10
|
+
# the models writing and reading the same Ruby values everywhere.
|
|
11
|
+
#
|
|
12
|
+
# The branch is on the column, not on the adapter, deliberately: an install
|
|
13
|
+
# that already ran the old migration against MariaDB has a LONGTEXT column and
|
|
14
|
+
# gets the encoding too, without needing its column rewritten first.
|
|
15
|
+
#
|
|
16
|
+
# @api private
|
|
17
|
+
module GenevaDrive::JsonColumn
|
|
18
|
+
extend ActiveSupport::Concern
|
|
19
|
+
|
|
20
|
+
class_methods do
|
|
21
|
+
# Whether the column holds JSON as text rather than as a JSON type, and so
|
|
22
|
+
# needs encoding and decoding here. Lazily detected: never touches the
|
|
23
|
+
# database at class definition time, since the column may not exist yet.
|
|
24
|
+
#
|
|
25
|
+
# @param name [String, Symbol] the column name
|
|
26
|
+
# @return [Boolean]
|
|
27
|
+
def json_column_as_text?(name)
|
|
28
|
+
name = name.to_s
|
|
29
|
+
@_json_column_as_text ||= {}
|
|
30
|
+
return @_json_column_as_text[name] if @_json_column_as_text.key?(name)
|
|
31
|
+
|
|
32
|
+
@_json_column_as_text[name] = begin
|
|
33
|
+
column = table_exists? && columns_hash[name]
|
|
34
|
+
column ? column.type == :text : false
|
|
35
|
+
rescue ActiveRecord::ActiveRecordError
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Clears the cached detection. Call after migrating in-process.
|
|
41
|
+
#
|
|
42
|
+
# @return [void]
|
|
43
|
+
def reset_json_column_cache!
|
|
44
|
+
remove_instance_variable(:@_json_column_as_text) if defined?(@_json_column_as_text)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Prepares an already-serialized value for writing to the column.
|
|
48
|
+
#
|
|
49
|
+
# @param name [String, Symbol] the column name
|
|
50
|
+
# @param value [Object, nil] a JSON-safe Ruby value
|
|
51
|
+
# @return [Object, nil] the value, encoded to a JSON string where needed
|
|
52
|
+
def encode_json_column(name, value)
|
|
53
|
+
return value unless json_column_as_text?(name)
|
|
54
|
+
return nil if value.nil?
|
|
55
|
+
|
|
56
|
+
JSON.generate(value)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Reverses {encode_json_column} for a value read off the column.
|
|
60
|
+
#
|
|
61
|
+
# @param name [String, Symbol] the column name
|
|
62
|
+
# @param raw [Object, nil] the raw attribute value
|
|
63
|
+
# @return [Object, nil] the decoded value
|
|
64
|
+
def decode_json_column(name, raw)
|
|
65
|
+
return raw unless json_column_as_text?(name)
|
|
66
|
+
return nil if raw.nil?
|
|
67
|
+
return raw unless raw.is_a?(String)
|
|
68
|
+
|
|
69
|
+
JSON.parse(raw)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -9,13 +9,17 @@
|
|
|
9
9
|
#
|
|
10
10
|
# def change
|
|
11
11
|
# create_table :geneva_drive_workflows, **geneva_drive_table_options do |t|
|
|
12
|
-
# t.references :hero, polymorphic: true, type:
|
|
12
|
+
# t.references :hero, polymorphic: true, type: geneva_drive_hero_key_type
|
|
13
13
|
# # ...
|
|
14
14
|
# end
|
|
15
15
|
# end
|
|
16
16
|
# end
|
|
17
17
|
#
|
|
18
18
|
module GenevaDrive::MigrationHelpers
|
|
19
|
+
UUID_SQL_TYPE = /uuid|char\(36\)|varchar\(36\)/
|
|
20
|
+
# SQLite reports `id: :string` as a bare "varchar", without a length
|
|
21
|
+
STRING_SQL_TYPE = /char|text|clob/
|
|
22
|
+
|
|
19
23
|
# Detects the appropriate key type for GenevaDrive tables.
|
|
20
24
|
# Returns :uuid if the schema predominantly uses UUIDs, otherwise :bigint.
|
|
21
25
|
#
|
|
@@ -25,13 +29,39 @@ module GenevaDrive::MigrationHelpers
|
|
|
25
29
|
id_columns = _geneva_drive_app_id_columns
|
|
26
30
|
return :bigint if id_columns.empty?
|
|
27
31
|
|
|
28
|
-
uuid_count = id_columns.count { |col| col.sql_type.downcase.match?(
|
|
32
|
+
uuid_count = id_columns.count { |col| col.sql_type.downcase.match?(UUID_SQL_TYPE) }
|
|
29
33
|
other_count = id_columns.size - uuid_count
|
|
30
34
|
|
|
31
35
|
(uuid_count > other_count) ? :uuid : :bigint
|
|
32
36
|
end
|
|
33
37
|
end
|
|
34
38
|
|
|
39
|
+
# Detects the column type for `hero_id` on the workflows table. Unlike
|
|
40
|
+
# {#geneva_drive_key_type}, this also recognizes string primary keys of any
|
|
41
|
+
# length (`varchar`, `char(n)`, `text`), so that heroes with string ids do not
|
|
42
|
+
# get a `hero_id` that casts their ids to integers.
|
|
43
|
+
#
|
|
44
|
+
# Returns :uuid if the schema predominantly uses UUIDs, :string if it predominantly
|
|
45
|
+
# uses UUIDs and string ids taken together, otherwise :bigint.
|
|
46
|
+
#
|
|
47
|
+
# @return [Symbol] :uuid, :string or :bigint
|
|
48
|
+
def geneva_drive_hero_key_type
|
|
49
|
+
@_geneva_drive_hero_key_type ||= begin
|
|
50
|
+
sql_types = _geneva_drive_app_id_columns.map { |col| col.sql_type.downcase }
|
|
51
|
+
uuid_count = sql_types.count { |t| t.match?(UUID_SQL_TYPE) }
|
|
52
|
+
string_count = sql_types.count { |t| !t.match?(UUID_SQL_TYPE) && t.match?(STRING_SQL_TYPE) }
|
|
53
|
+
integer_count = sql_types.size - uuid_count - string_count
|
|
54
|
+
|
|
55
|
+
if uuid_count > string_count + integer_count
|
|
56
|
+
:uuid
|
|
57
|
+
elsif uuid_count + string_count > integer_count
|
|
58
|
+
:string # a string column holds UUIDs just as well
|
|
59
|
+
else
|
|
60
|
+
:bigint
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
35
65
|
# Returns options hash for create_table based on detected primary key type.
|
|
36
66
|
# When UUIDs are detected, includes the most common default function from
|
|
37
67
|
# existing tables so that new tables match the application's convention
|
|
@@ -48,6 +78,33 @@ module GenevaDrive::MigrationHelpers
|
|
|
48
78
|
options
|
|
49
79
|
end
|
|
50
80
|
|
|
81
|
+
# Column type and options for a column that holds a JSON document.
|
|
82
|
+
#
|
|
83
|
+
# PostgreSQL gets jsonb and MySQL gets its native json. MariaDB gets neither:
|
|
84
|
+
# it has no JSON type at all, `json` there being an alias for LONGTEXT with an
|
|
85
|
+
# auto-generated CHECK constraint named after the column. Two things go wrong
|
|
86
|
+
# with that alias. The schema dump comes back as `t.text` plus
|
|
87
|
+
# `t.check_constraint name: "cursor"`, and loading that dump re-emits the
|
|
88
|
+
# constraint name unquoted - `cursor` is reserved in MariaDB, so the load dies
|
|
89
|
+
# on a syntax error, which takes out `db:schema:load` and every parallel test
|
|
90
|
+
# worker with it. And because Rails types the column as text either way, a
|
|
91
|
+
# Hash assigned to it is written out as `#inspect` rather than JSON, which the
|
|
92
|
+
# very same CHECK constraint then rejects. Asking for plain LONGTEXT and doing
|
|
93
|
+
# the JSON encoding in the model (see GenevaDrive::JsonColumn) sidesteps both.
|
|
94
|
+
#
|
|
95
|
+
# @return [Array(Symbol, Hash)] the column type and its options
|
|
96
|
+
def geneva_drive_json_column
|
|
97
|
+
adapter = connection.adapter_name.downcase
|
|
98
|
+
|
|
99
|
+
if adapter.include?("postgresql")
|
|
100
|
+
[:jsonb, {}]
|
|
101
|
+
elsif connection.respond_to?(:mariadb?) && connection.mariadb?
|
|
102
|
+
[:text, {limit: 4_294_967_295}]
|
|
103
|
+
else
|
|
104
|
+
[:json, {}]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
51
108
|
private
|
|
52
109
|
|
|
53
110
|
# Collects the id column from every application table (excludes system
|
|
@@ -69,7 +126,7 @@ module GenevaDrive::MigrationHelpers
|
|
|
69
126
|
def _geneva_drive_dominant_uuid_default
|
|
70
127
|
id_columns = _geneva_drive_app_id_columns
|
|
71
128
|
uuid_defaults = id_columns.filter_map do |col|
|
|
72
|
-
next unless col.sql_type.downcase.match?(
|
|
129
|
+
next unless col.sql_type.downcase.match?(UUID_SQL_TYPE)
|
|
73
130
|
col.default_function
|
|
74
131
|
end
|
|
75
132
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A gravestone left in place of a step that has been taken out of a workflow.
|
|
4
|
+
#
|
|
5
|
+
# Deleting a `step` outright is not safe while workflows are in flight: an
|
|
6
|
+
# execution row already scheduled for that name finds no definition when its
|
|
7
|
+
# job runs, and the workflow pauses with StepNotDefinedError. Declaring
|
|
8
|
+
# `removed_step :name` in the position the step used to occupy keeps the name
|
|
9
|
+
# resolvable, so those executions are skipped instead of stranding the
|
|
10
|
+
# workflow, while scheduling never spools a new execution for it again.
|
|
11
|
+
#
|
|
12
|
+
# A removed step holds nothing but a name - no block, no wait, no conditions.
|
|
13
|
+
# There is nothing left to configure about a step that does not run.
|
|
14
|
+
#
|
|
15
|
+
# @api private
|
|
16
|
+
class GenevaDrive::RemovedStepDefinition < GenevaDrive::StepDefinition
|
|
17
|
+
# Creates a gravestone for a step that no longer exists.
|
|
18
|
+
#
|
|
19
|
+
# The parent is handed a callable that never runs: a removed step is
|
|
20
|
+
# intercepted by the executor before it would be performed.
|
|
21
|
+
#
|
|
22
|
+
# @param name [String, Symbol] the name of the step that was removed
|
|
23
|
+
# @param call_location [Array<String, Integer>, nil] source location of the removed_step call
|
|
24
|
+
def initialize(name:, call_location: nil)
|
|
25
|
+
super(name: name, callable: :__removed_step_never_runs, call_location: call_location)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns true to mark this as a gravestone rather than a runnable step.
|
|
29
|
+
#
|
|
30
|
+
# @return [Boolean]
|
|
31
|
+
def removed?
|
|
32
|
+
true
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/geneva_drive/signal.rb
CHANGED
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
# workflow.signal!(:payment_confirmed, payload: {amount_cents: 12_500})
|
|
26
26
|
#
|
|
27
27
|
class GenevaDrive::Signal < ActiveRecord::Base
|
|
28
|
+
include GenevaDrive::JsonColumn
|
|
29
|
+
|
|
28
30
|
self.table_name = "geneva_drive_signals"
|
|
29
31
|
|
|
30
32
|
# Signal lifecycle states as enum with string values.
|
|
@@ -109,7 +111,7 @@ class GenevaDrive::Signal < ActiveRecord::Base
|
|
|
109
111
|
end
|
|
110
112
|
end
|
|
111
113
|
|
|
112
|
-
serialized
|
|
114
|
+
encode_json_column(:payload, serialized)
|
|
113
115
|
end
|
|
114
116
|
end
|
|
115
117
|
|
|
@@ -119,7 +121,7 @@ class GenevaDrive::Signal < ActiveRecord::Base
|
|
|
119
121
|
#
|
|
120
122
|
# @return [Object, nil] the payload
|
|
121
123
|
def payload
|
|
122
|
-
raw = self[:payload]
|
|
124
|
+
raw = self.class.decode_json_column(:payload, self[:payload])
|
|
123
125
|
return nil if raw.nil?
|
|
124
126
|
|
|
125
127
|
value = ActiveJob::Arguments.deserialize([raw]).first
|
|
@@ -65,30 +65,54 @@ class GenevaDrive::StepCollection
|
|
|
65
65
|
ordered_steps[position]
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
-
# Returns the next step after the given step name.
|
|
68
|
+
# Returns the next runnable step after the given step name.
|
|
69
|
+
#
|
|
70
|
+
# Steps declared with `removed_step` are stepped over: they hold a position
|
|
71
|
+
# in the sequence so that executions scheduled before their removal still
|
|
72
|
+
# resolve, but no execution is ever scheduled for one.
|
|
73
|
+
#
|
|
74
|
+
# A nil current_name means "the beginning", so the first runnable step is
|
|
75
|
+
# returned - which is how the first step of a workflow is chosen.
|
|
69
76
|
#
|
|
70
77
|
# @param current_name [String, Symbol, nil] the current step name
|
|
71
|
-
# @return [StepDefinition, nil] the next step or nil if at end
|
|
78
|
+
# @return [StepDefinition, nil] the next runnable step or nil if at end
|
|
72
79
|
def next_after(current_name)
|
|
73
|
-
return
|
|
80
|
+
return ordered_steps.find { |s| !s.removed? } if current_name.nil?
|
|
74
81
|
|
|
75
82
|
current_index = ordered_steps.index { |s| s.name == current_name.to_s }
|
|
76
83
|
return nil unless current_index
|
|
77
84
|
|
|
78
|
-
|
|
85
|
+
ordered_steps[(current_index + 1)..].find { |s| !s.removed? }
|
|
79
86
|
end
|
|
80
87
|
|
|
81
|
-
# Returns the step before the given step name in the
|
|
88
|
+
# Returns the previous runnable step before the given step name in the
|
|
89
|
+
# ordered sequence. Removed steps are stepped over, for the same reason
|
|
90
|
+
# {#next_after} steps over them.
|
|
82
91
|
#
|
|
83
92
|
# @param current_name [String, Symbol, nil] the current step name
|
|
84
|
-
# @return [StepDefinition, nil] the previous step or nil if at beginning
|
|
93
|
+
# @return [StepDefinition, nil] the previous runnable step or nil if at beginning
|
|
85
94
|
def previous_before(current_name)
|
|
86
95
|
return nil if current_name.nil?
|
|
87
96
|
|
|
88
97
|
current_index = find_index { |s| s.name == current_name.to_s }
|
|
89
98
|
return nil unless current_index && current_index > 0
|
|
90
99
|
|
|
91
|
-
|
|
100
|
+
ordered_steps[0...current_index].reverse_each.find { |s| !s.removed? }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Returns the steps that can actually run, in order, with `removed_step`
|
|
104
|
+
# gravestones filtered out.
|
|
105
|
+
#
|
|
106
|
+
# @return [Array<StepDefinition>] the runnable step definitions
|
|
107
|
+
def runnable
|
|
108
|
+
ordered_steps.reject(&:removed?)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Returns the `removed_step` gravestones, in order.
|
|
112
|
+
#
|
|
113
|
+
# @return [Array<StepDefinition>] the removed step definitions
|
|
114
|
+
def removed
|
|
115
|
+
ordered_steps.select(&:removed?)
|
|
92
116
|
end
|
|
93
117
|
|
|
94
118
|
# Checks if a step exists with the given name.
|
|
@@ -131,6 +131,15 @@ class GenevaDrive::StepDefinition
|
|
|
131
131
|
false
|
|
132
132
|
end
|
|
133
133
|
|
|
134
|
+
# Returns whether this step is a gravestone left by `removed_step`.
|
|
135
|
+
# Removed steps keep their name resolvable for executions scheduled before
|
|
136
|
+
# the step was taken out, but are never scheduled and never run.
|
|
137
|
+
#
|
|
138
|
+
# @return [Boolean] false for standard steps
|
|
139
|
+
def removed?
|
|
140
|
+
false
|
|
141
|
+
end
|
|
142
|
+
|
|
134
143
|
# Whether this step parks until a matching signal arrives.
|
|
135
144
|
#
|
|
136
145
|
# @return [Boolean]
|
|
@@ -20,6 +20,7 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
|
|
|
20
20
|
|
|
21
21
|
require_relative "step_execution/metadata_accessor"
|
|
22
22
|
include MetadataAccessor
|
|
23
|
+
include GenevaDrive::JsonColumn
|
|
23
24
|
|
|
24
25
|
# Step execution states as enum with string values
|
|
25
26
|
# Provides: scheduled?, in_progress?, etc. predicates
|
|
@@ -162,7 +163,7 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
|
|
|
162
163
|
end
|
|
163
164
|
end
|
|
164
165
|
|
|
165
|
-
serialized
|
|
166
|
+
encode_json_column(:cursor, serialized)
|
|
166
167
|
end
|
|
167
168
|
end
|
|
168
169
|
|
|
@@ -250,7 +251,7 @@ class GenevaDrive::StepExecution < ActiveRecord::Base
|
|
|
250
251
|
def cursor_value
|
|
251
252
|
return nil unless self.class.resumable_columns?
|
|
252
253
|
return nil if cursor.blank?
|
|
253
|
-
ActiveJob::Arguments.deserialize([cursor]).first
|
|
254
|
+
ActiveJob::Arguments.deserialize([self.class.decode_json_column(:cursor, cursor)]).first
|
|
254
255
|
end
|
|
255
256
|
|
|
256
257
|
# Sets the cursor value for resumable steps.
|
data/lib/geneva_drive/version.rb
CHANGED
|
@@ -206,6 +206,92 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
206
206
|
step_def
|
|
207
207
|
end
|
|
208
208
|
|
|
209
|
+
# Marks a step as removed, leaving a gravestone in the position the step
|
|
210
|
+
# used to occupy.
|
|
211
|
+
#
|
|
212
|
+
# Deleting a `step` outright is unsafe while workflows are in flight.
|
|
213
|
+
# Executions scheduled under the old name find no definition when their
|
|
214
|
+
# job runs, and the workflow pauses with {StepNotDefinedError} - stuck
|
|
215
|
+
# until an operator intervenes. The gem cannot recover from this on its
|
|
216
|
+
# own: it knows the step is gone, but not whether the work it did still
|
|
217
|
+
# needs doing, and not which of the remaining steps would have run had it
|
|
218
|
+
# executed. Only the author of the removal knows that.
|
|
219
|
+
#
|
|
220
|
+
# `removed_step` is how that knowledge gets written down. Declaring it
|
|
221
|
+
# where the step used to be says "this step no longer needs to happen,
|
|
222
|
+
# carry on past it", and the gem can then act without guessing:
|
|
223
|
+
#
|
|
224
|
+
# - Executions already scheduled for the name resolve, are skipped, and
|
|
225
|
+
# the workflow proceeds to the next runnable step.
|
|
226
|
+
# - Scheduling never spools a new execution for the name again.
|
|
227
|
+
# - `before_step:` / `after_step:` references to it keep working.
|
|
228
|
+
#
|
|
229
|
+
# Ship the `removed_step`, let in-flight workflows drain past it, then
|
|
230
|
+
# delete the line once {.removed_steps_in_flight} comes back empty.
|
|
231
|
+
#
|
|
232
|
+
# A removed step takes a name and nothing else. There is nothing to
|
|
233
|
+
# configure about a step that does not run.
|
|
234
|
+
#
|
|
235
|
+
# @param name [String, Symbol] the name of the step that was removed
|
|
236
|
+
# @return [RemovedStepDefinition] the gravestone
|
|
237
|
+
#
|
|
238
|
+
# @example Taking a step out of a live workflow
|
|
239
|
+
# class PaymentWorkflow < GenevaDrive::Workflow
|
|
240
|
+
# step :authorize do
|
|
241
|
+
# # ...
|
|
242
|
+
# end
|
|
243
|
+
#
|
|
244
|
+
# removed_step :capture_payment
|
|
245
|
+
#
|
|
246
|
+
# step :send_receipt do
|
|
247
|
+
# # ...
|
|
248
|
+
# end
|
|
249
|
+
# end
|
|
250
|
+
def removed_step(name)
|
|
251
|
+
raise ArgumentError, "removed_step requires a step name" if name.nil?
|
|
252
|
+
|
|
253
|
+
caller_loc = caller_locations(1, 1).first
|
|
254
|
+
call_location = caller_loc ? [caller_loc.path, caller_loc.lineno] : nil
|
|
255
|
+
|
|
256
|
+
step_name = prepare_step_registration!(name, {})
|
|
257
|
+
|
|
258
|
+
step_def = GenevaDrive::RemovedStepDefinition.new(
|
|
259
|
+
name: step_name,
|
|
260
|
+
call_location: call_location
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
_step_definitions << step_def
|
|
264
|
+
|
|
265
|
+
step_def
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Returns the names of this workflow's removed steps that step execution
|
|
269
|
+
# rows still reference, so a `removed_step` gravestone is only deleted
|
|
270
|
+
# once nothing can land on it any more.
|
|
271
|
+
#
|
|
272
|
+
# Each name maps to the number of rows still referencing it. An empty
|
|
273
|
+
# hash means every in-flight workflow has drained past the removal and
|
|
274
|
+
# the `removed_step` lines can go.
|
|
275
|
+
#
|
|
276
|
+
# Rows belonging to finished and canceled workflows are ignored - they
|
|
277
|
+
# will never execute again.
|
|
278
|
+
#
|
|
279
|
+
# @return [Hash{String => Integer}] removed step names to referencing row counts
|
|
280
|
+
#
|
|
281
|
+
# @example
|
|
282
|
+
# PaymentWorkflow.removed_steps_in_flight
|
|
283
|
+
# # => {"capture_payment" => 3}
|
|
284
|
+
def removed_steps_in_flight
|
|
285
|
+
removed_names = steps.removed.map(&:name)
|
|
286
|
+
return {} if removed_names.empty?
|
|
287
|
+
|
|
288
|
+
GenevaDrive::StepExecution
|
|
289
|
+
.where(step_name: removed_names)
|
|
290
|
+
.where(workflow_id: where.not(state: %w[finished canceled]).select(:id))
|
|
291
|
+
.group(:step_name)
|
|
292
|
+
.count
|
|
293
|
+
end
|
|
294
|
+
|
|
209
295
|
# Defines a blanket cancellation condition for the workflow.
|
|
210
296
|
# Checked before every step execution.
|
|
211
297
|
#
|
|
@@ -226,8 +312,8 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
226
312
|
_cancel_conditions << block if block_given?
|
|
227
313
|
end
|
|
228
314
|
|
|
229
|
-
# Sets job options for step execution jobs.
|
|
230
|
-
#
|
|
315
|
+
# Sets job options for step execution jobs. See GenevaDrive::JobOptions
|
|
316
|
+
# for the keys allowed, and the dispatcher for how they are applied.
|
|
231
317
|
#
|
|
232
318
|
# @param options [Hash] job options (queue, priority, etc.)
|
|
233
319
|
# @return [void]
|
|
@@ -566,6 +652,73 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
566
652
|
|
|
567
653
|
# No scheduled execution exists - create one for the next step
|
|
568
654
|
step_def = steps.named(next_step_name)
|
|
655
|
+
|
|
656
|
+
# The step this workflow is pointed at can be gone from the code - that is
|
|
657
|
+
# what paused it in the first place if it paused with StepNotDefinedError.
|
|
658
|
+
# Say so plainly instead of dying on nil deeper in create_step_execution,
|
|
659
|
+
# and name the way out.
|
|
660
|
+
unless step_def
|
|
661
|
+
raise GenevaDrive::StepNotDefinedError.new(
|
|
662
|
+
"Cannot resume #{self.class.name} ##{id}: step '#{next_step_name}' is no longer defined in " \
|
|
663
|
+
"#{self.class.name}. Pick the step to continue from with resume_at!(:step_name) - the steps " \
|
|
664
|
+
"still defined are #{steps.runnable.map(&:name).join(", ")} - or cancel! the workflow. To stop " \
|
|
665
|
+
"future removals from stranding workflows this way, leave a removed_step :#{next_step_name} " \
|
|
666
|
+
"gravestone in place of the deleted step.",
|
|
667
|
+
step_execution: nil,
|
|
668
|
+
workflow: self
|
|
669
|
+
)
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
create_step_execution(step_def, wait: nil)
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
# Resumes a paused workflow at a specific step, instead of at whatever step
|
|
676
|
+
# it was pointed at when it paused.
|
|
677
|
+
#
|
|
678
|
+
# This is the operator's escape hatch for a workflow stranded on a step that
|
|
679
|
+
# no longer exists - the step was deleted without leaving a
|
|
680
|
+
# {GenevaDrive::Workflow.removed_step} gravestone, so the workflow paused
|
|
681
|
+
# with {StepNotDefinedError} and plain {#resume!} has nowhere to go.
|
|
682
|
+
#
|
|
683
|
+
# Where to land is deliberately a human decision. A deleted step's position
|
|
684
|
+
# in the sequence says nothing about whether the steps that follow it were
|
|
685
|
+
# ever meant to run: a `capture_payment` that normally ends the workflow by
|
|
686
|
+
# calling `finished!` sits directly before `refund`, and nothing in the
|
|
687
|
+
# database distinguishes "carry on" from "stop here". Only the person who
|
|
688
|
+
# removed the step knows which, so this method asks them.
|
|
689
|
+
#
|
|
690
|
+
# Any stray scheduled execution is canceled first, so the workflow continues
|
|
691
|
+
# from the named step and nowhere else.
|
|
692
|
+
#
|
|
693
|
+
# @param step_name [String, Symbol] the step to continue from
|
|
694
|
+
# @return [StepExecution] the created step execution
|
|
695
|
+
# @raise [InvalidStateError] if the workflow is not paused
|
|
696
|
+
# @raise [StepNotDefinedError] if the named step is not defined, or is a removed_step
|
|
697
|
+
#
|
|
698
|
+
# @example Continue a stranded workflow past a deleted step
|
|
699
|
+
# workflow = GenevaDrive::Workflow.find(id)
|
|
700
|
+
# workflow.resume_at!(:send_receipt)
|
|
701
|
+
def resume_at!(step_name)
|
|
702
|
+
raise GenevaDrive::InvalidStateError, "Cannot resume a #{state} workflow" unless state == "paused"
|
|
703
|
+
|
|
704
|
+
step_def = steps.named(step_name.to_s)
|
|
705
|
+
|
|
706
|
+
if step_def.nil? || step_def.removed?
|
|
707
|
+
reason = step_def ? "a removed_step" : "not defined"
|
|
708
|
+
raise GenevaDrive::StepNotDefinedError.new(
|
|
709
|
+
"Cannot resume #{self.class.name} ##{id} at '#{step_name}': that step is #{reason} in " \
|
|
710
|
+
"#{self.class.name}. The steps it can be resumed at are #{steps.runnable.map(&:name).join(", ")}.",
|
|
711
|
+
step_execution: nil,
|
|
712
|
+
workflow: self
|
|
713
|
+
)
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
logger.info("Resuming paused workflow at step #{step_def.name.inspect} (was pointed at #{next_step_name.inspect})")
|
|
717
|
+
|
|
718
|
+
with_lock do
|
|
719
|
+
update!(state: "ready", current_step_name: nil, transitioned_at: nil)
|
|
720
|
+
end
|
|
721
|
+
|
|
569
722
|
create_step_execution(step_def, wait: nil)
|
|
570
723
|
end
|
|
571
724
|
|
|
@@ -920,7 +1073,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
920
1073
|
#
|
|
921
1074
|
# @return [void]
|
|
922
1075
|
def log_workflow_created
|
|
923
|
-
step_count =
|
|
1076
|
+
step_count = steps.runnable.size
|
|
924
1077
|
logger.info("Created workflow with #{step_count} step(s) defined")
|
|
925
1078
|
end
|
|
926
1079
|
|
|
@@ -928,7 +1081,10 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
928
1081
|
#
|
|
929
1082
|
# @return [StepExecution, nil] the created step execution
|
|
930
1083
|
def schedule_first_step!
|
|
931
|
-
|
|
1084
|
+
# next_after(nil) means "from the beginning" and honours both the
|
|
1085
|
+
# before_step:/after_step: ordering and removed_step gravestones, neither
|
|
1086
|
+
# of which the raw _step_definitions array knows about.
|
|
1087
|
+
first_step = steps.next_after(nil)
|
|
932
1088
|
unless first_step
|
|
933
1089
|
logger.info("No steps defined, finishing workflow immediately")
|
|
934
1090
|
return finish_workflow!
|
|
@@ -948,7 +1104,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
948
1104
|
signal
|
|
949
1105
|
end
|
|
950
1106
|
|
|
951
|
-
# Enqueues the
|
|
1107
|
+
# Enqueues the step job for an execution that dispatch just flipped
|
|
952
1108
|
# from waiting to scheduled. Uses the same enqueue discipline (merged job
|
|
953
1109
|
# options, after-commit deferral, job_id writeback) as create_step_execution.
|
|
954
1110
|
#
|
|
@@ -961,15 +1117,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
961
1117
|
workflow_logger = logger
|
|
962
1118
|
|
|
963
1119
|
run_after_commit do
|
|
964
|
-
|
|
965
|
-
.set(**job_options)
|
|
966
|
-
.perform_later(execution_id)
|
|
967
|
-
|
|
968
|
-
workflow_logger.debug("Enqueued PerformStepJob with job_id=#{job.job_id} for woken step execution #{execution_id}")
|
|
969
|
-
|
|
970
|
-
GenevaDrive::StepExecution
|
|
971
|
-
.where(id: execution_id)
|
|
972
|
-
.update_all(job_id: job.job_id)
|
|
1120
|
+
dispatch_step_job(execution_id, job_options, logger: workflow_logger)
|
|
973
1121
|
end
|
|
974
1122
|
end
|
|
975
1123
|
|
|
@@ -981,6 +1129,18 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
981
1129
|
# @return [StepExecution] the same execution (rescheduled if a signal matched)
|
|
982
1130
|
def rendezvous_waiting_execution!(step_execution)
|
|
983
1131
|
step_def = step_execution.step_definition
|
|
1132
|
+
|
|
1133
|
+
# The step was removed while this execution sat parked. It waits for an
|
|
1134
|
+
# event that nothing will ever match now, so release it here rather than
|
|
1135
|
+
# leaving it parked for good.
|
|
1136
|
+
if step_def&.removed?
|
|
1137
|
+
logger.info("Step #{step_execution.step_name} was parked but is now a removed_step — releasing it and continuing")
|
|
1138
|
+
|
|
1139
|
+
step_execution.mark_skipped!
|
|
1140
|
+
settle_signal_for_skipped!(step_execution)
|
|
1141
|
+
return schedule_next_step!
|
|
1142
|
+
end
|
|
1143
|
+
|
|
984
1144
|
matcher = step_def&.signal_matcher
|
|
985
1145
|
|
|
986
1146
|
if matcher
|
|
@@ -1022,12 +1182,9 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
1022
1182
|
# PerformStepJob sets `enqueue_after_transaction_commit = :never` -- without it,
|
|
1023
1183
|
# ActiveJob/SolidQueue would see the transaction as "open" and defer the queue
|
|
1024
1184
|
# INSERT to a second "after commit" that never fires (the double-deferral bug).
|
|
1185
|
+
# SidekiqDispatcher bypasses Sidekiq's transactional push for the same reason.
|
|
1025
1186
|
run_after_commit do
|
|
1026
|
-
|
|
1027
|
-
.set(**job_options)
|
|
1028
|
-
.perform_later(execution_id)
|
|
1029
|
-
|
|
1030
|
-
workflow_logger.debug("Enqueued job #{job.job_id} for step execution #{execution_id}")
|
|
1187
|
+
dispatch_step_job(execution_id, job_options, logger: workflow_logger)
|
|
1031
1188
|
end
|
|
1032
1189
|
|
|
1033
1190
|
step_execution
|
|
@@ -1077,15 +1234,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
1077
1234
|
workflow_logger = logger
|
|
1078
1235
|
|
|
1079
1236
|
run_after_commit do
|
|
1080
|
-
|
|
1081
|
-
.set(**job_options)
|
|
1082
|
-
.perform_later(successor_id)
|
|
1083
|
-
|
|
1084
|
-
workflow_logger.debug("Enqueued PerformStepJob with job_id=#{job.job_id} for successor execution ##{successor_id}")
|
|
1085
|
-
|
|
1086
|
-
GenevaDrive::StepExecution
|
|
1087
|
-
.where(id: successor_id)
|
|
1088
|
-
.update_all(job_id: job.job_id)
|
|
1237
|
+
dispatch_step_job(successor_id, job_options, logger: workflow_logger)
|
|
1089
1238
|
end
|
|
1090
1239
|
|
|
1091
1240
|
successor
|
|
@@ -1133,17 +1282,9 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
1133
1282
|
# without it, ActiveJob/SolidQueue would see the transaction as "open" and defer
|
|
1134
1283
|
# the queue INSERT to a second "after commit" that never fires (the double-deferral
|
|
1135
1284
|
# bug). See the extensive comment in PerformStepJob for the full explanation.
|
|
1285
|
+
# SidekiqDispatcher bypasses Sidekiq's transactional push for the same reason.
|
|
1136
1286
|
run_after_commit do
|
|
1137
|
-
|
|
1138
|
-
.set(**job_options)
|
|
1139
|
-
.perform_later(execution_id)
|
|
1140
|
-
|
|
1141
|
-
workflow_logger.debug("Enqueued PerformStepJob with job_id=#{job.job_id} for step execution #{execution_id}")
|
|
1142
|
-
|
|
1143
|
-
# Update job_id for debugging purposes (this is a separate transaction)
|
|
1144
|
-
GenevaDrive::StepExecution
|
|
1145
|
-
.where(id: execution_id)
|
|
1146
|
-
.update_all(job_id: job.job_id)
|
|
1287
|
+
dispatch_step_job(execution_id, job_options, logger: workflow_logger)
|
|
1147
1288
|
end
|
|
1148
1289
|
|
|
1149
1290
|
step_execution
|
|
@@ -1179,6 +1320,26 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
1179
1320
|
nil
|
|
1180
1321
|
end
|
|
1181
1322
|
|
|
1323
|
+
# Hands a step execution to the configured dispatcher (GenevaDrive.dispatcher)
|
|
1324
|
+
# and writes the returned job id onto the execution, so that the job can be
|
|
1325
|
+
# found from the row. Always called from inside run_after_commit.
|
|
1326
|
+
#
|
|
1327
|
+
# @param execution_id [Integer, String] the step execution to perform
|
|
1328
|
+
# @param job_options [Hash] merged, validated job options
|
|
1329
|
+
# @param logger [Logger] the workflow logger captured before the commit
|
|
1330
|
+
# @return [void]
|
|
1331
|
+
def dispatch_step_job(execution_id, job_options, logger:)
|
|
1332
|
+
job_id = GenevaDrive.dispatcher.enqueue_step(execution_id, job_options, workflow_class_name: self.class.name)
|
|
1333
|
+
|
|
1334
|
+
logger.debug("Enqueued step job with job_id=#{job_id} for step execution #{execution_id}")
|
|
1335
|
+
|
|
1336
|
+
# A separate statement, outside any transaction - the job may already be running.
|
|
1337
|
+
# resume! re-enqueues an existing execution, so this overwrites the old job's id.
|
|
1338
|
+
GenevaDrive::StepExecution
|
|
1339
|
+
.where(id: execution_id)
|
|
1340
|
+
.update_all(job_id: job_id)
|
|
1341
|
+
end
|
|
1342
|
+
|
|
1182
1343
|
# Runs a block either after all transactions commit or immediately.
|
|
1183
1344
|
#
|
|
1184
1345
|
# The behavior depends on GenevaDrive.enqueues_deferred?, which checks:
|
|
@@ -1192,7 +1353,7 @@ class GenevaDrive::Workflow < ActiveRecord::Base
|
|
|
1192
1353
|
# In tests (enqueue_after_commit = false), or inside a
|
|
1193
1354
|
# without_deferred_enqueues block, the block runs immediately.
|
|
1194
1355
|
#
|
|
1195
|
-
# @yield the block to run (typically
|
|
1356
|
+
# @yield the block to run (typically hands a step to the dispatcher)
|
|
1196
1357
|
# @return [void]
|
|
1197
1358
|
def run_after_commit(&block)
|
|
1198
1359
|
if GenevaDrive.enqueues_deferred?
|