Last active
May 28, 2019 13:20
-
-
Save sergueif/a4e7b23a77f5abea8607c2a13d4df8a1 to your computer and use it in GitHub Desktop.
Versioned state with optimistic concurrency in Rails. Domain model: Stickmen have bank accounts and accumulate money. See my blog post to learn more.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class CreateStickmanVersions < ActiveRecord::Migration[5.1] | |
def change | |
create_table :stickman_versions do |t| | |
t.references :stickman, foreign_key: true | |
t.integer :version, null: false, default: 0 | |
t.jsonb :body, null: false, default: {} | |
t.jsonb :debug, default: {} | |
t.timestamps | |
t.index [:stickman_id, :version], unique: true | |
end | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class CreateStickmen < ActiveRecord::Migration[5.1] | |
def change | |
create_table :stickmen do |t| | |
t.integer :version, null: false, default: 0 | |
t.timestamps | |
end | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class Stickman < ApplicationRecord | |
has_many :stickman_versions | |
def bank_balance | |
latest_version.bank_balance | |
end | |
def credit(by_amount, slow=false) | |
v = latest_version | |
if slow | |
sleep rand*0.1 | |
end | |
vn = v.credit(by_amount) | |
compare_and_swap(vn) | |
vn | |
rescue VersionMismatchException => e | |
retry | |
end | |
private | |
def compare_and_swap(new_version) | |
with_lock do | |
if new_version.version != self.version + 1 | |
raise VersionMismatchException.new( | |
"version mismatch: #{new_version.version} " + | |
"isn't #{self.version} + 1") | |
end | |
new_version.save! | |
self.version += 1 | |
save! | |
end | |
end | |
def latest_version | |
stickman_versions.where(version: self.version).first | |
|| StickmanVersion.new(stickman: self) | |
end | |
class VersionMismatchException < Exception | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require 'rails_helper' | |
RSpec.describe Stickman, type: :model do | |
it "is anomaly-free" do | |
stickman = Stickman.create! | |
expect(stickman.bank_balance).to eq(0) | |
threads = Array.new(2) do | |
Thread.new do | |
20.times do | |
stickman_again = Stickman.find(stickman.id) | |
stickman_again.credit(1, :slow) | |
end | |
end | |
end | |
threads.each(&:join) | |
expect(stickman.reload.bank_balance).to eq(40) | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class StickmanState | |
def initialize(bank_balance: 0) | |
@bank_balance = bank_balance | |
end | |
def bank_balance | |
@bank_balance | |
end | |
def credit(by_amount) | |
StickmanState.new( | |
bank_balance: @bank_balance + by_amount | |
) | |
end | |
def to_h | |
{'bank_balance' => @bank_balance} | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class StickmanVersion < ApplicationRecord | |
belongs_to :stickman | |
def bank_balance | |
state.bank_balance | |
end | |
def credit(by_amount) | |
new_state = state.credit(by_amount) | |
args = method(__method__).parameters | |
params = Hash[args.map{|_,name| | |
[name, binding.local_variable_get(name).inspect] | |
}] | |
StickmanVersion.new( | |
stickman: stickman, | |
version: version + 1, | |
body: new_state.to_h, | |
debug: { | |
method: __method__, | |
params: params | |
}) | |
end | |
private | |
def state | |
StickmanState.new(body.symbolize_keys) | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment