statesman 7.1.0 → 7.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d3c4334aeb972468799292be667b537dddf997d9fe065521cfe9454ed938b2a7
4
- data.tar.gz: d78b8accf1d41e1da296d4e815c660df4889320bd2d40e85ee680061e9c2d947
3
+ metadata.gz: 421da0e6d201060df4733a0e52e2742df2b1d527f241873dfd4b820c74cc587f
4
+ data.tar.gz: 20cd64e291373c21b83886cfdc12c7e68450759515557f3853bfc68bfff8ed38
5
5
  SHA512:
6
- metadata.gz: 582292d37d4cd51742ad15894a2c1fe0b93894d1337d9631ecc7fd4ca0f6ff7e33c0ce9889729cad4d10490def304b4336ea843121bb751d89256ef0b034c647
7
- data.tar.gz: 129e07afb181e19af2b89443740449b799616aaacf07e4bf3224417ae3c97cae376ba923ac979c2ada15111d54f844036e3e0ea8b9e7870e0300258736138e2a
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
- transition_attributes = { to_state: to,
70
- sort_key: next_sort_key,
71
- metadata: metadata }
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
- unset_old_most_recent
80
- transition.save!
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
- def unset_old_most_recent
102
- most_recent = transitions_for_parent.where(most_recent: true)
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
- # Check whether the `most_recent` column allows null values. If it
105
- # doesn't, set old records to `false`, otherwise, set them to `NULL`.
106
- #
107
- # Some conditioning here is required to support databases that don't
108
- # support partial indexes. By doing the conditioning on the column,
109
- # rather than Rails' opinion of whether the database supports partial
110
- # indexes, we're robust to DBs later adding support for partial indexes.
111
- if transition_class.columns_hash["most_recent"].null == false
112
- most_recent.update_all(with_updated_timestamp(most_recent: false))
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
- most_recent.update_all(with_updated_timestamp(most_recent: nil))
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
- def with_updated_timestamp(params)
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
- return params if column.nil?
291
+ # No updated timestamp column, don't return anything
292
+ return nil if column.nil?
188
293
 
189
- timestamp = if ::ActiveRecord::Base.default_timezone == :utc
190
- Time.now.utc
191
- else
192
- Time.now
193
- end
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
- params.merge(column => timestamp)
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
 
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Statesman
4
- VERSION = "7.1.0"
4
+ VERSION = "7.2.0"
5
5
  end
@@ -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 { storage_adapter(Statesman::Adapters::ActiveRecord) }
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
- before { MyActiveRecordModelTransition.serialize(:metadata, JSON) }
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", "~> 0.18"
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.1.0
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-02-10 00:00:00.000000000 Z
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.2
301
+ rubygems_version: 3.1.1
296
302
  signing_key:
297
303
  specification_version: 4
298
304
  summary: A statesman-like state machine library