statecraft 0.1.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.
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statecraft
4
+ # Executes one transition through the protocol pipeline:
5
+ #
6
+ # persisted? -> metadata normalize+freeze -> edge resolution ->
7
+ # dirty check (lock only) -> transaction(requires_new: true) [
8
+ # lock+reload -> re-resolve edge from fresh state -> guards ->
9
+ # before_transition -> CAS UPDATE -> log INSERT (insert path) ->
10
+ # after_transition
11
+ # ] -> after_commit registration on the outermost real commit
12
+ #
13
+ # The CAS statement and the log insert run against base_class; the log row
14
+ # is written via the insert path, past the log model's validations and
15
+ # callbacks — guards and machine callbacks are the single channel of truth.
16
+ class Pipeline
17
+ Transition = Struct.new(:from, :to, :event, :metadata, :log_record, keyword_init: true)
18
+
19
+ # One frame per pipeline currently running in this execution context. The
20
+ # key identifies the record across instances — (base_class name, id,
21
+ # machine) — so re-entering the same record's pipeline between its start
22
+ # and the completion of CAS (guards, before_transition) is caught even
23
+ # through a freshly loaded instance. cas_done flips right after the CAS
24
+ # statement: chains launched from after_transition are legal by
25
+ # construction.
26
+ Frame = Struct.new(:key, :label, :cas_done, keyword_init: true)
27
+
28
+ STACK_KEY = :statecraft_transition_stack
29
+ MAX_CHAIN_DEPTH = 16
30
+
31
+ def self.transition_stack
32
+ ActiveSupport::IsolatedExecutionState[STACK_KEY] ||= []
33
+ end
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
+ def initialize(record)
62
+ @record = record
63
+ @configuration = record.class.statecraft_mounting
64
+ @graph = @configuration.machine_class.finalize!
65
+ @machine_instance = @configuration.machine_class.new
66
+ end
67
+
68
+ def direct(to_state, metadata:, bypass_events:)
69
+ run(metadata) do |current_state|
70
+ edge = resolve_direct_edge(current_state, to_state.to_sym, bypass_events)
71
+ [edge, nil, bypass_events]
72
+ end
73
+ end
74
+
75
+ def fire(event_name, metadata:)
76
+ run(metadata) do |current_state|
77
+ edge = resolve_event_edge(current_state, event_name.to_sym)
78
+ [edge, event_name.to_sym, false]
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ attr_reader :record, :configuration, :graph, :machine_instance
85
+
86
+ def run(raw_metadata, &edge_resolver)
87
+ raise UnsavedRecordError.new(record: record) unless record.persisted?
88
+
89
+ metadata = Metadata.normalize(raw_metadata)
90
+ started_at = Time.current
91
+ begin
92
+ edge, event, bypass = edge_resolver.call(current_state)
93
+ assert_clean_when_locked(edge)
94
+ frame = open_frame(edge, event)
95
+ begin
96
+ log_record = execute_transaction(edge, event, bypass, metadata, edge_resolver, frame)
97
+ ensure
98
+ Pipeline.transition_stack.delete(frame)
99
+ end
100
+ rescue GuardFailed => guard_error
101
+ publish_failure(started_at, :guard_failed, guard: guard_error.guard)
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)
108
+ raise
109
+ end
110
+ Instrumentation.publish_transition(
111
+ started_at: started_at, record: record,
112
+ machine_class: configuration.machine_class, log_record: log_record
113
+ )
114
+ register_after_commit_callbacks(log_record, metadata)
115
+ log_record
116
+ end
117
+
118
+ def publish_failure(started_at, reason, details)
119
+ Instrumentation.publish_failure(
120
+ started_at: started_at, record: record,
121
+ machine_class: configuration.machine_class, reason: reason, details: details
122
+ )
123
+ end
124
+
125
+ def execute_transaction(edge, event, bypass, metadata, edge_resolver, frame)
126
+ base_class.transaction(requires_new: true) do
127
+ if edge.lock
128
+ warn_when_row_locking_unavailable
129
+ record.reload(lock: true)
130
+ edge, event, bypass = edge_resolver.call(current_state)
131
+ end
132
+ transition_time = Time.current
133
+ run_guards(edge, event, bypass, metadata)
134
+ context = Transition.new(
135
+ from: edge.from, to: edge.to, event: event, metadata: metadata, log_record: nil
136
+ )
137
+ run_callbacks(:before_transition, context)
138
+ cas_update!(edge, transition_time)
139
+ frame.cas_done = true
140
+ context.log_record = insert_log_row(edge, event, metadata, transition_time)
141
+ sync_record(edge, transition_time)
142
+ run_callbacks(:after_transition, context)
143
+ context.log_record
144
+ end
145
+ end
146
+
147
+ def open_frame(edge, event)
148
+ stack = Pipeline.transition_stack
149
+ frame_key = [base_class.name, record.id, configuration.machine_class]
150
+ pending_same_record = stack.find { |open| open.key == frame_key && !open.cas_done }
151
+ raise NestedTransitionError.new(record: record) if pending_same_record
152
+
153
+ if stack.length >= MAX_CHAIN_DEPTH
154
+ chain = stack.map(&:label) + [frame_label(edge, event)]
155
+ raise ChainDepthExceeded.new(chain: chain)
156
+ end
157
+
158
+ frame = Frame.new(key: frame_key, label: frame_label(edge, event), cas_done: false)
159
+ stack.push(frame)
160
+ frame
161
+ end
162
+
163
+ def frame_label(edge, event)
164
+ via = event ? " (#{event})" : ""
165
+ "#{base_class.name}##{record.id}: #{edge.from} -> #{edge.to}#{via}"
166
+ end
167
+
168
+ def current_state
169
+ record[configuration.column].to_s.to_sym
170
+ end
171
+
172
+ def resolve_direct_edge(current, to_state, bypass_events)
173
+ edge = graph.edges[[current, to_state]]
174
+ raise_invalid_transition(current, to_state) if edge.nil?
175
+ guarding_events = edge.event_names.select { |name| edge.event_guards[name].any? }
176
+ if guarding_events.any? && !bypass_events
177
+ raise InvalidTransition.new(
178
+ record: record, from: current, requested: to_state,
179
+ allowed: allowed_targets(current),
180
+ message: "direct transition #{current} -> #{to_state} is guarded by " \
181
+ "event#{"s" if guarding_events.length > 1} #{guarding_events.join(", ")}; " \
182
+ "call fire!(:#{guarding_events.first}) or pass bypass_events: true"
183
+ )
184
+ end
185
+ edge
186
+ end
187
+
188
+ def resolve_event_edge(current, event_name)
189
+ branches = graph.events[event_name]
190
+ raise_invalid_transition(current, event_name) if branches.nil?
191
+ edge = branches[current]
192
+ raise_invalid_transition(current, event_name) if edge.nil?
193
+ edge
194
+ end
195
+
196
+ def raise_invalid_transition(current, requested)
197
+ raise InvalidTransition.new(
198
+ record: record, from: current, requested: requested,
199
+ allowed: allowed_targets(current)
200
+ )
201
+ end
202
+
203
+ def allowed_targets(current)
204
+ graph.edges.keys.select { |from, _to| from == current }.map(&:last)
205
+ end
206
+
207
+ def assert_clean_when_locked(edge)
208
+ return unless edge.lock && record.changed?
209
+
210
+ raise DirtyRecordError.new(record: record, changed_attributes: record.changed)
211
+ end
212
+
213
+ def run_guards(edge, event, bypass, metadata)
214
+ guards = edge.edge_guards.dup
215
+ guards.concat(edge.event_guards.fetch(event, [])) if event && !bypass
216
+ guards.each do |guard|
217
+ next if Machine::Handlers.invoke(machine_instance, guard, record, metadata)
218
+
219
+ raise GuardFailed.new(
220
+ record: record, guard: guard.is_a?(Symbol) ? guard : guard.inspect,
221
+ from: edge.from, to: edge.to, event: event
222
+ )
223
+ end
224
+ end
225
+
226
+ def run_callbacks(phase, context)
227
+ matching_callbacks(phase, context).each do |callback|
228
+ Machine::Handlers.invoke(machine_instance, callback.handler, record, context)
229
+ end
230
+ end
231
+
232
+ def matching_callbacks(phase, context)
233
+ graph.callbacks.fetch(phase).select do |callback|
234
+ (callback.from.nil? || callback.from.include?(context.from)) &&
235
+ (callback.to.nil? || callback.to.include?(context.to)) &&
236
+ (callback.event.nil? || (context.event && callback.event.include?(context.event)))
237
+ end
238
+ end
239
+
240
+ def cas_update!(edge, transition_time)
241
+ affected_rows = base_class.unscoped
242
+ .where(base_class.primary_key => record.id,
243
+ configuration.column => edge.from.to_s)
244
+ .update_all(cas_updates(edge, transition_time))
245
+ raise TransitionConflict.new(record: record, expected_from: edge.from) if affected_rows.zero?
246
+ end
247
+
248
+ def cas_updates(edge, transition_time)
249
+ updates = { configuration.column => edge.to.to_s }
250
+ updates[:updated_at] = transition_time if touch_updated_at?
251
+ updates[changed_at_column] = transition_time if changed_at_column
252
+ updates
253
+ end
254
+
255
+ def insert_log_row(edge, event, metadata, transition_time)
256
+ log_class = configuration.log_class
257
+ attributes = {
258
+ configuration.log_foreign_key => record.id,
259
+ from_state: edge.from.to_s,
260
+ to_state: edge.to.to_s,
261
+ event: event&.to_s,
262
+ metadata: metadata,
263
+ created_at: transition_time
264
+ }
265
+ result = log_class.insert!(attributes, returning: [log_class.primary_key.to_sym])
266
+ log_id = result.rows.first&.first
267
+ log_class.find(log_id)
268
+ end
269
+
270
+ def sync_record(edge, transition_time)
271
+ synced_columns = [configuration.column]
272
+ record[configuration.column] = edge.to.to_s
273
+ if touch_updated_at?
274
+ record[:updated_at] = transition_time
275
+ synced_columns << :updated_at
276
+ end
277
+ if changed_at_column
278
+ record[changed_at_column] = transition_time
279
+ synced_columns << changed_at_column
280
+ end
281
+ record.clear_attribute_changes(synced_columns)
282
+ end
283
+
284
+ def register_after_commit_callbacks(log_record, metadata)
285
+ context = Transition.new(
286
+ from: log_record.from_state.to_sym, to: log_record.to_state.to_sym,
287
+ event: log_record.event&.to_sym, metadata: metadata, log_record: log_record
288
+ )
289
+ matching_callbacks(:after_commit, context).each do |callback|
290
+ ActiveRecord.after_all_transactions_commit do
291
+ Machine::Handlers.invoke(machine_instance, callback.handler, record, context)
292
+ end
293
+ end
294
+ end
295
+
296
+ def touch_updated_at?
297
+ configuration.touch && record.class.column_names.include?("updated_at")
298
+ end
299
+
300
+ def changed_at_column
301
+ column = configuration.changed_at_column
302
+ return nil unless column
303
+
304
+ unless record.class.column_names.include?(column.to_s)
305
+ raise CompilationError,
306
+ "changed_at column #{column} does not exist on #{record.class.table_name}; " \
307
+ "add the column or drop the changed_at option"
308
+ end
309
+ column
310
+ end
311
+
312
+ def warn_when_row_locking_unavailable
313
+ return unless base_class.connection.adapter_name.match?(/sqlite/i)
314
+
315
+ Statecraft.warn(
316
+ [configuration.machine_class.name, :sqlite_row_lock],
317
+ "row locking unavailable on sqlite (machine #{configuration.machine_class.name}); " \
318
+ "the reload still runs, but lock: true guarantees require PostgreSQL"
319
+ )
320
+ end
321
+
322
+ def base_class
323
+ record.class.base_class
324
+ end
325
+ end
326
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statecraft
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Statecraft
4
+ # The single internal warning funnel. Speaks to the developer at the
5
+ # keyboard through Kernel#warn (stderr) — never through Rails.logger, which
6
+ # may not exist, may be unconfigured at mounting time, and hides test-run
7
+ # warnings in a log file nobody reads. Deduplicates once-per-key-per-process
8
+ # and is deliberately not configurable: a "where do warnings go" knob would
9
+ # be the first global setting of a gem that has none.
10
+ def self.warn(key, message)
11
+ WARNING_DEDUP_MUTEX.synchronize do
12
+ return if emitted_warning_keys.include?(key)
13
+
14
+ emitted_warning_keys << key
15
+ end
16
+ Kernel.warn("[statecraft] #{message}")
17
+ end
18
+
19
+ WARNING_DEDUP_MUTEX = Mutex.new
20
+
21
+ def self.emitted_warning_keys
22
+ @emitted_warning_keys ||= Set.new
23
+ end
24
+ private_class_method :emitted_warning_keys
25
+
26
+ def self.reset_warning_dedup!
27
+ WARNING_DEDUP_MUTEX.synchronize { emitted_warning_keys.clear }
28
+ end
29
+ private_class_method :reset_warning_dedup!
30
+ end
data/lib/statecraft.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_record"
5
+
6
+ require_relative "statecraft/version"
7
+ require_relative "statecraft/errors"
8
+ require_relative "statecraft/warnings"
9
+ require_relative "statecraft/instrumentation"
10
+ require_relative "statecraft/machine"
11
+ require_relative "statecraft/metadata"
12
+ require_relative "statecraft/pipeline"
13
+ require_relative "statecraft/introspection"
14
+ require_relative "statecraft/mounting"
15
+
16
+ ActiveSupport.on_load(:active_record) do
17
+ extend Statecraft::Mounting
18
+ end
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: statecraft
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Igor Pugachev
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.2'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.2'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: activesupport
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '7.2'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '9'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '7.2'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '9'
52
+ description: 'State machine on top of ActiveRecord: the current state lives in a column
53
+ guarded by CAS updates, history is an append-only per-model log with write-once
54
+ metadata, guards are event-aware, and bypass is explicit.'
55
+ email:
56
+ - ipugachev84@gmail.com
57
+ executables: []
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - LICENSE.txt
62
+ - README.md
63
+ - lib/generators/statecraft/machine/machine_generator.rb
64
+ - lib/statecraft.rb
65
+ - lib/statecraft/errors.rb
66
+ - lib/statecraft/instrumentation.rb
67
+ - lib/statecraft/introspection.rb
68
+ - lib/statecraft/machine.rb
69
+ - lib/statecraft/metadata.rb
70
+ - lib/statecraft/mounting.rb
71
+ - lib/statecraft/pipeline.rb
72
+ - lib/statecraft/version.rb
73
+ - lib/statecraft/warnings.rb
74
+ licenses:
75
+ - MIT
76
+ metadata:
77
+ rubygems_mfa_required: 'true'
78
+ rdoc_options: []
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '3.3'
86
+ required_rubygems_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ requirements: []
92
+ rubygems_version: 3.6.9
93
+ specification_version: 4
94
+ summary: Concurrency-safe state machine for ActiveRecord with an append-only transition
95
+ log
96
+ test_files: []