statesman 13.3.0 → 13.4.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/.github/workflows/tests.yml +96 -36
- data/.tool-versions +1 -0
- data/CHANGELOG.md +6 -0
- data/CONTRIBUTING.md +20 -0
- data/Gemfile +5 -0
- data/README.md +60 -0
- data/Rakefile +32 -1
- data/lib/statesman/bulk_transition.rb +321 -0
- data/lib/statesman/version.rb +1 -1
- data/lib/statesman.rb +1 -0
- data/spec/spec_helper.rb +9 -2
- data/spec/statesman/adapters/active_record_spec.rb +1 -3
- data/spec/statesman/bulk_transition_spec.rb +392 -0
- data/spec/support/database_container.rb +41 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 706f8fde91c1454f1ab6a7082c1317341eb5855f4c4c418e73bca0246b677a0e
|
|
4
|
+
data.tar.gz: 6cc6d3c4c43fc38f751dd361db4a7dd622b02b4ca07afd94315bef7dc433f23d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fbcf95c6f1bfdbb867768072d5cfe47d0476d26c9f37ad038eb7563ee541cf03dd4a2e66c77fe808926732e9f51a6746be77892b171148756d07e24e3e11b368
|
|
7
|
+
data.tar.gz: b70bb3d87a2f6923a4f42b1d2fda278caae324d2339731a2abdd501ac76f4a43fd70a9128a528ae55e1e05173d944b6d328b2ebeedc6c56a8a128e0d7b48371d
|
data/.github/workflows/tests.yml
CHANGED
|
@@ -21,7 +21,52 @@ jobs:
|
|
|
21
21
|
bundler-cache: true
|
|
22
22
|
- run: bundle exec rubocop --extra-details --display-style-guide --parallel --force-exclusion
|
|
23
23
|
|
|
24
|
+
bundle:
|
|
25
|
+
strategy:
|
|
26
|
+
fail-fast: false
|
|
27
|
+
matrix:
|
|
28
|
+
ruby-version: ["3.3", "3.4", "4.0"]
|
|
29
|
+
rails-version:
|
|
30
|
+
- "7.2"
|
|
31
|
+
- "8.0"
|
|
32
|
+
- "8.1"
|
|
33
|
+
- "main"
|
|
34
|
+
runs-on: ubuntu-latest
|
|
35
|
+
env:
|
|
36
|
+
RAILS_VERSION: ${{ matrix.rails-version }}
|
|
37
|
+
BUNDLE_PATH: vendor/bundle
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/checkout@v5
|
|
40
|
+
- name: Set up Ruby
|
|
41
|
+
uses: ruby/setup-ruby@v1
|
|
42
|
+
with:
|
|
43
|
+
ruby-version: "${{ matrix.ruby-version }}"
|
|
44
|
+
- name: Resolve gems
|
|
45
|
+
run: bundle lock
|
|
46
|
+
- name: Restore gems
|
|
47
|
+
id: cache
|
|
48
|
+
uses: actions/cache/restore@v6
|
|
49
|
+
with:
|
|
50
|
+
path: vendor/bundle
|
|
51
|
+
key: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-${{ hashFiles('Gemfile.lock') }}
|
|
52
|
+
restore-keys: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-
|
|
53
|
+
- name: Install gems
|
|
54
|
+
if: steps.cache.outputs.cache-hit != 'true'
|
|
55
|
+
# The lockfile is stored inside the cached path so downstream jobs, which can't
|
|
56
|
+
# compute this key, can restore the most recent entry and use the same gems.
|
|
57
|
+
run: |
|
|
58
|
+
bundle install --jobs 4
|
|
59
|
+
bundle clean
|
|
60
|
+
cp Gemfile.lock vendor/bundle/Gemfile.lock
|
|
61
|
+
- name: Save gems
|
|
62
|
+
if: steps.cache.outputs.cache-hit != 'true'
|
|
63
|
+
uses: actions/cache/save@v6
|
|
64
|
+
with:
|
|
65
|
+
path: vendor/bundle
|
|
66
|
+
key: ${{ steps.cache.outputs.cache-primary-key }}
|
|
67
|
+
|
|
24
68
|
sqlite:
|
|
69
|
+
needs: bundle
|
|
25
70
|
strategy:
|
|
26
71
|
fail-fast: false
|
|
27
72
|
matrix:
|
|
@@ -37,18 +82,33 @@ jobs:
|
|
|
37
82
|
runs-on: ubuntu-latest
|
|
38
83
|
env:
|
|
39
84
|
RAILS_VERSION: ${{ matrix.rails-version }}
|
|
85
|
+
BUNDLE_PATH: vendor/bundle
|
|
40
86
|
steps:
|
|
41
87
|
- uses: actions/checkout@v5
|
|
42
88
|
- name: Set up Ruby
|
|
43
89
|
uses: ruby/setup-ruby@v1
|
|
44
90
|
with:
|
|
45
|
-
bundler-cache: true
|
|
46
91
|
ruby-version: "${{ matrix.ruby-version }}"
|
|
92
|
+
# Restore-only: the bundle job owns saving, so matrix jobs never race to write
|
|
93
|
+
# the same cache entry. The most recent entry for this prefix is the one the
|
|
94
|
+
# bundle job just resolved.
|
|
95
|
+
- name: Restore gems
|
|
96
|
+
uses: actions/cache/restore@v6
|
|
97
|
+
with:
|
|
98
|
+
path: vendor/bundle
|
|
99
|
+
key: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-
|
|
100
|
+
restore-keys: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-
|
|
101
|
+
fail-on-cache-miss: true
|
|
102
|
+
- name: Install gems
|
|
103
|
+
run: |
|
|
104
|
+
cp vendor/bundle/Gemfile.lock Gemfile.lock
|
|
105
|
+
bundle install --local
|
|
47
106
|
- name: Run specs
|
|
48
107
|
run: |
|
|
49
108
|
bundle exec rspec --profile --format progress --format RSpec::Github::Formatter
|
|
50
109
|
|
|
51
110
|
postgres:
|
|
111
|
+
needs: bundle
|
|
52
112
|
strategy:
|
|
53
113
|
fail-fast: false
|
|
54
114
|
matrix:
|
|
@@ -63,36 +123,37 @@ jobs:
|
|
|
63
123
|
- ruby-version: "3.2"
|
|
64
124
|
rails-version: "main"
|
|
65
125
|
runs-on: ubuntu-latest
|
|
66
|
-
services:
|
|
67
|
-
postgres:
|
|
68
|
-
image: postgres:${{ matrix.postgres-version }}
|
|
69
|
-
env:
|
|
70
|
-
POSTGRES_USER: postgres
|
|
71
|
-
POSTGRES_DB: statesman_test
|
|
72
|
-
POSTGRES_PASSWORD: statesman
|
|
73
|
-
ports:
|
|
74
|
-
- 5432:5432
|
|
75
|
-
options: >-
|
|
76
|
-
--health-cmd pg_isready
|
|
77
|
-
--health-interval 10s
|
|
78
|
-
--health-timeout 5s
|
|
79
|
-
--health-retries 10
|
|
80
126
|
env:
|
|
81
|
-
|
|
82
|
-
|
|
127
|
+
DB: postgres
|
|
128
|
+
DB_IMAGE: postgres:${{ matrix.postgres-version }}
|
|
83
129
|
RAILS_VERSION: ${{ matrix.rails-version }}
|
|
130
|
+
BUNDLE_PATH: vendor/bundle
|
|
84
131
|
steps:
|
|
85
132
|
- uses: actions/checkout@v5
|
|
86
133
|
- name: Set up Ruby
|
|
87
134
|
uses: ruby/setup-ruby@v1
|
|
88
135
|
with:
|
|
89
|
-
bundler-cache: true
|
|
90
136
|
ruby-version: "${{ matrix.ruby-version }}"
|
|
137
|
+
# Restore-only: the bundle job owns saving, so matrix jobs never race to write
|
|
138
|
+
# the same cache entry. The most recent entry for this prefix is the one the
|
|
139
|
+
# bundle job just resolved.
|
|
140
|
+
- name: Restore gems
|
|
141
|
+
uses: actions/cache/restore@v6
|
|
142
|
+
with:
|
|
143
|
+
path: vendor/bundle
|
|
144
|
+
key: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-
|
|
145
|
+
restore-keys: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-
|
|
146
|
+
fail-on-cache-miss: true
|
|
147
|
+
- name: Install gems
|
|
148
|
+
run: |
|
|
149
|
+
cp vendor/bundle/Gemfile.lock Gemfile.lock
|
|
150
|
+
bundle install --local
|
|
91
151
|
- name: Run specs
|
|
92
152
|
run: |
|
|
93
153
|
bundle exec rspec --profile --format progress --format RSpec::Github::Formatter
|
|
94
154
|
|
|
95
155
|
mysql:
|
|
156
|
+
needs: bundle
|
|
96
157
|
strategy:
|
|
97
158
|
fail-fast: false
|
|
98
159
|
matrix:
|
|
@@ -108,32 +169,31 @@ jobs:
|
|
|
108
169
|
rails-version: "main"
|
|
109
170
|
|
|
110
171
|
runs-on: ubuntu-latest
|
|
111
|
-
services:
|
|
112
|
-
mysql:
|
|
113
|
-
image: mysql:${{ matrix.mysql-version }}
|
|
114
|
-
env:
|
|
115
|
-
MYSQL_ROOT_PASSWORD: password
|
|
116
|
-
MYSQL_USER: foobar
|
|
117
|
-
MYSQL_PASSWORD: password
|
|
118
|
-
MYSQL_DATABASE: statesman_test
|
|
119
|
-
ports:
|
|
120
|
-
- "3306:3306"
|
|
121
|
-
options: >-
|
|
122
|
-
--health-cmd "mysqladmin ping"
|
|
123
|
-
--health-interval 10s
|
|
124
|
-
--health-timeout 5s
|
|
125
|
-
--health-retries 5
|
|
126
172
|
env:
|
|
127
|
-
|
|
128
|
-
|
|
173
|
+
DB: mysql
|
|
174
|
+
DB_IMAGE: mysql:${{ matrix.mysql-version }}
|
|
129
175
|
RAILS_VERSION: ${{ matrix.rails-version }}
|
|
176
|
+
BUNDLE_PATH: vendor/bundle
|
|
130
177
|
steps:
|
|
131
178
|
- uses: actions/checkout@v5
|
|
132
179
|
- name: Set up Ruby
|
|
133
180
|
uses: ruby/setup-ruby@v1
|
|
134
181
|
with:
|
|
135
|
-
bundler-cache: true
|
|
136
182
|
ruby-version: "${{ matrix.ruby-version }}"
|
|
183
|
+
# Restore-only: the bundle job owns saving, so matrix jobs never race to write
|
|
184
|
+
# the same cache entry. The most recent entry for this prefix is the one the
|
|
185
|
+
# bundle job just resolved.
|
|
186
|
+
- name: Restore gems
|
|
187
|
+
uses: actions/cache/restore@v6
|
|
188
|
+
with:
|
|
189
|
+
path: vendor/bundle
|
|
190
|
+
key: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-
|
|
191
|
+
restore-keys: bundle-${{ runner.os }}-${{ matrix.ruby-version }}-${{ matrix.rails-version }}-
|
|
192
|
+
fail-on-cache-miss: true
|
|
193
|
+
- name: Install gems
|
|
194
|
+
run: |
|
|
195
|
+
cp vendor/bundle/Gemfile.lock Gemfile.lock
|
|
196
|
+
bundle install --local
|
|
137
197
|
- name: Run specs
|
|
138
198
|
run: |
|
|
139
199
|
bundle exec rspec --profile --format progress --format RSpec::Github::Formatter
|
data/.tool-versions
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ruby 3.4.5
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## v13.4.0 14th October 2026
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `Statesman::BulkTransition`, which transitions many parents from one state to another in a single transaction with a fixed number of queries (a flip of the current `most_recent` rows, one `insert_all!`, and one cached-current-state update if configured). Works with the `ActiveRecord` adapter on PostgreSQL, MySQL (including `mysql_gaplock_protection`) and SQLite. Like `transition_to!`, it flips the previous transition and updates the cached current state, but it does not run guards, `before`/`after` callbacks or `ActiveRecord` validations/callbacks on the transition model. Supports per-parent overrides via `metadata_per_id` and `attributes_per_id`, and an optional block that is called inside the transaction with a lazy `ActiveRecord::Relation` over the newly inserted transitions. Parents not in the `from` state are skipped, so a batch is safe to re-run. Assumes a non-STI transition table and a single-column parent primary key.
|
|
13
|
+
|
|
8
14
|
## v13.3.0 4th August 2026
|
|
9
15
|
|
|
10
16
|
### Added
|
data/CONTRIBUTING.md
CHANGED
|
@@ -11,6 +11,26 @@ ways you can help make this project better!
|
|
|
11
11
|
- No need to add to the changelog, we will take care of updating it as we make
|
|
12
12
|
releases.
|
|
13
13
|
|
|
14
|
+
## Running tests
|
|
15
|
+
|
|
16
|
+
`bundle exec rake` runs the suite against SQLite, PostgreSQL and MySQL in
|
|
17
|
+
parallel. PostgreSQL and MySQL are started as throwaway containers in Docker via
|
|
18
|
+
[Testcontainers](https://github.com/testcontainers/testcontainers-ruby).
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
bundle exec rake # all databases, in parallel
|
|
22
|
+
bundle exec rake spec:postgres # a single database
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`bundle exec rspec` runs against SQLite by default. Set `DB` to use a container,
|
|
26
|
+
or point `DATABASE_URL` at an existing database:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
DB=postgres bundle exec rspec # postgres:17
|
|
30
|
+
DB=mysql bundle exec rspec # mysql:8.4
|
|
31
|
+
DB=mysql DB_IMAGE=mysql:9.4 bundle exec rspec spec/statesman/machine_spec.rb
|
|
32
|
+
```
|
|
33
|
+
|
|
14
34
|
## Style
|
|
15
35
|
|
|
16
36
|
We use [Rubocop](https://github.com/bbatsov/rubocop) to help maintain a
|
data/Gemfile
CHANGED
|
@@ -12,6 +12,9 @@ else
|
|
|
12
12
|
gem "rails", "~> 8.0"
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
# ActiveSupport 7.2 passes `quirks_mode:` to JSON, which json 3 no longer accepts.
|
|
16
|
+
gem "json", "< 3" if ENV['RAILS_VERSION'] == '7.2'
|
|
17
|
+
|
|
15
18
|
group :development, :test do
|
|
16
19
|
gem "ammeter", "~> 1.1"
|
|
17
20
|
gem "gc_ruboconfig", "~> 6.0.0"
|
|
@@ -24,6 +27,8 @@ group :development, :test do
|
|
|
24
27
|
gem "rspec-its", "~> 2.0"
|
|
25
28
|
gem "rspec-rails", "~> 8.0"
|
|
26
29
|
gem "sqlite3", "~> 2.9.0"
|
|
30
|
+
gem "testcontainers-mysql", "~> 0.2.0"
|
|
31
|
+
gem "testcontainers-postgres", "~> 0.2.0"
|
|
27
32
|
gem "test-unit", "~> 3.3"
|
|
28
33
|
gem "timecop", "~> 0.9.1"
|
|
29
34
|
end
|
data/README.md
CHANGED
|
@@ -674,6 +674,66 @@ returning the configured column, for generic tooling that needs to introspect
|
|
|
674
674
|
or repair the cached column (e.g. a cache-repair rake task working across
|
|
675
675
|
several models).
|
|
676
676
|
|
|
677
|
+
## Bulk transitions
|
|
678
|
+
|
|
679
|
+
`Statesman::BulkTransition` moves many parents from one state to another in a
|
|
680
|
+
single transaction, with a fixed number of queries however many parents there
|
|
681
|
+
are, instead of the several queries per record that `transition_to!` costs. It
|
|
682
|
+
works with the `ActiveRecord` adapter on PostgreSQL, MySQL and SQLite.
|
|
683
|
+
|
|
684
|
+
```ruby
|
|
685
|
+
bulk = Statesman::BulkTransition.new(
|
|
686
|
+
model_class: Order,
|
|
687
|
+
machine_class: OrderStateMachine,
|
|
688
|
+
from: :pending,
|
|
689
|
+
to: :confirmed,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
transitioned_ids = bulk.call!(order_ids, metadata: { source: "import" })
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
`call!` returns the ids of the parents that were actually transitioned. Parents
|
|
696
|
+
that aren't currently in the `from` state are silently skipped, so a batch is
|
|
697
|
+
safe to re-run.
|
|
698
|
+
|
|
699
|
+
It performs the same mechanical write as `transition_to!` — flipping the
|
|
700
|
+
current `most_recent` transition, inserting the new one, and updating the
|
|
701
|
+
parent's cached current state if you've called `configure_cached_current_state`
|
|
702
|
+
— but it deliberately **does not run** the machine's guards, its
|
|
703
|
+
`before_transition`/`after_transition` callbacks, or any validations and
|
|
704
|
+
callbacks on the transition model. If you need equivalent side effects,
|
|
705
|
+
reproduce them in the block (below).
|
|
706
|
+
|
|
707
|
+
Options:
|
|
708
|
+
|
|
709
|
+
- `metadata:` — applied to every new transition. `metadata_per_id:` overrides it
|
|
710
|
+
for individual parents, keyed by parent id (`1` and `"1"` match the same
|
|
711
|
+
parent).
|
|
712
|
+
- `attributes_to_copy:` — extra transition columns to carry forward from the
|
|
713
|
+
previous transition onto the new one. The parent foreign key and `sort_key`
|
|
714
|
+
are always carried. There's no default, so you have to choose explicitly.
|
|
715
|
+
`attributes_per_id:` overrides those carried values for individual parents,
|
|
716
|
+
and may only name columns listed in `attributes_to_copy` (anything else raises
|
|
717
|
+
`Statesman::BulkTransition::ValidationError`).
|
|
718
|
+
|
|
719
|
+
If you pass a block, it is called inside the same transaction with an
|
|
720
|
+
`ActiveRecord::Relation` over the newly inserted transitions. The relation is
|
|
721
|
+
lazy, so the block decides what to read or write:
|
|
722
|
+
|
|
723
|
+
```ruby
|
|
724
|
+
bulk.call!(order_ids) do |transitions|
|
|
725
|
+
rows = transitions.pluck(:order_id, :to_state)
|
|
726
|
+
AuditLog.insert_all(rows.map { |id, state| { order_id: id, state: state } })
|
|
727
|
+
end
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
An exception raised in the block rolls the entire batch back. If a row
|
|
731
|
+
conflicts with the unique index on the transition table, `call!` raises
|
|
732
|
+
`Statesman::TransitionConflictError` and nothing is committed.
|
|
733
|
+
|
|
734
|
+
Two assumptions to be aware of: the transition table must not be an STI table,
|
|
735
|
+
and the parent must have a single-column primary key.
|
|
736
|
+
|
|
677
737
|
## Frequently Asked Questions
|
|
678
738
|
|
|
679
739
|
### Storing the state on the model object
|
data/Rakefile
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
|
+
require "open3"
|
|
4
5
|
require "rspec/core/rake_task"
|
|
5
6
|
|
|
6
7
|
RSpec::Core::RakeTask.new(:spec) do |task|
|
|
@@ -13,4 +14,34 @@ RSpec::Core::RakeTask.new(:spec) do |task|
|
|
|
13
14
|
end
|
|
14
15
|
end
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
DATABASES = %w[sqlite postgres mysql].freeze
|
|
18
|
+
RSPEC_COMMAND = %w[bundle exec rspec].freeze
|
|
19
|
+
|
|
20
|
+
# DB_IMAGE and DATABASE_URL are cleared so each run gets its own database rather than all of them
|
|
21
|
+
# sharing whichever one the caller's environment points at.
|
|
22
|
+
database_env = ->(db) { { "DB" => (db unless db == "sqlite"), "DB_IMAGE" => nil, "DATABASE_URL" => nil } }
|
|
23
|
+
|
|
24
|
+
namespace :spec do
|
|
25
|
+
DATABASES.each do |db|
|
|
26
|
+
desc "Run specs against #{db}"
|
|
27
|
+
task(db) { sh(database_env.call(db), *RSPEC_COMMAND) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
desc "Run specs against all databases in parallel"
|
|
31
|
+
task :all do
|
|
32
|
+
output_lock = Mutex.new
|
|
33
|
+
|
|
34
|
+
results = DATABASES.map do |db|
|
|
35
|
+
Thread.new do
|
|
36
|
+
output, status = Open3.capture2e(database_env.call(db), *RSPEC_COMMAND)
|
|
37
|
+
output_lock.synchronize { puts "==> #{db}", output }
|
|
38
|
+
[db, status.success?]
|
|
39
|
+
end
|
|
40
|
+
end.map(&:value)
|
|
41
|
+
|
|
42
|
+
failed = results.reject { |_, success| success }.map(&:first)
|
|
43
|
+
abort "Specs failed for: #{failed.join(', ')}" if failed.any?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
task default: "spec:all"
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statesman
|
|
4
|
+
# Bulk, race-safe state transitions for ActiveRecord (PostgreSQL, MySQL, SQLite).
|
|
5
|
+
#
|
|
6
|
+
# Statesman's Machine#transition_to! handles one parent at a time: a flip UPDATE on
|
|
7
|
+
# the current `most_recent` transition, an INSERT for the new one, and (if the model
|
|
8
|
+
# called configure_cached_current_state) an UPDATE on the parent's cached column -
|
|
9
|
+
# each inside its own transaction, with guards/callbacks firing per record.
|
|
10
|
+
#
|
|
11
|
+
# BulkTransition performs the same mechanical write for many parents in a single
|
|
12
|
+
# transaction: one `UPDATE ... RETURNING` to flip+lock the old rows (on adapters without
|
|
13
|
+
# RETURNING: a `SELECT ... FOR UPDATE` then an `UPDATE`), one `insert_all!` for the new
|
|
14
|
+
# ones, and one bulk cached_current_state UPDATE if configured. This trades
|
|
15
|
+
# per-record guards/callbacks/AR-validations for an order-of-magnitude fewer round
|
|
16
|
+
# trips on large batches.
|
|
17
|
+
#
|
|
18
|
+
# Deliberately NOT run: Machine guards, before/after/after_commit callbacks, and AR
|
|
19
|
+
# validations/callbacks on the transition model. Callers that need equivalent side
|
|
20
|
+
# effects must reproduce them, set-based, in the block passed to #call!.
|
|
21
|
+
#
|
|
22
|
+
# Like the single-transition path, "not current" is written as NULL when the
|
|
23
|
+
# `most_recent` column is nullable (databases without partial indexes, e.g. MySQL, need
|
|
24
|
+
# this for the unique (parent, most_recent) index) and false otherwise. On MySQL the
|
|
25
|
+
# new rows are inserted "not current" and then marked current in a separate UPDATE, to
|
|
26
|
+
# avoid the next-key locks an INSERT of `most_recent = true` takes (see
|
|
27
|
+
# Statesman::Adapters::ActiveRecord#create_transition).
|
|
28
|
+
#
|
|
29
|
+
# Assumption (documented, not enforced): the transition table is not an STI table.
|
|
30
|
+
#
|
|
31
|
+
# A later version may add guard/callback execution (falling back to a plain per-item
|
|
32
|
+
# #transition_to! loop where needed) - this is the proven,
|
|
33
|
+
# independent mechanical core that such a version would sit on top of.
|
|
34
|
+
class BulkTransition
|
|
35
|
+
class ValidationError < StandardError; end
|
|
36
|
+
|
|
37
|
+
SORT_KEY_INCREMENT = 10
|
|
38
|
+
|
|
39
|
+
def initialize(model_class:, machine_class:, from:, to:)
|
|
40
|
+
@model_class = model_class
|
|
41
|
+
@from = from.to_s
|
|
42
|
+
@to = to.to_s
|
|
43
|
+
@transition_class = model_class.transition_class
|
|
44
|
+
|
|
45
|
+
machine_class.validate_from_and_to_state(@from, @to)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param parent_ids [Array<String, Integer>] primary keys of the parents to
|
|
49
|
+
# transition. Parents not currently in `from` state are silently skipped - this
|
|
50
|
+
# is what makes the batch idempotent and safe to re-run.
|
|
51
|
+
# @param metadata [Hash] applied uniformly to every new transition row in the batch,
|
|
52
|
+
# unless overridden per-parent by metadata_per_id.
|
|
53
|
+
# @param attributes_to_copy [Array<String, Symbol>] extra transition-row columns to
|
|
54
|
+
# carry forward from the flipped row onto the new one, beyond the parent FK and
|
|
55
|
+
# sort_key, which are always carried. No default: the caller must explicitly audit
|
|
56
|
+
# which mutable columns their own after_transition callbacks depend on.
|
|
57
|
+
# @param metadata_per_id [Hash{String, Integer => Hash}, nil] per-parent metadata
|
|
58
|
+
# overrides, keyed by parent id (matched by its string form, so 1 and "1" are the
|
|
59
|
+
# same parent), merged onto (and taking precedence over) the uniform `metadata` for
|
|
60
|
+
# that parent only. Parents absent from this hash just get the uniform `metadata`.
|
|
61
|
+
# @param attributes_per_id [Hash{String, Integer => Hash}, nil] per-parent overrides,
|
|
62
|
+
# keyed like metadata_per_id, for attributes_to_copy columns only (anything else
|
|
63
|
+
# raises ValidationError), merged onto (and taking precedence over) the value
|
|
64
|
+
# carried forward from that parent's own flipped row - for a value that can't be
|
|
65
|
+
# derived from the previous transition (e.g. a value looked up from an
|
|
66
|
+
# association the caller already has loaded).
|
|
67
|
+
# @param block [Proc, nil] optional, called with an ActiveRecord::Relation over the
|
|
68
|
+
# newly inserted transitions, inside the same transaction, after rows are flipped
|
|
69
|
+
# and inserted (and after the cached_current_state bulk update, if any). The
|
|
70
|
+
# relation is lazy: the block decides what to read (e.g. `pluck`) or write. An
|
|
71
|
+
# exception here rolls back the entire batch - nothing is committed.
|
|
72
|
+
# @return [Array] parent ids that were actually transitioned.
|
|
73
|
+
def call!(
|
|
74
|
+
parent_ids, metadata: {}, attributes_to_copy: [],
|
|
75
|
+
metadata_per_id: nil, attributes_per_id: nil, &block
|
|
76
|
+
)
|
|
77
|
+
return [] if parent_ids.empty?
|
|
78
|
+
|
|
79
|
+
attributes_to_copy = attributes_to_copy.to_set(&:to_s)
|
|
80
|
+
validate_attribute_names!(attributes_to_copy)
|
|
81
|
+
metadata_per_id = normalize_per_id(metadata_per_id)
|
|
82
|
+
attributes_per_id = normalize_per_id(attributes_per_id)
|
|
83
|
+
validate_attributes_per_id!(attributes_per_id, attributes_to_copy)
|
|
84
|
+
|
|
85
|
+
now = Time.current
|
|
86
|
+
transitioned_ids = []
|
|
87
|
+
|
|
88
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
89
|
+
flipped_rows = flip_most_recent!(parent_ids, now, attributes_to_copy)
|
|
90
|
+
next if flipped_rows.empty?
|
|
91
|
+
|
|
92
|
+
primary_keys, transitioned_ids = insert_new_transitions!(
|
|
93
|
+
flipped_rows, metadata, attributes_to_copy, now, metadata_per_id, attributes_per_id
|
|
94
|
+
).transpose
|
|
95
|
+
inserted = transition_class.where(transition_class.primary_key => primary_keys)
|
|
96
|
+
|
|
97
|
+
update_cached_current_state!(transitioned_ids)
|
|
98
|
+
|
|
99
|
+
block&.call(inserted)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
transitioned_ids
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
attr_reader :model_class, :from, :to, :transition_class
|
|
108
|
+
|
|
109
|
+
def mysql_gaplock_protection?
|
|
110
|
+
Statesman.mysql_gaplock_protection?(transition_class.connection)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Matches the single-transition path: NULL when the column is nullable (needed for a
|
|
114
|
+
# unique (parent, most_recent) index on databases without partial indexes), else false.
|
|
115
|
+
def not_most_recent_value
|
|
116
|
+
transition_class.columns_hash["most_recent"].null == false ? false : nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# PostgreSQL can flip and return rows in one `UPDATE ... RETURNING` statement and
|
|
120
|
+
# recover inserted keys from `INSERT ... RETURNING`. Other adapters (MySQL, SQLite)
|
|
121
|
+
# take a portable path built from `SELECT ... FOR UPDATE` + `UPDATE` by primary key.
|
|
122
|
+
def returning_supported?
|
|
123
|
+
transition_class.connection.adapter_name.casecmp("postgresql").zero?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def validate_attribute_names!(attributes_to_copy)
|
|
127
|
+
unknown = attributes_to_copy - transition_class.column_names
|
|
128
|
+
return if unknown.empty?
|
|
129
|
+
|
|
130
|
+
raise ValidationError, "Unknown #{transition_class.name} column(s): #{unknown.join(', ')}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Overrides are matched against ids read back from the database, whose type (Integer,
|
|
134
|
+
# String UUID) the caller needn't know - so both sides are compared as strings. Inner
|
|
135
|
+
# keys become strings too, because the rows being inserted are string-keyed.
|
|
136
|
+
def normalize_per_id(per_id)
|
|
137
|
+
per_id&.to_h { |id, overrides| [id.to_s, overrides.transform_keys(&:to_s)] }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Every row in one insert_all! must have the same keys, and an override for a column
|
|
141
|
+
# that isn't carried forward would add a key to only some rows.
|
|
142
|
+
def validate_attributes_per_id!(attributes_per_id, attributes_to_copy)
|
|
143
|
+
return if attributes_per_id.nil?
|
|
144
|
+
|
|
145
|
+
unknown = attributes_per_id.values.flat_map(&:keys).to_set - attributes_to_copy
|
|
146
|
+
return if unknown.empty?
|
|
147
|
+
|
|
148
|
+
raise ValidationError, "attributes_per_id column(s) not in attributes_to_copy: #{unknown.to_a.join(', ')}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def parent_reflection
|
|
152
|
+
@parent_reflection ||= Statesman::Adapters::ActiveRecordQueries.
|
|
153
|
+
transition_reflection_for(model_class, transition_class)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def parent_foreign_key
|
|
157
|
+
@parent_foreign_key ||= parent_reflection.foreign_key
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def parent_primary_key
|
|
161
|
+
@parent_primary_key ||= parent_reflection.active_record_primary_key
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Flips the `from`-state transitions to not-most-recent, returning the flipped
|
|
165
|
+
# rows (including their pre-flip sort_key) via RETURNING. Under Postgres Read
|
|
166
|
+
# Committed, a concurrent writer that commits a transition on one of these parents
|
|
167
|
+
# between this statement starting and acquiring its row lock causes the WHERE
|
|
168
|
+
# clause to re-evaluate against the latest committed row on wake - so that parent
|
|
169
|
+
# is silently excluded here, never corrupted.
|
|
170
|
+
def flip_most_recent!(parent_ids, now, attributes_to_copy)
|
|
171
|
+
if returning_supported?
|
|
172
|
+
flip_with_returning!(parent_ids, now, attributes_to_copy)
|
|
173
|
+
else
|
|
174
|
+
flip_with_lock!(parent_ids, now, attributes_to_copy)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def flip_with_returning!(parent_ids, now, attributes_to_copy)
|
|
179
|
+
# Not `RETURNING *`: only the carried columns are used afterwards, and a wider
|
|
180
|
+
# result set is costly on large batches.
|
|
181
|
+
returning = carried_columns(attributes_to_copy).
|
|
182
|
+
map { |column| transition_class.connection.quote_column_name(column) }.
|
|
183
|
+
join(", ")
|
|
184
|
+
|
|
185
|
+
# Arel has no RETURNING support, so that one clause is appended to the compiled
|
|
186
|
+
# statement; everything else (quoting, binds, IN list) is built by Arel.
|
|
187
|
+
sql = "#{flip_update_manager(parent_ids, now).to_sql} RETURNING #{returning}"
|
|
188
|
+
|
|
189
|
+
transition_class.connection.exec_query(sql).to_a
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def flip_update_manager(parent_ids, now)
|
|
193
|
+
table = transition_class.arel_table
|
|
194
|
+
|
|
195
|
+
Arel::UpdateManager.new.tap do |update|
|
|
196
|
+
update.table(table)
|
|
197
|
+
update.set([[table[:most_recent], not_most_recent_value], [table[:updated_at], now]])
|
|
198
|
+
update.where(
|
|
199
|
+
table[:to_state].eq(from).
|
|
200
|
+
and(table[:most_recent].eq(true)).
|
|
201
|
+
and(table[parent_foreign_key].in(parent_ids)),
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Portable equivalent of the above: lock the matching rows, then flip them by primary
|
|
207
|
+
# key. The locking read always sees the latest committed row (MySQL REPEATABLE READ
|
|
208
|
+
# included), so a parent that a concurrent writer already moved on is excluded. SQLite
|
|
209
|
+
# has no row locks, but serialises writers at the database level.
|
|
210
|
+
def flip_with_lock!(parent_ids, now, attributes_to_copy)
|
|
211
|
+
columns = carried_columns(attributes_to_copy)
|
|
212
|
+
primary_key = transition_class.primary_key
|
|
213
|
+
|
|
214
|
+
locked = transition_class.
|
|
215
|
+
where(to_state: from, most_recent: true, parent_foreign_key => parent_ids).
|
|
216
|
+
lock.
|
|
217
|
+
pluck(primary_key, *columns)
|
|
218
|
+
return [] if locked.empty?
|
|
219
|
+
|
|
220
|
+
transition_class.where(primary_key => locked.map(&:first)).
|
|
221
|
+
update_all(most_recent: not_most_recent_value, updated_at: now)
|
|
222
|
+
|
|
223
|
+
locked.map { |_pk, *values| columns.zip(values).to_h }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def carried_columns(attributes_to_copy)
|
|
227
|
+
Set[parent_foreign_key, "sort_key"] | attributes_to_copy
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def insert_new_transitions!(
|
|
231
|
+
flipped_rows, metadata, attributes_to_copy, now, metadata_per_id, attributes_per_id
|
|
232
|
+
)
|
|
233
|
+
carried = carried_columns(attributes_to_copy)
|
|
234
|
+
include_from_state = transition_class.has_attribute?(:from_state)
|
|
235
|
+
most_recent = mysql_gaplock_protection? ? not_most_recent_value : true
|
|
236
|
+
|
|
237
|
+
rows = flipped_rows.map do |row|
|
|
238
|
+
id = row[parent_foreign_key]
|
|
239
|
+
row_metadata = merge_per_id(metadata, metadata_per_id, id)
|
|
240
|
+
row_overrides = merge_per_id({}, attributes_per_id, id)
|
|
241
|
+
|
|
242
|
+
new_row = row.slice(*carried).merge(row_overrides).merge(
|
|
243
|
+
"to_state" => to,
|
|
244
|
+
"metadata" => row_metadata,
|
|
245
|
+
"most_recent" => most_recent,
|
|
246
|
+
"sort_key" => row["sort_key"].to_i + SORT_KEY_INCREMENT,
|
|
247
|
+
"created_at" => now,
|
|
248
|
+
"updated_at" => now,
|
|
249
|
+
)
|
|
250
|
+
new_row["from_state"] = from if include_from_state
|
|
251
|
+
new_row
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
insert_rows!(rows)
|
|
255
|
+
rescue ActiveRecord::RecordNotUnique => e
|
|
256
|
+
# One conflicting row aborts the whole batch - this is an accepted trade-off,
|
|
257
|
+
# not a silent gap: every real writer (Statesman's own single-transition path, and
|
|
258
|
+
# this class) follows the flip-then-insert convention, so a collision here means
|
|
259
|
+
# something outside that convention wrote a most_recent row directly.
|
|
260
|
+
raise TransitionConflictError, e.message
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Inserts the rows and returns [primary key, parent foreign key] pairs for them, so the
|
|
264
|
+
# caller can both build a relation and know which parents moved without another query.
|
|
265
|
+
# Under MySQL gap-lock protection the rows are also marked most recent here, so
|
|
266
|
+
# whatever reads them afterwards sees the final state.
|
|
267
|
+
def insert_rows!(rows)
|
|
268
|
+
inserted = insert_and_fetch_keys!(rows)
|
|
269
|
+
mark_most_recent!(inserted.map(&:first)) if mysql_gaplock_protection?
|
|
270
|
+
inserted
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def insert_and_fetch_keys!(rows)
|
|
274
|
+
if returning_supported?
|
|
275
|
+
return transition_class.insert_all!(rows, returning: [transition_class.primary_key, parent_foreign_key]).rows
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
transition_class.insert_all!(rows)
|
|
279
|
+
fetch_inserted_keys(rows)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Finds the rows just inserted by (parent, sort_key): the unique index Statesman
|
|
283
|
+
# requires on those columns makes that pair unambiguous, whatever state the rows are in.
|
|
284
|
+
def fetch_inserted_keys(inserted)
|
|
285
|
+
sort_keys = inserted.map { |row| row["sort_key"] }.uniq
|
|
286
|
+
expected = inserted.to_set { |row| [row[parent_foreign_key], row["sort_key"]] }
|
|
287
|
+
|
|
288
|
+
transition_class.
|
|
289
|
+
where(parent_foreign_key => inserted.map { |row| row[parent_foreign_key] }, sort_key: sort_keys).
|
|
290
|
+
pluck(transition_class.primary_key, parent_foreign_key, "sort_key").
|
|
291
|
+
select { |_pk, fk, sort_key| expected.include?([fk, sort_key]) }.
|
|
292
|
+
map { |pk, fk, _sort_key| [pk, fk] }
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Second half of the MySQL gap-lock protection: the old rows were already flipped, so
|
|
296
|
+
# this only ever sets one row per parent to true and cannot trip the unique index.
|
|
297
|
+
def mark_most_recent!(primary_keys)
|
|
298
|
+
transition_class.where(transition_class.primary_key => primary_keys).
|
|
299
|
+
update_all(most_recent: true)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Merges a per-parent override hash (see normalize_per_id) onto a base hash, for one
|
|
303
|
+
# parent only. The override, where present, wins key-by-key.
|
|
304
|
+
def merge_per_id(base, per_id, id)
|
|
305
|
+
return base if per_id.nil? || !per_id.key?(id.to_s)
|
|
306
|
+
|
|
307
|
+
base.merge(per_id.fetch(id.to_s))
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def update_cached_current_state!(transitioned_ids)
|
|
311
|
+
return if transitioned_ids.empty?
|
|
312
|
+
return unless model_class.respond_to?(:cached_state_column_name)
|
|
313
|
+
|
|
314
|
+
column = model_class.cached_state_column_name
|
|
315
|
+
attrs = { column => to }
|
|
316
|
+
attrs[:updated_at] = Time.current if model_class.cached_current_state_touch_updated_at?
|
|
317
|
+
|
|
318
|
+
model_class.where(parent_primary_key => transitioned_ids).update_all(attrs)
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
data/lib/statesman/version.rb
CHANGED
data/lib/statesman.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
module Statesman
|
|
4
4
|
autoload :Config, "statesman/config"
|
|
5
5
|
autoload :Machine, "statesman/machine"
|
|
6
|
+
autoload :BulkTransition, "statesman/bulk_transition"
|
|
6
7
|
autoload :Callback, "statesman/callback"
|
|
7
8
|
autoload :Guard, "statesman/guard"
|
|
8
9
|
autoload :Utils, "statesman/utils"
|
data/spec/spec_helper.rb
CHANGED
|
@@ -32,11 +32,18 @@ RSpec.configure do |config|
|
|
|
32
32
|
else
|
|
33
33
|
current_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call
|
|
34
34
|
|
|
35
|
+
database_url = ENV.fetch("DATABASE_URL") do
|
|
36
|
+
if (db = ENV["DB"].presence)
|
|
37
|
+
require "support/database_container"
|
|
38
|
+
DatabaseContainer.start(db, image: ENV["DB_IMAGE"].presence)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
35
42
|
# We have to parse this to a hash since ActiveRecord::Base.configurations
|
|
36
43
|
# will only consider a single URL config.
|
|
37
|
-
url_config = if
|
|
44
|
+
url_config = if database_url
|
|
38
45
|
ActiveRecord::DatabaseConfigurations::ConnectionUrlResolver.
|
|
39
|
-
new(
|
|
46
|
+
new(database_url).to_hash.merge({ sslmode: "disable" })
|
|
40
47
|
end
|
|
41
48
|
|
|
42
49
|
db_config = {
|
|
@@ -15,9 +15,7 @@ describe Statesman::Adapters::ActiveRecord, :active_record do
|
|
|
15
15
|
Statesman.configure do
|
|
16
16
|
# Rubocop requires described_class to be used, but this block
|
|
17
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
|
|
18
|
+
storage_adapter(Statesman::Adapters::ActiveRecord) # rubocop:disable RSpec/DescribedClass
|
|
21
19
|
end
|
|
22
20
|
end
|
|
23
21
|
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
describe Statesman::BulkTransition, :active_record do
|
|
6
|
+
before do
|
|
7
|
+
# The shared MyActiveRecordModel fixture is wired via the older
|
|
8
|
+
# `include ActiveRecordQueries[...]` form, which doesn't define `.transition_class`.
|
|
9
|
+
# BulkTransition relies on the newer `configure_state_machine` convention (the same
|
|
10
|
+
# one `configure_cached_current_state` requires), so configure it here once.
|
|
11
|
+
unless MyActiveRecordModel.respond_to?(:transition_class)
|
|
12
|
+
MyActiveRecordModel.extend(Statesman::Adapters::TypeSafeActiveRecordQueries)
|
|
13
|
+
MyActiveRecordModel.configure_state_machine(
|
|
14
|
+
transition_class: MyActiveRecordModelTransition, initial_state: :initial,
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
unless MyActiveRecordModel.respond_to?(:cached_state_column_name)
|
|
19
|
+
MyActiveRecordModel.include(Statesman::Adapters::ConfigureCachedCurrentState)
|
|
20
|
+
MyActiveRecordModel.configure_cached_current_state
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
prepare_model_table
|
|
24
|
+
prepare_transitions_table
|
|
25
|
+
|
|
26
|
+
# Other specs redefine MyActiveRecordModel.transition_class without restoring it, so
|
|
27
|
+
# pin it here to stay independent of spec ordering.
|
|
28
|
+
allow(MyActiveRecordModel).to receive(:transition_class).
|
|
29
|
+
and_return(MyActiveRecordModelTransition)
|
|
30
|
+
|
|
31
|
+
# Add a free business column (not one of AR's "magic" created_on/updated_on/etc.
|
|
32
|
+
# timestamp columns, which insert_all auto-populates regardless of row content) so
|
|
33
|
+
# attributes_to_copy has something meaningful to carry forward in this spec.
|
|
34
|
+
ActiveRecord::Base.connection.execute(
|
|
35
|
+
"ALTER TABLE my_active_record_model_transitions ADD COLUMN extra_data text",
|
|
36
|
+
)
|
|
37
|
+
MyActiveRecordModelTransition.reset_column_information
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
let(:machine_class) { MyStateMachine }
|
|
41
|
+
|
|
42
|
+
# How a non-current row's most_recent is stored: NULL when the column is nullable
|
|
43
|
+
# (needed for the unique index on databases without partial indexes, e.g. MySQL),
|
|
44
|
+
# false otherwise - same as the single-transition path.
|
|
45
|
+
def not_most_recent_value
|
|
46
|
+
MyActiveRecordModelTransition.columns_hash["most_recent"].null ? nil : false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def create_model(initial_state: "initial", extra_data: nil)
|
|
50
|
+
model = MyActiveRecordModel.create!
|
|
51
|
+
model.my_active_record_model_transitions.create!(
|
|
52
|
+
to_state: initial_state, sort_key: 10, most_recent: true, metadata: {},
|
|
53
|
+
extra_data: extra_data
|
|
54
|
+
)
|
|
55
|
+
model
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def last_transition_for(id)
|
|
59
|
+
MyActiveRecordModel.find(id).my_active_record_model_transitions.order(:sort_key).last
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
describe "#call!" do
|
|
63
|
+
it "transitions only the parents currently in the from state" do
|
|
64
|
+
in_initial = Array.new(3) { create_model.id }
|
|
65
|
+
in_succeeded = create_model(initial_state: "succeeded").id
|
|
66
|
+
|
|
67
|
+
bulk = described_class.new(
|
|
68
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
69
|
+
from: :initial, to: :succeeded
|
|
70
|
+
)
|
|
71
|
+
transitioned = bulk.call!(in_initial + [in_succeeded], metadata: { "foo" => "bar" })
|
|
72
|
+
|
|
73
|
+
expect(transitioned).to match_array(in_initial)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "raises TransitionConflictError and rolls the whole batch back on a conflicting row" do
|
|
77
|
+
ok = create_model.id
|
|
78
|
+
conflicted = create_model.id
|
|
79
|
+
# An out-of-band row occupying the sort_key the new transition would take.
|
|
80
|
+
MyActiveRecordModelTransition.create!(
|
|
81
|
+
my_active_record_model_id: conflicted, to_state: "failed", sort_key: 20,
|
|
82
|
+
most_recent: not_most_recent_value, metadata: {}
|
|
83
|
+
)
|
|
84
|
+
bulk = described_class.new(
|
|
85
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
86
|
+
from: :initial, to: :succeeded
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
expect { bulk.call!([ok, conflicted]) }.to raise_error(Statesman::TransitionConflictError)
|
|
90
|
+
|
|
91
|
+
expect(MyActiveRecordModelTransition.where(my_active_record_model_id: [ok, conflicted], most_recent: true).
|
|
92
|
+
pluck(:to_state)).to eq(%w[initial initial])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "marks flipped rows as not current the same way the single path does" do
|
|
96
|
+
ids = Array.new(2) { create_model.id }
|
|
97
|
+
bulk = described_class.new(
|
|
98
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
99
|
+
from: :initial, to: :succeeded
|
|
100
|
+
)
|
|
101
|
+
bulk.call!(ids)
|
|
102
|
+
|
|
103
|
+
old_rows = MyActiveRecordModelTransition.where(my_active_record_model_id: ids, to_state: "initial")
|
|
104
|
+
|
|
105
|
+
expect(old_rows.map(&:most_recent)).to all(eq(not_most_recent_value))
|
|
106
|
+
expect(MyActiveRecordModelTransition.where(my_active_record_model_id: ids, most_recent: true).count).
|
|
107
|
+
to eq(ids.size)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it "applies the metadata and most_recent flag to each transitioned parent" do
|
|
111
|
+
in_initial = Array.new(3) { create_model.id }
|
|
112
|
+
bulk = described_class.new(
|
|
113
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
114
|
+
from: :initial, to: :succeeded
|
|
115
|
+
)
|
|
116
|
+
bulk.call!(in_initial, metadata: { "foo" => "bar" })
|
|
117
|
+
|
|
118
|
+
MyActiveRecordModel.where(id: in_initial).each do |model|
|
|
119
|
+
last = model.my_active_record_model_transitions.order(:sort_key).last
|
|
120
|
+
expect(last.to_state).to eq("succeeded")
|
|
121
|
+
expect(last.most_recent).to be(true)
|
|
122
|
+
expect(last.metadata).to eq({ "foo" => "bar" })
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "leaves parents not currently in the from state untouched" do
|
|
127
|
+
in_succeeded = create_model(initial_state: "succeeded").id
|
|
128
|
+
bulk = described_class.new(
|
|
129
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
130
|
+
from: :initial, to: :succeeded
|
|
131
|
+
)
|
|
132
|
+
bulk.call!([in_succeeded], metadata: { "foo" => "bar" })
|
|
133
|
+
|
|
134
|
+
expect(last_transition_for(in_succeeded).sort_key).to eq(10)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it "is idempotent - re-running skips parents no longer in the from state" do
|
|
138
|
+
ids = Array.new(2) { create_model.id }
|
|
139
|
+
bulk = described_class.new(
|
|
140
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
141
|
+
from: :initial, to: :succeeded
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
first_run = bulk.call!(ids)
|
|
145
|
+
second_run = bulk.call!(ids)
|
|
146
|
+
|
|
147
|
+
expect(first_run).to match_array(ids)
|
|
148
|
+
expect(second_run).to eq([])
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it "flips the old most_recent row off and sets sort_key = old + 10" do
|
|
152
|
+
id = create_model.id
|
|
153
|
+
bulk = described_class.new(
|
|
154
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
155
|
+
from: :initial, to: :succeeded
|
|
156
|
+
)
|
|
157
|
+
bulk.call!([id])
|
|
158
|
+
|
|
159
|
+
transitions = MyActiveRecordModel.find(id).
|
|
160
|
+
my_active_record_model_transitions.order(:sort_key)
|
|
161
|
+
expect(transitions.map(&:most_recent)).to eq([not_most_recent_value, true])
|
|
162
|
+
expect(transitions.map(&:sort_key)).to eq([10, 20])
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it "carries forward only explicitly requested attributes_to_copy" do
|
|
166
|
+
id = create_model(extra_data: "keep-me").id
|
|
167
|
+
bulk = described_class.new(
|
|
168
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
169
|
+
from: :initial, to: :succeeded
|
|
170
|
+
)
|
|
171
|
+
bulk.call!([id], attributes_to_copy: ["extra_data"])
|
|
172
|
+
|
|
173
|
+
expect(last_transition_for(id).extra_data).to eq("keep-me")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it "does not carry forward attributes that weren't requested" do
|
|
177
|
+
id = create_model(extra_data: "should-not-persist").id
|
|
178
|
+
bulk = described_class.new(
|
|
179
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
180
|
+
from: :initial, to: :succeeded
|
|
181
|
+
)
|
|
182
|
+
bulk.call!([id]) # no attributes_to_copy
|
|
183
|
+
|
|
184
|
+
expect(last_transition_for(id).extra_data).to be_nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
describe "flip query RETURNING clause" do
|
|
188
|
+
def flip_sql_for(bulk, id, **options)
|
|
189
|
+
statements = []
|
|
190
|
+
subscriber = ->(*, payload) do
|
|
191
|
+
statements << payload[:sql] if /\AUPDATE\s+"?my_active_record_model_transitions/i.match?(payload[:sql])
|
|
192
|
+
end
|
|
193
|
+
ActiveSupport::Notifications.subscribed(subscriber, "sql.active_record") do
|
|
194
|
+
bulk.call!([id], **options)
|
|
195
|
+
end
|
|
196
|
+
statements.find { |sql| sql.include?(" RETURNING ") }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
let(:bulk) do
|
|
200
|
+
described_class.new(
|
|
201
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
202
|
+
from: :initial, to: :succeeded
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it "returns only the carried columns, not *" do
|
|
207
|
+
skip "RETURNING is PostgreSQL-only" unless postgres? # rubocop:disable RSpec/Pending
|
|
208
|
+
id = create_model(extra_data: "x").id
|
|
209
|
+
sql = flip_sql_for(bulk, id, attributes_to_copy: ["extra_data"])
|
|
210
|
+
returning = sql[/RETURNING (.*)\z/m, 1]
|
|
211
|
+
|
|
212
|
+
expect(sql).to_not include("RETURNING *")
|
|
213
|
+
expect(returning.split(", ")).to contain_exactly(
|
|
214
|
+
'"my_active_record_model_id"', '"sort_key"', '"extra_data"'
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
it "does not return columns that were not requested" do
|
|
219
|
+
skip "RETURNING is PostgreSQL-only" unless postgres? # rubocop:disable RSpec/Pending
|
|
220
|
+
id = create_model(extra_data: "x").id
|
|
221
|
+
sql = flip_sql_for(bulk, id, attributes_to_copy: [])
|
|
222
|
+
|
|
223
|
+
expect(sql[/RETURNING (.*)\z/m, 1]).to_not include("extra_data")
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
it "raises ValidationError for unknown attributes_to_copy columns" do
|
|
228
|
+
id = create_model.id
|
|
229
|
+
bulk = described_class.new(
|
|
230
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
231
|
+
from: :initial, to: :succeeded
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
expect do
|
|
235
|
+
bulk.call!([id], attributes_to_copy: ["not_a_real_column"])
|
|
236
|
+
end.to raise_error(Statesman::BulkTransition::ValidationError, /not_a_real_column/)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
it "raises InvalidTransitionError for a from/to pair the machine doesn't allow" do
|
|
240
|
+
expect do
|
|
241
|
+
described_class.new(
|
|
242
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
243
|
+
from: :succeeded, to: :succeeded
|
|
244
|
+
)
|
|
245
|
+
end.to raise_error(Statesman::InvalidTransitionError)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
it "runs the block inside the transaction and rolls back everything if it raises" do
|
|
249
|
+
id = create_model.id
|
|
250
|
+
bulk = described_class.new(
|
|
251
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
252
|
+
from: :initial, to: :succeeded
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
expect do
|
|
256
|
+
bulk.call!([id]) { |_rows| raise "boom" }
|
|
257
|
+
end.to raise_error("boom")
|
|
258
|
+
|
|
259
|
+
last = last_transition_for(id)
|
|
260
|
+
expect(last.to_state).to eq("initial")
|
|
261
|
+
expect(last.most_recent).to be(true)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
it "matches per-parent overrides by the string form of the id" do
|
|
265
|
+
id = create_model(extra_data: "from-previous-row").id
|
|
266
|
+
bulk = described_class.new(
|
|
267
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
268
|
+
from: :initial, to: :succeeded
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
bulk.call!(
|
|
272
|
+
[id],
|
|
273
|
+
metadata_per_id: { id.to_s => { "origin" => "api" } },
|
|
274
|
+
attributes_to_copy: ["extra_data"],
|
|
275
|
+
attributes_per_id: { id.to_s => { extra_data: "looked-up-value" } },
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
expect(last_transition_for(id).metadata).to eq({ "origin" => "api" })
|
|
279
|
+
expect(last_transition_for(id).extra_data).to eq("looked-up-value")
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
it "raises ValidationError for attributes_per_id columns not in attributes_to_copy" do
|
|
283
|
+
id = create_model.id
|
|
284
|
+
bulk = described_class.new(
|
|
285
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
286
|
+
from: :initial, to: :succeeded
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
expect do
|
|
290
|
+
bulk.call!([id], attributes_per_id: { id => { "extra_data" => "x" } })
|
|
291
|
+
end.to raise_error(Statesman::BulkTransition::ValidationError, /extra_data/)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
it "yields a relation over the inserted transitions to the block" do
|
|
295
|
+
id = create_model.id
|
|
296
|
+
bulk = described_class.new(
|
|
297
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
298
|
+
from: :initial, to: :succeeded
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
yielded = nil
|
|
302
|
+
bulk.call!([id]) { |transitions| yielded = transitions.pluck(:my_active_record_model_id, :to_state) }
|
|
303
|
+
|
|
304
|
+
expect(yielded).to eq([[id, "succeeded"]])
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
it "invokes a callback object's #call via normal method dispatch, not bare yield" do
|
|
308
|
+
id = create_model.id
|
|
309
|
+
bulk = described_class.new(
|
|
310
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
311
|
+
from: :initial, to: :succeeded
|
|
312
|
+
)
|
|
313
|
+
callback = ->(_rows) {}
|
|
314
|
+
|
|
315
|
+
expect(callback).to receive(:call).once.and_call_original
|
|
316
|
+
|
|
317
|
+
bulk.call!([id], &callback)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
it "returns an empty array and does nothing for an empty id list" do
|
|
321
|
+
bulk = described_class.new(
|
|
322
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
323
|
+
from: :initial, to: :succeeded
|
|
324
|
+
)
|
|
325
|
+
expect(bulk.call!([])).to eq([])
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
it "overrides metadata per-parent via metadata_per_id, falling back to the uniform metadata" do
|
|
329
|
+
overridden_id = create_model.id
|
|
330
|
+
default_id = create_model.id
|
|
331
|
+
bulk = described_class.new(
|
|
332
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
333
|
+
from: :initial, to: :succeeded
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
bulk.call!(
|
|
337
|
+
[overridden_id, default_id],
|
|
338
|
+
metadata: { "origin" => "gocardless" },
|
|
339
|
+
metadata_per_id: { overridden_id => { "origin" => "api" } },
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
expect(last_transition_for(overridden_id).metadata).to eq({ "origin" => "api" })
|
|
343
|
+
expect(last_transition_for(default_id).metadata).to eq({ "origin" => "gocardless" })
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
it "overrides a copied attribute per-parent via attributes_per_id" do
|
|
347
|
+
overridden_id = create_model(extra_data: "from-previous-row").id
|
|
348
|
+
default_id = create_model(extra_data: "from-previous-row").id
|
|
349
|
+
bulk = described_class.new(
|
|
350
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
351
|
+
from: :initial, to: :succeeded
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
bulk.call!(
|
|
355
|
+
[overridden_id, default_id],
|
|
356
|
+
attributes_to_copy: ["extra_data"],
|
|
357
|
+
attributes_per_id: { overridden_id => { "extra_data" => "looked-up-value" } },
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
expect(last_transition_for(overridden_id).extra_data).to eq("looked-up-value")
|
|
361
|
+
expect(last_transition_for(default_id).extra_data).to eq("from-previous-row")
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
it "yields only the new transitions, with their final most_recent state" do
|
|
365
|
+
id = create_model.id
|
|
366
|
+
bulk = described_class.new(
|
|
367
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
368
|
+
from: :initial, to: :succeeded
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
yielded = nil
|
|
372
|
+
bulk.call!([id]) { |transitions| yielded = transitions.pluck(:to_state, :most_recent) }
|
|
373
|
+
|
|
374
|
+
expect(yielded).to eq([["succeeded", true]])
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
describe "cached_current_state integration" do
|
|
379
|
+
it "bulk-updates the cached column for every transitioned parent" do
|
|
380
|
+
ids = Array.new(2) { create_model.id }
|
|
381
|
+
bulk = described_class.new(
|
|
382
|
+
model_class: MyActiveRecordModel, machine_class: machine_class,
|
|
383
|
+
from: :initial, to: :succeeded
|
|
384
|
+
)
|
|
385
|
+
bulk.call!(ids)
|
|
386
|
+
|
|
387
|
+
MyActiveRecordModel.where(id: ids).each do |model|
|
|
388
|
+
expect(model.cached_current_state).to eq("succeeded")
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "testcontainers/mysql"
|
|
4
|
+
require "testcontainers/postgres"
|
|
5
|
+
|
|
6
|
+
# Starts a throwaway database in Docker so the suite can run against PostgreSQL or
|
|
7
|
+
# MySQL locally without a pre-provisioned server.
|
|
8
|
+
module DatabaseContainer
|
|
9
|
+
DEFAULT_IMAGES = {
|
|
10
|
+
"postgres" => "postgres:17",
|
|
11
|
+
"mysql" => "mysql:8.4",
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def self.start(db, image: nil)
|
|
15
|
+
image ||= DEFAULT_IMAGES.fetch(db) do
|
|
16
|
+
raise ArgumentError, "Unsupported DB '#{db}', expected one of: " \
|
|
17
|
+
"#{DEFAULT_IMAGES.keys.join(', ')}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
container = build(db, image)
|
|
21
|
+
container.start
|
|
22
|
+
at_exit { container.remove(force: true) }
|
|
23
|
+
|
|
24
|
+
database_url(container)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.build(db, image)
|
|
28
|
+
case db
|
|
29
|
+
when "postgres" then Testcontainers::PostgresContainer.new(image)
|
|
30
|
+
when "mysql" then Testcontainers::MysqlContainer.new(image)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.database_url(container)
|
|
35
|
+
if container.is_a?(Testcontainers::MysqlContainer)
|
|
36
|
+
container.database_url(protocol: "mysql2")
|
|
37
|
+
else
|
|
38
|
+
container.database_url
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: statesman
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 13.
|
|
4
|
+
version: 13.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- GoCardless
|
|
@@ -25,6 +25,7 @@ files:
|
|
|
25
25
|
- ".rubocop.yml"
|
|
26
26
|
- ".rubocop_todo.yml"
|
|
27
27
|
- ".ruby-version"
|
|
28
|
+
- ".tool-versions"
|
|
28
29
|
- CHANGELOG.md
|
|
29
30
|
- CONTRIBUTING.md
|
|
30
31
|
- Gemfile
|
|
@@ -47,6 +48,7 @@ files:
|
|
|
47
48
|
- lib/statesman/adapters/memory.rb
|
|
48
49
|
- lib/statesman/adapters/memory_transition.rb
|
|
49
50
|
- lib/statesman/adapters/type_safe_active_record_queries.rb
|
|
51
|
+
- lib/statesman/bulk_transition.rb
|
|
50
52
|
- lib/statesman/callback.rb
|
|
51
53
|
- lib/statesman/config.rb
|
|
52
54
|
- lib/statesman/exceptions.rb
|
|
@@ -70,6 +72,7 @@ files:
|
|
|
70
72
|
- spec/statesman/adapters/memory_transition_spec.rb
|
|
71
73
|
- spec/statesman/adapters/shared_examples.rb
|
|
72
74
|
- spec/statesman/adapters/type_safe_active_record_queries_spec.rb
|
|
75
|
+
- spec/statesman/bulk_transition_spec.rb
|
|
73
76
|
- spec/statesman/callback_spec.rb
|
|
74
77
|
- spec/statesman/config_spec.rb
|
|
75
78
|
- spec/statesman/exceptions_spec.rb
|
|
@@ -77,6 +80,7 @@ files:
|
|
|
77
80
|
- spec/statesman/machine_spec.rb
|
|
78
81
|
- spec/statesman/utils_spec.rb
|
|
79
82
|
- spec/support/active_record.rb
|
|
83
|
+
- spec/support/database_container.rb
|
|
80
84
|
- spec/support/exactly_query_databases.rb
|
|
81
85
|
- spec/support/generators_shared_examples.rb
|
|
82
86
|
- statesman.gemspec
|