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 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