Module: Fosm::Lifecycle
- Extended by:
- ActiveSupport::Concern
- Defined in:
- lib/fosm/lifecycle.rb,
lib/fosm/lifecycle/definition.rb,
lib/fosm/lifecycle/role_definition.rb,
lib/fosm/lifecycle/event_definition.rb,
lib/fosm/lifecycle/guard_definition.rb,
lib/fosm/lifecycle/state_definition.rb,
lib/fosm/lifecycle/access_definition.rb,
lib/fosm/lifecycle/side_effect_definition.rb,
lib/fosm/lifecycle/snapshot_configuration.rb
Defined Under Namespace
Classes: AccessDefinition, Definition, EventDefinition, GuardDefinition, RoleDefinition, SideEffectDefinition, SnapshotConfiguration, StateDefinition
Instance Method Summary collapse
-
#available_events ⇒ Object
Returns list of event names that can be fired from the current state.
-
#can_fire?(event_name) ⇒ Boolean
Returns true if the given event can be fired from the current state.
-
#can_fire_with_actor?(event_name, actor:) ⇒ Boolean
Returns true if the actor has a role permitting this event AND the transition is valid.
-
#current_state ⇒ Object
Returns the current state as a symbol.
-
#fire!(event_name, actor: nil, metadata: {}, snapshot_data: nil) ⇒ Object
Fire a lifecycle event.
-
#last_snapshot ⇒ Fosm::TransitionLog?
Returns the most recent snapshot for this record, or nil if none exists.
-
#last_snapshot_data ⇒ Hash?
Returns the snapshot data from the most recent snapshot, or nil.
-
#replay_from(from_snapshot) {|transition_log| ... } ⇒ Array<Fosm::TransitionLog>
Replays events from a specific snapshot forward to the current state.
-
#snapshots ⇒ ActiveRecord::Relation
Returns all snapshots for this record in chronological order.
-
#state_at_transition(transition_log_id) ⇒ Hash
Returns the state of the record at a specific transition log ID.
-
#transitions_since_snapshot ⇒ Integer
Returns the number of transitions since the last snapshot.
-
#why_cannot_fire?(event_name) ⇒ Boolean
๐ Detailed introspection: why can this event (not) be fired? Returns a hash with diagnostic information for debugging and UI messages.
Instance Method Details
#available_events ⇒ Object
Returns list of event names that can be fired from the current state
329 330 331 332 333 334 335 336 337 |
# File 'lib/fosm/lifecycle.rb', line 329 def available_events lifecycle = self.class.fosm_lifecycle return [] unless lifecycle lifecycle.available_events_from(self.state).select { |event_def| # ๐ Use evaluate to properly check guards event_def.guards.all? { |g| g.evaluate(self).first } }.map(&:name) end |
#can_fire?(event_name) ⇒ Boolean
Returns true if the given event can be fired from the current state. Does NOT check RBAC โ use fosm_can_fire_with_actor? for that.
306 307 308 309 310 311 312 313 314 315 316 317 318 |
# File 'lib/fosm/lifecycle.rb', line 306 def can_fire?(event_name) lifecycle = self.class.fosm_lifecycle return false unless lifecycle event_def = lifecycle.find_event(event_name) return false unless event_def # Terminal states block transitions return false if lifecycle.find_state(self.state)&.terminal? return false unless event_def.valid_from?(self.state) # Use evaluate to properly check guards (handles rich return values) event_def.guards.all? { |guard_def| guard_def.evaluate(self).first } end |
#can_fire_with_actor?(event_name, actor:) ⇒ Boolean
Returns true if the actor has a role permitting this event AND the transition is valid.
321 322 323 324 325 326 |
# File 'lib/fosm/lifecycle.rb', line 321 def can_fire_with_actor?(event_name, actor:) return false unless can_fire?(event_name) lifecycle = self.class.fosm_lifecycle return true unless lifecycle.access_defined? (event_name, actor) end |
#current_state ⇒ Object
Returns the current state as a symbol
398 399 400 |
# File 'lib/fosm/lifecycle.rb', line 398 def current_state self.state.to_sym end |
#fire!(event_name, actor: nil, metadata: {}, snapshot_data: nil) ⇒ Object
Fire a lifecycle event. This is the ONLY way to change state.
Execution order (all in-memory checks first, then one DB write):
1. Validate event exists
2. Check current state is not terminal
3. Check event is valid from current state
4. Run guards (pure in-memory functions)
5. RBAC check (O(1) cache lookup after first request hit)
6. Acquire row lock (SELECT FOR UPDATE) and re-validate
7. BEGIN TRANSACTION: UPDATE state, run side effects
[optionally INSERT log if strategy == :sync]
COMMIT
8. Emit transition log (:async or :buffered, non-blocking)
9. Enqueue webhook delivery (always async)
RACE CONDITION PROTECTION:
- Uses SELECT FOR UPDATE to lock the row, preventing concurrent transitions
- Re-validates state, guards, and RBAC after acquiring lock
- Guarantees only one transition succeeds when concurrent requests fire
the same event on the same record
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 |
# File 'lib/fosm/lifecycle.rb', line 80 def fire!(event_name, actor: nil, metadata: {}, snapshot_data: nil) lifecycle = self.class.fosm_lifecycle raise Fosm::Error, "No lifecycle defined on #{self.class.name}" unless lifecycle event_def = lifecycle.find_event(event_name) raise Fosm::UnknownEvent.new(event_name, self.class) unless event_def current = self.state.to_s current_state_def = lifecycle.find_state(current) # Block terminal states from further transitions if current_state_def&.terminal? raise Fosm::TerminalState.new(current, self.class) end # Check the transition is valid from current state unless event_def.valid_from?(current) raise Fosm::InvalidTransition.new(event_name, current, self.class) end # Run guards (pure functions โ no side effects, evaluated before any writes) # ๐ Use evaluate for rich error messages event_def.guards.each do |guard_def| allowed, reason = guard_def.evaluate(self) unless allowed raise Fosm::GuardFailed.new(guard_def.name, event_name, reason) end end # RBAC check โ fail fast before touching the DB if lifecycle.access_defined? fosm_enforce_event_access!(event_name, actor) end from_state = current to_state = event_def.to_state.to_s transition_data = { from: from_state, to: to_state, event: event_name.to_s, actor: actor } # Auto-capture triggered_by when called from within a side effect triggered_by = Thread.current[:fosm_trigger_context] log_data = { "record_type" => self.class.name, "record_id" => self.id.to_s, "event_name" => event_name.to_s, "from_state" => from_state, "to_state" => to_state, "actor_type" => actor_type_for(actor), "actor_id" => actor_id_for(actor), "actor_label" => actor_label_for(actor), "metadata" => .merge( triggered_by ? { triggered_by: triggered_by } : {} ).compact } # ========================================================================== # SNAPSHOT CONFIGURATION # ========================================================================== # If snapshots are configured, determine if we should capture one for this # transition based on the strategy (every, count, time, terminal, manual). # ========================================================================== snapshot_config = lifecycle.snapshot_configuration if snapshot_config && [:snapshot] != false # allow manual opt-out # Calculate metrics for snapshot decision last_snapshot = Fosm::TransitionLog .where(record_type: self.class.name, record_id: self.id.to_s) .where.not(state_snapshot: nil) .order(created_at: :desc) .first transitions_since = last_snapshot ? Fosm::TransitionLog.where(record_type: self.class.name, record_id: self.id.to_s) .where("created_at > ?", last_snapshot.created_at).count : Fosm::TransitionLog.where(record_type: self.class.name, record_id: self.id.to_s).count seconds_since = last_snapshot ? (Time.current - last_snapshot.created_at).to_f : Float::INFINITY to_state_def = lifecycle.find_state(to_state) # Check if we should snapshot (respecting manual: false unless forced) force_snapshot = [:snapshot] == true should_snapshot = snapshot_config.should_snapshot?( transition_count: transitions_since, seconds_since_last: seconds_since, to_state: to_state, to_state_terminal: to_state_def&.terminal? || false, force: force_snapshot ) if should_snapshot # Build snapshot: schema attributes + arbitrary observations schema_snapshot = snapshot_config.build_snapshot(self) # Merge arbitrary observations if provided (stored under _observations key) if snapshot_data.present? schema_snapshot["_observations"] = snapshot_data.as_json end log_data["state_snapshot"] = schema_snapshot log_data["snapshot_reason"] = determine_snapshot_reason( snapshot_config.strategy, force_snapshot, to_state_def ) end end # ========================================================================== # RACE CONDITION FIX: SELECT FOR UPDATE # ========================================================================== # Acquire a row-level lock before proceeding. This prevents concurrent # transactions from reading stale state and attempting concurrent transitions. # # Behavior: # - PostgreSQL/MySQL: SELECT ... FOR UPDATE blocks until lock acquired # - SQLite: No-op (database-level locking makes it already serializable) # # We re-read state after locking to ensure we have the latest value. # If another transaction committed while we waited for the lock, we get # the fresh state and must re-validate our checks. # ========================================================================== # Acquire lock - this blocks if another transaction holds the lock locked_record = self.class.lock.find(self.id) # Re-validate with locked state - the world may have changed while waiting locked_current = locked_record.state.to_s locked_current_state_def = lifecycle.find_state(locked_current) # If state changed while waiting for lock, transition may no longer be valid if locked_current_state_def&.terminal? raise Fosm::TerminalState.new(locked_current, self.class) end unless event_def.valid_from?(locked_current) raise Fosm::InvalidTransition.new(event_name, locked_current, self.class) end # Re-check guards with locked record (guards may depend on fresh state) event_def.guards.each do |guard_def| allowed, reason = guard_def.evaluate(locked_record) unless allowed raise Fosm::GuardFailed.new(guard_def.name, event_name, reason) end end # Re-check RBAC with locked record (for consistency, though RBAC uses cache) if lifecycle.access_defined? unless fosm_rbac_bypass?(actor) unless (event_name, actor, locked_record) raise Fosm::AccessDenied.new(event_name, actor) end end end # Update from_state to reflect the locked state's current value from_state = locked_current log_data["from_state"] = from_state ActiveRecord::Base.transaction do # Use the locked record for the update to ensure we hold the lock locked_record.update!(state: to_state) # Sync our instance state with what was written self.state = to_state # :sync strategy โ INSERT inside transaction for strict consistency if Fosm.config.transition_log_strategy == :sync Fosm::TransitionLog.create!(log_data) end # Run immediate side effects inside transaction so they roll back on error # Set context for auto-capturing triggered_by in nested transitions begin Thread.current[:fosm_trigger_context] = { record_type: self.class.name, record_id: self.id.to_s, event_name: event_name.to_s } # Call side effects on self (not locked_record) so instance state is preserved event_def.side_effects.reject(&:deferred?).each do |side_effect_def| side_effect_def.call(self, transition_data) end ensure Thread.current[:fosm_trigger_context] = nil end # ๐ Queue deferred side effects to run after commit deferred_effects = event_def.side_effects.select(&:deferred?) if deferred_effects.any? # Set instance variables on locked_record (the instance that gets saved) # so after_commit callback can access them locked_record.instance_variable_set(:@_fosm_deferred_side_effects, deferred_effects) locked_record.instance_variable_set(:@_fosm_transition_data, transition_data) # Use after_commit to run after transaction completes locked_record.class.after_commit :_fosm_run_deferred_side_effects, on: :update end end # :async strategy โ enqueue job after transaction commits (non-blocking) if Fosm.config.transition_log_strategy == :async Fosm::TransitionLogJob.perform_later(log_data) end # :buffered strategy โ push to in-memory buffer (bulk INSERT every ~1s) if Fosm.config.transition_log_strategy == :buffered Fosm::TransitionBuffer.push(log_data) end # Deliver webhooks asynchronously (outside transaction, skipped when disabled) if Fosm.config.webhooks_enabled Fosm::WebhookDeliveryJob.perform_later( record_type: self.class.name, record_id: self.id.to_s, event_name: event_name.to_s, from_state: from_state, to_state: to_state, metadata: ) end true end |
#last_snapshot ⇒ Fosm::TransitionLog?
Returns the most recent snapshot for this record, or nil if none exists.
408 409 410 411 412 413 414 |
# File 'lib/fosm/lifecycle.rb', line 408 def last_snapshot Fosm::TransitionLog .where(record_type: self.class.name, record_id: self.id.to_s) .where.not(state_snapshot: nil) .order(created_at: :desc) .first end |
#last_snapshot_data ⇒ Hash?
Returns the snapshot data from the most recent snapshot, or nil.
418 419 420 |
# File 'lib/fosm/lifecycle.rb', line 418 def last_snapshot_data last_snapshot&.state_snapshot end |
#replay_from(from_snapshot) {|transition_log| ... } ⇒ Array<Fosm::TransitionLog>
Replays events from a specific snapshot forward to the current state. Yields each transition to a block for custom processing.
453 454 455 456 457 458 459 460 461 462 463 464 465 466 |
# File 'lib/fosm/lifecycle.rb', line 453 def replay_from(from_snapshot) snapshot_id = from_snapshot.is_a?(Fosm::TransitionLog) ? from_snapshot.id : from_snapshot transitions = Fosm::TransitionLog .where(record_type: self.class.name, record_id: self.id.to_s) .where("id > ?", snapshot_id) .order(:created_at) if block_given? transitions.each { |log| yield log } end transitions.to_a end |
#snapshots ⇒ ActiveRecord::Relation
Returns all snapshots for this record in chronological order. Useful for audit trails and debugging.
471 472 473 474 475 476 |
# File 'lib/fosm/lifecycle.rb', line 471 def snapshots Fosm::TransitionLog .where(record_type: self.class.name, record_id: self.id.to_s) .where.not(state_snapshot: nil) .order(:created_at) end |
#state_at_transition(transition_log_id) ⇒ Hash
Returns the state of the record at a specific transition log ID. This is a โtime-travelโ query that reconstructs state from snapshot + replay.
427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 |
# File 'lib/fosm/lifecycle.rb', line 427 def state_at_transition(transition_log_id) log = Fosm::TransitionLog.find_by(id: transition_log_id) return nil unless log return nil unless log.record_type == self.class.name && log.record_id == self.id.to_s # If this log entry has a snapshot, use it directly return log.state_snapshot if log.state_snapshot.present? # Otherwise, find the most recent snapshot before this log entry prior_snapshot = Fosm::TransitionLog .where(record_type: self.class.name, record_id: self.id.to_s) .where.not(state_snapshot: nil) .where("created_at <= ?", log.created_at) .order(created_at: :desc) .first # Return the prior snapshot data, or nil if no snapshot exists prior_snapshot&.state_snapshot end |
#transitions_since_snapshot ⇒ Integer
Returns the number of transitions since the last snapshot. Useful for monitoring snapshot coverage.
481 482 483 484 485 486 487 488 489 |
# File 'lib/fosm/lifecycle.rb', line 481 def transitions_since_snapshot last_snap = last_snapshot return Fosm::TransitionLog.where(record_type: self.class.name, record_id: self.id.to_s).count unless last_snap Fosm::TransitionLog .where(record_type: self.class.name, record_id: self.id.to_s) .where("created_at > ?", last_snap.created_at) .count end |
#why_cannot_fire?(event_name) ⇒ Boolean
๐ Detailed introspection: why can this event (not) be fired? Returns a hash with diagnostic information for debugging and UI messages.
341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 |
# File 'lib/fosm/lifecycle.rb', line 341 def why_cannot_fire?(event_name) lifecycle = self.class.fosm_lifecycle return { can_fire: false, reason: "No lifecycle defined" } unless lifecycle event_def = lifecycle.find_event(event_name) return { can_fire: false, reason: "Unknown event '#{event_name}'" } unless event_def current = self.state.to_s current_state_def = lifecycle.find_state(current) result = { can_fire: true, event: event_name.to_s, current_state: current } # Check terminal state if current_state_def&.terminal? result[:can_fire] = false result[:reason] = "State '#{current}' is terminal and cannot transition further" result[:is_terminal] = true return result end # Check valid from state unless event_def.valid_from?(current) result[:can_fire] = false result[:reason] = "Cannot fire '#{event_name}' from '#{current}' (valid from: #{event_def.from_states.join(', ')})" result[:valid_from_states] = event_def.from_states return result end # Evaluate guards failed_guards = [] passed_guards = [] event_def.guards.each do |guard_def| allowed, reason = guard_def.evaluate(self) if allowed passed_guards << guard_def.name else failed_guards << { name: guard_def.name, reason: reason } end end if failed_guards.any? result[:can_fire] = false result[:failed_guards] = failed_guards result[:passed_guards] = passed_guards first_failure = failed_guards.first result[:reason] = "Guard '#{first_failure[:name]}' failed" result[:reason] += ": #{first_failure[:reason]}" if first_failure[:reason] end result end |