statecraft 0.6.0 → 0.7.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/README.md +68 -4
- data/lib/generators/statecraft/machine/USAGE +5 -0
- data/lib/generators/statecraft/machine/machine_generator.rb +15 -1
- data/lib/generators/statecraft/machine/templates/add_migration.rb.tt +3 -0
- data/lib/generators/statecraft/machine/templates/create_migration.rb.tt +3 -0
- data/lib/generators/statecraft/machine/templates/model.rb.tt +1 -1
- data/lib/statecraft/errors.rb +18 -3
- data/lib/statecraft/mounting.rb +26 -8
- data/lib/statecraft/pipeline/surface.rb +9 -9
- data/lib/statecraft/pipeline/versioning.rb +68 -0
- data/lib/statecraft/pipeline.rb +19 -10
- data/lib/statecraft/version.rb +1 -1
- data/lib/statecraft.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5e98ec25e4cfbb6cec172cd1f54ac9362937c967eff450ccda67d414e2dce6d3
|
|
4
|
+
data.tar.gz: e8f112727ef007ad0d6d3e91e67c122298bbf1d403f09aa9e2aeda56e43545da
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ed3978c9e0098cfc1ed4a19b933ce2a134e8841385e0e9542c267e43b6095f81c2b97b230a40d04605d0618717b3af289716157c3ab029f85b30886c54a11cb5
|
|
7
|
+
data.tar.gz: 626c429ccc91fc9f40401e58ab6142b81544684d57890fb0f51cec79d901e6280d06e2a14afb0400bb6d592faf33e5dbab5ef7c09045e4435b8557dd2162fb73
|
data/README.md
CHANGED
|
@@ -210,6 +210,70 @@ through untouched: those errors mean "restart the whole transaction", a
|
|
|
210
210
|
different protocol than the conflict's "continue from clean state", and the
|
|
211
211
|
retry policy belongs to whoever chose the isolation level.
|
|
212
212
|
|
|
213
|
+
## Stale transitions: versioning against ABA
|
|
214
|
+
|
|
215
|
+
The CAS above compares the state's *value* — so a state that went away and
|
|
216
|
+
came back (`pending → paid → … → pending`) passes for the state an open
|
|
217
|
+
page rendered minutes ago. That is the ABA problem, and `versioning: true`
|
|
218
|
+
closes it:
|
|
219
|
+
|
|
220
|
+
<!-- illustrative -->
|
|
221
|
+
```ruby
|
|
222
|
+
class Order < ApplicationRecord
|
|
223
|
+
state_machine OrderFlow, versioning: true # the state_version column
|
|
224
|
+
end
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
With the option every transition compares-and-swaps on the *pair* of state
|
|
228
|
+
and version and increments the version in the same UPDATE — a returned
|
|
229
|
+
state no longer matches, even without any token. `true` names the column
|
|
230
|
+
`<column>_version`; a symbol overrides the name. The column is a plain
|
|
231
|
+
`bigint NOT NULL DEFAULT 0`: `rails g statecraft:machine Order --versioning`
|
|
232
|
+
generates it, and for an existing table one step is enough — on
|
|
233
|
+
PostgreSQL 11+ an `add_column` with a constant default is metadata-only,
|
|
234
|
+
no table rewrite:
|
|
235
|
+
|
|
236
|
+
<!-- illustrative -->
|
|
237
|
+
```ruby
|
|
238
|
+
add_column :orders, :state_version, :bigint, null: false, default: 0
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
The user-facing half is the `seen:` token: render the version into the
|
|
242
|
+
form, send it back, and the pipeline refuses the action when the row has
|
|
243
|
+
moved on — `Statecraft::StaleTransition`, a `TransitionConflict` subclass
|
|
244
|
+
carrying `expected_version` and `seen`, published as reason `:stale`:
|
|
245
|
+
|
|
246
|
+
<!-- illustrative -->
|
|
247
|
+
```erb
|
|
248
|
+
<input type="hidden" name="seen" value="<%= order.state_version %>">
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
<!-- illustrative -->
|
|
252
|
+
```ruby
|
|
253
|
+
def cancel
|
|
254
|
+
order = Order.find(params[:id])
|
|
255
|
+
order.cancel!(metadata: cancel_metadata, seen: params[:seen])
|
|
256
|
+
redirect_to order_path(order), notice: "Cancelled."
|
|
257
|
+
rescue Statecraft::StaleTransition
|
|
258
|
+
head :conflict # the API mapping: 409
|
|
259
|
+
# a form flow redirects instead:
|
|
260
|
+
# redirect_to order_path(order),
|
|
261
|
+
# alert: "This order changed while the page was open."
|
|
262
|
+
end
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
`seen:` rides all four surface forms and the helper verbs. A string token
|
|
266
|
+
straight from params is fine — the pipeline normalizes it with `Integer()`,
|
|
267
|
+
and garbage raises `ArgumentError` loudly. The honest limits: staleness
|
|
268
|
+
exists only where a token was given — without `seen:` a version mismatch
|
|
269
|
+
is the ordinary `TransitionConflict`; the early deterministic check runs
|
|
270
|
+
only under `lock: true`, where the reload holds the row's real version —
|
|
271
|
+
without the lock the CAS itself is the check, since a fresh token can
|
|
272
|
+
outrun a stale in-memory record; and `seen:` on a mounting without
|
|
273
|
+
`versioning:` raises a `CompilationError` naming the fix instead of
|
|
274
|
+
silently skipping the protection. Reads stay join-free: the version lives
|
|
275
|
+
on the parent row, right next to the state.
|
|
276
|
+
|
|
213
277
|
## Introspection
|
|
214
278
|
|
|
215
279
|
<!-- illustrative -->
|
|
@@ -644,7 +708,7 @@ actions over services, guard refusals local to their form:
|
|
|
644
708
|
@order = Order.find(params[:id])
|
|
645
709
|
authorize! event_name, @order
|
|
646
710
|
@metadata = submitted_metadata
|
|
647
|
-
@order.fire!(event_name, metadata: @metadata)
|
|
711
|
+
@order.fire!(event_name, metadata: @metadata, seen: params[:seen].presence)
|
|
648
712
|
redirect_to admin_order_path(@order),
|
|
649
713
|
notice: "#{event_name} fired: the order is now #{@order[:state]}."
|
|
650
714
|
rescue Statecraft::GuardFailed => error
|
|
@@ -668,6 +732,7 @@ possibility-times-permission intersection:
|
|
|
668
732
|
<!-- readme: preview-pattern -->
|
|
669
733
|
```erb
|
|
670
734
|
<%= form_with url: preview_admin_order_path(@order), method: :post, local: true do %>
|
|
735
|
+
<input type="hidden" name="seen" value="<%= @order[:state_version] %>">
|
|
671
736
|
<fieldset>
|
|
672
737
|
<legend>Metadata for the next action</legend>
|
|
673
738
|
<label>
|
|
@@ -705,13 +770,12 @@ ActiveSupport::Notifications.subscribe("transition.statecraft") do |_name, _star
|
|
|
705
770
|
)
|
|
706
771
|
end
|
|
707
772
|
|
|
773
|
+
# The failure payload names the record, the machine and the reason — it
|
|
774
|
+
# carries no from/to/event keys: the transition never happened.
|
|
708
775
|
ActiveSupport::Notifications.subscribe("transition_failed.statecraft") do |_name, _started, _finished, _id, payload|
|
|
709
776
|
OperationEntry.create!(
|
|
710
777
|
record_class: payload[:record_class],
|
|
711
778
|
record_id: payload[:record_id].to_s,
|
|
712
|
-
from_state: payload[:from],
|
|
713
|
-
to_state: payload[:to],
|
|
714
|
-
event_name: payload[:event],
|
|
715
779
|
outcome: "refused",
|
|
716
780
|
reason: payload[:reason].to_s
|
|
717
781
|
)
|
|
@@ -13,8 +13,13 @@ Description:
|
|
|
13
13
|
migration lands in the migrations_paths configured for that database
|
|
14
14
|
(db/migrate when none is configured).
|
|
15
15
|
|
|
16
|
+
With --versioning the migration also adds a state_version column and
|
|
17
|
+
the mounting carries versioning: true — a tagged CAS that refuses
|
|
18
|
+
stale seen: tokens with Statecraft::StaleTransition.
|
|
19
|
+
|
|
16
20
|
Example:
|
|
17
21
|
bin/rails generate statecraft:machine Order
|
|
22
|
+
bin/rails generate statecraft:machine Order --versioning
|
|
18
23
|
bin/rails generate statecraft:machine Shop::Order
|
|
19
24
|
|
|
20
25
|
This will create:
|
|
@@ -18,6 +18,10 @@ module Statecraft
|
|
|
18
18
|
|
|
19
19
|
source_root File.expand_path("templates", __dir__)
|
|
20
20
|
|
|
21
|
+
class_option :versioning, type: :boolean, default: false,
|
|
22
|
+
desc: "Add a state_version column and mount with versioning: " \
|
|
23
|
+
"true — a tagged CAS that refuses stale seen: tokens"
|
|
24
|
+
|
|
21
25
|
def detect_model_presence
|
|
22
26
|
@existing_model = File.exist?(File.join(destination_root, model_file))
|
|
23
27
|
end
|
|
@@ -71,7 +75,17 @@ module Statecraft
|
|
|
71
75
|
end
|
|
72
76
|
|
|
73
77
|
def mounting_line
|
|
74
|
-
" state_machine #{class_name}Flow,
|
|
78
|
+
" state_machine #{class_name}Flow, #{mounting_options}\n"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def mounting_options
|
|
82
|
+
options_list = ["changed_at: true"]
|
|
83
|
+
options_list << "versioning: true" if options[:versioning]
|
|
84
|
+
(options_list + ["helpers: true", "scopes: true"]).join(", ")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def versioning?
|
|
88
|
+
options[:versioning]
|
|
75
89
|
end
|
|
76
90
|
|
|
77
91
|
# inject_into_class matches the literal `class <name>` line, so the name
|
|
@@ -5,6 +5,9 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
|
|
|
5
5
|
add_column :<%= table_name %>, :state, :string, null: false, default: "pending"
|
|
6
6
|
add_index :<%= table_name %>, :state
|
|
7
7
|
add_column :<%= table_name %>, :state_changed_at, :datetime
|
|
8
|
+
<% if versioning? -%>
|
|
9
|
+
add_column :<%= table_name %>, :state_version, :bigint, null: false, default: 0
|
|
10
|
+
<% end -%>
|
|
8
11
|
|
|
9
12
|
# The existing table may carry legacy values, so no CHECK constraint is
|
|
10
13
|
# generated here. To add one later without blocking writes:
|
|
@@ -5,6 +5,9 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
|
|
|
5
5
|
create_table :<%= table_name %> do |t|
|
|
6
6
|
t.string :state, null: false, default: "pending", index: true
|
|
7
7
|
t.datetime :state_changed_at
|
|
8
|
+
<% if versioning? -%>
|
|
9
|
+
t.bigint :state_version, null: false, default: 0
|
|
10
|
+
<% end -%>
|
|
8
11
|
t.timestamps null: false
|
|
9
12
|
end
|
|
10
13
|
|
data/lib/statecraft/errors.rb
CHANGED
|
@@ -38,11 +38,26 @@ module Statecraft
|
|
|
38
38
|
class TransitionConflict < Error
|
|
39
39
|
attr_reader :record, :expected_from
|
|
40
40
|
|
|
41
|
-
def initialize(record:, expected_from:)
|
|
41
|
+
def initialize(record:, expected_from:, message: nil)
|
|
42
42
|
@record = record
|
|
43
43
|
@expected_from = expected_from
|
|
44
|
-
super("concurrent update detected for #{record.class.name}##{record.id}: " \
|
|
45
|
-
|
|
44
|
+
super(message || "concurrent update detected for #{record.class.name}##{record.id}: " \
|
|
45
|
+
"expected state #{expected_from.inspect}, another writer got there first")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The refusal of a seen: token: the snapshot the caller acted on is no
|
|
50
|
+
# longer the row's version. A subclass of TransitionConflict, so existing
|
|
51
|
+
# rescues keep catching it; controllers map it to 409 specifically.
|
|
52
|
+
class StaleTransition < TransitionConflict
|
|
53
|
+
attr_reader :expected_version, :seen
|
|
54
|
+
|
|
55
|
+
def initialize(record:, expected_from:, expected_version:, seen:)
|
|
56
|
+
@expected_version = expected_version
|
|
57
|
+
@seen = seen
|
|
58
|
+
super(record: record, expected_from: expected_from,
|
|
59
|
+
message: "stale transition for #{record.class.name}##{record.id}: " \
|
|
60
|
+
"the caller saw version #{seen.inspect}, but the row has moved on")
|
|
46
61
|
end
|
|
47
62
|
end
|
|
48
63
|
|
data/lib/statecraft/mounting.rb
CHANGED
|
@@ -10,16 +10,17 @@ module Statecraft
|
|
|
10
10
|
# in_state?). Everything internal goes through base_class.
|
|
11
11
|
module Mounting
|
|
12
12
|
Configuration = Struct.new(
|
|
13
|
-
:machine_class, :log_class, :column, :changed_at_column, :
|
|
14
|
-
:log_foreign_key,
|
|
13
|
+
:machine_class, :log_class, :column, :changed_at_column, :version_column, :touch,
|
|
14
|
+
:helpers, :scopes, :log_foreign_key,
|
|
15
15
|
keyword_init: true
|
|
16
16
|
)
|
|
17
17
|
|
|
18
|
-
def state_machine(machine_class, log: nil, column: :state, changed_at: false,
|
|
19
|
-
helpers: false, scopes: false)
|
|
18
|
+
def state_machine(machine_class, log: nil, column: :state, changed_at: false,
|
|
19
|
+
versioning: false, touch: true, helpers: false, scopes: false)
|
|
20
20
|
Builder.new(
|
|
21
21
|
model: self, machine_class: machine_class, log: log, column: column,
|
|
22
|
-
changed_at: changed_at,
|
|
22
|
+
changed_at: changed_at, versioning: versioning, touch: touch,
|
|
23
|
+
helpers: helpers, scopes: scopes
|
|
23
24
|
).mount
|
|
24
25
|
end
|
|
25
26
|
|
|
@@ -27,16 +28,20 @@ module Statecraft
|
|
|
27
28
|
# class hierarchies only — never on the schema and never on a live
|
|
28
29
|
# connection, so mounting is safe at load time.
|
|
29
30
|
class Builder
|
|
30
|
-
|
|
31
|
+
# rubocop:disable Metrics/ParameterLists -- mirrors state_machine's kwargs one-to-one
|
|
32
|
+
def initialize(model:, machine_class:, log:, column:, changed_at:, versioning:, touch:,
|
|
33
|
+
helpers:, scopes:)
|
|
31
34
|
@model = model
|
|
32
35
|
@machine_class = machine_class
|
|
33
36
|
@log_option = log
|
|
34
37
|
@column = column
|
|
35
38
|
@changed_at = changed_at
|
|
39
|
+
@versioning = versioning
|
|
36
40
|
@touch = touch
|
|
37
41
|
@helpers = helpers
|
|
38
42
|
@scopes = scopes
|
|
39
43
|
end
|
|
44
|
+
# rubocop:enable Metrics/ParameterLists
|
|
40
45
|
|
|
41
46
|
def mount
|
|
42
47
|
assert_not_mounted
|
|
@@ -132,6 +137,7 @@ module Statecraft
|
|
|
132
137
|
log_class: log_class,
|
|
133
138
|
column: @column.to_sym,
|
|
134
139
|
changed_at_column: resolve_changed_at_column,
|
|
140
|
+
version_column: resolve_version_column,
|
|
135
141
|
touch: @touch,
|
|
136
142
|
helpers: @helpers,
|
|
137
143
|
scopes: @scopes,
|
|
@@ -147,6 +153,14 @@ module Statecraft
|
|
|
147
153
|
end
|
|
148
154
|
end
|
|
149
155
|
|
|
156
|
+
def resolve_version_column
|
|
157
|
+
case @versioning
|
|
158
|
+
when false then nil
|
|
159
|
+
when true then :"#{@column}_version"
|
|
160
|
+
else @versioning.to_sym
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
150
164
|
def store_configuration(configuration)
|
|
151
165
|
model.define_singleton_method(:statecraft_mounting) { configuration }
|
|
152
166
|
end
|
|
@@ -163,8 +177,12 @@ module Statecraft
|
|
|
163
177
|
def define_verbs(graph)
|
|
164
178
|
verbs = Module.new do
|
|
165
179
|
graph.events.each_key do |event_name|
|
|
166
|
-
define_method("#{event_name}!")
|
|
167
|
-
|
|
180
|
+
define_method("#{event_name}!") do |metadata: {}, seen: nil|
|
|
181
|
+
fire!(event_name, metadata: metadata, seen: seen)
|
|
182
|
+
end
|
|
183
|
+
define_method(event_name) do |metadata: {}, seen: nil|
|
|
184
|
+
fire(event_name, metadata: metadata, seen: seen)
|
|
185
|
+
end
|
|
168
186
|
define_method("may_#{event_name}?") { |metadata: {}| can_fire?(event_name, metadata: metadata) }
|
|
169
187
|
end
|
|
170
188
|
end
|
|
@@ -5,24 +5,24 @@ module Statecraft
|
|
|
5
5
|
# The record-facing API mixed into the model at mounting time. Bang
|
|
6
6
|
# variants return the created log record; non-bang variants return it too,
|
|
7
7
|
# or false on GuardFailed / InvalidTransition. Programmer errors and
|
|
8
|
-
# TransitionConflict always raise.
|
|
8
|
+
# TransitionConflict — StaleTransition included — always raise.
|
|
9
9
|
module Surface
|
|
10
|
-
def transition_to!(to_state, metadata: {}, bypass_events: false)
|
|
11
|
-
Pipeline.new(self).direct(to_state, metadata: metadata, bypass_events: bypass_events)
|
|
10
|
+
def transition_to!(to_state, metadata: {}, bypass_events: false, seen: nil)
|
|
11
|
+
Pipeline.new(self).direct(to_state, metadata: metadata, bypass_events: bypass_events, seen: seen)
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
def transition_to(to_state, metadata: {}, bypass_events: false)
|
|
15
|
-
transition_to!(to_state, metadata: metadata, bypass_events: bypass_events)
|
|
14
|
+
def transition_to(to_state, metadata: {}, bypass_events: false, seen: nil)
|
|
15
|
+
transition_to!(to_state, metadata: metadata, bypass_events: bypass_events, seen: seen)
|
|
16
16
|
rescue GuardFailed, InvalidTransition
|
|
17
17
|
false
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def fire!(event_name, metadata: {})
|
|
21
|
-
Pipeline.new(self).fire(event_name, metadata: metadata)
|
|
20
|
+
def fire!(event_name, metadata: {}, seen: nil)
|
|
21
|
+
Pipeline.new(self).fire(event_name, metadata: metadata, seen: seen)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def fire(event_name, metadata: {})
|
|
25
|
-
fire!(event_name, metadata: metadata)
|
|
24
|
+
def fire(event_name, metadata: {}, seen: nil)
|
|
25
|
+
fire!(event_name, metadata: metadata, seen: seen)
|
|
26
26
|
rescue GuardFailed, InvalidTransition
|
|
27
27
|
false
|
|
28
28
|
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
class Pipeline
|
|
5
|
+
# The tagged half of the CAS: when the mounting carries versioning, every
|
|
6
|
+
# transition compares-and-swaps on (state, version) and increments the
|
|
7
|
+
# version in the same UPDATE. A seen: token substitutes the compared
|
|
8
|
+
# value with what the caller's form actually rendered, and its refusal is
|
|
9
|
+
# StaleTransition — the 409 of the pipeline.
|
|
10
|
+
module Versioning
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def version_column
|
|
14
|
+
column = configuration.version_column
|
|
15
|
+
return nil unless column
|
|
16
|
+
|
|
17
|
+
unless record.class.column_names.include?(column.to_s)
|
|
18
|
+
raise CompilationError,
|
|
19
|
+
"versioning column #{column} does not exist on #{record.class.table_name}; " \
|
|
20
|
+
"add the column or drop the versioning option"
|
|
21
|
+
end
|
|
22
|
+
column
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def normalize_seen(raw_seen)
|
|
26
|
+
return nil if raw_seen.nil?
|
|
27
|
+
|
|
28
|
+
unless configuration.version_column
|
|
29
|
+
raise CompilationError,
|
|
30
|
+
"seen: requires versioning: true on the mounting of #{record.class.name}; " \
|
|
31
|
+
"add the option or drop the token"
|
|
32
|
+
end
|
|
33
|
+
Integer(raw_seen)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# What the CAS requires the row's version to be: the caller's seen:
|
|
37
|
+
# token when given, the in-memory value otherwise — fresh after a lock
|
|
38
|
+
# reload, synced by the previous link inside a chain.
|
|
39
|
+
def expected_version
|
|
40
|
+
@seen || record[version_column]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# With a seen: token any versioned refusal is staleness — the caller
|
|
44
|
+
# acted on a snapshot; without one it is the ordinary conflict of two
|
|
45
|
+
# writers.
|
|
46
|
+
def cas_refusal(edge)
|
|
47
|
+
if @seen
|
|
48
|
+
StaleTransition.new(record: record, expected_from: edge.from,
|
|
49
|
+
expected_version: expected_version, seen: @raw_seen)
|
|
50
|
+
else
|
|
51
|
+
TransitionConflict.new(record: record, expected_from: edge.from)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The same early observation as assert_lock_saw_expected_state, for the
|
|
56
|
+
# seen: token: the reload holds the row's real version under the lock,
|
|
57
|
+
# so a mismatched token is a stale snapshot detected deterministically,
|
|
58
|
+
# before any write.
|
|
59
|
+
def assert_lock_saw_expected_version(edge)
|
|
60
|
+
return unless @seen && version_column
|
|
61
|
+
return if record[version_column] == @seen
|
|
62
|
+
|
|
63
|
+
raise StaleTransition.new(record: record, expected_from: edge.from,
|
|
64
|
+
expected_version: @seen, seen: @raw_seen)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/statecraft/pipeline.rb
CHANGED
|
@@ -29,6 +29,7 @@ module Statecraft
|
|
|
29
29
|
MAX_CHAIN_DEPTH = 16
|
|
30
30
|
|
|
31
31
|
include EdgeResolution
|
|
32
|
+
include Versioning
|
|
32
33
|
|
|
33
34
|
def self.transition_stack
|
|
34
35
|
ActiveSupport::IsolatedExecutionState[STACK_KEY] ||= []
|
|
@@ -41,15 +42,15 @@ module Statecraft
|
|
|
41
42
|
@machine_instance = @configuration.machine_class.new
|
|
42
43
|
end
|
|
43
44
|
|
|
44
|
-
def direct(to_state, metadata:, bypass_events:)
|
|
45
|
-
run(metadata) do |current_state|
|
|
45
|
+
def direct(to_state, metadata:, bypass_events:, seen: nil)
|
|
46
|
+
run(metadata, seen: seen) do |current_state|
|
|
46
47
|
edge = resolve_direct_edge(current_state, to_state.to_sym, bypass_events)
|
|
47
48
|
[edge, nil, bypass_events]
|
|
48
49
|
end
|
|
49
50
|
end
|
|
50
51
|
|
|
51
|
-
def fire(event_name, metadata:)
|
|
52
|
-
run(metadata) do |current_state|
|
|
52
|
+
def fire(event_name, metadata:, seen: nil)
|
|
53
|
+
run(metadata, seen: seen) do |current_state|
|
|
53
54
|
edge = resolve_event_edge(current_state, event_name.to_sym)
|
|
54
55
|
[edge, event_name.to_sym, false]
|
|
55
56
|
end
|
|
@@ -59,10 +60,12 @@ module Statecraft
|
|
|
59
60
|
|
|
60
61
|
attr_reader :record, :configuration, :graph, :machine_instance
|
|
61
62
|
|
|
62
|
-
def run(raw_metadata, &edge_resolver)
|
|
63
|
+
def run(raw_metadata, seen: nil, &edge_resolver)
|
|
63
64
|
raise UnsavedRecordError.new(record: record) unless record.persisted?
|
|
64
65
|
|
|
65
66
|
assert_single_column_primary_key
|
|
67
|
+
@raw_seen = seen
|
|
68
|
+
@seen = normalize_seen(seen)
|
|
66
69
|
metadata = Metadata.normalize(raw_metadata)
|
|
67
70
|
started_at = Time.current
|
|
68
71
|
begin
|
|
@@ -91,6 +94,7 @@ module Statecraft
|
|
|
91
94
|
case error
|
|
92
95
|
when GuardFailed then [:guard_failed, { guard: error.guard }]
|
|
93
96
|
when InvalidTransition then [:invalid_transition, { requested: error.requested }]
|
|
97
|
+
when StaleTransition then [:stale, { expected_version: error.expected_version, seen: error.seen }]
|
|
94
98
|
when TransitionConflict then [:conflict, { expected_from: error.expected_from }]
|
|
95
99
|
end
|
|
96
100
|
Instrumentation.publish_failure(
|
|
@@ -105,6 +109,7 @@ module Statecraft
|
|
|
105
109
|
warn_when_row_locking_unavailable
|
|
106
110
|
record.reload(lock: true)
|
|
107
111
|
assert_lock_saw_expected_state(edge)
|
|
112
|
+
assert_lock_saw_expected_version(edge)
|
|
108
113
|
end
|
|
109
114
|
transition_time = Time.current
|
|
110
115
|
run_guards(edge, event, bypass, metadata)
|
|
@@ -204,17 +209,21 @@ module Statecraft
|
|
|
204
209
|
end
|
|
205
210
|
|
|
206
211
|
def cas_update!(edge, transition_time)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
+
scope = base_class.unscoped
|
|
213
|
+
.where(base_class.primary_key => record.id,
|
|
214
|
+
configuration.column => edge.from.to_s)
|
|
215
|
+
scope = scope.where(version_column => expected_version) if version_column
|
|
216
|
+
affected_rows = scope.update_all(cas_updates(edge, transition_time))
|
|
217
|
+
raise cas_refusal(edge) if affected_rows.zero?
|
|
212
218
|
end
|
|
213
219
|
|
|
214
220
|
def cas_updates(edge, transition_time)
|
|
215
221
|
updates = { configuration.column => edge.to.to_s }
|
|
216
222
|
updates[:updated_at] = transition_time if touch_updated_at?
|
|
217
223
|
updates[changed_at_column] = transition_time if changed_at_column
|
|
224
|
+
# The literal is correct by construction: the CAS WHERE guarantees the
|
|
225
|
+
# row held exactly expected_version, so the memory mirror stays exact.
|
|
226
|
+
updates[version_column] = expected_version + 1 if version_column
|
|
218
227
|
updates
|
|
219
228
|
end
|
|
220
229
|
|
data/lib/statecraft/version.rb
CHANGED
data/lib/statecraft.rb
CHANGED
|
@@ -10,6 +10,7 @@ require_relative "statecraft/instrumentation"
|
|
|
10
10
|
require_relative "statecraft/machine"
|
|
11
11
|
require_relative "statecraft/metadata"
|
|
12
12
|
require_relative "statecraft/pipeline/edge_resolution"
|
|
13
|
+
require_relative "statecraft/pipeline/versioning"
|
|
13
14
|
require_relative "statecraft/pipeline"
|
|
14
15
|
require_relative "statecraft/pipeline/surface"
|
|
15
16
|
require_relative "statecraft/introspection"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: statecraft
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Igor Pugachev
|
|
@@ -87,6 +87,7 @@ files:
|
|
|
87
87
|
- lib/statecraft/pipeline.rb
|
|
88
88
|
- lib/statecraft/pipeline/edge_resolution.rb
|
|
89
89
|
- lib/statecraft/pipeline/surface.rb
|
|
90
|
+
- lib/statecraft/pipeline/versioning.rb
|
|
90
91
|
- lib/statecraft/rspec.rb
|
|
91
92
|
- lib/statecraft/rspec/allow_event.rb
|
|
92
93
|
- lib/statecraft/rspec/allow_transition_to.rb
|