statecraft 0.1.0 → 0.1.2
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 +34 -2
- data/lib/generators/statecraft/machine/machine_generator.rb +52 -25
- data/lib/generators/statecraft/machine/templates/add_migration.rb.tt +35 -0
- data/lib/generators/statecraft/machine/templates/application_machine.rb.tt +8 -0
- data/lib/generators/statecraft/machine/templates/create_migration.rb.tt +35 -0
- data/lib/generators/statecraft/machine/templates/log_model.rb.tt +9 -0
- data/lib/generators/statecraft/machine/templates/machine.rb.tt +16 -0
- data/lib/generators/statecraft/machine/templates/model.rb.tt +5 -0
- data/lib/generators/statecraft/machine/templates/namespace_module.rb.tt +11 -0
- data/lib/statecraft/metadata.rb +10 -1
- data/lib/statecraft/mounting.rb +0 -5
- data/lib/statecraft/pipeline/surface.rb +31 -0
- data/lib/statecraft/pipeline.rb +53 -52
- data/lib/statecraft/version.rb +1 -1
- data/lib/statecraft.rb +1 -0
- metadata +14 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 207eda16ee51c42a787b0e3ba1a37c297242316edfe742c6be75b5dbca1166c0
|
|
4
|
+
data.tar.gz: efe7505f2499fdcd1175b35be3c454a9143b7afb2824dc3a6f0e2f98a8429a5f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 29d2cd130932273c9469dcbecf03f2a0fb20d33fc8b776efba5f850cd21bb447c5032de1dc10375e5dc9718d993001f6e0b279a5aa2e72c7be0cd1b890408710
|
|
7
|
+
data.tar.gz: c0856de0dde4ff10884ac2f567848b01b8949b514a2a171971957161d88348994d8f207e0f1e2f5d10fb1137a2b750b7f9f08e183537759dd2947be8c57911f4
|
data/README.md
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# statecraft
|
|
2
2
|
|
|
3
|
+
[](https://rubygems.org/gems/statecraft)
|
|
4
|
+
[](https://github.com/supostat/statecraft/actions/workflows/ci.yml)
|
|
3
5
|
[](LICENSE.txt)
|
|
4
6
|
|
|
7
|
+
**[Website →](https://supostat.github.io/statecraft/)** · [RubyGems](https://rubygems.org/gems/statecraft) · [Issues](https://github.com/supostat/statecraft/issues)
|
|
8
|
+
|
|
5
9
|
A state machine for ActiveRecord where the current state lives in a column as
|
|
6
10
|
the single source of truth, history is an append-only per-model log with
|
|
7
11
|
write-once metadata, and every transition is guarded by a compare-and-swap
|
|
@@ -96,7 +100,8 @@ generator turns them on for new code).
|
|
|
96
100
|
reload.
|
|
97
101
|
5. `transaction(requires_new: true)` — a savepoint inside your transaction,
|
|
98
102
|
a real transaction otherwise: optional `SELECT ... FOR UPDATE` + reload
|
|
99
|
-
(
|
|
103
|
+
(a state that changed under the lock raises
|
|
104
|
+
`Statecraft::TransitionConflict`), guards, `before_transition`
|
|
100
105
|
callbacks, the CAS update (touching `updated_at` and the `changed_at`
|
|
101
106
|
column in the same statement), the log INSERT, `after_transition`.
|
|
102
107
|
6. `after_commit` callbacks are registered on the outermost real
|
|
@@ -309,7 +314,8 @@ The log lives next to its model, always — a cascade FK cannot cross
|
|
|
309
314
|
databases, so this is a definition, not a restriction. The generated log
|
|
310
315
|
class inherits the model's connection-owning ancestor (base, roles and
|
|
311
316
|
horizontal shards follow automatically), and the generator drops the
|
|
312
|
-
migration into
|
|
317
|
+
migration into the migration path configured for that connection's database
|
|
318
|
+
(`db/migrate` when none is configured). Mounting verifies connection
|
|
313
319
|
identity — same pool, same per-thread connection, one real transaction — and
|
|
314
320
|
raises `Statecraft::ConnectionMismatch` with a fix hint otherwise. Two
|
|
315
321
|
`connects_to` blocks pointing at one physical database are still two pools:
|
|
@@ -349,6 +355,32 @@ degrades — the locking clause is dropped, the reload still runs, and
|
|
|
349
355
|
statecraft warns once per machine per process that row-locking guarantees
|
|
350
356
|
require PostgreSQL.
|
|
351
357
|
|
|
358
|
+
## Running the tests
|
|
359
|
+
|
|
360
|
+
Natively, against SQLite:
|
|
361
|
+
|
|
362
|
+
```sh
|
|
363
|
+
bundle install
|
|
364
|
+
bundle exec rspec
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
Against PostgreSQL — where the concurrency proof actually runs — point
|
|
368
|
+
`DATABASE_URL` at a database of your own, or use the bundled containers, which
|
|
369
|
+
pin the same PostgreSQL major as CI and take the Ruby and ActiveRecord versions
|
|
370
|
+
as parameters, so any CI matrix cell reproduces locally:
|
|
371
|
+
|
|
372
|
+
```sh
|
|
373
|
+
docker compose run --rm test # sqlite, default Ruby
|
|
374
|
+
docker compose run --rm test-postgres # PostgreSQL 16
|
|
375
|
+
AR_VERSION=7.2 RUBY_VERSION=3.3 docker compose run --rm test-postgres
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
## Links
|
|
379
|
+
|
|
380
|
+
- Landing page: <https://supostat.github.io/statecraft/>
|
|
381
|
+
- RubyGems: <https://rubygems.org/gems/statecraft>
|
|
382
|
+
- Issues: <https://github.com/supostat/statecraft/issues>
|
|
383
|
+
|
|
352
384
|
## License
|
|
353
385
|
|
|
354
386
|
MIT. See [LICENSE.txt](LICENSE.txt).
|
|
@@ -10,16 +10,16 @@ module Statecraft
|
|
|
10
10
|
# the per-model log table with a cascade FK and a CHECK constraint for a
|
|
11
11
|
# freshly created table), the machine class, the readonly log model, the
|
|
12
12
|
# model mounting with helpers and scopes on, and lazily the shared
|
|
13
|
-
# ApplicationMachine parent. The migration lands in the migration path
|
|
14
|
-
# the model's
|
|
15
|
-
# their model.
|
|
13
|
+
# ApplicationMachine parent. The migration lands in the migration path
|
|
14
|
+
# configured for the model's database, so multi-database apps get it next
|
|
15
|
+
# to their model.
|
|
16
16
|
class MachineGenerator < Rails::Generators::NamedBase
|
|
17
17
|
include ActiveRecord::Generators::Migration
|
|
18
18
|
|
|
19
19
|
source_root File.expand_path("templates", __dir__)
|
|
20
20
|
|
|
21
21
|
def detect_model_presence
|
|
22
|
-
@existing_model = File.exist?(File.join(destination_root,
|
|
22
|
+
@existing_model = File.exist?(File.join(destination_root, model_file))
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def create_application_machine
|
|
@@ -29,26 +29,35 @@ module Statecraft
|
|
|
29
29
|
template "application_machine.rb.tt", application_machine_path
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
def create_namespace_module
|
|
33
|
+
return if class_path.empty? || existing_model?
|
|
34
|
+
|
|
35
|
+
module_file = "app/models/#{class_path.join("/")}.rb"
|
|
36
|
+
return if File.exist?(File.join(destination_root, module_file))
|
|
37
|
+
|
|
38
|
+
template "namespace_module.rb.tt", module_file
|
|
39
|
+
end
|
|
40
|
+
|
|
32
41
|
def create_machine_class
|
|
33
|
-
template "machine.rb.tt", "app/state_machines/#{
|
|
42
|
+
template "machine.rb.tt", "app/state_machines/#{file_path}_flow.rb"
|
|
34
43
|
end
|
|
35
44
|
|
|
36
45
|
def create_log_model
|
|
37
|
-
template "log_model.rb.tt", "app/models/#{
|
|
46
|
+
template "log_model.rb.tt", "app/models/#{file_path}_transition.rb"
|
|
38
47
|
end
|
|
39
48
|
|
|
40
49
|
def create_or_mount_model
|
|
41
50
|
if existing_model?
|
|
42
|
-
inject_into_class
|
|
51
|
+
inject_into_class model_file, mounting_target_class, mounting_line
|
|
43
52
|
else
|
|
44
|
-
template "model.rb.tt",
|
|
53
|
+
template "model.rb.tt", model_file
|
|
45
54
|
end
|
|
46
55
|
end
|
|
47
56
|
|
|
48
57
|
def create_migration_file
|
|
49
58
|
migration_source = existing_model? ? "add_migration.rb.tt" : "create_migration.rb.tt"
|
|
50
59
|
migration_template migration_source,
|
|
51
|
-
"#{migration_directory}/create_#{
|
|
60
|
+
"#{migration_directory}/create_#{migration_slug}_state_machine.rb"
|
|
52
61
|
end
|
|
53
62
|
|
|
54
63
|
private
|
|
@@ -57,22 +66,43 @@ module Statecraft
|
|
|
57
66
|
@existing_model
|
|
58
67
|
end
|
|
59
68
|
|
|
69
|
+
def model_file
|
|
70
|
+
"app/models/#{file_path}.rb"
|
|
71
|
+
end
|
|
72
|
+
|
|
60
73
|
def mounting_line
|
|
61
74
|
" state_machine #{class_name}Flow, changed_at: true, helpers: true, scopes: true\n"
|
|
62
75
|
end
|
|
63
76
|
|
|
77
|
+
# inject_into_class matches the literal `class <name>` line, so the name
|
|
78
|
+
# must follow the style the model file actually uses: the full constant
|
|
79
|
+
# for the compact `class Shop::Order` style, the demodulized one for
|
|
80
|
+
# `module Shop / class Order` nesting.
|
|
81
|
+
def mounting_target_class
|
|
82
|
+
return class_name if class_path.empty?
|
|
83
|
+
|
|
84
|
+
model_source = File.read(File.join(destination_root, model_file))
|
|
85
|
+
model_source.match?(/class #{class_name}\b/) ? class_name : class_name.demodulize
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Without a configured migrations_paths, `rails db:migrate` reads
|
|
89
|
+
# db/migrate for every database — so that is the honest fallback.
|
|
64
90
|
def migration_directory
|
|
65
|
-
|
|
66
|
-
|
|
91
|
+
db_config = model_class&.connection_db_config
|
|
92
|
+
configured = db_config && Array(db_config.migrations_paths).first
|
|
93
|
+
configured || "db/migrate"
|
|
94
|
+
end
|
|
67
95
|
|
|
68
|
-
|
|
96
|
+
def model_class
|
|
97
|
+
class_name.safe_constantize
|
|
69
98
|
end
|
|
70
99
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
100
|
+
# The generated model's table follows the loaded model when one exists;
|
|
101
|
+
# otherwise the Rails naming convention for the argument, namespace
|
|
102
|
+
# included (`Shop::Order` -> shop_orders, matching the generated
|
|
103
|
+
# namespace module's table_name_prefix).
|
|
104
|
+
def table_name
|
|
105
|
+
@table_name ||= model_class ? model_class.table_name : super
|
|
76
106
|
end
|
|
77
107
|
|
|
78
108
|
def log_table_name
|
|
@@ -80,7 +110,7 @@ module Statecraft
|
|
|
80
110
|
end
|
|
81
111
|
|
|
82
112
|
def parent_class_name
|
|
83
|
-
specification_name =
|
|
113
|
+
specification_name = model_class&.connection_specification_name
|
|
84
114
|
return "ApplicationRecord" if specification_name.nil? || specification_name == "ActiveRecord::Base"
|
|
85
115
|
|
|
86
116
|
specification_name
|
|
@@ -90,15 +120,12 @@ module Statecraft
|
|
|
90
120
|
"#{file_name}_id"
|
|
91
121
|
end
|
|
92
122
|
|
|
93
|
-
def
|
|
94
|
-
"
|
|
123
|
+
def migration_slug
|
|
124
|
+
file_path.tr("/", "_")
|
|
95
125
|
end
|
|
96
126
|
|
|
97
|
-
def
|
|
98
|
-
|
|
99
|
-
adapter.match?(/postg/i) ? "jsonb" : "json"
|
|
100
|
-
rescue ActiveRecord::ActiveRecordError
|
|
101
|
-
"jsonb"
|
|
127
|
+
def migration_class_name
|
|
128
|
+
"Create#{migration_slug.camelize}StateMachine"
|
|
102
129
|
end
|
|
103
130
|
end
|
|
104
131
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
|
+
def change
|
|
5
|
+
add_column :<%= table_name %>, :state, :string, null: false, default: "pending"
|
|
6
|
+
add_index :<%= table_name %>, :state
|
|
7
|
+
add_column :<%= table_name %>, :state_changed_at, :datetime
|
|
8
|
+
|
|
9
|
+
# The existing table may carry legacy values, so no CHECK constraint is
|
|
10
|
+
# generated here. To add one later without blocking writes:
|
|
11
|
+
# ALTER TABLE ... ADD CONSTRAINT ... CHECK (state IN (...)) NOT VALID;
|
|
12
|
+
# -- clean the data --
|
|
13
|
+
# ALTER TABLE ... VALIDATE CONSTRAINT ...;
|
|
14
|
+
|
|
15
|
+
create_table :<%= log_table_name.pluralize %> do |t|
|
|
16
|
+
t.references :<%= file_name %>, null: false,
|
|
17
|
+
foreign_key: { to_table: :<%= table_name %>, on_delete: :cascade },
|
|
18
|
+
index: false
|
|
19
|
+
t.string :from_state, null: false
|
|
20
|
+
t.string :to_state, null: false
|
|
21
|
+
t.string :event
|
|
22
|
+
t.column :metadata, metadata_column_type, null: false, default: {}
|
|
23
|
+
t.datetime :created_at, null: false
|
|
24
|
+
t.index %i[<%= foreign_key_column %> id]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# jsonb where the database has it, json elsewhere: the adapter is known
|
|
31
|
+
# only when the migration actually runs, never at generation time.
|
|
32
|
+
def metadata_column_type
|
|
33
|
+
connection.adapter_name.match?(/postg/i) ? :jsonb : :json
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Shared home for private guard helpers used by several machines. Keep it
|
|
4
|
+
# free of DSL declarations (states, transitions, events): machine definitions
|
|
5
|
+
# are NOT inherited — each machine declares its own graph.
|
|
6
|
+
class ApplicationMachine
|
|
7
|
+
include Statecraft::Machine
|
|
8
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
|
+
def change
|
|
5
|
+
create_table :<%= table_name %> do |t|
|
|
6
|
+
t.string :state, null: false, default: "pending", index: true
|
|
7
|
+
t.datetime :state_changed_at
|
|
8
|
+
t.timestamps null: false
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# add new states here when the machine grows
|
|
12
|
+
add_check_constraint :<%= table_name %>, "state IN ('pending')",
|
|
13
|
+
name: "<%= table_name %>_state_check"
|
|
14
|
+
|
|
15
|
+
create_table :<%= log_table_name.pluralize %> do |t|
|
|
16
|
+
t.references :<%= file_name %>, null: false,
|
|
17
|
+
foreign_key: { to_table: :<%= table_name %>, on_delete: :cascade },
|
|
18
|
+
index: false
|
|
19
|
+
t.string :from_state, null: false
|
|
20
|
+
t.string :to_state, null: false
|
|
21
|
+
t.string :event
|
|
22
|
+
t.column :metadata, metadata_column_type, null: false, default: {}
|
|
23
|
+
t.datetime :created_at, null: false
|
|
24
|
+
t.index %i[<%= foreign_key_column %> id]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# jsonb where the database has it, json elsewhere: the adapter is known
|
|
31
|
+
# only when the migration actually runs, never at generation time.
|
|
32
|
+
def metadata_column_type
|
|
33
|
+
connection.adapter_name.match?(/postg/i) ? :jsonb : :json
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Append-only transition log for <%= class_name %>. This is a read-model:
|
|
4
|
+
# scopes, reading methods and serializers are yours; the transition pipeline
|
|
5
|
+
# writes rows through the insert path and never calls validations or
|
|
6
|
+
# callbacks declared here.
|
|
7
|
+
class <%= class_name %>Transition < <%= parent_class_name %>
|
|
8
|
+
def readonly? = persisted?
|
|
9
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= class_name %>Flow < ApplicationMachine
|
|
4
|
+
state :pending, initial: true
|
|
5
|
+
# state :paid
|
|
6
|
+
#
|
|
7
|
+
# event :pay, from: :pending, to: :paid, guard: :payable?
|
|
8
|
+
#
|
|
9
|
+
# Guards and callbacks resolve to instance methods of this class:
|
|
10
|
+
#
|
|
11
|
+
# private
|
|
12
|
+
#
|
|
13
|
+
# def payable?(record, metadata)
|
|
14
|
+
# metadata["amount"].to_i.positive?
|
|
15
|
+
# end
|
|
16
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
<% class_path.each_with_index do |part, depth| -%>
|
|
4
|
+
<%= " " * depth %>module <%= part.camelize %>
|
|
5
|
+
<% end -%>
|
|
6
|
+
<%= " " * class_path.length %>def self.table_name_prefix
|
|
7
|
+
<%= " " * class_path.length %> "<%= class_path.join("_") %>_"
|
|
8
|
+
<%= " " * class_path.length %>end
|
|
9
|
+
<% (class_path.length - 1).downto(0) do |depth| -%>
|
|
10
|
+
<%= " " * depth %>end
|
|
11
|
+
<% end -%>
|
data/lib/statecraft/metadata.rb
CHANGED
|
@@ -19,7 +19,8 @@ module Statecraft
|
|
|
19
19
|
value.map { |element| round_trip(element) }
|
|
20
20
|
when String then value.dup
|
|
21
21
|
when Symbol then value.to_s
|
|
22
|
-
when Integer,
|
|
22
|
+
when Integer, true, false, nil then value
|
|
23
|
+
when Float then round_trip_float(value)
|
|
23
24
|
when Time, Date, DateTime then value.iso8601
|
|
24
25
|
else
|
|
25
26
|
raise ArgumentError,
|
|
@@ -28,6 +29,14 @@ module Statecraft
|
|
|
28
29
|
end
|
|
29
30
|
end
|
|
30
31
|
|
|
32
|
+
def self.round_trip_float(value)
|
|
33
|
+
return value if value.finite?
|
|
34
|
+
|
|
35
|
+
raise ArgumentError,
|
|
36
|
+
"metadata value #{value.inspect} is not JSON-serializable; " \
|
|
37
|
+
"jsonb has no NaN or Infinity"
|
|
38
|
+
end
|
|
39
|
+
|
|
31
40
|
def self.round_trip_key(key)
|
|
32
41
|
case key
|
|
33
42
|
when String then key.dup
|
data/lib/statecraft/mounting.rb
CHANGED
|
@@ -40,7 +40,6 @@ module Statecraft
|
|
|
40
40
|
|
|
41
41
|
def mount
|
|
42
42
|
assert_not_mounted
|
|
43
|
-
assert_single_column_primary_key
|
|
44
43
|
log_class = resolve_log_class
|
|
45
44
|
assert_shared_connection_class(log_class)
|
|
46
45
|
graph = machine_class.finalize!
|
|
@@ -64,10 +63,6 @@ module Statecraft
|
|
|
64
63
|
raise AlreadyMounted.new(model: model) if model.respond_to?(:statecraft_mounting)
|
|
65
64
|
end
|
|
66
65
|
|
|
67
|
-
def assert_single_column_primary_key
|
|
68
|
-
raise CompositePrimaryKeyUnsupported.new(model: model) if model.primary_key.is_a?(Array)
|
|
69
|
-
end
|
|
70
|
-
|
|
71
66
|
def resolve_log_class
|
|
72
67
|
return @log_option if @log_option
|
|
73
68
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statecraft
|
|
4
|
+
class Pipeline
|
|
5
|
+
# The record-facing API mixed into the model at mounting time. Bang
|
|
6
|
+
# variants return the created log record; non-bang variants return it too,
|
|
7
|
+
# or false on GuardFailed / InvalidTransition. Programmer errors and
|
|
8
|
+
# TransitionConflict always raise.
|
|
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)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def transition_to(to_state, metadata: {}, bypass_events: false)
|
|
15
|
+
transition_to!(to_state, metadata: metadata, bypass_events: bypass_events)
|
|
16
|
+
rescue GuardFailed, InvalidTransition
|
|
17
|
+
false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def fire!(event_name, metadata: {})
|
|
21
|
+
Pipeline.new(self).fire(event_name, metadata: metadata)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def fire(event_name, metadata: {})
|
|
25
|
+
fire!(event_name, metadata: metadata)
|
|
26
|
+
rescue GuardFailed, InvalidTransition
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/statecraft/pipeline.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Statecraft
|
|
|
5
5
|
#
|
|
6
6
|
# persisted? -> metadata normalize+freeze -> edge resolution ->
|
|
7
7
|
# dirty check (lock only) -> transaction(requires_new: true) [
|
|
8
|
-
# lock+reload ->
|
|
8
|
+
# lock+reload -> conflict check against the resolved edge -> guards ->
|
|
9
9
|
# before_transition -> CAS UPDATE -> log INSERT (insert path) ->
|
|
10
10
|
# after_transition
|
|
11
11
|
# ] -> after_commit registration on the outermost real commit
|
|
@@ -32,32 +32,6 @@ module Statecraft
|
|
|
32
32
|
ActiveSupport::IsolatedExecutionState[STACK_KEY] ||= []
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
# The record-facing API mixed into the model at mounting time. Bang
|
|
36
|
-
# variants return the created log record; non-bang variants return it too,
|
|
37
|
-
# or false on GuardFailed / InvalidTransition. Programmer errors and
|
|
38
|
-
# TransitionConflict always raise.
|
|
39
|
-
module Surface
|
|
40
|
-
def transition_to!(to_state, metadata: {}, bypass_events: false)
|
|
41
|
-
Pipeline.new(self).direct(to_state, metadata: metadata, bypass_events: bypass_events)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def transition_to(to_state, metadata: {}, bypass_events: false)
|
|
45
|
-
transition_to!(to_state, metadata: metadata, bypass_events: bypass_events)
|
|
46
|
-
rescue GuardFailed, InvalidTransition
|
|
47
|
-
false
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def fire!(event_name, metadata: {})
|
|
51
|
-
Pipeline.new(self).fire(event_name, metadata: metadata)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def fire(event_name, metadata: {})
|
|
55
|
-
fire!(event_name, metadata: metadata)
|
|
56
|
-
rescue GuardFailed, InvalidTransition
|
|
57
|
-
false
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
|
|
61
35
|
def initialize(record)
|
|
62
36
|
@record = record
|
|
63
37
|
@configuration = record.class.statecraft_mounting
|
|
@@ -86,6 +60,7 @@ module Statecraft
|
|
|
86
60
|
def run(raw_metadata, &edge_resolver)
|
|
87
61
|
raise UnsavedRecordError.new(record: record) unless record.persisted?
|
|
88
62
|
|
|
63
|
+
assert_single_column_primary_key
|
|
89
64
|
metadata = Metadata.normalize(raw_metadata)
|
|
90
65
|
started_at = Time.current
|
|
91
66
|
begin
|
|
@@ -93,18 +68,12 @@ module Statecraft
|
|
|
93
68
|
assert_clean_when_locked(edge)
|
|
94
69
|
frame = open_frame(edge, event)
|
|
95
70
|
begin
|
|
96
|
-
log_record = execute_transaction(edge, event, bypass, metadata,
|
|
71
|
+
log_record = execute_transaction(edge, event, bypass, metadata, frame)
|
|
97
72
|
ensure
|
|
98
73
|
Pipeline.transition_stack.delete(frame)
|
|
99
74
|
end
|
|
100
|
-
rescue GuardFailed =>
|
|
101
|
-
publish_failure(started_at,
|
|
102
|
-
raise
|
|
103
|
-
rescue InvalidTransition => invalid_error
|
|
104
|
-
publish_failure(started_at, :invalid_transition, requested: invalid_error.requested)
|
|
105
|
-
raise
|
|
106
|
-
rescue TransitionConflict => conflict_error
|
|
107
|
-
publish_failure(started_at, :conflict, expected_from: conflict_error.expected_from)
|
|
75
|
+
rescue GuardFailed, InvalidTransition, TransitionConflict => transition_error
|
|
76
|
+
publish_failure(started_at, transition_error)
|
|
108
77
|
raise
|
|
109
78
|
end
|
|
110
79
|
Instrumentation.publish_transition(
|
|
@@ -115,19 +84,25 @@ module Statecraft
|
|
|
115
84
|
log_record
|
|
116
85
|
end
|
|
117
86
|
|
|
118
|
-
def publish_failure(started_at,
|
|
87
|
+
def publish_failure(started_at, error)
|
|
88
|
+
reason, details =
|
|
89
|
+
case error
|
|
90
|
+
when GuardFailed then [:guard_failed, { guard: error.guard }]
|
|
91
|
+
when InvalidTransition then [:invalid_transition, { requested: error.requested }]
|
|
92
|
+
when TransitionConflict then [:conflict, { expected_from: error.expected_from }]
|
|
93
|
+
end
|
|
119
94
|
Instrumentation.publish_failure(
|
|
120
95
|
started_at: started_at, record: record,
|
|
121
96
|
machine_class: configuration.machine_class, reason: reason, details: details
|
|
122
97
|
)
|
|
123
98
|
end
|
|
124
99
|
|
|
125
|
-
def execute_transaction(edge, event, bypass, metadata,
|
|
100
|
+
def execute_transaction(edge, event, bypass, metadata, frame)
|
|
126
101
|
base_class.transaction(requires_new: true) do
|
|
127
102
|
if edge.lock
|
|
128
103
|
warn_when_row_locking_unavailable
|
|
129
104
|
record.reload(lock: true)
|
|
130
|
-
edge
|
|
105
|
+
assert_lock_saw_expected_state(edge)
|
|
131
106
|
end
|
|
132
107
|
transition_time = Time.current
|
|
133
108
|
run_guards(edge, event, bypass, metadata)
|
|
@@ -138,8 +113,13 @@ module Statecraft
|
|
|
138
113
|
cas_update!(edge, transition_time)
|
|
139
114
|
frame.cas_done = true
|
|
140
115
|
context.log_record = insert_log_row(edge, event, metadata, transition_time)
|
|
141
|
-
sync_record(edge, transition_time)
|
|
142
|
-
|
|
116
|
+
synced_snapshot = sync_record(edge, transition_time)
|
|
117
|
+
begin
|
|
118
|
+
run_callbacks(:after_transition, context)
|
|
119
|
+
rescue Exception # rubocop:disable Lint/RescueException -- the savepoint rolls the database back on ANY exception, so the in-memory sync must roll back with it
|
|
120
|
+
restore_synced_attributes(synced_snapshot)
|
|
121
|
+
raise
|
|
122
|
+
end
|
|
143
123
|
context.log_record
|
|
144
124
|
end
|
|
145
125
|
end
|
|
@@ -204,12 +184,31 @@ module Statecraft
|
|
|
204
184
|
graph.edges.keys.select { |from, _to| from == current }.map(&:last)
|
|
205
185
|
end
|
|
206
186
|
|
|
187
|
+
# Checked at the first transition, not at mounting time: resolving an
|
|
188
|
+
# implicit primary key goes through the schema cache, and mounting must
|
|
189
|
+
# stay safe without a database connection.
|
|
190
|
+
def assert_single_column_primary_key
|
|
191
|
+
return unless base_class.primary_key.is_a?(Array)
|
|
192
|
+
|
|
193
|
+
raise CompositePrimaryKeyUnsupported.new(model: base_class)
|
|
194
|
+
end
|
|
195
|
+
|
|
207
196
|
def assert_clean_when_locked(edge)
|
|
208
197
|
return unless edge.lock && record.changed?
|
|
209
198
|
|
|
210
199
|
raise DirtyRecordError.new(record: record, changed_attributes: record.changed)
|
|
211
200
|
end
|
|
212
201
|
|
|
202
|
+
# The lock reload can reveal a state that no longer matches the edge the
|
|
203
|
+
# caller validated. That is a concurrent write observed early: the same
|
|
204
|
+
# conflict CAS would report, so it raises the same error instead of
|
|
205
|
+
# silently resolving a different edge from the fresh state.
|
|
206
|
+
def assert_lock_saw_expected_state(edge)
|
|
207
|
+
return if current_state == edge.from
|
|
208
|
+
|
|
209
|
+
raise TransitionConflict.new(record: record, expected_from: edge.from)
|
|
210
|
+
end
|
|
211
|
+
|
|
213
212
|
def run_guards(edge, event, bypass, metadata)
|
|
214
213
|
guards = edge.edge_guards.dup
|
|
215
214
|
guards.concat(edge.event_guards.fetch(event, [])) if event && !bypass
|
|
@@ -267,18 +266,20 @@ module Statecraft
|
|
|
267
266
|
log_class.find(log_id)
|
|
268
267
|
end
|
|
269
268
|
|
|
269
|
+
# Mirrors exactly what the CAS statement wrote into the row, without a
|
|
270
|
+
# dirty mark, and returns the prior values so a failing after_transition
|
|
271
|
+
# can roll the memory back alongside the savepoint.
|
|
270
272
|
def sync_record(edge, transition_time)
|
|
271
|
-
|
|
272
|
-
record[
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
record.clear_attribute_changes(synced_columns)
|
|
273
|
+
updates = cas_updates(edge, transition_time)
|
|
274
|
+
snapshot = updates.to_h { |column, _value| [column, record[column]] }
|
|
275
|
+
updates.each { |column, value| record[column] = value }
|
|
276
|
+
record.clear_attribute_changes(updates.keys)
|
|
277
|
+
snapshot
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def restore_synced_attributes(snapshot)
|
|
281
|
+
snapshot.each { |column, value| record[column] = value }
|
|
282
|
+
record.clear_attribute_changes(snapshot.keys)
|
|
282
283
|
end
|
|
283
284
|
|
|
284
285
|
def register_after_commit_callbacks(log_record, metadata)
|
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"
|
|
13
|
+
require_relative "statecraft/pipeline/surface"
|
|
13
14
|
require_relative "statecraft/introspection"
|
|
14
15
|
require_relative "statecraft/mounting"
|
|
15
16
|
|
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.1.
|
|
4
|
+
version: 0.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Igor Pugachev
|
|
@@ -61,6 +61,13 @@ files:
|
|
|
61
61
|
- LICENSE.txt
|
|
62
62
|
- README.md
|
|
63
63
|
- lib/generators/statecraft/machine/machine_generator.rb
|
|
64
|
+
- lib/generators/statecraft/machine/templates/add_migration.rb.tt
|
|
65
|
+
- lib/generators/statecraft/machine/templates/application_machine.rb.tt
|
|
66
|
+
- lib/generators/statecraft/machine/templates/create_migration.rb.tt
|
|
67
|
+
- lib/generators/statecraft/machine/templates/log_model.rb.tt
|
|
68
|
+
- lib/generators/statecraft/machine/templates/machine.rb.tt
|
|
69
|
+
- lib/generators/statecraft/machine/templates/model.rb.tt
|
|
70
|
+
- lib/generators/statecraft/machine/templates/namespace_module.rb.tt
|
|
64
71
|
- lib/statecraft.rb
|
|
65
72
|
- lib/statecraft/errors.rb
|
|
66
73
|
- lib/statecraft/instrumentation.rb
|
|
@@ -69,11 +76,17 @@ files:
|
|
|
69
76
|
- lib/statecraft/metadata.rb
|
|
70
77
|
- lib/statecraft/mounting.rb
|
|
71
78
|
- lib/statecraft/pipeline.rb
|
|
79
|
+
- lib/statecraft/pipeline/surface.rb
|
|
72
80
|
- lib/statecraft/version.rb
|
|
73
81
|
- lib/statecraft/warnings.rb
|
|
82
|
+
homepage: https://supostat.github.io/statecraft/
|
|
74
83
|
licenses:
|
|
75
84
|
- MIT
|
|
76
85
|
metadata:
|
|
86
|
+
homepage_uri: https://supostat.github.io/statecraft/
|
|
87
|
+
source_code_uri: https://github.com/supostat/statecraft
|
|
88
|
+
changelog_uri: https://github.com/supostat/statecraft/releases
|
|
89
|
+
bug_tracker_uri: https://github.com/supostat/statecraft/issues
|
|
77
90
|
rubygems_mfa_required: 'true'
|
|
78
91
|
rdoc_options: []
|
|
79
92
|
require_paths:
|