Skip to content

Instantly share code, notes, and snippets.

@sergueif
Last active May 28, 2019 13:20
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sergueif/a4e7b23a77f5abea8607c2a13d4df8a1 to your computer and use it in GitHub Desktop.
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.
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
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
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
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
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
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