Skip to content

Commit 2a2df71

Browse files
committed
Create state changes in background job
In many state machines (order, payment and shipment) we record state changes in the `spree_state_changes` table as well as in the `OrderUpdater`. This happens in the same transaction as the model change itself. In the latest `state_machines` gem version (`> 0.30`) these record creations cause validation errors and an infinite validation loop. Since the state changes are for debugging / analyse purposes only and are not critical to the actual process of buying we can easily background them. In order to keep the correct timeline of events we make sure that the time is stored on the event time and not background job runtime.
1 parent a379422 commit 2a2df71

File tree

15 files changed

+455
-83
lines changed

15 files changed

+455
-83
lines changed

core/app/jobs/spree/base_job.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# frozen_string_literal: true
2+
3+
module Spree
4+
# Base class for all Solidus background jobs
5+
class BaseJob < ActiveJob::Base # rubocop:disable Rails/ApplicationJob
6+
# Automatically retry jobs that encountered a deadlock
7+
retry_on ActiveRecord::Deadlocked
8+
9+
# Most jobs are safe to ignore if the underlying records are no longer available
10+
discard_on ActiveJob::DeserializationError
11+
end
12+
end
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# frozen_string_literal: true
2+
3+
module Spree
4+
# Background job to track state changes asynchronously
5+
# This avoids performance impact during checkout and prevents
6+
# callback-related issues with recent versions of the state_machines gem.
7+
class StateChangeTrackingJob < BaseJob
8+
# @param stateful [GlobalId] The stateful object to track changes for
9+
# @param previous_state [String] The previous state of the order
10+
# @param current_state [String] The current state of the order
11+
# @param transition_timestamp [Time] When the state transition occurred
12+
def perform(stateful, previous_state, current_state, transition_timestamp)
13+
Spree::StateChange.create!(
14+
name: stateful.class.model_name.element,
15+
stateful: stateful,
16+
previous_state: previous_state,
17+
next_state: current_state,
18+
created_at: transition_timestamp,
19+
updated_at: transition_timestamp,
20+
user_id: stateful.try(:user_id) || stateful.try(:order)&.user_id
21+
)
22+
end
23+
end
24+
end
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
module Spree
4+
module StateChangeTracking
5+
extend ActiveSupport::Concern
6+
7+
included do
8+
after_update :enqueue_state_change_tracking, if: :saved_change_to_state?
9+
end
10+
11+
private
12+
13+
# Enqueue background job to track state changes asynchronously
14+
def enqueue_state_change_tracking
15+
previous_state, current_state = saved_changes['state']
16+
17+
# Enqueue the job to track this state change
18+
StateChangeTrackingJob.perform_later(
19+
self,
20+
previous_state,
21+
current_state,
22+
Time.current
23+
)
24+
end
25+
end
26+
end

core/app/models/spree/core/state_machines/order.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ class StateMachines
66
module Order
77
def self.included(klass)
88
klass.extend ClassMethods
9+
klass.include StateChangeTracking
910
end
1011

1112
def checkout_steps

core/app/models/spree/core/state_machines/order/class_methods.rb

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -38,22 +38,6 @@ def define_state_machine!
3838
state_machine :state, initial: :cart, use_transactions: false do
3939
klass.next_event_transitions.each { |state| transition(state.merge(on: :next)) }
4040

41-
# Persist the state on the order
42-
after_transition do |order, transition|
43-
# Hard to say if this is really necessary, it was introduced in this commit:
44-
# https://github.com/mamhoff/solidus/commit/fa1d66c42e4c04ee7cd1c20d87e4cdb74a226d3d
45-
# But it seems to be harmless, so we'll keep it for now.
46-
order.state = order.state # rubocop:disable Lint/SelfAssignment
47-
48-
order.state_changes.create(
49-
previous_state: transition.from,
50-
next_state: transition.to,
51-
name: 'order',
52-
user_id: order.user_id
53-
)
54-
order.save
55-
end
56-
5741
event :cancel do
5842
transition to: :canceled, if: :allow_cancel?, from: :complete
5943
end

core/app/models/spree/core/state_machines/payment.rb

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class StateMachines
1515
#
1616
module Payment
1717
extend ActiveSupport::Concern
18+
include StateChangeTracking
1819

1920
included do
2021
state_machine initial: :checkout do
@@ -45,14 +46,6 @@ module Payment
4546
event :invalidate do
4647
transition from: [:checkout], to: :invalid
4748
end
48-
49-
after_transition do |payment, transition|
50-
payment.state_changes.create!(
51-
previous_state: transition.from,
52-
next_state: transition.to,
53-
name: 'payment'
54-
)
55-
end
5649
end
5750
end
5851
end

core/app/models/spree/core/state_machines/shipment.rb

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class StateMachines
1515
#
1616
module Shipment
1717
extend ActiveSupport::Concern
18+
include StateChangeTracking
1819

1920
included do
2021
state_machine initial: :pending, use_transactions: false do
@@ -42,14 +43,6 @@ module Shipment
4243
transition from: :canceled, to: :pending
4344
end
4445
after_transition from: :canceled, to: [:pending, :ready, :shipped], do: :after_resume
45-
46-
after_transition do |shipment, transition|
47-
shipment.state_changes.create!(
48-
previous_state: transition.from,
49-
next_state: transition.to,
50-
name: 'shipment'
51-
)
52-
end
5346
end
5447
end
5548
end

core/app/models/spree/order_updater.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,11 @@ def log_state_change(name)
185185
yield
186186
new_state = order.public_send(state)
187187
if old_state != new_state
188-
order.state_changes.new(
189-
previous_state: old_state,
190-
next_state: new_state,
191-
name:,
192-
user_id: order.user_id
188+
StateChangeTrackingJob.perform_later(
189+
order,
190+
old_state,
191+
new_state,
192+
Time.current
193193
)
194194
end
195195
end

core/lib/spree/testing_support/dummy_app.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ class ApplicationRecord < ActiveRecord::Base
2323
self.abstract_class = true
2424
end
2525

26+
# @private
27+
class ApplicationJob < ActiveJob::Base
28+
end
29+
2630
# @private
2731
class ApplicationMailer < ActionMailer::Base
2832
end
@@ -94,6 +98,9 @@ class Application < ::Rails::Application
9498
# We don't want to send email in the test environment.
9599
config.action_mailer.delivery_method = :test
96100

101+
# Do not actually run background jobs
102+
config.active_job.queue_adapter = :test
103+
97104
# No need to use credentials file in a test environment.
98105
config.secret_key_base = 'SECRET_TOKEN'
99106

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe Spree::StateChangeTrackingJob, type: :job do
6+
let(:order) { create(:order, user: user) }
7+
let(:user) { create(:user) }
8+
let(:transition_timestamp) { Time.current }
9+
10+
describe '#perform' do
11+
it 'creates a state change record with correct attributes' do
12+
expect {
13+
described_class.perform_now(
14+
order,
15+
'cart',
16+
'address',
17+
transition_timestamp
18+
)
19+
}.to change(Spree::StateChange, :count).by(1)
20+
21+
state_change = Spree::StateChange.last
22+
expect(state_change.previous_state).to eq('cart')
23+
expect(state_change.next_state).to eq('address')
24+
expect(state_change.name).to eq('order')
25+
expect(state_change.user_id).to eq(user.id)
26+
expect(state_change.stateful_id).to eq(order.id)
27+
expect(state_change.stateful_type).to eq('Spree::Order')
28+
expect(state_change.created_at).to be_within(1.second).of(transition_timestamp)
29+
expect(state_change.updated_at).to be_within(1.second).of(transition_timestamp)
30+
end
31+
32+
it 'stores all state transitions in correct order' do
33+
transitions = [
34+
['cart', 'address'],
35+
['address', 'delivery'],
36+
['delivery', 'payment'],
37+
['payment', 'confirm'],
38+
['confirm', 'complete'],
39+
['complete', 'canceled'],
40+
['canceled', 'resumed']
41+
]
42+
43+
transitions.each do |from_state, to_state|
44+
described_class.perform_now(
45+
order,
46+
from_state,
47+
to_state,
48+
transition_timestamp
49+
)
50+
51+
state_change = Spree::StateChange.last
52+
expect(state_change.previous_state).to eq(from_state)
53+
expect(state_change.next_state).to eq(to_state)
54+
expect(state_change.stateful_id).to eq(order.id)
55+
expect(state_change.stateful_type).to eq('Spree::Order')
56+
end
57+
58+
expect(Spree::StateChange.count).to eq(transitions.length)
59+
expect(Spree::StateChange.order(:created_at).pluck(:previous_state, :next_state)).to eq(transitions)
60+
end
61+
62+
it 'preserves the exact transition timestamp' do
63+
specific_time = Time.zone.parse('2023-12-25 10:30:45')
64+
65+
described_class.perform_now(
66+
order,
67+
'cart',
68+
'address',
69+
specific_time
70+
)
71+
72+
state_change = Spree::StateChange.last
73+
expect(state_change.created_at).to eq(specific_time)
74+
expect(state_change.updated_at).to eq(specific_time)
75+
end
76+
77+
context 'when the order has no user' do
78+
let(:order) { create(:order, user: nil) }
79+
80+
it 'sets user_id to nil in the state change record' do
81+
described_class.perform_now(
82+
order,
83+
'cart',
84+
'address',
85+
transition_timestamp
86+
)
87+
88+
state_change = Spree::StateChange.last
89+
expect(state_change.user_id).to be_nil
90+
end
91+
end
92+
93+
context 'when the object has a order association' do
94+
let(:payment) { create(:payment, order: order) }
95+
96+
it 'uses the order user_id if available' do
97+
described_class.perform_now(
98+
payment,
99+
'checkout',
100+
'completed',
101+
transition_timestamp
102+
)
103+
104+
state_change = Spree::StateChange.last
105+
expect(state_change.user_id).to eq(order.user_id)
106+
end
107+
end
108+
109+
it 'stores stateful name' do
110+
described_class.perform_now(
111+
order,
112+
'cart',
113+
'address',
114+
transition_timestamp
115+
)
116+
117+
state_change = Spree::StateChange.last
118+
expect(state_change.name).to eq('order')
119+
end
120+
end
121+
end

0 commit comments

Comments
 (0)