statesman 7.1.0 → 7.2.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/.circleci/config.yml +5 -0
- data/lib/statesman.rb +12 -1
- data/lib/statesman/adapters/active_record.rb +166 -32
- data/lib/statesman/config.rb +26 -0
- data/lib/statesman/version.rb +1 -1
- data/spec/statesman/adapters/active_record_queries_spec.rb +3 -1
- data/spec/statesman/adapters/active_record_spec.rb +11 -1
- data/statesman.gemspec +1 -1
- metadata +11 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 421da0e6d201060df4733a0e52e2742df2b1d527f241873dfd4b820c74cc587f
|
4
|
+
data.tar.gz: 20cd64e291373c21b83886cfdc12c7e68450759515557f3853bfc68bfff8ed38
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c48d5415915aee8e4f595b78b9018b9a048bfb3d8e3ff33a3efce0ba962540f71af1fe982a389d5b51104c8284fc485f4bfa62390a3c92f03fa7ce4592a2ffbf
|
7
|
+
data.tar.gz: cf11b1793461381b835efe9b2343b54c348a01de85fc0cbd63623a0c18cf21f9935f6cb4251d9117a7d73b52df7041458a943d160628073afc96eb5c16237d73
|
data/.circleci/config.yml
CHANGED
@@ -56,6 +56,7 @@ jobs:
|
|
56
56
|
environment:
|
57
57
|
- POSTGRES_USER=postgres
|
58
58
|
- POSTGRES_DB=statesman_test
|
59
|
+
- POSTGRES_PASSWORD=statesman
|
59
60
|
steps: *steps
|
60
61
|
|
61
62
|
build-ruby265-rails-602-mysql:
|
@@ -83,6 +84,7 @@ jobs:
|
|
83
84
|
environment:
|
84
85
|
- POSTGRES_USER=postgres
|
85
86
|
- POSTGRES_DB=statesman_test
|
87
|
+
- POSTGRES_PASSWORD=statesman
|
86
88
|
steps: *steps
|
87
89
|
build-ruby265-rails-master-mysql:
|
88
90
|
docker:
|
@@ -110,6 +112,7 @@ jobs:
|
|
110
112
|
environment:
|
111
113
|
- POSTGRES_USER=postgres
|
112
114
|
- POSTGRES_DB=statesman_test
|
115
|
+
- POSTGRES_PASSWORD=statesman
|
113
116
|
steps: *steps
|
114
117
|
|
115
118
|
build-ruby270-rails-602-mysql:
|
@@ -137,6 +140,7 @@ jobs:
|
|
137
140
|
environment:
|
138
141
|
- POSTGRES_USER=postgres
|
139
142
|
- POSTGRES_DB=statesman_test
|
143
|
+
- POSTGRES_PASSWORD=statesman
|
140
144
|
steps: *steps
|
141
145
|
build-ruby270-rails-master-mysql:
|
142
146
|
docker:
|
@@ -164,6 +168,7 @@ jobs:
|
|
164
168
|
environment:
|
165
169
|
- POSTGRES_USER=postgres
|
166
170
|
- POSTGRES_DB=statesman_test
|
171
|
+
- POSTGRES_PASSWORD=statesman
|
167
172
|
steps: *steps
|
168
173
|
|
169
174
|
workflows:
|
data/lib/statesman.rb
CHANGED
@@ -20,14 +20,25 @@ module Statesman
|
|
20
20
|
# Example:
|
21
21
|
# Statesman.configure do
|
22
22
|
# storage_adapter Statesman::ActiveRecordAdapter
|
23
|
+
# enable_mysql_gaplock_protection
|
23
24
|
# end
|
24
25
|
#
|
25
26
|
def self.configure(&block)
|
26
|
-
config = Config.new(block)
|
27
|
+
@config = Config.new(block)
|
27
28
|
@storage_adapter = config.adapter_class
|
28
29
|
end
|
29
30
|
|
30
31
|
def self.storage_adapter
|
31
32
|
@storage_adapter || Adapters::Memory
|
32
33
|
end
|
34
|
+
|
35
|
+
def self.mysql_gaplock_protection?
|
36
|
+
return @mysql_gaplock_protection unless @mysql_gaplock_protection.nil?
|
37
|
+
|
38
|
+
@mysql_gaplock_protection = config.mysql_gaplock_protection?
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.config
|
42
|
+
@config ||= Config.new
|
43
|
+
end
|
33
44
|
end
|
@@ -5,9 +5,6 @@ require_relative "../exceptions"
|
|
5
5
|
module Statesman
|
6
6
|
module Adapters
|
7
7
|
class ActiveRecord
|
8
|
-
attr_reader :transition_class
|
9
|
-
attr_reader :parent_model
|
10
|
-
|
11
8
|
JSON_COLUMN_TYPES = %w[json jsonb].freeze
|
12
9
|
|
13
10
|
def self.database_supports_partial_indexes?
|
@@ -19,6 +16,10 @@ module Statesman
|
|
19
16
|
end
|
20
17
|
end
|
21
18
|
|
19
|
+
def self.adapter_name
|
20
|
+
::ActiveRecord::Base.connection.adapter_name.downcase
|
21
|
+
end
|
22
|
+
|
22
23
|
def initialize(transition_class, parent_model, observer, options = {})
|
23
24
|
serialized = serialized?(transition_class)
|
24
25
|
column_type = transition_class.columns_hash["metadata"].sql_type
|
@@ -29,12 +30,15 @@ module Statesman
|
|
29
30
|
end
|
30
31
|
|
31
32
|
@transition_class = transition_class
|
33
|
+
@transition_table = transition_class.arel_table
|
32
34
|
@parent_model = parent_model
|
33
35
|
@observer = observer
|
34
36
|
@association_name =
|
35
37
|
options[:association_name] || @transition_class.table_name
|
36
38
|
end
|
37
39
|
|
40
|
+
attr_reader :transition_class, :transition_table, :parent_model
|
41
|
+
|
38
42
|
def create(from, to, metadata = {})
|
39
43
|
create_transition(from.to_s, to.to_s, metadata)
|
40
44
|
rescue ::ActiveRecord::RecordNotUnique => e
|
@@ -65,19 +69,36 @@ module Statesman
|
|
65
69
|
|
66
70
|
private
|
67
71
|
|
72
|
+
# rubocop:disable Metrics/MethodLength
|
68
73
|
def create_transition(from, to, metadata)
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
transition_attributes[:most_recent] = true
|
74
|
-
|
75
|
-
transition = transitions_for_parent.build(transition_attributes)
|
74
|
+
transition = transitions_for_parent.build(
|
75
|
+
default_transition_attributes(to, metadata),
|
76
|
+
)
|
76
77
|
|
77
78
|
::ActiveRecord::Base.transaction(requires_new: true) do
|
78
79
|
@observer.execute(:before, from, to, transition)
|
79
|
-
|
80
|
-
|
80
|
+
|
81
|
+
if mysql_gaplock_protection?
|
82
|
+
# We save the transition first with most_recent falsy, then mark most_recent
|
83
|
+
# true after to avoid letting MySQL acquire a next-key lock which can cause
|
84
|
+
# deadlocks.
|
85
|
+
#
|
86
|
+
# To avoid an additional query, we manually adjust the most_recent attribute
|
87
|
+
# on our transition assuming that update_most_recents will have set it to true
|
88
|
+
|
89
|
+
transition.save!
|
90
|
+
|
91
|
+
unless update_most_recents(transition.id).positive?
|
92
|
+
raise ActiveRecord::Rollback, "failed to update most_recent"
|
93
|
+
end
|
94
|
+
|
95
|
+
transition.assign_attributes(most_recent: true)
|
96
|
+
else
|
97
|
+
update_most_recents
|
98
|
+
transition.assign_attributes(most_recent: true)
|
99
|
+
transition.save!
|
100
|
+
end
|
101
|
+
|
81
102
|
@last_transition = transition
|
82
103
|
@observer.execute(:after, from, to, transition)
|
83
104
|
add_after_commit_callback(from, to, transition)
|
@@ -85,6 +106,16 @@ module Statesman
|
|
85
106
|
|
86
107
|
transition
|
87
108
|
end
|
109
|
+
# rubocop:enable Metrics/MethodLength
|
110
|
+
|
111
|
+
def default_transition_attributes(to, metadata)
|
112
|
+
{
|
113
|
+
to_state: to,
|
114
|
+
sort_key: next_sort_key,
|
115
|
+
metadata: metadata,
|
116
|
+
most_recent: not_most_recent_value,
|
117
|
+
}
|
118
|
+
end
|
88
119
|
|
89
120
|
def add_after_commit_callback(from, to, transition)
|
90
121
|
::ActiveRecord::Base.connection.add_transaction_record(
|
@@ -98,20 +129,92 @@ module Statesman
|
|
98
129
|
parent_model.send(@association_name)
|
99
130
|
end
|
100
131
|
|
101
|
-
|
102
|
-
|
132
|
+
# Sets the given transition most_recent = t while unsetting the most_recent of any
|
133
|
+
# previous transitions.
|
134
|
+
def update_most_recents(most_recent_id = nil)
|
135
|
+
update = build_arel_manager(::Arel::UpdateManager)
|
136
|
+
update.table(transition_table)
|
137
|
+
update.where(most_recent_transitions(most_recent_id))
|
138
|
+
update.set(build_most_recents_update_all_values(most_recent_id))
|
103
139
|
|
104
|
-
#
|
105
|
-
#
|
106
|
-
#
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
140
|
+
# MySQL will validate index constraints across the intermediate result of an
|
141
|
+
# update. This means we must order our update to deactivate the previous
|
142
|
+
# most_recent before setting the new row to be true.
|
143
|
+
update.order(transition_table[:most_recent].desc) if mysql_gaplock_protection?
|
144
|
+
|
145
|
+
::ActiveRecord::Base.connection.update(update.to_sql)
|
146
|
+
end
|
147
|
+
|
148
|
+
def most_recent_transitions(most_recent_id = nil)
|
149
|
+
if most_recent_id
|
150
|
+
transitions_of_parent.and(
|
151
|
+
transition_table[:id].eq(most_recent_id).or(
|
152
|
+
transition_table[:most_recent].eq(true),
|
153
|
+
),
|
154
|
+
)
|
155
|
+
else
|
156
|
+
transitions_of_parent.and(transition_table[:most_recent].eq(true))
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
def transitions_of_parent
|
161
|
+
transition_table[parent_join_foreign_key.to_sym].eq(parent_model.id)
|
162
|
+
end
|
163
|
+
|
164
|
+
# Generates update_all Arel values that will touch the updated timestamp (if valid
|
165
|
+
# for this model) and set most_recent to true only for the transition with a
|
166
|
+
# matching most_recent ID.
|
167
|
+
#
|
168
|
+
# This is quite nasty, but combines two updates (set all most_recent = f, set
|
169
|
+
# current most_recent = t) into one, which helps improve transition performance
|
170
|
+
# especially when database latency is significant.
|
171
|
+
#
|
172
|
+
# The SQL this can help produce looks like:
|
173
|
+
#
|
174
|
+
# update transitions
|
175
|
+
# set most_recent = (case when id = 'PA123' then TRUE else FALSE end)
|
176
|
+
# , updated_at = '...'
|
177
|
+
# ...
|
178
|
+
#
|
179
|
+
def build_most_recents_update_all_values(most_recent_id = nil)
|
180
|
+
[
|
181
|
+
[
|
182
|
+
transition_table[:most_recent],
|
183
|
+
Arel::Nodes::SqlLiteral.new(most_recent_value(most_recent_id)),
|
184
|
+
],
|
185
|
+
].tap do |values|
|
186
|
+
# Only if we support the updated at timestamps should we add this column to the
|
187
|
+
# update
|
188
|
+
updated_column, updated_at = updated_column_and_timestamp
|
189
|
+
|
190
|
+
if updated_column
|
191
|
+
values << [
|
192
|
+
transition_table[updated_column.to_sym],
|
193
|
+
updated_at,
|
194
|
+
]
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
def most_recent_value(most_recent_id)
|
200
|
+
if most_recent_id
|
201
|
+
Arel::Nodes::Case.new.
|
202
|
+
when(transition_table[:id].eq(most_recent_id)).then(db_true).
|
203
|
+
else(not_most_recent_value).to_sql
|
204
|
+
else
|
205
|
+
Arel::Nodes::SqlLiteral.new(not_most_recent_value)
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
# Provide a wrapper for constructing an update manager which handles a breaking API
|
210
|
+
# change in Arel as we move into Rails >6.0.
|
211
|
+
#
|
212
|
+
# https://github.com/rails/rails/commit/7508284800f67b4611c767bff9eae7045674b66f
|
213
|
+
def build_arel_manager(manager)
|
214
|
+
if manager.instance_method(:initialize).arity.zero?
|
215
|
+
manager.new
|
113
216
|
else
|
114
|
-
|
217
|
+
manager.new(::ActiveRecord::Base)
|
115
218
|
end
|
116
219
|
end
|
117
220
|
|
@@ -170,7 +273,8 @@ module Statesman
|
|
170
273
|
end
|
171
274
|
end
|
172
275
|
|
173
|
-
|
276
|
+
# updated_column_and_timestamp should return [column_name, value]
|
277
|
+
def updated_column_and_timestamp
|
174
278
|
# TODO: Once we've set expectations that transition classes should conform to
|
175
279
|
# the interface of Adapters::ActiveRecordTransition as a breaking change in the
|
176
280
|
# next major version, we can stop calling `#respond_to?` first and instead
|
@@ -184,15 +288,45 @@ module Statesman
|
|
184
288
|
ActiveRecordTransition::DEFAULT_UPDATED_TIMESTAMP_COLUMN
|
185
289
|
end
|
186
290
|
|
187
|
-
|
291
|
+
# No updated timestamp column, don't return anything
|
292
|
+
return nil if column.nil?
|
188
293
|
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
294
|
+
[
|
295
|
+
column, ::ActiveRecord::Base.default_timezone == :utc ? Time.now.utc : Time.now
|
296
|
+
]
|
297
|
+
end
|
298
|
+
|
299
|
+
def mysql_gaplock_protection?
|
300
|
+
Statesman.mysql_gaplock_protection?
|
301
|
+
end
|
302
|
+
|
303
|
+
def db_true
|
304
|
+
value = ::ActiveRecord::Base.connection.type_cast(
|
305
|
+
true,
|
306
|
+
transition_class.columns_hash["most_recent"],
|
307
|
+
)
|
308
|
+
::ActiveRecord::Base.connection.quote(value)
|
309
|
+
end
|
310
|
+
|
311
|
+
def db_false
|
312
|
+
value = ::ActiveRecord::Base.connection.type_cast(
|
313
|
+
false,
|
314
|
+
transition_class.columns_hash["most_recent"],
|
315
|
+
)
|
316
|
+
::ActiveRecord::Base.connection.quote(value)
|
317
|
+
end
|
194
318
|
|
195
|
-
|
319
|
+
# Check whether the `most_recent` column allows null values. If it doesn't, set old
|
320
|
+
# records to `false`, otherwise, set them to `NULL`.
|
321
|
+
#
|
322
|
+
# Some conditioning here is required to support databases that don't support partial
|
323
|
+
# indexes. By doing the conditioning on the column, rather than Rails' opinion of
|
324
|
+
# whether the database supports partial indexes, we're robust to DBs later adding
|
325
|
+
# support for partial indexes.
|
326
|
+
def not_most_recent_value
|
327
|
+
return db_false if transition_class.columns_hash["most_recent"].null == false
|
328
|
+
|
329
|
+
nil
|
196
330
|
end
|
197
331
|
end
|
198
332
|
|
data/lib/statesman/config.rb
CHANGED
@@ -14,5 +14,31 @@ module Statesman
|
|
14
14
|
def storage_adapter(adapter_class)
|
15
15
|
@adapter_class = adapter_class
|
16
16
|
end
|
17
|
+
|
18
|
+
def mysql_gaplock_protection?
|
19
|
+
return @mysql_gaplock_protection unless @mysql_gaplock_protection.nil?
|
20
|
+
|
21
|
+
# If our adapter class suggests we're using mysql, enable gaplock protection by
|
22
|
+
# default.
|
23
|
+
enable_mysql_gaplock_protection if mysql_adapter?(adapter_class)
|
24
|
+
@mysql_gaplock_protection
|
25
|
+
end
|
26
|
+
|
27
|
+
def enable_mysql_gaplock_protection
|
28
|
+
@mysql_gaplock_protection = true
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def mysql_adapter?(adapter_class)
|
34
|
+
adapter_name = adapter_name(adapter_class)
|
35
|
+
return false unless adapter_name
|
36
|
+
|
37
|
+
adapter_name.start_with?("mysql")
|
38
|
+
end
|
39
|
+
|
40
|
+
def adapter_name(adapter_class)
|
41
|
+
adapter_class.respond_to?(:adapter_name) && adapter_class&.adapter_name
|
42
|
+
end
|
17
43
|
end
|
18
44
|
end
|
data/lib/statesman/version.rb
CHANGED
@@ -20,7 +20,9 @@ describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
|
|
20
20
|
prepare_other_model_table
|
21
21
|
prepare_other_transitions_table
|
22
22
|
|
23
|
-
Statesman.configure
|
23
|
+
Statesman.configure do
|
24
|
+
storage_adapter(Statesman::Adapters::ActiveRecord)
|
25
|
+
end
|
24
26
|
end
|
25
27
|
|
26
28
|
after { Statesman.configure { storage_adapter(Statesman::Adapters::Memory) } }
|
@@ -9,9 +9,19 @@ describe Statesman::Adapters::ActiveRecord, active_record: true do
|
|
9
9
|
before do
|
10
10
|
prepare_model_table
|
11
11
|
prepare_transitions_table
|
12
|
+
|
13
|
+
MyActiveRecordModelTransition.serialize(:metadata, JSON)
|
14
|
+
|
15
|
+
Statesman.configure do
|
16
|
+
# Rubocop requires described_class to be used, but this block
|
17
|
+
# is instance_eval'd and described_class won't be defined
|
18
|
+
# rubocop:disable RSpec/DescribedClass
|
19
|
+
storage_adapter(Statesman::Adapters::ActiveRecord)
|
20
|
+
# rubocop:enable RSpec/DescribedClass
|
21
|
+
end
|
12
22
|
end
|
13
23
|
|
14
|
-
|
24
|
+
after { Statesman.configure { storage_adapter(Statesman::Adapters::Memory) } }
|
15
25
|
|
16
26
|
let(:observer) { double(Statesman::Machine, execute: nil) }
|
17
27
|
let(:model) { MyActiveRecordModel.create(current_state: :pending) }
|
data/statesman.gemspec
CHANGED
@@ -25,7 +25,7 @@ Gem::Specification.new do |spec|
|
|
25
25
|
spec.add_development_dependency "bundler", "~> 2.1.4"
|
26
26
|
spec.add_development_dependency "gc_ruboconfig", "~> 2.3.9"
|
27
27
|
spec.add_development_dependency "mysql2", ">= 0.4", "< 0.6"
|
28
|
-
spec.add_development_dependency "pg", "
|
28
|
+
spec.add_development_dependency "pg", ">= 0.18", "<= 1.3"
|
29
29
|
spec.add_development_dependency "pry"
|
30
30
|
spec.add_development_dependency "rails", ">= 5.2"
|
31
31
|
spec.add_development_dependency "rake", "~> 13.0.0"
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: statesman
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 7.
|
4
|
+
version: 7.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- GoCardless
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-
|
11
|
+
date: 2020-05-19 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: ammeter
|
@@ -76,16 +76,22 @@ dependencies:
|
|
76
76
|
name: pg
|
77
77
|
requirement: !ruby/object:Gem::Requirement
|
78
78
|
requirements:
|
79
|
-
- - "
|
79
|
+
- - ">="
|
80
80
|
- !ruby/object:Gem::Version
|
81
81
|
version: '0.18'
|
82
|
+
- - "<="
|
83
|
+
- !ruby/object:Gem::Version
|
84
|
+
version: '1.3'
|
82
85
|
type: :development
|
83
86
|
prerelease: false
|
84
87
|
version_requirements: !ruby/object:Gem::Requirement
|
85
88
|
requirements:
|
86
|
-
- - "
|
89
|
+
- - ">="
|
87
90
|
- !ruby/object:Gem::Version
|
88
91
|
version: '0.18'
|
92
|
+
- - "<="
|
93
|
+
- !ruby/object:Gem::Version
|
94
|
+
version: '1.3'
|
89
95
|
- !ruby/object:Gem::Dependency
|
90
96
|
name: pry
|
91
97
|
requirement: !ruby/object:Gem::Requirement
|
@@ -292,7 +298,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
292
298
|
- !ruby/object:Gem::Version
|
293
299
|
version: '0'
|
294
300
|
requirements: []
|
295
|
-
rubygems_version: 3.1.
|
301
|
+
rubygems_version: 3.1.1
|
296
302
|
signing_key:
|
297
303
|
specification_version: 4
|
298
304
|
summary: A statesman-like state machine library
|