statecraft 0.6.0 → 0.7.1
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 +77 -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 +25 -3
- data/lib/statecraft/mounting.rb +26 -8
- data/lib/statecraft/pipeline/surface.rb +9 -9
- data/lib/statecraft/pipeline/versioning.rb +78 -0
- data/lib/statecraft/pipeline.rb +21 -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: b91fc7675cab350149a2e58679893b18ccdfa779cc84e2eb5a7d28c5591c265c
|
|
4
|
+
data.tar.gz: c751611f88af3a85738877185b68de734578a353818aa130c19206d40b498b82
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 42c744386532cf91f06374dc3695c4e2ee3e64602fbb428045e3b588c27dc32843959552dea944be5d98834f28036baf1d712da2c25008dba8b16766ca23310b
|
|
7
|
+
data.tar.gz: 86822e6c1fba14b00e6a8e18efc586a7f92f35280490a168e4d96d063a6d3ecd471eac6fe877c512b9972359d1d14f4d344521dc6f0fea56235075df2bae975b
|
data/README.md
CHANGED
|
@@ -210,6 +210,74 @@ 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
|
+
The field comes back from the browser, so it is hostile input: a token that
|
|
268
|
+
cannot be read at all — a tampered `seen=abc`, an array from `seen[]=1` —
|
|
269
|
+
is refused as `StaleTransition` too, with `expected_version: nil` because
|
|
270
|
+
nothing was compared. A broken or forged form never turns into a 500.
|
|
271
|
+
The honest limits: staleness
|
|
272
|
+
exists only where a token was given — without `seen:` a version mismatch
|
|
273
|
+
is the ordinary `TransitionConflict`; the early deterministic check runs
|
|
274
|
+
only under `lock: true`, where the reload holds the row's real version —
|
|
275
|
+
without the lock the CAS itself is the check, since a fresh token can
|
|
276
|
+
outrun a stale in-memory record; and `seen:` on a mounting without
|
|
277
|
+
`versioning:` raises a `CompilationError` naming the fix instead of
|
|
278
|
+
silently skipping the protection. Reads stay join-free: the version lives
|
|
279
|
+
on the parent row, right next to the state.
|
|
280
|
+
|
|
213
281
|
## Introspection
|
|
214
282
|
|
|
215
283
|
<!-- illustrative -->
|
|
@@ -449,6 +517,11 @@ statecraft's CAS on the state column.
|
|
|
449
517
|
Outside Rails, the same five steps work by hand — pair the runbook above
|
|
450
518
|
with the reference schema in [Outside Rails](#outside-rails).
|
|
451
519
|
|
|
520
|
+
Once the conversion has settled, one more `add_column` buys protection
|
|
521
|
+
statesman never had: see [Stale transitions](#stale-transitions-versioning-against-aba)
|
|
522
|
+
— the version column is a constant default, so adding it costs a
|
|
523
|
+
metadata-only migration on PostgreSQL 11+.
|
|
524
|
+
|
|
452
525
|
## PII and erasure
|
|
453
526
|
|
|
454
527
|
Metadata is the only place personal data can live — `from_state`, `to_state`
|
|
@@ -644,7 +717,7 @@ actions over services, guard refusals local to their form:
|
|
|
644
717
|
@order = Order.find(params[:id])
|
|
645
718
|
authorize! event_name, @order
|
|
646
719
|
@metadata = submitted_metadata
|
|
647
|
-
@order.fire!(event_name, metadata: @metadata)
|
|
720
|
+
@order.fire!(event_name, metadata: @metadata, seen: params[:seen].presence)
|
|
648
721
|
redirect_to admin_order_path(@order),
|
|
649
722
|
notice: "#{event_name} fired: the order is now #{@order[:state]}."
|
|
650
723
|
rescue Statecraft::GuardFailed => error
|
|
@@ -668,6 +741,7 @@ possibility-times-permission intersection:
|
|
|
668
741
|
<!-- readme: preview-pattern -->
|
|
669
742
|
```erb
|
|
670
743
|
<%= form_with url: preview_admin_order_path(@order), method: :post, local: true do %>
|
|
744
|
+
<input type="hidden" name="seen" value="<%= @order.state_version %>">
|
|
671
745
|
<fieldset>
|
|
672
746
|
<legend>Metadata for the next action</legend>
|
|
673
747
|
<label>
|
|
@@ -705,13 +779,12 @@ ActiveSupport::Notifications.subscribe("transition.statecraft") do |_name, _star
|
|
|
705
779
|
)
|
|
706
780
|
end
|
|
707
781
|
|
|
782
|
+
# The failure payload names the record, the machine and the reason — it
|
|
783
|
+
# carries no from/to/event keys: the transition never happened.
|
|
708
784
|
ActiveSupport::Notifications.subscribe("transition_failed.statecraft") do |_name, _started, _finished, _id, payload|
|
|
709
785
|
OperationEntry.create!(
|
|
710
786
|
record_class: payload[:record_class],
|
|
711
787
|
record_id: payload[:record_id].to_s,
|
|
712
|
-
from_state: payload[:from],
|
|
713
|
-
to_state: payload[:to],
|
|
714
|
-
event_name: payload[:event],
|
|
715
788
|
outcome: "refused",
|
|
716
789
|
reason: payload[:reason].to_s
|
|
717
790
|
)
|
|
@@ -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,33 @@ 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 not
|
|
50
|
+
# the row's version — or could not be read at all, which is the same
|
|
51
|
+
# answer with nothing to compare (expected_version stays nil). A subclass
|
|
52
|
+
# of TransitionConflict, so existing rescues keep catching it;
|
|
53
|
+
# controllers map it to 409 specifically.
|
|
54
|
+
class StaleTransition < TransitionConflict
|
|
55
|
+
attr_reader :expected_version, :seen
|
|
56
|
+
|
|
57
|
+
def initialize(record:, expected_from:, expected_version:, seen:)
|
|
58
|
+
@expected_version = expected_version
|
|
59
|
+
@seen = seen
|
|
60
|
+
reason =
|
|
61
|
+
if expected_version.nil?
|
|
62
|
+
"the token #{seen.inspect} is not a readable version"
|
|
63
|
+
else
|
|
64
|
+
"the caller saw version #{seen.inspect}, but the row has moved on"
|
|
65
|
+
end
|
|
66
|
+
super(record: record, expected_from: expected_from,
|
|
67
|
+
message: "stale transition for #{record.class.name}##{record.id}: #{reason}")
|
|
46
68
|
end
|
|
47
69
|
end
|
|
48
70
|
|
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,78 @@
|
|
|
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
|
+
# The token arrives from a form field, so it is hostile input: a
|
|
26
|
+
# tampered or broken form must not escape the gem's own hierarchy.
|
|
27
|
+
# An unreadable token IS an invalid snapshot — the same refusal a
|
|
28
|
+
# stale one gets, with no version to compare against.
|
|
29
|
+
def normalize_seen(raw_seen)
|
|
30
|
+
return nil if raw_seen.nil?
|
|
31
|
+
|
|
32
|
+
unless configuration.version_column
|
|
33
|
+
raise CompilationError,
|
|
34
|
+
"seen: requires versioning: true on the mounting of #{record.class.name}; " \
|
|
35
|
+
"add the option or drop the token"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
Integer(raw_seen)
|
|
40
|
+
rescue ArgumentError, TypeError
|
|
41
|
+
raise StaleTransition.new(record: record, expected_from: current_state,
|
|
42
|
+
expected_version: nil, seen: raw_seen)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# What the CAS requires the row's version to be: the caller's seen:
|
|
47
|
+
# token when given, the in-memory value otherwise — fresh after a lock
|
|
48
|
+
# reload, synced by the previous link inside a chain.
|
|
49
|
+
def expected_version
|
|
50
|
+
@seen || record[version_column]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# With a seen: token any versioned refusal is staleness — the caller
|
|
54
|
+
# acted on a snapshot; without one it is the ordinary conflict of two
|
|
55
|
+
# writers.
|
|
56
|
+
def cas_refusal(edge)
|
|
57
|
+
if @seen
|
|
58
|
+
StaleTransition.new(record: record, expected_from: edge.from,
|
|
59
|
+
expected_version: expected_version, seen: @raw_seen)
|
|
60
|
+
else
|
|
61
|
+
TransitionConflict.new(record: record, expected_from: edge.from)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The same early observation as assert_lock_saw_expected_state, for the
|
|
66
|
+
# seen: token: the reload holds the row's real version under the lock,
|
|
67
|
+
# so a mismatched token is a stale snapshot detected deterministically,
|
|
68
|
+
# before any write.
|
|
69
|
+
def assert_lock_saw_expected_version(edge)
|
|
70
|
+
return unless @seen && version_column
|
|
71
|
+
return if record[version_column] == @seen
|
|
72
|
+
|
|
73
|
+
raise StaleTransition.new(record: record, expected_from: edge.from,
|
|
74
|
+
expected_version: @seen, seen: @raw_seen)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
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,13 +60,17 @@ 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
|
|
66
68
|
metadata = Metadata.normalize(raw_metadata)
|
|
67
69
|
started_at = Time.current
|
|
68
70
|
begin
|
|
71
|
+
# Inside the rescue: an unreadable token is a refusal like any
|
|
72
|
+
# other and belongs in the failure telemetry.
|
|
73
|
+
@seen = normalize_seen(seen)
|
|
69
74
|
edge, event, bypass = edge_resolver.call(current_state)
|
|
70
75
|
assert_clean_when_locked(edge)
|
|
71
76
|
frame = open_frame(edge, event)
|
|
@@ -91,6 +96,7 @@ module Statecraft
|
|
|
91
96
|
case error
|
|
92
97
|
when GuardFailed then [:guard_failed, { guard: error.guard }]
|
|
93
98
|
when InvalidTransition then [:invalid_transition, { requested: error.requested }]
|
|
99
|
+
when StaleTransition then [:stale, { expected_version: error.expected_version, seen: error.seen }]
|
|
94
100
|
when TransitionConflict then [:conflict, { expected_from: error.expected_from }]
|
|
95
101
|
end
|
|
96
102
|
Instrumentation.publish_failure(
|
|
@@ -105,6 +111,7 @@ module Statecraft
|
|
|
105
111
|
warn_when_row_locking_unavailable
|
|
106
112
|
record.reload(lock: true)
|
|
107
113
|
assert_lock_saw_expected_state(edge)
|
|
114
|
+
assert_lock_saw_expected_version(edge)
|
|
108
115
|
end
|
|
109
116
|
transition_time = Time.current
|
|
110
117
|
run_guards(edge, event, bypass, metadata)
|
|
@@ -204,17 +211,21 @@ module Statecraft
|
|
|
204
211
|
end
|
|
205
212
|
|
|
206
213
|
def cas_update!(edge, transition_time)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
214
|
+
scope = base_class.unscoped
|
|
215
|
+
.where(base_class.primary_key => record.id,
|
|
216
|
+
configuration.column => edge.from.to_s)
|
|
217
|
+
scope = scope.where(version_column => expected_version) if version_column
|
|
218
|
+
affected_rows = scope.update_all(cas_updates(edge, transition_time))
|
|
219
|
+
raise cas_refusal(edge) if affected_rows.zero?
|
|
212
220
|
end
|
|
213
221
|
|
|
214
222
|
def cas_updates(edge, transition_time)
|
|
215
223
|
updates = { configuration.column => edge.to.to_s }
|
|
216
224
|
updates[:updated_at] = transition_time if touch_updated_at?
|
|
217
225
|
updates[changed_at_column] = transition_time if changed_at_column
|
|
226
|
+
# The literal is correct by construction: the CAS WHERE guarantees the
|
|
227
|
+
# row held exactly expected_version, so the memory mirror stays exact.
|
|
228
|
+
updates[version_column] = expected_version + 1 if version_column
|
|
218
229
|
updates
|
|
219
230
|
end
|
|
220
231
|
|
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.1
|
|
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
|